From 00c116847e7a49838f17782aed9eb19ec335e9c4 Mon Sep 17 00:00:00 2001 From: whisper011 Date: Wed, 29 Jul 2026 14:08:20 +0000 Subject: [PATCH 1/4] Add transaction result XDR decoder and attach decoded results to payment receipts --- src/index.ts | 2 + src/receipt.ts | 36 +++++++++++-- src/txResultDecoder.ts | 114 +++++++++++++++++++++++++++++++++++++++++ src/types.ts | 17 ++++++ 4 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 src/txResultDecoder.ts diff --git a/src/index.ts b/src/index.ts index 38d8fe1..8675d20 100644 --- a/src/index.ts +++ b/src/index.ts @@ -383,6 +383,7 @@ export type { DecodedXDR, DecodedTransactionEnvelope, DecodedTransactionResult, + DecodedOperationResult, DecodedTransactionMeta, DecodedLedgerEntry, DecodedOperation, @@ -423,6 +424,7 @@ export { getSuggestion } from "./errorSuggestions.js"; // --------------------------------------------------------------------------- export { decodeXDR } from "./xdrDecoder.js"; +export { decodeTransactionResult } from "./txResultDecoder.js"; // --------------------------------------------------------------------------- // SSE Cursor Tracker — persistent cursor for stream resumption diff --git a/src/receipt.ts b/src/receipt.ts index deaaa39..ae3bf07 100644 --- a/src/receipt.ts +++ b/src/receipt.ts @@ -1,6 +1,7 @@ import { createHash } from "crypto"; -import type { Invoice, Payment } from "./types.js"; +import type { DecodedTransactionResult, Invoice, Payment } from "./types.js"; import { PayerAddressRequiredError } from "./errors.js"; +import { decodeTransactionResult } from "./txResultDecoder.js"; /** A verified payment receipt compiled from on-chain invoice data. */ export interface PaymentReceipt { @@ -18,6 +19,8 @@ export interface PaymentReceipt { generatedAt: number; /** Ledger timestamp or sequence used in the proof hash calculation. */ ledgerTimestamp: number; + /** Decoded submission result, when a `resultXdr` was supplied during receipt generation. */ + decodedResult?: DecodedTransactionResult; /** Convert receipt to a JSON-serializable object (with bigints represented as strings). */ toJSON(): PaymentReceiptJSON; } @@ -37,6 +40,7 @@ export interface PaymentReceiptJSON { proofHash: string; generatedAt: number; ledgerTimestamp: number; + decodedResult?: DecodedTransactionResult; } /** Interface for any client capable of fetching an invoice by ID. */ @@ -50,11 +54,15 @@ export interface InvoiceFetcher { * * @param invoice - The on-chain invoice object. * @param payerAddress - The Stellar address of the payer. + * @param resultXdr - Optional base64-encoded `TransactionResult` XDR from the + * payment submission; when provided, it is decoded and + * attached as `decodedResult`. * @returns A structured payment receipt with SHA-256 proof hash. */ export function compilePaymentReceipt( invoice: Invoice, - payerAddress: string + payerAddress: string, + resultXdr?: string ): PaymentReceipt { const payerPayments = (invoice.payments || []).filter( (p) => p.payer === payerAddress @@ -82,6 +90,15 @@ export function compilePaymentReceipt( const proofHash = createHash("sha256").update(payload).digest("hex"); const generatedAt = Date.now(); + let decodedResult: DecodedTransactionResult | undefined; + if (resultXdr) { + try { + decodedResult = decodeTransactionResult(resultXdr); + } catch { + // Best-effort: an unparseable resultXdr should not fail receipt generation. + } + } + return _buildReceiptObject({ invoiceId: invoice.id, payer: payerAddress, @@ -90,6 +107,7 @@ export function compilePaymentReceipt( proofHash, generatedAt, ledgerTimestamp, + decodedResult, }); } @@ -100,22 +118,26 @@ export function compilePaymentReceipt( * @param source - Either a client with `getInvoice` or an `Invoice` object. * @param invoiceIdOrPayer - The invoice ID (if passing a client) or payer address (if passing an Invoice). * @param payerAddress - The payer address (if passing a client). + * @param resultXdr - Optional base64-encoded `TransactionResult` XDR from the + * payment submission; when provided, it is decoded and + * attached as `decodedResult`. * @returns Promise resolving to the PaymentReceipt. */ export async function generatePaymentReceipt( source: InvoiceFetcher | Invoice, invoiceIdOrPayer: string, - payerAddress?: string + payerAddress?: string, + resultXdr?: string ): Promise { if ("getInvoice" in source && typeof source.getInvoice === "function") { if (!payerAddress) { throw new PayerAddressRequiredError(); } const invoice = await source.getInvoice(invoiceIdOrPayer); - return compilePaymentReceipt(invoice, payerAddress); + return compilePaymentReceipt(invoice, payerAddress, resultXdr); } - return compilePaymentReceipt(source as Invoice, invoiceIdOrPayer); + return compilePaymentReceipt(source as Invoice, invoiceIdOrPayer, resultXdr); } /** @@ -152,6 +174,7 @@ export function deserializePaymentReceipt(json: string): PaymentReceipt { proofHash: data.proofHash, generatedAt: data.generatedAt, ledgerTimestamp: data.ledgerTimestamp, + decodedResult: data.decodedResult, }); } @@ -163,6 +186,7 @@ function _buildReceiptObject(data: { proofHash: string; generatedAt: number; ledgerTimestamp: number; + decodedResult?: DecodedTransactionResult; }): PaymentReceipt { return { invoiceId: data.invoiceId, @@ -172,6 +196,7 @@ function _buildReceiptObject(data: { proofHash: data.proofHash, generatedAt: data.generatedAt, ledgerTimestamp: data.ledgerTimestamp, + decodedResult: data.decodedResult, toJSON(): PaymentReceiptJSON { return { invoiceId: this.invoiceId, @@ -184,6 +209,7 @@ function _buildReceiptObject(data: { proofHash: this.proofHash, generatedAt: this.generatedAt, ledgerTimestamp: this.ledgerTimestamp, + decodedResult: this.decodedResult, }; }, }; diff --git a/src/txResultDecoder.ts b/src/txResultDecoder.ts new file mode 100644 index 0000000..7d2f4ce --- /dev/null +++ b/src/txResultDecoder.ts @@ -0,0 +1,114 @@ +/** + * Transaction Result XDR Decoder — parses the base64-encoded XDR + * `TransactionResult` blob returned by Horizon/Soroban RPC after a + * submission into a typed, JSON-safe object: fee charged, result code, + * per-operation results, and (for fee-bump transactions) both the outer + * fee-bump result and the nested inner transaction result. + */ + +import { xdr } from "@stellar/stellar-sdk"; +import type { + DecodedOperationResult, + DecodedTransactionResult, +} from "./types.js"; + +const FEE_BUMP_CODES = new Set([ + "txFeeBumpInnerSuccess", + "txFeeBumpInnerFailed", +]); + +/** Decode a single per-operation `xdr.OperationResult` entry. */ +function decodeOperationResult( + opResult: xdr.OperationResult, +): DecodedOperationResult { + const code = opResult.switch().name; + const decoded: DecodedOperationResult = { code }; + + if (code === "opInner") { + try { + const tr = opResult.tr(); + decoded.operationType = tr.switch().name; + try { + decoded.resultCode = tr.value().switch().name; + } catch { + // Some operation results (e.g. inflation) don't nest a result union. + } + } catch { + // best-effort + } + } + + return decoded; +} + +/** Safely read the per-operation results array off a (inner) transaction result union. */ +function safeOperationResults( + resultUnion: xdr.TransactionResultResult | xdr.InnerTransactionResultResult, +): xdr.OperationResult[] { + try { + return resultUnion.results() ?? []; + } catch { + return []; + } +} + +/** + * Decode a base64-encoded `TransactionResult` XDR string. + * + * @param resultXdr - Base64-encoded XDR of the Horizon/Soroban `TransactionResult`. + * @returns A typed, JSON-safe `DecodedTransactionResult`. + * + * @example + * ```ts + * import { decodeTransactionResult } from "@stellar-split/sdk"; + * + * const decoded = decodeTransactionResult(resultXdrBase64); + * console.log(decoded.result.code, decoded.operationResults?.length); + * ``` + */ +export function decodeTransactionResult( + resultXdr: string, +): DecodedTransactionResult { + const buffer = Buffer.from(resultXdr, "base64"); + const result = xdr.TransactionResult.fromXDR(buffer); + const resultUnion = result.result(); + const code = resultUnion.switch().name; + const feeCharged = result.feeCharged().toString(); + + const decoded: DecodedTransactionResult = { + type: "TransactionResult", + feeCharged, + result: { code }, + operationResults: [], + }; + + if (FEE_BUMP_CODES.has(code)) { + const pair = resultUnion.innerResultPair(); + const innerResult = pair.result(); + const innerResultUnion = innerResult.result(); + const innerCode = innerResultUnion.switch().name; + const innerFeeCharged = innerResult.feeCharged().toString(); + const innerOperationResults = safeOperationResults(innerResultUnion).map( + decodeOperationResult, + ); + + const inner: DecodedTransactionResult = { + type: "TransactionResult", + feeCharged: innerFeeCharged, + result: { code: innerCode }, + operationResults: innerOperationResults, + }; + + decoded.feeBump = { + outer: { feeCharged, code }, + inner, + }; + decoded.operationResults = innerOperationResults; + } else { + decoded.operationResults = safeOperationResults(resultUnion).map( + decodeOperationResult, + ); + } + + return decoded; +} diff --git a/src/types.ts b/src/types.ts index fff3f03..1d531ed 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1296,6 +1296,16 @@ export interface DecodedOperation { body: Record; } +/** A single decoded per-operation result within a DecodedTransactionResult. */ +export interface DecodedOperationResult { + /** OperationResultCode switch name (e.g. "opInner", "opBadAuth", "opNoAccount"). */ + code: string; + /** Operation type name when `code === "opInner"` (e.g. "payment", "createClaimableBalance"). */ + operationType?: string; + /** The operation-specific result code (e.g. "paymentSuccess", "paymentUnderfunded"). */ + resultCode?: string; +} + /** Decoded TransactionResult as a structured JSON-safe object. */ export interface DecodedTransactionResult { type: "TransactionResult"; @@ -1304,6 +1314,13 @@ export interface DecodedTransactionResult { code: string; innerResult?: Record; }; + /** Per-operation results, in the same order as the submitted transaction's operations. */ + operationResults?: DecodedOperationResult[]; + /** Present only for fee-bump transactions: the outer fee-bump result plus the nested inner transaction result. */ + feeBump?: { + outer: { feeCharged: string; code: string }; + inner: DecodedTransactionResult; + }; } /** Decoded TransactionMeta as a structured JSON-safe object. */ From 75dc847b81ea25f1961406eb078b4f41019f7671 Mon Sep 17 00:00:00 2001 From: whisper011 Date: Wed, 29 Jul 2026 14:09:36 +0000 Subject: [PATCH 2/4] Add fluent claimable balance predicate builder with configurable claim windows --- src/claimableBalanceFallback.ts | 11 +++-- src/index.ts | 4 ++ src/predicateBuilder.ts | 77 +++++++++++++++++++++++++++++++++ src/types.ts | 8 ++++ 4 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 src/predicateBuilder.ts diff --git a/src/claimableBalanceFallback.ts b/src/claimableBalanceFallback.ts index 61885f8..ed4055f 100644 --- a/src/claimableBalanceFallback.ts +++ b/src/claimableBalanceFallback.ts @@ -25,6 +25,7 @@ import type { StellarSplitClientConfig } from "./client.js"; import { ValidationError, ClaimableBalanceLifecycleError } from "./errors.js"; import { TypedEventEmitter, type Unsubscribe } from "./events/TypedEventEmitter.js"; import type { ClaimableBalanceRecord, ClaimableBalanceStatus } from "./types.js"; +import { PredicateBuilder, type ClaimPredicate } from "./predicateBuilder.js"; // --------------------------------------------------------------------------- // Error-pattern detection @@ -97,7 +98,9 @@ export interface ClaimableRefundEntry { * Build and submit a `createClaimableBalance` operation so that `payer` can * claim the refund once their account / trustline is ready. * - * The claimable balance is unconditional — the payer may claim it at any time. + * The claimable balance is unconditional by default — the payer may claim it + * at any time. Pass `predicate` (see {@link PredicateBuilder}) to restrict + * when the balance can be claimed. * * Requires `config.horizonUrl` to be set. * @@ -107,6 +110,7 @@ export interface ClaimableRefundEntry { * @param sourceAddress - Stellar address funding / submitting the transaction. * This account must hold sufficient `asset` balance. * @param config - StellarSplit client config. `horizonUrl` must be set. + * @param predicate - Optional claim predicate. Defaults to unconditional. * * @throws If `config.horizonUrl` is not configured. */ @@ -115,7 +119,8 @@ export async function createClaimableRefund( amount: bigint, asset: Asset, sourceAddress: string, - config: StellarSplitClientConfig + config: StellarSplitClientConfig, + predicate: ClaimPredicate = PredicateBuilder.unconditional() ): Promise { if (!config.horizonUrl) { throw new ValidationError( @@ -143,7 +148,7 @@ export async function createClaimableRefund( Operation.createClaimableBalance({ asset, amount: amountStr, - claimants: [new Claimant(payer, Claimant.predicateUnconditional())], + claimants: [new Claimant(payer, predicate)], }) ) .setTimeout(30) diff --git a/src/index.ts b/src/index.ts index 8675d20..79e927f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -774,6 +774,10 @@ export type { ClaimableBalanceLifecycleEventMap, } from "./claimableBalanceFallback.js"; +export { PredicateBuilder } from "./predicateBuilder.js"; +export type { ClaimPredicate } from "./predicateBuilder.js"; +export type { PredicateConfig } from "./types.js"; + export { subscribeToInvoice } from "./sse.js"; export type { SSEInvoiceEventType, diff --git a/src/predicateBuilder.ts b/src/predicateBuilder.ts new file mode 100644 index 0000000..d358122 --- /dev/null +++ b/src/predicateBuilder.ts @@ -0,0 +1,77 @@ +/** + * Claimable Balance Predicate Builder — a fluent, type-safe wrapper around + * `@stellar/stellar-sdk`'s `Claimant` predicate helpers, which otherwise + * require directly composing `xdr.ClaimPredicate` unions by hand. + */ + +import { Claimant, xdr } from "@stellar/stellar-sdk"; +import type { PredicateConfig } from "./types.js"; + +export type ClaimPredicate = xdr.ClaimPredicate; + +/** Fluent builder for Stellar claimable-balance claim predicates. */ +export class PredicateBuilder { + /** An always-claimable predicate. */ + static unconditional(): ClaimPredicate { + return Claimant.predicateUnconditional(); + } + + /** + * A predicate claimable only within `[startUnixSeconds, endUnixSeconds]`. + * + * @param startUnixSeconds - Unix timestamp (seconds) the balance becomes claimable. + * @param endUnixSeconds - Unix timestamp (seconds) after which the balance can no longer be claimed. + */ + static absoluteWindow( + startUnixSeconds: number, + endUnixSeconds: number, + ): ClaimPredicate { + const notBeforeStart = Claimant.predicateNot( + Claimant.predicateBeforeAbsoluteTime(startUnixSeconds.toString()), + ); + const beforeEnd = Claimant.predicateBeforeAbsoluteTime( + endUnixSeconds.toString(), + ); + return Claimant.predicateAnd(notBeforeStart, beforeEnd); + } + + /** + * A predicate claimable for the next `secondsFromNow` seconds (relative to + * the ledger close time the claim transaction is applied in). + */ + static relativeWindow(secondsFromNow: number): ClaimPredicate { + return Claimant.predicateBeforeRelativeTime(secondsFromNow.toString()); + } + + /** Logical AND of two predicates — both must hold for the claim to succeed. */ + static and(a: ClaimPredicate, b: ClaimPredicate): ClaimPredicate { + return Claimant.predicateAnd(a, b); + } + + /** Logical OR of two predicates — either may hold for the claim to succeed. */ + static or(a: ClaimPredicate, b: ClaimPredicate): ClaimPredicate { + return Claimant.predicateOr(a, b); + } + + /** Build a `ClaimPredicate` from a declarative, JSON-serializable `PredicateConfig`. */ + static build(config: PredicateConfig): ClaimPredicate { + switch (config.type) { + case "unconditional": + return PredicateBuilder.unconditional(); + case "absoluteWindow": + return PredicateBuilder.absoluteWindow(config.start, config.end); + case "relativeWindow": + return PredicateBuilder.relativeWindow(config.secondsFromNow); + case "and": + return PredicateBuilder.and( + PredicateBuilder.build(config.predicates[0]), + PredicateBuilder.build(config.predicates[1]), + ); + case "or": + return PredicateBuilder.or( + PredicateBuilder.build(config.predicates[0]), + PredicateBuilder.build(config.predicates[1]), + ); + } + } +} diff --git a/src/types.ts b/src/types.ts index 1d531ed..b862031 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1351,6 +1351,14 @@ export interface DecodedLedgerEntry { }; } +/** Declarative description of a claimable-balance claim predicate, buildable via `PredicateBuilder.build()`. */ +export type PredicateConfig = + | { type: "unconditional" } + | { type: "absoluteWindow"; start: number; end: number } + | { type: "relativeWindow"; secondsFromNow: number } + | { type: "and"; predicates: [PredicateConfig, PredicateConfig] } + | { type: "or"; predicates: [PredicateConfig, PredicateConfig] }; + /** Union type of all decoded XDR variants. */ export type DecodedXDR = | DecodedTransactionEnvelope From 522884fcf54216b5ffdef4589ee2446761cfea32 Mon Sep 17 00:00:00 2001 From: whisper011 Date: Wed, 29 Jul 2026 14:11:01 +0000 Subject: [PATCH 3/4] Add TTL exchange rate cache with background refresh and wire it into currencyConverter --- src/currencyConverter.ts | 47 +++++++++------- src/index.ts | 3 + src/rateCache.ts | 118 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 21 deletions(-) create mode 100644 src/rateCache.ts diff --git a/src/currencyConverter.ts b/src/currencyConverter.ts index 5c1d906..1f9acfe 100644 --- a/src/currencyConverter.ts +++ b/src/currencyConverter.ts @@ -14,6 +14,7 @@ import { scValToNative, } from "@stellar/stellar-sdk"; import { OraclePriceError, NoReturnValueError } from "./errors.js"; +import { RateCache } from "./rateCache.js"; export interface ConvertedAmount { original: bigint; @@ -23,21 +24,34 @@ export interface ConvertedAmount { toDisplayCurrency: string; } -interface CacheEntry { - rate: bigint; - fetchedAt: number; -} - -const priceCache = new Map(); - const DEFAULT_CACHE_TTL_MS = 10_000; -function cacheKey(fromToken: string, toDisplayCurrency: string, oracleAddress: string): string { - return `${fromToken}:${toDisplayCurrency}:${oracleAddress}`; +/** One `RateCache` per oracle address, so entries survive across calls. */ +const rateCachesByOracle = new Map>(); + +function getRateCache( + oracleAddress: string, + server: SorobanRpc.Server, + networkPassphrase: string, + ttlMs: number, +): RateCache { + let cache = rateCachesByOracle.get(oracleAddress); + if (!cache) { + cache = new RateCache( + (from, to) => fetchOraclePrice(from, to, oracleAddress, server, networkPassphrase), + { ttlMs }, + ); + rateCachesByOracle.set(oracleAddress, cache); + } + return cache; } export function clearPriceCache(): void { - priceCache.clear(); + for (const cache of rateCachesByOracle.values()) { + cache.stop(); + cache.invalidateAll(); + } + rateCachesByOracle.clear(); } async function fetchOraclePrice( @@ -88,17 +102,8 @@ export async function convertAmount( networkPassphrase: string, priceCacheTtlMs: number = DEFAULT_CACHE_TTL_MS, ): Promise { - const key = cacheKey(fromToken, toDisplayCurrency, oracleAddress); - const now = Date.now(); - const cached = priceCache.get(key); - - let rate: bigint; - if (cached && now - cached.fetchedAt < priceCacheTtlMs) { - rate = cached.rate; - } else { - rate = await fetchOraclePrice(fromToken, toDisplayCurrency, oracleAddress, server, networkPassphrase); - priceCache.set(key, { rate, fetchedAt: now }); - } + const cache = getRateCache(oracleAddress, server, networkPassphrase, priceCacheTtlMs); + const rate = await cache.getRate(fromToken, toDisplayCurrency); // rate is assumed to be in fixed-point with 18 decimals (1e18 = 1.0) const converted = (amount * rate) / 1_000_000_000_000_000_000n; diff --git a/src/index.ts b/src/index.ts index 79e927f..7388d2b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -778,6 +778,9 @@ export { PredicateBuilder } from "./predicateBuilder.js"; export type { ClaimPredicate } from "./predicateBuilder.js"; export type { PredicateConfig } from "./types.js"; +export { RateCache } from "./rateCache.js"; +export type { RateCacheEntry, RateOracleFn, RateCacheConfig } from "./rateCache.js"; + export { subscribeToInvoice } from "./sse.js"; export type { SSEInvoiceEventType, diff --git a/src/rateCache.ts b/src/rateCache.ts new file mode 100644 index 0000000..a6e8771 --- /dev/null +++ b/src/rateCache.ts @@ -0,0 +1,118 @@ +/** + * Exchange Rate Cache — a time-to-live cache for exchange rates fetched from + * external oracles, so that repeated conversions within a single rendering + * session don't re-fetch a rate that hasn't gone stale yet. + * + * Supports background refresh: entries are pre-warmed at `ttlMs * 0.8` so a + * cache read never pays cold-fetch latency right after expiry. + */ + +/** A single cached rate entry. */ +export interface RateCacheEntry { + rate: TRate; + fetchedAt: number; +} + +/** A function that fetches the current exchange rate for a `from -> to` asset pair. */ +export type RateOracleFn = (from: string, to: string) => Promise; + +/** Configuration for {@link RateCache}. */ +export interface RateCacheConfig { + /** Time-to-live for a cached rate, in milliseconds. Default: 60_000 (60s). */ + ttlMs?: number; +} + +const DEFAULT_TTL_MS = 60_000; + +function cacheKey(from: string, to: string): string { + return `${from}:${to}`; +} + +/** + * TTL-based cache for exchange rates, keyed by `"${fromAsset}:${toAsset}"`. + * + * ```ts + * const cache = new RateCache((from, to) => oracle.getPrice(from, to)); + * const rate = await cache.getRate("USDC", "USD"); + * cache.start(); // pre-warm entries in the background before they expire + * ``` + */ +export class RateCache { + private readonly store = new Map>(); + private readonly oracle: RateOracleFn; + private readonly ttlMs: number; + private refreshTimer: ReturnType | null = null; + private _running = false; + + constructor(oracle: RateOracleFn, config: RateCacheConfig = {}) { + this.oracle = oracle; + this.ttlMs = config.ttlMs ?? DEFAULT_TTL_MS; + } + + /** Whether background refresh is currently running. */ + get running(): boolean { + return this._running; + } + + /** + * Get the exchange rate for `from -> to`, serving from cache when the + * entry is younger than `ttlMs`. On a cache miss (or expired entry), calls + * the configured oracle function and stores the result with a timestamp. + */ + async getRate(from: string, to: string): Promise { + const key = cacheKey(from, to); + const entry = this.store.get(key); + if (entry && Date.now() - entry.fetchedAt < this.ttlMs) { + return entry.rate; + } + + const rate = await this.oracle(from, to); + this.store.set(key, { rate, fetchedAt: Date.now() }); + return rate; + } + + /** Remove a single `from -> to` pair from the cache. */ + invalidate(from: string, to: string): void { + this.store.delete(cacheKey(from, to)); + } + + /** Clear the entire cache. */ + invalidateAll(): void { + this.store.clear(); + } + + /** + * Start background refresh. Every `ttlMs * 0.8`, every currently cached + * pair is re-fetched from the oracle and its entry updated, so a read + * never has to pay cold-fetch latency right after expiry. + */ + start(): void { + if (this._running) return; + this._running = true; + this.refreshTimer = setInterval(() => { + void this.refreshAll(); + }, this.ttlMs * 0.8); + } + + /** Stop background refresh. Cached entries are preserved. */ + stop(): void { + this._running = false; + if (this.refreshTimer !== null) { + clearInterval(this.refreshTimer); + this.refreshTimer = null; + } + } + + private async refreshAll(): Promise { + for (const key of Array.from(this.store.keys())) { + const [from, to] = key.split(":"); + if (from === undefined || to === undefined) continue; + try { + const rate = await this.oracle(from, to); + this.store.set(key, { rate, fetchedAt: Date.now() }); + } catch { + // Best-effort background refresh; keep the stale entry until the next attempt. + } + } + } +} From 3c2cc05eb822fa64df02ca7eeaed0fb18039eb84 Mon Sep 17 00:00:00 2001 From: whisper011 Date: Wed, 29 Jul 2026 14:20:10 +0000 Subject: [PATCH 4/4] Add account flags state inspector and wire it into preflight checks --- src/accountFlagsInspector.ts | 54 ++++++++++++++++++++++++++++++++++++ src/index.ts | 7 +++-- src/preflightChecker.ts | 32 +++++++++++++++++++++ src/types.ts | 14 ++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 src/accountFlagsInspector.ts diff --git a/src/accountFlagsInspector.ts b/src/accountFlagsInspector.ts new file mode 100644 index 0000000..cb13f2f --- /dev/null +++ b/src/accountFlagsInspector.ts @@ -0,0 +1,54 @@ +/** + * Account Flags State Inspector — decodes the AUTH_* flags on a Stellar + * account's Horizon `AccountRecord` into a typed `AccountFlagSet`, so callers + * don't need to inspect Horizon's raw `flags` object themselves. + */ + +import { Horizon } from "@stellar/stellar-sdk"; +import type { AccountFlagSet } from "./types.js"; + +/** Operations that AUTH_REQUIRED blocks until the holder has been explicitly authorized. */ +const AUTH_REQUIRED_BLOCKS = new Set(["trustline", "create_trustline", "payment"]); + +function buildFlagSet(flags: Horizon.HorizonApi.Flags): AccountFlagSet { + const flagSet: AccountFlagSet = { + authRequired: flags.auth_required, + authRevocable: flags.auth_revocable, + authImmutable: flags.auth_immutable, + authClawbackEnabled: flags.auth_clawback_enabled, + isCompatibleWith(operation: string): boolean { + if (flagSet.authRequired && AUTH_REQUIRED_BLOCKS.has(operation.toLowerCase())) { + return false; + } + return true; + }, + }; + return flagSet; +} + +/** + * Fetch and decode the AUTH_* flags for a Stellar account. + * + * @param accountId - Stellar address to inspect. + * @param horizonUrl - Horizon API base URL used to load the account. + */ +export async function inspectFlags( + accountId: string, + horizonUrl: string, +): Promise { + const server = new Horizon.Server(horizonUrl); + const account = await server.loadAccount(accountId); + return buildFlagSet(account.flags); +} + +/** + * Convenience quick-fail check: `true` when any flag that can restrict a + * counterparty's ability to hold or transact in this account's asset is set + * (`authRequired`, `authRevocable`, or `authClawbackEnabled`). + * + * `authImmutable` is excluded — it only affects whether the issuer's own + * flags can change in the future, not a counterparty's current operations. + */ +export function hasAnyRestrictiveFlag(flags: AccountFlagSet): boolean { + return flags.authRequired || flags.authRevocable || flags.authClawbackEnabled; +} diff --git a/src/index.ts b/src/index.ts index 7388d2b..f5799e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -414,8 +414,11 @@ export type { RpcClient } from "./rpcClient.js"; export { negotiateVersion, SDK_CONTRACT_VERSION } from "./version.js"; export type { VersionInfo } from "./types.js"; -export { checkPayerReadiness, checkInvoiceExpiry, checkSponsorReserve } from "./preflightChecker.js"; -export type { PayerReadinessResult, PayerReadinessReason, InvoiceExpiryResult, InvoiceExpiryReason, SponsorReserveCheck } from "./preflightChecker.js"; +export { checkPayerReadiness, checkInvoiceExpiry, checkSponsorReserve, checkRecipientFlags } from "./preflightChecker.js"; +export type { PayerReadinessResult, PayerReadinessReason, InvoiceExpiryResult, InvoiceExpiryReason, SponsorReserveCheck, RecipientFlagsCheck } from "./preflightChecker.js"; + +export { inspectFlags, hasAnyRestrictiveFlag } from "./accountFlagsInspector.js"; +export type { AccountFlagSet } from "./types.js"; export { getSuggestion } from "./errorSuggestions.js"; diff --git a/src/preflightChecker.ts b/src/preflightChecker.ts index 1a6c9e1..3b71cd6 100644 --- a/src/preflightChecker.ts +++ b/src/preflightChecker.ts @@ -1,4 +1,6 @@ import { rpc as SorobanRpc, Horizon } from "@stellar/stellar-sdk"; +import { inspectFlags } from "./accountFlagsInspector.js"; +import type { AccountFlagSet } from "./types.js"; export type PayerReadinessReason = | "account_not_found" @@ -157,6 +159,36 @@ export async function checkSponsorReserve( }; } +// --------------------------------------------------------------------------- +// Recipient Flags Preflight Check +// --------------------------------------------------------------------------- + +/** Result of checking a recipient's account flags against an intended operation. */ +export interface RecipientFlagsCheck { + /** `false` when the recipient's flags make `operation` impossible without prior authorization. */ + compatible: boolean; + /** The recipient's decoded AUTH_* flags. */ + flags: AccountFlagSet; +} + +/** + * Preflight check: inspect a recipient's AUTH_* flags and flag when they are + * incompatible with the intended operation (e.g. AUTH_REQUIRED blocking a + * trustline creation or payment without prior issuer authorization). + * + * @param accountId - Stellar address of the recipient to inspect. + * @param horizonUrl - Horizon API base URL used to load the account. + * @param operation - The operation the caller intends to perform (e.g. "payment"). + */ +export async function checkRecipientFlags( + accountId: string, + horizonUrl: string, + operation: string, +): Promise { + const flags = await inspectFlags(accountId, horizonUrl); + return { compatible: flags.isCompatibleWith(operation), flags }; +} + // --------------------------------------------------------------------------- // Payer Readiness Check (existing) // --------------------------------------------------------------------------- diff --git a/src/types.ts b/src/types.ts index b862031..81a8047 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1351,6 +1351,20 @@ export interface DecodedLedgerEntry { }; } +/** Decoded AUTH_* flags for a Stellar account, with operation-compatibility checks. */ +export interface AccountFlagSet { + /** AUTH_REQUIRED — the issuer must approve an account before it can hold this asset. */ + authRequired: boolean; + /** AUTH_REVOCABLE — the issuer can revoke an account's authorization to hold this asset. */ + authRevocable: boolean; + /** AUTH_IMMUTABLE — this account's flags can never be changed again. */ + authImmutable: boolean; + /** AUTH_CLAWBACK_ENABLED — the issuer can claw back this asset from holders. */ + authClawbackEnabled: boolean; + /** Returns `false` when this account's flags make `operation` impossible without prior authorization. */ + isCompatibleWith(operation: string): boolean; +} + /** Declarative description of a claimable-balance claim predicate, buildable via `PredicateBuilder.build()`. */ export type PredicateConfig = | { type: "unconditional" }