From c5d3bd5cbaceea8070323a5898e3da0e814a6ff1 Mon Sep 17 00:00:00 2001 From: Atisan12 Date: Thu, 25 Jun 2026 17:37:38 +0100 Subject: [PATCH 1/3] 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/3] 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/3] 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(); + }); +});