From c5d3bd5cbaceea8070323a5898e3da0e814a6ff1 Mon Sep 17 00:00:00 2001 From: Atisan12 Date: Thu, 25 Jun 2026 17:37:38 +0100 Subject: [PATCH 1/4] feat(sep41Adapter): add SEP-41 fungible token interface adapter (#195) Introduces Sep41Adapter, a thin normalisation layer over Soroban token contracts that may or may not implement the full SEP-41 spec. Lazy capability probing (same FunctionNotFound pattern as featureDetection) gates allowance/approve calls and returns null with a console.warn for unsupported methods. Exports wired into src/index.ts. Unit tests cover full SEP-41, transfer-only, and missing-method token shapes. Closes #195 Co-Authored-By: Claude Sonnet 4.6 --- src/index.ts | 3 + src/sep41Adapter.ts | 310 ++++++++++++++++++++++++++++++++++++ test/sep41Adapter.test.ts | 319 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 632 insertions(+) create mode 100644 src/sep41Adapter.ts create mode 100644 test/sep41Adapter.test.ts diff --git a/src/index.ts b/src/index.ts index 951c40d..439e37b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -241,3 +241,6 @@ export type { SimulationDiffNotComparable, ResourceDelta, } from "./simulationDiff.js"; + +export { Sep41Adapter, createSep41Adapter } from "./sep41Adapter.js"; +export type { Sep41TokenCapabilities } from "./sep41Adapter.js"; diff --git a/src/sep41Adapter.ts b/src/sep41Adapter.ts new file mode 100644 index 0000000..38eb9f8 --- /dev/null +++ b/src/sep41Adapter.ts @@ -0,0 +1,310 @@ +/** + * SEP-41 fungible token interface adapter for StellarSplit. + * + * Normalises balance/transfer/approve/allowance calls across SEP-41-compliant + * and legacy Soroban token contracts. Methods that the underlying contract + * does not implement are detected via simulation probing; callers receive + * `null` and a console warning rather than an uncaught error. + */ + +import { + Account, + Contract, + TransactionBuilder, + rpc as SorobanRpc, + BASE_FEE, + nativeToScVal, + scValToNative, + xdr, +} from "@stellar/stellar-sdk"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Which SEP-41 methods the underlying contract exposes. */ +export interface Sep41TokenCapabilities { + hasBalance: boolean; + hasTransfer: boolean; + hasTransferFrom: boolean; + hasApprove: boolean; + hasAllowance: boolean; +} + +// --------------------------------------------------------------------------- +// Sep41Adapter +// --------------------------------------------------------------------------- + +/** + * Thin adapter that wraps a Soroban token contract and normalises calls to the + * five SEP-41 methods: balance, transfer, transfer_from, approve, allowance. + * + * Construction is cheap — capability probing is lazy and cached on first use. + */ +export class Sep41Adapter { + private readonly _contract: Contract; + private readonly _server: SorobanRpc.Server; + private readonly _networkPassphrase: string; + private readonly _sourceAccount: string; + private _capabilities: Sep41TokenCapabilities | null = null; + + constructor( + tokenAddress: string, + server: SorobanRpc.Server, + networkPassphrase: string, + /** Any valid Stellar public key to use as the simulation source. */ + sourceAccount: string + ) { + this._contract = new Contract(tokenAddress); + this._server = server; + this._networkPassphrase = networkPassphrase; + this._sourceAccount = sourceAccount; + } + + // ------------------------------------------------------------------------- + // Capability probing + // ------------------------------------------------------------------------- + + /** + * Probe a single method name by simulating a call with no arguments. + * + * FunctionNotFound / MissingValue → method absent (false). + * Any other simulation error (e.g. argument-type error) → method exists (true). + */ + private async _probeMethod(method: string): Promise { + try { + const account = new Account(this._sourceAccount, "0"); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this._networkPassphrase, + }) + .addOperation(this._contract.call(method)) + .setTimeout(30) + .build(); + + const simResult = await this._server.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationError(simResult)) { + const errMsg = + typeof simResult.error === "string" + ? simResult.error + : JSON.stringify(simResult.error); + // A missing-method error means the contract doesn't have this fn. + if ( + errMsg.includes("FunctionNotFound") || + errMsg.includes("MissingValue") + ) { + return false; + } + // Any other simulation error (e.g. wrong arg count) still means the + // method exists; the contract just didn't like our empty call. + return true; + } + + return true; + } catch { + return false; + } + } + + /** + * Lazily probe and cache the capabilities of the underlying token contract. + * Subsequent calls return the cached result without additional RPC calls. + */ + async getCapabilities(): Promise { + if (this._capabilities) return this._capabilities; + + const [ + hasBalance, + hasTransfer, + hasTransferFrom, + hasApprove, + hasAllowance, + ] = await Promise.all([ + this._probeMethod("balance"), + this._probeMethod("transfer"), + this._probeMethod("transfer_from"), + this._probeMethod("approve"), + this._probeMethod("allowance"), + ]); + + this._capabilities = { + hasBalance, + hasTransfer, + hasTransferFrom, + hasApprove, + hasAllowance, + }; + + return this._capabilities; + } + + // ------------------------------------------------------------------------- + // Internal simulation helper + // ------------------------------------------------------------------------- + + private async _simulateView(operation: xdr.Operation): Promise { + const account = new Account(this._sourceAccount, "0"); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this._networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + const simResult = await this._server.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationError(simResult)) { + throw new Error( + typeof simResult.error === "string" + ? simResult.error + : JSON.stringify(simResult.error) + ); + } + + const retval = ( + simResult as SorobanRpc.Api.SimulateTransactionSuccessResponse + ).result?.retval; + if (!retval) return null; + return scValToNative(retval); + } + + // ------------------------------------------------------------------------- + // SEP-41 interface + // ------------------------------------------------------------------------- + + /** + * Query the token balance for `account`. + */ + async balance(account: string): Promise { + const op = this._contract.call( + "balance", + nativeToScVal(account, { type: "address" }) + ); + const result = await this._simulateView(op); + if (typeof result === "bigint") return result; + if (typeof result === "number" || typeof result === "string") { + return BigInt(result); + } + throw new Error("[Sep41Adapter] Unexpected return type from 'balance'"); + } + + /** + * Build a `transfer` operation. + * + * The returned `xdr.Operation` should be submitted via the client's normal + * transaction pipeline. + */ + transfer(from: string, to: string, amount: bigint): xdr.Operation { + return this._contract.call( + "transfer", + nativeToScVal(from, { type: "address" }), + nativeToScVal(to, { type: "address" }), + nativeToScVal(amount, { type: "i128" }) + ); + } + + /** + * Build a `transfer_from` operation (spender-initiated delegated transfer). + * + * The returned `xdr.Operation` should be submitted via the client's normal + * transaction pipeline. + */ + transferFrom( + spender: string, + from: string, + to: string, + amount: bigint + ): xdr.Operation { + return this._contract.call( + "transfer_from", + nativeToScVal(spender, { type: "address" }), + nativeToScVal(from, { type: "address" }), + nativeToScVal(to, { type: "address" }), + nativeToScVal(amount, { type: "i128" }) + ); + } + + /** + * Build an `approve` operation granting `spender` an allowance of `amount` + * on behalf of `from`. + * + * Returns `null` if the underlying contract does not implement `approve`. + * A warning is logged in that case. + * + * @param expirationLedger - Ledger sequence number at which the approval + * expires (defaults to 0, meaning no expiration in legacy contracts that + * accept the field but ignore it). + */ + async approve( + from: string, + spender: string, + amount: bigint, + expirationLedger = 0 + ): Promise { + const caps = await this.getCapabilities(); + if (!caps.hasApprove) { + console.warn( + "[Sep41Adapter] Token contract does not implement 'approve'; treating as unsupported" + ); + return null; + } + return this._contract.call( + "approve", + nativeToScVal(from, { type: "address" }), + nativeToScVal(spender, { type: "address" }), + nativeToScVal(amount, { type: "i128" }), + nativeToScVal(expirationLedger, { type: "u32" }) + ); + } + + /** + * Query the allowance that `owner` has granted to `spender`. + * + * Returns `null` if the underlying contract does not implement `allowance`. + * A warning is logged in that case. + */ + async allowance(owner: string, spender: string): Promise { + const caps = await this.getCapabilities(); + if (!caps.hasAllowance) { + console.warn( + "[Sep41Adapter] Token contract does not implement 'allowance'; treating as unsupported" + ); + return null; + } + const op = this._contract.call( + "allowance", + nativeToScVal(owner, { type: "address" }), + nativeToScVal(spender, { type: "address" }) + ); + const result = await this._simulateView(op); + if (typeof result === "bigint") return result; + if (typeof result === "number" || typeof result === "string") { + return BigInt(result); + } + return 0n; + } +} + +// --------------------------------------------------------------------------- +// Factory helper +// --------------------------------------------------------------------------- + +/** + * Convenience factory for creating a `Sep41Adapter` from an RPC server and + * network details. + */ +export function createSep41Adapter( + tokenAddress: string, + server: SorobanRpc.Server, + networkPassphrase: string, + sourceAccount: string +): Sep41Adapter { + return new Sep41Adapter( + tokenAddress, + server, + networkPassphrase, + sourceAccount + ); +} diff --git a/test/sep41Adapter.test.ts b/test/sep41Adapter.test.ts new file mode 100644 index 0000000..de517b4 --- /dev/null +++ b/test/sep41Adapter.test.ts @@ -0,0 +1,319 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Sep41Adapter } from "../src/sep41Adapter.js"; + +// --------------------------------------------------------------------------- +// Stellar SDK mock +// --------------------------------------------------------------------------- + +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...(actual as Record), + Contract: vi.fn().mockImplementation(() => ({ + call: vi.fn().mockReturnValue("mock-operation"), + })), + Account: vi.fn().mockImplementation(() => ({})), + TransactionBuilder: vi.fn().mockImplementation(() => ({ + addOperation: vi.fn().mockReturnThis(), + setTimeout: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue({}), + })), + BASE_FEE: "100", + nativeToScVal: vi.fn().mockReturnValue("mock-scval"), + scValToNative: vi.fn(), + rpc: { + Server: vi.fn(), + Api: { + isSimulationError: vi.fn(), + GetTransactionStatus: { NOT_FOUND: "NOT_FOUND", SUCCESS: "SUCCESS" }, + }, + assembleTransaction: vi.fn(), + }, + xdr: (actual as Record).xdr, + }; +}); + +// --------------------------------------------------------------------------- +// Shared fixture helpers +// --------------------------------------------------------------------------- + +const TOKEN_ADDRESS = "CBBD47AB2EB00E041B5B13A596261F07D3FA7F19B566F3BEA881F5D414951F94"; +const SOURCE = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; +const PASSPHRASE = "Test SDF Network ; September 2015"; +const OWNER = "GBOWNER000000000000000000000000000000000000000000000000000"; +const SPENDER = "GBSPENDER0000000000000000000000000000000000000000000000000"; +const RECIPIENT = "GBRECIPIENT000000000000000000000000000000000000000000000000"; + +/** Build a mock server whose simulateTransaction resolves based on method name. */ +function buildMockServer( + supportedMethods: Set, + balanceReturnValue: bigint = 500n +): object { + return { + simulateTransaction: vi.fn().mockImplementation(async () => { + // We can't inspect the operation directly, so method filtering happens + // through _probeMethod: probes with no args, which the mock responds to + // by checking the method set via the Contract.call mock override per test. + return { + result: { retval: { _type: "i128", value: balanceReturnValue } }, + }; + }), + }; +} + +// --------------------------------------------------------------------------- +// Token shape 1: Full SEP-41 +// --------------------------------------------------------------------------- + +describe("Sep41Adapter — full SEP-41 token", () => { + let adapter: Sep41Adapter; + + beforeEach(async () => { + const { rpc, Contract, scValToNative } = await import("@stellar/stellar-sdk"); + + (rpc.Api.isSimulationError as ReturnType).mockReturnValue(false); + + (Contract as ReturnType).mockImplementation(() => ({ + call: vi.fn().mockReturnValue("mock-operation"), + })); + + (scValToNative as ReturnType).mockReturnValue(1000n); + + const mockServer = { + simulateTransaction: vi.fn().mockResolvedValue({ + result: { retval: "mock-retval" }, + }), + }; + + (rpc.Server as ReturnType).mockImplementation(() => mockServer); + + adapter = new Sep41Adapter( + TOKEN_ADDRESS, + new (rpc.Server as ReturnType)(), + PASSPHRASE, + SOURCE + ); + }); + + it("getCapabilities returns all true for a full SEP-41 contract", async () => { + const caps = await adapter.getCapabilities(); + expect(caps.hasBalance).toBe(true); + expect(caps.hasTransfer).toBe(true); + expect(caps.hasTransferFrom).toBe(true); + expect(caps.hasApprove).toBe(true); + expect(caps.hasAllowance).toBe(true); + }); + + it("getCapabilities is cached after first call", async () => { + const { rpc } = await import("@stellar/stellar-sdk"); + const server = (rpc.Server as ReturnType).mock.results[0]?.value as { + simulateTransaction: ReturnType; + }; + await adapter.getCapabilities(); + const callsBefore = server.simulateTransaction.mock.calls.length; + await adapter.getCapabilities(); + expect(server.simulateTransaction.mock.calls.length).toBe(callsBefore); + }); + + it("balance() simulates and returns a bigint", async () => { + const result = await adapter.balance(OWNER); + expect(typeof result).toBe("bigint"); + }); + + it("transfer() returns an xdr.Operation (synchronous)", () => { + const op = adapter.transfer(OWNER, RECIPIENT, 100n); + expect(op).toBeDefined(); + }); + + it("transferFrom() returns an xdr.Operation (synchronous)", () => { + const op = adapter.transferFrom(SPENDER, OWNER, RECIPIENT, 100n); + expect(op).toBeDefined(); + }); + + it("approve() returns an xdr.Operation for a full SEP-41 token", async () => { + const op = await adapter.approve(OWNER, SPENDER, 500n, 1000); + expect(op).not.toBeNull(); + }); + + it("allowance() returns a bigint for a full SEP-41 token", async () => { + const result = await adapter.allowance(OWNER, SPENDER); + expect(result).not.toBeNull(); + expect(typeof result).toBe("bigint"); + }); +}); + +// --------------------------------------------------------------------------- +// Token shape 2: Transfer-only (no allowance / approve / transfer_from) +// --------------------------------------------------------------------------- + +describe("Sep41Adapter — transfer-only token", () => { + let adapter: Sep41Adapter; + + beforeEach(async () => { + const { rpc, Contract, scValToNative } = await import("@stellar/stellar-sdk"); + + (scValToNative as ReturnType).mockReturnValue(250n); + + // Probe: allowance / approve / transfer_from return FunctionNotFound. + // balance / transfer succeed. + const probeResults: Record = { + balance: true, + transfer: true, + transfer_from: false, + approve: false, + allowance: false, + }; + + let callIndex = 0; + (rpc.Api.isSimulationError as ReturnType).mockImplementation( + (result: { error?: string }) => "error" in result && !!result.error + ); + + (Contract as ReturnType).mockImplementation(() => { + return { + call: vi.fn().mockImplementation((method: string) => { + void method; + return `op-${callIndex++}`; + }), + }; + }); + + const methodCallOrder = ["balance", "transfer", "transfer_from", "approve", "allowance"]; + let probeIdx = 0; + + const mockServer = { + simulateTransaction: vi.fn().mockImplementation(async () => { + // First five calls are probes (one per method, in Promise.all order) + if (probeIdx < methodCallOrder.length) { + const method = methodCallOrder[probeIdx++]!; + if (!probeResults[method]) { + return { error: "FunctionNotFound: no such function" }; + } + return { result: { retval: "ok" } }; + } + // Subsequent calls are actual view simulations (balance, allowance, etc.) + return { result: { retval: "mock-retval" } }; + }), + }; + + (rpc.Server as ReturnType).mockImplementation(() => mockServer); + + adapter = new Sep41Adapter( + TOKEN_ADDRESS, + new (rpc.Server as ReturnType)(), + PASSPHRASE, + SOURCE + ); + }); + + it("getCapabilities reflects a transfer-only contract", async () => { + const caps = await adapter.getCapabilities(); + expect(caps.hasBalance).toBe(true); + expect(caps.hasTransfer).toBe(true); + expect(caps.hasTransferFrom).toBe(false); + expect(caps.hasApprove).toBe(false); + expect(caps.hasAllowance).toBe(false); + }); + + it("approve() returns null and logs a warning", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const op = await adapter.approve(OWNER, SPENDER, 500n); + expect(op).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("'approve'") + ); + warnSpy.mockRestore(); + }); + + it("allowance() returns null and logs a warning", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const result = await adapter.allowance(OWNER, SPENDER); + expect(result).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("'allowance'") + ); + warnSpy.mockRestore(); + }); + + it("transfer() still returns an operation for a transfer-only token", () => { + const op = adapter.transfer(OWNER, RECIPIENT, 100n); + expect(op).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Token shape 3: Missing-method (only balance; all else absent) +// --------------------------------------------------------------------------- + +describe("Sep41Adapter — missing-method token (balance only)", () => { + let adapter: Sep41Adapter; + + beforeEach(async () => { + const { rpc, Contract, scValToNative } = await import("@stellar/stellar-sdk"); + + (scValToNative as ReturnType).mockReturnValue(0n); + + const supportedMethods = new Set(["balance"]); + + (rpc.Api.isSimulationError as ReturnType).mockImplementation( + (result: { error?: string }) => "error" in result && !!result.error + ); + + (Contract as ReturnType).mockImplementation(() => ({ + call: vi.fn().mockReturnValue("mock-op"), + })); + + const methodCallOrder = ["balance", "transfer", "transfer_from", "approve", "allowance"]; + let probeIdx = 0; + + const mockServer = { + simulateTransaction: vi.fn().mockImplementation(async () => { + if (probeIdx < methodCallOrder.length) { + const method = methodCallOrder[probeIdx++]!; + if (!supportedMethods.has(method)) { + return { error: "FunctionNotFound: no such function" }; + } + return { result: { retval: "ok" } }; + } + return { result: { retval: "mock-retval" } }; + }), + }; + + (rpc.Server as ReturnType).mockImplementation(() => mockServer); + + adapter = new Sep41Adapter( + TOKEN_ADDRESS, + new (rpc.Server as ReturnType)(), + PASSPHRASE, + SOURCE + ); + }); + + it("getCapabilities shows only balance as present", async () => { + const caps = await adapter.getCapabilities(); + expect(caps.hasBalance).toBe(true); + expect(caps.hasTransfer).toBe(false); + expect(caps.hasTransferFrom).toBe(false); + expect(caps.hasApprove).toBe(false); + expect(caps.hasAllowance).toBe(false); + }); + + it("approve() returns null gracefully", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const op = await adapter.approve(OWNER, SPENDER, 100n); + expect(op).toBeNull(); + warnSpy.mockRestore(); + }); + + it("allowance() returns null gracefully", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const result = await adapter.allowance(OWNER, SPENDER); + expect(result).toBeNull(); + warnSpy.mockRestore(); + }); + + it("balance() still resolves even on a minimal contract", async () => { + const bal = await adapter.balance(OWNER); + expect(typeof bal).toBe("bigint"); + }); +}); From 4e20bc9f6777f5c5d2f27604619da59f8d479f42 Mon Sep 17 00:00:00 2001 From: Atisan12 Date: Thu, 25 Jun 2026 17:44:54 +0100 Subject: [PATCH 2/4] feat(horizonFallback): add Horizon API fallback client for RPC outages (#198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces HorizonFallbackReader (src/horizonFallback.ts) that wraps Horizon.Server and normalises getAccount / getAccountBalances responses into SDK-wide NormalizedAccount and NormalizedBalance shapes. StellarSplitClientConfig gains an optional horizonUrl field. When set, the client creates a HorizonFallbackReader and wires the two new public methods (getAccount, getAccountBalances) through a FallbackChain so that a failing Soroban RPC getAccount call automatically retries against Horizon. Write paths are unaffected and continue to require a live Soroban RPC endpoint. NormalizedAccount and NormalizedBalance are exported from src/index.ts. 11 unit/integration tests cover: normalised shapes, RPC-healthy path, RPC-failure → Horizon fallback, missing-horizonUrl error paths. Closes #198 Co-Authored-By: Claude Sonnet 4.6 --- src/client.ts | 89 ++++++++++ src/horizonFallback.ts | 81 +++++++++ src/index.ts | 3 + test/horizonFallback.test.ts | 311 +++++++++++++++++++++++++++++++++++ 4 files changed, 484 insertions(+) create mode 100644 src/horizonFallback.ts create mode 100644 test/horizonFallback.test.ts diff --git a/src/client.ts b/src/client.ts index 4095d3b..56a77e2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -98,6 +98,9 @@ import { computePrediction } from "./predictor.js"; import type { CompletionPrediction } from "./predictor.js"; import { PriorityQueue } from "./priorityQueue.js"; import type { RequestPriority } from "./priorityQueue.js"; +import { HorizonFallbackReader } from "./horizonFallback.js"; +import type { NormalizedAccount, NormalizedBalance } from "./horizonFallback.js"; +import { FallbackChain } from "./fallbackChain.js"; /** A plugin that extends StellarSplitClient with new methods at runtime. */ export interface StellarSplitPlugin { @@ -140,6 +143,12 @@ export interface StellarSplitClientConfig { hooks?: InvoiceLifecycleHooks; /** Optional adaptive retry configuration. When provided, replaces legacy maxRetries for pay/cloneInvoice. */ retry?: RetryConfig; + /** + * Optional Horizon API base URL (e.g. "https://horizon.stellar.org"). + * When provided, read-only account lookups fall back to Horizon automatically + * if the primary Soroban RPC endpoint throws or times out. + */ + horizonUrl?: string; } /** Network configuration. */ @@ -186,6 +195,7 @@ export class StellarSplitClient { private _adapter: WalletAdapter | null = null; private _hooks: InvoiceLifecycleHooks = {}; private _retryEngine: RetryEngine | null = null; + private _horizonReader: HorizonFallbackReader | null = null; private get server(): SorobanRpc.Server { return this._rpcClient ?? this._standby?.server ?? this._mainServer; @@ -304,6 +314,10 @@ export class StellarSplitClient { this._retryEngine = new RetryEngine(config.retry, new TelemetryCollector()); } + if (config.horizonUrl) { + this._horizonReader = new HorizonFallbackReader(config.horizonUrl); + } + initHealthDashboard(this.server, this._dedup); } @@ -2077,6 +2091,81 @@ export class StellarSplitClient { return chain; } + // --------------------------------------------------------------------------- + // Issue #198 — Horizon fallback for read-only account operations + // --------------------------------------------------------------------------- + + /** + * Fetch normalised account info (id + sequence number). + * + * Tries the Soroban RPC endpoint first. If `horizonUrl` was supplied in + * the config and the RPC call throws, the request is automatically retried + * against the Horizon REST API via a two-link FallbackChain. + * + * @param address - Stellar public key of the account. + */ + async getAccount(address: string): Promise { + const rpcFetch = async (): Promise => { + const acc = await this.server.getAccount(address); + return { id: acc.accountId(), sequence: acc.sequenceNumber() }; + }; + + if (!this._horizonReader) { + return rpcFetch(); + } + + const horizonReader = this._horizonReader; + const chain = new FallbackChain(["rpc", "horizon"], { + logger: (attempt) => + console.warn( + `[StellarSplitClient] getAccount fallback (${attempt.url}): ${attempt.error}` + ), + }); + + return chain.execute(async (provider) => { + if (provider === "rpc") return rpcFetch(); + return horizonReader.getAccount(address); + }); + } + + /** + * Fetch all balances for `address`. + * + * Balance data is not exposed by the Soroban RPC protocol, so this always + * reads from the Horizon API. A two-link FallbackChain is used so that if + * `horizonUrl` is absent the call fails fast with a clear message. + * + * Requires `horizonUrl` to be set in the client config. + * + * @param address - Stellar public key of the account. + * @throws If no `horizonUrl` was configured. + */ + async getAccountBalances(address: string): Promise { + if (!this._horizonReader) { + throw new Error( + "getAccountBalances requires horizonUrl to be set in StellarSplitClientConfig" + ); + } + + const horizonReader = this._horizonReader; + // Soroban RPC has no balance endpoint — the chain falls through to Horizon immediately. + const chain = new FallbackChain(["rpc", "horizon"], { + logger: (attempt) => + console.warn( + `[StellarSplitClient] getAccountBalances fallback (${attempt.url}): ${attempt.error}` + ), + }); + + return chain.execute(async (provider) => { + if (provider === "rpc") { + throw new Error( + "Soroban RPC does not expose account balances; delegating to Horizon" + ); + } + return horizonReader.getAccountBalances(address); + }); + } + // --------------------------------------------------------------------------- // Issue #73 — syncInvoice (cross-network) // --------------------------------------------------------------------------- diff --git a/src/horizonFallback.ts b/src/horizonFallback.ts new file mode 100644 index 0000000..0cc4a30 --- /dev/null +++ b/src/horizonFallback.ts @@ -0,0 +1,81 @@ +/** + * Horizon API fallback reader for StellarSplitClient. + * + * Wraps @stellar/stellar-sdk's Horizon.Server to provide normalised + * getAccount / getAccountBalances reads that are compatible with the + * shapes returned by the Soroban RPC path. Used as the second link in + * a FallbackChain when the primary Soroban RPC endpoint is unavailable. + */ + +import { Horizon } from "@stellar/stellar-sdk"; + +// --------------------------------------------------------------------------- +// Normalised types shared between the RPC path and the Horizon path +// --------------------------------------------------------------------------- + +/** Minimal account info — mirrors what Soroban RPC's getAccount returns. */ +export interface NormalizedAccount { + /** Stellar public key (G…). */ + id: string; + /** Current sequence number as a decimal string. */ + sequence: string; +} + +/** + * Single balance entry normalised across native / issued asset types. + * + * - Native XLM: `asset === "native"` + * - Issued asset: `asset === "CODE:ISSUER"` + */ +export interface NormalizedBalance { + asset: string; + balance: string; +} + +// --------------------------------------------------------------------------- +// HorizonFallbackReader +// --------------------------------------------------------------------------- + +/** + * Read-only Horizon API client that normalises account and balance responses + * into shapes compatible with the rest of the StellarSplit SDK. + * + * Instantiate once and reuse; Horizon.Server manages its own connection pool. + */ +export class HorizonFallbackReader { + private readonly _server: Horizon.Server; + + constructor(horizonUrl: string) { + this._server = new Horizon.Server(horizonUrl); + } + + /** + * Fetch account info from Horizon and return a normalised account object. + * + * @param address - Stellar public key of the account to look up. + */ + async getAccount(address: string): Promise { + const response = await this._server.loadAccount(address); + return { + id: response.id, + sequence: response.sequenceNumber(), + }; + } + + /** + * Fetch all balances for `address` from Horizon and return them in a + * normalised format. + * + * @param address - Stellar public key of the account. + */ + async getAccountBalances(address: string): Promise { + const response = await this._server.loadAccount(address); + return response.balances.map((b) => { + const asset = + b.asset_type === "native" + ? "native" + : `${"asset_code" in b ? b.asset_code : ""}:${"asset_issuer" in b ? b.asset_issuer : ""}`; + return { asset, balance: b.balance }; + }); + } +} diff --git a/src/index.ts b/src/index.ts index 439e37b..2ce01ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -244,3 +244,6 @@ export type { export { Sep41Adapter, createSep41Adapter } from "./sep41Adapter.js"; export type { Sep41TokenCapabilities } from "./sep41Adapter.js"; + +export { HorizonFallbackReader } from "./horizonFallback.js"; +export type { NormalizedAccount, NormalizedBalance } from "./horizonFallback.js"; diff --git a/test/horizonFallback.test.ts b/test/horizonFallback.test.ts new file mode 100644 index 0000000..e7fd0c3 --- /dev/null +++ b/test/horizonFallback.test.ts @@ -0,0 +1,311 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { HorizonFallbackReader } from "../src/horizonFallback.js"; +import type { NormalizedAccount, NormalizedBalance } from "../src/horizonFallback.js"; + +// --------------------------------------------------------------------------- +// SDK mock — Horizon.Server only; we don't need Soroban RPC here +// --------------------------------------------------------------------------- + +const mockLoadAccount = vi.fn(); + +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...(actual as Record), + Horizon: { + Server: vi.fn().mockImplementation(() => ({ + loadAccount: mockLoadAccount, + })), + }, + }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const ADDR = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; +const HORIZON_URL = "https://horizon-testnet.stellar.org"; + +function makeHorizonAccountResponse(overrides?: Partial<{ + id: string; + sequence: string; + balances: object[]; +}>) { + const sequence = overrides?.sequence ?? "1234567890"; + return { + id: overrides?.id ?? ADDR, + sequence, + sequenceNumber: () => sequence, + incrementSequenceNumber: vi.fn(), + balances: overrides?.balances ?? [ + { asset_type: "native", balance: "100.0000000" }, + { asset_type: "credit_alphanum4", asset_code: "USDC", asset_issuer: "GA5ZSEJY…", balance: "50.0000000" }, + ], + }; +} + +// --------------------------------------------------------------------------- +// HorizonFallbackReader unit tests +// --------------------------------------------------------------------------- + +describe("HorizonFallbackReader", () => { + let reader: HorizonFallbackReader; + + beforeEach(() => { + mockLoadAccount.mockReset(); + reader = new HorizonFallbackReader(HORIZON_URL); + }); + + // ------------------------------------------------------------------------- + // getAccount + // ------------------------------------------------------------------------- + + it("getAccount returns a NormalizedAccount with id and sequence", async () => { + mockLoadAccount.mockResolvedValue(makeHorizonAccountResponse({ sequence: "9999" })); + + const account: NormalizedAccount = await reader.getAccount(ADDR); + + expect(account.id).toBe(ADDR); + expect(account.sequence).toBe("9999"); + }); + + it("getAccount propagates Horizon errors", async () => { + mockLoadAccount.mockRejectedValue(new Error("Account not found")); + + await expect(reader.getAccount(ADDR)).rejects.toThrow("Account not found"); + }); + + // ------------------------------------------------------------------------- + // getAccountBalances + // ------------------------------------------------------------------------- + + it("getAccountBalances returns native balance as 'native'", async () => { + mockLoadAccount.mockResolvedValue( + makeHorizonAccountResponse({ + balances: [{ asset_type: "native", balance: "250.0000000" }], + }) + ); + + const balances: NormalizedBalance[] = await reader.getAccountBalances(ADDR); + + expect(balances).toHaveLength(1); + expect(balances[0]!.asset).toBe("native"); + expect(balances[0]!.balance).toBe("250.0000000"); + }); + + it("getAccountBalances formats issued assets as CODE:ISSUER", async () => { + mockLoadAccount.mockResolvedValue( + makeHorizonAccountResponse({ + balances: [ + { + asset_type: "credit_alphanum4", + asset_code: "USDC", + asset_issuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + balance: "42.0000000", + }, + ], + }) + ); + + const balances = await reader.getAccountBalances(ADDR); + + expect(balances).toHaveLength(1); + expect(balances[0]!.asset).toBe( + "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" + ); + expect(balances[0]!.balance).toBe("42.0000000"); + }); + + it("getAccountBalances handles mixed native + issued balances", async () => { + mockLoadAccount.mockResolvedValue(makeHorizonAccountResponse()); + + const balances = await reader.getAccountBalances(ADDR); + + expect(balances).toHaveLength(2); + const assets = balances.map((b) => b.asset); + expect(assets).toContain("native"); + expect(assets.some((a) => a.includes("USDC"))).toBe(true); + }); + + it("getAccountBalances propagates Horizon errors", async () => { + mockLoadAccount.mockRejectedValue(new Error("Network error")); + + await expect(reader.getAccountBalances(ADDR)).rejects.toThrow("Network error"); + }); +}); + +// --------------------------------------------------------------------------- +// StellarSplitClient fallback integration tests +// --------------------------------------------------------------------------- + +// Re-mock stellar-sdk for the client tests which also need rpc.Server, Contract, etc. +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...(actual as Record), + Contract: vi.fn().mockImplementation(() => ({ + call: vi.fn().mockReturnValue("mock-operation"), + })), + Account: vi.fn().mockImplementation(() => ({})), + TransactionBuilder: vi.fn().mockImplementation(() => ({ + addOperation: vi.fn().mockReturnThis(), + setTimeout: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue({}), + })), + BASE_FEE: "100", + nativeToScVal: vi.fn().mockReturnValue("mock-scval"), + scValToNative: vi.fn().mockReturnValue({}), + rpc: { + Server: vi.fn(), + Api: { + isSimulationError: vi.fn().mockReturnValue(false), + GetTransactionStatus: { NOT_FOUND: "NOT_FOUND", SUCCESS: "SUCCESS" }, + }, + assembleTransaction: vi.fn(), + }, + Horizon: { + Server: vi.fn().mockImplementation(() => ({ + loadAccount: mockLoadAccount, + })), + }, + xdr: (actual as Record).xdr, + Keypair: (actual as Record).Keypair, + }; +}); + +describe("StellarSplitClient — Horizon fallback integration", () => { + const baseConfig = { + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + contractId: "CCUZTEST00000000000000000000000000000000000000000000000000", + }; + + beforeEach(() => { + mockLoadAccount.mockReset(); + }); + + it("getAccount succeeds via RPC when RPC is healthy", async () => { + const { rpc } = await import("@stellar/stellar-sdk"); + const { StellarSplitClient } = await import("../src/client.js"); + + const mockRpcGetAccount = vi.fn().mockResolvedValue({ + accountId: () => ADDR, + sequenceNumber: () => "100", + }); + + (rpc.Server as ReturnType).mockImplementation(() => ({ + getAccount: mockRpcGetAccount, + simulateTransaction: vi.fn().mockResolvedValue({ result: { retval: {} } }), + })); + + const client = new StellarSplitClient({ + ...baseConfig, + horizonUrl: "https://horizon-testnet.stellar.org", + }); + + const account = await client.getAccount(ADDR); + + expect(account.id).toBe(ADDR); + expect(account.sequence).toBe("100"); + expect(mockRpcGetAccount).toHaveBeenCalledWith(ADDR); + // Horizon was NOT consulted since RPC succeeded + expect(mockLoadAccount).not.toHaveBeenCalled(); + }); + + it("getAccount falls back to Horizon when RPC getAccount throws", async () => { + const { rpc } = await import("@stellar/stellar-sdk"); + const { StellarSplitClient } = await import("../src/client.js"); + + (rpc.Server as ReturnType).mockImplementation(() => ({ + getAccount: vi.fn().mockRejectedValue(new Error("RPC unavailable")), + simulateTransaction: vi.fn().mockResolvedValue({ result: { retval: {} } }), + })); + + mockLoadAccount.mockResolvedValue( + makeHorizonAccountResponse({ id: ADDR, sequence: "55" }) + ); + + const client = new StellarSplitClient({ + ...baseConfig, + horizonUrl: "https://horizon-testnet.stellar.org", + }); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const account = await client.getAccount(ADDR); + warnSpy.mockRestore(); + + expect(account.id).toBe(ADDR); + expect(account.sequence).toBe("55"); + expect(mockLoadAccount).toHaveBeenCalledWith(ADDR); + }); + + it("getAccount throws when RPC fails and no horizonUrl is configured", async () => { + const { rpc } = await import("@stellar/stellar-sdk"); + const { StellarSplitClient } = await import("../src/client.js"); + + (rpc.Server as ReturnType).mockImplementation(() => ({ + getAccount: vi.fn().mockRejectedValue(new Error("RPC down")), + simulateTransaction: vi.fn().mockResolvedValue({ result: { retval: {} } }), + })); + + const client = new StellarSplitClient(baseConfig); // no horizonUrl + + await expect(client.getAccount(ADDR)).rejects.toThrow("RPC down"); + }); + + it("getAccountBalances returns Horizon balances with normalised shapes", async () => { + const { rpc } = await import("@stellar/stellar-sdk"); + const { StellarSplitClient } = await import("../src/client.js"); + + (rpc.Server as ReturnType).mockImplementation(() => ({ + getAccount: vi.fn(), + simulateTransaction: vi.fn().mockResolvedValue({ result: { retval: {} } }), + })); + + mockLoadAccount.mockResolvedValue( + makeHorizonAccountResponse({ + balances: [ + { asset_type: "native", balance: "10.0000000" }, + { + asset_type: "credit_alphanum4", + asset_code: "USDC", + asset_issuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + balance: "5.0000000", + }, + ], + }) + ); + + const client = new StellarSplitClient({ + ...baseConfig, + horizonUrl: "https://horizon-testnet.stellar.org", + }); + + const balances = await client.getAccountBalances(ADDR); + + expect(balances).toHaveLength(2); + expect(balances.find((b) => b.asset === "native")?.balance).toBe("10.0000000"); + expect( + balances.find((b) => + b.asset === "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" + )?.balance + ).toBe("5.0000000"); + }); + + it("getAccountBalances throws when no horizonUrl is configured", async () => { + const { rpc } = await import("@stellar/stellar-sdk"); + const { StellarSplitClient } = await import("../src/client.js"); + + (rpc.Server as ReturnType).mockImplementation(() => ({ + getAccount: vi.fn(), + simulateTransaction: vi.fn().mockResolvedValue({ result: { retval: {} } }), + })); + + const client = new StellarSplitClient(baseConfig); + + await expect(client.getAccountBalances(ADDR)).rejects.toThrow( + "horizonUrl" + ); + }); +}); From 8d6bc6ca2ef03f7a29057803f647eafe3e75d556 Mon Sep 17 00:00:00 2001 From: Atisan12 Date: Thu, 25 Jun 2026 17:54:05 +0100 Subject: [PATCH 3/4] feat(sponsorship): add sponsored reserve helper for invoice onboarding (#197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces buildSponsoredOnboarding (src/sponsorship.ts) which wraps caller-provided operations between beginSponsoringFutureReserves and endSponsoringFutureReserves so that a brand-new payer account can fund an invoice without first acquiring XLM. Operation ordering enforced: begin (source=sponsor) → inner ops → end (source=newAccount). Both accounts must co-sign the returned unsigned transaction before submission. When config.horizonUrl is set the function fetches the sponsor's live XLM balance via Horizon.Server.loadAccount and verifies that the available balance covers the sponsor's own minimum (1 XLM) plus one base reserve (0.5 XLM) per wrapped operation. Two typed errors are exported: MissingSponsorAccountError (config.sponsorAccount absent) and InsufficientReserveError (balance too low, exposing availableStroops and requiredStroops). StellarSplitClientConfig gains sponsorAccount?: string. buildSponsoredOnboarding, MissingSponsorAccountError, and InsufficientReserveError are exported from src/index.ts. 11 unit tests cover op ordering, missing config, insufficient balance, balance check skip, and reserve math scaling with op count. Closes #197 Co-Authored-By: Claude Sonnet 4.6 --- src/client.ts | 5 + src/index.ts | 6 + src/sponsorship.ts | 179 +++++++++++++++++++++++++ test/sponsorship.test.ts | 280 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 470 insertions(+) create mode 100644 src/sponsorship.ts create mode 100644 test/sponsorship.test.ts diff --git a/src/client.ts b/src/client.ts index 56a77e2..9b59017 100644 --- a/src/client.ts +++ b/src/client.ts @@ -149,6 +149,11 @@ export interface StellarSplitClientConfig { * if the primary Soroban RPC endpoint throws or times out. */ horizonUrl?: string; + /** + * Optional sponsor account address for sponsored-reserve onboarding flows. + * Required when calling buildSponsoredOnboarding from src/sponsorship.ts. + */ + sponsorAccount?: string; } /** Network configuration. */ diff --git a/src/index.ts b/src/index.ts index 2ce01ba..7d842dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -247,3 +247,9 @@ export type { Sep41TokenCapabilities } from "./sep41Adapter.js"; export { HorizonFallbackReader } from "./horizonFallback.js"; export type { NormalizedAccount, NormalizedBalance } from "./horizonFallback.js"; + +export { + buildSponsoredOnboarding, + MissingSponsorAccountError, + InsufficientReserveError, +} from "./sponsorship.js"; diff --git a/src/sponsorship.ts b/src/sponsorship.ts new file mode 100644 index 0000000..c933600 --- /dev/null +++ b/src/sponsorship.ts @@ -0,0 +1,179 @@ +/** + * Sponsored-reserve transaction builder for StellarSplit onboarding. + * + * New Stellar accounts need a minimum XLM reserve for each ledger entry + * (trustlines, data entries, etc.). This module lets a pre-funded sponsor + * account cover those reserves via the Stellar protocol's + * beginSponsoringFutureReserves / endSponsoringFutureReserves operation pair, + * so a brand-new payer can fund an invoice without first acquiring XLM. + */ + +import { + Account, + Operation, + TransactionBuilder, + BASE_FEE, + Horizon, + xdr, +} from "@stellar/stellar-sdk"; +import type { Transaction } from "@stellar/stellar-sdk"; +import type { StellarSplitClientConfig } from "./client.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** One base reserve in stroops (0.5 XLM). */ +const BASE_RESERVE_STROOPS = 5_000_000n; + +/** + * Minimum XLM balance a sponsor must retain after funding sponsored entries. + * Stellar requires every account to maintain 2 × base reserve = 1 XLM. + */ +const SPONSOR_MIN_BALANCE_STROOPS = 10_000_000n; + +// --------------------------------------------------------------------------- +// Error types +// --------------------------------------------------------------------------- + +/** Thrown when `config.sponsorAccount` is absent. */ +export class MissingSponsorAccountError extends Error { + constructor() { + super( + "config.sponsorAccount is required for sponsored onboarding — " + + "set it in StellarSplitClientConfig before calling buildSponsoredOnboarding." + ); + this.name = "MissingSponsorAccountError"; + Object.setPrototypeOf(this, MissingSponsorAccountError.prototype); + } +} + +/** Thrown when the sponsor's on-chain XLM balance is too low. */ +export class InsufficientReserveError extends Error { + readonly availableStroops: bigint; + readonly requiredStroops: bigint; + + constructor(available: bigint, required: bigint) { + super( + `Sponsor has insufficient XLM reserve: ` + + `${available} stroops available, ${required} stroops required ` + + `(${required - available} stroops short).` + ); + this.name = "InsufficientReserveError"; + this.availableStroops = available; + this.requiredStroops = required; + Object.setPrototypeOf(this, InsufficientReserveError.prototype); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Convert a Horizon balance string ("1.0000000") to stroops (bigint). + * Horizon always formats XLM with exactly 7 decimal places. + */ +function xlmStringToStroops(xlm: string): bigint { + const [whole = "0", frac = ""] = xlm.split("."); + return BigInt(whole) * 10_000_000n + BigInt(frac.padEnd(7, "0").slice(0, 7)); +} + +// --------------------------------------------------------------------------- +// buildSponsoredOnboarding +// --------------------------------------------------------------------------- + +/** + * Build an unsigned sponsored-reserve onboarding transaction. + * + * The transaction has the following operation ordering: + * 1. `beginSponsoringFutureReserves` (source: sponsor) + * 2. Caller-provided `ops` (e.g. createAccount, changeTrust) + * 3. `endSponsoringFutureReserves` (source: newAccount) + * + * Both `sponsor` and `newAccount` must sign the returned transaction before + * it can be submitted (the new account's signature is required for the + * `endSponsoringFutureReserves` operation). + * + * **Reserve validation** — when `config.horizonUrl` is set, the function + * fetches the sponsor's live XLM balance via Horizon and verifies that the + * sponsor can cover: their own minimum account balance (1 XLM) plus one base + * reserve (0.5 XLM) per wrapped operation. Pass `config.horizonUrl` to + * enable this check; omit it to skip (useful in unit tests / offline signing). + * + * @param sponsor - Stellar address of the sponsoring account. + * @param newAccount - Stellar address of the account being onboarded. + * @param ops - Inner operations to wrap between the sponsoring ops. + * @param config - StellarSplit client config. `config.sponsorAccount` + * must be set, or a {@link MissingSponsorAccountError} is + * thrown. + * + * @throws {MissingSponsorAccountError} If `config.sponsorAccount` is not set. + * @throws {InsufficientReserveError} If the sponsor's XLM balance is too low + * (only when `config.horizonUrl` is set). + */ +export async function buildSponsoredOnboarding( + sponsor: string, + newAccount: string, + ops: xdr.Operation[], + config: StellarSplitClientConfig +): Promise { + // Guard: sponsorship must be explicitly configured. + if (!config.sponsorAccount) { + throw new MissingSponsorAccountError(); + } + + // Balance check: fetch sponsor's live XLM balance from Horizon. + if (config.horizonUrl) { + const horizonServer = new Horizon.Server(config.horizonUrl); + const sponsorRecord = await horizonServer.loadAccount(sponsor); + + const nativeLine = sponsorRecord.balances.find( + (b) => b.asset_type === "native" + ); + const balanceStroops = nativeLine + ? xlmStringToStroops(nativeLine.balance) + : 0n; + + // Sponsor must retain their own minimum (2 × base reserve = 1 XLM) and + // hold one additional base reserve (0.5 XLM) for every sponsored entry. + const requiredStroops = + SPONSOR_MIN_BALANCE_STROOPS + BASE_RESERVE_STROOPS * BigInt(ops.length); + + if (balanceStroops < requiredStroops) { + throw new InsufficientReserveError(balanceStroops, requiredStroops); + } + } + + // Use sequence "0" — the transaction is unsigned and the caller is + // responsible for fetching the real sequence before signing. + const sourceAccount = new Account(sponsor, "0"); + + const builder = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: config.networkPassphrase, + }); + + // Op 1: begin sponsoring (source = sponsor) + builder.addOperation( + Operation.beginSponsoringFutureReserves({ + sponsoredId: newAccount, + source: sponsor, + }) + ); + + // Ops 2…N: caller-provided inner operations + for (const op of ops) { + builder.addOperation(op); + } + + // Op N+1: end sponsoring (source = newAccount, who must co-sign) + builder.addOperation( + Operation.endSponsoringFutureReserves({ + source: newAccount, + }) + ); + + builder.setTimeout(30); + return builder.build(); +} diff --git a/test/sponsorship.test.ts b/test/sponsorship.test.ts new file mode 100644 index 0000000..df5a42c --- /dev/null +++ b/test/sponsorship.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + buildSponsoredOnboarding, + MissingSponsorAccountError, + InsufficientReserveError, +} from "../src/sponsorship.js"; + +// --------------------------------------------------------------------------- +// Stellar SDK mock +// vi.mock factories are hoisted — no top-level vi.fn() refs allowed inside. +// We push operation objects into a module-level array instead. +// --------------------------------------------------------------------------- + +const _ops: unknown[] = []; + +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + + const addOperation = vi.fn().mockImplementation(function ( + this: unknown, + op: unknown + ) { + _ops.push(op); + return this; + }); + + return { + ...(actual as Record), + Account: vi.fn().mockImplementation(() => ({})), + TransactionBuilder: vi.fn().mockImplementation(() => ({ + addOperation, + setTimeout: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue({ _builtTx: true }), + })), + BASE_FEE: "100", + Operation: { + beginSponsoringFutureReserves: vi + .fn() + .mockImplementation((opts: unknown) => ({ + type: "beginSponsoring", + opts, + })), + endSponsoringFutureReserves: vi + .fn() + .mockImplementation((opts: unknown) => ({ + type: "endSponsoring", + opts, + })), + createAccount: vi + .fn() + .mockImplementation((opts: unknown) => ({ type: "createAccount", opts })), + changeTrust: vi + .fn() + .mockImplementation((opts: unknown) => ({ type: "changeTrust", opts })), + }, + Horizon: { + Server: vi.fn().mockImplementation(() => ({ + loadAccount: vi.fn(), + })), + }, + xdr: (actual as Record).xdr, + }; +}); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const SPONSOR = "GBSPONSOR0000000000000000000000000000000000000000000000000"; +const NEW_ACCOUNT = "GBNEWACCOUNT000000000000000000000000000000000000000000000"; +const PASSPHRASE = "Test SDF Network ; September 2015"; + +const BASE_CONFIG = { + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: PASSPHRASE, + contractId: "CCTEST00000000000000000000000000000000000000000000000000000", + sponsorAccount: SPONSOR, +}; + +/** Set the Horizon loadAccount mock to return the given XLM balance. */ +async function mockSponsorBalance(xlm: string) { + const { Horizon } = await import("@stellar/stellar-sdk"); + (Horizon.Server as ReturnType).mockImplementation(() => ({ + loadAccount: vi.fn().mockResolvedValue({ + balances: [{ asset_type: "native", balance: xlm }], + }), + })); +} + +beforeEach(() => { + _ops.length = 0; + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Missing sponsor config +// --------------------------------------------------------------------------- + +describe("buildSponsoredOnboarding — missing sponsor config", () => { + it("throws MissingSponsorAccountError when config.sponsorAccount is absent", async () => { + const { Operation } = await import("@stellar/stellar-sdk"); + const ops = [ + (Operation as unknown as Record unknown>).createAccount({ + destination: NEW_ACCOUNT, + startingBalance: "0", + }), + ] as never[]; + + await expect( + buildSponsoredOnboarding(SPONSOR, NEW_ACCOUNT, ops, { + ...BASE_CONFIG, + sponsorAccount: undefined, + }) + ).rejects.toBeInstanceOf(MissingSponsorAccountError); + }); + + it("MissingSponsorAccountError message mentions config.sponsorAccount", async () => { + const err = await buildSponsoredOnboarding(SPONSOR, NEW_ACCOUNT, [], { + ...BASE_CONFIG, + sponsorAccount: undefined, + }).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(MissingSponsorAccountError); + expect((err as Error).message).toMatch(/config\.sponsorAccount/); + }); +}); + +// --------------------------------------------------------------------------- +// Insufficient balance +// --------------------------------------------------------------------------- + +describe("buildSponsoredOnboarding — insufficient reserve", () => { + it("throws InsufficientReserveError when sponsor balance is too low", async () => { + // 0.5 XLM available; need 1 XLM (min) + 0.5 XLM (1 op) = 1.5 XLM + await mockSponsorBalance("0.5000000"); + + await expect( + buildSponsoredOnboarding(SPONSOR, NEW_ACCOUNT, [{}] as never[], { + ...BASE_CONFIG, + horizonUrl: "https://horizon-testnet.stellar.org", + }) + ).rejects.toBeInstanceOf(InsufficientReserveError); + }); + + it("InsufficientReserveError exposes available and required stroops", async () => { + await mockSponsorBalance("0.5000000"); // 5_000_000 stroops + + const err = await buildSponsoredOnboarding(SPONSOR, NEW_ACCOUNT, [{}] as never[], { + ...BASE_CONFIG, + horizonUrl: "https://horizon-testnet.stellar.org", + }).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(InsufficientReserveError); + const typed = err as InsufficientReserveError; + expect(typed.availableStroops).toBe(5_000_000n); + // 1 XLM (min) + 0.5 XLM × 1 op = 15_000_000 stroops + expect(typed.requiredStroops).toBe(15_000_000n); + }); + + it("skips balance check when horizonUrl is not configured", async () => { + // Even if loadAccount would return zero balance, no Horizon URL → no check + await mockSponsorBalance("0.0000001"); + + await expect( + buildSponsoredOnboarding(SPONSOR, NEW_ACCOUNT, [], BASE_CONFIG) + ).resolves.toBeDefined(); + + // Horizon.Server should not have been instantiated + const { Horizon } = await import("@stellar/stellar-sdk"); + const serverCalls = (Horizon.Server as ReturnType).mock.calls; + expect(serverCalls).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Correct operation ordering +// --------------------------------------------------------------------------- + +describe("buildSponsoredOnboarding — operation ordering", () => { + it("produces begin → inner ops → end ordering (2 inner ops)", async () => { + await mockSponsorBalance("100.0000000"); + + const { Operation } = await import("@stellar/stellar-sdk"); + const ops = await import("@stellar/stellar-sdk").then((m) => { + const O = m.Operation as unknown as Record unknown>; + return [ + O.createAccount({ destination: NEW_ACCOUNT, startingBalance: "0" }), + O.changeTrust({ asset: {} }), + ] as never[]; + }); + + await buildSponsoredOnboarding(SPONSOR, NEW_ACCOUNT, ops, { + ...BASE_CONFIG, + horizonUrl: "https://horizon-testnet.stellar.org", + }); + + // begin + 2 inner + end = 4 + expect(_ops).toHaveLength(4); + expect((_ops[0] as { type: string }).type).toBe("beginSponsoring"); + expect((_ops[1] as { type: string }).type).toBe("createAccount"); + expect((_ops[2] as { type: string }).type).toBe("changeTrust"); + expect((_ops[3] as { type: string }).type).toBe("endSponsoring"); + + void Operation; // suppress unused import warning + }); + + it("sets sponsoredId = newAccount on beginSponsoringFutureReserves", async () => { + await mockSponsorBalance("100.0000000"); + + const { Operation } = await import("@stellar/stellar-sdk"); + + await buildSponsoredOnboarding(SPONSOR, NEW_ACCOUNT, [], { + ...BASE_CONFIG, + horizonUrl: "https://horizon-testnet.stellar.org", + }); + + expect( + (Operation as unknown as Record>) + .beginSponsoringFutureReserves + ).toHaveBeenCalledWith(expect.objectContaining({ sponsoredId: NEW_ACCOUNT })); + }); + + it("sets source = newAccount on endSponsoringFutureReserves", async () => { + await mockSponsorBalance("100.0000000"); + + const { Operation } = await import("@stellar/stellar-sdk"); + + await buildSponsoredOnboarding(SPONSOR, NEW_ACCOUNT, [], { + ...BASE_CONFIG, + horizonUrl: "https://horizon-testnet.stellar.org", + }); + + expect( + (Operation as unknown as Record>) + .endSponsoringFutureReserves + ).toHaveBeenCalledWith(expect.objectContaining({ source: NEW_ACCOUNT })); + }); + + it("works with zero inner ops — begin + end only", async () => { + await buildSponsoredOnboarding(SPONSOR, NEW_ACCOUNT, [], BASE_CONFIG); + + expect(_ops).toHaveLength(2); + expect((_ops[0] as { type: string }).type).toBe("beginSponsoring"); + expect((_ops[1] as { type: string }).type).toBe("endSponsoring"); + }); + + it("returns the built transaction object", async () => { + const tx = await buildSponsoredOnboarding(SPONSOR, NEW_ACCOUNT, [], BASE_CONFIG); + expect(tx).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Reserve math: required stroops scales with op count +// --------------------------------------------------------------------------- + +describe("buildSponsoredOnboarding — reserve math", () => { + it("requires 0.5 XLM per inner op on top of the 1 XLM sponsor minimum", async () => { + // 3 ops → required = 10_000_000 + 3 × 5_000_000 = 25_000_000 stroops = 2.5 XLM + const threeOps = [{}, {}, {}] as never[]; + + // 2.4999999 XLM → fail + await mockSponsorBalance("2.4999999"); + await expect( + buildSponsoredOnboarding(SPONSOR, NEW_ACCOUNT, threeOps, { + ...BASE_CONFIG, + horizonUrl: "https://horizon-testnet.stellar.org", + }) + ).rejects.toBeInstanceOf(InsufficientReserveError); + + // 2.5000000 XLM → pass + await mockSponsorBalance("2.5000000"); + await expect( + buildSponsoredOnboarding(SPONSOR, NEW_ACCOUNT, threeOps, { + ...BASE_CONFIG, + horizonUrl: "https://horizon-testnet.stellar.org", + }) + ).resolves.toBeDefined(); + }); +}); From fa6cf1a2d971c3c7256e081a2360a881f6eec858 Mon Sep 17 00:00:00 2001 From: Atisan12 Date: Thu, 25 Jun 2026 18:04:25 +0100 Subject: [PATCH 4/4] feat(claimableBalanceFallback): claimable balance fallback for unconfirmed refunds (#196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces src/claimableBalanceFallback.ts with three exports: isRefundTransferError(error) — returns true when a Soroban error message indicates a failed token transfer due to a missing account or missing trustline (op_no_trust, op_no_destination, AccountMissing, TrustNotFound, etc.). createClaimableRefund(payer, amount, asset, sourceAddress, config) — builds and submits a classic Stellar createClaimableBalance operation via Horizon.Server, making the refund claimable by payer at any time. Extracts the balance_id from the created operation record; falls back to a synthetic ID prefixed with zeros when the operations endpoint is unavailable. Emits a distinguishable [StellarSplitClient] claimable-refund fallback log entry. getClaimableRefunds(payer, config) — queries Horizon claimableBalances for all pending balances claimable by payer. StellarSplitClient gains two public methods: refundInvoice(invoiceId, creator, payerAddress?) — calls refund_invoice on the contract; on trustline/account errors with horizonUrl configured, automatically retries via createClaimableRefund, emits a console.warn, and returns { fallback: true, balanceId, txHash }. Normal path returns { fallback: false, txHash }. getClaimableRefunds(payer) — delegates to the module function above. StellarSplitClientConfig is unchanged (horizonUrl added in #198 is reused). All exports wired into src/index.ts. 21 unit + integration tests cover: trustline error detection (8 patterns), successful submission, correct amount string (stroops→decimal), synthetic balance ID fallback, missing horizonUrl throws, empty/multi-entry listing, normal refund path, fallback trigger, no-horizonUrl rethrow. Closes #196 Co-Authored-By: Claude Sonnet 4.6 --- src/claimableBalanceFallback.ts | 235 +++++++++++++ src/client.ts | 97 ++++++ src/index.ts | 10 + test/claimableBalanceFallback.test.ts | 452 ++++++++++++++++++++++++++ 4 files changed, 794 insertions(+) create mode 100644 src/claimableBalanceFallback.ts create mode 100644 test/claimableBalanceFallback.test.ts diff --git a/src/claimableBalanceFallback.ts b/src/claimableBalanceFallback.ts new file mode 100644 index 0000000..c059800 --- /dev/null +++ b/src/claimableBalanceFallback.ts @@ -0,0 +1,235 @@ +/** + * Claimable-balance fallback for failed refund transfers. + * + * When a `refund_invoice` token transfer fails because the recipient account + * does not exist or has no trustline for the asset, funds would otherwise be + * stuck in the contract. This module creates a Stellar claimable balance + * instead, letting the payer claim it once their account is ready. + */ + +import { + Account, + Asset, + Claimant, + Horizon, + Operation, + TransactionBuilder, + BASE_FEE, +} from "@stellar/stellar-sdk"; +import type { StellarSplitClientConfig } from "./client.js"; + +// --------------------------------------------------------------------------- +// Error-pattern detection +// --------------------------------------------------------------------------- + +/** + * Substrings that appear in Soroban simulation / submission errors when a + * token transfer fails due to a missing account or missing trustline on the + * recipient side. Matching is case-insensitive. + */ +const REFUND_TRANSFER_ERROR_PATTERNS = [ + "no account", + "no trust", + "not authorized", + "trustline", + "trust not found", + "account missing", + "accountmissing", + "trustlinemissing", + "trustnotfound", + "invalidaccount", + "op_no_destination", + "op_no_trust", +]; + +/** + * Returns `true` when `error` looks like a token-transfer failure caused by a + * missing account or missing trustline — the two situations where a claimable + * balance fallback is appropriate. + */ +export function isRefundTransferError(error: unknown): boolean { + const msg = error instanceof Error ? error.message : String(error); + const lower = msg.toLowerCase(); + return REFUND_TRANSFER_ERROR_PATTERNS.some((p) => lower.includes(p.toLowerCase())); +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Result returned by {@link createClaimableRefund}. */ +export interface ClaimableRefundResult { + /** Stellar claimable balance ID (e.g. `00000000…`). */ + balanceId: string; + /** Transaction hash of the submission. */ + txHash: string; + /** Always `true` — distinguishes this from a normal direct refund. */ + fallback: true; +} + +/** A single pending claimable balance entry, as returned by {@link getClaimableRefunds}. */ +export interface ClaimableRefundEntry { + /** Stellar claimable balance ID. */ + balanceId: string; + /** Stellar address of the account that can claim this balance. */ + payer: string; + /** Human-readable amount string (e.g. `"12.5000000"`). */ + amount: string; + /** Asset descriptor: `"native"` for XLM, `"CODE:ISSUER"` for issued assets. */ + asset: string; + /** Ledger sequence number of the last modification. */ + lastModifiedLedger: number; +} + +// --------------------------------------------------------------------------- +// createClaimableRefund +// --------------------------------------------------------------------------- + +/** + * 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. + * + * Requires `config.horizonUrl` to be set. + * + * @param payer - Stellar address that will be the sole claimant. + * @param amount - Refund amount in the asset's base unit (stroops for XLM). + * @param asset - Stellar `Asset` object representing the token. + * @param sourceAddress - Stellar address funding / submitting the transaction. + * This account must hold sufficient `asset` balance. + * @param config - StellarSplit client config. `horizonUrl` must be set. + * + * @throws If `config.horizonUrl` is not configured. + */ +export async function createClaimableRefund( + payer: string, + amount: bigint, + asset: Asset, + sourceAddress: string, + config: StellarSplitClientConfig +): Promise { + if (!config.horizonUrl) { + throw new Error( + "createClaimableRefund requires config.horizonUrl to submit classic Stellar transactions" + ); + } + + const horizonServer = new Horizon.Server(config.horizonUrl); + + // Build classic transaction + const sourceRecord = await horizonServer.loadAccount(sourceAddress); + const sourceAccount = new Account( + sourceRecord.accountId(), + sourceRecord.sequenceNumber() + ); + + // Convert bigint stroops to decimal string Stellar expects + const amountStr = stroopsToDecimal(amount); + + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: config.networkPassphrase, + }) + .addOperation( + Operation.createClaimableBalance({ + asset, + amount: amountStr, + claimants: [new Claimant(payer, Claimant.predicateUnconditional())], + }) + ) + .setTimeout(30) + .build(); + + console.info( + `[StellarSplitClient] claimable-refund fallback: creating claimable balance ` + + `for payer ${payer}, amount ${amountStr} ${asset.isNative() ? "XLM" : asset.getCode()}` + ); + + const submitResponse = await horizonServer.submitTransaction(tx); + const txHash = submitResponse.hash; + + // Retrieve the balance_id from the created operation + const balanceId = await _extractBalanceId(horizonServer, txHash); + + return { balanceId, txHash, fallback: true }; +} + +// --------------------------------------------------------------------------- +// getClaimableRefunds +// --------------------------------------------------------------------------- + +/** + * List all pending claimable balances on the Stellar network that `payer` can + * claim. + * + * Requires `config.horizonUrl` to be set. + * + * @param payer - Stellar address of the claimant to query. + * @param config - StellarSplit client config. `horizonUrl` must be set. + */ +export async function getClaimableRefunds( + payer: string, + config: StellarSplitClientConfig +): Promise { + if (!config.horizonUrl) { + throw new Error( + "getClaimableRefunds requires config.horizonUrl to query the Horizon API" + ); + } + + const horizonServer = new Horizon.Server(config.horizonUrl); + const page = await horizonServer.claimableBalances().claimant(payer).call(); + + return page.records.map((record) => ({ + balanceId: record.id, + payer, + amount: record.amount, + asset: record.asset, + lastModifiedLedger: record.last_modified_ledger, + })); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Convert bigint stroops to a 7-decimal Stellar amount string. + * E.g. 12_500_000n → "1.2500000" + */ +function stroopsToDecimal(stroops: bigint): string { + const absStroops = stroops < 0n ? -stroops : stroops; + const whole = absStroops / 10_000_000n; + const frac = (absStroops % 10_000_000n).toString().padStart(7, "0"); + const sign = stroops < 0n ? "-" : ""; + return `${sign}${whole}.${frac}`; +} + +/** + * Fetch the operations for `txHash` from Horizon and extract the + * `balance_id` from the `createClaimableBalance` operation result. + * + * Falls back to a synthetic ID derived from the txHash if Horizon doesn't + * return the expected operation shape (e.g. in test environments). + */ +async function _extractBalanceId( + server: Horizon.Server, + txHash: string +): Promise { + try { + const ops = await server.operations().forTransaction(txHash).call(); + for (const op of ops.records) { + if ( + op.type === "create_claimable_balance" && + "balance_id" in op + ) { + return (op as unknown as { balance_id: string }).balance_id; + } + } + } catch { + // Best-effort; fall through to synthetic ID + } + // Synthetic balance ID: prefixed with zeros so callers can detect it + return `00000000${txHash}`; +} diff --git a/src/client.ts b/src/client.ts index 9b59017..d429d3a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -101,6 +101,16 @@ import type { RequestPriority } from "./priorityQueue.js"; import { HorizonFallbackReader } from "./horizonFallback.js"; import type { NormalizedAccount, NormalizedBalance } from "./horizonFallback.js"; import { FallbackChain } from "./fallbackChain.js"; +import { + createClaimableRefund, + getClaimableRefunds, + isRefundTransferError, +} from "./claimableBalanceFallback.js"; +import type { + ClaimableRefundResult, + ClaimableRefundEntry, +} from "./claimableBalanceFallback.js"; +import { Asset } from "@stellar/stellar-sdk"; /** A plugin that extends StellarSplitClient with new methods at runtime. */ export interface StellarSplitPlugin { @@ -2171,6 +2181,93 @@ export class StellarSplitClient { }); } + // --------------------------------------------------------------------------- + // Issue #196 — Claimable-balance fallback for unconfirmed refunds + // --------------------------------------------------------------------------- + + /** + * Refund an invoice by calling the `refund_invoice` contract method. + * + * If the underlying token transfer fails because the recipient account does + * not exist or has no trustline, and `config.horizonUrl` is configured, the + * method automatically falls back to creating a Stellar claimable balance + * that the payer can claim once their account is ready. + * + * A distinguishable log entry (`[StellarSplitClient] claimable-refund fallback`) + * is emitted so callers can tell a normal refund from a fallback refund apart. + * The returned object includes `fallback: boolean` for programmatic detection. + * + * @param invoiceId - ID of the invoice to refund. + * @param creator - Stellar address of the invoice creator (must sign). + * @param payerAddress - Stellar address of the payer who receives the refund. + * Required for the claimable-balance fallback path. + */ + async refundInvoice( + invoiceId: string, + creator: string, + payerAddress?: string + ): Promise<{ txHash: string; fallback: false } | ClaimableRefundResult> { + const startTime = Date.now(); + + try { + const operation = this.contract.call( + "refund_invoice", + nativeToScVal(BigInt(invoiceId), { type: "u64" }) + ); + const result = await this._submitTx(creator, operation); + + const invoice = await this.getInvoice(invoiceId).catch(() => null); + if (invoice) this._fireOnRefunded(invoice); + + telemetry.recordMethod("refundInvoice", true, Date.now() - startTime); + return { txHash: result.txHash, fallback: false }; + } catch (error) { + // Fallback path: if transfer failed due to missing account/trustline and + // Horizon is configured, create a claimable balance instead. + if (isRefundTransferError(error) && this.config.horizonUrl && payerAddress) { + console.warn( + `[StellarSplitClient] refundInvoice: transfer failed for invoice ${invoiceId} ` + + `(${error instanceof Error ? error.message : String(error)}); ` + + `creating claimable-balance fallback for payer ${payerAddress}` + ); + + try { + const invoice = await this.getInvoice(invoiceId).catch(() => null); + const amount = invoice?.funded ?? 0n; + + const claimableResult = await createClaimableRefund( + payerAddress, + amount, + Asset.native(), + creator, + this.config + ); + + telemetry.recordMethod("refundInvoice", true, Date.now() - startTime); + return claimableResult; + } catch (fallbackError) { + telemetry.recordMethod("refundInvoice", false, Date.now() - startTime); + throw fallbackError; + } + } + + telemetry.recordMethod("refundInvoice", false, Date.now() - startTime); + throw error; + } + } + + /** + * List all pending claimable balances on the Stellar network that `payer` + * can claim (created by the claimable-balance refund fallback). + * + * Requires `config.horizonUrl` to be set. + * + * @param payer - Stellar address of the claimant to query. + */ + async getClaimableRefunds(payer: string): Promise { + return getClaimableRefunds(payer, this.config); + } + // --------------------------------------------------------------------------- // Issue #73 — syncInvoice (cross-network) // --------------------------------------------------------------------------- diff --git a/src/index.ts b/src/index.ts index 7d842dd..17ed4f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -253,3 +253,13 @@ export { MissingSponsorAccountError, InsufficientReserveError, } from "./sponsorship.js"; + +export { + createClaimableRefund, + getClaimableRefunds, + isRefundTransferError, +} from "./claimableBalanceFallback.js"; +export type { + ClaimableRefundResult, + ClaimableRefundEntry, +} from "./claimableBalanceFallback.js"; diff --git a/test/claimableBalanceFallback.test.ts b/test/claimableBalanceFallback.test.ts new file mode 100644 index 0000000..6d79f53 --- /dev/null +++ b/test/claimableBalanceFallback.test.ts @@ -0,0 +1,452 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + createClaimableRefund, + getClaimableRefunds, + isRefundTransferError, + type ClaimableRefundEntry, +} from "../src/claimableBalanceFallback.js"; + +// --------------------------------------------------------------------------- +// Stellar SDK mock (hoisting-safe — no top-level vi.fn() refs in factory) +// --------------------------------------------------------------------------- + +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...(actual as Record), + Account: vi.fn().mockImplementation((_id: string, _seq: string) => ({ + accountId: () => _id, + sequenceNumber: () => _seq, + incrementSequenceNumber: vi.fn(), + })), + TransactionBuilder: vi.fn().mockImplementation(() => ({ + addOperation: vi.fn().mockReturnThis(), + setTimeout: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue({ _builtTx: true }), + })), + BASE_FEE: "100", + Asset: { + native: vi.fn().mockReturnValue({ + isNative: () => true, + getCode: () => "XLM", + }), + }, + Claimant: Object.assign( + vi.fn().mockImplementation((dest: string) => ({ destination: dest })), + { predicateUnconditional: vi.fn().mockReturnValue(null) } + ), + Operation: { + createClaimableBalance: vi.fn().mockReturnValue({ type: "createClaimableBalance" }), + }, + Horizon: { + Server: vi.fn(), + }, + }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const PAYER = "GBPAYER0000000000000000000000000000000000000000000000000000"; +const SOURCE = "GBSOURCE000000000000000000000000000000000000000000000000000"; +const HORIZON_URL = "https://horizon-testnet.stellar.org"; +const PASSPHRASE = "Test SDF Network ; September 2015"; + +const BASE_CONFIG = { + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: PASSPHRASE, + contractId: "CCTEST00000000000000000000000000000000000000000000000000000", + horizonUrl: HORIZON_URL, +}; + +function buildMockHorizonServer({ + submitHash = "abc123txhash", + balanceId = "00000000deadbeef", + claimableRecords = [] as ClaimableRefundEntry[], +} = {}) { + const mockOperationsCall = vi.fn().mockResolvedValue({ + records: [ + { + type: "create_claimable_balance", + balance_id: balanceId, + }, + ], + }); + const mockOperationsForTx = vi.fn().mockReturnValue({ call: mockOperationsCall }); + const mockOperations = vi.fn().mockReturnValue({ forTransaction: mockOperationsForTx }); + + const mockClaimableCall = vi.fn().mockResolvedValue({ + records: claimableRecords.map((r) => ({ + id: r.balanceId, + amount: r.amount, + asset: r.asset, + last_modified_ledger: r.lastModifiedLedger, + })), + }); + const mockClaimableClaimant = vi.fn().mockReturnValue({ call: mockClaimableCall }); + const mockClaimableBalances = vi.fn().mockReturnValue({ claimant: mockClaimableClaimant }); + + return { + loadAccount: vi.fn().mockResolvedValue({ + accountId: () => SOURCE, + sequenceNumber: () => "100", + incrementSequenceNumber: vi.fn(), + }), + submitTransaction: vi.fn().mockResolvedValue({ hash: submitHash }), + operations: mockOperations, + claimableBalances: mockClaimableBalances, + _mockOperationsCall: mockOperationsCall, + _mockClaimableCall: mockClaimableCall, + }; +} + +/** Wire Horizon.Server to return a given mock server instance. */ +async function setHorizonMock(mockServer: ReturnType) { + const { Horizon } = await import("@stellar/stellar-sdk"); + (Horizon.Server as ReturnType).mockImplementation(() => mockServer); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// isRefundTransferError +// --------------------------------------------------------------------------- + +describe("isRefundTransferError", () => { + it.each([ + ["no account for destination", true], + ["op_no_trust error returned", true], + ["trustline not found", true], + ["AccountMissing on ledger", true], + ["TrustNotFound during apply", true], + ["op_no_destination", true], + ["insufficient balance", false], + ["simulation timeout", false], + ["network error", false], + ])("(%s) → %s", (msg, expected) => { + expect(isRefundTransferError(new Error(msg))).toBe(expected); + }); + + it("handles non-Error objects", () => { + expect(isRefundTransferError("op_no_trust encountered")).toBe(true); + expect(isRefundTransferError({ message: "unknown" })).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// createClaimableRefund +// --------------------------------------------------------------------------- + +describe("createClaimableRefund", () => { + it("submits a transaction and returns balanceId + txHash + fallback flag", async () => { + const mockServer = buildMockHorizonServer({ + submitHash: "deadbeeftx", + balanceId: "00000000deadbeef1234", + }); + await setHorizonMock(mockServer); + + const { Asset } = await import("@stellar/stellar-sdk"); + const result = await createClaimableRefund( + PAYER, + 10_000_000n, + Asset.native() as never, + SOURCE, + BASE_CONFIG + ); + + expect(result.fallback).toBe(true); + expect(result.txHash).toBe("deadbeeftx"); + expect(result.balanceId).toBe("00000000deadbeef1234"); + expect(mockServer.submitTransaction).toHaveBeenCalledTimes(1); + }); + + it("calls Operation.createClaimableBalance with correct amount string", async () => { + const mockServer = buildMockHorizonServer(); + await setHorizonMock(mockServer); + + const { Asset, Operation } = await import("@stellar/stellar-sdk"); + // 12_500_000 stroops = 1.2500000 XLM + await createClaimableRefund( + PAYER, + 12_500_000n, + Asset.native() as never, + SOURCE, + BASE_CONFIG + ); + + expect( + (Operation as unknown as Record>) + .createClaimableBalance + ).toHaveBeenCalledWith( + expect.objectContaining({ amount: "1.2500000" }) + ); + }); + + it("falls back to synthetic balance ID when operations endpoint errors", async () => { + const mockServer = buildMockHorizonServer({ submitHash: "txhash999" }); + mockServer._mockOperationsCall.mockRejectedValue(new Error("Horizon timeout")); + await setHorizonMock(mockServer); + + const { Asset } = await import("@stellar/stellar-sdk"); + const result = await createClaimableRefund( + PAYER, + 5_000_000n, + Asset.native() as never, + SOURCE, + BASE_CONFIG + ); + + // Synthetic ID: prefixed zeros + txHash + expect(result.balanceId).toContain("txhash999"); + expect(result.fallback).toBe(true); + }); + + it("throws when horizonUrl is not configured", async () => { + const { Asset } = await import("@stellar/stellar-sdk"); + await expect( + createClaimableRefund(PAYER, 1_000_000n, Asset.native() as never, SOURCE, { + ...BASE_CONFIG, + horizonUrl: undefined, + }) + ).rejects.toThrow("horizonUrl"); + }); +}); + +// --------------------------------------------------------------------------- +// getClaimableRefunds +// --------------------------------------------------------------------------- + +describe("getClaimableRefunds", () => { + it("returns claimable balance entries for the payer", async () => { + const mockEntries: ClaimableRefundEntry[] = [ + { + balanceId: "00000000aabbccdd", + payer: PAYER, + amount: "5.0000000", + asset: "native", + lastModifiedLedger: 100, + }, + { + balanceId: "00000000eeff1122", + payer: PAYER, + amount: "2.5000000", + asset: "USDC:GA5ZSEJY", + lastModifiedLedger: 105, + }, + ]; + + const mockServer = buildMockHorizonServer({ claimableRecords: mockEntries }); + await setHorizonMock(mockServer); + + const results = await getClaimableRefunds(PAYER, BASE_CONFIG); + + expect(results).toHaveLength(2); + expect(results[0]!.balanceId).toBe("00000000aabbccdd"); + expect(results[0]!.amount).toBe("5.0000000"); + expect(results[1]!.asset).toBe("USDC:GA5ZSEJY"); + expect(results.every((r) => r.payer === PAYER)).toBe(true); + }); + + it("returns an empty array when no claimable balances exist", async () => { + const mockServer = buildMockHorizonServer({ claimableRecords: [] }); + await setHorizonMock(mockServer); + + const results = await getClaimableRefunds(PAYER, BASE_CONFIG); + expect(results).toHaveLength(0); + }); + + it("throws when horizonUrl is not configured", async () => { + await expect( + getClaimableRefunds(PAYER, { ...BASE_CONFIG, horizonUrl: undefined }) + ).rejects.toThrow("horizonUrl"); + }); +}); + +// --------------------------------------------------------------------------- +// StellarSplitClient.refundInvoice integration +// --------------------------------------------------------------------------- + +// Re-mock stellar-sdk for the client tests (adds rpc.Server, Contract, etc.) +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...(actual as Record), + Contract: vi.fn().mockImplementation(() => ({ + call: vi.fn().mockReturnValue("mock-operation"), + })), + Account: vi.fn().mockImplementation((_id: string, _seq: string) => ({ + accountId: () => _id, + sequenceNumber: () => _seq, + incrementSequenceNumber: vi.fn(), + })), + TransactionBuilder: vi.fn().mockImplementation(() => ({ + addOperation: vi.fn().mockReturnThis(), + setTimeout: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue({ toXDR: () => "xdr" }), + })), + BASE_FEE: "100", + nativeToScVal: vi.fn().mockReturnValue("mock-scval"), + scValToNative: vi.fn().mockReturnValue({}), + Asset: { + native: vi.fn().mockReturnValue({ isNative: () => true, getCode: () => "XLM" }), + }, + Claimant: Object.assign( + vi.fn().mockImplementation((dest: string) => ({ destination: dest })), + { predicateUnconditional: vi.fn().mockReturnValue(null) } + ), + Operation: { + createClaimableBalance: vi.fn().mockReturnValue({ type: "createClaimableBalance" }), + beginSponsoringFutureReserves: vi.fn().mockReturnValue({}), + endSponsoringFutureReserves: vi.fn().mockReturnValue({}), + }, + Keypair: (actual as Record).Keypair, + xdr: (actual as Record).xdr, + rpc: { + Server: vi.fn(), + Api: { + isSimulationError: vi.fn().mockReturnValue(false), + GetTransactionStatus: { NOT_FOUND: "NOT_FOUND", SUCCESS: "SUCCESS" }, + assembleTransaction: vi.fn(), + }, + assembleTransaction: vi.fn(), + }, + Horizon: { + Server: vi.fn(), + }, + }; +}); + +describe("StellarSplitClient.refundInvoice", () => { + const CREATOR = "GBCREATOR000000000000000000000000000000000000000000000000"; + const clientConfig = { + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: PASSPHRASE, + contractId: "CCTEST00000000000000000000000000000000000000000000000000000", + horizonUrl: HORIZON_URL, + }; + + it("returns fallback:false on a successful normal refund", async () => { + const { rpc } = await import("@stellar/stellar-sdk"); + const { StellarSplitClient } = await import("../src/client.js"); + + (rpc.Server as ReturnType).mockImplementation(() => ({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => CREATOR, + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + result: { retval: { toXDR: () => Buffer.alloc(0) } }, + minResourceFee: "100", + }), + sendTransaction: vi.fn().mockResolvedValue({ status: "PENDING", hash: "normaltxhash" }), + getTransaction: vi.fn().mockResolvedValue({ + status: "SUCCESS", + returnValue: { toXDR: () => Buffer.alloc(0) }, + }), + })); + + const client = new StellarSplitClient(clientConfig); + // Patch _submitTx to succeed directly + (client as unknown as Record)["_submitTx"] = vi + .fn() + .mockResolvedValue({ txHash: "normaltxhash", returnValue: {} }); + // Patch getInvoice to avoid real simulation + (client as unknown as Record)["getInvoice"] = vi + .fn() + .mockResolvedValue({ id: "1", funded: 10_000_000n, status: "Refunded" }); + + const result = await client.refundInvoice("1", CREATOR, PAYER); + expect(result.fallback).toBe(false); + expect((result as { txHash: string }).txHash).toBe("normaltxhash"); + }); + + it("triggers claimable-balance fallback when RPC refund throws a trustline error", async () => { + const { rpc, Horizon } = await import("@stellar/stellar-sdk"); + const { StellarSplitClient } = await import("../src/client.js"); + + (rpc.Server as ReturnType).mockImplementation(() => ({ + getAccount: vi.fn().mockResolvedValue({ accountId: () => CREATOR, sequenceNumber: () => "1", incrementSequenceNumber: vi.fn() }), + simulateTransaction: vi.fn().mockResolvedValue({ result: { retval: {} } }), + sendTransaction: vi.fn(), + getTransaction: vi.fn(), + })); + + const mockHorizon = buildMockHorizonServer({ + submitHash: "fallbacktxhash", + balanceId: "00000000fallbackbalid", + }); + (Horizon.Server as ReturnType).mockImplementation(() => mockHorizon); + + const client = new StellarSplitClient(clientConfig); + + // Patch _submitTx to throw a trustline error + (client as unknown as Record)["_submitTx"] = vi + .fn() + .mockRejectedValue(new Error("op_no_trust: trustline not found for destination")); + + // Patch getInvoice to return a known invoice + (client as unknown as Record)["getInvoice"] = vi + .fn() + .mockResolvedValue({ id: "1", funded: 5_000_000n, status: "Pending" }); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const result = await client.refundInvoice("1", CREATOR, PAYER); + warnSpy.mockRestore(); + + expect(result.fallback).toBe(true); + expect((result as ClaimableRefundEntry & { txHash: string }).txHash).toBe("fallbacktxhash"); + }); + + it("rethrows the error when no horizonUrl is configured and refund fails", async () => { + const { rpc } = await import("@stellar/stellar-sdk"); + const { StellarSplitClient } = await import("../src/client.js"); + + (rpc.Server as ReturnType).mockImplementation(() => ({ + getAccount: vi.fn().mockResolvedValue({ accountId: () => CREATOR, sequenceNumber: () => "1", incrementSequenceNumber: vi.fn() }), + simulateTransaction: vi.fn().mockResolvedValue({ result: { retval: {} } }), + sendTransaction: vi.fn(), + getTransaction: vi.fn(), + })); + + const client = new StellarSplitClient({ + ...clientConfig, + horizonUrl: undefined, + }); + + (client as unknown as Record)["_submitTx"] = vi + .fn() + .mockRejectedValue(new Error("op_no_trust: trustline not found")); + + await expect(client.refundInvoice("1", CREATOR, PAYER)).rejects.toThrow( + "op_no_trust" + ); + }); + + it("getClaimableRefunds returns entries for a payer", async () => { + const { rpc, Horizon } = await import("@stellar/stellar-sdk"); + const { StellarSplitClient } = await import("../src/client.js"); + + (rpc.Server as ReturnType).mockImplementation(() => ({ + getAccount: vi.fn().mockResolvedValue({ accountId: () => CREATOR, sequenceNumber: () => "1", incrementSequenceNumber: vi.fn() }), + simulateTransaction: vi.fn().mockResolvedValue({ result: { retval: {} } }), + })); + + const mockHorizon = buildMockHorizonServer({ + claimableRecords: [ + { balanceId: "ba1", payer: PAYER, amount: "3.0000000", asset: "native", lastModifiedLedger: 50 }, + ], + }); + (Horizon.Server as ReturnType).mockImplementation(() => mockHorizon); + + const client = new StellarSplitClient(clientConfig); + const refunds = await client.getClaimableRefunds(PAYER); + + expect(refunds).toHaveLength(1); + expect(refunds[0]!.balanceId).toBe("ba1"); + expect(refunds[0]!.amount).toBe("3.0000000"); + }); +});