From 01f20c7c6afc90d903fed08ee5e0cb6c78273f09 Mon Sep 17 00:00:00 2001 From: panditdhamdhere Date: Wed, 29 Jul 2026 16:48:04 +0530 Subject: [PATCH] feat: add typed transaction result reconciliation (Closes #62) Reconcile submitted Soroban transactions into confirmed, failed, pending, rejected, and unknown states so dashboards stop inferring outcomes from raw RPC status strings. Mapping is conservative: NOT_FOUND stays pending because absence means either "not included yet" or "outside the retention window", and unrecognised statuses resolve to unknown rather than confirmed. Polling is bounded and only ever calls getTransaction, so no reconciliation path can cause duplicate ledger effects. --- CONTRIBUTING.md | 3 +- README.md | 18 ++ docs/api-reference.md | 116 +++++++++ docs/transaction-reconciliation.md | 153 +++++++++++ src/client.ts | 3 + src/errors/transaction.ts | 16 ++ src/index.ts | 11 + src/soroban/transaction-result.ts | 37 +++ src/transactions/module.ts | 170 +++++++++++++ src/transactions/reconciliation.ts | 252 +++++++++++++++++++ src/types/transaction-result.ts | 149 +++++++++++ tests/fixtures/transaction-results.ts | 88 +++++++ tests/transaction-module.test.ts | 211 ++++++++++++++++ tests/transaction-reconciliation.test.ts | 307 +++++++++++++++++++++++ 14 files changed, 1533 insertions(+), 1 deletion(-) create mode 100644 docs/transaction-reconciliation.md create mode 100644 src/errors/transaction.ts create mode 100644 src/soroban/transaction-result.ts create mode 100644 src/transactions/module.ts create mode 100644 src/transactions/reconciliation.ts create mode 100644 src/types/transaction-result.ts create mode 100644 tests/fixtures/transaction-results.ts create mode 100644 tests/transaction-module.test.ts create mode 100644 tests/transaction-reconciliation.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62afcbb..cf50804 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,7 @@ See [docs/payment-period-conduct.md](docs/payment-period-conduct.md) for the ful ## Updating API Reference Documentation -When you add or change a public method on `ComplianceModule`, `AssetModule`, `InvestorModule`, `EventsModule`, or an exported utility/type, update `docs/api-reference.md` (and `docs/investor-portfolio.md` if the investor read model changes; `docs/contract-events.md` if event decoding changes). Review checklist: +When you add or change a public method on `ComplianceModule`, `AssetModule`, `InvestorModule`, `EventsModule`, `TransactionModule`, or an exported utility/type, update `docs/api-reference.md` (and `docs/investor-portfolio.md` if the investor read model changes; `docs/contract-events.md` if event decoding changes; `docs/transaction-reconciliation.md` if transaction status reconciliation changes). Review checklist: - [ ] The signature block matches the method's actual TypeScript signature (parameter names, types, return type). - [ ] The Parameters section lists every parameter, including optional ones and their defaults. @@ -55,3 +55,4 @@ When you add or change a public method on `ComplianceModule`, `AssetModule`, `In - [ ] Anything the source leaves ambiguous, incomplete, or marked with a `// TODO` is called out as an explicit note rather than assumed or omitted. - [ ] If the change affects compliance/whitelist-gated behavior, the compliance disclaimer at the top of `docs/api-reference.md` still accurately describes it. - [ ] If the change affects contract event decoding, follow the checklist in `docs/contract-events.md` (edge cases, unknown fallback, and security/compliance assumptions). +- [ ] If the change affects transaction status reconciliation or polling, follow the checklist in `docs/transaction-reconciliation.md` (conservative status mapping, terminal states, and no blind resubmission). diff --git a/README.md b/README.md index 0ccdb4d..5f59d2b 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,24 @@ if (event.kind === 'transfer') { See [Contract Event Decoder](./docs/contract-events.md) for supported topics, unknown fallback behaviour, and dashboard integration guidance. +## Transaction Result Reconciliation +Turn a submitted transaction into a typed outcome instead of guessing from raw RPC statuses. + +```typescript +const txHash = await aegis.asset.transfer('G_RECIPIENT_PUBLIC_KEY', 500); +const result = await aegis.transaction.waitForResult(txHash); + +console.log(result.status); // 'confirmed' | 'failed' | 'pending' | 'rejected' | 'unknown' + +if (!result.terminal) { + // Outcome is still open — reconcile the same hash later. Never resubmit blindly. +} +``` + +Polling only reads status; it never resubmits. `pending` and `unknown` are not proof +of failure. See [Transaction Result Reconciliation](./docs/transaction-reconciliation.md) +for the full status model and retry caution. + ## Testing To run the SDK unit tests locally: diff --git a/docs/api-reference.md b/docs/api-reference.md index e047534..18a1187 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -22,6 +22,7 @@ Either `environment` or both `rpcUrl` and `networkPassphrase` must be provided. * `client.investor`: Investor portfolio read model module (`InvestorModule`). See [Investor Portfolio Documentation](./investor-portfolio.md). * `client.role`: Role discovery & capability checks module (`RoleModule`). See [Role Discovery & Capability Checks Documentation](./role-discovery.md). * `client.events`: Contract event fetch/decode module (`EventsModule`). See [Contract Event Decoder Documentation](./contract-events.md). +* `client.transaction`: Transaction result reconciliation module (`TransactionModule`). See [Transaction Result Reconciliation](./transaction-reconciliation.md). --- @@ -190,6 +191,121 @@ Typed Soroban contract event decoding for audit trails. See [Contract Event Deco * `decodeContractEvents(inputs, options?)` — batch decode preserving order. * `normalizeEventTopicName(name)` / `isKnownAegisEventTopic(name)` — topic compatibility helpers. +## `TransactionModule` & result reconciliation + +Reconciles submitted Soroban transactions into typed outcomes. Accessed via +`client.transaction`. See [Transaction Result Reconciliation](./transaction-reconciliation.md) +for the status model and retry caution. + +This module only reads transaction state. No method signs, submits, or +resubmits a transaction. + +### `reconcile(input: ReconcileTransactionStatusInput): TransactionResult` + +Reconciles a known hash and status without contacting RPC. + +**Parameters** +* `input.hash` (string): 64-character hex transaction hash. Trimmed and lowercased. +* `input.status` (string): raw RPC status (`SUCCESS`, `FAILED`, `NOT_FOUND`, `PENDING`, `DUPLICATE`, `TRY_AGAIN_LATER`, `ERROR`, or any other string). +* `input.ledger` / `input.latestLedger` (number, optional): ledger metadata to carry into the result. +* `input.attempts` (number, optional): observation count. Defaults to `1`. +* `input.failureCode` (string, optional): pre-extracted failure code. Values that do not match `^[A-Z0-9][A-Z0-9_.:-]{0,63}$` are dropped. +* `input.observedAt` (`Date | string`, optional): observation timestamp. Defaults to now. +* `input.observationWindowExpired` (boolean, optional): converts a `pending` reading into `unknown` with code `OBSERVATION_WINDOW_EXPIRED`. Terminal statuses are unaffected. + +**Returns** +A frozen `TransactionResult` discriminated on `status` (`confirmed` | `failed` | `pending` | `rejected` | `unknown`). Unrecognised statuses return `unknown` with code `UNRECOGNIZED_STATUS` rather than throwing. + +**Errors** +Throws `TransactionReconciliationError` with code `INVALID_TRANSACTION_HASH` (hash is not 64 hex characters), `INVALID_STATUS` (status is not a non-empty string), `INVALID_POLL_OPTIONS` (`attempts` is not a positive integer), or `INVALID_TIMESTAMP` (`observedAt` is not a valid date). + +### `reconcileSubmission(response, options?): TransactionResult` + +**Signature** +```typescript +public reconcileSubmission( + response: rpc.Api.SendTransactionResponse, + options?: ReconcileTransactionResponseOptions, +): TransactionResult +``` + +Maps a `sendTransaction` response. `PENDING` and `DUPLICATE` are `pending`, +`ERROR` is `rejected`, `TRY_AGAIN_LATER` is `unknown`. When `errorResult` is +present, `failureCode` is set from the transaction result switch name only — +raw XDR is not copied. Same throw paths as `reconcile`. + +### `getResult(hash: string): Promise` + +Performs one `getTransaction` read. Returns `pending` for `NOT_FOUND`, which +means "not observed yet", not "failed". + +**Errors** +Throws `TransactionReconciliationError` (`INVALID_TRANSACTION_HASH`) before any +RPC call, or `NetworkFailure` if the RPC read fails — the call goes through +`client.runNetworkOperation`. + +### `waitForResult(hash, options?): Promise` + +**Signature** +```typescript +public async waitForResult( + hash: string, + options?: WaitForTransactionOptions, +): Promise +``` + +**Parameters** +* `options.maxAttempts` (number): maximum `getTransaction` reads. Defaults to `10`. +* `options.intervalMs` (number): delay before the second read. Defaults to `1000`. +* `options.backoffFactor` (number): delay multiplier after each read. Defaults to `1.5`. +* `options.maxIntervalMs` (number): delay ceiling. Defaults to `8000`. +* `options.sleep` (`(ms: number) => Promise`): injectable delay for deterministic tests. Defaults to `setTimeout`. + +**Returns** +The first terminal result (`confirmed`, `failed`, or `rejected`). If the window +ends without inclusion, returns `unknown` with code +`OBSERVATION_WINDOW_EXPIRED` and `attempts` set to the number of reads made — +not an error and not a failure. + +**Errors** +Throws `TransactionReconciliationError` with `INVALID_TRANSACTION_HASH`, or +`INVALID_POLL_OPTIONS` when `maxAttempts` is not a positive integer, +`intervalMs` is negative, `backoffFactor` is below `1`, or `maxIntervalMs` is +below `intervalMs`. Validation happens before any RPC call. RPC failures surface +as `NetworkFailure`. + +### Standalone helpers + +* `reconcileTransactionStatus(input)` — pure reconciler behind `client.transaction.reconcile`. +* `reconcileSendTransactionResponse(response, options?)` — pure submission reconciler. +* `reconcileGetTransactionResponse(hash, response, options?)` — pure `getTransaction` reconciler. +* `normalizeTransactionResultStatus(status)` — maps a raw RPC status string to a `TransactionResultStatus`; unrecognised values return `unknown`. +* `normalizeTransactionHash(hash)` — validates and lowercases a transaction hash; throws `TransactionReconciliationError`. +* `decodeTransactionResultCode(result)` — reads a transaction result's switch name as a stable uppercase code (`txFailed` becomes `TX_FAILED`). Returns `undefined` when the input is absent or unreadable; never throws. + +**Example** +```typescript +const submitted = client.transaction.reconcileSubmission(sendResponse); + +if (submitted.status === 'rejected') { + throw new Error(`Rejected before inclusion: ${submitted.failureCode}`); +} + +const result = await client.transaction.waitForResult(submitted.hash); + +if (!result.terminal) { + console.log('Still indeterminate — reconcile the same hash later, do not resubmit.'); +} +``` + +> **Open note:** `AssetModule.mint` and `AssetModule.transfer` still return a +> bare hash string and do not inspect the submission status, so an `ERROR` or +> `TRY_AGAIN_LATER` submission currently looks the same as an accepted one. +> Pass the returned hash to `client.transaction.waitForResult` (or reconcile the +> raw `sendTransaction` response yourself) to learn the actual outcome. + +--- + ## Error Handling Strategies Soroban transactions and RPC queries can fail for several reasons. The SDK manages errors with custom taxonomy (`PortfolioError`) and safe fallbacks: diff --git a/docs/transaction-reconciliation.md b/docs/transaction-reconciliation.md new file mode 100644 index 0000000..0a8b0d5 --- /dev/null +++ b/docs/transaction-reconciliation.md @@ -0,0 +1,153 @@ +# Transaction result reconciliation + +Submitting a Soroban transaction and knowing what happened to it are two +different problems. `sendTransaction` only reports whether the network accepted +the transaction for inclusion; `getTransaction` reports inclusion, but returns +`NOT_FOUND` both for transactions that have not landed yet and for transactions +that fall outside the RPC retention window. + +The SDK reconciles both signals into one stable status model so dashboards can +render reliable receipts without matching raw RPC strings. + +## Status model + +| Status | Terminal | Meaning | +| ----------- | -------- | --------------------------------------------------------- | +| `confirmed` | yes | Included in a ledger and succeeded. | +| `failed` | yes | Included in a ledger and failed. | +| `rejected` | yes | Rejected before entering a ledger. | +| `pending` | no | Accepted or not yet observed. Outcome still open. | +| `unknown` | no | Outcome could not be determined from the current reading. | + +Every result also carries a `code` explaining *why* the status was chosen: + +| RPC status | Status | Code | +| --------------------------------- | ----------- | ---------------------------- | +| `getTransaction: SUCCESS` | `confirmed` | `CONFIRMED` | +| `getTransaction: FAILED` | `failed` | `LEDGER_FAILURE` | +| `getTransaction: NOT_FOUND` | `pending` | `AWAITING_INCLUSION` | +| `sendTransaction: PENDING` | `pending` | `AWAITING_INCLUSION` | +| `sendTransaction: DUPLICATE` | `pending` | `SUBMISSION_DUPLICATE` | +| `sendTransaction: ERROR` | `rejected` | `SUBMISSION_REJECTED` | +| `sendTransaction: TRY_AGAIN_LATER`| `unknown` | `SUBMISSION_THROTTLED` | +| polling window ended | `unknown` | `OBSERVATION_WINDOW_EXPIRED` | +| any unrecognised status | `unknown` | `UNRECOGNIZED_STATUS` | + +Mapping is conservative: a status this SDK version does not recognise resolves +to `unknown` and never to `confirmed`. + +### `rejected` vs `failed` + +These are different outcomes and dashboards should not merge them: + +- `rejected` — the network refused the submission. Nothing was applied, and no + sequence number or fee was consumed. +- `failed` — the transaction was included in a ledger and then failed. The fee + was charged and the sequence number was consumed. + +## Reconcile a submission + +```typescript +const submitted = client.transaction.reconcileSubmission(sendResponse); + +if (submitted.status === 'rejected') { + console.error('Not accepted:', submitted.failureCode); +} +``` + +`failureCode` is derived only from the transaction result switch name (for +example `TX_INSUFFICIENT_BALANCE`). Envelopes, signatures, and raw XDR payloads +are never copied into the reconciled result. + +## Read the current state + +```typescript +const result = await client.transaction.getResult(txHash); + +console.log(result.status, result.code, result.ledger); +``` + +A single read is a snapshot. `pending` means "not observed yet", never "failed". + +## Poll safely + +```typescript +const result = await client.transaction.waitForResult(txHash, { + maxAttempts: 10, + intervalMs: 1000, + backoffFactor: 1.5, + maxIntervalMs: 8000, +}); + +switch (result.status) { + case 'confirmed': + return renderReceipt(result); + case 'failed': + case 'rejected': + return renderFailure(result); + default: + return renderStillPending(result); +} +``` + +Polling is bounded by `maxAttempts` and only ever calls `getTransaction`. It +reads state; it never signs, submits, or resubmits, so a long poll cannot +produce duplicate ledger effects. Delays grow by `backoffFactor` and are capped +at `maxIntervalMs`. + +When the window ends without inclusion, the result is `unknown` with code +`OBSERVATION_WINDOW_EXPIRED` and `attempts` set to the number of reads made. +That is an instruction to keep reconciling the same hash later — not a failure. + +## Retry caution + +The SDK does not resubmit transactions automatically, and applications should +not either. Blind resubmission after an indeterminate reading risks applying the +same operation twice. + +Before building any retry path: + +1. **Reconcile the original hash first.** `pending` and `unknown` are not proof + of failure. Re-read the hash until it becomes terminal or you accept the + uncertainty. +2. **Only `rejected` is safe to submit again**, and only as a corrected, + re-signed transaction. `safeToResubmit` is `true` for exactly this case. + Resending the identical envelope will simply be rejected again. +3. **Never resubmit after a `NetworkFailure`.** A timeout means the response was + lost, not that the transaction was. `NetworkFailure.retryable` refers to + retrying the *read*, not the submission — see + [Network failure handling](./network-failures.md). +4. **Treat `DUPLICATE` as a signal to stop submitting.** The transaction is + already in flight; reconcile the existing hash. +5. **Do not resubmit after `failed`.** The sequence number was consumed, so a + new transaction must be built rather than the old one resent. + +## Dashboard integration + +- Render `confirmed`, `failed`, and `rejected` as final receipts. +- Render `pending` and `unknown` as "in progress" with a manual refresh, not as + errors, and keep the hash visible so users can verify independently. +- Surface `code` and `failureCode` in support tooling; they are stable and safe + to log. +- `observedAt` records when the reading was taken, which lets a dashboard show + the age of a non-terminal status. +- Reconciled results are frozen, so they can be cached and shared safely. +- Pair reconciliation with [contract events](./contract-events.md) for the audit + trail, and with [admin action receipts](./admin-action-receipts.md) for admin + operation history. Events alone do not prove inclusion. + +## Contributor review checklist + +When changing reconciliation behaviour: + +- [ ] Every new RPC status has an explicit mapping, and unrecognised statuses + still fall back to `unknown`. +- [ ] `NOT_FOUND` never maps to `failed` or `rejected`. +- [ ] `terminal` is only `true` for `confirmed`, `failed`, and `rejected`. +- [ ] `safeToResubmit` is only `true` when the network never accepted the + transaction. +- [ ] No polling or reconciliation path calls `sendTransaction`. +- [ ] Polling stays bounded and uses the injected `sleep` in tests. +- [ ] No raw XDR, envelope, signature, or RPC URL data reaches the result. +- [ ] Tests cover confirmed, failed, pending, rejected, unknown, and window + expiry. diff --git a/src/client.ts b/src/client.ts index bf959ef..1139c76 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,6 +4,7 @@ import { AssetModule } from './asset'; import { InvestorModule } from './investor/portfolio'; import { RoleModule } from './role'; import { EventsModule } from './events/module'; +import { TransactionModule } from './transactions/module'; import { AegisClientConfig, resolveClientConfig } from './config/validate'; import { classifyNetworkFailure } from './network/failures'; import { @@ -25,6 +26,7 @@ export class AegisClient { public investor: InvestorModule; public role: RoleModule; public events: EventsModule; + public transaction: TransactionModule; /** * Initializes the Aegis RWA SDK Client. @@ -47,6 +49,7 @@ export class AegisClient { this.investor = new InvestorModule(this); this.role = new RoleModule(this); this.events = new EventsModule(this); + this.transaction = new TransactionModule(this); } /** diff --git a/src/errors/transaction.ts b/src/errors/transaction.ts new file mode 100644 index 0000000..cfe18d3 --- /dev/null +++ b/src/errors/transaction.ts @@ -0,0 +1,16 @@ +export type TransactionReconciliationErrorCode = + | 'INVALID_TRANSACTION_HASH' + | 'INVALID_STATUS' + | 'INVALID_TIMESTAMP' + | 'INVALID_POLL_OPTIONS'; + +export class TransactionReconciliationError extends Error { + public readonly code: TransactionReconciliationErrorCode; + + constructor(code: TransactionReconciliationErrorCode, message: string) { + super(message); + this.name = 'TransactionReconciliationError'; + this.code = code; + Object.setPrototypeOf(this, TransactionReconciliationError.prototype); + } +} diff --git a/src/index.ts b/src/index.ts index b498dcf..84d59d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,14 @@ export { AssetModule } from './asset'; export { InvestorModule } from './investor/portfolio'; export { RoleModule } from './role'; export { EventsModule } from './events/module'; +export { TransactionModule } from './transactions/module'; +export { + normalizeTransactionHash, + normalizeTransactionResultStatus, + reconcileGetTransactionResponse, + reconcileSendTransactionResponse, + reconcileTransactionStatus, +} from './transactions/reconciliation'; export { decodeContractEvent, decodeContractEvents } from './events/decoder'; export { AEGIS_EVENT_TOPICS, @@ -28,6 +36,7 @@ export { normalizeEventTopicName, } from './events/topics'; export { decodeScVal, decodeEventName } from './soroban/scval'; +export { decodeTransactionResultCode } from './soroban/transaction-result'; export { parseSorobanResult } from './utils/xdr-parser'; export { buildAdminActionReceipt, @@ -51,5 +60,7 @@ export * from './errors/network'; export * from './errors/config'; export * from './types/contract-event'; export * from './errors/event'; +export * from './types/transaction-result'; +export * from './errors/transaction'; export type { AegisEnvironmentName, AegisEnvironmentPreset } from './config/environments'; export type { ResolvedAegisConfig } from './config/validate'; diff --git a/src/soroban/transaction-result.ts b/src/soroban/transaction-result.ts new file mode 100644 index 0000000..016b75b --- /dev/null +++ b/src/soroban/transaction-result.ts @@ -0,0 +1,37 @@ +const RESULT_CODE_PATTERN = /^[A-Z0-9][A-Z0-9_.:-]{0,63}$/; + +/** + * Reads the result code of a Soroban transaction result as a stable uppercase + * string (for example `txFailed` becomes `TX_FAILED`). + * + * Only the result switch name is read, so envelopes, signatures, memos, and raw + * XDR payloads never leave this helper. Returns `undefined` when the input is + * absent or does not expose a readable switch name, so callers can treat a + * missing code as "no safe detail available" rather than an error. + */ +export function decodeTransactionResultCode( + result: unknown, +): string | undefined { + if (!result) { + return undefined; + } + + try { + const inner = ( + result as { result?: () => { switch?: () => { name?: string } } } + ).result?.(); + const name = inner?.switch?.().name; + + if (typeof name !== 'string' || !name) { + return undefined; + } + + const normalized = name + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toUpperCase(); + + return RESULT_CODE_PATTERN.test(normalized) ? normalized : undefined; + } catch { + return undefined; + } +} diff --git a/src/transactions/module.ts b/src/transactions/module.ts new file mode 100644 index 0000000..aeb17ff --- /dev/null +++ b/src/transactions/module.ts @@ -0,0 +1,170 @@ +import { rpc } from '@stellar/stellar-sdk'; +import { AegisClient } from '../client'; +import { TransactionReconciliationError } from '../errors/transaction'; +import { + ReconcileTransactionResponseOptions, + ReconcileTransactionStatusInput, + TransactionResult, + WaitForTransactionOptions, +} from '../types/transaction-result'; +import { + normalizeTransactionHash, + reconcileGetTransactionResponse, + reconcileSendTransactionResponse, + reconcileTransactionStatus, +} from './reconciliation'; + +const DEFAULT_MAX_ATTEMPTS = 10; +const DEFAULT_INTERVAL_MS = 1000; +const DEFAULT_BACKOFF_FACTOR = 1.5; +const DEFAULT_MAX_INTERVAL_MS = 8000; + +interface PollSettings { + maxAttempts: number; + intervalMs: number; + backoffFactor: number; + maxIntervalMs: number; + sleep: (milliseconds: number) => Promise; +} + +/** + * Reconciles submitted Soroban transactions into typed, stable outcomes. + * + * This module only reads transaction state. It never signs, submits, or + * resubmits a transaction, so polling cannot cause duplicate ledger effects. + */ +export class TransactionModule { + private client: AegisClient; + + constructor(client: AegisClient) { + this.client = client; + } + + /** + * Reconciles an already-known hash and status without contacting RPC. + */ + public reconcile(input: ReconcileTransactionStatusInput): TransactionResult { + return reconcileTransactionStatus(input); + } + + /** + * Reconciles a `sendTransaction` response into a typed submission outcome. + */ + public reconcileSubmission( + response: rpc.Api.SendTransactionResponse, + options?: ReconcileTransactionResponseOptions, + ): TransactionResult { + return reconcileSendTransactionResponse(response, options); + } + + /** + * Reads the current state of a transaction with a single RPC observation. + * + * A `pending` result means "not observed yet", not "failed". + */ + public async getResult(hash: string): Promise { + const normalizedHash = normalizeTransactionHash(hash); + const response = await this.client.runNetworkOperation(() => + this.client.rpcServer.getTransaction(normalizedHash), + ); + + return reconcileGetTransactionResponse(normalizedHash, response); + } + + /** + * Polls `getTransaction` until the outcome is terminal or the bounded + * observation window ends. + * + * Returns `confirmed`, `failed`, or `rejected` when terminal. Returns + * `unknown` with code `OBSERVATION_WINDOW_EXPIRED` when the window ends + * without inclusion — callers must reconcile the same hash again rather than + * resubmitting. + */ + public async waitForResult( + hash: string, + options: WaitForTransactionOptions = {}, + ): Promise { + const normalizedHash = normalizeTransactionHash(hash); + const settings = resolvePollSettings(options); + + let delayMs = settings.intervalMs; + let lastResult: TransactionResult | undefined; + + for (let attempt = 1; attempt <= settings.maxAttempts; attempt += 1) { + const response = await this.client.runNetworkOperation(() => + this.client.rpcServer.getTransaction(normalizedHash), + ); + + lastResult = reconcileGetTransactionResponse(normalizedHash, response, { + attempts: attempt, + }); + + if (lastResult.terminal) { + return lastResult; + } + + if (attempt < settings.maxAttempts) { + await settings.sleep(delayMs); + delayMs = Math.min( + Math.ceil(delayMs * settings.backoffFactor), + settings.maxIntervalMs, + ); + } + } + + return reconcileTransactionStatus({ + hash: normalizedHash, + status: lastResult?.rpcStatus ?? rpc.Api.GetTransactionStatus.NOT_FOUND, + attempts: settings.maxAttempts, + latestLedger: lastResult?.latestLedger, + observationWindowExpired: true, + }); + } +} + +function resolvePollSettings(options: WaitForTransactionOptions): PollSettings { + const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; + const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS; + const backoffFactor = options.backoffFactor ?? DEFAULT_BACKOFF_FACTOR; + const maxIntervalMs = options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS; + + if (!Number.isInteger(maxAttempts) || maxAttempts < 1) { + throw new TransactionReconciliationError( + 'INVALID_POLL_OPTIONS', + 'maxAttempts must be a positive integer.', + ); + } + + if (!Number.isFinite(intervalMs) || intervalMs < 0) { + throw new TransactionReconciliationError( + 'INVALID_POLL_OPTIONS', + 'intervalMs must be a non-negative number.', + ); + } + + if (!Number.isFinite(backoffFactor) || backoffFactor < 1) { + throw new TransactionReconciliationError( + 'INVALID_POLL_OPTIONS', + 'backoffFactor must be greater than or equal to 1.', + ); + } + + if (!Number.isFinite(maxIntervalMs) || maxIntervalMs < intervalMs) { + throw new TransactionReconciliationError( + 'INVALID_POLL_OPTIONS', + 'maxIntervalMs must be greater than or equal to intervalMs.', + ); + } + + return { + maxAttempts, + intervalMs, + backoffFactor, + maxIntervalMs, + sleep: options.sleep ?? defaultSleep, + }; +} + +function defaultSleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/src/transactions/reconciliation.ts b/src/transactions/reconciliation.ts new file mode 100644 index 0000000..7c0b67a --- /dev/null +++ b/src/transactions/reconciliation.ts @@ -0,0 +1,252 @@ +import { rpc } from '@stellar/stellar-sdk'; +import { TransactionReconciliationError } from '../errors/transaction'; +import { decodeTransactionResultCode } from '../soroban/transaction-result'; +import { + ReconcileTransactionResponseOptions, + ReconcileTransactionStatusInput, + TransactionReconciliationCode, + TransactionResult, + TransactionResultStatus, +} from '../types/transaction-result'; + +const TRANSACTION_HASH_PATTERN = /^[a-fA-F0-9]{64}$/; +const FAILURE_CODE_PATTERN = /^[A-Z0-9][A-Z0-9_.:-]{0,63}$/; + +interface StatusMapping { + status: TransactionResultStatus; + code: TransactionReconciliationCode; +} + +/** + * Conservative mapping from Soroban RPC status strings to SDK statuses. + * + * `NOT_FOUND` stays `pending` because the transaction may simply not have been + * included yet, or may sit outside the RPC retention window — neither case + * proves rejection or failure. + */ +const STATUS_MAP: Readonly> = { + SUCCESS: { status: 'confirmed', code: 'CONFIRMED' }, + CONFIRMED: { status: 'confirmed', code: 'CONFIRMED' }, + FAILED: { status: 'failed', code: 'LEDGER_FAILURE' }, + NOT_FOUND: { status: 'pending', code: 'AWAITING_INCLUSION' }, + PENDING: { status: 'pending', code: 'AWAITING_INCLUSION' }, + DUPLICATE: { status: 'pending', code: 'SUBMISSION_DUPLICATE' }, + ERROR: { status: 'rejected', code: 'SUBMISSION_REJECTED' }, + TRY_AGAIN_LATER: { status: 'unknown', code: 'SUBMISSION_THROTTLED' }, +}; + +const SUMMARIES: Readonly> = { + CONFIRMED: 'Transaction was included in a ledger and succeeded.', + LEDGER_FAILURE: 'Transaction was included in a ledger and failed.', + AWAITING_INCLUSION: + 'Transaction has not been observed in a ledger yet. Keep reconciling the same hash.', + SUBMISSION_DUPLICATE: + 'Transaction was already submitted. Reconcile the existing hash instead of resubmitting.', + SUBMISSION_REJECTED: + 'Transaction was rejected before entering a ledger. Correct and re-sign before submitting again.', + SUBMISSION_THROTTLED: + 'Submission was throttled, so the outcome is indeterminate. Reconcile the hash before retrying.', + OBSERVATION_WINDOW_EXPIRED: + 'Observation window ended without inclusion. The outcome is still indeterminate.', + UNRECOGNIZED_STATUS: + 'Reported status is not recognised by this SDK version and is treated as indeterminate.', +}; + +const TERMINAL_STATUSES: ReadonlySet = new Set([ + 'confirmed', + 'failed', + 'rejected', +]); + +/** + * Normalises a raw RPC status string into a stable SDK transaction status. + * + * Unrecognised values resolve to `unknown` rather than implying success. + */ +export function normalizeTransactionResultStatus( + status: string, +): TransactionResultStatus { + return resolveMapping(status).status; +} + +/** + * Reconciles a transaction hash and RPC status into a typed result. + * + * Status mapping never throws: unrecognised statuses resolve to `unknown`. + * Malformed hashes, timestamps, and failure codes throw + * `TransactionReconciliationError`. + */ +export function reconcileTransactionStatus( + input: ReconcileTransactionStatusInput, +): TransactionResult { + const hash = normalizeTransactionHash(input.hash); + const rpcStatus = normalizeStatusString(input.status); + const mapping = applyObservationWindow( + resolveMapping(rpcStatus), + input.observationWindowExpired === true, + ); + + const status = mapping.status; + const code = mapping.code; + const failureCode = normalizeFailureCode(input.failureCode); + + const result = { + hash, + status, + code, + rpcStatus, + terminal: TERMINAL_STATUSES.has(status), + safeToResubmit: status === 'rejected', + summary: SUMMARIES[code], + observedAt: normalizeObservedAt(input.observedAt), + attempts: normalizeAttempts(input.attempts), + ...(input.ledger !== undefined ? { ledger: input.ledger } : {}), + ...(input.latestLedger !== undefined + ? { latestLedger: input.latestLedger } + : {}), + ...(failureCode ? { failureCode } : {}), + } as TransactionResult; + + return Object.freeze(result); +} + +/** + * Reconciles a `sendTransaction` response without polling. + * + * A submission response only reports whether the network accepted the + * transaction for inclusion, so `PENDING` and `DUPLICATE` remain `pending`. + */ +export function reconcileSendTransactionResponse( + response: rpc.Api.SendTransactionResponse, + options: ReconcileTransactionResponseOptions = {}, +): TransactionResult { + return reconcileTransactionStatus({ + hash: response.hash, + status: response.status, + latestLedger: response.latestLedger, + attempts: options.attempts, + observedAt: options.observedAt, + observationWindowExpired: options.observationWindowExpired, + failureCode: decodeTransactionResultCode(response.errorResult), + }); +} + +/** + * Reconciles a `getTransaction` response for a known transaction hash. + */ +export function reconcileGetTransactionResponse( + hash: string, + response: rpc.Api.GetTransactionResponse, + options: ReconcileTransactionResponseOptions = {}, +): TransactionResult { + const ledger = 'ledger' in response ? response.ledger : undefined; + const failureCode = + response.status === rpc.Api.GetTransactionStatus.FAILED + ? decodeTransactionResultCode(response.resultXdr) + : undefined; + + return reconcileTransactionStatus({ + hash, + status: response.status, + ledger, + latestLedger: response.latestLedger, + attempts: options.attempts, + observedAt: options.observedAt, + observationWindowExpired: options.observationWindowExpired, + failureCode, + }); +} + +/** + * Validates and normalises a Soroban transaction hash. + */ +export function normalizeTransactionHash(hash: string): string { + if (typeof hash !== 'string') { + throw new TransactionReconciliationError( + 'INVALID_TRANSACTION_HASH', + 'Transaction hash must be a string.', + ); + } + + const normalized = hash.trim().toLowerCase(); + if (!TRANSACTION_HASH_PATTERN.test(normalized)) { + throw new TransactionReconciliationError( + 'INVALID_TRANSACTION_HASH', + 'Transaction hash must contain exactly 64 hexadecimal characters.', + ); + } + + return normalized; +} + +function resolveMapping(status: string): StatusMapping { + const normalized = normalizeStatusString(status); + return ( + STATUS_MAP[normalized] ?? { + status: 'unknown', + code: 'UNRECOGNIZED_STATUS', + } + ); +} + +function applyObservationWindow( + mapping: StatusMapping, + windowExpired: boolean, +): StatusMapping { + if (!windowExpired || mapping.status !== 'pending') { + return mapping; + } + + return { status: 'unknown', code: 'OBSERVATION_WINDOW_EXPIRED' }; +} + +function normalizeStatusString(status: unknown): string { + if (typeof status !== 'string' || !status.trim()) { + throw new TransactionReconciliationError( + 'INVALID_STATUS', + 'Transaction status must be a non-empty string.', + ); + } + + return status.trim().toUpperCase(); +} + +function normalizeAttempts(attempts?: number): number { + if (attempts === undefined) { + return 1; + } + + if (!Number.isInteger(attempts) || attempts < 1) { + throw new TransactionReconciliationError( + 'INVALID_POLL_OPTIONS', + 'Observation attempts must be a positive integer.', + ); + } + + return attempts; +} + +function normalizeObservedAt(observedAt?: Date | string): string { + const date = + observedAt instanceof Date + ? observedAt + : new Date(observedAt ?? Date.now()); + + if (Number.isNaN(date.getTime())) { + throw new TransactionReconciliationError( + 'INVALID_TIMESTAMP', + 'Observation timestamp must be a valid date.', + ); + } + + return date.toISOString(); +} + +function normalizeFailureCode(failureCode?: string): string | undefined { + if (!failureCode) { + return undefined; + } + + const normalized = failureCode.trim().toUpperCase(); + return FAILURE_CODE_PATTERN.test(normalized) ? normalized : undefined; +} diff --git a/src/types/transaction-result.ts b/src/types/transaction-result.ts new file mode 100644 index 0000000..96fa8d1 --- /dev/null +++ b/src/types/transaction-result.ts @@ -0,0 +1,149 @@ +/** + * Stable reconciliation status for a submitted Soroban transaction. + * + * `confirmed`, `failed`, and `rejected` are terminal. `pending` and `unknown` + * mean the outcome has not been observed yet — they are never proof of failure. + */ +export type TransactionResultStatus = + | 'confirmed' + | 'failed' + | 'pending' + | 'rejected' + | 'unknown'; + +/** + * Machine-readable reason behind a reconciled status. + */ +export type TransactionReconciliationCode = + | 'CONFIRMED' + | 'LEDGER_FAILURE' + | 'AWAITING_INCLUSION' + | 'SUBMISSION_DUPLICATE' + | 'SUBMISSION_REJECTED' + | 'SUBMISSION_THROTTLED' + | 'OBSERVATION_WINDOW_EXPIRED' + | 'UNRECOGNIZED_STATUS'; + +/** + * Soroban RPC status strings the reconciler recognises. + * + * `SUCCESS`, `FAILED`, and `NOT_FOUND` come from `getTransaction`. + * `PENDING`, `DUPLICATE`, `TRY_AGAIN_LATER`, and `ERROR` come from `sendTransaction`. + */ +export type TransactionStatusInput = + | 'SUCCESS' + | 'FAILED' + | 'NOT_FOUND' + | 'PENDING' + | 'DUPLICATE' + | 'TRY_AGAIN_LATER' + | 'ERROR'; + +export interface TransactionResultBase { + hash: string; + status: TransactionResultStatus; + code: TransactionReconciliationCode; + /** Raw RPC status string that produced this reconciliation. */ + rpcStatus: string; + /** `true` only when further observation cannot change the outcome. */ + terminal: boolean; + /** + * `true` only when the network never accepted the submission, so no ledger + * slot or sequence number was consumed. Even then, build a corrected + * transaction instead of resending the same envelope. + */ + safeToResubmit: boolean; + summary: string; + observedAt: string; + /** Number of observations made, including the one that produced this result. */ + attempts: number; + ledger?: number; + latestLedger?: number; + failureCode?: string; +} + +export interface ConfirmedTransactionResult extends TransactionResultBase { + status: 'confirmed'; + code: 'CONFIRMED'; + terminal: true; + safeToResubmit: false; +} + +export interface FailedTransactionResult extends TransactionResultBase { + status: 'failed'; + code: 'LEDGER_FAILURE'; + terminal: true; + safeToResubmit: false; +} + +export interface PendingTransactionResult extends TransactionResultBase { + status: 'pending'; + code: 'AWAITING_INCLUSION' | 'SUBMISSION_DUPLICATE'; + terminal: false; + safeToResubmit: false; +} + +export interface RejectedTransactionResult extends TransactionResultBase { + status: 'rejected'; + code: 'SUBMISSION_REJECTED'; + terminal: true; + safeToResubmit: true; +} + +export interface UnknownTransactionResult extends TransactionResultBase { + status: 'unknown'; + code: + | 'SUBMISSION_THROTTLED' + | 'OBSERVATION_WINDOW_EXPIRED' + | 'UNRECOGNIZED_STATUS'; + terminal: false; + safeToResubmit: false; +} + +/** + * Discriminated reconciliation result for a submitted transaction. + */ +export type TransactionResult = + | ConfirmedTransactionResult + | FailedTransactionResult + | PendingTransactionResult + | RejectedTransactionResult + | UnknownTransactionResult; + +export interface ReconcileTransactionStatusInput { + hash: string; + status: TransactionStatusInput | string; + ledger?: number; + latestLedger?: number; + attempts?: number; + failureCode?: string; + observedAt?: Date | string; + /** + * Set when a bounded observation window ended without inclusion. Converts a + * `pending` reading into `unknown` so callers stop waiting without assuming + * the transaction failed. + */ + observationWindowExpired?: boolean; +} + +export interface ReconcileTransactionResponseOptions { + attempts?: number; + observedAt?: Date | string; + observationWindowExpired?: boolean; +} + +/** + * Bounded polling configuration. Polling only reads status; it never resubmits. + */ +export interface WaitForTransactionOptions { + /** Maximum number of `getTransaction` reads. Defaults to `10`. */ + maxAttempts?: number; + /** Delay before the second read, in milliseconds. Defaults to `1000`. */ + intervalMs?: number; + /** Multiplier applied to the delay after each read. Defaults to `1.5`. */ + backoffFactor?: number; + /** Upper bound for the delay between reads. Defaults to `8000`. */ + maxIntervalMs?: number; + /** Injectable delay, primarily for deterministic tests. */ + sleep?: (milliseconds: number) => Promise; +} diff --git a/tests/fixtures/transaction-results.ts b/tests/fixtures/transaction-results.ts new file mode 100644 index 0000000..3ef10b2 --- /dev/null +++ b/tests/fixtures/transaction-results.ts @@ -0,0 +1,88 @@ +import { rpc } from '@stellar/stellar-sdk'; + +/** + * Deterministic transaction hashes. These are placeholder hex strings, not + * hashes of real submitted transactions. + */ +export const TRANSACTION_HASHES = { + confirmed: 'ab01'.padEnd(64, '0'), + failed: 'ba02'.padEnd(64, '0'), + pending: 'cd03'.padEnd(64, '0'), + rejected: 'de04'.padEnd(64, '0'), +} as const; + +/** + * Minimal stand-in for `xdr.TransactionResult`. The reconciler only reads the + * result switch name, so the fixture implements just that accessor instead of + * building a full XDR envelope. + */ +function transactionResultStub(switchName: string): never { + return { + result: () => ({ switch: () => ({ name: switchName }) }), + } as never; +} + +export function successfulTransactionResponse( + overrides: { ledger?: number; latestLedger?: number } = {}, +): rpc.Api.GetTransactionResponse { + return { + status: rpc.Api.GetTransactionStatus.SUCCESS, + ledger: overrides.ledger ?? 1200, + latestLedger: overrides.latestLedger ?? 1205, + latestLedgerCloseTime: 1700000000, + oldestLedger: 900, + oldestLedgerCloseTime: 1699000000, + createdAt: 1700000000, + applicationOrder: 1, + feeBump: false, + envelopeXdr: transactionResultStub('txSuccess'), + resultXdr: transactionResultStub('txSuccess'), + resultMetaXdr: transactionResultStub('txSuccess'), + } as unknown as rpc.Api.GetTransactionResponse; +} + +export function failedTransactionResponse( + overrides: { ledger?: number; switchName?: string } = {}, +): rpc.Api.GetTransactionResponse { + return { + status: rpc.Api.GetTransactionStatus.FAILED, + ledger: overrides.ledger ?? 1300, + latestLedger: 1305, + latestLedgerCloseTime: 1700000100, + oldestLedger: 900, + oldestLedgerCloseTime: 1699000000, + createdAt: 1700000100, + applicationOrder: 2, + feeBump: false, + envelopeXdr: transactionResultStub('txFailed'), + resultXdr: transactionResultStub(overrides.switchName ?? 'txFailed'), + resultMetaXdr: transactionResultStub('txFailed'), + } as unknown as rpc.Api.GetTransactionResponse; +} + +export function missingTransactionResponse( + overrides: { latestLedger?: number } = {}, +): rpc.Api.GetTransactionResponse { + return { + status: rpc.Api.GetTransactionStatus.NOT_FOUND, + latestLedger: overrides.latestLedger ?? 1400, + latestLedgerCloseTime: 1700000200, + oldestLedger: 900, + oldestLedgerCloseTime: 1699000000, + } as unknown as rpc.Api.GetTransactionResponse; +} + +export function sendTransactionResponse( + status: rpc.Api.SendTransactionStatus, + overrides: { hash?: string; withErrorResult?: boolean } = {}, +): rpc.Api.SendTransactionResponse { + return { + status, + hash: overrides.hash ?? TRANSACTION_HASHES.pending, + latestLedger: 1500, + latestLedgerCloseTime: 1700000300, + ...(overrides.withErrorResult + ? { errorResult: transactionResultStub('txInsufficientBalance') } + : {}), + } as unknown as rpc.Api.SendTransactionResponse; +} diff --git a/tests/transaction-module.test.ts b/tests/transaction-module.test.ts new file mode 100644 index 0000000..c418cdd --- /dev/null +++ b/tests/transaction-module.test.ts @@ -0,0 +1,211 @@ +import { AegisClient } from '../src/client'; +import { TransactionModule } from '../src/transactions/module'; +import { + TRANSACTION_HASHES, + failedTransactionResponse, + missingTransactionResponse, + sendTransactionResponse, + successfulTransactionResponse, +} from './fixtures/transaction-results'; + +jest.mock('@stellar/stellar-sdk', () => { + const original = jest.requireActual('@stellar/stellar-sdk'); + return { + ...original, + rpc: { + ...original.rpc, + Server: jest.fn().mockImplementation(() => ({ + getTransaction: jest.fn(), + sendTransaction: jest.fn(), + })), + }, + }; +}); + +describe('TransactionModule', () => { + let client: AegisClient; + let transaction: TransactionModule; + let mockGetTransaction: jest.Mock; + let mockSendTransaction: jest.Mock; + let sleep: jest.Mock; + let delays: number[]; + + beforeEach(() => { + client = new AegisClient({ + environment: 'testnet', + contractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + }); + transaction = client.transaction; + mockGetTransaction = client.rpcServer.getTransaction as jest.Mock; + mockSendTransaction = client.rpcServer.sendTransaction as jest.Mock; + delays = []; + sleep = jest.fn(async (milliseconds: number) => { + delays.push(milliseconds); + }); + }); + + it('is wired onto the client', () => { + expect(client.transaction).toBeInstanceOf(TransactionModule); + }); + + it('reads a single observation without polling', async () => { + mockGetTransaction.mockResolvedValue( + successfulTransactionResponse({ ledger: 1210 }), + ); + + const result = await transaction.getResult(TRANSACTION_HASHES.confirmed); + + expect(mockGetTransaction).toHaveBeenCalledTimes(1); + expect(mockGetTransaction).toHaveBeenCalledWith( + TRANSACTION_HASHES.confirmed, + ); + expect(result).toMatchObject({ status: 'confirmed', ledger: 1210 }); + }); + + it('polls until inclusion is confirmed', async () => { + mockGetTransaction + .mockResolvedValueOnce(missingTransactionResponse()) + .mockResolvedValueOnce(missingTransactionResponse()) + .mockResolvedValueOnce(successfulTransactionResponse({ ledger: 1220 })); + + const result = await transaction.waitForResult( + TRANSACTION_HASHES.confirmed, + { intervalMs: 100, sleep }, + ); + + expect(result).toMatchObject({ + status: 'confirmed', + code: 'CONFIRMED', + terminal: true, + attempts: 3, + ledger: 1220, + }); + expect(mockGetTransaction).toHaveBeenCalledTimes(3); + }); + + it('stops polling as soon as a ledger failure is observed', async () => { + mockGetTransaction + .mockResolvedValueOnce(missingTransactionResponse()) + .mockResolvedValueOnce(failedTransactionResponse({ ledger: 1330 })) + .mockResolvedValueOnce(successfulTransactionResponse()); + + const result = await transaction.waitForResult(TRANSACTION_HASHES.failed, { + intervalMs: 50, + sleep, + }); + + expect(result).toMatchObject({ + status: 'failed', + code: 'LEDGER_FAILURE', + terminal: true, + attempts: 2, + failureCode: 'TX_FAILED', + }); + expect(mockGetTransaction).toHaveBeenCalledTimes(2); + }); + + it('ends an exhausted window as unknown instead of failed', async () => { + mockGetTransaction.mockResolvedValue(missingTransactionResponse()); + + const result = await transaction.waitForResult(TRANSACTION_HASHES.pending, { + maxAttempts: 3, + intervalMs: 100, + sleep, + }); + + expect(result).toMatchObject({ + status: 'unknown', + code: 'OBSERVATION_WINDOW_EXPIRED', + terminal: false, + safeToResubmit: false, + attempts: 3, + }); + expect(mockGetTransaction).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenCalledTimes(2); + }); + + it('never submits or resubmits while reconciling', async () => { + mockGetTransaction.mockResolvedValue(missingTransactionResponse()); + + await transaction.waitForResult(TRANSACTION_HASHES.pending, { + maxAttempts: 4, + intervalMs: 10, + sleep, + }); + await transaction.getResult(TRANSACTION_HASHES.pending); + + expect(mockSendTransaction).not.toHaveBeenCalled(); + }); + + it('applies bounded exponential backoff between observations', async () => { + mockGetTransaction.mockResolvedValue(missingTransactionResponse()); + + await transaction.waitForResult(TRANSACTION_HASHES.pending, { + maxAttempts: 5, + intervalMs: 100, + backoffFactor: 3, + maxIntervalMs: 500, + sleep, + }); + + expect(delays).toEqual([100, 300, 500, 500]); + }); + + it('surfaces RPC problems as typed network failures', async () => { + mockGetTransaction.mockRejectedValue( + Object.assign(new Error('rpc url contains secret'), { + code: 'ETIMEDOUT', + }), + ); + + await expect( + transaction.waitForResult(TRANSACTION_HASHES.pending, { sleep }), + ).rejects.toMatchObject({ + name: 'NetworkFailure', + code: 'TIMEOUT', + retryable: true, + }); + }); + + it('rejects malformed hashes and poll options before contacting RPC', async () => { + await expect(transaction.getResult('C...')).rejects.toMatchObject({ + code: 'INVALID_TRANSACTION_HASH', + }); + await expect( + transaction.waitForResult(TRANSACTION_HASHES.pending, { maxAttempts: 0 }), + ).rejects.toMatchObject({ code: 'INVALID_POLL_OPTIONS' }); + await expect( + transaction.waitForResult(TRANSACTION_HASHES.pending, { + intervalMs: 1000, + maxIntervalMs: 100, + }), + ).rejects.toMatchObject({ code: 'INVALID_POLL_OPTIONS' }); + + expect(mockGetTransaction).not.toHaveBeenCalled(); + }); + + it('reconciles a submission response without any RPC read', () => { + const rejected = transaction.reconcileSubmission( + sendTransactionResponse('ERROR', { + hash: TRANSACTION_HASHES.rejected, + withErrorResult: true, + }), + ); + + expect(rejected).toMatchObject({ + status: 'rejected', + terminal: true, + safeToResubmit: true, + }); + expect(mockGetTransaction).not.toHaveBeenCalled(); + }); + + it('delegates plain reconciliation to the shared helper', () => { + const result = transaction.reconcile({ + hash: TRANSACTION_HASHES.pending, + status: 'DUPLICATE', + }); + + expect(result.code).toBe('SUBMISSION_DUPLICATE'); + }); +}); diff --git a/tests/transaction-reconciliation.test.ts b/tests/transaction-reconciliation.test.ts new file mode 100644 index 0000000..feff415 --- /dev/null +++ b/tests/transaction-reconciliation.test.ts @@ -0,0 +1,307 @@ +import { rpc } from '@stellar/stellar-sdk'; +import { + TransactionReconciliationError, + decodeTransactionResultCode, + normalizeTransactionHash, + normalizeTransactionResultStatus, + reconcileGetTransactionResponse, + reconcileSendTransactionResponse, + reconcileTransactionStatus, +} from '../src'; +import { + TRANSACTION_HASHES, + failedTransactionResponse, + missingTransactionResponse, + sendTransactionResponse, + successfulTransactionResponse, +} from './fixtures/transaction-results'; + +describe('Transaction status normalization', () => { + it.each([ + ['SUCCESS', 'confirmed'], + ['FAILED', 'failed'], + ['NOT_FOUND', 'pending'], + ['PENDING', 'pending'], + ['DUPLICATE', 'pending'], + ['ERROR', 'rejected'], + ['TRY_AGAIN_LATER', 'unknown'], + ] as const)('maps %s to %s', (rpcStatus, expected) => { + expect(normalizeTransactionResultStatus(rpcStatus)).toBe(expected); + }); + + it('treats an unrecognised future status as unknown rather than success', () => { + expect(normalizeTransactionResultStatus('ARCHIVED_PENDING_RESTORE')).toBe( + 'unknown', + ); + }); + + it('accepts lowercase and padded status strings', () => { + expect(normalizeTransactionResultStatus(' success ')).toBe('confirmed'); + }); +}); + +describe('decodeTransactionResultCode', () => { + function resultStub(name: unknown): unknown { + return { result: () => ({ switch: () => ({ name }) }) }; + } + + it('converts a result switch name into a stable uppercase code', () => { + expect(decodeTransactionResultCode(resultStub('txFailed'))).toBe( + 'TX_FAILED', + ); + expect( + decodeTransactionResultCode(resultStub('txInsufficientBalance')), + ).toBe('TX_INSUFFICIENT_BALANCE'); + }); + + it.each([ + ['a missing result', undefined], + ['a null result', null], + ['an unreadable shape', {}], + ['a non-string switch name', resultStub(42)], + ['an empty switch name', resultStub('')], + ])('returns undefined for %s', (_label, input) => { + expect(decodeTransactionResultCode(input)).toBeUndefined(); + }); + + it('swallows accessors that throw instead of propagating XDR errors', () => { + const hostile = { + result: () => { + throw new Error('raw xdr access failed'); + }, + }; + + expect(decodeTransactionResultCode(hostile)).toBeUndefined(); + }); +}); + +describe('reconcileTransactionStatus', () => { + const hash = TRANSACTION_HASHES.confirmed; + + it('marks a confirmed transaction terminal and not resubmittable', () => { + const result = reconcileTransactionStatus({ + hash, + status: 'SUCCESS', + ledger: 1200, + observedAt: '2026-07-29T00:00:00.000Z', + }); + + expect(result).toMatchObject({ + hash, + status: 'confirmed', + code: 'CONFIRMED', + rpcStatus: 'SUCCESS', + terminal: true, + safeToResubmit: false, + ledger: 1200, + attempts: 1, + observedAt: '2026-07-29T00:00:00.000Z', + }); + }); + + it('marks a ledger failure terminal without inviting resubmission', () => { + const result = reconcileTransactionStatus({ hash, status: 'FAILED' }); + + expect(result.status).toBe('failed'); + expect(result.code).toBe('LEDGER_FAILURE'); + expect(result.terminal).toBe(true); + expect(result.safeToResubmit).toBe(false); + }); + + it('keeps a missing transaction pending because absence is not failure', () => { + const result = reconcileTransactionStatus({ hash, status: 'NOT_FOUND' }); + + expect(result.status).toBe('pending'); + expect(result.code).toBe('AWAITING_INCLUSION'); + expect(result.terminal).toBe(false); + expect(result.safeToResubmit).toBe(false); + }); + + it('treats a duplicate submission as pending on the existing hash', () => { + const result = reconcileTransactionStatus({ hash, status: 'DUPLICATE' }); + + expect(result.status).toBe('pending'); + expect(result.code).toBe('SUBMISSION_DUPLICATE'); + expect(result.safeToResubmit).toBe(false); + expect(result.summary).toContain('instead of resubmitting'); + }); + + it('marks a pre-inclusion rejection as the only resubmittable state', () => { + const result = reconcileTransactionStatus({ hash, status: 'ERROR' }); + + expect(result.status).toBe('rejected'); + expect(result.code).toBe('SUBMISSION_REJECTED'); + expect(result.terminal).toBe(true); + expect(result.safeToResubmit).toBe(true); + }); + + it('treats throttled submissions as indeterminate, not retryable', () => { + const result = reconcileTransactionStatus({ + hash, + status: 'TRY_AGAIN_LATER', + }); + + expect(result.status).toBe('unknown'); + expect(result.code).toBe('SUBMISSION_THROTTLED'); + expect(result.safeToResubmit).toBe(false); + }); + + it('converts a pending reading into unknown when the window expires', () => { + const result = reconcileTransactionStatus({ + hash, + status: 'NOT_FOUND', + attempts: 5, + observationWindowExpired: true, + }); + + expect(result.status).toBe('unknown'); + expect(result.code).toBe('OBSERVATION_WINDOW_EXPIRED'); + expect(result.terminal).toBe(false); + expect(result.safeToResubmit).toBe(false); + expect(result.attempts).toBe(5); + }); + + it('does not downgrade a terminal outcome when the window expires', () => { + const result = reconcileTransactionStatus({ + hash, + status: 'SUCCESS', + observationWindowExpired: true, + }); + + expect(result.status).toBe('confirmed'); + }); + + it('normalizes hashes and rejects malformed ones', () => { + expect( + normalizeTransactionHash(` ${TRANSACTION_HASHES.failed.toUpperCase()} `), + ).toBe(TRANSACTION_HASHES.failed); + + expect(() => reconcileTransactionStatus({ hash: 'C...', status: 'SUCCESS' })) + .toThrow(TransactionReconciliationError); + expect(() => + reconcileTransactionStatus({ hash: 'C...', status: 'SUCCESS' }), + ).toThrow( + expect.objectContaining({ code: 'INVALID_TRANSACTION_HASH' }), + ); + }); + + it('rejects empty statuses and non-positive attempt counts', () => { + expect(() => reconcileTransactionStatus({ hash, status: ' ' })).toThrow( + expect.objectContaining({ code: 'INVALID_STATUS' }), + ); + expect(() => + reconcileTransactionStatus({ hash, status: 'SUCCESS', attempts: 0 }), + ).toThrow(expect.objectContaining({ code: 'INVALID_POLL_OPTIONS' })); + expect(() => + reconcileTransactionStatus({ + hash, + status: 'SUCCESS', + observedAt: 'not-a-date', + }), + ).toThrow(expect.objectContaining({ code: 'INVALID_TIMESTAMP' })); + }); + + it('returns a frozen result so dashboards cannot mutate receipts', () => { + const result = reconcileTransactionStatus({ hash, status: 'SUCCESS' }); + + expect(Object.isFrozen(result)).toBe(true); + }); +}); + +describe('reconcileSendTransactionResponse', () => { + it('reconciles an accepted submission as pending', () => { + const response = sendTransactionResponse('PENDING'); + const result = reconcileSendTransactionResponse(response); + + expect(result).toMatchObject({ + hash: TRANSACTION_HASHES.pending, + status: 'pending', + code: 'AWAITING_INCLUSION', + rpcStatus: 'PENDING', + latestLedger: 1500, + }); + }); + + it('reconciles a rejected submission and exposes a safe failure code', () => { + const response = sendTransactionResponse('ERROR', { + hash: TRANSACTION_HASHES.rejected, + withErrorResult: true, + }); + const result = reconcileSendTransactionResponse(response); + + expect(result.status).toBe('rejected'); + expect(result.failureCode).toBe('TX_INSUFFICIENT_BALANCE'); + }); +}); + +describe('reconcileGetTransactionResponse', () => { + const hash = TRANSACTION_HASHES.confirmed; + + it('reconciles a successful ledger inclusion', () => { + const result = reconcileGetTransactionResponse( + hash, + successfulTransactionResponse({ ledger: 1201, latestLedger: 1207 }), + { attempts: 3 }, + ); + + expect(result).toMatchObject({ + status: 'confirmed', + ledger: 1201, + latestLedger: 1207, + attempts: 3, + }); + expect(result.failureCode).toBeUndefined(); + }); + + it('reconciles a ledger failure with the result switch name', () => { + const result = reconcileGetTransactionResponse( + TRANSACTION_HASHES.failed, + failedTransactionResponse({ switchName: 'txBadAuth' }), + ); + + expect(result.status).toBe('failed'); + expect(result.failureCode).toBe('TX_BAD_AUTH'); + }); + + it('omits ledger details for a missing transaction', () => { + const result = reconcileGetTransactionResponse( + TRANSACTION_HASHES.pending, + missingTransactionResponse({ latestLedger: 1444 }), + ); + + expect(result.status).toBe('pending'); + expect(result.ledger).toBeUndefined(); + expect(result.latestLedger).toBe(1444); + }); + + it('never copies raw XDR payloads into the reconciled result', () => { + const result = reconcileGetTransactionResponse( + hash, + successfulTransactionResponse(), + ); + + expect(Object.keys(result)).toEqual([ + 'hash', + 'status', + 'code', + 'rpcStatus', + 'terminal', + 'safeToResubmit', + 'summary', + 'observedAt', + 'attempts', + 'ledger', + 'latestLedger', + ]); + expect(JSON.stringify(result)).not.toContain('envelopeXdr'); + }); + + it('accepts the RPC status enum values directly', () => { + expect( + reconcileTransactionStatus({ + hash, + status: rpc.Api.GetTransactionStatus.NOT_FOUND, + }).status, + ).toBe('pending'); + }); +});