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..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 @@ -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" }, @@ -80,10 +82,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 +152,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..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 @@ -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"; @@ -243,9 +243,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { protected nextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase { 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..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 @@ -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"; /** @@ -54,10 +54,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) + ); +} 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/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; 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; + `); +} 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] }); diff --git a/apps/frontend/src/contexts/rampState.tsx b/apps/frontend/src/contexts/rampState.tsx index 4a441ee88..871441332 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