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'); + }); +});