Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
116 changes: 116 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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<TransactionResult>`

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<TransactionResult>`

**Signature**
```typescript
public async waitForResult(
hash: string,
options?: WaitForTransactionOptions,
): Promise<TransactionResult>
```

**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<void>`): 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:
Expand Down
153 changes: 153 additions & 0 deletions docs/transaction-reconciliation.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand All @@ -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);
}

/**
Expand Down
16 changes: 16 additions & 0 deletions src/errors/transaction.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
11 changes: 11 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,22 @@ 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,
isKnownAegisEventTopic,
normalizeEventTopicName,
} from './events/topics';
export { decodeScVal, decodeEventName } from './soroban/scval';
export { decodeTransactionResultCode } from './soroban/transaction-result';
export { parseSorobanResult } from './utils/xdr-parser';
export {
buildAdminActionReceipt,
Expand All @@ -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';
Loading
Loading