From f59cf39fb5e6f5a7034087cc2067bd24cb5788f1 Mon Sep 17 00:00:00 2001 From: PhilBot <9mmnwvp6vs@privaterelay.appleid.com> Date: Sun, 27 Sep 2026 11:56:06 +0000 Subject: [PATCH 1/2] fix(e2e): retry Solana devnet 429s in swig setup Public devnet returns HTTP 429 during Swig funding when SVM_TESTNET_RPC_URL is unset. swig-setup treated that as fatal, so the e2e job died after the account was created. Co-authored-by: phdargen <29732335+phdargen@users.noreply.github.com> --- .github/workflows/e2e_tests.yml | 3 +- e2e/scripts/svm-rpc-retry.test.ts | 150 ++++++++++++++++++++++++++++ e2e/scripts/svm-rpc-retry.ts | 158 ++++++++++++++++++++++++++++++ e2e/scripts/swig-setup.ts | 68 +++++++------ 4 files changed, 348 insertions(+), 31 deletions(-) create mode 100644 e2e/scripts/svm-rpc-retry.test.ts create mode 100644 e2e/scripts/svm-rpc-retry.ts diff --git a/.github/workflows/e2e_tests.yml b/.github/workflows/e2e_tests.yml index 48abc70e3a..ea118212fb 100644 --- a/.github/workflows/e2e_tests.yml +++ b/.github/workflows/e2e_tests.yml @@ -176,7 +176,8 @@ jobs: # shellcheck disable=SC1091 source .env set +a - # Only forward a custom RPC; empty SVM_TESTNET_RPC_URL falls back to devnet in swig-setup. + # Forward a custom RPC when the secret is set. An empty secret falls back + # to public devnet inside swig-setup, which retries HTTP 429. if [[ -n "${SVM_TESTNET_RPC_URL:-}" ]]; then export SVM_RPC_URL="$SVM_TESTNET_RPC_URL" fi diff --git a/e2e/scripts/svm-rpc-retry.test.ts b/e2e/scripts/svm-rpc-retry.test.ts new file mode 100644 index 0000000000..d86105bbbe --- /dev/null +++ b/e2e/scripts/svm-rpc-retry.test.ts @@ -0,0 +1,150 @@ +import { SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, SolanaError, type RpcTransport } from "@solana/kit"; +import { describe, expect, it, vi } from "vitest"; +import { + DEVNET_RPC_URL, + isRetryableRpcRateLimit, + resolveSwigRpcUrl, + rpcRateLimitDelayMs, + signaturePollDelayMs, + withRpcRateLimitRetry, +} from "./svm-rpc-retry"; + +function httpError(statusCode: number, retryAfter?: string): SolanaError { + const headers = new Headers(); + if (retryAfter !== undefined) { + headers.set("retry-after", retryAfter); + } + return new SolanaError(SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, { + headers, + message: statusCode === 429 ? "Too Many Requests" : "Server Error", + statusCode, + }); +} + +describe("resolveSwigRpcUrl", () => { + it("prefers SVM_RPC_URL, then SVM_TESTNET_RPC_URL, then public devnet", () => { + expect( + resolveSwigRpcUrl({ + svmRpcUrl: "https://private.example", + svmTestnetRpcUrl: "https://catalog.example", + }), + ).toBe("https://private.example"); + expect(resolveSwigRpcUrl({ svmTestnetRpcUrl: "https://catalog.example" })).toBe( + "https://catalog.example", + ); + expect(resolveSwigRpcUrl({})).toBe(DEVNET_RPC_URL); + }); + + it("treats blank values as unset", () => { + expect( + resolveSwigRpcUrl({ + svmRpcUrl: " ", + svmTestnetRpcUrl: " https://catalog.example ", + }), + ).toBe("https://catalog.example"); + expect(resolveSwigRpcUrl({ svmRpcUrl: "", svmTestnetRpcUrl: " " })).toBe(DEVNET_RPC_URL); + }); +}); + +describe("isRetryableRpcRateLimit", () => { + it("retries only HTTP 429", () => { + expect(isRetryableRpcRateLimit(httpError(429))).toBe(true); + expect(isRetryableRpcRateLimit(httpError(500))).toBe(false); + expect(isRetryableRpcRateLimit(new Error("HTTP error (429): Too Many Requests"))).toBe(true); + expect(isRetryableRpcRateLimit(new Error("Transaction failed onchain"))).toBe(false); + expect(isRetryableRpcRateLimit("429")).toBe(false); + }); +}); + +describe("rpcRateLimitDelayMs", () => { + const nowMs = 1_700_000_000_000; + + it("uses exponential backoff when Retry-After is missing or unusable", () => { + expect(rpcRateLimitDelayMs(0, null, nowMs)).toBe(1_000); + expect(rpcRateLimitDelayMs(1, null, nowMs)).toBe(2_000); + expect(rpcRateLimitDelayMs(4, null, nowMs)).toBe(16_000); + expect(rpcRateLimitDelayMs(5, null, nowMs)).toBe(30_000); + expect(rpcRateLimitDelayMs(0, "0", nowMs)).toBe(1_000); + expect(rpcRateLimitDelayMs(0, "soon", nowMs)).toBe(1_000); + }); + + it("honors Retry-After and caps it", () => { + expect(rpcRateLimitDelayMs(4, "2", nowMs)).toBe(2_000); + expect(rpcRateLimitDelayMs(0, " 3 ", nowMs)).toBe(3_000); + expect(rpcRateLimitDelayMs(0, "120", nowMs)).toBe(30_000); + expect(rpcRateLimitDelayMs(0, new Date(nowMs + 5_000).toUTCString(), nowMs)).toBe(5_000); + }); +}); + +describe("signaturePollDelayMs", () => { + it("polls public devnet once per second", () => { + expect(signaturePollDelayMs(DEVNET_RPC_URL, 0)).toBe(1_000); + expect(signaturePollDelayMs(DEVNET_RPC_URL, 5_000)).toBe(1_000); + }); + + it("keeps the short initial poll for a dedicated RPC", () => { + const dedicated = "https://private.example"; + expect(signaturePollDelayMs(dedicated, 0)).toBe(250); + expect(signaturePollDelayMs(dedicated, 1_999)).toBe(250); + expect(signaturePollDelayMs(dedicated, 2_000)).toBe(1_000); + }); +}); + +describe("withRpcRateLimitRetry", () => { + it("retries HTTP 429 and then returns the successful response", async () => { + const sleeps: number[] = []; + let calls = 0; + const inner: RpcTransport = async () => { + calls += 1; + if (calls < 3) { + throw httpError(429, "1"); + } + return { ok: true } as never; + }; + + const transport = withRpcRateLimitRetry(inner, { + sleep: async ms => { + sleeps.push(ms); + }, + }); + + await expect(transport({ payload: { method: "getLatestBlockhash" } })).resolves.toEqual({ + ok: true, + }); + expect(calls).toBe(3); + expect(sleeps).toEqual([1_000, 1_000]); + }); + + it("does not retry other HTTP statuses", async () => { + const sleep = vi.fn(async () => {}); + let calls = 0; + const inner: RpcTransport = async () => { + calls += 1; + throw httpError(500); + }; + + const transport = withRpcRateLimitRetry(inner, { sleep }); + await expect(transport({ payload: { method: "sendTransaction" } })).rejects.toThrow( + "HTTP error (500)", + ); + expect(calls).toBe(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it("stops after the attempt budget", async () => { + let calls = 0; + const inner: RpcTransport = async () => { + calls += 1; + throw new Error("HTTP error (429): Too Many Requests"); + }; + + const transport = withRpcRateLimitRetry(inner, { + maxAttempts: 2, + sleep: async () => {}, + }); + await expect(transport({ payload: { method: "sendTransaction" } })).rejects.toThrow( + "HTTP error (429): Too Many Requests", + ); + expect(calls).toBe(2); + }); +}); diff --git a/e2e/scripts/svm-rpc-retry.ts b/e2e/scripts/svm-rpc-retry.ts new file mode 100644 index 0000000000..6a742c1d65 --- /dev/null +++ b/e2e/scripts/svm-rpc-retry.ts @@ -0,0 +1,158 @@ +/** + * Solana RPC helpers for Swig e2e setup. + * + * Public devnet is a free-tier endpoint. One setup run creates a Swig account, + * an ATA, and a funding transfer, and each confirmation polls signature status. + * Shared CI IPs hit HTTP 429 (`HTTP error (429): Too Many Requests`) on that + * burst. The kit transport does not retry, so the setup process exits. + */ + +import { + createDefaultRpcTransport, + createSolanaRpcFromTransport, + isSolanaError, + SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, + type Rpc, + type RpcTransport, + type SolanaRpcApi, +} from "@solana/kit"; + +/** Public devnet fallback when neither SVM_RPC_URL nor SVM_TESTNET_RPC_URL is set. */ +export const DEVNET_RPC_URL = "https://api.devnet.solana.com"; + +const RPC_RATE_LIMIT_ATTEMPTS = 6; +const RPC_RATE_LIMIT_BASE_DELAY_MS = 1_000; +const RPC_RATE_LIMIT_MAX_DELAY_MS = 30_000; + +const PUBLIC_DEVNET_POLL_MS = 1_000; +const DEDICATED_RPC_INITIAL_POLL_MS = 250; +const DEDICATED_RPC_INITIAL_WINDOW_MS = 2_000; +const DEDICATED_RPC_FALLBACK_POLL_MS = 1_000; + +type Sleep = (ms: number) => Promise; + +export type RpcRateLimitRetryOptions = { + sleep?: Sleep; + maxAttempts?: number; +}; + +function defaultSleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * SVM_RPC_URL wins when the workflow or harness sets it. SVM_TESTNET_RPC_URL is + * the catalog override stored in e2e/.env. Blank values fall through to public devnet. + */ +export function resolveSwigRpcUrl(input: { svmRpcUrl?: string; svmTestnetRpcUrl?: string }): string { + const explicit = input.svmRpcUrl?.trim(); + if (explicit) { + return explicit; + } + const testnet = input.svmTestnetRpcUrl?.trim(); + if (testnet) { + return testnet; + } + return DEVNET_RPC_URL; +} + +export function isRetryableRpcRateLimit(error: unknown): boolean { + if (isSolanaError(error, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR)) { + return error.context.statusCode === 429; + } + if (!(error instanceof Error)) { + return false; + } + return error.message.includes("429") && error.message.includes("Too Many Requests"); +} + +function retryAfterHeader(error: unknown): string | null { + if (!isSolanaError(error, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR)) { + return null; + } + return error.context.headers.get("retry-after"); +} + +/** + * Prefer a positive Retry-After (delta-seconds or HTTP-date). Otherwise use + * exponential backoff. Both are capped so a bad header cannot stall setup. + */ +export function rpcRateLimitDelayMs( + attempt: number, + retryAfter: string | null, + nowMs: number = Date.now(), +): number { + if (retryAfter !== null) { + const trimmed = retryAfter.trim(); + if (/^\d+$/.test(trimmed)) { + const delay = Number(trimmed) * 1_000; + if (delay > 0) { + return Math.min(delay, RPC_RATE_LIMIT_MAX_DELAY_MS); + } + } else { + const retryDate = Date.parse(retryAfter); + if (!Number.isNaN(retryDate)) { + const delay = retryDate - nowMs; + if (delay > 0) { + return Math.min(delay, RPC_RATE_LIMIT_MAX_DELAY_MS); + } + } + } + } + + const exponential = RPC_RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt; + return Math.min(exponential, RPC_RATE_LIMIT_MAX_DELAY_MS); +} + +/** + * Retry HTTP 429 from the Solana RPC transport. + * The same signed transaction bytes are submitted again; a signature the + * cluster already processed is rejected, so a retried send is idempotent. + */ +export function withRpcRateLimitRetry( + inner: RpcTransport, + options: RpcRateLimitRetryOptions = {}, +): RpcTransport { + const sleep = options.sleep ?? defaultSleep; + const maxAttempts = options.maxAttempts ?? RPC_RATE_LIMIT_ATTEMPTS; + + return async (config: Parameters[0]): Promise => { + let lastError: unknown; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + return await inner(config); + } catch (error) { + lastError = error; + if (!isRetryableRpcRateLimit(error) || attempt === maxAttempts - 1) { + throw error; + } + const delayMs = rpcRateLimitDelayMs(attempt, retryAfterHeader(error)); + console.log( + `ā³ Solana RPC rate limited (429); retrying in ${delayMs}ms (${attempt + 1}/${maxAttempts - 1})`, + ); + await sleep(delayMs); + } + } + throw lastError; + }; +} + +export function createRateLimitedSolanaRpc(rpcUrl: string): Rpc { + const transport = withRpcRateLimitRetry(createDefaultRpcTransport({ url: rpcUrl })); + return createSolanaRpcFromTransport(transport); +} + +/** + * Dedicated RPCs keep the facilitator signer's 250ms-then-1s poll. Public + * devnet uses 1s so create + ATA confirmation does not burn the free-tier budget + * before the funding transfer. + */ +export function signaturePollDelayMs(rpcUrl: string, elapsedMs: number): number { + if (rpcUrl === DEVNET_RPC_URL) { + return PUBLIC_DEVNET_POLL_MS; + } + if (elapsedMs < DEDICATED_RPC_INITIAL_WINDOW_MS) { + return DEDICATED_RPC_INITIAL_POLL_MS; + } + return DEDICATED_RPC_FALLBACK_POLL_MS; +} diff --git a/e2e/scripts/swig-setup.ts b/e2e/scripts/swig-setup.ts index 0d6a2b19af..f114ad7cd3 100644 --- a/e2e/scripts/swig-setup.ts +++ b/e2e/scripts/swig-setup.ts @@ -10,7 +10,8 @@ * * Environment variables: * CLIENT_SVM_PRIVATE_KEY - Swig authority (required) - * SVM_RPC_URL - Solana RPC (optional, defaults to devnet) + * SVM_RPC_URL - Solana RPC (optional; wins over SVM_TESTNET_RPC_URL) + * SVM_TESTNET_RPC_URL - Catalog RPC override when SVM_RPC_URL is unset * SWIG_ACCOUNT_ADDRESS - Reuse existing Swig account (optional) * SWIG_ID_BASE58 - Fixed Swig id when creating (optional) * SVM_USDC_MINT - Token mint to fund (optional, devnet USDC default) @@ -39,7 +40,6 @@ import { addSignersToTransactionMessage, appendTransactionMessageInstructions, createKeyPairSignerFromBytes, - createSolanaRpc, createTransactionMessage, getBase64EncodedWireTransaction, getSignatureFromTransaction, @@ -60,30 +60,27 @@ import { getSwigWalletAddress, } from "@swig-wallet/kit"; import { Actions, createEd25519AuthorityInfo } from "@swig-wallet/lib"; +import { + createRateLimitedSolanaRpc, + isRetryableRpcRateLimit, + resolveSwigRpcUrl, + signaturePollDelayMs, +} from "./svm-rpc-retry"; config(); -const DEVNET_RPC_URL = "https://api.devnet.solana.com"; const USDC_DEVNET_MINT = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; const MIN_AUTHORITY_SOL = 5_000_000n; /** Standard e2e exact endpoint price: $0.001 USDC (6 decimals). */ const E2E_EXACT_PAYMENT_BASE_UNITS = 1_000n; const SWIG_FUND_MULTIPLIER = 10n; -function resolveRpcUrl(rpcUrl?: string): string { - const trimmed = rpcUrl?.trim(); - return trimmed || DEVNET_RPC_URL; -} - -function createRpc(rpcUrl?: string): Rpc { - return createSolanaRpc(resolveRpcUrl(rpcUrl)); -} - -/** Poll signature status over HTTP RPC (same approach as @x402/svm facilitator signer). */ -async function confirmTransaction(rpc: Rpc, signature: string): Promise { - const initialDelayMs = 250; - const initialWindowMs = 2_000; - const fallbackDelayMs = 1_000; +/** Poll signature status over HTTP RPC. Poll interval depends on the RPC host. */ +async function confirmTransaction( + rpc: Rpc, + rpcUrl: string, + signature: string, +): Promise { const maxWaitMs = 30_000; const startedAt = Date.now(); @@ -98,8 +95,7 @@ async function confirmTransaction(rpc: Rpc, signature: string): Pr return; } - const elapsed = Date.now() - startedAt; - const delay = elapsed < initialWindowMs ? initialDelayMs : fallbackDelayMs; + const delay = signaturePollDelayMs(rpcUrl, Date.now() - startedAt); await new Promise(resolve => setTimeout(resolve, delay)); } @@ -108,6 +104,7 @@ async function confirmTransaction(rpc: Rpc, signature: string): Pr async function sendInstructions( rpc: Rpc, + rpcUrl: string, payer: KeyPairSigner, instructions: Instruction[], signers: KeyPairSigner[] = [], @@ -133,7 +130,7 @@ async function sendInstructions( .send(); const signature = getSignatureFromTransaction(signedTx); - await confirmTransaction(rpc, signature); + await confirmTransaction(rpc, rpcUrl, signature); return signature; } @@ -209,6 +206,7 @@ function printSwigEnvSummary(resolved: ResolvedSwigAccount): void { async function resolveSwigAccountAddress( rpc: Rpc, + rpcUrl: string, authority: KeyPairSigner, ): Promise { const fromEnv = process.env.SWIG_ACCOUNT_ADDRESS; @@ -234,7 +232,7 @@ async function resolveSwigAccountAddress( }); console.log(`šŸ”„ Creating Swig account ${swigAccountAddress}...`); - const sig = await sendInstructions(rpc, authority, [createSwigIx as Instruction]); + const sig = await sendInstructions(rpc, rpcUrl, authority, [createSwigIx as Instruction]); console.log(` āœ… Swig create tx: ${sig}`); return { @@ -246,6 +244,7 @@ async function resolveSwigAccountAddress( async function ensureSwigFunded( rpc: Rpc, + rpcUrl: string, authority: KeyPairSigner, swigAccountAddress: Address, mint: Address, @@ -283,16 +282,22 @@ async function ensureSwigFunded( try { await rpc.getTokenAccountBalance(authorityAta).send(); - } catch { + } catch (error) { + if (isRetryableRpcRateLimit(error)) { + throw error; + } console.log("šŸ”„ Creating authority USDC ATA..."); - await sendInstructions(rpc, authority, [createAuthorityAtaIx]); + await sendInstructions(rpc, rpcUrl, authority, [createAuthorityAtaIx]); } try { await rpc.getTokenAccountBalance(swigAta).send(); - } catch { + } catch (error) { + if (isRetryableRpcRateLimit(error)) { + throw error; + } console.log("šŸ”„ Creating Swig wallet USDC ATA..."); - await sendInstructions(rpc, authority, [createSwigAtaIx]); + await sendInstructions(rpc, rpcUrl, authority, [createSwigAtaIx]); } const swigBalance = await rpc.getTokenAccountBalance(swigAta).send(); @@ -327,7 +332,7 @@ async function ensureSwigFunded( }, { programAddress: tokenProgram }, ); - const sig = await sendInstructions(rpc, authority, [fundIx]); + const sig = await sendInstructions(rpc, rpcUrl, authority, [fundIx]); console.log(` āœ… Fund tx: ${sig}`); } @@ -338,17 +343,20 @@ async function main(): Promise { process.exit(1); } - const rpcUrl = resolveRpcUrl(process.env.SVM_RPC_URL); + const rpcUrl = resolveSwigRpcUrl({ + svmRpcUrl: process.env.SVM_RPC_URL, + svmTestnetRpcUrl: process.env.SVM_TESTNET_RPC_URL, + }); const mint = (process.env.SVM_USDC_MINT ?? USDC_DEVNET_MINT) as Address; - const rpc = createRpc(rpcUrl); + const rpc = createRateLimitedSolanaRpc(rpcUrl); const authority = await createKeyPairSignerFromBytes(base58.decode(privateKey)); console.log(`\nšŸ”‘ Authority: ${authority.address}`); console.log(`šŸ“ RPC: ${rpcUrl}`); console.log(`šŸ’° Mint: ${mint}\n`); - const resolved = await resolveSwigAccountAddress(rpc, authority); - await ensureSwigFunded(rpc, authority, resolved.address, mint); + const resolved = await resolveSwigAccountAddress(rpc, rpcUrl, authority); + await ensureSwigFunded(rpc, rpcUrl, authority, resolved.address, mint); persistSwigEnv(join(process.cwd(), ".env"), resolved); printSwigEnvSummary(resolved); From 2e07252b8b60c18b73c334251b4e0a92a210e512 Mon Sep 17 00:00:00 2001 From: PhilBot <9mmnwvp6vs@privaterelay.appleid.com> Date: Sun, 27 Sep 2026 12:09:04 +0000 Subject: [PATCH 2/2] fix(e2e): inline public-devnet 429 retry in swig-setup Keep the HTTP 429 backoff in the setup script and drop the helper module and tests. Co-authored-by: phdargen <29732335+phdargen@users.noreply.github.com> --- .github/workflows/e2e_tests.yml | 3 +- e2e/scripts/svm-rpc-retry.test.ts | 150 ---------------------------- e2e/scripts/svm-rpc-retry.ts | 158 ------------------------------ e2e/scripts/swig-setup.ts | 115 +++++++++++++++------- 4 files changed, 78 insertions(+), 348 deletions(-) delete mode 100644 e2e/scripts/svm-rpc-retry.test.ts delete mode 100644 e2e/scripts/svm-rpc-retry.ts diff --git a/.github/workflows/e2e_tests.yml b/.github/workflows/e2e_tests.yml index ea118212fb..48abc70e3a 100644 --- a/.github/workflows/e2e_tests.yml +++ b/.github/workflows/e2e_tests.yml @@ -176,8 +176,7 @@ jobs: # shellcheck disable=SC1091 source .env set +a - # Forward a custom RPC when the secret is set. An empty secret falls back - # to public devnet inside swig-setup, which retries HTTP 429. + # Only forward a custom RPC; empty SVM_TESTNET_RPC_URL falls back to devnet in swig-setup. if [[ -n "${SVM_TESTNET_RPC_URL:-}" ]]; then export SVM_RPC_URL="$SVM_TESTNET_RPC_URL" fi diff --git a/e2e/scripts/svm-rpc-retry.test.ts b/e2e/scripts/svm-rpc-retry.test.ts deleted file mode 100644 index d86105bbbe..0000000000 --- a/e2e/scripts/svm-rpc-retry.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, SolanaError, type RpcTransport } from "@solana/kit"; -import { describe, expect, it, vi } from "vitest"; -import { - DEVNET_RPC_URL, - isRetryableRpcRateLimit, - resolveSwigRpcUrl, - rpcRateLimitDelayMs, - signaturePollDelayMs, - withRpcRateLimitRetry, -} from "./svm-rpc-retry"; - -function httpError(statusCode: number, retryAfter?: string): SolanaError { - const headers = new Headers(); - if (retryAfter !== undefined) { - headers.set("retry-after", retryAfter); - } - return new SolanaError(SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, { - headers, - message: statusCode === 429 ? "Too Many Requests" : "Server Error", - statusCode, - }); -} - -describe("resolveSwigRpcUrl", () => { - it("prefers SVM_RPC_URL, then SVM_TESTNET_RPC_URL, then public devnet", () => { - expect( - resolveSwigRpcUrl({ - svmRpcUrl: "https://private.example", - svmTestnetRpcUrl: "https://catalog.example", - }), - ).toBe("https://private.example"); - expect(resolveSwigRpcUrl({ svmTestnetRpcUrl: "https://catalog.example" })).toBe( - "https://catalog.example", - ); - expect(resolveSwigRpcUrl({})).toBe(DEVNET_RPC_URL); - }); - - it("treats blank values as unset", () => { - expect( - resolveSwigRpcUrl({ - svmRpcUrl: " ", - svmTestnetRpcUrl: " https://catalog.example ", - }), - ).toBe("https://catalog.example"); - expect(resolveSwigRpcUrl({ svmRpcUrl: "", svmTestnetRpcUrl: " " })).toBe(DEVNET_RPC_URL); - }); -}); - -describe("isRetryableRpcRateLimit", () => { - it("retries only HTTP 429", () => { - expect(isRetryableRpcRateLimit(httpError(429))).toBe(true); - expect(isRetryableRpcRateLimit(httpError(500))).toBe(false); - expect(isRetryableRpcRateLimit(new Error("HTTP error (429): Too Many Requests"))).toBe(true); - expect(isRetryableRpcRateLimit(new Error("Transaction failed onchain"))).toBe(false); - expect(isRetryableRpcRateLimit("429")).toBe(false); - }); -}); - -describe("rpcRateLimitDelayMs", () => { - const nowMs = 1_700_000_000_000; - - it("uses exponential backoff when Retry-After is missing or unusable", () => { - expect(rpcRateLimitDelayMs(0, null, nowMs)).toBe(1_000); - expect(rpcRateLimitDelayMs(1, null, nowMs)).toBe(2_000); - expect(rpcRateLimitDelayMs(4, null, nowMs)).toBe(16_000); - expect(rpcRateLimitDelayMs(5, null, nowMs)).toBe(30_000); - expect(rpcRateLimitDelayMs(0, "0", nowMs)).toBe(1_000); - expect(rpcRateLimitDelayMs(0, "soon", nowMs)).toBe(1_000); - }); - - it("honors Retry-After and caps it", () => { - expect(rpcRateLimitDelayMs(4, "2", nowMs)).toBe(2_000); - expect(rpcRateLimitDelayMs(0, " 3 ", nowMs)).toBe(3_000); - expect(rpcRateLimitDelayMs(0, "120", nowMs)).toBe(30_000); - expect(rpcRateLimitDelayMs(0, new Date(nowMs + 5_000).toUTCString(), nowMs)).toBe(5_000); - }); -}); - -describe("signaturePollDelayMs", () => { - it("polls public devnet once per second", () => { - expect(signaturePollDelayMs(DEVNET_RPC_URL, 0)).toBe(1_000); - expect(signaturePollDelayMs(DEVNET_RPC_URL, 5_000)).toBe(1_000); - }); - - it("keeps the short initial poll for a dedicated RPC", () => { - const dedicated = "https://private.example"; - expect(signaturePollDelayMs(dedicated, 0)).toBe(250); - expect(signaturePollDelayMs(dedicated, 1_999)).toBe(250); - expect(signaturePollDelayMs(dedicated, 2_000)).toBe(1_000); - }); -}); - -describe("withRpcRateLimitRetry", () => { - it("retries HTTP 429 and then returns the successful response", async () => { - const sleeps: number[] = []; - let calls = 0; - const inner: RpcTransport = async () => { - calls += 1; - if (calls < 3) { - throw httpError(429, "1"); - } - return { ok: true } as never; - }; - - const transport = withRpcRateLimitRetry(inner, { - sleep: async ms => { - sleeps.push(ms); - }, - }); - - await expect(transport({ payload: { method: "getLatestBlockhash" } })).resolves.toEqual({ - ok: true, - }); - expect(calls).toBe(3); - expect(sleeps).toEqual([1_000, 1_000]); - }); - - it("does not retry other HTTP statuses", async () => { - const sleep = vi.fn(async () => {}); - let calls = 0; - const inner: RpcTransport = async () => { - calls += 1; - throw httpError(500); - }; - - const transport = withRpcRateLimitRetry(inner, { sleep }); - await expect(transport({ payload: { method: "sendTransaction" } })).rejects.toThrow( - "HTTP error (500)", - ); - expect(calls).toBe(1); - expect(sleep).not.toHaveBeenCalled(); - }); - - it("stops after the attempt budget", async () => { - let calls = 0; - const inner: RpcTransport = async () => { - calls += 1; - throw new Error("HTTP error (429): Too Many Requests"); - }; - - const transport = withRpcRateLimitRetry(inner, { - maxAttempts: 2, - sleep: async () => {}, - }); - await expect(transport({ payload: { method: "sendTransaction" } })).rejects.toThrow( - "HTTP error (429): Too Many Requests", - ); - expect(calls).toBe(2); - }); -}); diff --git a/e2e/scripts/svm-rpc-retry.ts b/e2e/scripts/svm-rpc-retry.ts deleted file mode 100644 index 6a742c1d65..0000000000 --- a/e2e/scripts/svm-rpc-retry.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Solana RPC helpers for Swig e2e setup. - * - * Public devnet is a free-tier endpoint. One setup run creates a Swig account, - * an ATA, and a funding transfer, and each confirmation polls signature status. - * Shared CI IPs hit HTTP 429 (`HTTP error (429): Too Many Requests`) on that - * burst. The kit transport does not retry, so the setup process exits. - */ - -import { - createDefaultRpcTransport, - createSolanaRpcFromTransport, - isSolanaError, - SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, - type Rpc, - type RpcTransport, - type SolanaRpcApi, -} from "@solana/kit"; - -/** Public devnet fallback when neither SVM_RPC_URL nor SVM_TESTNET_RPC_URL is set. */ -export const DEVNET_RPC_URL = "https://api.devnet.solana.com"; - -const RPC_RATE_LIMIT_ATTEMPTS = 6; -const RPC_RATE_LIMIT_BASE_DELAY_MS = 1_000; -const RPC_RATE_LIMIT_MAX_DELAY_MS = 30_000; - -const PUBLIC_DEVNET_POLL_MS = 1_000; -const DEDICATED_RPC_INITIAL_POLL_MS = 250; -const DEDICATED_RPC_INITIAL_WINDOW_MS = 2_000; -const DEDICATED_RPC_FALLBACK_POLL_MS = 1_000; - -type Sleep = (ms: number) => Promise; - -export type RpcRateLimitRetryOptions = { - sleep?: Sleep; - maxAttempts?: number; -}; - -function defaultSleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -/** - * SVM_RPC_URL wins when the workflow or harness sets it. SVM_TESTNET_RPC_URL is - * the catalog override stored in e2e/.env. Blank values fall through to public devnet. - */ -export function resolveSwigRpcUrl(input: { svmRpcUrl?: string; svmTestnetRpcUrl?: string }): string { - const explicit = input.svmRpcUrl?.trim(); - if (explicit) { - return explicit; - } - const testnet = input.svmTestnetRpcUrl?.trim(); - if (testnet) { - return testnet; - } - return DEVNET_RPC_URL; -} - -export function isRetryableRpcRateLimit(error: unknown): boolean { - if (isSolanaError(error, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR)) { - return error.context.statusCode === 429; - } - if (!(error instanceof Error)) { - return false; - } - return error.message.includes("429") && error.message.includes("Too Many Requests"); -} - -function retryAfterHeader(error: unknown): string | null { - if (!isSolanaError(error, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR)) { - return null; - } - return error.context.headers.get("retry-after"); -} - -/** - * Prefer a positive Retry-After (delta-seconds or HTTP-date). Otherwise use - * exponential backoff. Both are capped so a bad header cannot stall setup. - */ -export function rpcRateLimitDelayMs( - attempt: number, - retryAfter: string | null, - nowMs: number = Date.now(), -): number { - if (retryAfter !== null) { - const trimmed = retryAfter.trim(); - if (/^\d+$/.test(trimmed)) { - const delay = Number(trimmed) * 1_000; - if (delay > 0) { - return Math.min(delay, RPC_RATE_LIMIT_MAX_DELAY_MS); - } - } else { - const retryDate = Date.parse(retryAfter); - if (!Number.isNaN(retryDate)) { - const delay = retryDate - nowMs; - if (delay > 0) { - return Math.min(delay, RPC_RATE_LIMIT_MAX_DELAY_MS); - } - } - } - } - - const exponential = RPC_RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt; - return Math.min(exponential, RPC_RATE_LIMIT_MAX_DELAY_MS); -} - -/** - * Retry HTTP 429 from the Solana RPC transport. - * The same signed transaction bytes are submitted again; a signature the - * cluster already processed is rejected, so a retried send is idempotent. - */ -export function withRpcRateLimitRetry( - inner: RpcTransport, - options: RpcRateLimitRetryOptions = {}, -): RpcTransport { - const sleep = options.sleep ?? defaultSleep; - const maxAttempts = options.maxAttempts ?? RPC_RATE_LIMIT_ATTEMPTS; - - return async (config: Parameters[0]): Promise => { - let lastError: unknown; - for (let attempt = 0; attempt < maxAttempts; attempt++) { - try { - return await inner(config); - } catch (error) { - lastError = error; - if (!isRetryableRpcRateLimit(error) || attempt === maxAttempts - 1) { - throw error; - } - const delayMs = rpcRateLimitDelayMs(attempt, retryAfterHeader(error)); - console.log( - `ā³ Solana RPC rate limited (429); retrying in ${delayMs}ms (${attempt + 1}/${maxAttempts - 1})`, - ); - await sleep(delayMs); - } - } - throw lastError; - }; -} - -export function createRateLimitedSolanaRpc(rpcUrl: string): Rpc { - const transport = withRpcRateLimitRetry(createDefaultRpcTransport({ url: rpcUrl })); - return createSolanaRpcFromTransport(transport); -} - -/** - * Dedicated RPCs keep the facilitator signer's 250ms-then-1s poll. Public - * devnet uses 1s so create + ATA confirmation does not burn the free-tier budget - * before the funding transfer. - */ -export function signaturePollDelayMs(rpcUrl: string, elapsedMs: number): number { - if (rpcUrl === DEVNET_RPC_URL) { - return PUBLIC_DEVNET_POLL_MS; - } - if (elapsedMs < DEDICATED_RPC_INITIAL_WINDOW_MS) { - return DEDICATED_RPC_INITIAL_POLL_MS; - } - return DEDICATED_RPC_FALLBACK_POLL_MS; -} diff --git a/e2e/scripts/swig-setup.ts b/e2e/scripts/swig-setup.ts index f114ad7cd3..8df252ef39 100644 --- a/e2e/scripts/swig-setup.ts +++ b/e2e/scripts/swig-setup.ts @@ -10,8 +10,7 @@ * * Environment variables: * CLIENT_SVM_PRIVATE_KEY - Swig authority (required) - * SVM_RPC_URL - Solana RPC (optional; wins over SVM_TESTNET_RPC_URL) - * SVM_TESTNET_RPC_URL - Catalog RPC override when SVM_RPC_URL is unset + * SVM_RPC_URL - Solana RPC (optional, defaults to devnet) * SWIG_ACCOUNT_ADDRESS - Reuse existing Swig account (optional) * SWIG_ID_BASE58 - Fixed Swig id when creating (optional) * SVM_USDC_MINT - Token mint to fund (optional, devnet USDC default) @@ -39,18 +38,24 @@ import { import { addSignersToTransactionMessage, appendTransactionMessageInstructions, + createDefaultRpcTransport, createKeyPairSignerFromBytes, + createSolanaRpcFromTransport, createTransactionMessage, getBase64EncodedWireTransaction, getSignatureFromTransaction, + isSolanaError, pipe, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, signTransactionMessageWithSigners, + SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, type Address, type Instruction, type KeyPairSigner, type Rpc, + type RpcTransport, + type SolanaError, type SolanaRpcApi, } from "@solana/kit"; import { @@ -60,27 +65,72 @@ import { getSwigWalletAddress, } from "@swig-wallet/kit"; import { Actions, createEd25519AuthorityInfo } from "@swig-wallet/lib"; -import { - createRateLimitedSolanaRpc, - isRetryableRpcRateLimit, - resolveSwigRpcUrl, - signaturePollDelayMs, -} from "./svm-rpc-retry"; config(); +const DEVNET_RPC_URL = "https://api.devnet.solana.com"; const USDC_DEVNET_MINT = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; const MIN_AUTHORITY_SOL = 5_000_000n; /** Standard e2e exact endpoint price: $0.001 USDC (6 decimals). */ const E2E_EXACT_PAYMENT_BASE_UNITS = 1_000n; const SWIG_FUND_MULTIPLIER = 10n; +const RPC_RATE_LIMIT_ATTEMPTS = 6; +const RPC_RATE_LIMIT_BASE_DELAY_MS = 1_000; +const RPC_RATE_LIMIT_MAX_DELAY_MS = 30_000; -/** Poll signature status over HTTP RPC. Poll interval depends on the RPC host. */ -async function confirmTransaction( - rpc: Rpc, - rpcUrl: string, - signature: string, -): Promise { +function resolveRpcUrl(rpcUrl?: string): string { + const trimmed = rpcUrl?.trim(); + return trimmed || DEVNET_RPC_URL; +} + +function isRpcRateLimit( + error: unknown, +): error is SolanaError { + return ( + isSolanaError(error, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR) && + error.context.statusCode === 429 + ); +} + +function rpcRateLimitDelayMs(attempt: number, retryAfter: string | null): number { + const seconds = retryAfter?.trim(); + if (seconds && /^\d+$/.test(seconds)) { + const delay = Number(seconds) * 1_000; + if (delay > 0) { + return Math.min(delay, RPC_RATE_LIMIT_MAX_DELAY_MS); + } + } + return Math.min(RPC_RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt, RPC_RATE_LIMIT_MAX_DELAY_MS); +} + +/** Public devnet returns HTTP 429 under a setup burst. Resubmitting the same payload is idempotent. */ +function createRpc(rpcUrl?: string): Rpc { + const inner = createDefaultRpcTransport({ url: resolveRpcUrl(rpcUrl) }); + const transport: RpcTransport = async config => { + for (let attempt = 0; attempt < RPC_RATE_LIMIT_ATTEMPTS; attempt++) { + try { + return await inner(config); + } catch (error) { + if (!isRpcRateLimit(error) || attempt === RPC_RATE_LIMIT_ATTEMPTS - 1) { + throw error; + } + const delayMs = rpcRateLimitDelayMs(attempt, error.context.headers.get("retry-after")); + console.log( + `ā³ Solana RPC rate limited (429); retrying in ${delayMs}ms (${attempt + 1}/${RPC_RATE_LIMIT_ATTEMPTS - 1})`, + ); + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + } + throw new Error("Solana RPC rate limit retries exhausted"); + }; + return createSolanaRpcFromTransport(transport); +} + +/** Poll signature status over HTTP RPC (same approach as @x402/svm facilitator signer). */ +async function confirmTransaction(rpc: Rpc, signature: string): Promise { + const initialDelayMs = 250; + const initialWindowMs = 2_000; + const fallbackDelayMs = 1_000; const maxWaitMs = 30_000; const startedAt = Date.now(); @@ -95,7 +145,8 @@ async function confirmTransaction( return; } - const delay = signaturePollDelayMs(rpcUrl, Date.now() - startedAt); + const elapsed = Date.now() - startedAt; + const delay = elapsed < initialWindowMs ? initialDelayMs : fallbackDelayMs; await new Promise(resolve => setTimeout(resolve, delay)); } @@ -104,7 +155,6 @@ async function confirmTransaction( async function sendInstructions( rpc: Rpc, - rpcUrl: string, payer: KeyPairSigner, instructions: Instruction[], signers: KeyPairSigner[] = [], @@ -130,7 +180,7 @@ async function sendInstructions( .send(); const signature = getSignatureFromTransaction(signedTx); - await confirmTransaction(rpc, rpcUrl, signature); + await confirmTransaction(rpc, signature); return signature; } @@ -206,7 +256,6 @@ function printSwigEnvSummary(resolved: ResolvedSwigAccount): void { async function resolveSwigAccountAddress( rpc: Rpc, - rpcUrl: string, authority: KeyPairSigner, ): Promise { const fromEnv = process.env.SWIG_ACCOUNT_ADDRESS; @@ -232,7 +281,7 @@ async function resolveSwigAccountAddress( }); console.log(`šŸ”„ Creating Swig account ${swigAccountAddress}...`); - const sig = await sendInstructions(rpc, rpcUrl, authority, [createSwigIx as Instruction]); + const sig = await sendInstructions(rpc, authority, [createSwigIx as Instruction]); console.log(` āœ… Swig create tx: ${sig}`); return { @@ -244,7 +293,6 @@ async function resolveSwigAccountAddress( async function ensureSwigFunded( rpc: Rpc, - rpcUrl: string, authority: KeyPairSigner, swigAccountAddress: Address, mint: Address, @@ -282,22 +330,16 @@ async function ensureSwigFunded( try { await rpc.getTokenAccountBalance(authorityAta).send(); - } catch (error) { - if (isRetryableRpcRateLimit(error)) { - throw error; - } + } catch { console.log("šŸ”„ Creating authority USDC ATA..."); - await sendInstructions(rpc, rpcUrl, authority, [createAuthorityAtaIx]); + await sendInstructions(rpc, authority, [createAuthorityAtaIx]); } try { await rpc.getTokenAccountBalance(swigAta).send(); - } catch (error) { - if (isRetryableRpcRateLimit(error)) { - throw error; - } + } catch { console.log("šŸ”„ Creating Swig wallet USDC ATA..."); - await sendInstructions(rpc, rpcUrl, authority, [createSwigAtaIx]); + await sendInstructions(rpc, authority, [createSwigAtaIx]); } const swigBalance = await rpc.getTokenAccountBalance(swigAta).send(); @@ -332,7 +374,7 @@ async function ensureSwigFunded( }, { programAddress: tokenProgram }, ); - const sig = await sendInstructions(rpc, rpcUrl, authority, [fundIx]); + const sig = await sendInstructions(rpc, authority, [fundIx]); console.log(` āœ… Fund tx: ${sig}`); } @@ -343,20 +385,17 @@ async function main(): Promise { process.exit(1); } - const rpcUrl = resolveSwigRpcUrl({ - svmRpcUrl: process.env.SVM_RPC_URL, - svmTestnetRpcUrl: process.env.SVM_TESTNET_RPC_URL, - }); + const rpcUrl = resolveRpcUrl(process.env.SVM_RPC_URL); const mint = (process.env.SVM_USDC_MINT ?? USDC_DEVNET_MINT) as Address; - const rpc = createRateLimitedSolanaRpc(rpcUrl); + const rpc = createRpc(rpcUrl); const authority = await createKeyPairSignerFromBytes(base58.decode(privateKey)); console.log(`\nšŸ”‘ Authority: ${authority.address}`); console.log(`šŸ“ RPC: ${rpcUrl}`); console.log(`šŸ’° Mint: ${mint}\n`); - const resolved = await resolveSwigAccountAddress(rpc, rpcUrl, authority); - await ensureSwigFunded(rpc, rpcUrl, authority, resolved.address, mint); + const resolved = await resolveSwigAccountAddress(rpc, authority); + await ensureSwigFunded(rpc, authority, resolved.address, mint); persistSwigEnv(join(process.cwd(), ".env"), resolved); printSwigEnvSummary(resolved);