From 08143c746aa1377be5eb4ec16188c34e2e8e4086 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 6 May 2026 10:50:05 +0200 Subject: [PATCH 1/4] Fix bug with moneriumWalletAddress not set when registering ramp --- .../frontend/src/hooks/ramp/useRampSubmission.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/hooks/ramp/useRampSubmission.ts b/apps/frontend/src/hooks/ramp/useRampSubmission.ts index 7fca5940a..161b64748 100644 --- a/apps/frontend/src/hooks/ramp/useRampSubmission.ts +++ b/apps/frontend/src/hooks/ramp/useRampSubmission.ts @@ -4,10 +4,12 @@ import { createStellarEphemeral, FiatToken, getNetworkId, - Networks + Networks, + RampDirection } from "@vortexfi/shared"; import { useSelector } from "@xstate/react"; import { useCallback, useState } from "react"; +import { useAccount } from "wagmi"; import { useEventsContext } from "../../contexts/events"; import { useRampActor } from "../../contexts/rampState"; import { usePreRampCheck } from "../../services/initialChecks"; @@ -44,6 +46,8 @@ export const useRampSubmission = () => { const storeQuote = useQuote(); const quote = contextQuote || storeQuote; + const { address: connectedEvmAddress } = useAccount(); + const { inputAmount, fiatToken, onChainToken } = useQuoteFormStore(); const network = quote ? ((Object.values(Networks).includes(quote.to as Networks) ? quote.to : quote.from) as Networks) @@ -89,10 +93,16 @@ export const useRampSubmission = () => { } const ephemerals = await createEphemerals(); + // For EUR (Monerium) onramps the moneriumWalletAddress is the user's connected EVM wallet. + // Callers that don't pass it explicitly (e.g. the Onramp / RampSubmitButton flows) would + // otherwise leave it undefined and the API rejects the registerRamp request. + const isMoneriumOnramp = quote.rampType === RampDirection.BUY && fiatToken === FiatToken.EURC; + const moneriumWalletAddress = data.moneriumWalletAddress ?? (isMoneriumOnramp ? connectedEvmAddress : undefined); + const executionInput: RampExecutionInput = { ephemerals, fiatToken, - moneriumWalletAddress: data.moneriumWalletAddress, + moneriumWalletAddress, network, onChainToken, pixId: data.pixId, @@ -105,7 +115,7 @@ export const useRampSubmission = () => { }; return executionInput; }, - [validateSubmissionData, quote, onChainToken, fiatToken, connectedWalletAddress, network] + [validateSubmissionData, quote, onChainToken, fiatToken, connectedWalletAddress, network, connectedEvmAddress] ); const handleSubmissionError = useCallback( From ff0b82feb8b65085888604b78078fbe1e5b9f961 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 6 May 2026 11:04:32 +0200 Subject: [PATCH 2/4] Increase route queue interval to 1500ms and implement retry logic for rate limit errors --- .../shared/src/services/squidrouter/route.ts | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/services/squidrouter/route.ts b/packages/shared/src/services/squidrouter/route.ts index d22111f7e..25c922492 100644 --- a/packages/shared/src/services/squidrouter/route.ts +++ b/packages/shared/src/services/squidrouter/route.ts @@ -107,10 +107,15 @@ export interface GetRouteOptions { useCache?: boolean; } -// Rate-limited queues per fromAddress: at most 1 concurrent request per address, with a minimum 1000ms gap between calls. +// Rate-limited queues per fromAddress: at most 1 concurrent request per address, with a minimum 1500ms gap between calls. // This prevents hitting SquidRouter API rate limits for the same user when multiple getRoute() calls happen in quick succession. +// 1500ms was chosen empirically: Squidrouter's per-address bucket rejected requests at 1000ms apart with retryAfter=1s. +const ROUTE_QUEUE_INTERVAL_MS = 1500; const routeQueues = new Map(); +// Cap any retryAfter value Squidrouter returns to avoid pathologically long waits if the API misbehaves. +const MAX_RETRY_AFTER_MS = 5000; + class HttpError extends Error { status: number; data: unknown; @@ -160,12 +165,12 @@ export async function getRoute( let queue = routeQueues.get(normalizedFromAddress); if (!queue) { - queue = new PQueue({ concurrency: 1, interval: 1000, intervalCap: 1 }); + queue = new PQueue({ concurrency: 1, interval: ROUTE_QUEUE_INTERVAL_MS, intervalCap: 1 }); routeQueues.set(normalizedFromAddress, queue); } try { - const result = await queue.add(() => getRouteInternal(params)); + const result = await queue.add(() => getRouteInternalWithRetry(params)); if (!result) throw new Error("Route fetch returned no result"); if (useCache) { @@ -184,6 +189,39 @@ export async function getRoute( } } +async function getRouteInternalWithRetry(params: RouteParams): Promise { + try { + return await getRouteInternal(params); + } catch (error) { + const retryAfterMs = extractRateLimitRetryAfterMs(error); + if (retryAfterMs === undefined) throw error; + + logger.current.warn(`Squidrouter rate limit hit. Retrying once after ${retryAfterMs}ms.`); + await sleep(retryAfterMs); + return getRouteInternal(params); + } +} + +function extractRateLimitRetryAfterMs(error: unknown): number | undefined { + if (!(error instanceof HttpError)) return undefined; + + const data = error.data as { retryAfter?: unknown; error?: unknown } | null; + const looksLikeRateLimit = + error.status === 429 || + (typeof data?.error === "string" && data.error.toLowerCase().includes("too many")) || + typeof data?.retryAfter === "number"; + if (!looksLikeRateLimit) return undefined; + + const retryAfterSeconds = typeof data?.retryAfter === "number" ? data.retryAfter : 1; + const retryAfterMs = Math.min(Math.max(retryAfterSeconds, 0) * 1000, MAX_RETRY_AFTER_MS); + // Add a small jitter (up to 250ms) so concurrent retries don't all fire at the same instant. + return retryAfterMs + Math.floor(Math.random() * 250); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + async function getRouteInternal(params: RouteParams): Promise { const { integratorId } = squidRouterConfigBase; const url = `${SQUIDROUTER_BASE_URL}/route`; From 7313cc31afd3553e0eed30b12fa5a009ccb7a865 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 6 May 2026 11:12:15 +0200 Subject: [PATCH 3/4] Fix cap for delay not applied Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/shared/src/services/squidrouter/route.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/services/squidrouter/route.ts b/packages/shared/src/services/squidrouter/route.ts index 25c922492..af5cbcb10 100644 --- a/packages/shared/src/services/squidrouter/route.ts +++ b/packages/shared/src/services/squidrouter/route.ts @@ -214,8 +214,10 @@ function extractRateLimitRetryAfterMs(error: unknown): number | undefined { const retryAfterSeconds = typeof data?.retryAfter === "number" ? data.retryAfter : 1; const retryAfterMs = Math.min(Math.max(retryAfterSeconds, 0) * 1000, MAX_RETRY_AFTER_MS); - // Add a small jitter (up to 250ms) so concurrent retries don't all fire at the same instant. - return retryAfterMs + Math.floor(Math.random() * 250); + // Add a small jitter (up to 250ms) so concurrent retries don't all fire at the same instant, + // while still respecting the maximum retry delay cap. + const jitterMs = Math.floor(Math.random() * 250); + return Math.min(retryAfterMs + jitterMs, MAX_RETRY_AFTER_MS); } function sleep(ms: number): Promise { From c041b5ef1864dc3eb8e9dce03d15fe4b6c3a3e79 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 6 May 2026 11:12:32 +0200 Subject: [PATCH 4/4] Throw error if moneriumWalletAddress is undefined Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/frontend/src/hooks/ramp/useRampSubmission.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/frontend/src/hooks/ramp/useRampSubmission.ts b/apps/frontend/src/hooks/ramp/useRampSubmission.ts index 161b64748..4f2d20ad1 100644 --- a/apps/frontend/src/hooks/ramp/useRampSubmission.ts +++ b/apps/frontend/src/hooks/ramp/useRampSubmission.ts @@ -99,6 +99,12 @@ export const useRampSubmission = () => { const isMoneriumOnramp = quote.rampType === RampDirection.BUY && fiatToken === FiatToken.EURC; const moneriumWalletAddress = data.moneriumWalletAddress ?? (isMoneriumOnramp ? connectedEvmAddress : undefined); + if (isMoneriumOnramp && !moneriumWalletAddress) { + throw new Error( + "No Monerium wallet address found. Please connect an EVM wallet or provide a Monerium wallet address." + ); + } + const executionInput: RampExecutionInput = { ephemerals, fiatToken,