diff --git a/.env.example b/.env.example index a742f2ee..22dbfd47 100644 --- a/.env.example +++ b/.env.example @@ -93,6 +93,25 @@ VITE_TX_POLL_BACKOFF_FACTOR=1.25 # Example: VITE_TX_DEMO_CONFIRMATION_ATTEMPTS=2 VITE_TX_DEMO_CONFIRMATION_ATTEMPTS=2 +# VITE_TX_CONFIRMATION_MAX_RETRIES (Optional, Defaults to '15') +# Purpose: Maximum number of poll attempts in waitForTransaction before a timeout error is raised. +# Increase for slow networks (e.g. Testnet) where confirmation can take longer. +# Values outside [1, 300] are clamped. +# Maximum total wait ≈ VITE_TX_CONFIRMATION_MAX_RETRIES × VITE_TX_CONFIRMATION_DELAY_MS +# Status: Optional. +# Format: Positive integer in the range [1, 300]. +# Example: VITE_TX_CONFIRMATION_MAX_RETRIES=15 +VITE_TX_CONFIRMATION_MAX_RETRIES=15 + +# VITE_TX_CONFIRMATION_DELAY_MS (Optional, Defaults to '1500') +# Purpose: Milliseconds to wait between each waitForTransaction poll attempt. +# Values outside [100, 30000] are clamped to prevent runaway loops or RPC hammering. +# Maximum total wait ≈ VITE_TX_CONFIRMATION_MAX_RETRIES × VITE_TX_CONFIRMATION_DELAY_MS +# Status: Optional. +# Format: Positive integer in the range [100, 30000]. +# Example: VITE_TX_CONFIRMATION_DELAY_MS=1500 +VITE_TX_CONFIRMATION_DELAY_MS=1500 + # ------------------------------------------------------------------------------ # Additional runtime tuning # ------------------------------------------------------------------------------ diff --git a/docs/environment.md b/docs/environment.md index d7a8444b..3d81e029 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -97,3 +97,19 @@ These variables define the timing characteristics of the transaction-status conf - **Required/Optional**: Optional. Defaults to `2`. - **Format**: Positive integer greater than or equal to `1`. - **Example**: `2` + +### `VITE_TX_CONFIRMATION_MAX_RETRIES` +- **Purpose**: Maximum number of poll attempts `waitForTransaction` makes before raising a timeout error. Increase this on Testnet or slow networks where block confirmation can take longer than the default window. +- **Required/Optional**: Optional. Defaults to `15`. +- **Clamping**: Values outside `[1, 300]` are clamped to prevent an unbounded or zero-attempt loop. +- **Format**: Positive integer in the range `[1, 300]`. +- **Maximum total wait**: ≈ `VITE_TX_CONFIRMATION_MAX_RETRIES × VITE_TX_CONFIRMATION_DELAY_MS` +- **Example**: `15` (22.5 s at the default 1 500 ms delay) + +### `VITE_TX_CONFIRMATION_DELAY_MS` +- **Purpose**: Milliseconds to wait between each `waitForTransaction` confirmation poll attempt. Increase to reduce RPC traffic on slow networks; decrease to confirm faster in controlled environments. +- **Required/Optional**: Optional. Defaults to `1500`. +- **Clamping**: Values outside `[100, 30000]` are clamped to prevent instant hammering or excessively long pauses. +- **Format**: Positive integer in the range `[100, 30000]`. +- **Maximum total wait**: ≈ `VITE_TX_CONFIRMATION_MAX_RETRIES × VITE_TX_CONFIRMATION_DELAY_MS` +- **Example**: `1500` diff --git a/src/lib/__tests__/transactionConfig.test.ts b/src/lib/__tests__/transactionConfig.test.ts index dad9b38b..7c58a9c2 100644 --- a/src/lib/__tests__/transactionConfig.test.ts +++ b/src/lib/__tests__/transactionConfig.test.ts @@ -42,4 +42,143 @@ describe("transactionConfig", () => { const { transactionConfig } = await import("../transactionConfig"); expect(transactionConfig.baseFee).toBe(100); }); + + // ── confirmationMaxRetries ─────────────────────────────────────────────── + + describe("confirmationMaxRetries", () => { + it("defaults to 15 when VITE_TX_CONFIRMATION_MAX_RETRIES is unset", async () => { + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationMaxRetries).toBe(15); + }); + + it("reads a valid positive integer from env", async () => { + vi.stubEnv("VITE_TX_CONFIRMATION_MAX_RETRIES", "30"); + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationMaxRetries).toBe(30); + }); + + it("floors decimal values", async () => { + vi.stubEnv("VITE_TX_CONFIRMATION_MAX_RETRIES", "20.9"); + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationMaxRetries).toBe(20); + }); + + it("clamps to minimum of 1 when 0 is provided", async () => { + vi.stubEnv("VITE_TX_CONFIRMATION_MAX_RETRIES", "0"); + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationMaxRetries).toBe(1); + }); + + it("clamps to minimum of 1 when a negative value is provided", async () => { + vi.stubEnv("VITE_TX_CONFIRMATION_MAX_RETRIES", "-10"); + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationMaxRetries).toBe(1); + }); + + it("clamps to maximum of 300 when an excessively large value is provided", async () => { + vi.stubEnv("VITE_TX_CONFIRMATION_MAX_RETRIES", "9999"); + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationMaxRetries).toBe(300); + }); + + it("accepts the boundary value of 300", async () => { + vi.stubEnv("VITE_TX_CONFIRMATION_MAX_RETRIES", "300"); + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationMaxRetries).toBe(300); + }); + + it("accepts the boundary value of 1", async () => { + vi.stubEnv("VITE_TX_CONFIRMATION_MAX_RETRIES", "1"); + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationMaxRetries).toBe(1); + }); + + it("falls back to default 15 for a non-numeric string", async () => { + vi.stubEnv("VITE_TX_CONFIRMATION_MAX_RETRIES", "not-a-number"); + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationMaxRetries).toBe(15); + }); + }); + + // ── confirmationDelayMs ────────────────────────────────────────────────── + + describe("confirmationDelayMs", () => { + it("defaults to 1500 ms when VITE_TX_CONFIRMATION_DELAY_MS is unset", async () => { + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationDelayMs).toBe(1500); + }); + + it("reads a valid positive integer from env", async () => { + vi.stubEnv("VITE_TX_CONFIRMATION_DELAY_MS", "3000"); + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationDelayMs).toBe(3000); + }); + + it("floors decimal values", async () => { + vi.stubEnv("VITE_TX_CONFIRMATION_DELAY_MS", "1200.8"); + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationDelayMs).toBe(1200); + }); + + it("clamps to minimum of 100 when a value below the floor is provided", async () => { + vi.stubEnv("VITE_TX_CONFIRMATION_DELAY_MS", "50"); + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationDelayMs).toBe(100); + }); + + it("clamps to minimum of 100 when 0 is provided", async () => { + vi.stubEnv("VITE_TX_CONFIRMATION_DELAY_MS", "0"); + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationDelayMs).toBe(100); + }); + + it("clamps to minimum of 100 when a negative value is provided", async () => { + vi.stubEnv("VITE_TX_CONFIRMATION_DELAY_MS", "-500"); + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationDelayMs).toBe(100); + }); + + it("clamps to maximum of 30000 when an excessively large value is provided", async () => { + vi.stubEnv("VITE_TX_CONFIRMATION_DELAY_MS", "999999"); + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationDelayMs).toBe(30000); + }); + + it("accepts the boundary value of 30000", async () => { + vi.stubEnv("VITE_TX_CONFIRMATION_DELAY_MS", "30000"); + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationDelayMs).toBe(30000); + }); + + it("accepts the boundary value of 100", async () => { + vi.stubEnv("VITE_TX_CONFIRMATION_DELAY_MS", "100"); + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationDelayMs).toBe(100); + }); + + it("falls back to default 1500 for a non-numeric string", async () => { + vi.stubEnv("VITE_TX_CONFIRMATION_DELAY_MS", "bad-value"); + vi.resetModules(); + const { transactionConfig } = await import("../transactionConfig"); + expect(transactionConfig.confirmationDelayMs).toBe(1500); + }); + }); }); diff --git a/src/lib/stellar/__tests__/tx.test.ts b/src/lib/stellar/__tests__/tx.test.ts index 45979eaf..906b19bb 100644 --- a/src/lib/stellar/__tests__/tx.test.ts +++ b/src/lib/stellar/__tests__/tx.test.ts @@ -338,6 +338,116 @@ describe("Soroban transaction layer (tx.ts)", () => { } }); + // ── 4. Configurable confirmation budget ──────────────────────────────────── + + describe("waitForTransaction — configurable confirmation budget", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("respects a custom confirmationMaxRetries from transactionConfig", async () => { + vi.useFakeTimers(); + + const originalRetries = transactionConfig.confirmationMaxRetries; + transactionConfig.confirmationMaxRetries = 3; + + serverInstance.getTransaction.mockResolvedValue({ status: "NOT_FOUND" }); + + const promise = createStream(mockAddress, mockAddress, "1000", 100, 1000); + promise.catch(() => {}); + + for (let i = 0; i < 3; i++) { + await vi.advanceTimersByTimeAsync(transactionConfig.confirmationDelayMs); + } + + await expect(promise).rejects.toThrowError( + new TransactionError("timeout", "Transaction confirmation timed out. Please check your explorer."), + ); + + expect(serverInstance.getTransaction).toHaveBeenCalledTimes(3); + + transactionConfig.confirmationMaxRetries = originalRetries; + }); + + it("respects a custom confirmationDelayMs from transactionConfig", async () => { + vi.useFakeTimers(); + + const originalRetries = transactionConfig.confirmationMaxRetries; + const originalDelay = transactionConfig.confirmationDelayMs; + transactionConfig.confirmationMaxRetries = 2; + transactionConfig.confirmationDelayMs = 500; + + serverInstance.getTransaction.mockResolvedValue({ status: "NOT_FOUND" }); + + const promise = createStream(mockAddress, mockAddress, "1000", 100, 1000); + promise.catch(() => {}); + + // Should NOT time out after fewer than confirmationMaxRetries × delay ms + await vi.advanceTimersByTimeAsync(499); + expect(serverInstance.getTransaction).toHaveBeenCalledTimes(1); + + // Advance through the remaining retries + await vi.advanceTimersByTimeAsync(501); + await vi.advanceTimersByTimeAsync(500); + + await expect(promise).rejects.toThrowError( + new TransactionError("timeout", "Transaction confirmation timed out. Please check your explorer."), + ); + + expect(serverInstance.getTransaction).toHaveBeenCalledTimes(2); + + transactionConfig.confirmationMaxRetries = originalRetries; + transactionConfig.confirmationDelayMs = originalDelay; + }); + + it("succeeds when the transaction confirms within the configured budget", async () => { + vi.useFakeTimers(); + + const originalRetries = transactionConfig.confirmationMaxRetries; + const originalDelay = transactionConfig.confirmationDelayMs; + transactionConfig.confirmationMaxRetries = 5; + transactionConfig.confirmationDelayMs = 1000; + + serverInstance.getTransaction + .mockResolvedValueOnce({ status: "NOT_FOUND" }) + .mockResolvedValueOnce({ status: "NOT_FOUND" }) + .mockResolvedValueOnce({ status: "SUCCESS", txHash: "mock_tx_hash" }); + + const promise = createStream(mockAddress, mockAddress, "1000", 100, 1000); + + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + + const res = await promise; + expect(res.status).toBe("SUCCESS"); + expect(serverInstance.getTransaction).toHaveBeenCalledTimes(3); + + transactionConfig.confirmationMaxRetries = originalRetries; + transactionConfig.confirmationDelayMs = originalDelay; + }); + + it("uses default retries (15) when transactionConfig.confirmationMaxRetries is its default", async () => { + vi.useFakeTimers(); + + serverInstance.getTransaction.mockResolvedValue({ status: "NOT_FOUND" }); + + const promise = createStream(mockAddress, mockAddress, "1000", 100, 1000); + promise.catch(() => {}); + + // Advance 15 times at the default delay (1500ms) + for (let i = 0; i < 15; i++) { + await vi.advanceTimersByTimeAsync(1500); + } + + await expect(promise).rejects.toThrowError( + new TransactionError("timeout", "Transaction confirmation timed out. Please check your explorer."), + ); + + expect(serverInstance.getTransaction).toHaveBeenCalledTimes(15); + }); + }); + // ── 4. withTimeout helper ────────────────────────────────────────────────── describe("withTimeout helper", () => { diff --git a/src/lib/stellar/tx.ts b/src/lib/stellar/tx.ts index 5150c069..bae952b2 100644 --- a/src/lib/stellar/tx.ts +++ b/src/lib/stellar/tx.ts @@ -138,12 +138,21 @@ async function validateNetwork(): Promise { /** * Helper to wait for a transaction to be confirmed on-chain by polling the Soroban RPC. + * + * Retry budget and inter-poll delay are read from {@link transactionConfig} so + * they can be tuned per environment via env vars without code edits: + * - `VITE_TX_CONFIRMATION_MAX_RETRIES` — maximum poll attempts (default: 15, clamped 1–300) + * - `VITE_TX_CONFIRMATION_DELAY_MS` — ms between attempts (default: 1500, clamped 100–30000) + * + * **Maximum total wait** ≈ `confirmationMaxRetries × confirmationDelayMs` + * + * Safe defaults are applied in case `transactionConfig` values are somehow absent. */ async function waitForTransaction( server: SorobanRpc.Server, hash: string, - maxRetries = 15, - delayMs = 1500 + maxRetries = transactionConfig.confirmationMaxRetries ?? 15, + delayMs = transactionConfig.confirmationDelayMs ?? 1500 ): Promise { for (let i = 0; i < maxRetries; i++) { try { diff --git a/src/lib/transactionConfig.ts b/src/lib/transactionConfig.ts index e432ec56..a843eab2 100644 --- a/src/lib/transactionConfig.ts +++ b/src/lib/transactionConfig.ts @@ -19,6 +19,18 @@ function readPositiveNumber( return parsed; } +/** + * Parse a numeric env var without a range constraint. Returns `fallback` + * only when the value is absent or non-numeric so that explicit out-of-range + * values can be clamped by the caller rather than silently falling back. + */ +function readFiniteNumber(key: keyof ImportMetaEnv, fallback: number): number { + const raw = import.meta.env[key]; + if (typeof raw !== "string" || raw.trim() === "") return fallback; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : fallback; +} + /** * Runtime knobs for transaction-status polling. * @@ -52,8 +64,78 @@ export const transactionPollingConfig = { const DEFAULT_BASE_FEE = 100; +/** Minimum allowed value for {@link transactionConfig.confirmationMaxRetries}. */ +const CONFIRMATION_MAX_RETRIES_MIN = 1; +/** Maximum allowed value for {@link transactionConfig.confirmationMaxRetries}. */ +const CONFIRMATION_MAX_RETRIES_MAX = 300; +/** Default number of retry attempts for {@link waitForTransaction}. */ +const DEFAULT_CONFIRMATION_MAX_RETRIES = 15; + +/** Minimum allowed value (ms) for {@link transactionConfig.confirmationDelayMs}. */ +const CONFIRMATION_DELAY_MS_MIN = 100; +/** Maximum allowed value (ms) for {@link transactionConfig.confirmationDelayMs}. */ +const CONFIRMATION_DELAY_MS_MAX = 30_000; +/** Default delay in ms between confirmation poll attempts. */ +const DEFAULT_CONFIRMATION_DELAY_MS = 1_500; + +/** + * Runtime knobs for on-chain transaction confirmation polling + * (`waitForTransaction`). + * + * These are client-side timing controls only. A confirmed status must still + * come from the configured transaction status source. + */ export const transactionConfig = { baseFee: Math.floor( readPositiveNumber("VITE_TX_BASE_FEE", DEFAULT_BASE_FEE, { min: 0 }) ), + + /** + * Maximum number of retry attempts when polling for transaction confirmation. + * + * Read from `VITE_TX_CONFIRMATION_MAX_RETRIES`. Values outside + * `[1, 300]` are clamped to keep the confirmation budget bounded. + * + * **Maximum total wait** (approx.) = + * `confirmationMaxRetries × confirmationDelayMs` + * + * Default: `15` (22.5 s at the default 1 500 ms delay). + */ + confirmationMaxRetries: Math.min( + CONFIRMATION_MAX_RETRIES_MAX, + Math.max( + CONFIRMATION_MAX_RETRIES_MIN, + Math.floor( + readFiniteNumber( + "VITE_TX_CONFIRMATION_MAX_RETRIES", + DEFAULT_CONFIRMATION_MAX_RETRIES, + ), + ), + ), + ), + + /** + * Delay in milliseconds between each confirmation poll attempt. + * + * Read from `VITE_TX_CONFIRMATION_DELAY_MS`. Values outside + * `[100, 30 000]` are clamped to prevent runaway loops or effectively + * instant hammering of the RPC endpoint. + * + * **Maximum total wait** (approx.) = + * `confirmationMaxRetries × confirmationDelayMs` + * + * Default: `1 500` ms. + */ + confirmationDelayMs: Math.min( + CONFIRMATION_DELAY_MS_MAX, + Math.max( + CONFIRMATION_DELAY_MS_MIN, + Math.floor( + readFiniteNumber( + "VITE_TX_CONFIRMATION_DELAY_MS", + DEFAULT_CONFIRMATION_DELAY_MS, + ), + ), + ), + ), }; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 1bcfd04b..5c7a258d 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -12,6 +12,16 @@ interface ImportMetaEnv { readonly VITE_TX_DEMO_CONFIRMATION_ATTEMPTS?: string; readonly VITE_DEMO_MODE?: string; readonly VITE_TX_BASE_FEE?: string; + /** + * Maximum number of retry attempts for `waitForTransaction` confirmation + * polling. Clamped to [1, 300]. Defaults to `15`. + */ + readonly VITE_TX_CONFIRMATION_MAX_RETRIES?: string; + /** + * Delay in milliseconds between each `waitForTransaction` confirmation poll. + * Clamped to [100, 30000]. Defaults to `1500`. + */ + readonly VITE_TX_CONFIRMATION_DELAY_MS?: string; /** * How often (in ms) WatchWalletChanges polls the Freighter extension for * account/network changes. Defaults to 2000 ms. Must be ≥ 500 ms.