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
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ------------------------------------------------------------------------------
Expand Down
16 changes: 16 additions & 0 deletions docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
139 changes: 139 additions & 0 deletions src/lib/__tests__/transactionConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
110 changes: 110 additions & 0 deletions src/lib/stellar/__tests__/tx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
13 changes: 11 additions & 2 deletions src/lib/stellar/tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,21 @@ async function validateNetwork(): Promise<void> {

/**
* 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<SorobanRpc.Api.GetTransactionResponse> {
for (let i = 0; i < maxRetries; i++) {
try {
Expand Down
Loading