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
129 changes: 129 additions & 0 deletions src/contractRetryQueue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* Persistent retry queue for Soroban contract invocations.
*
* Wraps a caller-supplied executor (which builds the initial unsigned
* transaction and signs/submits a resource-assembled one) with re-simulation,
* exponential backoff, and durable tracking so transient ledger congestion or
* fee/resource mis-estimation doesn't surface as an immediate failure to the
* caller.
*
* Note: `RetryEngine` (retryEngine.ts) intentionally never retries
* contract-classified errors, so this queue implements its own backoff loop
* rather than reusing that engine.
*/

import { rpc as SorobanRpc, type Transaction } from "@stellar/stellar-sdk";
import type { ContractInvocation, ContractResult } from "./types.js";
import { ContractRetryExhaustedError } from "./errors.js";
import { PersistentTxQueue } from "./persistentQueue.js";
import { TypedEventEmitter, type Unsubscribe } from "./events/TypedEventEmitter.js";

/** Builds and submits the transaction for a contract invocation. */
export interface ContractInvocationExecutor {
/** Build the unsigned transaction for this invocation (fee/sequence handled by the caller). */
buildTransaction(invocation: ContractInvocation): Promise<Transaction>;
/** Sign and submit a simulated-and-assembled transaction, returning the result once confirmed. */
submit(tx: Transaction): Promise<ContractResult>;
}

export interface ContractRetryQueueConfig {
/** Initial backoff delay in milliseconds. Default: 500. */
baseDelayMs?: number;
/** Maximum backoff delay in milliseconds. Default: 30 000. */
maxDelayMs?: number;
/** Maximum number of attempts before giving up. Default: 5. */
maxAttempts?: number;
}

interface ContractRetryEvents {
contractRetryAttempted: { invocation: ContractInvocation; attempt: number; delay: number; error: unknown };
contractRetryExhausted: { invocation: ContractInvocation; attempts: number; error: unknown };
}

const DEFAULT_CONFIG: Required<ContractRetryQueueConfig> = {
baseDelayMs: 500,
maxDelayMs: 30_000,
maxAttempts: 5,
};

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

export class ContractRetryQueue {
private readonly _config: Required<ContractRetryQueueConfig>;
private readonly _emitter = new TypedEventEmitter<ContractRetryEvents>();
private readonly _persistentQueue = new PersistentTxQueue();
private _seq = 0;

constructor(
private readonly server: SorobanRpc.Server,
private readonly executor: ContractInvocationExecutor,
config: ContractRetryQueueConfig = {}
) {
this._config = { ...DEFAULT_CONFIG, ...config };
}

/** Subscribe to `contractRetryAttempted` / `contractRetryExhausted` events. */
on<K extends keyof ContractRetryEvents>(
event: K,
handler: (payload: ContractRetryEvents[K]) => void
): Unsubscribe {
return this._emitter.on(event, handler);
}

/**
* Enqueue a contract invocation. Resolves once the invocation succeeds,
* re-simulating and resubmitting with exponential backoff on failure.
*
* @throws {ContractRetryExhaustedError} After `maxAttempts` failed attempts.
*/
async enqueue(invocation: ContractInvocation): Promise<ContractResult> {
const id = `contract-retry-${++this._seq}-${invocation.contractId}`;
await this._persistentQueue.enqueue({ id, payload: invocation, enqueuedAt: Date.now() });

try {
return await this._runWithRetry(invocation);
} finally {
await this._persistentQueue.remove(id);
}
}

private async _runWithRetry(invocation: ContractInvocation): Promise<ContractResult> {
let lastError: unknown;

for (let attempt = 1; attempt <= this._config.maxAttempts; attempt++) {
try {
return await this._attempt(invocation);
} catch (error) {
lastError = error;
if (attempt >= this._config.maxAttempts) break;

const delay = Math.min(
this._config.baseDelayMs * 2 ** (attempt - 1),
this._config.maxDelayMs
);
this._emitter.emit("contractRetryAttempted", { invocation, attempt, delay, error });
await sleep(delay);
}
}

this._emitter.emit("contractRetryExhausted", {
invocation,
attempts: this._config.maxAttempts,
error: lastError,
});
throw new ContractRetryExhaustedError(this._config.maxAttempts, lastError);
}

private async _attempt(invocation: ContractInvocation): Promise<ContractResult> {
const tx = await this.executor.buildTransaction(invocation);
const simResult = await this.server.simulateTransaction(tx);
if (SorobanRpc.Api.isSimulationError(simResult)) {
throw new Error(`Simulation failed: ${simResult.error}`);
}

const prepared = SorobanRpc.assembleTransaction(tx, simResult).build();
return this.executor.submit(prepared);
}
}
87 changes: 87 additions & 0 deletions src/effectAggregator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Transaction operation effect aggregator.
*
* Fetches all Horizon effects for a submitted transaction and consolidates
* them into a per-account net asset balance delta summary, so callers get a
* single "what changed" view instead of raw per-operation effect records.
*/

import type { Horizon } from "@stellar/stellar-sdk";
import type { AccountEffectSummary, AssetDelta } from "./types.js";
import { collectAll } from "./horizonPaginator.js";

/** Effect types that represent a net balance change for an account. */
const CREDIT_EFFECT = "account_credited";
const DEBIT_EFFECT = "account_debited";

/**
* Convert a Horizon decimal amount string (always 7 fractional digits) to a
* stroop bigint, without floating-point rounding.
*/
function decimalToStroops(amount: string): bigint {
const [intPart, fracPart = ""] = amount.split(".");
const frac = fracPart.padEnd(7, "0").slice(0, 7);
return BigInt(intPart || "0") * 10_000_000n + BigInt(frac || "0");
}

function effectAssetKey(effect: Record<string, unknown>): string {
if (effect.asset_type === "native") return "native";
const code = effect.asset_code as string | undefined;
const issuer = effect.asset_issuer as string | undefined;
if (code && issuer) return `${code}:${issuer}`;
return String(effect.asset_type ?? "unknown");
}

/**
* Aggregate all effects of a transaction into per-account net asset deltas.
*
* Only `account_credited`/`account_debited` effects are counted; intermediate
* DEX effects (offer creation/removal, trades) are ignored since they net out
* to the same credited/debited effects on the accounts actually affected.
*
* @param server - Horizon server instance.
* @param txHash - Hash of the transaction to aggregate effects for.
* @returns One summary per affected account, sorted by account ID.
*/
export async function aggregateEffects(
server: Horizon.Server,
txHash: string,
): Promise<AccountEffectSummary[]> {
const initialPage = await server.effects().forTransaction(txHash).call();
const effects = await collectAll(initialPage);

const byAccount = new Map<string, Map<string, bigint>>();

for (const raw of effects) {
const effect = raw as unknown as Record<string, unknown>;
const type = effect.type as string | undefined;
if (type !== CREDIT_EFFECT && type !== DEBIT_EFFECT) continue;

const account = effect.account as string | undefined;
const amount = effect.amount as string | undefined;
if (!account || amount === undefined) continue;

const asset = effectAssetKey(effect);
const stroops = decimalToStroops(amount);
const signed = type === DEBIT_EFFECT ? -stroops : stroops;

let assetDeltas = byAccount.get(account);
if (!assetDeltas) {
assetDeltas = new Map();
byAccount.set(account, assetDeltas);
}
assetDeltas.set(asset, (assetDeltas.get(asset) ?? 0n) + signed);
}

const summaries: AccountEffectSummary[] = [];
for (const accountId of Array.from(byAccount.keys()).sort()) {
const assetDeltas: AssetDelta[] = Array.from(byAccount.get(accountId)!.entries())
.filter(([, delta]) => delta !== 0n)
.map(([asset, delta]) => ({ asset, delta }));
if (assetDeltas.length > 0) {
summaries.push({ accountId, assetDeltas });
}
}

return summaries;
}
40 changes: 40 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,38 @@ export class Sep41AdapterError extends StellarSplitError {
}
}

/** Thrown when a queued contract invocation exhausts its retry attempts. */
export class ContractRetryExhaustedError extends StellarSplitError {
readonly attempts: number;

constructor(attempts: number, lastError: unknown) {
super(
`Contract invocation retry exhausted after ${attempts} attempts`,
"CONTRACT_RETRY_EXHAUSTED",
{ attempts, lastError: lastError instanceof Error ? lastError.message : String(lastError) }
);
this.name = "ContractRetryExhaustedError";
this.attempts = attempts;
Object.setPrototypeOf(this, new.target.prototype);
}
}

/** Thrown when a line item's asset has no oracle price available for normalisation. */
export class UnsupportedLineItemAssetError extends StellarSplitError {
readonly asset: string;

constructor(asset: string) {
super(
`No oracle price available for line item asset: ${asset}`,
"UNSUPPORTED_LINE_ITEM_ASSET",
{ asset }
);
this.name = "UnsupportedLineItemAssetError";
this.asset = asset;
Object.setPrototypeOf(this, new.target.prototype);
}
}

/** Thrown when tranche status check fails. */
export class TrancheProgressError extends StellarSplitError {
constructor(message: string) {
Expand Down Expand Up @@ -1087,6 +1119,14 @@ export function isInsufficientSignaturesError(err: unknown): err is Insufficient
return err instanceof InsufficientSignaturesError;
}

export function isUnsupportedLineItemAssetError(err: unknown): err is UnsupportedLineItemAssetError {
return err instanceof UnsupportedLineItemAssetError;
}

export function isContractRetryExhaustedError(err: unknown): err is ContractRetryExhaustedError {
return err instanceof ContractRetryExhaustedError;
}

export function isCloneChainTooDeepError(err: unknown): err is CloneChainTooDeepError {
return err instanceof CloneChainTooDeepError;
}
Expand Down
30 changes: 30 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,8 +585,38 @@ export type {
PaymentReceipt,
PaymentReceiptJSON,
InvoiceFetcher,
ReceiptConfig,
} from "./receipt.js";

// Transaction operation effect aggregator
export { aggregateEffects } from "./effectAggregator.js";
export type { AccountEffectSummary, AssetDelta } from "./types.js";

// Multi-asset invoice line item normalizer
export { normalizeLineItems } from "./lineItemNormalizer.js";
export { ContractPriceOracle } from "./priceOracle.js";
export type { PriceOracle } from "./priceOracle.js";
export type {
InvoiceLineItem,
NormalizedLineItem,
NormalizedInvoiceTotal,
} from "./types.js";
export { UnsupportedLineItemAssetError, isUnsupportedLineItemAssetError } from "./errors.js";

// Contract invocation retry queue
export { ContractRetryQueue } from "./contractRetryQueue.js";
export type { ContractInvocationExecutor, ContractRetryQueueConfig } from "./contractRetryQueue.js";
export type { ContractInvocation, ContractResult } from "./types.js";
export { ContractRetryExhaustedError, isContractRetryExhaustedError } from "./errors.js";

// Invoice batch processor with concurrency limiter
export { InvoiceBatchProcessor } from "./invoiceBatchProcessor.js";
export type {
BatchInvoiceResult,
InvoiceBatchConfig,
InvoicePaymentSubmitter,
} from "./invoiceBatchProcessor.js";

// Merkle proof functionality
export { generateMerkleProof, verifyMerkleProof } from "./merkle.js";
export type { MerkleProof } from "./merkle.js";
Expand Down
Loading