diff --git a/src/client.ts b/src/client.ts index 4095d3b..9b59017 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,17 @@ 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; + /** + * Optional sponsor account address for sponsored-reserve onboarding flows. + * Required when calling buildSponsoredOnboarding from src/sponsorship.ts. + */ + sponsorAccount?: string; } /** Network configuration. */ @@ -186,6 +200,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 +319,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 +2096,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 951c40d..7d842dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -241,3 +241,15 @@ export type { SimulationDiffNotComparable, ResourceDelta, } from "./simulationDiff.js"; + +export { Sep41Adapter, createSep41Adapter } from "./sep41Adapter.js"; +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/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/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/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" + ); + }); +}); 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"); + }); +}); 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(); + }); +});