From a42161dffc8a6665309b6c11049e6f244e7e35a4 Mon Sep 17 00:00:00 2001 From: dev-kiki0 Date: Sun, 18 Oct 2026 00:47:33 +0000 Subject: [PATCH] Add contract address validation, transaction verification, error codes, and recovery strategies (#1065, #1066, #1067, #1068) #1065: Add validateContractAddress/isValidContractAddress/assertValidContractAddress\n enforcing the StrKey contract-address rules (56-char C-address, uppercase\n base32 alphabet, CRC16 checksum) with format and checksum reported apart,\n plus a typed InvalidContractAddressError.\n\n#1066: Add verifyTransaction/verifyTransactionSignatures/assertTransactionVerified\n to check transaction integrity and authenticity before submit: structure,\n fee, sequence, time bounds, ed25519 signature, and hash integrity checks,\n plus a typed TransactionVerificationError.\n\n#1067: Extend SdkErrorCode to cover every SDK failure category and add\n SDK_ERROR_CODE_DESCRIPTIONS, describeSdkErrorCode, RETRYABLE_ERROR_CODES,\n and isRetryableErrorCode. Suggestions, descriptions, and recovery\n strategies are exhaustive over the enum (compile-time enforced).\n\n#1068: Add an exhaustive RECOVERY_STRATEGY_TABLE mapping each error code to\n a recovery action, getRecoveryStrategy/getRecoveryPlan for inspecting\n remediation, executeWithRecovery for automatic retry with backoff,\n coverage for every code in getSuggestion, docs/ERROR_CODES.md, and\n examples/error-recovery-example.ts. --- docs/ERROR_CODES.md | 193 +++++++++++ examples/error-recovery-example.ts | 145 ++++++++ src/contractAddressValidator.ts | 159 +++++++++ src/errorRecovery.ts | 478 ++++++++++++++++++++++++++ src/errorSuggestions.ts | 90 ++++- src/errors.ts | 259 +++++++++++++- src/index.ts | 50 +++ src/transactionVerifier.ts | 316 +++++++++++++++++ test/contractAddressValidator.test.ts | 187 ++++++++++ test/errorCodes.test.ts | 161 +++++++++ test/errorRecovery.test.ts | 220 ++++++++++++ test/transactionVerifier.test.ts | 324 +++++++++++++++++ 12 files changed, 2571 insertions(+), 11 deletions(-) create mode 100644 docs/ERROR_CODES.md create mode 100644 examples/error-recovery-example.ts create mode 100644 src/contractAddressValidator.ts create mode 100644 src/errorRecovery.ts create mode 100644 src/transactionVerifier.ts create mode 100644 test/contractAddressValidator.test.ts create mode 100644 test/errorCodes.test.ts create mode 100644 test/errorRecovery.test.ts create mode 100644 test/transactionVerifier.test.ts diff --git a/docs/ERROR_CODES.md b/docs/ERROR_CODES.md new file mode 100644 index 00000000..970be527 --- /dev/null +++ b/docs/ERROR_CODES.md @@ -0,0 +1,193 @@ +# Error Codes and Recovery Strategies + +Reference for the SDK's machine-readable error codes (`SdkErrorCode`, +issue #1067), their recovery strategies (issue #1068), and the Soroban +contract-address validation rules (issue #1065). + +Related APIs: + +- `describeSdkErrorCode(code)` — human-readable description of a code +- `isRetryableErrorCode(code)` — whether the failure is safe to retry unchanged +- `getSuggestion(error)` — remediation suggestion for UI/logs +- `getRecoveryPlan(error)` — recovery strategy + suggestion +- `executeWithRecovery(op)` — apply the mapped strategy automatically +- `validateContractAddress(address)` / `assertValidContractAddress(address)` + +## Error code categories + +`SdkErrorCode` is an exhaustive enum: every SDK failure mode maps to exactly +one code, and every code must have a description +(`SDK_ERROR_CODE_DESCRIPTIONS`), a suggestion (`getSuggestion`), and a +recovery strategy (`RECOVERY_STRATEGY_TABLE`). Adding a code without all +three is a compile error. + +### Invoice / resource lifecycle + +| Code | Description | +| ---- | ----------- | +| `INVOICE_NOT_FOUND` | The requested invoice does not exist on-chain. | +| `INVOICE_NOT_PENDING` | The invoice is not in the Pending state required by this operation. | +| `DEADLINE_EXPIRED` | The invoice's payment deadline has passed. | +| `INVOICE_FROZEN` | The invoice is frozen due to an active dispute or lock. | + +### Accounts & funds + +| Code | Description | +| ---- | ----------- | +| `ACCOUNT_NOT_FOUND` | The referenced Stellar account does not exist on the network. | +| `INSUFFICIENT_FUNDS` | The account balance is too low to cover the operation. | + +### Input validation + +| Code | Description | +| ---- | ----------- | +| `INVALID_INPUT` | A generic input failed validation. | +| `INVALID_ADDRESS` | A Stellar account address is malformed. | +| `INVALID_CONTRACT_ADDRESS` | A Soroban contract address is malformed or fails its CRC16 checksum. | +| `INVALID_AMOUNT` | A payment amount is malformed or out of range. | +| `INVALID_RECIPIENT` | One or more recipient addresses are invalid. | +| `INVALID_CONFIG` | The SDK configuration is invalid. | + +### Network / transport + +| Code | Description | +| ---- | ----------- | +| `NETWORK_ERROR` | A network-level failure occurred (DNS, TLS, connection reset). | +| `NETWORK_TIMEOUT` | A network request timed out. | +| `RPC_FAILED` | The RPC node rejected or failed to process the request. | +| `HORIZON_ERROR` | Horizon returned an error response. | +| `SERVICE_UNAVAILABLE` | The network endpoint is temporarily unavailable. | +| `RATE_LIMITED` | The endpoint is rate limiting requests. | + +### Transaction lifecycle + +| Code | Description | +| ---- | ----------- | +| `TRANSACTION_FAILED` | The transaction was rejected or failed on-chain. | +| `TRANSACTION_EXPIRED` | The transaction's time bounds elapsed before inclusion in a ledger. | +| `SEQUENCE_MISMATCH` | The transaction's sequence number does not match the account state. | +| `INSUFFICIENT_FEE` | The provided fee is below the network minimum or resource-fee quote. | +| `SUBMISSION_FAILED` | Submitting the transaction to the network failed. | +| `CONFIRMATION_TIMEOUT` | The transaction was submitted but was not confirmed in time. | +| `VERIFICATION_FAILED` | Pre-submit transaction verification found one or more failed checks. | +| `MISSING_SIGNATURE` | A required signature is missing from the transaction. | +| `INVALID_SIGNATURE` | A signature on the transaction failed cryptographic verification. | + +### Soroban contract execution + +| Code | Description | +| ---- | ----------- | +| `CONTRACT_NOT_FOUND` | The referenced contract does not exist on the network. | +| `CONTRACT_REJECTED` | The contract rejected the transaction (panic or error result). | +| `SIMULATION_FAILED` | Simulating the contract invocation failed. | +| `OUT_OF_RESOURCE` | The invocation exceeded the Soroban CPU or memory budget. | + +### Authorization + +| Code | Description | +| ---- | ----------- | +| `UNAUTHORIZED` | The caller is not authorized to perform the operation. | +| `WALLET_NOT_CONNECTED` | No wallet is connected (or the connection was lost). | +| `AUTHENTICATION_FAILED` | Wallet / signer authentication failed. | + +### Operational / state + +| Code | Description | +| ---- | ----------- | +| `STATE_CONFLICT` | Concurrent modification produced a state conflict. | +| `CIRCUIT_OPEN` | The circuit breaker is open and calls are being short-circuited. | +| `OPERATION_ABORTED` | The operation was aborted before completion. | +| `BATCH_TOO_LARGE` | A batch exceeds the maximum permitted size. | +| `INTERNAL_ERROR` | An unexpected internal SDK error occurred. | + +## Retryability + +`RETRYABLE_ERROR_CODES` is deliberately conservative — only transient +network-level failures are safe to retry **unchanged**: + +`NETWORK_ERROR`, `NETWORK_TIMEOUT`, `RPC_FAILED`, `HORIZON_ERROR`, +`SERVICE_UNAVAILABLE`, `RATE_LIMITED`, `CONFIRMATION_TIMEOUT`. + +Transaction-state errors such as `SEQUENCE_MISMATCH` or `TRANSACTION_FAILED` +are **not** retryable: a blind retry can double-submit. They require +re-fetching state and rebuilding the transaction +(`action: "refresh_and_retry"`). + +## Recovery strategies + +Every code maps to a strategy in `RECOVERY_STRATEGY_TABLE`: + +| Action | Meaning | +| ------ | ------- | +| `retry` | Re-issue the same request immediately. | +| `retry_with_backoff` | Re-issue after exponential backoff (`backoffMs * 2^(n-1)`). | +| `refresh_and_retry` | Re-fetch on-chain/network state, rebuild, then retry. | +| `correct_input` | Fix the caller's input; retrying unchanged will fail again. | +| `use_fallback` | Switch to a fallback RPC/Horizon endpoint. | +| `wait_for_resolution` | External state (dispute, freeze, cooldown) must change first. | +| `abort` | Terminal failure — do not retry. | + +Retryable strategies carry `maxAttempts` and `backoffMs`, which +`executeWithRecovery` applies automatically: + +```ts +import { executeWithRecovery } from "@stellar-split/sdk"; + +const invoice = await executeWithRecovery(() => client.getInvoice(id), { + onRetry: (attempt, strategy) => + console.warn(`Retry ${attempt}: ${strategy.description}`), +}); +``` + +Errors without a known code (plain `Error`s) use a conservative generic +strategy: `retry_with_backoff`, 3 attempts, 500 ms base delay. + +See `examples/error-recovery-example.ts` for a runnable walkthrough. + +## Contract address validation rules (#1065) + +A valid Soroban contract address must satisfy all of: + +1. **Type** — a non-empty string. +2. **Length** — exactly 56 characters. +3. **Prefix** — starts with `C`. +4. **Alphabet** — uppercase RFC 4648 base32 only (`A`–`Z`, `2`–`7`); the + encoding is case-sensitive. +5. **Checksum** — the trailing characters encode a CRC16-XModem checksum over + the version byte plus the 32-byte payload (StrKey / SEP-0023). + +Rules 2–4 are reported as the `format` check and rule 5 as the `checksum` +check by `validateContractAddress`, so a mistyped address is distinguishable +from a corrupted one: + +```ts +import { validateContractAddress } from "@stellar-split/sdk"; + +const result = validateContractAddress(candidate); +if (!result.valid) { + console.error(result.errors); // e.g. ["Contract address failed CRC16 checksum validation"] +} +``` + +`assertValidContractAddress(candidate)` throws `InvalidContractAddressError` +(code `INVALID_CONTRACT_ADDRESS`) instead of returning a result. + +## Transaction verification checks (#1066) + +`verifyTransaction(tx, options)` runs: + +| Check | Verifies | +| ----- | -------- | +| `structure` | At least one operation; parseable positive integer fee. | +| `fee` | Fee is positive and at or below `maxFee` (when provided). | +| `sequence` | Sequence number equals `expectedSequence` (when provided). | +| `timebounds` | Current time lies within the transaction's time bounds. | +| `signatures` | Every key in `signers` holds a valid ed25519 signature over the tx hash; at least `minSignatures` entries are present. | +| `hash` | Recomputed hash equals `expectedHash` (hex or base64, when provided). | + +Options whose properties you cannot vouch for should be omitted — skipped +checks are not passed vacuously. `assertTransactionVerified` throws +`TransactionVerificationError` (code `VERIFICATION_FAILED`) listing the +failed checks. + + diff --git a/examples/error-recovery-example.ts b/examples/error-recovery-example.ts new file mode 100644 index 00000000..3fe0e664 --- /dev/null +++ b/examples/error-recovery-example.ts @@ -0,0 +1,145 @@ +/** + * Example: Error recovery strategies, error codes, and pre-submit validation + * + * Demonstrates the pieces added by issues #1065–#1068: + * + * 1. Validating a Soroban contract address before it is used (#1065) + * 2. Verifying transaction integrity/authenticity before submission (#1066) + * 3. Inspecting comprehensive error codes and their descriptions (#1067) + * 4. Mapping errors to recovery strategies and recovering automatically (#1068) + * + * Run with: npx tsx examples/error-recovery-example.ts + */ + +import { + // #1065 — contract address validation + validateContractAddress, + isValidContractAddress, + assertValidContractAddress, + // #1066 — transaction verification + verifyTransaction, + assertTransactionVerified, + // #1067 — comprehensive error codes + SdkError, + SdkErrorCode, + describeSdkErrorCode, + isRetryableErrorCode, + // #1068 — recovery strategies + getRecoveryStrategy, + getRecoveryPlan, + executeWithRecovery, +} from "@stellar-split/sdk"; + +// --------------------------------------------------------------------------- +// 1. Contract address validation (#1065) +// --------------------------------------------------------------------------- + +const CONTRACT_ID = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; + +console.log("== Contract address validation (#1065) =="); + +const validation = validateContractAddress(CONTRACT_ID); +console.log(`validateContractAddress(${CONTRACT_ID.slice(0, 12)}...)`); +console.log(` valid: ${validation.valid}`); // true +console.log(` format: ${validation.format}`); // true — length, prefix, base32 +console.log(` checksum: ${validation.checksum}`); // true — CRC16 verified + +const typo = `${CONTRACT_ID.slice(0, 55)}${CONTRACT_ID[55] === "4" ? "5" : "4"}`; +console.log(`\nTypo'd checksum: ${isValidContractAddress(typo)}`); // false +console.log(` errors: ${validateContractAddress(typo).errors.join("; ")}`); + +try { + assertValidContractAddress("not-a-contract"); +} catch (err) { + console.log(`\nassertValidContractAddress threw: ${(err as Error).message}`); +} + +// --------------------------------------------------------------------------- +// 2. Transaction verification before submit (#1066) +// --------------------------------------------------------------------------- + +console.log("\n== Transaction verification (#1066) =="); + +// In real usage you would build and sign a transaction with your wallet. +// Here we only show the API surface: +// +// const result = verifyTransaction(tx, { +// signers: [walletPublicKey], // every key must hold a valid signature +// maxFee: 500_000, // refuse to overpay +// expectedSequence: account.sequenceNumber(), // refuse stale sequences +// expectedHash: previouslyComputedHash, // refuse tampered payloads +// }); +// if (!result.valid) { +// console.warn("Refusing to submit, failed checks:", result.failedChecks); +// } +// +// // Or fail fast: +// assertTransactionVerified(tx, { signers: [walletPublicKey] }); + +console.log("verifyTransaction(tx, { signers, maxFee, expectedSequence }) runs"); +console.log("checks: structure, fee, sequence, timebounds, signatures, hash"); +console.log("assertTransactionVerified throws TransactionVerificationError on failure"); + +// --------------------------------------------------------------------------- +// 3. Comprehensive error codes (#1067) +// --------------------------------------------------------------------------- + +console.log("\n== Comprehensive error codes (#1067) =="); + +const exampleError = new SdkError( + "The transaction's sequence number is stale", + SdkErrorCode.SEQUENCE_MISMATCH, +); + +console.log(`code: ${exampleError.code}`); +console.log(`description: ${describeSdkErrorCode(exampleError.code)}`); +console.log(`retryable: ${isRetryableErrorCode(exampleError.code)}`); + +// --------------------------------------------------------------------------- +// 4. Recovery strategies (#1068) +// --------------------------------------------------------------------------- + +console.log("\n== Recovery strategies (#1068) =="); + +const strategy = getRecoveryStrategy(exampleError); +console.log(`action: ${strategy.action}`); // refresh_and_retry +console.log(`retryable: ${strategy.retryable}`); // false — rebuild required +console.log(`description: ${strategy.description}`); + +const plan = getRecoveryPlan(exampleError); +console.log(`\nFull recovery plan:`); +console.log(` code: ${plan.code}`); +console.log(` action: ${plan.strategy.action}`); +console.log(` suggestion: ${plan.suggestion}`); + +// Automatic recovery: retry transient failures with the mapped backoff. +console.log("\nAutomatic recovery with executeWithRecovery:"); + +let attempts = 0; +const flakyGetInvoice = async () => { + attempts += 1; + if (attempts < 3) { + // Simulate a transient network failure the SDK classifies as retryable. + throw new SdkError("Request timed out", SdkErrorCode.NETWORK_TIMEOUT); + } + return { id: "inv-1", status: "Pending" }; +}; + +const invoice = await executeWithRecovery(flakyGetInvoice, { + onRetry: (attempt, s) => + console.warn(` retry ${attempt}: ${s.description}`), +}); + +console.log(` result after ${attempts} attempts:`, invoice); + +// Non-retryable failures are surfaced immediately, with a plan attached. +console.log("\nNon-retryable failures are NOT retried:"); +try { + await executeWithRecovery(async () => { + throw new SdkError("stale sequence", SdkErrorCode.SEQUENCE_MISMATCH); + }); +} catch (err) { + const e = err as SdkError; + const plan = getRecoveryPlan(e); + console.log(` caught ${e.code} → action: ${plan.strategy.action}`); +} diff --git a/src/contractAddressValidator.ts b/src/contractAddressValidator.ts new file mode 100644 index 00000000..8ec895e6 --- /dev/null +++ b/src/contractAddressValidator.ts @@ -0,0 +1,159 @@ +/** + * Soroban contract address validation for StellarSplit (issue #1065). + * + * Validates contract identifiers (`C...` addresses) against the Stellar + * StrKey standard before they are used to build or submit transactions, so + * mistyped or corrupt contract IDs fail fast locally instead of producing + * on-chain failures. + * + * ## Validation rules + * + * A valid Soroban contract address must satisfy all of the following: + * + * 1. **Type** — a non-empty string (`typeof address === "string"`). + * 2. **Length** — exactly 56 characters. + * 3. **Prefix** — starts with `C` (the contract StrKey version byte). + * 4. **Alphabet** — every character is in the RFC 4648 base32 alphabet + * (`A`–`Z`, `2`–`7`); base32 is case-sensitive, so lowercase is rejected. + * 5. **Checksum** — the trailing two characters encode a CRC16-XModem + * checksum over the version byte plus the 32-byte payload, as defined by + * the StrKey specification (CAP-0005 / SEP-0023). + * + * Rules 2–4 are reported as the `format` check; rule 5 is reported as the + * `checksum` check. Both must pass for the address to be valid. + * + * @example + * ```ts + * import { validateContractAddress, isValidContractAddress } from "@stellar-split/sdk"; + * + * isValidContractAddress("CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"); // true + * + * const result = validateContractAddress("CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC5"); + * result.format; // true — well-formed, only the checksum is broken + * result.checksum; // false + * result.errors; // ["Contract address failed CRC16 checksum validation"] + * ``` + */ + +import { StrKey } from "@stellar/stellar-sdk"; +import { InvalidContractAddressError } from "./errors.js"; + +/** Total length of a StrKey-encoded contract address, in characters. */ +export const CONTRACT_ADDRESS_LENGTH = 56; + +/** Required leading character of a contract (`C...`) address. */ +export const CONTRACT_ADDRESS_PREFIX = "C"; + +/** RFC 4648 base32 alphabet used by StrKey (uppercase, case-sensitive). */ +const BASE32_PATTERN = /^[A-Z2-7]+$/; + +/** Detailed outcome of validating a contract address. */ +export interface ContractAddressValidationResult { + /** `true` when both the format and checksum checks pass. */ + valid: boolean; + /** The value that was validated (stringified for non-string input). */ + address: string; + /** `true` when length, prefix, and base32 alphabet checks pass. */ + format: boolean; + /** `true` when the StrKey CRC16 checksum verifies (only checked when `format` is `true`). */ + checksum: boolean; + /** Human-readable descriptions of every failed rule. */ + errors: string[]; +} + +/** + * Validate a Soroban contract address against the StrKey standard. + * + * Performs format validation (type, length, prefix, base32 alphabet) followed + * by CRC16 checksum validation via the stellar-sdk `StrKey` codec. Never + * throws — all failures are reported in the returned result. + * + * @param address - Candidate contract address (any value; non-strings fail). + * @returns A {@link ContractAddressValidationResult} describing which rules + * passed and why any failed. + */ +export function validateContractAddress(address: unknown): ContractAddressValidationResult { + if (typeof address !== "string" || address.length === 0) { + return { + valid: false, + address: String(address), + format: false, + checksum: false, + errors: ["Contract address must be a non-empty string"], + }; + } + + const errors: string[] = []; + let format = true; + + if (address.length !== CONTRACT_ADDRESS_LENGTH) { + format = false; + errors.push( + `Contract address must be exactly ${CONTRACT_ADDRESS_LENGTH} characters (got ${address.length})`, + ); + } + if (!address.startsWith(CONTRACT_ADDRESS_PREFIX)) { + format = false; + errors.push( + `Contract address must start with '${CONTRACT_ADDRESS_PREFIX}' (got '${address.slice(0, 1)}')`, + ); + } + if (!BASE32_PATTERN.test(address)) { + format = false; + errors.push( + "Contract address must contain only uppercase base32 characters (A-Z, 2-7)", + ); + } + + // Only attempt checksum validation once the address is well-formed; + // StrKey.decodeContract throws on any structural or checksum mismatch. + let checksum = false; + if (format) { + try { + StrKey.decodeContract(address); + checksum = true; + } catch { + errors.push("Contract address failed CRC16 checksum validation"); + } + } + + return { + valid: format && checksum, + address, + format, + checksum, + errors, + }; +} + +/** + * Report whether `address` is a valid Soroban contract address. + * + * Shorthand for `validateContractAddress(address).valid`. + * + * @param address - Candidate contract address string. + * + * @example + * ```ts + * isValidContractAddress("CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"); // true + * isValidContractAddress("GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"); // false — G-address + * isValidContractAddress(""); // false + * ``` + */ +export function isValidContractAddress(address: string): boolean { + return validateContractAddress(address).valid; +} + +/** + * Assert that `address` is a valid Soroban contract address, throwing + * {@link InvalidContractAddressError} with every violated rule when it is not. + * + * @param address - Candidate contract address string. + * @throws {InvalidContractAddressError} If the address fails any rule. + */ +export function assertValidContractAddress(address: string): void { + const result = validateContractAddress(address); + if (!result.valid) { + throw new InvalidContractAddressError(address, result.errors); + } +} diff --git a/src/errorRecovery.ts b/src/errorRecovery.ts new file mode 100644 index 00000000..fdf21651 --- /dev/null +++ b/src/errorRecovery.ts @@ -0,0 +1,478 @@ +/** + * Error recovery strategies for StellarSplit SDK (issue #1068). + * + * Maps every {@link SdkErrorCode} (issue #1067) to a concrete recovery + * strategy describing what a caller should do when that failure occurs: + * retry as-is, retry with backoff, refresh state and rebuild, correct the + * input, switch to a fallback endpoint, wait for external state to change, + * or abort. + * + * The mapping is exhaustive by construction — adding a new error code without + * declaring its recovery strategy is a compile error. + * + * @example Inspect the strategy for an error + * ```ts + * import { getRecoveryPlan } from "@stellar-split/sdk"; + * + * try { + * await client.pay(invoiceId, amount); + * } catch (err) { + * const plan = getRecoveryPlan(err); + * console.log(plan.strategy.action); // e.g. "retry_with_backoff" + * console.log(plan.suggestion); // human-readable remediation + * } + * ``` + * + * @example Automatic recovery + * ```ts + * import { executeWithRecovery } from "@stellar-split/sdk"; + * + * const result = await executeWithRecovery(() => client.getInvoice(invoiceId), { + * onRetry: (attempt, strategy) => console.warn(`Retry ${attempt}: ${strategy.description}`), + * }); + * ``` + */ + +import { + SdkError, + SdkErrorCode, + StellarSplitError, +} from "./errors.js"; +import { getSuggestion } from "./errorSuggestions.js"; + +/** The category of remediation recommended for a failure. */ +export type RecoveryAction = + /** Safe to re-issue the same request immediately. */ + | "retry" + /** Re-issue after an exponential backoff delay (rate limits, congestion). */ + | "retry_with_backoff" + /** Refresh on-chain/network state, rebuild the request, then retry. */ + | "refresh_and_retry" + /** Fix the caller's input; retrying unchanged will fail again. */ + | "correct_input" + /** Switch to a fallback RPC/Horizon endpoint or provider. */ + | "use_fallback" + /** External state (dispute, freeze, cooldown) must change before retrying. */ + | "wait_for_resolution" + /** Terminal failure — do not retry. */ + | "abort"; + +/** A concrete recovery strategy for one error code. */ +export interface RecoveryStrategy { + /** What the caller should do when this failure occurs. */ + action: RecoveryAction; + /** Whether re-issuing the same request is safe at all. */ + retryable: boolean; + /** Maximum attempts to apply the strategy (when retrying). */ + maxAttempts?: number; + /** Base delay in ms between attempts; doubled after each failure. */ + backoffMs?: number; + /** Human-readable explanation of the strategy. */ + description: string; +} + +/** + * Recovery strategy for every {@link SdkErrorCode}. + * + * Retryable network codes (`NETWORK_TIMEOUT`, `RATE_LIMITED`, ...) retry with + * exponential backoff. State-dependent failures (`SEQUENCE_MISMATCH`, + * `TRANSACTION_EXPIRED`) require re-fetching state and rebuilding the + * transaction. Input and authorization failures are terminal until the caller + * changes something, so they never retry automatically. + */ +export const RECOVERY_STRATEGY_TABLE: Record = { + // Invoice / resource lifecycle + [SdkErrorCode.INVOICE_NOT_FOUND]: { + action: "correct_input", + retryable: false, + description: + "The invoice does not exist on this network. Verify the invoice ID and network before retrying.", + }, + [SdkErrorCode.INVOICE_NOT_PENDING]: { + action: "wait_for_resolution", + retryable: false, + description: + "The invoice is not Pending. Wait for its state to change (or pick an operation valid for its current state).", + }, + [SdkErrorCode.DEADLINE_EXPIRED]: { + action: "correct_input", + retryable: false, + description: + "The invoice deadline passed. Create a new invoice with a future deadline; the old one cannot be revived.", + }, + [SdkErrorCode.INVOICE_FROZEN]: { + action: "wait_for_resolution", + retryable: false, + description: + "The invoice is frozen by a dispute or lock. Wait for the dispute to resolve before retrying.", + }, + // Accounts & funds + [SdkErrorCode.ACCOUNT_NOT_FOUND]: { + action: "correct_input", + retryable: false, + description: + "The account does not exist on-chain. Fund it (minbalance + reserves) or fix the address.", + }, + [SdkErrorCode.INSUFFICIENT_FUNDS]: { + action: "correct_input", + retryable: false, + description: + "The balance cannot cover the operation. Top up the account or reduce the amount before retrying.", + }, + // Input validation + [SdkErrorCode.INVALID_INPUT]: { + action: "correct_input", + retryable: false, + description: "The input is invalid. Fix the reported fields; retrying unchanged will fail again.", + }, + [SdkErrorCode.INVALID_ADDRESS]: { + action: "correct_input", + retryable: false, + description: + "An address failed StrKey validation. Correct the address (56 base32 chars, valid CRC16).", + }, + [SdkErrorCode.INVALID_CONTRACT_ADDRESS]: { + action: "correct_input", + retryable: false, + description: + "The contract address failed StrKey validation. Correct it or regenerate with StrKey.encodeContract().", + }, + [SdkErrorCode.INVALID_AMOUNT]: { + action: "correct_input", + retryable: false, + description: "The amount is malformed or out of range. Correct the amount before retrying.", + }, + [SdkErrorCode.INVALID_RECIPIENT]: { + action: "correct_input", + retryable: false, + description: "A recipient address is invalid. Fix the recipient list before retrying.", + }, + [SdkErrorCode.INVALID_CONFIG]: { + action: "correct_input", + retryable: false, + description: "The SDK configuration is invalid. Fix the reported config fields, then retry.", + }, + // Network / transport + [SdkErrorCode.NETWORK_ERROR]: { + action: "use_fallback", + retryable: true, + maxAttempts: 3, + backoffMs: 500, + description: + "Network-level failure. Retry with backoff; if it persists, fail over to a fallback RPC or Horizon endpoint.", + }, + [SdkErrorCode.NETWORK_TIMEOUT]: { + action: "retry_with_backoff", + retryable: true, + maxAttempts: 3, + backoffMs: 500, + description: + "The request timed out. Retry with backoff; for submissions, check the transaction status by hash first.", + }, + [SdkErrorCode.RPC_FAILED]: { + action: "use_fallback", + retryable: true, + maxAttempts: 3, + backoffMs: 500, + description: "The RPC node failed. Retry with backoff or switch to a healthy fallback node.", + }, + [SdkErrorCode.HORIZON_ERROR]: { + action: "retry_with_backoff", + retryable: true, + maxAttempts: 3, + backoffMs: 500, + description: + "Horizon returned an error. If the request is malformed, fix it; otherwise retry with backoff.", + }, + [SdkErrorCode.SERVICE_UNAVAILABLE]: { + action: "use_fallback", + retryable: true, + maxAttempts: 5, + backoffMs: 1_000, + description: "The endpoint is unavailable. Wait for recovery or switch to a fallback endpoint.", + }, + [SdkErrorCode.RATE_LIMITED]: { + action: "retry_with_backoff", + retryable: true, + maxAttempts: 5, + backoffMs: 1_000, + description: "Rate limited. Back off exponentially and retry; reduce request concurrency.", + }, + // Transaction lifecycle + [SdkErrorCode.TRANSACTION_FAILED]: { + action: "abort", + retryable: false, + description: + "The transaction failed on-chain. Inspect the result/diagnostic events, fix the cause, and submit a new transaction.", + }, + [SdkErrorCode.TRANSACTION_EXPIRED]: { + action: "refresh_and_retry", + retryable: false, + description: + "The transaction's time bounds elapsed. Re-fetch the account state and rebuild with fresh time bounds.", + }, + [SdkErrorCode.SEQUENCE_MISMATCH]: { + action: "refresh_and_retry", + retryable: false, + description: + "The sequence number is stale. Re-fetch the account sequence and rebuild the transaction before resubmitting.", + }, + [SdkErrorCode.INSUFFICIENT_FEE]: { + action: "refresh_and_retry", + retryable: false, + description: + "The fee is too low. Rebuild with a higher base fee (and resource fee for Soroban), then resubmit.", + }, + [SdkErrorCode.SUBMISSION_FAILED]: { + action: "retry_with_backoff", + retryable: true, + maxAttempts: 3, + backoffMs: 500, + description: + "Submission failed in transit. Retry submission with backoff, but check status by hash first to avoid double submission.", + }, + [SdkErrorCode.CONFIRMATION_TIMEOUT]: { + action: "refresh_and_retry", + retryable: true, + maxAttempts: 3, + backoffMs: 1_000, + description: + "Confirmation timed out. Poll the transaction status by hash before deciding whether to rebuild and resubmit.", + }, + [SdkErrorCode.VERIFICATION_FAILED]: { + action: "correct_input", + retryable: false, + description: + "Pre-submit verification failed. Fix the reported checks (signatures, fee, sequence, time bounds) and rebuild.", + }, + [SdkErrorCode.MISSING_SIGNATURE]: { + action: "correct_input", + retryable: false, + description: "A required signature is missing. Collect the missing signatures, then resubmit.", + }, + [SdkErrorCode.INVALID_SIGNATURE]: { + action: "correct_input", + retryable: false, + description: "A signature failed verification. Re-sign with the correct key over the exact hash.", + }, + // Soroban contract execution + [SdkErrorCode.CONTRACT_NOT_FOUND]: { + action: "correct_input", + retryable: false, + description: "The contract does not exist on this network. Verify the contract ID and network.", + }, + [SdkErrorCode.CONTRACT_REJECTED]: { + action: "abort", + retryable: false, + description: + "The contract rejected the invocation. Inspect the panic code/diagnostics and fix the call; do not blind-retry.", + }, + [SdkErrorCode.SIMULATION_FAILED]: { + action: "correct_input", + retryable: false, + description: + "Simulation failed. Fix the invocation parameters or storage prerequisites, then resimulate.", + }, + [SdkErrorCode.OUT_OF_RESOURCE]: { + action: "refresh_and_retry", + retryable: false, + description: + "Resource budget exceeded. Raise the resource fee/limits (or reduce the work done) and resimulate.", + }, + // Authorization + [SdkErrorCode.UNAUTHORIZED]: { + action: "correct_input", + retryable: false, + description: "The signer lacks permission. Sign with an authorized account or grant the role first.", + }, + [SdkErrorCode.WALLET_NOT_CONNECTED]: { + action: "refresh_and_retry", + retryable: false, + description: "No wallet is connected. Reconnect the wallet, then retry the operation.", + }, + [SdkErrorCode.AUTHENTICATION_FAILED]: { + action: "refresh_and_retry", + retryable: false, + description: "Authentication failed. Re-authenticate with the wallet/signer, then retry.", + }, + // Operational / state + [SdkErrorCode.STATE_CONFLICT]: { + action: "refresh_and_retry", + retryable: false, + description: "On-chain state changed underneath you. Re-fetch, reapply, and retry.", + }, + [SdkErrorCode.CIRCUIT_OPEN]: { + action: "wait_for_resolution", + retryable: false, + description: "The circuit breaker is open. Wait for the cooldown window before sending more calls.", + }, + [SdkErrorCode.OPERATION_ABORTED]: { + action: "retry", + retryable: true, + maxAttempts: 2, + backoffMs: 250, + description: "The operation was aborted before completion. Verify no partial state, then retry once.", + }, + [SdkErrorCode.BATCH_TOO_LARGE]: { + action: "correct_input", + retryable: false, + description: + "The batch exceeds the maximum size. Split it into smaller chunks and process sequentially.", + }, + [SdkErrorCode.INTERNAL_ERROR]: { + action: "retry_with_backoff", + retryable: true, + maxAttempts: 2, + backoffMs: 500, + description: "Internal SDK error. Retry once with backoff; report the issue if it persists.", + }, +}; + +/** + * Conservative strategy for errors without a known {@link SdkErrorCode} + * (plain `Error`s, unparsed payloads). Unknown failures are assumed transient + * and retried a limited number of times with backoff. + */ +const GENERIC_RECOVERY_STRATEGY: RecoveryStrategy = { + action: "retry_with_backoff", + retryable: true, + maxAttempts: 3, + backoffMs: 500, + description: + "Unknown error. Retry with backoff a limited number of times; if it persists, treat it as terminal.", +}; + +/** Extract the SdkErrorCode carried by an error, if it has one. */ +function extractCode(error: SdkErrorCode | string | Error): SdkErrorCode | null { + let raw: string; + if (typeof error === "string") { + raw = error; + } else if (error instanceof SdkError || error instanceof StellarSplitError) { + raw = String(error.code); + } else { + return null; + } + const normalized = raw.toUpperCase() as SdkErrorCode; + return normalized in RECOVERY_STRATEGY_TABLE ? normalized : null; +} + +/** + * Resolve the recovery strategy for an error code, code string, or error + * instance. + * + * - {@link SdkError} and {@link StellarSplitError}s resolve against + * {@link RECOVERY_STRATEGY_TABLE} via their `code` (case-insensitive). + * - Code strings resolve against the same table. + * - Everything else resolves to a conservative generic strategy that retries + * with backoff a limited number of times. + * + * @param error - An SdkErrorCode, a code string, or any Error instance. + */ +export function getRecoveryStrategy(error: SdkErrorCode | string | Error): RecoveryStrategy { + const code = extractCode(error); + return code ? RECOVERY_STRATEGY_TABLE[code] : GENERIC_RECOVERY_STRATEGY; +} + +/** A resolved recovery plan: strategy plus human-readable remediation. */ +export interface RecoveryPlan { + /** The error code the plan was resolved from, when one could be identified. */ + code: SdkErrorCode | null; + /** The recovery strategy to apply. */ + strategy: RecoveryStrategy; + /** Human-readable remediation suggestion (via {@link getSuggestion}). */ + suggestion: string; +} + +/** + * Build a complete recovery plan for an error: the mapped + * {@link RecoveryStrategy} plus a human-readable remediation suggestion. + * + * @param error - An SdkErrorCode, a code string, or any Error instance. + * + * @example + * ```ts + * const plan = getRecoveryPlan(err); + * telemetry.track("recovery_plan", { code: plan.code, action: plan.strategy.action }); + * showUserError(plan.suggestion); + * ``` + */ +export function getRecoveryPlan(error: SdkErrorCode | string | Error): RecoveryPlan { + return { + code: extractCode(error), + strategy: getRecoveryStrategy(error), + suggestion: getSuggestion(error), + }; +} + +/** Options for {@link executeWithRecovery}. */ +export interface RecoveryRunOptions { + /** Code assumed for thrown errors that do not carry one. */ + code?: SdkErrorCode; + /** Abort signal checked before each retry. */ + signal?: AbortSignal; + /** Override the sleep implementation (useful in tests). */ + sleep?: (ms: number) => Promise; + /** Invoked before each backoff delay with the 1-based retry attempt number. */ + onRetry?: (attempt: number, strategy: RecoveryStrategy) => void; +} + +const defaultSleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Execute an async operation, automatically applying the recovery strategy + * mapped to the error it throws. + * + * - Non-retryable strategies rethrow immediately. + * - Retryable strategies re-invoke `op` up to `strategy.maxAttempts` times + * with exponential backoff (`backoffMs * 2^(attempt - 1)`). + * - If `op` throws an {@link SdkError} its code selects the strategy; other + * errors use `options.code` when provided, else the conservative generic + * strategy. + * + * @param op - The async operation to run. + * @param options - Retry/abort configuration. + * @throws The last error when every attempt fails (or the error is + * non-retryable). + * + * @example + * ```ts + * const invoice = await executeWithRecovery( + * () => client.getInvoice(invoiceId), + * { onRetry: (n, s) => console.warn(`Attempt ${n} failed: ${s.description}`) }, + * ); + * ``` + */ +export async function executeWithRecovery( + op: () => Promise, + options: RecoveryRunOptions = {}, +): Promise { + const sleep = options.sleep ?? defaultSleep; + let attempt = 0; + + for (;;) { + try { + return await op(); + } catch (err) { + attempt += 1; + + const errCode = + err instanceof SdkError ? err.code : (options.code ?? null); + const strategy = errCode + ? RECOVERY_STRATEGY_TABLE[errCode] + : GENERIC_RECOVERY_STRATEGY; + + const maxAttempts = strategy.maxAttempts ?? 3; + const canRetry = strategy.retryable && attempt < maxAttempts; + + if (!canRetry || options.signal?.aborted) { + throw err; + } + + const backoffMs = (strategy.backoffMs ?? 500) * 2 ** (attempt - 1); + options.onRetry?.(attempt, strategy); + await sleep(backoffMs); + } + } +} + diff --git a/src/errorSuggestions.ts b/src/errorSuggestions.ts index 9cf2d289..596cbdfa 100644 --- a/src/errorSuggestions.ts +++ b/src/errorSuggestions.ts @@ -55,27 +55,101 @@ const SUGGESTION_TABLE: SuggestionEntry[] = [ // === Machine-readable error-code suggestions -// Keyed by SdkErrorCode. Consulted when getSuggestion is called with a code -// string or an SdkError instance, ahead of the Error-subclass and raw-pattern -// tables. Lookup normalizes the key with toUpperCase so a caller passing -// "account_not_found" resolves the same entry as "ACCOUNT_NOT_FOUND". +// Machine-readable error-code suggestions, keyed by SdkErrorCode. Consulted +// when getSuggestion is called with a code string or an SdkError instance, +// ahead of the Error-subclass and raw-pattern tables. Lookup normalizes the +// key with toUpperCase so a caller passing "account_not_found" resolves the +// same entry as "ACCOUNT_NOT_FOUND". +// +// Exhaustive by construction: every SdkErrorCode (issue #1067) must have a +// suggestion here, so new codes cannot be added without remediation guidance. const CODE_SUGGESTION_TABLE: Record = { + // Invoice / resource lifecycle [SdkErrorCode.INVOICE_NOT_FOUND]: "The requested invoice does not exist on-chain. Verify the invoice ID and ensure it was created on the correct network.", + [SdkErrorCode.INVOICE_NOT_PENDING]: + "This operation requires the invoice to be in Pending status. Check the invoice status before retrying.", + [SdkErrorCode.DEADLINE_EXPIRED]: + "The invoice deadline has passed. Create a new invoice with a future deadline if you still need to collect payment.", + [SdkErrorCode.INVOICE_FROZEN]: + "The invoice is currently frozen due to an active dispute or lock. Wait for the dispute to resolve before retrying.", + // Accounts & funds [SdkErrorCode.ACCOUNT_NOT_FOUND]: "The account does not exist on the Stellar network. Fund the account with a minimum XLM balance before retrying.", [SdkErrorCode.INSUFFICIENT_FUNDS]: "The account balance is too low to cover this operation. Top up the account and retry.", - [SdkErrorCode.DEADLINE_EXPIRED]: - "The invoice deadline has passed. Create a new invoice with a future deadline if you still need to collect payment.", + // Input validation + [SdkErrorCode.INVALID_INPUT]: + "One or more inputs failed validation. Review the operation parameters against the documented input rules and retry with corrected values.", + [SdkErrorCode.INVALID_ADDRESS]: + "One or more Stellar addresses are malformed. Each account address must be a 56-character base32 string starting with 'G' (or 'M' for muxed addresses) with a valid CRC16 checksum.", + [SdkErrorCode.INVALID_CONTRACT_ADDRESS]: + "The contract address is not a valid Soroban C-address. It must be 56 base32 characters starting with 'C' with a valid CRC16 checksum. Use validateContractAddress() to check it, or StrKey.encodeContract() to generate one.", + [SdkErrorCode.INVALID_AMOUNT]: + "The payment amount is malformed or out of range. Provide a positive integer amount in stroops (or a correctly formatted decimal string).", [SdkErrorCode.INVALID_RECIPIENT]: "One or more recipient addresses are invalid. Check that each recipient is a valid Stellar public key.", - [SdkErrorCode.CONTRACT_REJECTED]: - "The contract rejected the transaction. Review the operation parameters and the invoice state before retrying.", + [SdkErrorCode.INVALID_CONFIG]: + "The SDK configuration is invalid. Run the configuration validator and fix the reported fields before retrying.", + // Network / transport + [SdkErrorCode.NETWORK_ERROR]: + "A network-level failure occurred. Check your connectivity and retry; if it persists, try a fallback RPC or Horizon endpoint.", [SdkErrorCode.NETWORK_TIMEOUT]: "The network request timed out. Check your connection and retry; the transaction may still settle.", + [SdkErrorCode.RPC_FAILED]: + "The RPC node rejected or failed to process the request. Retry, or switch to a fallback RPC endpoint if the node appears unhealthy.", + [SdkErrorCode.HORIZON_ERROR]: + "Horizon returned an error response. Inspect the error payload, fix the request if it is malformed, and retry otherwise.", + [SdkErrorCode.SERVICE_UNAVAILABLE]: + "The network endpoint is temporarily unavailable. Wait briefly and retry, or switch to a fallback endpoint.", [SdkErrorCode.RATE_LIMITED]: "Requests are being rate limited. Back off and retry after a short delay.", + // Transaction lifecycle + [SdkErrorCode.TRANSACTION_FAILED]: + "The transaction failed on-chain. Inspect the transaction result and diagnostic events, fix the underlying cause, then build and submit a new transaction.", + [SdkErrorCode.TRANSACTION_EXPIRED]: + "The transaction expired before being included in a ledger. Rebuild it with fresh time bounds and a current sequence number, then resubmit.", + [SdkErrorCode.SEQUENCE_MISMATCH]: + "The transaction's sequence number is stale. Fetch the account's current sequence number, rebuild the transaction, and resubmit.", + [SdkErrorCode.INSUFFICIENT_FEE]: + "The transaction fee is too low. Increase the base fee (and resource fee, for Soroban) and rebuild the transaction.", + [SdkErrorCode.SUBMISSION_FAILED]: + "Submitting the transaction failed. Verify network connectivity and the transaction payload, then retry submission; check status before assuming failure.", + [SdkErrorCode.CONFIRMATION_TIMEOUT]: + "The transaction was not confirmed in time. Poll the transaction status by hash before resubmitting to avoid double submission.", + [SdkErrorCode.VERIFICATION_FAILED]: + "Pre-submit verification rejected the transaction. Review the failed checks (signatures, fee, sequence, time bounds) and rebuild the transaction before submitting.", + [SdkErrorCode.MISSING_SIGNATURE]: + "The transaction is missing a required signature. Collect all required signatures (per the account thresholds) before submitting.", + [SdkErrorCode.INVALID_SIGNATURE]: + "A signature on the transaction failed verification. Re-sign with the correct key over the exact transaction hash and rebuild the signature envelope.", + // Soroban contract execution + [SdkErrorCode.CONTRACT_NOT_FOUND]: + "The contract does not exist on this network. Verify the contract address and network passphrase; deploy the contract if it is missing.", + [SdkErrorCode.CONTRACT_REJECTED]: + "The contract rejected the transaction. Review the operation parameters and the invoice state before retrying.", + [SdkErrorCode.SIMULATION_FAILED]: + "Contract simulation failed. Inspect the simulation error and diagnostic events, correct the invocation, and resimulate before submitting.", + [SdkErrorCode.OUT_OF_RESOURCE]: + "The invocation exceeded the Soroban resource budget. Reduce the work done by the invocation or increase the resource fee and limits, then resimulate.", + // Authorization + [SdkErrorCode.UNAUTHORIZED]: + "You are not authorized to perform this action. Ensure you are signing with the correct account and hold the required role.", + [SdkErrorCode.WALLET_NOT_CONNECTED]: + "No wallet is connected. Connect and unlock your wallet before retrying the operation.", + [SdkErrorCode.AUTHENTICATION_FAILED]: + "Wallet or signer authentication failed. Re-authenticate with your wallet or signer and retry.", + // Operational / state + [SdkErrorCode.STATE_CONFLICT]: + "The operation conflicted with a newer on-chain state. Re-fetch the latest state, merge or reapply your changes, and retry.", + [SdkErrorCode.CIRCUIT_OPEN]: + "The circuit breaker is open because recent calls are failing. Wait for the cooldown window to close before retrying.", + [SdkErrorCode.OPERATION_ABORTED]: + "The operation was aborted before completion. Verify no partial state was committed, then retry if appropriate.", + [SdkErrorCode.BATCH_TOO_LARGE]: + "The batch exceeds the maximum permitted size. Split the batch into smaller chunks and process them sequentially.", + [SdkErrorCode.INTERNAL_ERROR]: + "An unexpected internal SDK error occurred. Retry once; if the failure persists, report it with the error context attached.", }; function suggestionForCode(code: string): string { diff --git a/src/errors.ts b/src/errors.ts index 7332b5f2..dac3bd2d 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -2213,16 +2213,112 @@ export class ChannelExhaustedError extends StellarSplitError { // so callers can switch on `err.code` instead of string-matching `.message`. // --------------------------------------------------------------------------- -/** Machine-readable error codes carried by {@link SdkError}. */ +/** + * Machine-readable error codes carried by {@link SdkError}. + * + * Grouped by failure category and designed to be comprehensive: every + * distinct SDK failure mode maps to exactly one code. Use + * {@link describeSdkErrorCode} for a human-readable description of each code + * and {@link isRetryableErrorCode} to check whether the failure is transient + * and safe to retry. + * + * @see {@link SDK_ERROR_CODE_DESCRIPTIONS} for per-code descriptions. + * @see docs/ERROR_CODES.md for the full error-code and recovery reference. + */ export enum SdkErrorCode { + // --- Invoice / resource lifecycle ----------------------------------- + /** The requested invoice does not exist on-chain. */ INVOICE_NOT_FOUND = "INVOICE_NOT_FOUND", + /** The invoice exists but is not in the Pending state required by the operation. */ + INVOICE_NOT_PENDING = "INVOICE_NOT_PENDING", + /** The invoice's payment deadline has passed. */ + DEADLINE_EXPIRED = "DEADLINE_EXPIRED", + /** The invoice is frozen (active dispute or lock) and cannot be modified. */ + INVOICE_FROZEN = "INVOICE_FROZEN", + + // --- Accounts & funds ----------------------------------------------- + /** The referenced Stellar account does not exist on the network. */ ACCOUNT_NOT_FOUND = "ACCOUNT_NOT_FOUND", + /** The account balance is too low to cover the operation. */ INSUFFICIENT_FUNDS = "INSUFFICIENT_FUNDS", - DEADLINE_EXPIRED = "DEADLINE_EXPIRED", + + // --- Input validation ------------------------------------------------ + /** A generic input failed validation (malformed JSON, out-of-range value, ...). */ + INVALID_INPUT = "INVALID_INPUT", + /** A Stellar account address (`G...`/`M...`) is malformed. */ + INVALID_ADDRESS = "INVALID_ADDRESS", + /** A Soroban contract address (`C...`) is malformed or fails its checksum. */ + INVALID_CONTRACT_ADDRESS = "INVALID_CONTRACT_ADDRESS", + /** A payment amount is malformed or out of range. */ + INVALID_AMOUNT = "INVALID_AMOUNT", + /** One or more recipient addresses are invalid. */ INVALID_RECIPIENT = "INVALID_RECIPIENT", - CONTRACT_REJECTED = "CONTRACT_REJECTED", + /** The SDK configuration is invalid. */ + INVALID_CONFIG = "INVALID_CONFIG", + + // --- Network / transport -------------------------------------------- + /** A generic network failure (DNS, TLS, connection reset, ...). */ + NETWORK_ERROR = "NETWORK_ERROR", + /** A network request timed out. */ NETWORK_TIMEOUT = "NETWORK_TIMEOUT", + /** The RPC node rejected or failed to process the request. */ + RPC_FAILED = "RPC_FAILED", + /** Horizon returned an error response. */ + HORIZON_ERROR = "HORIZON_ERROR", + /** The service is temporarily unavailable (overloaded or down). */ + SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE", + /** The endpoint is rate limiting requests. */ RATE_LIMITED = "RATE_LIMITED", + + // --- Transaction lifecycle ------------------------------------------ + /** The transaction was rejected or failed on-chain. */ + TRANSACTION_FAILED = "TRANSACTION_FAILED", + /** The transaction's time bounds elapsed before it was included in a ledger. */ + TRANSACTION_EXPIRED = "TRANSACTION_EXPIRED", + /** The transaction's sequence number does not match the account state. */ + SEQUENCE_MISMATCH = "SEQUENCE_MISMATCH", + /** The provided fee is below the network minimum or the resource fee quote. */ + INSUFFICIENT_FEE = "INSUFFICIENT_FEE", + /** Submitting the transaction to the network failed. */ + SUBMISSION_FAILED = "SUBMISSION_FAILED", + /** The transaction was submitted but was not confirmed in time. */ + CONFIRMATION_TIMEOUT = "CONFIRMATION_TIMEOUT", + /** Pre-submit transaction verification found one or more failed checks. */ + VERIFICATION_FAILED = "VERIFICATION_FAILED", + /** A required signature is missing from the transaction. */ + MISSING_SIGNATURE = "MISSING_SIGNATURE", + /** A signature present on the transaction failed cryptographic verification. */ + INVALID_SIGNATURE = "INVALID_SIGNATURE", + + // --- Soroban contract execution -------------------------------------- + /** The referenced contract does not exist on the network. */ + CONTRACT_NOT_FOUND = "CONTRACT_NOT_FOUND", + /** The contract rejected the transaction (panic or error result). */ + CONTRACT_REJECTED = "CONTRACT_REJECTED", + /** Simulating the contract invocation failed. */ + SIMULATION_FAILED = "SIMULATION_FAILED", + /** The invocation exceeded the Soroban CPU or memory budget. */ + OUT_OF_RESOURCE = "OUT_OF_RESOURCE", + + // --- Authorization --------------------------------------------------- + /** The caller is not authorized to perform the operation. */ + UNAUTHORIZED = "UNAUTHORIZED", + /** No wallet is connected (or the wallet connection was lost). */ + WALLET_NOT_CONNECTED = "WALLET_NOT_CONNECTED", + /** Wallet / signer authentication failed. */ + AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED", + + // --- Operational / state --------------------------------------------- + /** Concurrent modification produced a state conflict (e.g. merge conflict). */ + STATE_CONFLICT = "STATE_CONFLICT", + /** The circuit breaker is open and calls are being short-circuited. */ + CIRCUIT_OPEN = "CIRCUIT_OPEN", + /** The operation was aborted before completion. */ + OPERATION_ABORTED = "OPERATION_ABORTED", + /** A batch exceeds the maximum permitted size. */ + BATCH_TOO_LARGE = "BATCH_TOO_LARGE", + /** An unexpected internal SDK error. */ + INTERNAL_ERROR = "INTERNAL_ERROR", } /** Generic SDK error carrying a typed {@link SdkErrorCode} and optional details. */ @@ -2243,6 +2339,163 @@ export function isSdkError(err: unknown): err is SdkError { return err instanceof SdkError; } +/** + * Human-readable description for every {@link SdkErrorCode}. + * + * Exhaustive by construction: adding a new code to the enum without adding a + * description here is a compile error. + */ +export const SDK_ERROR_CODE_DESCRIPTIONS: Record = { + [SdkErrorCode.INVOICE_NOT_FOUND]: "The requested invoice does not exist on-chain.", + [SdkErrorCode.INVOICE_NOT_PENDING]: + "The invoice is not in the Pending state required by this operation.", + [SdkErrorCode.DEADLINE_EXPIRED]: "The invoice's payment deadline has passed.", + [SdkErrorCode.INVOICE_FROZEN]: "The invoice is frozen due to an active dispute or lock.", + [SdkErrorCode.ACCOUNT_NOT_FOUND]: "The referenced Stellar account does not exist on the network.", + [SdkErrorCode.INSUFFICIENT_FUNDS]: "The account balance is too low to cover the operation.", + [SdkErrorCode.INVALID_INPUT]: "A generic input failed validation.", + [SdkErrorCode.INVALID_ADDRESS]: "A Stellar account address is malformed.", + [SdkErrorCode.INVALID_CONTRACT_ADDRESS]: + "A Soroban contract address is malformed or fails its CRC16 checksum.", + [SdkErrorCode.INVALID_AMOUNT]: "A payment amount is malformed or out of range.", + [SdkErrorCode.INVALID_RECIPIENT]: "One or more recipient addresses are invalid.", + [SdkErrorCode.INVALID_CONFIG]: "The SDK configuration is invalid.", + [SdkErrorCode.NETWORK_ERROR]: "A network-level failure occurred (DNS, TLS, connection reset).", + [SdkErrorCode.NETWORK_TIMEOUT]: "A network request timed out.", + [SdkErrorCode.RPC_FAILED]: "The RPC node rejected or failed to process the request.", + [SdkErrorCode.HORIZON_ERROR]: "Horizon returned an error response.", + [SdkErrorCode.SERVICE_UNAVAILABLE]: "The network endpoint is temporarily unavailable.", + [SdkErrorCode.RATE_LIMITED]: "The endpoint is rate limiting requests.", + [SdkErrorCode.TRANSACTION_FAILED]: "The transaction was rejected or failed on-chain.", + [SdkErrorCode.TRANSACTION_EXPIRED]: + "The transaction's time bounds elapsed before it was included in a ledger.", + [SdkErrorCode.SEQUENCE_MISMATCH]: + "The transaction's sequence number does not match the account's current sequence.", + [SdkErrorCode.INSUFFICIENT_FEE]: "The provided fee is below the required minimum.", + [SdkErrorCode.SUBMISSION_FAILED]: "Submitting the transaction to the network failed.", + [SdkErrorCode.CONFIRMATION_TIMEOUT]: + "The transaction was submitted but was not confirmed in time.", + [SdkErrorCode.VERIFICATION_FAILED]: + "Pre-submit transaction verification found one or more failed checks.", + [SdkErrorCode.MISSING_SIGNATURE]: "A required signature is missing from the transaction.", + [SdkErrorCode.INVALID_SIGNATURE]: + "A signature on the transaction failed cryptographic verification.", + [SdkErrorCode.CONTRACT_NOT_FOUND]: "The referenced contract does not exist on the network.", + [SdkErrorCode.CONTRACT_REJECTED]: + "The contract rejected the transaction (panic or error result).", + [SdkErrorCode.SIMULATION_FAILED]: "Simulating the contract invocation failed.", + [SdkErrorCode.OUT_OF_RESOURCE]: "The invocation exceeded the Soroban CPU or memory budget.", + [SdkErrorCode.UNAUTHORIZED]: "The caller is not authorized to perform the operation.", + [SdkErrorCode.WALLET_NOT_CONNECTED]: "No wallet is connected, or the connection was lost.", + [SdkErrorCode.AUTHENTICATION_FAILED]: "Wallet or signer authentication failed.", + [SdkErrorCode.STATE_CONFLICT]: "Concurrent modification produced a state conflict.", + [SdkErrorCode.CIRCUIT_OPEN]: "The circuit breaker is open and calls are short-circuited.", + [SdkErrorCode.OPERATION_ABORTED]: "The operation was aborted before completion.", + [SdkErrorCode.BATCH_TOO_LARGE]: "A batch exceeds the maximum permitted size.", + [SdkErrorCode.INTERNAL_ERROR]: "An unexpected internal SDK error occurred.", +}; + +/** + * Codes that describe transient failures where retrying the same logical + * request (unchanged) is safe and may succeed. + * + * Deliberately conservative: transaction-state errors such as + * `SEQUENCE_MISMATCH` or `TRANSACTION_FAILED` are excluded because a blind + * retry can double-submit; they require rebuilding the transaction instead. + */ +export const RETRYABLE_ERROR_CODES: ReadonlySet = new Set([ + SdkErrorCode.NETWORK_TIMEOUT, + SdkErrorCode.NETWORK_ERROR, + SdkErrorCode.RPC_FAILED, + SdkErrorCode.HORIZON_ERROR, + SdkErrorCode.SERVICE_UNAVAILABLE, + SdkErrorCode.RATE_LIMITED, + SdkErrorCode.CONFIRMATION_TIMEOUT, +]); + +/** + * Return a human-readable description for an {@link SdkErrorCode}. + * + * Unknown strings fall back to a generic description so callers can safely + * pass through arbitrary code strings (e.g. from parsed error payloads). + * + * @param code - An SdkErrorCode member or any code string (matched + * case-insensitively). + */ +export function describeSdkErrorCode(code: SdkErrorCode | string): string { + return ( + SDK_ERROR_CODE_DESCRIPTIONS[code.toUpperCase() as SdkErrorCode] ?? + "Unknown SDK error code." + ); +} + +/** + * Report whether a code describes a transient failure that is safe to retry + * unchanged. Unknown codes are treated as not retryable. + * + * @param code - An SdkErrorCode member or any code string (matched + * case-insensitively). + */ +export function isRetryableErrorCode(code: SdkErrorCode | string): boolean { + return RETRYABLE_ERROR_CODES.has(code.toUpperCase() as SdkErrorCode); +} + +// --------------------------------------------------------------------------- +// Contract address validation error (issue #1065) +// --------------------------------------------------------------------------- + +/** + * Thrown when a string is not a well-formed Soroban contract address + * (`C...`, 56 base32 characters, valid CRC16 checksum). + */ +export class InvalidContractAddressError extends StellarSplitError { + /** The rejected address value. */ + readonly address: string; + /** Human-readable reason(s) the address was rejected. */ + readonly reasons: string[]; + + constructor(address: string, reasons: string[]) { + super( + `Invalid contract address: ${reasons.join("; ")}`, + "INVALID_CONTRACT_ADDRESS", + { address, reasons }, + ); + this.name = "InvalidContractAddressError"; + this.address = address; + this.reasons = reasons; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isInvalidContractAddressError(err: unknown): err is InvalidContractAddressError { + return err instanceof InvalidContractAddressError; +} + +// --------------------------------------------------------------------------- +// Transaction verification error (issue #1066) +// --------------------------------------------------------------------------- + +/** Thrown by transaction verification when one or more checks fail. */ +export class TransactionVerificationError extends StellarSplitError { + /** Names of the verification checks that failed. */ + readonly failedChecks: string[]; + + constructor(failedChecks: string[], context?: Record) { + super( + `Transaction verification failed: ${failedChecks.join(", ")}`, + "VERIFICATION_FAILED", + { failedChecks, ...context }, + ); + this.name = "TransactionVerificationError"; + this.failedChecks = failedChecks; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isTransactionVerificationError(err: unknown): err is TransactionVerificationError { + return err instanceof TransactionVerificationError; +} + // --------------------------------------------------------------------------- // Three-way merge errors (issue #703) // --------------------------------------------------------------------------- diff --git a/src/index.ts b/src/index.ts index bf64e06a..f0fe05be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -544,6 +544,56 @@ export type { AccountFlagSet } from "./types.js"; export { getSuggestion } from "./errorSuggestions.js"; +// Comprehensive error codes — descriptions and retryability (issue #1067) +export { + SDK_ERROR_CODE_DESCRIPTIONS, + RETRYABLE_ERROR_CODES, + describeSdkErrorCode, + isRetryableErrorCode, + InvalidContractAddressError, + isInvalidContractAddressError, + TransactionVerificationError, + isTransactionVerificationError, +} from "./errors.js"; + +// Soroban contract address validation (issue #1065) +export { + validateContractAddress, + isValidContractAddress, + assertValidContractAddress, + CONTRACT_ADDRESS_LENGTH, + CONTRACT_ADDRESS_PREFIX, +} from "./contractAddressValidator.js"; +export type { ContractAddressValidationResult } from "./contractAddressValidator.js"; + +// Pre-submit transaction verification (issue #1066) +export { + verifyTransaction, + verifyTransactionSignatures, + assertTransactionVerified, +} from "./transactionVerifier.js"; +export type { + TransactionCheckName, + TransactionCheckResult, + TransactionVerificationOptions, + TransactionVerificationResult, + SignatureVerificationResult, +} from "./transactionVerifier.js"; + +// Error recovery strategies (issue #1068) +export { + RECOVERY_STRATEGY_TABLE, + getRecoveryStrategy, + getRecoveryPlan, + executeWithRecovery, +} from "./errorRecovery.js"; +export type { + RecoveryAction, + RecoveryStrategy, + RecoveryPlan, + RecoveryRunOptions, +} from "./errorRecovery.js"; + // --------------------------------------------------------------------------- // XDR Decoder — structured logging of Stellar XDR // --------------------------------------------------------------------------- diff --git a/src/transactionVerifier.ts b/src/transactionVerifier.ts new file mode 100644 index 00000000..b5a380e5 --- /dev/null +++ b/src/transactionVerifier.ts @@ -0,0 +1,316 @@ +/** + * Pre-submit transaction verification for StellarSplit (issue #1066). + * + * Verifies the integrity and authenticity of a Stellar transaction before it + * is submitted to the network. Each concern is an isolated, independently + * testable check, and the composed {@link verifyTransaction} runs every check + * and returns a structured result. Use {@link assertTransactionVerified} to + * fail fast with a {@link TransactionVerificationError}. + * + * ## Checks + * + * - `structure` — at least one operation and a parseable positive integer fee. + * - `fee` — the fee is positive and (optionally) at or below `maxFee`. + * - `sequence` — the sequence number equals `expectedSequence`, when provided. + * - `timebounds`— "now" lies within the transaction's time bounds. + * - `signatures`— every required signer holds a valid signature over the hash. + * - `hash` — the recomputed hash equals `expectedHash`, when provided. + * + * @example + * ```ts + * import { verifyTransaction, assertTransactionVerified } from "@stellar-split/sdk"; + * + * const result = verifyTransaction(tx, { + * signers: [walletPublicKey], + * maxFee: 500_000, + * expectedSequence: account.sequenceNumber(), + * }); + * if (!result.valid) { + * console.warn("Refusing to submit:", result.failedChecks); + * } + * + * // Or fail fast on the first run with any failed check: + * assertTransactionVerified(tx, { signers: [walletPublicKey] }); + * ``` + */ + +import { Keypair, Transaction } from "@stellar/stellar-sdk"; +import { TransactionVerificationError } from "./errors.js"; + +/** Names of the individual checks performed by {@link verifyTransaction}. */ +export type TransactionCheckName = + | "structure" + | "fee" + | "sequence" + | "timebounds" + | "signatures" + | "hash"; + +/** Outcome of a single verification check. */ +export interface TransactionCheckResult { + /** The check this result describes. */ + name: TransactionCheckName; + /** `true` if the check passed. */ + passed: boolean; + /** Human-readable detail explaining the outcome. */ + detail: string; +} + +/** Aggregated result of running every verification check. */ +export interface TransactionVerificationResult { + /** `true` when every check passed. */ + valid: boolean; + /** Per-check results, in execution order. */ + checks: TransactionCheckResult[]; + /** Names of the checks that failed (empty when `valid`). */ + failedChecks: TransactionCheckName[]; +} + +/** Options controlling which checks {@link verifyTransaction} performs. */ +export interface TransactionVerificationOptions { + /** Maximum acceptable total fee, in stroops. Fee check is skipped when omitted. */ + maxFee?: number | bigint | string; + /** The exact sequence number the transaction must carry. Skipped when omitted. */ + expectedSequence?: string; + /** The hex- or base64-encoded hash the transaction must match. Skipped when omitted. */ + expectedHash?: string; + /** `G...` public keys that must each hold a valid signature over the tx hash. */ + signers?: string[]; + /** Minimum number of signature entries the transaction must carry (default: 1). */ + minSignatures?: number; + /** Unix time (seconds) used for the time-bounds check (default: now). */ + now?: number; +} + +/** Outcome of verifying the signatures of a transaction for a set of keys. */ +export interface SignatureVerificationResult { + /** Signers that produced a valid signature over the transaction hash. */ + valid: string[]; + /** Signers with no signature present on the transaction. */ + missing: string[]; + /** Signers with a signature present that failed cryptographic verification. */ + invalid: string[]; +} + +function toBigIntSafe(value: string | number | bigint): bigint | null { + try { + return BigInt(value); + } catch { + return null; + } +} + +/** + * Verify that each of `publicKeys` holds a valid signature over `tx`'s hash. + * + * Decorated signatures carry a 4-byte hint derived from the signer's public + * key; candidates are filtered by hint and then verified cryptographically + * with ed25519 over the 32-byte transaction hash. Hints are advisory only — + * a signature counts as valid solely when ed25519 verification succeeds. + * + * @param tx - The (partially or fully) signed transaction. + * @param publicKeys - `G...` keys expected to have signed the transaction. + */ +export function verifyTransactionSignatures( + tx: Transaction, + publicKeys: string[], +): SignatureVerificationResult { + const result: SignatureVerificationResult = { valid: [], missing: [], invalid: [] }; + if (publicKeys.length === 0) { + return result; + } + + const hash = tx.hash(); + + for (const publicKey of publicKeys) { + let keypair: Keypair; + try { + keypair = Keypair.fromPublicKey(publicKey); + } catch { + // Not a parseable G-address — it can never hold a valid signature. + result.invalid.push(publicKey); + continue; + } + + const keyHint = keypair.signatureHint().toString("hex"); + const candidates = tx.signatures.filter( + (sig) => Buffer.from(sig.hint()).toString("hex") === keyHint, + ); + + if (candidates.length === 0) { + result.missing.push(publicKey); + continue; + } + + let verified = false; + for (const decorated of candidates) { + try { + if (keypair.verify(hash, Buffer.from(decorated.signature()))) { + verified = true; + break; + } + } catch { + // Malformed signature payload — treated as a failed verification. + } + } + + if (verified) { + result.valid.push(publicKey); + } else { + result.invalid.push(publicKey); + } + } + + return result; +} + +/** + * Run every verification check against `tx` and return the structured result. + * + * Checks whose options are omitted (e.g. `maxFee`, `expectedSequence`, + * `expectedHash`) are skipped rather than passed vacuously, so callers only + * assert the properties they can vouch for. + * + * @param tx - The transaction to verify. + * @param options - Which additional properties the transaction must satisfy. + */ +export function verifyTransaction( + tx: Transaction, + options: TransactionVerificationOptions = {}, +): TransactionVerificationResult { + const checks: TransactionCheckResult[] = []; + + // --- structure --------------------------------------------------------- + const operationCount = tx.operations.length; + const rawFee = toBigIntSafe(tx.fee); + const structureOk = operationCount > 0 && rawFee !== null && rawFee > 0n; + checks.push({ + name: "structure", + passed: structureOk, + detail: structureOk + ? `Transaction has ${operationCount} operation(s) and fee ${tx.fee}` + : `Malformed transaction: ${operationCount} operation(s), fee "${tx.fee}"`, + }); + + // --- fee --------------------------------------------------------------- + if (options.maxFee !== undefined) { + const maxFee = toBigIntSafe(options.maxFee); + const feeOk = rawFee !== null && maxFee !== null && rawFee > 0n && rawFee <= maxFee; + checks.push({ + name: "fee", + passed: feeOk, + detail: feeOk + ? `Fee ${tx.fee} is within max ${String(options.maxFee)}` + : `Fee ${tx.fee} exceeds maximum ${String(options.maxFee)} (or is not a positive integer)`, + }); + } + + // --- sequence ---------------------------------------------------------- + if (options.expectedSequence !== undefined) { + const seqOk = tx.sequence === options.expectedSequence; + checks.push({ + name: "sequence", + passed: seqOk, + detail: seqOk + ? `Sequence ${tx.sequence} matches expected` + : `Sequence ${tx.sequence} does not match expected ${options.expectedSequence}`, + }); + } + + // --- time bounds ------------------------------------------------------- + const timeBounds = tx.timeBounds; + if (timeBounds !== undefined && timeBounds !== null) { + const now = BigInt(options.now ?? Math.floor(Date.now() / 1000)); + const minTime = BigInt(timeBounds.minTime); + const maxTime = BigInt(timeBounds.maxTime); + const afterMin = now >= minTime; + // maxTime is exclusive; 0 means "no upper bound". + const beforeMax = maxTime === 0n || now < maxTime; + const boundsOk = afterMin && beforeMax; + checks.push({ + name: "timebounds", + passed: boundsOk, + detail: boundsOk + ? `Now (${now}) is within [${timeBounds.minTime}, ${timeBounds.maxTime})` + : `Now (${now}) is outside time bounds [${timeBounds.minTime}, ${timeBounds.maxTime})`, + }); + } + + // --- signatures -------------------------------------------------------- + // Without an explicit signer list, at least one signature entry must be + // present; with signers, minSignatures defaults to 0 and each listed key + // must hold a cryptographically valid signature. + const minSignatures = options.minSignatures ?? (options.signers ? 0 : 1); + const signatureDetailParts: string[] = []; + let signaturesOk = tx.signatures.length >= Math.max(minSignatures, 0); + + if (!signaturesOk) { + signatureDetailParts.push( + `Transaction carries ${tx.signatures.length} signature(s); ${minSignatures} required`, + ); + } + + if (options.signers && options.signers.length > 0) { + const sigResult = verifyTransactionSignatures(tx, options.signers); + if (sigResult.valid.length > 0) { + signatureDetailParts.push(`valid: [${sigResult.valid.join(", ")}]`); + } + if (sigResult.missing.length > 0) { + signaturesOk = false; + signatureDetailParts.push(`missing: [${sigResult.missing.join(", ")}]`); + } + if (sigResult.invalid.length > 0) { + signaturesOk = false; + signatureDetailParts.push(`invalid: [${sigResult.invalid.join(", ")}]`); + } + } else if (signaturesOk) { + signatureDetailParts.push(`${tx.signatures.length} signature(s) present`); + } + + checks.push({ + name: "signatures", + passed: signaturesOk, + detail: signatureDetailParts.join("; "), + }); + + // --- hash integrity ---------------------------------------------------- + if (options.expectedHash !== undefined) { + const hash = tx.hash(); + const expected = options.expectedHash.toLowerCase(); + const hashOk = + hash.toString("hex").toLowerCase() === expected || + hash.toString("base64") === options.expectedHash; + checks.push({ + name: "hash", + passed: hashOk, + detail: hashOk + ? "Transaction hash matches expected" + : `Transaction hash ${hash.toString("hex")} does not match expected ${options.expectedHash}`, + }); + } + + const failedChecks = checks.filter((c) => !c.passed).map((c) => c.name); + return { valid: failedChecks.length === 0, checks, failedChecks }; +} + +/** + * Verify `tx` and throw {@link TransactionVerificationError} if any check + * fails; otherwise return the successful result. + * + * @param tx - The transaction to verify. + * @param options - Which additional properties the transaction must satisfy. + * @throws {TransactionVerificationError} Carries the names of the failed checks. + */ +export function assertTransactionVerified( + tx: Transaction, + options: TransactionVerificationOptions = {}, +): TransactionVerificationResult { + const result = verifyTransaction(tx, options); + if (!result.valid) { + throw new TransactionVerificationError(result.failedChecks, { + checks: result.checks, + }); + } + return result; +} + diff --git a/test/contractAddressValidator.test.ts b/test/contractAddressValidator.test.ts new file mode 100644 index 00000000..b0f6e176 --- /dev/null +++ b/test/contractAddressValidator.test.ts @@ -0,0 +1,187 @@ +/** + * Tests for issue #1065 — Contract address validation + * + * Verifies StrKey format rules (length, prefix, base32 alphabet), CRC16 + * checksum validation, known-address acceptance, and the throwing helper. + */ +import { describe, it, expect } from "vitest"; +import { StrKey } from "@stellar/stellar-sdk"; +import { + validateContractAddress, + isValidContractAddress, + assertValidContractAddress, + CONTRACT_ADDRESS_LENGTH, + CONTRACT_ADDRESS_PREFIX, +} from "../src/contractAddressValidator.js"; +import { + StellarSplitError, + InvalidContractAddressError, + isInvalidContractAddressError, +} from "../src/errors.js"; + +// Deterministic, known-valid contract addresses (generated with +// StrKey.encodeContract over fixed 32-byte payloads). +const ZERO_PAYLOAD_ADDRESS = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +const PATTERN_PAYLOAD_ADDRESS = "CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526"; +const FF_PAYLOAD_ADDRESS = "CD7777777777777777777777777777777777777777777777777767GY"; + +// ZERO_PAYLOAD_ADDRESS with the final checksum character corrupted: +// format (length/prefix/alphabet) intact, CRC16 broken. +const BROKEN_CHECKSUM_ADDRESS = `${ZERO_PAYLOAD_ADDRESS.slice(0, 55)}${ + ZERO_PAYLOAD_ADDRESS[55] === "4" ? "5" : "4" +}`; + +// A well-formed non-contract address that must be rejected. +const G_ADDRESS = "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"; + +describe("validateContractAddress — known valid addresses (#1065)", () => { + it.each([ + ["all-zero payload", ZERO_PAYLOAD_ADDRESS], + ["patterned payload", PATTERN_PAYLOAD_ADDRESS], + ["0xff payload", FF_PAYLOAD_ADDRESS], + ])("accepts a known-valid address (%s)", (_label, address) => { + const result = validateContractAddress(address); + expect(result.valid).toBe(true); + expect(result.format).toBe(true); + expect(result.checksum).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("accepts addresses round-tripped through StrKey.encodeContract", () => { + // Regenerate a known address to prove the documented rules match the SDK. + expect(StrKey.encodeContract(Buffer.alloc(32, 0))).toBe(ZERO_PAYLOAD_ADDRESS); + expect(validateContractAddress(StrKey.encodeContract(Buffer.alloc(32, 0))).valid).toBe(true); + }); +}); + +describe("validateContractAddress — format rules (#1065)", () => { + it("rejects an empty string", () => { + const result = validateContractAddress(""); + expect(result.valid).toBe(false); + expect(result.format).toBe(false); + expect(result.errors[0]).toContain("non-empty string"); + }); + + it("rejects non-string values", () => { + for (const value of [null, undefined, 42, {}, []]) { + const result = validateContractAddress(value); + expect(result.valid).toBe(false); + expect(result.format).toBe(false); + } + }); + + it("rejects an address that is too short", () => { + const result = validateContractAddress("CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASC4"); + expect(result.valid).toBe(false); + expect(result.format).toBe(false); + expect(result.checksum).toBe(false); + expect(result.errors.some((e) => e.includes(`exactly ${CONTRACT_ADDRESS_LENGTH} characters`))).toBe( + true, + ); + }); + + it("rejects an address that is too long", () => { + const result = validateContractAddress(`${ZERO_PAYLOAD_ADDRESS}AAAA`); + expect(result.valid).toBe(false); + expect(result.format).toBe(false); + }); + + it("rejects an address without the C prefix", () => { + const result = validateContractAddress(G_ADDRESS); + expect(result.valid).toBe(false); + expect(result.format).toBe(false); + expect(result.errors.some((e) => e.includes(`must start with '${CONTRACT_ADDRESS_PREFIX}'`))).toBe( + true, + ); + }); + + it("rejects a lowercase address (base32 is case-sensitive)", () => { + const result = validateContractAddress(ZERO_PAYLOAD_ADDRESS.toLowerCase()); + expect(result.valid).toBe(false); + expect(result.format).toBe(false); + expect(result.errors.some((e) => e.includes("base32"))).toBe(true); + }); + + it("rejects characters outside the base32 alphabet", () => { + // Replace a mid-string character with '1' (not in A-Z/2-7). + const mutated = `${ZERO_PAYLOAD_ADDRESS.slice(0, 20)}1${ZERO_PAYLOAD_ADDRESS.slice(21)}`; + const result = validateContractAddress(mutated); + expect(result.valid).toBe(false); + expect(result.format).toBe(false); + }); + + it("rejects non-contract StrKey types", () => { + expect(isValidContractAddress(G_ADDRESS)).toBe(false); + expect(isValidContractAddress("SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")).toBe( + false, + ); + }); +}); + +describe("validateContractAddress — checksum rule (#1065)", () => { + it("rejects a well-formed address with a broken CRC16 checksum", () => { + const result = validateContractAddress(BROKEN_CHECKSUM_ADDRESS); + expect(result.valid).toBe(false); + // Format is fine — only the checksum fails, and the two are reported apart. + expect(result.format).toBe(true); + expect(result.checksum).toBe(false); + expect(result.errors).toEqual(["Contract address failed CRC16 checksum validation"]); + }); + + it("flags checksum=false (not format=false) for corrupted payload bytes", () => { + // Flip a mid-string payload character; length/prefix/alphabet stay intact. + const corrupted = `${ZERO_PAYLOAD_ADDRESS.slice(0, 40)}${ + ZERO_PAYLOAD_ADDRESS[40] === "A" ? "B" : "A" + }${ZERO_PAYLOAD_ADDRESS.slice(41)}`; + const result = validateContractAddress(corrupted); + if (result.valid) { + // Vanishingly unlikely collision — still must be self-consistent. + expect(result.checksum).toBe(true); + } else { + expect(result.format).toBe(true); + expect(result.checksum).toBe(false); + } + }); +}); + +describe("isValidContractAddress (#1065)", () => { + it("returns true for known-valid addresses", () => { + expect(isValidContractAddress(ZERO_PAYLOAD_ADDRESS)).toBe(true); + expect(isValidContractAddress(PATTERN_PAYLOAD_ADDRESS)).toBe(true); + }); + + it("returns false for invalid addresses and non-strings", () => { + expect(isValidContractAddress(BROKEN_CHECKSUM_ADDRESS)).toBe(false); + expect(isValidContractAddress(G_ADDRESS)).toBe(false); + expect(isValidContractAddress("")).toBe(false); + expect(isValidContractAddress(null as unknown as string)).toBe(false); + }); +}); + +describe("assertValidContractAddress (#1065)", () => { + it("does not throw for a valid address", () => { + expect(() => assertValidContractAddress(ZERO_PAYLOAD_ADDRESS)).not.toThrow(); + }); + + it("throws InvalidContractAddressError for a broken checksum", () => { + let thrown: unknown; + try { + assertValidContractAddress(BROKEN_CHECKSUM_ADDRESS); + } catch (err) { + thrown = err; + } + expect(thrown).toBeInstanceOf(InvalidContractAddressError); + expect(thrown).toBeInstanceOf(StellarSplitError); + const err = thrown as InvalidContractAddressError; + expect(err.code).toBe("INVALID_CONTRACT_ADDRESS"); + expect(err.address).toBe(BROKEN_CHECKSUM_ADDRESS); + expect(err.reasons.some((r) => r.includes("checksum"))).toBe(true); + }); + + it("isInvalidContractAddressError guard works", () => { + expect(isInvalidContractAddressError(new InvalidContractAddressError("x", ["bad"]))).toBe(true); + expect(isInvalidContractAddressError(new Error("plain"))).toBe(false); + expect(isInvalidContractAddressError(null)).toBe(false); + }); +}); + diff --git a/test/errorCodes.test.ts b/test/errorCodes.test.ts new file mode 100644 index 00000000..04d52f4d --- /dev/null +++ b/test/errorCodes.test.ts @@ -0,0 +1,161 @@ +/** + * Tests for issue #1067 — Comprehensive error codes + * + * Verifies that every SdkErrorCode has a description and a suggestion, that + * retryability is classified correctly, and that the new typed errors work. + */ +import { describe, it, expect } from "vitest"; +import { + SdkError, + SdkErrorCode, + SDK_ERROR_CODE_DESCRIPTIONS, + RETRYABLE_ERROR_CODES, + describeSdkErrorCode, + isRetryableErrorCode, + StellarSplitError, + TransactionVerificationError, + isTransactionVerificationError, +} from "../src/errors.js"; +import { getSuggestion } from "../src/errorSuggestions.js"; + +describe("SdkErrorCode — comprehensive coverage (#1067)", () => { + it("includes the original codes from issue #607", () => { + for (const code of [ + SdkErrorCode.INVOICE_NOT_FOUND, + SdkErrorCode.ACCOUNT_NOT_FOUND, + SdkErrorCode.INSUFFICIENT_FUNDS, + SdkErrorCode.DEADLINE_EXPIRED, + SdkErrorCode.INVALID_RECIPIENT, + SdkErrorCode.CONTRACT_REJECTED, + SdkErrorCode.NETWORK_TIMEOUT, + SdkErrorCode.RATE_LIMITED, + ]) { + expect(Object.values(SdkErrorCode)).toContain(code); + } + }); + + it("has unique string values that match their member names", () => { + const values = Object.values(SdkErrorCode); + expect(new Set(values).size).toBe(values.length); + for (const [name, value] of Object.entries(SdkErrorCode)) { + expect(value).toBe(name); + } + }); + + it("covers every failure category with at least one code", () => { + const values = Object.values(SdkErrorCode); + // One representative per category — guards against accidental removal. + expect(values).toContain(SdkErrorCode.INVALID_CONTRACT_ADDRESS); + expect(values).toContain(SdkErrorCode.VERIFICATION_FAILED); + expect(values).toContain(SdkErrorCode.SEQUENCE_MISMATCH); + expect(values).toContain(SdkErrorCode.SIMULATION_FAILED); + expect(values).toContain(SdkErrorCode.WALLET_NOT_CONNECTED); + expect(values).toContain(SdkErrorCode.CIRCUIT_OPEN); + expect(values).toContain(SdkErrorCode.INTERNAL_ERROR); + expect(values.length).toBeGreaterThanOrEqual(30); + }); +}); + +describe("SDK_ERROR_CODE_DESCRIPTIONS (#1067)", () => { + it("has a non-empty description for every code", () => { + for (const code of Object.values(SdkErrorCode)) { + const description = SDK_ERROR_CODE_DESCRIPTIONS[code]; + expect(description, `missing description for ${code}`).toBeTruthy(); + expect(description.length).toBeGreaterThan(10); + } + }); + + it("describeSdkErrorCode returns the description for a code", () => { + expect(describeSdkErrorCode(SdkErrorCode.SEQUENCE_MISMATCH)).toBe( + SDK_ERROR_CODE_DESCRIPTIONS[SdkErrorCode.SEQUENCE_MISMATCH], + ); + }); + + it("describeSdkErrorCode is case-insensitive for strings", () => { + expect(describeSdkErrorCode("rate_limited")).toBe( + SDK_ERROR_CODE_DESCRIPTIONS[SdkErrorCode.RATE_LIMITED], + ); + }); + + it("describeSdkErrorCode falls back for unknown codes", () => { + expect(describeSdkErrorCode("NOT_A_REAL_CODE")).toBe("Unknown SDK error code."); + }); +}); + +describe("RETRYABLE_ERROR_CODES (#1067)", () => { + it("marks transient network failures as retryable", () => { + for (const code of [ + SdkErrorCode.NETWORK_TIMEOUT, + SdkErrorCode.NETWORK_ERROR, + SdkErrorCode.RPC_FAILED, + SdkErrorCode.HORIZON_ERROR, + SdkErrorCode.SERVICE_UNAVAILABLE, + SdkErrorCode.RATE_LIMITED, + SdkErrorCode.CONFIRMATION_TIMEOUT, + ]) { + expect(isRetryableErrorCode(code), `${code} should be retryable`).toBe(true); + } + }); + + it("does not mark state/input failures as retryable", () => { + for (const code of [ + SdkErrorCode.SEQUENCE_MISMATCH, + SdkErrorCode.TRANSACTION_FAILED, + SdkErrorCode.INVALID_INPUT, + SdkErrorCode.UNAUTHORIZED, + SdkErrorCode.INVOICE_FROZEN, + SdkErrorCode.DEADLINE_EXPIRED, + ]) { + expect(isRetryableErrorCode(code), `${code} should not be retryable`).toBe(false); + } + }); + + it("isRetryableErrorCode is case-insensitive and safe for unknown codes", () => { + expect(isRetryableErrorCode("network_timeout")).toBe(true); + expect(isRetryableErrorCode("bogus_code")).toBe(false); + }); + + it("RETRYABLE_ERROR_CODES only contains real enum members", () => { + const values = new Set(Object.values(SdkErrorCode)); + for (const code of RETRYABLE_ERROR_CODES) { + expect(values.has(code)).toBe(true); + } + }); +}); + +describe("every code has a remediation suggestion (#1067)", () => { + it("getSuggestion returns a specific suggestion (not the fallback) for every code", () => { + const fallback = getSuggestion("TOTALLY_UNKNOWN_CODE"); + for (const code of Object.values(SdkErrorCode)) { + expect(getSuggestion(code), `no suggestion for ${code}`).not.toBe(fallback); + expect(getSuggestion(code).length).toBeGreaterThan(20); + } + }); +}); + +describe("TransactionVerificationError (#1066/#1067)", () => { + it("carries the failed check names and VERIFICATION_FAILED code", () => { + const err = new TransactionVerificationError(["signatures", "fee"]); + expect(err).toBeInstanceOf(StellarSplitError); + expect(err.code).toBe("VERIFICATION_FAILED"); + expect(err.failedChecks).toEqual(["signatures", "fee"]); + expect(err.message).toContain("signatures"); + expect(err.message).toContain("fee"); + }); + + it("isTransactionVerificationError guard works", () => { + expect(isTransactionVerificationError(new TransactionVerificationError(["x"]))).toBe(true); + expect(isTransactionVerificationError(new Error("plain"))).toBe(false); + expect(isTransactionVerificationError(null)).toBe(false); + }); +}); + +describe("SdkError with extended codes (#1067)", () => { + it("constructs with a new code and preserves details", () => { + const err = new SdkError("stale sequence", SdkErrorCode.SEQUENCE_MISMATCH, { expected: "5" }); + expect(err).toBeInstanceOf(SdkError); + expect(err.code).toBe(SdkErrorCode.SEQUENCE_MISMATCH); + expect(err.details).toEqual({ expected: "5" }); + }); +}); + diff --git a/test/errorRecovery.test.ts b/test/errorRecovery.test.ts new file mode 100644 index 00000000..db85b12f --- /dev/null +++ b/test/errorRecovery.test.ts @@ -0,0 +1,220 @@ +/** + * Tests for issue #1068 — Error recovery strategies + * + * Verifies exhaustive strategy mapping, plan composition (strategy + + * suggestion), and the automatic retry executor. + */ +import { describe, it, expect, vi } from "vitest"; +import { + RECOVERY_STRATEGY_TABLE, + getRecoveryStrategy, + getRecoveryPlan, + executeWithRecovery, +} from "../src/errorRecovery.js"; +import { + SdkError, + SdkErrorCode, + StellarSplitError, + InvoiceNotFoundError, +} from "../src/errors.js"; +import { getSuggestion } from "../src/errorSuggestions.js"; + +describe("RECOVERY_STRATEGY_TABLE (#1068)", () => { + it("has an entry for every SdkErrorCode", () => { + for (const code of Object.values(SdkErrorCode)) { + expect(RECOVERY_STRATEGY_TABLE[code], `missing strategy for ${code}`).toBeDefined(); + expect(RECOVERY_STRATEGY_TABLE[code].description.length).toBeGreaterThan(10); + } + }); + + it("marks retryable strategies with backoff parameters", () => { + for (const [code, strategy] of Object.entries(RECOVERY_STRATEGY_TABLE)) { + if (strategy.retryable) { + expect(strategy.maxAttempts, `${code} missing maxAttempts`).toBeGreaterThan(1); + expect(strategy.backoffMs, `${code} missing backoffMs`).toBeGreaterThan(0); + } + } + }); + + it("maps transient network codes to retry/backoff actions", () => { + for (const code of [ + SdkErrorCode.NETWORK_TIMEOUT, + SdkErrorCode.RATE_LIMITED, + SdkErrorCode.RPC_FAILED, + ]) { + const strategy = RECOVERY_STRATEGY_TABLE[code]; + expect(strategy.retryable).toBe(true); + expect(["retry", "retry_with_backoff", "use_fallback"]).toContain(strategy.action); + } + }); + + it("maps input/state codes to non-retryable actions", () => { + expect(RECOVERY_STRATEGY_TABLE[SdkErrorCode.INVALID_CONTRACT_ADDRESS].action).toBe( + "correct_input", + ); + expect(RECOVERY_STRATEGY_TABLE[SdkErrorCode.SEQUENCE_MISMATCH].action).toBe( + "refresh_and_retry", + ); + expect(RECOVERY_STRATEGY_TABLE[SdkErrorCode.SEQUENCE_MISMATCH].retryable).toBe(false); + expect(RECOVERY_STRATEGY_TABLE[SdkErrorCode.CONTRACT_REJECTED].action).toBe("abort"); + expect(RECOVERY_STRATEGY_TABLE[SdkErrorCode.INVOICE_FROZEN].action).toBe( + "wait_for_resolution", + ); + }); +}); + +describe("getRecoveryStrategy (#1068)", () => { + it("resolves an SdkErrorCode enum value", () => { + expect(getRecoveryStrategy(SdkErrorCode.RATE_LIMITED)).toBe( + RECOVERY_STRATEGY_TABLE[SdkErrorCode.RATE_LIMITED], + ); + }); + + it("resolves a code string case-insensitively", () => { + expect(getRecoveryStrategy("network_timeout")).toBe( + RECOVERY_STRATEGY_TABLE[SdkErrorCode.NETWORK_TIMEOUT], + ); + }); + + it("resolves an SdkError instance via its code", () => { + const err = new SdkError("limited", SdkErrorCode.RATE_LIMITED); + expect(getRecoveryStrategy(err)).toBe(RECOVERY_STRATEGY_TABLE[SdkErrorCode.RATE_LIMITED]); + }); + + it("resolves other StellarSplitError instances via their code", () => { + const err = new InvoiceNotFoundError("inv-1"); + expect(getRecoveryStrategy(err)).toBe( + RECOVERY_STRATEGY_TABLE[SdkErrorCode.INVOICE_NOT_FOUND], + ); + }); + + it("falls back to a conservative strategy for unknown inputs", () => { + const strategy = getRecoveryStrategy(new Error("boom")); + expect(strategy.retryable).toBe(true); + expect(strategy.action).toBe("retry_with_backoff"); + expect(getRecoveryStrategy("not_a_code").retryable).toBe(true); + }); + + it("returns the same object identity as the table entry", () => { + const err = new SdkError("x", SdkErrorCode.SEQUENCE_MISMATCH); + expect(getRecoveryStrategy(err)).toBe(RECOVERY_STRATEGY_TABLE[SdkErrorCode.SEQUENCE_MISMATCH]); + }); +}); + +describe("getRecoveryPlan (#1068)", () => { + it("combines the mapped strategy with a suggestion for an SdkError", () => { + const err = new SdkError("account missing", SdkErrorCode.ACCOUNT_NOT_FOUND); + const plan = getRecoveryPlan(err); + + expect(plan.code).toBe(SdkErrorCode.ACCOUNT_NOT_FOUND); + expect(plan.strategy).toBe(RECOVERY_STRATEGY_TABLE[SdkErrorCode.ACCOUNT_NOT_FOUND]); + expect(plan.suggestion).toBe(getSuggestion(err)); + expect(plan.suggestion).toContain("account does not exist"); + }); + + it("works for typed StellarSplitError subclasses too", () => { + const plan = getRecoveryPlan(new InvoiceNotFoundError("inv-9")); + expect(plan.code).toBe(SdkErrorCode.INVOICE_NOT_FOUND); + expect(plan.strategy.action).toBe("correct_input"); + expect(plan.suggestion).toContain("does not exist on-chain"); + }); + + it("returns code=null for unrecognized errors", () => { + const plan = getRecoveryPlan(new Error("mystery")); + expect(plan.code).toBeNull(); + expect(plan.strategy.retryable).toBe(true); + expect(plan.suggestion.length).toBeGreaterThan(0); + }); + + it("accepts raw code strings", () => { + const plan = getRecoveryPlan("rate_limited"); + expect(plan.code).toBe(SdkErrorCode.RATE_LIMITED); + expect(plan.strategy.retryable).toBe(true); + }); +}); + +describe("executeWithRecovery (#1068)", () => { + const noSleep = () => Promise.resolve(); + + it("returns the first successful result without retrying", async () => { + const op = vi.fn().mockResolvedValue("ok"); + await expect(executeWithRecovery(op, { sleep: noSleep })).resolves.toBe("ok"); + expect(op).toHaveBeenCalledTimes(1); + }); + + it("retries a retryable error code until it succeeds", async () => { + const err = new SdkError("timed out", SdkErrorCode.NETWORK_TIMEOUT); + const op = vi + .fn() + .mockRejectedValueOnce(err) + .mockRejectedValueOnce(err) + .mockResolvedValue("recovered"); + + const onRetry = vi.fn(); + const result = await executeWithRecovery(op, { sleep: noSleep, onRetry }); + + expect(result).toBe("recovered"); + expect(op).toHaveBeenCalledTimes(3); + expect(onRetry).toHaveBeenCalledTimes(2); + expect(onRetry).toHaveBeenLastCalledWith(2, RECOVERY_STRATEGY_TABLE[SdkErrorCode.NETWORK_TIMEOUT]); + }); + + it("throws immediately for non-retryable error codes", async () => { + const err = new SdkError("bad sequence", SdkErrorCode.SEQUENCE_MISMATCH); + const op = vi.fn().mockRejectedValue(err); + + await expect(executeWithRecovery(op, { sleep: noSleep })).rejects.toBe(err); + expect(op).toHaveBeenCalledTimes(1); + }); + + it("stops after the strategy's maxAttempts and rethrows the last error", async () => { + const err = new SdkError("limited", SdkErrorCode.RATE_LIMITED); + const op = vi.fn().mockRejectedValue(err); + const strategy = RECOVERY_STRATEGY_TABLE[SdkErrorCode.RATE_LIMITED]; + + await expect(executeWithRecovery(op, { sleep: noSleep })).rejects.toBe(err); + expect(op).toHaveBeenCalledTimes(strategy.maxAttempts!); + }); + + it("uses exponential backoff derived from the strategy", async () => { + const err = new SdkError("timed out", SdkErrorCode.NETWORK_TIMEOUT); + const op = vi.fn().mockRejectedValueOnce(err).mockResolvedValue("done"); + const sleeps: number[] = []; + const sleep = (ms: number) => { + sleeps.push(ms); + return Promise.resolve(); + }; + + // First call rejects, second resolves; only one backoff delay is applied. + await executeWithRecovery(op, { sleep }); + const base = RECOVERY_STRATEGY_TABLE[SdkErrorCode.NETWORK_TIMEOUT].backoffMs!; + expect(sleeps).toEqual([base]); + }); + + it("treats plain errors as retryable (generic strategy) with a bounded attempt count", async () => { + const op = vi.fn().mockRejectedValue(new Error("mystery")); + await expect(executeWithRecovery(op, { sleep: noSleep })).rejects.toThrow("mystery"); + expect(op).toHaveBeenCalledTimes(3); // generic maxAttempts + }); + + it("honors an explicit code option for plain errors", async () => { + const op = vi.fn().mockRejectedValue(new Error("wallet closed")); + await expect( + executeWithRecovery(op, { sleep: noSleep, code: SdkErrorCode.UNAUTHORIZED }), + ).rejects.toThrow("wallet closed"); + expect(op).toHaveBeenCalledTimes(1); // UNAUTHORIZED is not retryable + }); + + it("stops retrying when the abort signal is set", async () => { + const err = new SdkError("timed out", SdkErrorCode.NETWORK_TIMEOUT); + const op = vi.fn().mockRejectedValue(err); + const controller = new AbortController(); + controller.abort(); + + await expect( + executeWithRecovery(op, { sleep: noSleep, signal: controller.signal }), + ).rejects.toBe(err); + expect(op).toHaveBeenCalledTimes(1); + }); +}); + diff --git a/test/transactionVerifier.test.ts b/test/transactionVerifier.test.ts new file mode 100644 index 00000000..60965404 --- /dev/null +++ b/test/transactionVerifier.test.ts @@ -0,0 +1,324 @@ +/** + * Tests for issue #1066 — Transaction verification + * + * Builds real (offline) Stellar transactions and verifies each check: + * structure, fee, sequence, time bounds, signatures, and hash integrity. + */ +import { describe, it, expect } from "vitest"; +import { + Account, + Asset, + Keypair, + Networks, + Operation, + TransactionBuilder, + xdr, +} from "@stellar/stellar-sdk"; +import type { Transaction } from "@stellar/stellar-sdk"; +import { + verifyTransaction, + verifyTransactionSignatures, + assertTransactionVerified, +} from "../src/transactionVerifier.js"; +import { + StellarSplitError, + TransactionVerificationError, + isTransactionVerificationError, +} from "../src/errors.js"; + +const NETWORK = Networks.TESTNET; +const FEE = "100"; + +function buildTx(opts: { + fee?: string; + timeout?: number; + source?: Keypair; + sequence?: string; +}): Transaction { + const source = opts.source ?? Keypair.random(); + const account = new Account(source.publicKey(), opts.sequence ?? "100"); + const builder = new TransactionBuilder(account, { + fee: opts.fee ?? FEE, + networkPassphrase: NETWORK, + }).addOperation( + Operation.payment({ + destination: Keypair.random().publicKey(), + asset: Asset.native(), + amount: "10", + }), + ); + if (opts.timeout !== 0) { + builder.setTimeout(opts.timeout ?? 300); + } else { + builder.setTimeout(0); // no time bounds + } + return builder.build(); +} + +describe("verifyTransaction — happy path (#1066)", () => { + it("passes every check for a properly signed transaction", () => { + const source = Keypair.random(); + const tx = buildTx({ source }); + tx.sign(source); + + const result = verifyTransaction(tx, { + signers: [source.publicKey()], + maxFee: 1_000, + expectedSequence: "101", + now: Math.floor(Date.now() / 1000), + }); + + expect(result.valid).toBe(true); + expect(result.failedChecks).toEqual([]); + for (const check of result.checks) { + expect(check.passed, `${check.name}: ${check.detail}`).toBe(true); + } + }); + + it("accepts a transaction with no explicit options beyond defaults", () => { + const source = Keypair.random(); + const tx = buildTx({ source }); + tx.sign(source); + + // Default signature policy: at least one signature entry must be present. + const result = verifyTransaction(tx); + expect(result.valid).toBe(true); + }); +}); + +describe("verifyTransaction — fee check (#1066)", () => { + it("fails when the fee exceeds maxFee", () => { + const source = Keypair.random(); + const tx = buildTx({ source, fee: "5000" }); + tx.sign(source); + + const result = verifyTransaction(tx, { maxFee: 1_000 }); + expect(result.valid).toBe(false); + expect(result.failedChecks).toContain("fee"); + expect(result.checks.find((c) => c.name === "fee")?.detail).toContain("exceeds maximum"); + }); + + it("passes when the fee is exactly maxFee", () => { + const source = Keypair.random(); + const tx = buildTx({ source, fee: "1000" }); + tx.sign(source); + + const result = verifyTransaction(tx, { maxFee: 1000 }); + expect(result.failedChecks).not.toContain("fee"); + }); + + it("skips the fee check when maxFee is omitted", () => { + const source = Keypair.random(); + const tx = buildTx({ source, fee: "999999" }); + tx.sign(source); + + const result = verifyTransaction(tx); + expect(result.checks.some((c) => c.name === "fee")).toBe(false); + }); +}); + +describe("verifyTransaction — sequence check (#1066)", () => { + it("fails when the sequence number does not match", () => { + const source = Keypair.random(); + const tx = buildTx({ source }); + tx.sign(source); + + const result = verifyTransaction(tx, { expectedSequence: "999" }); + expect(result.valid).toBe(false); + expect(result.failedChecks).toContain("sequence"); + }); + + it("passes when the sequence number matches", () => { + const source = Keypair.random(); + const tx = buildTx({ source }); + tx.sign(source); + + const result = verifyTransaction(tx, { expectedSequence: tx.sequence }); + expect(result.failedChecks).not.toContain("sequence"); + }); +}); + +describe("verifyTransaction — time bounds check (#1066)", () => { + it("passes when now is inside the bounds", () => { + const source = Keypair.random(); + const tx = buildTx({ source }); + tx.sign(source); + + const result = verifyTransaction(tx, { now: Math.floor(Date.now() / 1000) }); + expect(result.failedChecks).not.toContain("timebounds"); + }); + + it("fails when now is after the max time", () => { + const source = Keypair.random(); + const tx = buildTx({ source }); + tx.sign(source); + + const maxTime = Number(tx.timeBounds!.maxTime); + const result = verifyTransaction(tx, { now: maxTime + 10 }); + expect(result.valid).toBe(false); + expect(result.failedChecks).toContain("timebounds"); + }); + + it("fails when now is before the min time", () => { + const source = Keypair.random(); + const account = new Account(source.publicKey(), "100"); + const tx = new TransactionBuilder(account, { + fee: FEE, + networkPassphrase: NETWORK, + }) + .addOperation(Operation.bumpSequence({ bumpTo: "200" })) + .setTimebounds(2_000_000_000, 3_000_000_000) + .build(); + tx.sign(source); + + const result = verifyTransaction(tx, { now: 1_000_000_000 }); + expect(result.failedChecks).toContain("timebounds"); + }); + + it("treats zero/absent time bounds as unbounded", () => { + const source = Keypair.random(); + const tx = buildTx({ source, timeout: 0 }); + tx.sign(source); + + // setTimeout(0) yields { minTime: "0", maxTime: "0" } — "no upper bound". + const result = verifyTransaction(tx, { now: 2_000_000_000 }); + const boundsCheck = result.checks.find((c) => c.name === "timebounds"); + expect(boundsCheck?.passed).toBe(true); + }); +}); + +describe("verifyTransactionSignatures (#1066)", () => { + it("classifies valid, missing, and invalid signers", () => { + const signer = Keypair.random(); + const missing = Keypair.random(); + const forger = Keypair.random(); + const tx = buildTx({ source: signer }); + tx.sign(signer); + // Forge a signature entry with forger's hint but garbage bytes. + tx.signatures.push( + new xdr.DecoratedSignature({ hint: forger.signatureHint(), signature: Buffer.alloc(64) }), + ); + + const result = verifyTransactionSignatures(tx, [ + signer.publicKey(), + missing.publicKey(), + forger.publicKey(), + ]); + + expect(result.valid).toEqual([signer.publicKey()]); + expect(result.missing).toEqual([missing.publicKey()]); + expect(result.invalid).toEqual([forger.publicKey()]); + }); + + it("returns an empty result for an empty key list", () => { + const tx = buildTx({}); + expect(verifyTransactionSignatures(tx, [])).toEqual({ valid: [], missing: [], invalid: [] }); + }); + + it("treats an unparseable key as invalid", () => { + const tx = buildTx({}); + const result = verifyTransactionSignatures(tx, ["not-a-public-key"]); + expect(result.invalid).toEqual(["not-a-public-key"]); + }); +}); + +describe("verifyTransaction — signatures check (#1066)", () => { + it("fails for an unsigned transaction (default minSignatures=1)", () => { + const tx = buildTx({}); + const result = verifyTransaction(tx); + expect(result.failedChecks).toContain("signatures"); + }); + + it("fails when a required signer has not signed", () => { + const source = Keypair.random(); + const other = Keypair.random(); + const tx = buildTx({ source }); + tx.sign(source); + + const result = verifyTransaction(tx, { signers: [source.publicKey(), other.publicKey()] }); + expect(result.failedChecks).toContain("signatures"); + expect(result.checks.find((c) => c.name === "signatures")?.detail).toContain("missing"); + }); + + it("fails when minSignatures is not met", () => { + const source = Keypair.random(); + const tx = buildTx({ source }); + tx.sign(source); + + const result = verifyTransaction(tx, { minSignatures: 2 }); + expect(result.failedChecks).toContain("signatures"); + }); +}); + +describe("verifyTransaction — hash integrity check (#1066)", () => { + it("passes when expectedHash matches (hex, case-insensitive)", () => { + const source = Keypair.random(); + const tx = buildTx({ source }); + tx.sign(source); + + const hex = tx.hash().toString("hex"); + const result = verifyTransaction(tx, { expectedHash: hex.toUpperCase() }); + expect(result.failedChecks).not.toContain("hash"); + }); + + it("passes when expectedHash matches (base64)", () => { + const source = Keypair.random(); + const tx = buildTx({ source }); + tx.sign(source); + + const result = verifyTransaction(tx, { expectedHash: tx.hash().toString("base64") }); + expect(result.failedChecks).not.toContain("hash"); + }); + + it("fails when expectedHash does not match", () => { + const source = Keypair.random(); + const tx = buildTx({ source }); + tx.sign(source); + + const other = buildTx({ source }); + const result = verifyTransaction(tx, { + expectedHash: other.hash().toString("hex"), + }); + expect(result.failedChecks).toContain("hash"); + }); +}); + +describe("assertTransactionVerified (#1066)", () => { + it("returns the result when all checks pass", () => { + const source = Keypair.random(); + const tx = buildTx({ source }); + tx.sign(source); + + const result = assertTransactionVerified(tx, { signers: [source.publicKey()] }); + expect(result.valid).toBe(true); + }); + + it("throws TransactionVerificationError listing the failed checks", () => { + const source = Keypair.random(); + const tx = buildTx({ source }); // signed by nobody + tx.sign(source); + const other = Keypair.random(); + + let thrown: unknown; + try { + assertTransactionVerified(tx, { + signers: [other.publicKey()], + expectedSequence: "999", + maxFee: 1, + }); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(TransactionVerificationError); + expect(thrown).toBeInstanceOf(StellarSplitError); + const err = thrown as TransactionVerificationError; + expect(err.code).toBe("VERIFICATION_FAILED"); + expect(err.failedChecks).toContain("sequence"); + expect(err.failedChecks).toContain("fee"); + expect(err.failedChecks).toContain("signatures"); + expect(isTransactionVerificationError(err)).toBe(true); + }); +}); + +