Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions apps/frontend/src/hooks/ramp/useRampSubmission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -89,10 +93,22 @@ 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);

Comment thread
ebma marked this conversation as resolved.
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,
moneriumWalletAddress: data.moneriumWalletAddress,
moneriumWalletAddress,
network,
onChainToken,
pixId: data.pixId,
Expand All @@ -105,7 +121,7 @@ export const useRampSubmission = () => {
};
return executionInput;
},
[validateSubmissionData, quote, onChainToken, fiatToken, connectedWalletAddress, network]
[validateSubmissionData, quote, onChainToken, fiatToken, connectedWalletAddress, network, connectedEvmAddress]
);

const handleSubmissionError = useCallback(
Expand Down
46 changes: 43 additions & 3 deletions packages/shared/src/services/squidrouter/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, PQueue>();

// 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;
Expand Down Expand Up @@ -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) {
Expand All @@ -184,6 +189,41 @@ export async function getRoute(
}
}

async function getRouteInternalWithRetry(params: RouteParams): Promise<SquidrouterRouteResult> {
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,
// 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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

async function getRouteInternal(params: RouteParams): Promise<SquidrouterRouteResult> {
const { integratorId } = squidRouterConfigBase;
const url = `${SQUIDROUTER_BASE_URL}/route`;
Expand Down
Loading