From 89a46d708f63ff5f12bdc07923243d0225325b23 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 15:55:36 +0200 Subject: [PATCH 1/3] fix(shared): retry the Squid token list after a failed load A failed Squid fetch installed the static-only fallback and set isLoaded, which turned every later initializeEvmTokens() call into a no-op. Squid-only tokens such as PAXG then stayed unlisted and unquotable until the process restarted, and the fetch had no timeout, so a hung request stalled boot. Track whether the Squid list itself loaded, guard on that instead, resolve to whether it did, and bound the fetch at 10 s. The static fallback on failure is unchanged. --- .../src/tokens/evm/dynamicEvmTokens.test.ts | 124 ++++++++++++++++++ .../shared/src/tokens/evm/dynamicEvmTokens.ts | 24 +++- 2 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 packages/shared/src/tokens/evm/dynamicEvmTokens.test.ts diff --git a/packages/shared/src/tokens/evm/dynamicEvmTokens.test.ts b/packages/shared/src/tokens/evm/dynamicEvmTokens.test.ts new file mode 100644 index 000000000..58297797a --- /dev/null +++ b/packages/shared/src/tokens/evm/dynamicEvmTokens.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { Networks } from "../../helpers/networks"; +import { EvmToken } from "../types/evm"; +import { evmTokenConfig } from "./config"; + +type DynamicEvmTokens = typeof import("./dynamicEvmTokens"); + +const PAXG_ADDRESS = "0x45804880de22913dafe09f4980848ece6ecbaf78"; +const staticUsdc = evmTokenConfig[Networks.Ethereum][EvmToken.USDC]!; + +const squidToken = (symbol: string, address: string, decimals: number, usdPrice: number) => ({ + address, + chainId: "1", + decimals, + logoURI: "", + symbol, + usdPrice +}); + +const squidTokenList = { + tokens: [ + squidToken("PAXG", PAXG_ADDRESS, 18, 3300), + squidToken(staticUsdc.assetSymbol, staticUsdc.erc20AddressSourceChain, 6, 1) + ] +}; + +const okResponse = () => new Response(JSON.stringify(squidTokenList)); + +// The service keeps its state at module level; a query string makes Bun evaluate a private copy per test. +let freshImports = 0; +let tokens: DynamicEvmTokens; + +const realFetch = globalThis.fetch; +let fetchCalls: number; +const stubFetch = (handler: (init: RequestInit) => Promise) => { + globalThis.fetch = (async (_url: unknown, init: RequestInit = {}) => { + fetchCalls++; + return handler(init); + }) as unknown as typeof fetch; +}; + +const ethereumSymbols = () => tokens.getEvmTokensForNetwork(Networks.Ethereum).map(token => token.assetSymbol); + +let consoleError: ReturnType; + +beforeEach(async () => { + fetchCalls = 0; + consoleError = spyOn(console, "error").mockImplementation(() => {}); + tokens = (await import(`./dynamicEvmTokens.ts?fresh=${++freshImports}`)) as DynamicEvmTokens; +}); + +afterEach(() => { + globalThis.fetch = realFetch; + consoleError.mockRestore(); +}); + +describe("initializeEvmTokens", () => { + test("a failed fetch falls back to the static tokens and reports that Squid is not loaded", async () => { + stubFetch(() => Promise.reject(new Error("network down"))); + + expect(await tokens.initializeEvmTokens()).toBe(false); + + // The fallback still unblocks subscribers and keeps the static tokens quotable; only Squid-only tokens are missing. + expect(tokens.getEvmTokensLoadedSnapshot()).toBe(true); + expect(ethereumSymbols()).toContain(staticUsdc.assetSymbol); + expect(ethereumSymbols()).not.toContain("PAXG"); + }); + + test("a non-OK response counts as a failed fetch", async () => { + stubFetch(async () => new Response("bad gateway", { status: 502 })); + + expect(await tokens.initializeEvmTokens()).toBe(false); + expect(ethereumSymbols()).not.toContain("PAXG"); + }); + + test("a later call retries after a failure and loads the Squid-only tokens", async () => { + stubFetch(() => Promise.reject(new Error("network down"))); + expect(await tokens.initializeEvmTokens()).toBe(false); + + let notifications = 0; + tokens.subscribeEvmTokensLoaded(() => notifications++); + stubFetch(async () => okResponse()); + + expect(await tokens.initializeEvmTokens()).toBe(true); + + const paxg = tokens.getEvmTokenConfig()[Networks.Ethereum].PAXG; + expect(paxg?.decimals).toBe(18); + expect(paxg?.erc20AddressSourceChain).toBe(PAXG_ADDRESS); + expect(tokens.getTokenUsdPrice("PAXG")).toBe(3300); + expect(tokens.getEvmTokenConfig()[Networks.Ethereum][EvmToken.USDC]?.erc20AddressSourceChain).toBe( + staticUsdc.erc20AddressSourceChain + ); + expect(notifications).toBeGreaterThan(0); + }); + + test("does not fetch again once the Squid list is loaded", async () => { + stubFetch(async () => okResponse()); + expect(await tokens.initializeEvmTokens()).toBe(true); + expect(fetchCalls).toBe(1); + + expect(await tokens.initializeEvmTokens()).toBe(true); + expect(fetchCalls).toBe(1); + }); + + test("aborts a hung fetch after the timeout instead of stalling", async () => { + // Keep the production timeout value observable, but let the test abort after 20 ms. + const realTimeout = AbortSignal.timeout.bind(AbortSignal); + const timeoutSpy = spyOn(AbortSignal, "timeout").mockImplementation(() => realTimeout(20)); + stubFetch( + init => + new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => reject(init.signal?.reason)); + }) + ); + + try { + expect(await tokens.initializeEvmTokens()).toBe(false); + expect(timeoutSpy).toHaveBeenCalledWith(10_000); + expect(ethereumSymbols()).toContain(staticUsdc.assetSymbol); + } finally { + timeoutSpy.mockRestore(); + } + }); +}); diff --git a/packages/shared/src/tokens/evm/dynamicEvmTokens.ts b/packages/shared/src/tokens/evm/dynamicEvmTokens.ts index 5211c72e2..9f7a9ce9f 100644 --- a/packages/shared/src/tokens/evm/dynamicEvmTokens.ts +++ b/packages/shared/src/tokens/evm/dynamicEvmTokens.ts @@ -7,6 +7,7 @@ import { EvmTokenDetails } from "../types/evm"; import { evmTokenConfig } from "./config"; const SQUID_ROUTER_API_URL = "https://v2.api.squidrouter.com/v2/tokens"; +const SQUID_ROUTER_FETCH_TIMEOUT_MS = 10_000; // Token filtering configuration to exclude irrelevant tokens from EVM chains const TOKEN_FILTER_CONFIG = { @@ -36,10 +37,14 @@ interface DynamicEvmTokensState { tokensByNetwork: Record>>; priceBySymbol: Map; isLoaded: boolean; + // True only once the Squid list itself is installed. The static-only fallback sets isLoaded but not this, + // so initializeEvmTokens() keeps fetching until Squid answers. + loadedFromSquid: boolean; } const state: DynamicEvmTokensState = { isLoaded: false, + loadedFromSquid: false, priceBySymbol: new Map(), tokensByNetwork: {} as Record>> }; @@ -245,7 +250,8 @@ function buildPriceLookup(tokensByNetwork: Record { const response = await fetch(SQUID_ROUTER_API_URL, { - headers: { "x-integrator-id": squidRouterConfigBase.integratorId } + headers: { "x-integrator-id": squidRouterConfigBase.integratorId }, + signal: AbortSignal.timeout(SQUID_ROUTER_FETCH_TIMEOUT_MS) }); if (!response.ok) throw new Error(`Failed to fetch SquidRouter tokens: ${response.status}`); const data = await response.json(); @@ -277,12 +283,16 @@ function deriveAllTokens(tokensByNetwork: Record { - if (state.isLoaded) { - return; +export async function initializeEvmTokens(): Promise { + if (state.loadedFromSquid) { + return true; } try { @@ -295,6 +305,7 @@ export async function initializeEvmTokens(): Promise { state.tokensByNetwork = mergeWithStaticConfig(groupedTokens); state.priceBySymbol = buildPriceLookup(state.tokensByNetwork); state.isLoaded = true; + state.loadedFromSquid = true; for (const listener of evmTokenListeners) { try { listener(); @@ -315,6 +326,7 @@ export async function initializeEvmTokens(): Promise { logger.current.error("[DynamicEvmTokens] Error in EVM token listener", listenerErr); } } + return state.loadedFromSquid; } /** From ee0fc25c2b98cc0daaf66cd6a6845be1a04472a4 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 15:55:38 +0200 Subject: [PATCH 2/3] fix(api): retry the Squid token list in the background at boot Boot awaited a single initializeEvmTokens() call, so one failed Squid request left the API on static tokens until the next restart. Wait for the first attempt only, then retry every 60 s until the list loads. --- apps/api/src/config/evmTokens.test.ts | 33 +++++++++++++++++++++++++++ apps/api/src/config/evmTokens.ts | 25 ++++++++++++++++++++ apps/api/src/index.ts | 7 +++--- 3 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/config/evmTokens.test.ts create mode 100644 apps/api/src/config/evmTokens.ts diff --git a/apps/api/src/config/evmTokens.test.ts b/apps/api/src/config/evmTokens.test.ts new file mode 100644 index 000000000..571608683 --- /dev/null +++ b/apps/api/src/config/evmTokens.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, mock, test } from "bun:test"; +import { loadEvmTokens } from "./evmTokens"; + +const INTERVAL_MS = 5; + +const waitFor = async (condition: () => boolean) => { + const deadline = Date.now() + 2000; + while (!condition() && Date.now() < deadline) await Bun.sleep(1); +}; + +describe("loadEvmTokens", () => { + test("does not retry when the first load succeeds", async () => { + const load = mock(async () => true); + + await loadEvmTokens(load, INTERVAL_MS); + await Bun.sleep(INTERVAL_MS * 6); + + expect(load).toHaveBeenCalledTimes(1); + }); + + test("resolves after the first attempt, retries until the load succeeds, then stops", async () => { + const results = [false, false, true]; + const load = mock(async () => results.shift() ?? true); + + await loadEvmTokens(load, INTERVAL_MS); + expect(load).toHaveBeenCalledTimes(1); + + await waitFor(() => load.mock.calls.length >= 3); + await Bun.sleep(INTERVAL_MS * 6); + + expect(load).toHaveBeenCalledTimes(3); + }); +}); diff --git a/apps/api/src/config/evmTokens.ts b/apps/api/src/config/evmTokens.ts new file mode 100644 index 000000000..327046ce5 --- /dev/null +++ b/apps/api/src/config/evmTokens.ts @@ -0,0 +1,25 @@ +import { initializeEvmTokens } from "@vortexfi/shared"; +import logger from "./logger"; + +const RETRY_INTERVAL_MS = 60_000; + +/** + * Loads the Squid token list at boot. Tokens only Squid lists (PAXG, ...) are unavailable while just the + * static fallback is installed, so a failed first load is retried in the background until it succeeds. + * Boot waits for the first attempt only, which is bounded by the fetch timeout. + */ +export async function loadEvmTokens( + load: () => Promise = initializeEvmTokens, + retryIntervalMs = RETRY_INTERVAL_MS +): Promise { + if (await load()) return; + + logger.warn(`Squid token list unavailable, serving static tokens only; retrying every ${retryIntervalMs / 1000}s`); + const timer = setInterval(async () => { + if (await load()) { + clearInterval(timer); + logger.info("Squid token list loaded"); + } + }, retryIntervalMs); + timer.unref(); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 15e69885d..f04da55bd 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,8 +1,9 @@ -import { EvmClientManager, initializeEvmTokens, setLogger } from "@vortexfi/shared"; +import { EvmClientManager, setLogger } from "@vortexfi/shared"; import dotenv from "dotenv"; import path from "path"; import cryptoService from "./config/crypto"; import { testDatabaseConnection } from "./config/database"; +import { loadEvmTokens } from "./config/evmTokens"; import app from "./config/express"; import logger from "./config/logger"; import { config } from "./config/vars"; @@ -63,8 +64,8 @@ const initializeApp = async () => { // Sandbox demo deployments only; a no-op everywhere else. installDemoProviders(); - // Initialize dynamic EVM tokens from SquidRouter API (falls back to static config on failure) - await initializeEvmTokens(); + // Initialize dynamic EVM tokens from SquidRouter API (static config on failure, retried in the background) + await loadEvmTokens(); // Test database connection await testDatabaseConnection(); From 9c1de90eafb2a29452c1fa7c5e3fa45e609217d6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 15:55:38 +0200 Subject: [PATCH 3/3] docs(api): record the token-registry retry invariant in the Squid spec --- docs/security-spec/05-integrations/squid-router.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/security-spec/05-integrations/squid-router.md b/docs/security-spec/05-integrations/squid-router.md index 52bf4294f..fd64fec1b 100644 --- a/docs/security-spec/05-integrations/squid-router.md +++ b/docs/security-spec/05-integrations/squid-router.md @@ -72,6 +72,7 @@ When the BRL on-ramp's destination is **Base + USDC**, the Nabla swap output is 15. **The SDK pre-checks the source wallet balance by default, with an explicit deferred-funding mode** — In the default `offrampFundingMode: "prefunded"`, `assertSufficientOfframpBalance` runs from `VortexSdk.registerRamp` for every SELL corridor, reads the input token balance of `walletAddress` on the source EVM chain, and rejects registration with `InsufficientBalanceError` when it does not cover `inputAmount`. Server integrations that intentionally register before funding a temporary wallet MAY configure `offrampFundingMode: "deferred"`; they MUST fund that exact wallet before submitting user transactions and starting the ramp within the registration window. The SDK guard remains client-side defense-in-depth: RPC failure or unknown token skips it, AssetHub sources are not checked, and deferred mode intentionally omits it. It MUST NOT be relied on in place of invariant 14 or backend-side validation. 16. **Source builders and nonce topology MUST match the source network** — Base-internal BRL and persisted Mykobo routes MUST use the Base builder, while Polygon Monerium and Alfredpay routes use the Polygon builder. Same-chain routes MUST omit bridge-pay and backup transactions, and `destinationTransfer` MUST be the first nonce after `squidRouterSwap`. 17. **Native-token offramps MUST NOT generate or await an ERC-20 approval** — Route construction emits only the Squid swap at nonce zero for native input. User-hash verification requires an approval only when an approval blueprint exists; the swap hash remains mandatory. +18. **A failed token-registry load MUST fall back to the static tokens and be retried until the Squid list loads** — `initializeEvmTokens()` in `packages/shared/src/tokens/evm/dynamicEvmTokens.ts` bounds the Squid `/v2/tokens` fetch at 10 s. On failure it installs the static-only set and resolves `false`; only a successful load turns later calls into no-ops. The static set has no Squid-only tokens (for example PAXG), so those are unlisted and unquotable until the load succeeds; the fallback only narrows the routable set. API boot waits for the first attempt only, then `loadEvmTokens()` in `apps/api/src/config/evmTokens.ts` retries every 60 s until it succeeds. ## Threat Vectors & Mitigations @@ -86,6 +87,7 @@ When the BRL on-ramp's destination is **Base + USDC**, the Nabla swap output is | **Unfunded owner burns the single-use permit** | User signs the permit before funding the wallet (or drains it after signing); executing `permit()` would consume the nonce with no recoverable transfer. Backend checks `balanceOf(owner) >= value` before touching the permit and retries recoverably (~10 min window). The SDK additionally refuses registration by default; deferred-funding integrations accept responsibility for funding before transaction submission and start. | | **Executor key compromise** | Attacker can call `execute()` with their own signatures but cannot steal in-flight user funds — the key only pays gas. Blast radius: gas balance drain. | | **Squid Router API manipulation (fake "success")** | Balance check runs in parallel; even if Squid reports premature success, tokens must actually arrive. | +| **Squid token list unavailable at boot** | The fetch is bounded at 10 s, so a hung request cannot stall boot. Failure keeps the static tokens (fail-closed: Squid-only tokens are unlisted and rejected with `Invalid token details for EVM bridge`) and is retried every 60 s until the list loads. | | **Squid rate limit (429) or gateway 5xx with a non-JSON body** | Single retry (`retryAfter` capped at 5 s for 429, 1 s for the gateway error); Squid's own JSON errors and all other errors fail fast. | | **Transaction not found during confirmation** | Exponential backoff retry (5s → 10s → 20s → 30s cap), up to 4 attempts. | | **No-permit fallback hash spoofing** | User reports tx hash → backend calls `waitForTransactionReceipt(hash)` and verifies the receipt `from`, receipt `to`, and transaction calldata against the expected presigned user-wallet transaction. A missing hash or mismatched transaction fails before the phase advances. |