From f7bc628e420600779d8b07d056c04e3d191aa589 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 2 Jun 2026 11:33:13 +0200 Subject: [PATCH 1/8] Add index for created_at descending on quotes --- .../migrations/031-add-quote-created-at-index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 apps/api/src/database/migrations/031-add-quote-created-at-index.ts diff --git a/apps/api/src/database/migrations/031-add-quote-created-at-index.ts b/apps/api/src/database/migrations/031-add-quote-created-at-index.ts new file mode 100644 index 000000000..7939d4848 --- /dev/null +++ b/apps/api/src/database/migrations/031-add-quote-created-at-index.ts @@ -0,0 +1,14 @@ +import { QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.sequelize.query(` + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_quote_tickets_created_at + ON quote_tickets (created_at DESC); + `); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.sequelize.query(` + DROP INDEX CONCURRENTLY IF EXISTS idx_quote_tickets_created_at; + `); +} From 918e7b7c303b17c2752991b4e9b88350d41e2509 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 2 Jun 2026 11:33:28 +0200 Subject: [PATCH 2/8] Increase batch size for deleted quotes to 5000 --- apps/api/src/api/services/ramp/base.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/api/services/ramp/base.service.ts b/apps/api/src/api/services/ramp/base.service.ts index 81e8299e7..d5ab2e8a5 100644 --- a/apps/api/src/api/services/ramp/base.service.ts +++ b/apps/api/src/api/services/ramp/base.service.ts @@ -7,7 +7,7 @@ import QuoteTicket from "../../../models/quoteTicket.model"; import RampState, { RampStateAttributes } from "../../../models/rampState.model"; import { StateMetadata } from "../phases/meta-state-types"; -const EXPIRED_QUOTE_DELETE_BATCH_SIZE = 1000; +const EXPIRED_QUOTE_DELETE_BATCH_SIZE = 5000; const QUOTE_CLEANUP_ADVISORY_LOCK_NAMESPACE = 918521; const QUOTE_CLEANUP_ADVISORY_LOCK_KEY = 1; From 709d5e0672fcb4400d4432b7e78c0a26c285596a Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 2 Jun 2026 15:52:55 +0100 Subject: [PATCH 3/8] use skipToken for mykobo profile query --- .../widget-steps/SummaryStep/TransactionTokensDisplay.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx index 3fa80bd41..82dd1404f 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx @@ -1,5 +1,5 @@ import { ArrowDownIcon } from "@heroicons/react/20/solid"; -import { useQuery } from "@tanstack/react-query"; +import { skipToken, useQuery } from "@tanstack/react-query"; import { BaseFiatTokenDetails, FiatToken, @@ -69,8 +69,7 @@ export const TransactionTokensDisplay: FC = ({ ex const isEurOfframp = !isOnramp && executionInput.fiatToken === FiatToken.EURC; const { data: mykoboProfile } = useQuery({ - enabled: isEurOfframp && !!userEmail, - queryFn: () => MykoboService.getProfile(userEmail as string), + queryFn: isEurOfframp && userEmail ? () => MykoboService.getProfile(userEmail) : skipToken, queryKey: ["mykoboProfile", userEmail] }); From 4ce71bf1e28d5474604c0d216bc52b1bcf8002ad Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 2 Jun 2026 15:53:28 +0100 Subject: [PATCH 4/8] dedupe direct-transfer check and name subsidy ratio --- .../phases/handlers/final-settlement-subsidy.ts | 13 +++++++------ .../phases/handlers/fund-ephemeral-handler.ts | 8 ++++---- .../phases/handlers/squid-router-phase-handler.ts | 9 ++++----- apps/api/src/api/services/quote/utils.ts | 9 +++++++++ 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts index 2baef7318..6d0640928 100644 --- a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts +++ b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts @@ -28,12 +28,14 @@ import { MAX_FINAL_SETTLEMENT_SUBSIDY_USD } from "../../../../constants/constant import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; import { priceFeedService } from "../../priceFeed.service"; -import { isBrlToBrlaBaseDirect, isEurToEurcBaseDirect } from "../../quote/utils"; +import { isFiatToOwnStablecoinBaseDirect } from "../../quote/utils"; import { BasePhaseHandler } from "../base-phase-handler"; import { getEvmFundingAccount } from "../evm-funding"; const BALANCE_POLLING_TIME_MS = 5000; const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes +// Wait for >=90% of expected bridge delivery to absorb slippage while still waiting for actual bridge arrival. +const MIN_BRIDGE_DELIVERY_RATIO = 0.9; const NATIVE_TOKENS: Record = { [Networks.Ethereum]: { decimals: 18, symbol: "ETH" }, @@ -64,6 +66,8 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { protected async executePhase(state: RampState): Promise { logger.debug(`FinalSettlementSubsidyHandler: Starting phase execution for ramp ${state.id}, type=${state.type}`); + // Two direct-transfer gates: the stateMeta flag is the cheap pre-quote early-out (set by the EUR/BRL + // Base routes); the quote-derived check below is the safety net for any direct route that didn't set it. if (state.state.isDirectTransfer === true) { logger.info(`FinalSettlementSubsidyHandler: Skipping subsidy for direct-transfer ramp ${state.id}`); return this.transitionToNextPhase(state, "destinationTransfer"); @@ -80,10 +84,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { `FinalSettlementSubsidyHandler: Quote found. inputCurrency=${quote.inputCurrency}, outputCurrency=${quote.outputCurrency}, network=${quote.network}` ); - if ( - isEurToEurcBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network) || - isBrlToBrlaBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network) - ) { + if (isFiatToOwnStablecoinBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network)) { logger.info(`FinalSettlementSubsidyHandler: Skipping subsidy for Base direct-transfer route (ramp ${state.id})`); return this.transitionToNextPhase(state, "destinationTransfer"); } @@ -153,7 +154,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { `FinalSettlementSubsidyHandler: Polling ephemeral balance for ${ephemeralAddress} on ${destinationNetwork} (timeout=${EVM_BALANCE_CHECK_TIMEOUT_MS}ms, interval=${BALANCE_POLLING_TIME_MS}ms)` ); const actualBalance = await checkEvmBalanceForToken({ - amountDesiredRaw: expectedAmountRaw.mul(0.9).toFixed(0, 0), // Wait for >=90% of expected bridge delivery to absorb slippage while still waiting for actual bridge arrival. + amountDesiredRaw: expectedAmountRaw.mul(MIN_BRIDGE_DELIVERY_RATIO).toFixed(0, 0), chain: destinationNetwork, intervalMs: BALANCE_POLLING_TIME_MS, ownerAddress: ephemeralAddress, diff --git a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts index a5e7b7fa9..99633e031 100644 --- a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts +++ b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts @@ -23,7 +23,7 @@ import RampState from "../../../../models/rampState.model"; import { UnrecoverablePhaseError } from "../../../errors/phase-error"; import { multiplyByPowerOfTen } from "../../pendulum/helpers"; import { fundEphemeralAccount } from "../../pendulum/pendulum.service"; -import { isBrlToBrlaBaseDirect, isEurToEurcBaseDirect } from "../../quote/utils"; +import { isFiatToOwnStablecoinBaseDirect } from "../../quote/utils"; import { BasePhaseHandler } from "../base-phase-handler"; import { getEvmFundingAccount } from "../evm-funding"; import { verifyUserSubmittedTxByHash } from "../helpers/user-tx-verifier"; @@ -241,11 +241,11 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { } protected nextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { + // stateMeta flag is the cheap early-out (set by the EUR/BRL Base routes); the quote-derived check is + // the safety net for any direct onramp route that didn't set it. if ( state.state.isDirectTransfer === true || - (isOnramp(state) && - (isEurToEurcBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network) || - isBrlToBrlaBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network))) + (isOnramp(state) && isFiatToOwnStablecoinBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network)) ) { return "destinationTransfer"; } diff --git a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts index aea6c136c..cb3f53b46 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts @@ -18,7 +18,7 @@ import { PublicClient } from "viem"; import logger from "../../../../config/logger"; import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; -import { isBrlToBrlaBaseDirect, isEurToEurcBaseDirect } from "../../quote/utils"; +import { isFiatToOwnStablecoinBaseDirect } from "../../quote/utils"; import { BasePhaseHandler } from "../base-phase-handler"; /** @@ -44,6 +44,8 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { protected async executePhase(state: RampState): Promise { logger.info(`Executing squidRouter phase for ramp ${state.id}`); + // Two direct-transfer gates: the stateMeta flag is the cheap pre-quote early-out (set by the EUR/BRL + // Base routes); the quote-derived check below is the safety net for any direct route that didn't set it. if (state.state.isDirectTransfer === true) { logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for direct-transfer ramp ${state.id}`); return this.transitionToNextPhase(state, "destinationTransfer"); @@ -54,10 +56,7 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { throw new Error("Quote not found for the given state"); } - if ( - isEurToEurcBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network) || - isBrlToBrlaBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network) - ) { + if (isFiatToOwnStablecoinBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network)) { logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for Base direct-transfer route (ramp ${state.id})`); return this.transitionToNextPhase(state, "destinationTransfer"); } diff --git a/apps/api/src/api/services/quote/utils.ts b/apps/api/src/api/services/quote/utils.ts index 729943941..7fa7acae0 100644 --- a/apps/api/src/api/services/quote/utils.ts +++ b/apps/api/src/api/services/quote/utils.ts @@ -7,3 +7,12 @@ export function isEurToEurcBaseDirect(inputCurrency: string, outputCurrency: str export function isBrlToBrlaBaseDirect(inputCurrency: string, outputCurrency: string, toNetwork: string): boolean { return inputCurrency === FiatToken.BRL && outputCurrency === EvmToken.BRLA && toNetwork === Networks.Base; } + +// Fiat -> own-stablecoin passthrough on Base (EUR->EURC, BRL->BRLA): the anchor already minted the +// requested stablecoin, so the swap/bridge/subsidy steps are skipped and funds transfer directly. +export function isFiatToOwnStablecoinBaseDirect(inputCurrency: string, outputCurrency: string, toNetwork: string): boolean { + return ( + isEurToEurcBaseDirect(inputCurrency, outputCurrency, toNetwork) || + isBrlToBrlaBaseDirect(inputCurrency, outputCurrency, toNetwork) + ); +} From b7766ff3126615537458f5c9ec5a8cb7660662c9 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 2 Jun 2026 16:28:11 +0100 Subject: [PATCH 5/8] remove unnecessary comments --- .../api/services/phases/handlers/final-settlement-subsidy.ts | 2 -- .../src/api/services/phases/handlers/fund-ephemeral-handler.ts | 2 -- .../api/services/phases/handlers/squid-router-phase-handler.ts | 2 -- 3 files changed, 6 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts index 6d0640928..a8aff7379 100644 --- a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts +++ b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts @@ -66,8 +66,6 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { protected async executePhase(state: RampState): Promise { logger.debug(`FinalSettlementSubsidyHandler: Starting phase execution for ramp ${state.id}, type=${state.type}`); - // Two direct-transfer gates: the stateMeta flag is the cheap pre-quote early-out (set by the EUR/BRL - // Base routes); the quote-derived check below is the safety net for any direct route that didn't set it. if (state.state.isDirectTransfer === true) { logger.info(`FinalSettlementSubsidyHandler: Skipping subsidy for direct-transfer ramp ${state.id}`); return this.transitionToNextPhase(state, "destinationTransfer"); diff --git a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts index 99633e031..0c76e0e57 100644 --- a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts +++ b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts @@ -241,8 +241,6 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { } protected nextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { - // stateMeta flag is the cheap early-out (set by the EUR/BRL Base routes); the quote-derived check is - // the safety net for any direct onramp route that didn't set it. if ( state.state.isDirectTransfer === true || (isOnramp(state) && isFiatToOwnStablecoinBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network)) diff --git a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts index cb3f53b46..a36b98fd0 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts @@ -44,8 +44,6 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { protected async executePhase(state: RampState): Promise { logger.info(`Executing squidRouter phase for ramp ${state.id}`); - // Two direct-transfer gates: the stateMeta flag is the cheap pre-quote early-out (set by the EUR/BRL - // Base routes); the quote-derived check below is the safety net for any direct route that didn't set it. if (state.state.isDirectTransfer === true) { logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for direct-transfer ramp ${state.id}`); return this.transitionToNextPhase(state, "destinationTransfer"); From 65520e8767540bd451f19a313b9e29aeefa84c22 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 2 Jun 2026 18:27:22 +0200 Subject: [PATCH 6/8] Cap rampEphemerals localStorage to 50 entries with FIFO eviction Each entry now stores a timestamp. When the map exceeds 50 items, the oldest entries are removed. Entries without timestamps (from before this change) default to 0 and are evicted first. --- apps/frontend/src/contexts/rampState.tsx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/contexts/rampState.tsx b/apps/frontend/src/contexts/rampState.tsx index 4a441ee88..161c401e0 100644 --- a/apps/frontend/src/contexts/rampState.tsx +++ b/apps/frontend/src/contexts/rampState.tsx @@ -16,13 +16,29 @@ import { RampExecutionInput } from "../types/phases"; const RAMP_STATE_STORAGE_KEY = "rampState"; const RAMP_EPHEMERALS_STORAGE_KEY = "rampEphemerals"; +const MAX_RAMP_EPHEMERALS = 50; -type RampEphemeralsMap = Record; +type RampEphemeralEntry = { + substrateEphemeral: EphemeralAccount; + evmEphemeral: EphemeralAccount; + timestamp: number; +}; +type RampEphemeralsMap = Record; export function updateRampEphemeral(rampId: string, ephemerals: RampExecutionInput["ephemerals"]): void { try { const existing = readRampEphemerals(); - existing[rampId] = ephemerals; + existing[rampId] = { ...ephemerals, timestamp: Date.now() }; + + const keys = Object.keys(existing); + if (keys.length > MAX_RAMP_EPHEMERALS) { + const sorted = keys.sort((a, b) => (existing[a].timestamp ?? 0) - (existing[b].timestamp ?? 0)); + const toRemove = sorted.slice(0, sorted.length - MAX_RAMP_EPHEMERALS); + for (const key of toRemove) { + delete existing[key]; + } + } + localStorage.setItem(RAMP_EPHEMERALS_STORAGE_KEY, JSON.stringify(existing)); } catch { // localStorage may be full or unavailable — non-critical backup From 75d74202cc57eda8ffbbfe3f53532a2675d07b5c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:16:51 +0000 Subject: [PATCH 7/8] Initial plan From 4f77d211c4692488a9df490b6de2a588202a1dc9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:19:13 +0000 Subject: [PATCH 8/8] fix: make timestamp optional in RampEphemeralEntry, use optional chaining in sort, align test batch size - Make `timestamp` optional in `RampEphemeralEntry` for backwards compatibility with entries from older app versions - Use optional chaining (`?.timestamp ?? 0`) in eviction sort to guard against null/corrupt localStorage entries; legacy entries without a timestamp get value 0 (treated as oldest) - Update `expectedDeleteBatchSize` in base.service.test.ts from 500 to 5000 to match the production `EXPIRED_QUOTE_DELETE_BATCH_SIZE` constant --- apps/api/src/api/services/ramp/base.service.test.ts | 2 +- apps/frontend/src/contexts/rampState.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/services/ramp/base.service.test.ts b/apps/api/src/api/services/ramp/base.service.test.ts index 27725359d..384024c87 100644 --- a/apps/api/src/api/services/ramp/base.service.test.ts +++ b/apps/api/src/api/services/ramp/base.service.test.ts @@ -5,7 +5,7 @@ import QuoteTicket from "../../../models/quoteTicket.model"; import {BaseRampService} from "./base.service"; const transaction = { id: "cleanup-test-transaction" }; -const expectedDeleteBatchSize = 500; +const expectedDeleteBatchSize = 5000; type QuoteCleanupWhere = { expiresAt?: { diff --git a/apps/frontend/src/contexts/rampState.tsx b/apps/frontend/src/contexts/rampState.tsx index 161c401e0..871441332 100644 --- a/apps/frontend/src/contexts/rampState.tsx +++ b/apps/frontend/src/contexts/rampState.tsx @@ -21,7 +21,7 @@ const MAX_RAMP_EPHEMERALS = 50; type RampEphemeralEntry = { substrateEphemeral: EphemeralAccount; evmEphemeral: EphemeralAccount; - timestamp: number; + timestamp?: number; }; type RampEphemeralsMap = Record; @@ -32,7 +32,7 @@ export function updateRampEphemeral(rampId: string, ephemerals: RampExecutionInp const keys = Object.keys(existing); if (keys.length > MAX_RAMP_EPHEMERALS) { - const sorted = keys.sort((a, b) => (existing[a].timestamp ?? 0) - (existing[b].timestamp ?? 0)); + const sorted = keys.sort((a, b) => (existing[a]?.timestamp ?? 0) - (existing[b]?.timestamp ?? 0)); const toRemove = sorted.slice(0, sorted.length - MAX_RAMP_EPHEMERALS); for (const key of toRemove) { delete existing[key];