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
33 changes: 33 additions & 0 deletions apps/api/src/config/evmTokens.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
25 changes: 25 additions & 0 deletions apps/api/src/config/evmTokens.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> = initializeEvmTokens,
retryIntervalMs = RETRY_INTERVAL_MS
): Promise<void> {
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();
}
7 changes: 4 additions & 3 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions docs/security-spec/05-integrations/squid-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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. |
Expand Down
124 changes: 124 additions & 0 deletions packages/shared/src/tokens/evm/dynamicEvmTokens.test.ts
Original file line number Diff line number Diff line change
@@ -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<Response>) => {
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<typeof spyOn>;

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<Response>((_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();
}
});
});
24 changes: 18 additions & 6 deletions packages/shared/src/tokens/evm/dynamicEvmTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -36,10 +37,14 @@ interface DynamicEvmTokensState {
tokensByNetwork: Record<EvmNetworks, Partial<Record<string, EvmTokenDetails>>>;
priceBySymbol: Map<string, number>;
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<EvmNetworks, Partial<Record<string, EvmTokenDetails>>>
};
Expand Down Expand Up @@ -245,7 +250,8 @@ function buildPriceLookup(tokensByNetwork: Record<EvmNetworks, Partial<Record<st

async function fetchSquidRouterTokens(): Promise<SquidRouterToken[]> {
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();
Expand Down Expand Up @@ -277,12 +283,16 @@ function deriveAllTokens(tokensByNetwork: Record<EvmNetworks, Partial<Record<str

/**
* Initialize the dynamic EVM tokens service.
* Call this once at app startup before React renders.
* This function is idempotent - calling it multiple times is safe.
* Call this at app startup before React renders.
* This function is idempotent - calling it multiple times is safe. On a failed Squid fetch it installs the
* static-only fallback and resolves to false; call it again to retry. It never fetches again once the Squid
* list is loaded.
*
* @returns true when the Squid token list is loaded, false when only the static fallback is in use
*/
export async function initializeEvmTokens(): Promise<void> {
if (state.isLoaded) {
return;
export async function initializeEvmTokens(): Promise<boolean> {
if (state.loadedFromSquid) {
return true;
}

try {
Expand All @@ -295,6 +305,7 @@ export async function initializeEvmTokens(): Promise<void> {
state.tokensByNetwork = mergeWithStaticConfig(groupedTokens);
state.priceBySymbol = buildPriceLookup(state.tokensByNetwork);
state.isLoaded = true;
state.loadedFromSquid = true;
for (const listener of evmTokenListeners) {
try {
listener();
Expand All @@ -315,6 +326,7 @@ export async function initializeEvmTokens(): Promise<void> {
logger.current.error("[DynamicEvmTokens] Error in EVM token listener", listenerErr);
}
}
return state.loadedFromSquid;
}

/**
Expand Down
Loading