Skip to content
Merged
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
271 changes: 271 additions & 0 deletions docs/adr/0003-transaction-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
# ADR 0003: Transaction Lifecycle Architecture

- **Status:** Accepted
- **Date:** 2026-07-22
- **Deciders:** PocketPay SDK maintainers
- **Type:** Documentation / Architecture

## Context

The PocketPay SDK submits transactions to two distinct Stellar backends:
Horizon (classic payments) and Soroban RPC (smart-contract vault
operations). Each backend has different preparation, submission, and
confirmation semantics. Without a single reference document, these
differences risk being understood only by whoever last touched the code,
making review and onboarding harder.

This ADR records the current transaction lifecycle for both paths, the
retry and polling assumptions baked into each, and the security boundaries
between the SDK and the consuming application. It is purely descriptive;
it proposes no new behavior.

## Decision

### Lifecycle stages

Every write operation in the SDK follows the same five-stage pipeline.
The details differ between Horizon and Soroban, but the shape is
consistent.

#### Stage 1 — Preflight validation

All user-supplied inputs are validated synchronously before any network
call is made. This includes public-key format, secret-key format, amount
syntax, memo length, self-payment checks, and contract-ID presence.

Invalid inputs throw `PocketPayError` with a machine-readable code
(e.g. `INVALID_PUBLIC_KEY`, `INVALID_AMOUNT`, `SELF_PAYMENT`) and are
never sent over the wire.

**Files:** `src/utils/index.ts` (validation functions), beginning of each
public function in `src/payments/index.ts` and `src/soroban/index.ts`.

#### Stage 2 — Configuration resolution and server construction

`resolveConfig(overrides?)` merges explicit overrides, environment
variables, and built-in defaults (Testnet endpoints, 30 000 ms timeout)
into a fully validated `SDKConfig`. A Horizon `Server` or
`SorobanRpc.Server` is then constructed from the resolved URL.

**Files:** `src/config/index.ts`.

#### Stage 3 — Account state fetch

The source account's current sequence number and state are loaded from
the network. This is a read-only call that anchors the transaction to a
specific ledger snapshot.

- **Horizon path:** `server.loadAccount(publicKey)` returns a
`StellarSDK.Account` usable with `TransactionBuilder`.
- **Soroban path:** `sorobanServer.getAccount(publicKey)` returns a
Soroban-aware account object.

Both calls are wrapped in `withTimeout()` using the resolved timeout.

#### Stage 4 — Transaction construction, signing, and (for Soroban) simulation

**Horizon payments (`sendXLM`):**

1. A `TransactionBuilder` is created with `BASE_FEE` and the network
passphrase.
2. A `payment` operation (native XLM) is added; an optional memo is
attached.
3. `setTimeout(30)` is set on the builder so the transaction expires if
not included within 30 seconds of ledger close.
4. The transaction is built and signed with the source `Keypair`.

**Soroban vault operations (`depositToVault`, `withdrawFromVault`,
`getVaultBalance`):**

1. A `TransactionBuilder` is created with `BASE_FEE` and the network
passphrase.
2. A `contract.call(...)` operation is added with the appropriate
Soroban-scVal arguments (public key as `address`, amount as `i128`).
3. `setTimeout(30)` is set.
4. The transaction is built (but **not** signed yet).
5. The transaction is **simulated** via
`sorobanServer.simulateTransaction(tx)`.
6. If simulation returns an error, a `VaultResult` with `success: false`
is returned immediately — no submission occurs.
7. If simulation succeeds, `rpc.assembleTransaction(tx, simulated).build()`
produces a prepared transaction with the correct resource fees and
Soroban footprint baked in.
8. The prepared transaction is signed with the source `Keypair`.

The simulate-then-assemble pattern is required by Soroban: the runtime
must evaluate the contract to determine CPU/memory resource consumption
before the transaction can be priced and submitted.

#### Stage 5 — Submission and confirmation

**Horizon payments (`sendXLM`):**

`server.submitTransaction(transaction)` sends the signed transaction
to Horizon. On success the response is mapped to a `PaymentResult` with
`hash`, `ledger`, `fee`, and `createdAt`.

On failure, the error is classified:

| Horizon condition | SDK error code | Behavior |
|---|---|---|
| HTTP 404 | `ACCOUNT_NOT_FOUND` | Thrown immediately |
| `result_codes.transaction` present | `PAYMENT_FAILED` | Thrown with result code |
| Other / network error | `SEND_ERROR` | Wrapped via `wrapError` |

**Idempotent submission (`submitTransactionIdempotently`):**

The SDK also exports an idempotent submission helper for callers who
need stronger delivery guarantees. It wraps `server.submitTransaction`
and, when the initial attempt returns a status-unknown error (timeout or
network failure), enters a polling loop:

1. The transaction hash is extracted from the signed transaction.
2. `pollTransactionStatus` queries Horizon for the transaction record.
3. Polling continues at 2-second intervals for up to 10 attempts (or
until the transaction's `timeBounds.maxTime` is exceeded, whichever
comes first).
4. If the transaction is found, the record is returned (confirming it
was eventually included).
5. If the transaction expires on-chain, `TX_EXPIRED` is thrown.
6. If polling exhausts all attempts without resolution,
`TX_STATUS_UNKNOWN` (HTTP 504) is thrown.

**Soroban vault operations:**

`sorobanServer.sendTransaction(prepared)` submits the prepared
transaction. If the send itself returns `status: 'ERROR'`, a
`VaultResult` with `success: false` is returned immediately.

Otherwise, the SDK enters a polling loop:

1. `sorobanServer.getTransaction(hash)` is called.
2. While the status is `NOT_FOUND`, the SDK waits 1 second and retries.
3. Once the status is no longer `NOT_FOUND`, the loop exits.
4. `SUCCESS` returns `{ success: true, hash }`.
5. Any other final status returns `{ success: false, error }`.

There is no maximum poll count on the Soroban path — the loop continues
until the transaction reaches a terminal state. The 30-second
`setTimeout` on the transaction itself provides the ultimate bound: if
the transaction is not included within that window, the Stellar network
rejects it.

### Timeout strategy

Every network call in the SDK is wrapped in `withTimeout(operation, timeoutMs, promise)`.

- Default timeout: 30 000 ms (configurable via `SDKConfig.timeout` or
`STELLAR_TIMEOUT` env var).
- On timeout, the promise rejects with `PocketPayError` code
`REQUEST_TIMEOUT`.
- The timeout is enforced via `Promise.race` against a timer; the timer
is cleaned up in a `finally` block regardless of which side wins.
- For `fetch`-based calls (e.g. Friendbot), `fetchWithTimeout` uses
`AbortController` when available, falling back to the same `Promise.race`
strategy on older runtimes.

Timeouts are per-request, not cumulative. A Soroban vault deposit that
requires simulation + submission + polling may incur multiple independent
timeouts across those steps.

### Error model

All errors bubble up as `PocketPayError` instances with a stable
machine-readable `code` field. Consumers branch on `code`, never on
message text.

- **Throwing functions** (`sendXLM`, `depositToVault`, etc.) throw
`PocketPayError` on failure.
- **Safe wrappers** (`safeSendXLM`, etc.) return a `PocketPayResult<T>`
discriminated union — `ok: true` with a value, or `ok: false` with an
error. They never reject.
- **Enhanced wrappers** (`enhancedSendXLM`, etc.) return an
`EnhancedPocketPayResult<T>` that carries the same value/error plus
optional `ResultWarning[]` and `RecoveryHint[]`. Hints include an
`action` string (`"retry"`, `"fund_account"`, `"check_input"`, etc.),
a human-readable `message`, and `retryable` / `suggestedDelayMs` fields.

The `wrapError` utility normalizes non-`PocketPayError` throwables into
a `PocketPayError` with the original error as `cause`.

### Security boundaries

The SDK enforces the following invariants:

1. **No key persistence.** The SDK never writes, caches, or logs secret
keys. `createWallet` generates a keypair in memory and returns it; the
consumer is responsible for persisting it to secure storage.

2. **Preflight-only validation.** All input validation happens before any
network call. Malformed keys, amounts, or addresses never reach
Horizon or Soroban RPC.

3. **No secret leakage in errors.** Validation errors for secret keys
use generic messages (`"Invalid Stellar secret key"`) without
including the key value. The `redactSecretKey` and `redactSensitive`
utilities are available for consumers who need to log key-adjacent
data.

4. **Configuration validated eagerly.** `resolveConfig` validates every
field (network, URLs, timeout, contract ID) before returning. Invalid
configuration fails fast with a descriptive `PocketPayError` rather
than producing a malformed request downstream.

5. **Testnet default.** The SDK defaults to Stellar Testnet. Mainnet
must be selected explicitly, preventing accidental real-asset
transactions during development.

### Consumer responsibilities

The SDK does not handle the following, and consuming applications must:

- **Key backup and recovery.** Secret keys exist only in the return
value of `createWallet` or `importWallet`. If lost, the SDK provides
no recovery mechanism.

- **Retry strategy.** The SDK's idempotency module handles post-submit
polling, but automatic retry of the initial submission is the
consumer's responsibility. The `enhanced*` variants provide
`RecoveryHint` data to guide this.

- **Transaction confirmation UX.** The SDK returns transaction hashes
and ledger numbers, but does not poll for ledger close or provide
real-time confirmation callbacks. Consumers that need a "confirmed"
indicator should use `getTransactions` or the Stellar SDK's
`transactions().transaction(hash)` endpoint.

- **Fee estimation.** The SDK uses `BASE_FEE` for all transactions.
Consumers that need dynamic fee management should use the Stellar
SDK's fee simulation directly.

- **Nonce / sequence number management.** The SDK fetches the source
account state fresh for each transaction. Concurrent transactions
from the same account may encounter sequence-number conflicts. The
consumer is responsible for sequencing or using the Stellar SDK's
`TransactionBuilder` sequence-number management if concurrency is
required.

## Consequences

**Positive.** A single reference document makes the transaction lifecycle
accessible to reviewers and new contributors without reading implementation
code. The lifecycle stages, retry semantics, and security boundaries are
now explicit and auditable.

**Negative / trade-offs.** This document reflects current practice; as
the codebase evolves, the ADR must be kept in sync or superseded. The
lifecycle description is necessarily a simplification — edge cases in
Horizon error classification or Soroban simulation are in the code, not
here.

**Neutral.** This ADR does not change any SDK behavior. It is a
documentation-only record.

## References

- [ADR 0001 — SDK API Design Principles](./0001-api-design-principles.md)
- [ADR 0002 — Account Abstraction Layer](./0002-account-abstraction.md)
- [Network Error Handling](../network-errors.md)
- [Error Handling](../error-handling.md)
- [Security Best Practices](../security.md)
- [Soroban Vault](../soroban-vault.md)