Skip to content
Merged
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
4 changes: 4 additions & 0 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ All errors thrown by the SDK's modules (Wallet, Payments, Transactions, and Soro
| `code` | `string` | A machine-readable string identifier representing the specific error type. |
| `statusCode` | `number \| undefined` | The HTTP status code returned by the remote service (Horizon, Friendbot, etc.), if applicable. |
| `cause` | `Error \| undefined` | The original underlying error (e.g., Axios/Fetch error or Stellar SDK error) that triggered this error. |
| `transactionHash` | `string \| undefined` | The unique Stellar transaction hash related to the failure, if available. |
| `retryable` | `boolean \| undefined` | Set to `true` if the submission failed in a way that is safe to retry immediately. |

---

Expand All @@ -40,6 +42,8 @@ These errors occur during interactions with the Stellar Horizon network.

* `ACCOUNT_NOT_FOUND` (HTTP 404): The requested public key has not been funded or created on the ledger yet.
* `PAYMENT_FAILED` (HTTP 400): Stellar Core rejected the transaction. The error message will contain Horizon-specific transaction and operation result codes (e.g., `tx_insufficient_balance`, `op_no_destination`).
* `TX_STATUS_UNKNOWN`: A submission timeout or network failure occurred. The transaction may or may not be confirmed in the ledger. Status polling is required before retrying.
* `TX_EXPIRED`: The transaction was not found in the ledger and its `maxTime` bound has expired, indicating it is safe to rebuild and retry.
* `SEND_ERROR`: A general failure occurred while building or submitting the payment transaction.
* `TX_FETCH_ERROR`: Failed to query transaction history.
* `PAYMENTS_FETCH_ERROR`: Failed to query payment operation history.
Expand Down
158 changes: 158 additions & 0 deletions docs/idempotency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# Idempotency Strategy for Transaction Submission

This guide explains the SDK's idempotency strategy for transaction submission, details the risks of blind retries, and demonstrates how to safely handle network timeouts and transient failures.

---

## The Problem: Uncertain Submission Outcomes

When submitting a signed transaction to the Stellar network, a network drop, timeout (e.g., HTTP 504 Gateway Timeout), or transient server issue (e.g., HTTP 503) might occur.

In these cases, **the transaction status is unknown**. The transaction may have reached the Stellar validators and successfully executed, or it may have been dropped entirely.

If the client application blindly rebuilds and signs a new transaction (or retries the exact same transaction after it expires), it risks:
1. **Double spending** (making duplicate payments) if the original transaction actually succeeded.
2. **Unnecessary fees** and sequence number confusion.

---

## The PocketPay Idempotency Strategy

To prevent duplicate submissions, the SDK implements an idempotency strategy based on **Transaction Hash Tracking**, **Status Polling**, and **Timebounds enforcement**.

```mermaid
graph TD
A[Build & Sign Transaction] --> B[Calculate Transaction Hash]
B --> C[Submit to Horizon]
C -->|Success| D[Return Successful PaymentResult]
C -->|Transient Timeout / HTTP 504| E[Classify Error: TX_STATUS_UNKNOWN]
E --> F[Poll Horizon by Hash]
F -->|Found in Ledger| G[Return Successful PaymentResult]
F -->|Not Found & Within maxTime Bounds| H[Wait & Poll Again]
F -->|Not Found & Past maxTime Bounds| I[Throw TX_EXPIRED: Safe to Rebuild & Retry]
F -->|Polling Limit Exceeded| J[Throw TX_STATUS_UNKNOWN: Require Manual Check]
C -->|Final Error: e.g. tx_bad_seq| K[Throw PAYMENT_FAILED: Non-Retryable]
```

### 1. Error Classification & Metadata
Every submission failure is analyzed and categorized via `classifySubmitError`. Enriched metadata is attached to the thrown `PocketPayError`:
* `transactionHash`: The unique SHA-256 hash of the submitted transaction envelope.
* `retryable`: A boolean flag indicating if it is safe to submit the exact same transaction envelope again without checking status (e.g., on rate limits).
* `code`: Custom error codes representing the failure mode:
* `TX_STATUS_UNKNOWN`: The status is unknown due to a gateway timeout. **Do not retry blindly.**
* `PAYMENT_FAILED`: The transaction was rejected on-chain (e.g., `tx_bad_auth`, `tx_insufficient_balance`). Non-retryable.

### 2. Timebounds Check
Every transaction should have a `maxTime` bound (set automatically by `setTimeout` during building).
* If a submission times out, the SDK polls the network for the transaction hash.
* If the transaction is not found, polling continues.
* If the local system time exceeds the transaction's `maxTime` bound and the transaction is still not found on-chain, we are guaranteed that the transaction has expired and can **never** be accepted by validators. It is now safe to rebuild the transaction with a new sequence number and retry.

### 3. Automated & Manual Polling
The SDK provides:
* `submitTransactionIdempotently`: Automatically submits the transaction and handles transient timeouts by polling until confirmation or transaction expiry.
* `pollTransactionStatus`: A helper to query the transaction status manually when an unknown status error is caught.

---

## API Reference

### `submitTransactionIdempotently`
Submits a transaction. If a network timeout or unknown status error occurs, it polls Horizon for confirmation until `maxTime` or `maxPollAttempts` is reached.

```typescript
import { submitTransactionIdempotently, getHorizonServer } from '@axionvera/pocketpay-sdk';

const server = getHorizonServer();
const result = await submitTransactionIdempotently(transaction, {
maxPollAttempts: 10,
pollIntervalMs: 2000
});
```

### `pollTransactionStatus`
Manually queries Horizon for a transaction's status by hash until it is confirmed or its `maxTime` bounds expire.

```typescript
import { pollTransactionStatus } from '@axionvera/pocketpay-sdk';

const result = await pollTransactionStatus(transaction, {
maxPollAttempts: 5,
pollIntervalMs: 1000
});
```

---

## Consumer Implementation Guide

Here is the recommended pattern for consumers submitting transactions:

### Option A: Using `submitTransactionIdempotently` (Recommended)
This approach handles submission and polling automatically.

```typescript
import { sendXLM, PocketPayError } from '@axionvera/pocketpay-sdk';

try {
const result = await sendXLM({
sourceSecret: 'S...',
destination: 'G...',
amount: '10.0',
memo: 'Order #9021'
});
console.log('Payment successful! Hash:', result.hash);
} catch (error) {
if (error instanceof PocketPayError) {
if (error.code === 'TX_STATUS_UNKNOWN') {
console.error(`Transaction status remains unknown. Hash: ${error.transactionHash}. Check explorer before retrying.`);
} else if (error.code === 'TX_EXPIRED') {
console.log('Transaction expired and never executed. Rebuilding and retrying is safe.');
// Rebuild and retry payment...
} else {
console.error(`Submission failed: ${error.code} - ${error.message}`);
}
}
}
```

### Option B: Manual Error Classification & Polling
If you build and submit transactions manually, utilize `classifySubmitError` and `pollTransactionStatus`.

```typescript
import {
getHorizonServer,
classifySubmitError,
pollTransactionStatus
} from '@axionvera/pocketpay-sdk';

const server = getHorizonServer();
const txHash = transaction.hash().toString('hex');

try {
await server.submitTransaction(transaction);
console.log('Submission succeeded!');
} catch (error) {
const classified = classifySubmitError(error, txHash);

if (classified.code === 'TX_STATUS_UNKNOWN') {
console.warn(`Submission timed out. Polling status for hash: ${txHash}...`);
try {
const txRecord = await pollTransactionStatus(transaction, {
maxPollAttempts: 15,
pollIntervalMs: 2000
});
console.log('Confirmed via polling! Ledger:', txRecord.ledger);
} catch (pollError) {
// If pollTransactionStatus throws TX_EXPIRED, it is safe to rebuild & resubmit
if (pollError.code === 'TX_EXPIRED') {
console.error('Transaction expired. Re-building transaction is safe.');
} else {
console.error('Failed to confirm transaction status. DO NOT retry.');
}
}
} else {
console.error('Non-retryable submission error:', classified.message);
}
}
```
13 changes: 13 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ export {
// ─── Soroban Vault ──────────────────────────────────────────────────────────
export { depositToVault, withdrawFromVault, getVaultBalance } from './soroban';

// ─── Network & Idempotency ──────────────────────────────────────────────────
export {
submitTransactionIdempotently,
pollTransactionStatus,
} from './network';

// ─── Errors ─────────────────────────────────────────────────────────────────
export {
classifySubmitError,
isRetryableError,
isUnknownStatusError,
} from './errors';

// ─── Config ─────────────────────────────────────────────────────────────────
export {
resolveConfig,
Expand Down
122 changes: 122 additions & 0 deletions src/network/idempotency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import * as StellarSDK from '@stellar/stellar-sdk';
import { getHorizonServer } from '../config';
import { PocketPayError, SDKConfig } from '../types';
import { classifySubmitError } from '../errors';

export interface IdempotencyOptions {
/** Maximum number of poll attempts (default: 10) */
maxPollAttempts?: number;
/** Delay between poll attempts in milliseconds (default: 2000) */
pollIntervalMs?: number;
}

/**
* Submits a transaction to Horizon with idempotency handling.
* If a timeout or network error occurs during submission, it polls Horizon
* to check if the transaction eventually succeeded, up until the transaction's
* maxTime bounds or maximum poll attempts.
*
* @param transaction - The transaction to submit (Transaction or FeeBumpTransaction)
* @param options - Polling interval and max attempts configuration
* @param config - Optional SDK config overrides
* @returns The successful submission transaction response from Horizon
*/
export async function submitTransactionIdempotently(
transaction: StellarSDK.Transaction | StellarSDK.FeeBumpTransaction,
options: IdempotencyOptions = {},
config?: Partial<SDKConfig>
): Promise<any> {
const txHash = transaction.hash().toString('hex');
const server = getHorizonServer(config);

try {
const result = await server.submitTransaction(transaction);
return result;
} catch (error) {
const classified = classifySubmitError(error, txHash);

// If the status is unknown (timeout/network error), we poll for the status instead of throwing immediately.
if (classified.code === 'TX_STATUS_UNKNOWN') {
return await pollTransactionStatus(transaction, options, config);
}

throw classified;
}
}

/**
* Polls Horizon for the status of a transaction by its hash.
* If not found, continues polling until the transaction's maxTime is reached,
* or maxPollAttempts is exceeded.
*
* @param transaction - The transaction to check status for
* @param options - Polling options (maxPollAttempts, pollIntervalMs)
* @param config - Optional SDK config overrides
* @returns The transaction record from Horizon once successfully found
*/
export async function pollTransactionStatus(
transaction: StellarSDK.Transaction | StellarSDK.FeeBumpTransaction,
options: IdempotencyOptions = {},
config?: Partial<SDKConfig>
): Promise<any> {
const txHash = transaction.hash().toString('hex');
const server = getHorizonServer(config);
const maxPollAttempts = options.maxPollAttempts ?? 10;
const pollIntervalMs = options.pollIntervalMs ?? 2000;

// Retrieve maxTime from the transaction's timeBounds (handle both Transaction & FeeBumpTransaction)
let maxTime: bigint | undefined;
if ('timeBounds' in transaction && transaction.timeBounds) {
maxTime = BigInt(transaction.timeBounds.maxTime);
} else if ('innerTransaction' in transaction && (transaction as any).innerTransaction?.timeBounds) {
maxTime = BigInt((transaction as any).innerTransaction.timeBounds.maxTime);
}

for (let attempt = 1; attempt <= maxPollAttempts; attempt++) {
// Check if the transaction has expired based on local system time
if (maxTime && maxTime > 0n) {
const nowInSeconds = BigInt(Math.floor(Date.now() / 1000));
if (nowInSeconds > maxTime) {
throw new PocketPayError(
`Transaction expired on-chain (maxTime bounds exceeded: ${maxTime.toString()})`,
'TX_EXPIRED',
400,
undefined,
txHash,
false
);
}
}

try {
// Query Horizon for the transaction details
const txRecord = await server.transactions().transaction(txHash).call();
if (txRecord) {
return txRecord;
}
} catch (error: any) {
// Horizon returns 404 (Not Found) if the transaction hasn't been included in a ledger yet.
const isNotFound = error?.response?.status === 404 || error?.status === 404;
if (!isNotFound) {
const classified = classifySubmitError(error, txHash);
if (classified.code !== 'TX_STATUS_UNKNOWN') {
throw classified;
}
}
}

// Wait before the next poll attempt
if (attempt < maxPollAttempts) {
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
}

throw new PocketPayError(
`Failed to determine transaction status after ${maxPollAttempts} attempts.`,
'TX_STATUS_UNKNOWN',
504,
undefined,
txHash,
false
);
}
2 changes: 1 addition & 1 deletion src/payments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export async function sendXLM(
builder.addMemo(StellarSDK.Memo.text(memo));
}
builder.setTimeout(30);
const transaction = builder.build();
transaction = builder.build();
transaction.sign(sourceKeypair);
const result = await withTimeout(
'Horizon transaction submission',
Expand Down
Loading