diff --git a/src/integration/index.ts b/src/integration/index.ts index 05f0015..09d2a4e 100644 --- a/src/integration/index.ts +++ b/src/integration/index.ts @@ -90,10 +90,18 @@ export { pruneExpiredChallenges, getPendingChallengesCount, clearPendingChallenges, + generateSep10Nonce, + generateSep10Challenge, + buildSep10Challenge, + buildSep10ChallengeTx, CHALLENGE_TTL_MS, MAX_PENDING_CHALLENGES, } from "./sep10Auth"; -export type { AuthToken, InitiateSep10AuthOptions } from "./sep10Auth"; +export type { + AuthToken, + InitiateSep10AuthOptions, + GenerateSep10ChallengeOptions, +} from "./sep10Auth"; export { initiateDeposit, initiateWithdraw, diff --git a/src/integration/sep10Auth.ts b/src/integration/sep10Auth.ts index ccd218d..68698da 100644 --- a/src/integration/sep10Auth.ts +++ b/src/integration/sep10Auth.ts @@ -1,8 +1,18 @@ -import { StrKey, TransactionBuilder, WebAuth } from "@stellar/stellar-sdk"; +import { + Account, + BASE_FEE, + Keypair, + Networks, + Operation, + StrKey, + TransactionBuilder, + WebAuth, +} from "@stellar/stellar-sdk"; import type { Transaction } from "@stellar/stellar-sdk"; import { err, ok, SorokitErrorCode } from "../shared/response"; import type { SorokitResult } from "../shared/response"; import { fetchStellarToml } from "./sep1Toml"; +import { crypto } from "../shared/environment"; export interface InitiateSep10AuthOptions { networkPassphrase?: string; @@ -312,3 +322,131 @@ export async function completeSep10Auth( return err(SorokitErrorCode.NETWORK_ERROR, "Failed to complete SEP-10 authentication.", cause); } } + +/** + * Generate a cryptographically secure random nonce for SEP-10 challenge generation (#861). + * + * Uses the environment's CSPRNG (Web Crypto API getRandomValues or Node.js crypto.randomBytes). + * Format: 48 bytes (or custom length) providing 384+ bits of cryptographic entropy. + * + * @param length - Nonce length in bytes (default: 48 bytes per SEP-10 standard). + */ +export function generateSep10Nonce(length = 48): Buffer { + const bytes = crypto.randomBytes(length); + return Buffer.from(bytes); +} + +export interface GenerateSep10ChallengeOptions { + /** Lifetime of the challenge in seconds (default: 300, 5 minutes per SEP-10). */ + timeoutSec?: number; + /** Stellar network passphrase (defaults to Testnet). */ + networkPassphrase?: string; + /** Optional secondary web auth domain if different from homeDomain. */ + webAuthDomain?: string; + /** Optional client domain for custodial/managed clients. */ + clientDomain?: string; + /** Length of the random nonce in bytes (default: 48 bytes). */ + nonceLength?: number; + /** Explicit pre-generated nonce buffer (optional). */ + nonce?: Buffer; +} + +/** + * Generate a cryptographically random, replay-resistant SEP-10 challenge transaction (#861). + * + * Generates a fresh cryptographically secure random nonce for every challenge transaction, + * ensuring challenge uniqueness and preventing replay attacks. + * + * @param serverKey - Keypair of the server signing the challenge. + * @param clientAccountId - Stellar public key (G...) of the authenticating client. + * @param homeDomain - Domain name of the service (e.g. "example.com"). + * @param options - Configuration options for timeout, domains, and nonce length. + */ +export function generateSep10Challenge( + serverKey: Keypair, + clientAccountId: string, + homeDomain: string, + options: GenerateSep10ChallengeOptions = {}, +): SorokitResult { + if (!StrKey.isValidEd25519PublicKey(clientAccountId)) { + return err( + SorokitErrorCode.INVALID_ADDRESS, + "A valid client Stellar public key (G...) is required for SEP-10 challenge generation.", + ); + } + + if (!homeDomain || typeof homeDomain !== "string" || !homeDomain.trim()) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "homeDomain must be a non-empty string.", + ); + } + + const networkPassphrase = + options.networkPassphrase ?? Networks.TESTNET; + const timeoutSec = options.timeoutSec ?? 300; + const nonceLength = options.nonceLength ?? 48; + + try { + const nonce = options.nonce ?? generateSep10Nonce(nonceLength); + const now = Math.floor(Date.now() / 1000); + const minTime = now - 300; + const maxTime = now + timeoutSec; + + const serverAccount = new Account(serverKey.publicKey(), "-1"); + const txBuilder = new TransactionBuilder(serverAccount, { + fee: BASE_FEE, + networkPassphrase, + timebounds: { minTime, maxTime }, + }); + + // Operation 1: ManageData on client account with unique random nonce + txBuilder.addOperation( + Operation.manageData({ + source: clientAccountId, + name: `${homeDomain} auth`, + value: nonce, + }), + ); + + // Optional Operation 2: web_auth_domain + if (options.webAuthDomain && options.webAuthDomain !== homeDomain) { + txBuilder.addOperation( + Operation.manageData({ + source: serverKey.publicKey(), + name: "web_auth_domain", + value: Buffer.from(options.webAuthDomain), + }), + ); + } + + // Optional Operation 3: client_domain + if (options.clientDomain) { + txBuilder.addOperation( + Operation.manageData({ + source: clientAccountId, + name: "client_domain", + value: Buffer.from(options.clientDomain), + }), + ); + } + + const tx = txBuilder.build(); + tx.sign(serverKey); + + return ok(tx.toXDR()); + } catch (cause) { + return err( + SorokitErrorCode.TX_BUILD_FAILED, + `Failed to generate SEP-10 challenge: ${String(cause)}`, + cause, + ); + } +} + +/** Backward-compatible alias for generateSep10Challenge. */ +export const buildSep10Challenge = generateSep10Challenge; + +/** Backward-compatible alias for generateSep10Challenge. */ +export const buildSep10ChallengeTx = generateSep10Challenge; + diff --git a/src/soroban/contractErrors.ts b/src/soroban/contractErrors.ts index fffc91b..dbe184d 100644 --- a/src/soroban/contractErrors.ts +++ b/src/soroban/contractErrors.ts @@ -5,6 +5,7 @@ * (factory/router) are plain data so applications can supply their own maps * without modifying the core decoder. */ +import { SorobanErrorDecoder } from "./sorobanErrorDecoder"; export interface ContractErrorInfo { /** Human-readable description of what went wrong. */ @@ -328,6 +329,16 @@ export function decodeContractError( } } + const panic = SorobanErrorDecoder.decode(error); + if (panic.isPanic) { + return { + decoded: true, + code: candidates[0] ?? (panic.code ? String(panic.code) : null), + message: panic.description, + raw: error, + }; + } + if (candidates.length > 0) { return { decoded: false, diff --git a/src/soroban/index.ts b/src/soroban/index.ts index 2132f5a..ea1a122 100644 --- a/src/soroban/index.ts +++ b/src/soroban/index.ts @@ -133,6 +133,14 @@ export type { ContractErrorMap, DecodedContractError, } from "./contractErrors"; +export { + SorobanErrorDecoder, + decodeSorobanPanic, +} from "./sorobanErrorDecoder"; +export type { + DecodedPanic, + PanicType, +} from "./sorobanErrorDecoder"; export { ContractInteractionBuilder, createContractInteractionBuilder } from "./contractInteractionBuilder"; export type { ContractInteractionBuilderConfig, diff --git a/src/soroban/sorobanErrorDecoder.ts b/src/soroban/sorobanErrorDecoder.ts new file mode 100644 index 0000000..d3fac06 --- /dev/null +++ b/src/soroban/sorobanErrorDecoder.ts @@ -0,0 +1,219 @@ +/** + * Soroban panic and VM trap decoder (#859). + * + * Decodes cryptic serialized Soroban panics, WebAssembly traps, and host errors + * into human-readable descriptions, enabling easier contract debugging. + */ + +export type PanicType = + | "wasm_trap" + | "host_trap" + | "rust_panic" + | "budget_exceeded" + | "auth_failed" + | "storage_error" + | "unknown"; + +export interface DecodedPanic { + /** True when a recognized or generic panic/trap format was detected. */ + isPanic: boolean; + /** Categorized panic/trap type. */ + type: PanicType; + /** Human-readable explanation of the panic or trap. */ + description: string; + /** Extracted contract or host error code if present. */ + code?: string | number; + /** Original unparsed error or message. */ + raw: unknown; +} + +/** Known WebAssembly trap message patterns and their readable descriptions. */ +const KNOWN_WASM_TRAPS: Array<{ pattern: RegExp; description: string }> = [ + { + pattern: /unreachable/i, + description: "WebAssembly unreachable instruction executed (contract assertion failed or explicit panic).", + }, + { + pattern: /(integer\s+overflow|arithmetic\s+overflow|overflow\s+during\s+arithmetic|attempt to (add|subtract|multiply|divide) with overflow)/i, + description: "Arithmetic overflow or underflow detected during contract execution.", + }, + { + pattern: /(division\s+by\s+zero|divide\s+by\s+zero|remainder\s+by\s+zero)/i, + description: "Division or remainder by zero in contract execution.", + }, + { + pattern: /(out\s+of\s+bounds|memory\s+access\s+out\s+of\s+bounds|table\s+index\s+out\s+of\s+bounds)/i, + description: "WebAssembly memory or table access out of bounds.", + }, + { + pattern: /(stack\s+overflow|call\s+stack\s+exhausted|call\s+stack\s+overflow)/i, + description: "WebAssembly call stack exhausted (deep recursion or excessive stack frames).", + }, + { + pattern: /unaligned\s+memory/i, + description: "Unaligned memory access in WebAssembly execution.", + }, + { + pattern: /indirect\s+call\s+type\s+mismatch/i, + description: "Indirect function call type mismatch in WebAssembly module.", + }, +]; + +/** Known host error patterns and their readable descriptions. */ +const KNOWN_HOST_TRAPS: Array<{ pattern: RegExp; description: string; type: PanicType }> = [ + { + pattern: /Error\s*\(\s*WasmVm\s*,\s*(?:Trapped|#\d+)\s*\)/i, + description: "WebAssembly VM execution trapped.", + type: "wasm_trap", + }, + { + pattern: /Error\s*\(\s*Context\s*,\s*(?:Trap|#\d+)\s*\)/i, + description: "Soroban host context trap occurred during contract execution.", + type: "host_trap", + }, + { + pattern: /Error\s*\(\s*Budget\s*,\s*(?:ExceededLimit|#\d+)\s*\)|budget\s+exceeded|cpu\s+instructions\s+exceeded|memory\s+bytes\s+exceeded/i, + description: "Soroban compute or memory resource budget exceeded.", + type: "budget_exceeded", + }, + { + pattern: /Error\s*\(\s*Storage\s*,\s*(?:MissingValue|#\d+)\s*\)|storage\s+entry\s+not\s+found/i, + description: "Contract attempted to access missing or expired storage key.", + type: "storage_error", + }, + { + pattern: /Error\s*\(\s*Auth\s*,\s*(?:Invalid|#\d+)\s*\)|authorization\s+failed|bad\s+auth/i, + description: "Contract invocation authorization failed or invalid credentials.", + type: "auth_failed", + }, +]; + +/** Matches Rust panic text, e.g. `panicked at 'assertion failed: x > 0', src/lib.rs:42:5`. */ +const RUST_PANIC_REGEX = /panicked at (?:'([^']+)'|([^,\n]+))(?:,\s*([^\n]+))?/i; + +/** Matches Soroban Contract custom error codes, e.g. `Error(Contract, #123)`. */ +const CONTRACT_CODE_REGEX = /Error\s*\(\s*Contract\s*,\s*#?(-?\d+)\s*\)/i; + +function extractRawString(error: unknown): string { + if (typeof error === "string") return error; + if (error instanceof Error) return error.message; + if (error !== null && typeof error === "object") { + const obj = error as Record; + if (typeof obj.message === "string") return obj.message; + if (typeof obj.error === "string") return obj.error; + if (obj.error && typeof (obj.error as Record).message === "string") { + return (obj.error as Record).message as string; + } + try { + return JSON.stringify(error); + } catch { + return String(error); + } + } + return String(error ?? ""); +} + +/** + * SorobanErrorDecoder decodes Soroban panic and VM trap messages into readable descriptions. + */ +export class SorobanErrorDecoder { + /** + * Determine whether an error represents a Soroban panic or trap. + */ + static isPanic(error: unknown): boolean { + const raw = extractRawString(error); + if (!raw) return false; + if (/panic|trap|unreachable|overflow|hosterror|error\s*\(\s*(wasmvm|context|budget|contract|storage|auth)/i.test(raw)) { + return true; + } + return false; + } + + /** + * Decode a Soroban panic or VM trap into structured info. + * If the panic format is unknown, falls back to a generic invocation failed message. + */ + static decode(error: unknown): DecodedPanic { + const rawStr = extractRawString(error); + + // 1. Check for contract error code: Error(Contract, #123) + const codeMatch = CONTRACT_CODE_REGEX.exec(rawStr); + const code = codeMatch?.[1]; + + // 2. Check for Rust panic syntax: panicked at '...' + const rustMatch = RUST_PANIC_REGEX.exec(rawStr); + if (rustMatch) { + const reason = rustMatch[1] || rustMatch[2] || "explicit panic"; + const location = rustMatch[3] ? ` at ${rustMatch[3]}` : ""; + return { + isPanic: true, + type: "rust_panic", + description: `Contract panicked${location}: "${reason.trim()}"`, + ...(code !== undefined ? { code } : {}), + raw: error, + }; + } + + // 3. Check for specific WASM VM traps + for (const trap of KNOWN_WASM_TRAPS) { + if (trap.pattern.test(rawStr)) { + return { + isPanic: true, + type: "wasm_trap", + description: trap.description, + ...(code !== undefined ? { code } : {}), + raw: error, + }; + } + } + + // 4. Check for known Host error codes / traps + for (const host of KNOWN_HOST_TRAPS) { + if (host.pattern.test(rawStr)) { + return { + isPanic: true, + type: host.type, + description: host.description, + ...(code !== undefined ? { code } : {}), + raw: error, + }; + } + } + + // 5. Check if it explicitly contains "panic" or "trap" or "HostError" + if (this.isPanic(error)) { + return { + isPanic: true, + type: "unknown", + description: rawStr + ? `Contract trapped with panic: ${rawStr}` + : "Contract invocation failed (unknown panic format).", + ...(code !== undefined ? { code } : {}), + raw: error, + }; + } + + // 6. Fallback for generic/unknown errors + return { + isPanic: false, + type: "unknown", + description: rawStr || "Contract invocation failed.", + ...(code !== undefined ? { code } : {}), + raw: error, + }; + } + + /** + * Convenience helper to return just the human-readable decoded description. + */ + static decodePanic(error: unknown): string { + return this.decode(error).description; + } +} + +/** + * Functional wrapper for SorobanErrorDecoder.decode. + */ +export function decodeSorobanPanic(error: unknown): DecodedPanic { + return SorobanErrorDecoder.decode(error); +} diff --git a/src/tests/feeBumpTransaction.test.ts b/src/tests/feeBumpTransaction.test.ts index fd4af74..5c89eac 100644 --- a/src/tests/feeBumpTransaction.test.ts +++ b/src/tests/feeBumpTransaction.test.ts @@ -149,6 +149,36 @@ describe("buildFeeBumpTransaction (#398)", () => { ); expect(result.status).toBe("error"); expect(result.error?.code).toBe("TX_BUILD_FAILED"); + expect(result.error?.message).toContain("must be higher than the inner transaction fee"); + }); + + it("rejects a base fee equal to the inner transaction's base fee (downgrade prevention)", () => { + const source = Keypair.random(); + const account = new Account(source.publicKey(), "0"); + const tx = new TransactionBuilder(account, { + fee: "200", + networkPassphrase: PASSPHRASE, + }) + .addOperation( + Operation.payment({ + destination: Keypair.random().publicKey(), + asset: Asset.native(), + amount: "1", + }), + ) + .setTimeout(30) + .build(); + tx.sign(source); + + const result = buildFeeBumpTransaction( + tx.toXDR(), + feeAccount, + "200", + PASSPHRASE, + ); + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("TX_BUILD_FAILED"); + expect(result.error?.message).toContain("must be higher than the inner transaction fee"); }); it("rejects wrapping an existing fee-bump transaction", () => { diff --git a/src/tests/sep10ChallengeNonce.test.ts b/src/tests/sep10ChallengeNonce.test.ts new file mode 100644 index 0000000..8a7640a --- /dev/null +++ b/src/tests/sep10ChallengeNonce.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { Keypair, Networks, WebAuth } from "@stellar/stellar-sdk"; +import { + generateSep10Nonce, + generateSep10Challenge, +} from "../integration/sep10Auth"; + +describe("SEP-10 challenge generation & nonce randomization (#861)", () => { + const serverKey = Keypair.random(); + const clientKey = Keypair.random(); + const homeDomain = "example.com"; + const networkPassphrase = Networks.TESTNET; + + it("generates cryptographically secure nonces of specified length", () => { + const nonce1 = generateSep10Nonce(); + const nonce2 = generateSep10Nonce(); + + expect(nonce1).toBeInstanceOf(Buffer); + expect(nonce1.length).toBe(48); // 48 bytes per SEP-10 + expect(nonce2.length).toBe(48); + expect(nonce1.equals(nonce2)).toBe(false); // Unique + }); + + it("generates unique challenge transactions preventing replay attacks", () => { + const challenges = new Set(); + const nonces = new Set(); + + for (let i = 0; i < 25; i++) { + const res = generateSep10Challenge(serverKey, clientKey.publicKey(), homeDomain, { + networkPassphrase, + }); + + expect(res.status).toBe("ok"); + if (res.status !== "ok") return; + + expect(challenges.has(res.data)).toBe(false); + challenges.add(res.data); + + const parsed = WebAuth.readChallengeTx( + res.data, + serverKey.publicKey(), + networkPassphrase, + homeDomain, + homeDomain, + ); + + // Verify the parsed challenge + expect(parsed.clientAccountID).toBe(clientKey.publicKey()); + const op = parsed.tx.operations[0] as { value?: Buffer | string }; + const nonceStr = Buffer.isBuffer(op?.value) + ? op.value.toString("base64") + : String(op?.value); + expect(nonces.has(nonceStr)).toBe(false); + nonces.add(nonceStr); + } + + expect(challenges.size).toBe(25); + expect(nonces.size).toBe(25); + }); + + it("produces challenges verifiable by WebAuth.readChallengeTx and signed by server", () => { + const res = generateSep10Challenge(serverKey, clientKey.publicKey(), homeDomain, { + networkPassphrase, + timeoutSec: 600, + }); + + expect(res.status).toBe("ok"); + if (res.status !== "ok") return; + + const parsed = WebAuth.readChallengeTx( + res.data, + serverKey.publicKey(), + networkPassphrase, + homeDomain, + homeDomain, + ); + + expect(parsed.clientAccountID).toBe(clientKey.publicKey()); + expect(WebAuth.verifyTxSignedBy(parsed.tx, serverKey.publicKey())).toBe(true); + }); + + it("rejects an invalid client public key", () => { + const res = generateSep10Challenge(serverKey, "not-a-valid-key", homeDomain); + expect(res.status).toBe("error"); + if (res.status === "error") { + expect(res.error.code).toBe("INVALID_ADDRESS"); + } + }); + + it("rejects an empty homeDomain", () => { + const res = generateSep10Challenge(serverKey, clientKey.publicKey(), ""); + expect(res.status).toBe("error"); + if (res.status === "error") { + expect(res.error.code).toBe("INVALID_CONFIG"); + } + }); +}); diff --git a/src/tests/sorobanErrorDecoder.test.ts b/src/tests/sorobanErrorDecoder.test.ts new file mode 100644 index 0000000..8ae82ff --- /dev/null +++ b/src/tests/sorobanErrorDecoder.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest"; +import { + SorobanErrorDecoder, + decodeSorobanPanic, +} from "../soroban/sorobanErrorDecoder"; +import { decodeContractError } from "../soroban/contractErrors"; + +describe("SorobanErrorDecoder (#859)", () => { + describe("WebAssembly traps", () => { + it("decodes unreachable instruction traps", () => { + const err = new Error("VM call trapped: unreachable"); + const decoded = SorobanErrorDecoder.decode(err); + expect(decoded.isPanic).toBe(true); + expect(decoded.type).toBe("wasm_trap"); + expect(decoded.description).toContain("unreachable"); + }); + + it("decodes integer overflow traps", () => { + const err = "HostError: Error(WasmVm, Trapped) attempt to add with overflow"; + const decoded = decodeSorobanPanic(err); + expect(decoded.isPanic).toBe(true); + expect(decoded.description).toContain("overflow"); + }); + + it("decodes division by zero traps", () => { + const err = "division by zero"; + const decoded = SorobanErrorDecoder.decode(err); + expect(decoded.isPanic).toBe(true); + expect(decoded.description).toContain("Division or remainder by zero"); + }); + + it("decodes out of bounds memory access", () => { + const err = "memory access out of bounds"; + const decoded = SorobanErrorDecoder.decode(err); + expect(decoded.isPanic).toBe(true); + expect(decoded.description).toContain("out of bounds"); + }); + + it("decodes call stack exhaustion", () => { + const err = "call stack exhausted"; + const decoded = SorobanErrorDecoder.decode(err); + expect(decoded.isPanic).toBe(true); + expect(decoded.description).toContain("stack exhausted"); + }); + }); + + describe("Host error traps", () => { + it("decodes WasmVm trap host error", () => { + const err = "HostError: Error(WasmVm, Trapped)"; + const decoded = SorobanErrorDecoder.decode(err); + expect(decoded.isPanic).toBe(true); + expect(decoded.type).toBe("wasm_trap"); + expect(decoded.description).toContain("WebAssembly VM execution trapped"); + }); + + it("decodes host context trap", () => { + const err = "HostError: Error(Context, Trap)"; + const decoded = SorobanErrorDecoder.decode(err); + expect(decoded.isPanic).toBe(true); + expect(decoded.type).toBe("host_trap"); + expect(decoded.description).toContain("host context trap"); + }); + + it("decodes budget exceeded error", () => { + const err = "Error(Budget, ExceededLimit) cpu instructions exceeded"; + const decoded = SorobanErrorDecoder.decode(err); + expect(decoded.isPanic).toBe(true); + expect(decoded.type).toBe("budget_exceeded"); + expect(decoded.description).toContain("budget exceeded"); + }); + + it("decodes missing storage key error", () => { + const err = "Error(Storage, MissingValue)"; + const decoded = SorobanErrorDecoder.decode(err); + expect(decoded.isPanic).toBe(true); + expect(decoded.type).toBe("storage_error"); + expect(decoded.description).toContain("missing or expired storage key"); + }); + + it("decodes authorization failure", () => { + const err = "Error(Auth, Invalid)"; + const decoded = SorobanErrorDecoder.decode(err); + expect(decoded.isPanic).toBe(true); + expect(decoded.type).toBe("auth_failed"); + expect(decoded.description).toContain("authorization failed"); + }); + }); + + describe("Rust panic strings", () => { + it("decodes rust panic with message and source location", () => { + const err = "panicked at 'insufficient balance: required 100, available 10', src/token.rs:88:9"; + const decoded = SorobanErrorDecoder.decode(err); + expect(decoded.isPanic).toBe(true); + expect(decoded.type).toBe("rust_panic"); + expect(decoded.description).toContain("insufficient balance: required 100, available 10"); + expect(decoded.description).toContain("src/token.rs:88:9"); + }); + + it("extracts custom contract code when present", () => { + const err = "HostError: Error(Contract, #42) panicked at 'unauthorized access'"; + const decoded = SorobanErrorDecoder.decode(err); + expect(decoded.isPanic).toBe(true); + expect(decoded.code).toBe("42"); + expect(decoded.description).toContain("unauthorized access"); + }); + }); + + describe("Fallback behavior", () => { + it("falls back gracefully for unknown panic format", () => { + const err = "Unknown Soroban panic 0xdeadbeef"; + const decoded = SorobanErrorDecoder.decode(err); + expect(decoded.isPanic).toBe(true); + expect(decoded.type).toBe("unknown"); + expect(decoded.description).toContain("Contract trapped with panic"); + }); + + it("returns non-panic status for standard errors", () => { + const err = new Error("Network request timed out"); + const decoded = SorobanErrorDecoder.decode(err); + expect(decoded.isPanic).toBe(false); + expect(decoded.description).toBe("Network request timed out"); + }); + + it("integrates seamlessly with decodeContractError", () => { + const panicErr = new Error("VM call trapped: unreachable"); + const decoded = decodeContractError(panicErr); + expect(decoded.decoded).toBe(true); + expect(decoded.message).toContain("unreachable"); + }); + }); +}); diff --git a/src/tests/transactionSigningSession.test.ts b/src/tests/transactionSigningSession.test.ts new file mode 100644 index 0000000..45c3a9b --- /dev/null +++ b/src/tests/transactionSigningSession.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + TransactionSigningSession, + DEFAULT_SIGNING_SESSION_TIMEOUT_MS, + signTransactionWithSession, +} from "../wallet/transactionSigningSession"; +import { WalletType, type WalletAdapter } from "../wallet/types"; +import { ok } from "../shared/response"; + +function mockAdapter(delayMs = 0, returnValue = "signed-xdr"): WalletAdapter { + return { + walletType: WalletType.FREIGHTER, + isAvailable: () => true, + connect: async () => ok("GAAAA"), + disconnect: async () => ok(undefined), + signTransaction: vi.fn( + () => + new Promise((resolve) => { + setTimeout(() => resolve(ok(returnValue)), delayMs); + }), + ), + }; +} + +describe("TransactionSigningSession (#860)", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("has a default timeout duration of at least 5 minutes", () => { + const session = new TransactionSigningSession(); + expect(DEFAULT_SIGNING_SESSION_TIMEOUT_MS).toBeGreaterThanOrEqual(5 * 60 * 1000); + expect(session.getTimeoutMs()).toBe(DEFAULT_SIGNING_SESSION_TIMEOUT_MS); + }); + + it("times out and returns an OPERATION_TIMEOUT SorokitResult error", async () => { + const adapter = mockAdapter(10_000); // Takes 10s, but session timeout is 2s + const onTimeout = vi.fn(); + const session = new TransactionSigningSession({ + timeoutMs: 2_000, + onTimeout, + }); + + const promise = session.start(adapter, { + transactionXdr: "AAAA", + networkPassphrase: "Test SDF Network ; September 2015", + }); + + expect(session.getStatus()).toBe("pending"); + + // Advance clock past 2 seconds + vi.advanceTimersByTime(2_100); + + const result = await promise; + expect(result.status).toBe("error"); + if (result.status === "error") { + expect(result.error.code).toBe("OPERATION_TIMEOUT"); + expect(result.error.message).toContain("timed out after 2 seconds"); + } + expect(session.getStatus()).toBe("timed_out"); + expect(onTimeout).toHaveBeenCalledTimes(1); + }); + + it("completes successfully if wallet responds before timeout", async () => { + const adapter = mockAdapter(500, "success-xdr"); + const session = new TransactionSigningSession({ timeoutMs: 5_000 }); + + const promise = session.start(adapter, { + transactionXdr: "AAAA", + networkPassphrase: "Test SDF Network ; September 2015", + }); + + vi.advanceTimersByTime(600); + + const result = await promise; + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.data).toBe("success-xdr"); + } + expect(session.getStatus()).toBe("completed"); + }); + + it("allows callers to cancel the pending session", async () => { + const adapter = mockAdapter(10_000); + const onCancel = vi.fn(); + const session = new TransactionSigningSession({ + timeoutMs: 5_000, + onCancel, + }); + + const promise = session.start(adapter, { + transactionXdr: "AAAA", + networkPassphrase: "Test SDF Network ; September 2015", + }); + + expect(session.getStatus()).toBe("pending"); + + const cancelResult = session.cancel("User closed prompt"); + expect(cancelResult.status).toBe("ok"); + expect(session.getStatus()).toBe("cancelled"); + expect(onCancel).toHaveBeenCalledTimes(1); + + const result = await promise; + expect(result.status).toBe("error"); + if (result.status === "error") { + expect(result.error.code).toBe("WALLET_SIGN_REJECTED"); + expect(result.error.message).toContain("User closed prompt"); + } + }); + + it("allows callers to extend the timeout while pending", async () => { + const adapter = mockAdapter(5_000); // Takes 5s + const session = new TransactionSigningSession({ timeoutMs: 2_000 }); // Initially 2s + + const promise = session.start(adapter, { + transactionXdr: "AAAA", + networkPassphrase: "Test SDF Network ; September 2015", + }); + + // Advance 1 second + vi.advanceTimersByTime(1_000); + + // Extend by 6 more seconds (so 7s total from start) + const extendResult = session.extendTimeout(6_000); + expect(extendResult.status).toBe("ok"); + + // Advance 2 more seconds (total 3s, which would have timed out under initial 2s limit) + vi.advanceTimersByTime(2_000); + expect(session.getStatus()).toBe("pending"); + + // Advance 2.5 more seconds (total 5.5s, wallet responds at 5s) + vi.advanceTimersByTime(2_500); + + const result = await promise; + expect(result.status).toBe("ok"); + expect(session.getStatus()).toBe("completed"); + }); + + it("allows callers to reset the timeout", async () => { + const adapter = mockAdapter(4_000); + const session = new TransactionSigningSession({ timeoutMs: 2_000 }); + + const promise = session.start(adapter, { + transactionXdr: "AAAA", + networkPassphrase: "Test SDF Network ; September 2015", + }); + + // Advance 1.5 seconds + vi.advanceTimersByTime(1_500); + + // Reset timeout for 5 seconds + const resetResult = session.resetTimeout(5_000); + expect(resetResult.status).toBe("ok"); + + // Advance 3 more seconds + vi.advanceTimersByTime(3_000); + + const result = await promise; + expect(result.status).toBe("ok"); + }); + + it("supports signTransactionWithSession helper", async () => { + const adapter = mockAdapter(200, "helper-xdr"); + const promise = signTransactionWithSession( + adapter, + { + transactionXdr: "AAAA", + networkPassphrase: "Test SDF Network ; September 2015", + }, + { timeoutMs: 1_000 }, + ); + + vi.advanceTimersByTime(300); + const result = await promise; + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.data).toBe("helper-xdr"); + } + }); +}); diff --git a/src/transaction/feeBump.ts b/src/transaction/feeBump.ts new file mode 100644 index 0000000..e18c30e --- /dev/null +++ b/src/transaction/feeBump.ts @@ -0,0 +1 @@ +export * from "./feeBumpTransaction"; diff --git a/src/transaction/feeBumpTransaction.ts b/src/transaction/feeBumpTransaction.ts index be0e20f..7f43dd3 100644 --- a/src/transaction/feeBumpTransaction.ts +++ b/src/transaction/feeBumpTransaction.ts @@ -114,6 +114,15 @@ export function buildFeeBumpTransaction( ); } + const innerFee = BigInt(inner.fee); + const bumpFee = BigInt(feeString); + if (bumpFee <= innerFee) { + return err( + SorokitErrorCode.TX_BUILD_FAILED, + `Fee bump fee (${feeString} stroops) must be higher than the inner transaction fee (${innerFee} stroops)`, + ); + } + try { const feeBump = TransactionBuilder.buildFeeBumpTransaction( feeAccount, diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 9ce49b4..f450aed 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -4,6 +4,16 @@ export { disconnectWallet } from "./disconnect"; export { WalletConnectionManager } from "./connection"; export { signTransaction } from "./signTransaction"; export { signTransactionOffline } from "./signTransactionOffline"; +export { + TransactionSigningSession, + createTransactionSigningSession, + signTransactionWithSession, + DEFAULT_SIGNING_SESSION_TIMEOUT_MS, +} from "./transactionSigningSession"; +export type { + TransactionSigningSessionOptions, + TransactionSigningSessionStatus, +} from "./transactionSigningSession"; export { SigningRateLimiter } from "./signingRateLimiter"; export type { SigningRateLimiterConfig, QueueState } from "./signingRateLimiter"; export { createSigningChallenge, mergeSignatures } from "./signingDelegation"; diff --git a/src/wallet/transactionSigningSession.ts b/src/wallet/transactionSigningSession.ts new file mode 100644 index 0000000..ab58b7e --- /dev/null +++ b/src/wallet/transactionSigningSession.ts @@ -0,0 +1,293 @@ +/** + * Transaction signing session with timeout and cancellation support (#860). + * + * Enforces a configurable signing timeout when waiting for user signatures + * through connected wallet extensions or hardware prompts, preventing + * applications from hanging indefinitely. + */ + +import { err, ok, SorokitErrorCode } from "../shared/response"; +import type { SorokitResult } from "../shared/response"; +import type { SignTransactionInput, WalletAdapter } from "./types"; +import { signTransaction } from "./signTransaction"; +import type { SigningHistoryStore } from "./signingHistory"; +import type { SigningRateLimiter } from "./signingRateLimiter"; + +/** + * Default signing session timeout duration (5 minutes, ≥ 5 minutes as required). + */ +export const DEFAULT_SIGNING_SESSION_TIMEOUT_MS = 5 * 60 * 1000; + +export type TransactionSigningSessionStatus = + | "idle" + | "pending" + | "completed" + | "timed_out" + | "cancelled" + | "failed"; + +export interface TransactionSigningSessionOptions { + /** + * Timeout in milliseconds before the session is aborted. + * Default: 300,000 ms (5 minutes). + */ + timeoutMs?: number; + /** + * Optional callback triggered when the session times out. + */ + onTimeout?: () => void; + /** + * Optional callback triggered when the session is cancelled by caller. + */ + onCancel?: () => void; + /** + * Optional history store for recording signing outcomes. + */ + historyStore?: SigningHistoryStore; + /** + * Optional rate limiter for queueing signing prompts. + */ + rateLimiter?: SigningRateLimiter; +} + +export class TransactionSigningSession { + private status: TransactionSigningSessionStatus = "idle"; + private timeoutMs: number; + private deadline: number = 0; + private timer: ReturnType | null = null; + private resolvePending: ((result: SorokitResult) => void) | null = null; + private readonly onTimeout?: () => void; + private readonly onCancel?: () => void; + private readonly historyStore?: SigningHistoryStore; + private readonly rateLimiter?: SigningRateLimiter; + + constructor(options: TransactionSigningSessionOptions = {}) { + this.timeoutMs = options.timeoutMs ?? DEFAULT_SIGNING_SESSION_TIMEOUT_MS; + if (this.timeoutMs <= 0 || !Number.isFinite(this.timeoutMs)) { + this.timeoutMs = DEFAULT_SIGNING_SESSION_TIMEOUT_MS; + } + this.onTimeout = options.onTimeout; + this.onCancel = options.onCancel; + this.historyStore = options.historyStore; + this.rateLimiter = options.rateLimiter; + } + + /** + * Current session lifecycle status. + */ + getStatus(): TransactionSigningSessionStatus { + return this.status; + } + + /** + * Configured timeout in milliseconds. + */ + getTimeoutMs(): number { + return this.timeoutMs; + } + + /** + * Remaining time until the session times out, in milliseconds. + * Returns 0 if not currently pending or expired. + */ + getTimeRemainingMs(): number { + if (this.status !== "pending") return 0; + return Math.max(0, this.deadline - Date.now()); + } + + /** + * Extend the current timeout by additional milliseconds. + * + * @param additionalMs - Number of additional milliseconds to add to deadline. + * @returns SorokitResult with the updated remaining time, or error if not pending. + */ + extendTimeout(additionalMs: number): SorokitResult { + if (this.status !== "pending") { + return err( + SorokitErrorCode.VALIDATION, + `Cannot extend timeout for session in '${this.status}' state; must be 'pending'.`, + ); + } + if (!Number.isFinite(additionalMs) || additionalMs <= 0) { + return err( + SorokitErrorCode.VALIDATION, + "additionalMs must be a positive finite number.", + ); + } + + this.clearTimer(); + this.deadline += additionalMs; + const remaining = this.getTimeRemainingMs(); + this.scheduleTimer(remaining); + return ok(remaining); + } + + /** + * Reset the session timeout from the current moment. + * + * @param newTimeoutMs - Optional new timeout duration in milliseconds. Defaults to original timeout. + * @returns SorokitResult with the new remaining time. + */ + resetTimeout(newTimeoutMs?: number): SorokitResult { + if (this.status !== "pending") { + return err( + SorokitErrorCode.VALIDATION, + `Cannot reset timeout for session in '${this.status}' state; must be 'pending'.`, + ); + } + const duration = newTimeoutMs ?? this.timeoutMs; + if (!Number.isFinite(duration) || duration <= 0) { + return err( + SorokitErrorCode.VALIDATION, + "newTimeoutMs must be a positive finite number.", + ); + } + + this.clearTimer(); + this.timeoutMs = duration; + this.deadline = Date.now() + duration; + this.scheduleTimer(duration); + return ok(duration); + } + + /** + * Cancel the signing session immediately. + * + * @param reason - Optional explanation for cancellation. + */ + cancel(reason = "Transaction signing session was cancelled by caller."): SorokitResult { + if (this.status !== "pending") { + return err( + SorokitErrorCode.VALIDATION, + `Cannot cancel session in '${this.status}' state.`, + ); + } + + this.clearTimer(); + this.status = "cancelled"; + + if (this.onCancel) { + try { + this.onCancel(); + } catch { + // Callback errors should not crash caller + } + } + + if (this.resolvePending) { + const resolver = this.resolvePending; + this.resolvePending = null; + resolver(err(SorokitErrorCode.WALLET_SIGN_REJECTED, reason)); + } + + return ok(undefined); + } + + /** + * Start the signing session with the provided wallet adapter and transaction input. + * Resolves with the signed XDR or an error result (e.g. timeout or rejection). + */ + start( + adapter: WalletAdapter, + input: SignTransactionInput, + ): Promise> { + if (this.status === "pending") { + return Promise.resolve( + err( + SorokitErrorCode.VALIDATION, + "Signing session is already active.", + ), + ); + } + + this.status = "pending"; + this.deadline = Date.now() + this.timeoutMs; + + return new Promise>((resolve) => { + this.resolvePending = resolve; + this.scheduleTimer(this.timeoutMs); + + signTransaction(adapter, input, this.historyStore, this.rateLimiter) + .then((result) => { + if (this.status !== "pending") return; + this.clearTimer(); + this.status = result.status === "ok" ? "completed" : "failed"; + const resolver = this.resolvePending; + this.resolvePending = null; + resolver?.(result); + }) + .catch((cause) => { + if (this.status !== "pending") return; + this.clearTimer(); + this.status = "failed"; + const resolver = this.resolvePending; + this.resolvePending = null; + resolver?.( + err( + SorokitErrorCode.WALLET_SIGN_FAILED, + `Signing session failed: ${String(cause)}`, + cause, + ), + ); + }); + }); + } + + private scheduleTimer(ms: number): void { + this.clearTimer(); + this.timer = setTimeout(() => { + if (this.status !== "pending") return; + this.status = "timed_out"; + this.timer = null; + + if (this.onTimeout) { + try { + this.onTimeout(); + } catch { + // Ignore callback failure + } + } + + const resolver = this.resolvePending; + this.resolvePending = null; + resolver?.( + err( + SorokitErrorCode.OPERATION_TIMEOUT, + `Transaction signing session timed out after ${Math.round(this.timeoutMs / 1000)} seconds. No signature was received.`, + ), + ); + }, ms); + + if (typeof this.timer.unref === "function") { + this.timer.unref(); + } + } + + private clearTimer(): void { + if (this.timer !== null) { + clearTimeout(this.timer); + this.timer = null; + } + } +} + +/** + * Create a new TransactionSigningSession instance. + */ +export function createTransactionSigningSession( + options?: TransactionSigningSessionOptions, +): TransactionSigningSession { + return new TransactionSigningSession(options); +} + +/** + * Convenience helper to sign a transaction with session timeout and cancellation control. + */ +export async function signTransactionWithSession( + adapter: WalletAdapter, + input: SignTransactionInput, + options?: TransactionSigningSessionOptions, +): Promise> { + const session = new TransactionSigningSession(options); + return session.start(adapter, input); +}