diff --git a/src/claimableBalanceFallback.ts b/src/claimableBalanceFallback.ts new file mode 100644 index 0000000..c059800 --- /dev/null +++ b/src/claimableBalanceFallback.ts @@ -0,0 +1,235 @@ +/** + * Claimable-balance fallback for failed refund transfers. + * + * When a `refund_invoice` token transfer fails because the recipient account + * does not exist or has no trustline for the asset, funds would otherwise be + * stuck in the contract. This module creates a Stellar claimable balance + * instead, letting the payer claim it once their account is ready. + */ + +import { + Account, + Asset, + Claimant, + Horizon, + Operation, + TransactionBuilder, + BASE_FEE, +} from "@stellar/stellar-sdk"; +import type { StellarSplitClientConfig } from "./client.js"; + +// --------------------------------------------------------------------------- +// Error-pattern detection +// --------------------------------------------------------------------------- + +/** + * Substrings that appear in Soroban simulation / submission errors when a + * token transfer fails due to a missing account or missing trustline on the + * recipient side. Matching is case-insensitive. + */ +const REFUND_TRANSFER_ERROR_PATTERNS = [ + "no account", + "no trust", + "not authorized", + "trustline", + "trust not found", + "account missing", + "accountmissing", + "trustlinemissing", + "trustnotfound", + "invalidaccount", + "op_no_destination", + "op_no_trust", +]; + +/** + * Returns `true` when `error` looks like a token-transfer failure caused by a + * missing account or missing trustline — the two situations where a claimable + * balance fallback is appropriate. + */ +export function isRefundTransferError(error: unknown): boolean { + const msg = error instanceof Error ? error.message : String(error); + const lower = msg.toLowerCase(); + return REFUND_TRANSFER_ERROR_PATTERNS.some((p) => lower.includes(p.toLowerCase())); +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Result returned by {@link createClaimableRefund}. */ +export interface ClaimableRefundResult { + /** Stellar claimable balance ID (e.g. `00000000…`). */ + balanceId: string; + /** Transaction hash of the submission. */ + txHash: string; + /** Always `true` — distinguishes this from a normal direct refund. */ + fallback: true; +} + +/** A single pending claimable balance entry, as returned by {@link getClaimableRefunds}. */ +export interface ClaimableRefundEntry { + /** Stellar claimable balance ID. */ + balanceId: string; + /** Stellar address of the account that can claim this balance. */ + payer: string; + /** Human-readable amount string (e.g. `"12.5000000"`). */ + amount: string; + /** Asset descriptor: `"native"` for XLM, `"CODE:ISSUER"` for issued assets. */ + asset: string; + /** Ledger sequence number of the last modification. */ + lastModifiedLedger: number; +} + +// --------------------------------------------------------------------------- +// createClaimableRefund +// --------------------------------------------------------------------------- + +/** + * Build and submit a `createClaimableBalance` operation so that `payer` can + * claim the refund once their account / trustline is ready. + * + * The claimable balance is unconditional — the payer may claim it at any time. + * + * Requires `config.horizonUrl` to be set. + * + * @param payer - Stellar address that will be the sole claimant. + * @param amount - Refund amount in the asset's base unit (stroops for XLM). + * @param asset - Stellar `Asset` object representing the token. + * @param sourceAddress - Stellar address funding / submitting the transaction. + * This account must hold sufficient `asset` balance. + * @param config - StellarSplit client config. `horizonUrl` must be set. + * + * @throws If `config.horizonUrl` is not configured. + */ +export async function createClaimableRefund( + payer: string, + amount: bigint, + asset: Asset, + sourceAddress: string, + config: StellarSplitClientConfig +): Promise { + if (!config.horizonUrl) { + throw new Error( + "createClaimableRefund requires config.horizonUrl to submit classic Stellar transactions" + ); + } + + const horizonServer = new Horizon.Server(config.horizonUrl); + + // Build classic transaction + const sourceRecord = await horizonServer.loadAccount(sourceAddress); + const sourceAccount = new Account( + sourceRecord.accountId(), + sourceRecord.sequenceNumber() + ); + + // Convert bigint stroops to decimal string Stellar expects + const amountStr = stroopsToDecimal(amount); + + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: config.networkPassphrase, + }) + .addOperation( + Operation.createClaimableBalance({ + asset, + amount: amountStr, + claimants: [new Claimant(payer, Claimant.predicateUnconditional())], + }) + ) + .setTimeout(30) + .build(); + + console.info( + `[StellarSplitClient] claimable-refund fallback: creating claimable balance ` + + `for payer ${payer}, amount ${amountStr} ${asset.isNative() ? "XLM" : asset.getCode()}` + ); + + const submitResponse = await horizonServer.submitTransaction(tx); + const txHash = submitResponse.hash; + + // Retrieve the balance_id from the created operation + const balanceId = await _extractBalanceId(horizonServer, txHash); + + return { balanceId, txHash, fallback: true }; +} + +// --------------------------------------------------------------------------- +// getClaimableRefunds +// --------------------------------------------------------------------------- + +/** + * List all pending claimable balances on the Stellar network that `payer` can + * claim. + * + * Requires `config.horizonUrl` to be set. + * + * @param payer - Stellar address of the claimant to query. + * @param config - StellarSplit client config. `horizonUrl` must be set. + */ +export async function getClaimableRefunds( + payer: string, + config: StellarSplitClientConfig +): Promise { + if (!config.horizonUrl) { + throw new Error( + "getClaimableRefunds requires config.horizonUrl to query the Horizon API" + ); + } + + const horizonServer = new Horizon.Server(config.horizonUrl); + const page = await horizonServer.claimableBalances().claimant(payer).call(); + + return page.records.map((record) => ({ + balanceId: record.id, + payer, + amount: record.amount, + asset: record.asset, + lastModifiedLedger: record.last_modified_ledger, + })); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Convert bigint stroops to a 7-decimal Stellar amount string. + * E.g. 12_500_000n → "1.2500000" + */ +function stroopsToDecimal(stroops: bigint): string { + const absStroops = stroops < 0n ? -stroops : stroops; + const whole = absStroops / 10_000_000n; + const frac = (absStroops % 10_000_000n).toString().padStart(7, "0"); + const sign = stroops < 0n ? "-" : ""; + return `${sign}${whole}.${frac}`; +} + +/** + * Fetch the operations for `txHash` from Horizon and extract the + * `balance_id` from the `createClaimableBalance` operation result. + * + * Falls back to a synthetic ID derived from the txHash if Horizon doesn't + * return the expected operation shape (e.g. in test environments). + */ +async function _extractBalanceId( + server: Horizon.Server, + txHash: string +): Promise { + try { + const ops = await server.operations().forTransaction(txHash).call(); + for (const op of ops.records) { + if ( + op.type === "create_claimable_balance" && + "balance_id" in op + ) { + return (op as unknown as { balance_id: string }).balance_id; + } + } + } catch { + // Best-effort; fall through to synthetic ID + } + // Synthetic balance ID: prefixed with zeros so callers can detect it + return `00000000${txHash}`; +} diff --git a/src/client.ts b/src/client.ts index 4095d3b..d429d3a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -98,6 +98,19 @@ 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"; +import { + createClaimableRefund, + getClaimableRefunds, + isRefundTransferError, +} from "./claimableBalanceFallback.js"; +import type { + ClaimableRefundResult, + ClaimableRefundEntry, +} from "./claimableBalanceFallback.js"; +import { Asset } from "@stellar/stellar-sdk"; /** A plugin that extends StellarSplitClient with new methods at runtime. */ export interface StellarSplitPlugin { @@ -140,6 +153,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 +210,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 +329,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 +2106,168 @@ 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 #196 — Claimable-balance fallback for unconfirmed refunds + // --------------------------------------------------------------------------- + + /** + * Refund an invoice by calling the `refund_invoice` contract method. + * + * If the underlying token transfer fails because the recipient account does + * not exist or has no trustline, and `config.horizonUrl` is configured, the + * method automatically falls back to creating a Stellar claimable balance + * that the payer can claim once their account is ready. + * + * A distinguishable log entry (`[StellarSplitClient] claimable-refund fallback`) + * is emitted so callers can tell a normal refund from a fallback refund apart. + * The returned object includes `fallback: boolean` for programmatic detection. + * + * @param invoiceId - ID of the invoice to refund. + * @param creator - Stellar address of the invoice creator (must sign). + * @param payerAddress - Stellar address of the payer who receives the refund. + * Required for the claimable-balance fallback path. + */ + async refundInvoice( + invoiceId: string, + creator: string, + payerAddress?: string + ): Promise<{ txHash: string; fallback: false } | ClaimableRefundResult> { + const startTime = Date.now(); + + try { + const operation = this.contract.call( + "refund_invoice", + nativeToScVal(BigInt(invoiceId), { type: "u64" }) + ); + const result = await this._submitTx(creator, operation); + + const invoice = await this.getInvoice(invoiceId).catch(() => null); + if (invoice) this._fireOnRefunded(invoice); + + telemetry.recordMethod("refundInvoice", true, Date.now() - startTime); + return { txHash: result.txHash, fallback: false }; + } catch (error) { + // Fallback path: if transfer failed due to missing account/trustline and + // Horizon is configured, create a claimable balance instead. + if (isRefundTransferError(error) && this.config.horizonUrl && payerAddress) { + console.warn( + `[StellarSplitClient] refundInvoice: transfer failed for invoice ${invoiceId} ` + + `(${error instanceof Error ? error.message : String(error)}); ` + + `creating claimable-balance fallback for payer ${payerAddress}` + ); + + try { + const invoice = await this.getInvoice(invoiceId).catch(() => null); + const amount = invoice?.funded ?? 0n; + + const claimableResult = await createClaimableRefund( + payerAddress, + amount, + Asset.native(), + creator, + this.config + ); + + telemetry.recordMethod("refundInvoice", true, Date.now() - startTime); + return claimableResult; + } catch (fallbackError) { + telemetry.recordMethod("refundInvoice", false, Date.now() - startTime); + throw fallbackError; + } + } + + telemetry.recordMethod("refundInvoice", false, Date.now() - startTime); + throw error; + } + } + + /** + * List all pending claimable balances on the Stellar network that `payer` + * can claim (created by the claimable-balance refund fallback). + * + * Requires `config.horizonUrl` to be set. + * + * @param payer - Stellar address of the claimant to query. + */ + async getClaimableRefunds(payer: string): Promise { + return getClaimableRefunds(payer, this.config); + } + // --------------------------------------------------------------------------- // Issue #73 — syncInvoice (cross-network) // --------------------------------------------------------------------------- diff --git a/src/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..17ed4f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -241,3 +241,25 @@ 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"; + +export { + createClaimableRefund, + getClaimableRefunds, + isRefundTransferError, +} from "./claimableBalanceFallback.js"; +export type { + ClaimableRefundResult, + ClaimableRefundEntry, +} from "./claimableBalanceFallback.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/claimableBalanceFallback.test.ts b/test/claimableBalanceFallback.test.ts new file mode 100644 index 0000000..6d79f53 --- /dev/null +++ b/test/claimableBalanceFallback.test.ts @@ -0,0 +1,452 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + createClaimableRefund, + getClaimableRefunds, + isRefundTransferError, + type ClaimableRefundEntry, +} from "../src/claimableBalanceFallback.js"; + +// --------------------------------------------------------------------------- +// Stellar SDK mock (hoisting-safe — no top-level vi.fn() refs in factory) +// --------------------------------------------------------------------------- + +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...(actual as Record), + Account: vi.fn().mockImplementation((_id: string, _seq: string) => ({ + accountId: () => _id, + sequenceNumber: () => _seq, + incrementSequenceNumber: vi.fn(), + })), + TransactionBuilder: vi.fn().mockImplementation(() => ({ + addOperation: vi.fn().mockReturnThis(), + setTimeout: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue({ _builtTx: true }), + })), + BASE_FEE: "100", + Asset: { + native: vi.fn().mockReturnValue({ + isNative: () => true, + getCode: () => "XLM", + }), + }, + Claimant: Object.assign( + vi.fn().mockImplementation((dest: string) => ({ destination: dest })), + { predicateUnconditional: vi.fn().mockReturnValue(null) } + ), + Operation: { + createClaimableBalance: vi.fn().mockReturnValue({ type: "createClaimableBalance" }), + }, + Horizon: { + Server: vi.fn(), + }, + }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const PAYER = "GBPAYER0000000000000000000000000000000000000000000000000000"; +const SOURCE = "GBSOURCE000000000000000000000000000000000000000000000000000"; +const HORIZON_URL = "https://horizon-testnet.stellar.org"; +const PASSPHRASE = "Test SDF Network ; September 2015"; + +const BASE_CONFIG = { + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: PASSPHRASE, + contractId: "CCTEST00000000000000000000000000000000000000000000000000000", + horizonUrl: HORIZON_URL, +}; + +function buildMockHorizonServer({ + submitHash = "abc123txhash", + balanceId = "00000000deadbeef", + claimableRecords = [] as ClaimableRefundEntry[], +} = {}) { + const mockOperationsCall = vi.fn().mockResolvedValue({ + records: [ + { + type: "create_claimable_balance", + balance_id: balanceId, + }, + ], + }); + const mockOperationsForTx = vi.fn().mockReturnValue({ call: mockOperationsCall }); + const mockOperations = vi.fn().mockReturnValue({ forTransaction: mockOperationsForTx }); + + const mockClaimableCall = vi.fn().mockResolvedValue({ + records: claimableRecords.map((r) => ({ + id: r.balanceId, + amount: r.amount, + asset: r.asset, + last_modified_ledger: r.lastModifiedLedger, + })), + }); + const mockClaimableClaimant = vi.fn().mockReturnValue({ call: mockClaimableCall }); + const mockClaimableBalances = vi.fn().mockReturnValue({ claimant: mockClaimableClaimant }); + + return { + loadAccount: vi.fn().mockResolvedValue({ + accountId: () => SOURCE, + sequenceNumber: () => "100", + incrementSequenceNumber: vi.fn(), + }), + submitTransaction: vi.fn().mockResolvedValue({ hash: submitHash }), + operations: mockOperations, + claimableBalances: mockClaimableBalances, + _mockOperationsCall: mockOperationsCall, + _mockClaimableCall: mockClaimableCall, + }; +} + +/** Wire Horizon.Server to return a given mock server instance. */ +async function setHorizonMock(mockServer: ReturnType) { + const { Horizon } = await import("@stellar/stellar-sdk"); + (Horizon.Server as ReturnType).mockImplementation(() => mockServer); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// isRefundTransferError +// --------------------------------------------------------------------------- + +describe("isRefundTransferError", () => { + it.each([ + ["no account for destination", true], + ["op_no_trust error returned", true], + ["trustline not found", true], + ["AccountMissing on ledger", true], + ["TrustNotFound during apply", true], + ["op_no_destination", true], + ["insufficient balance", false], + ["simulation timeout", false], + ["network error", false], + ])("(%s) → %s", (msg, expected) => { + expect(isRefundTransferError(new Error(msg))).toBe(expected); + }); + + it("handles non-Error objects", () => { + expect(isRefundTransferError("op_no_trust encountered")).toBe(true); + expect(isRefundTransferError({ message: "unknown" })).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// createClaimableRefund +// --------------------------------------------------------------------------- + +describe("createClaimableRefund", () => { + it("submits a transaction and returns balanceId + txHash + fallback flag", async () => { + const mockServer = buildMockHorizonServer({ + submitHash: "deadbeeftx", + balanceId: "00000000deadbeef1234", + }); + await setHorizonMock(mockServer); + + const { Asset } = await import("@stellar/stellar-sdk"); + const result = await createClaimableRefund( + PAYER, + 10_000_000n, + Asset.native() as never, + SOURCE, + BASE_CONFIG + ); + + expect(result.fallback).toBe(true); + expect(result.txHash).toBe("deadbeeftx"); + expect(result.balanceId).toBe("00000000deadbeef1234"); + expect(mockServer.submitTransaction).toHaveBeenCalledTimes(1); + }); + + it("calls Operation.createClaimableBalance with correct amount string", async () => { + const mockServer = buildMockHorizonServer(); + await setHorizonMock(mockServer); + + const { Asset, Operation } = await import("@stellar/stellar-sdk"); + // 12_500_000 stroops = 1.2500000 XLM + await createClaimableRefund( + PAYER, + 12_500_000n, + Asset.native() as never, + SOURCE, + BASE_CONFIG + ); + + expect( + (Operation as unknown as Record>) + .createClaimableBalance + ).toHaveBeenCalledWith( + expect.objectContaining({ amount: "1.2500000" }) + ); + }); + + it("falls back to synthetic balance ID when operations endpoint errors", async () => { + const mockServer = buildMockHorizonServer({ submitHash: "txhash999" }); + mockServer._mockOperationsCall.mockRejectedValue(new Error("Horizon timeout")); + await setHorizonMock(mockServer); + + const { Asset } = await import("@stellar/stellar-sdk"); + const result = await createClaimableRefund( + PAYER, + 5_000_000n, + Asset.native() as never, + SOURCE, + BASE_CONFIG + ); + + // Synthetic ID: prefixed zeros + txHash + expect(result.balanceId).toContain("txhash999"); + expect(result.fallback).toBe(true); + }); + + it("throws when horizonUrl is not configured", async () => { + const { Asset } = await import("@stellar/stellar-sdk"); + await expect( + createClaimableRefund(PAYER, 1_000_000n, Asset.native() as never, SOURCE, { + ...BASE_CONFIG, + horizonUrl: undefined, + }) + ).rejects.toThrow("horizonUrl"); + }); +}); + +// --------------------------------------------------------------------------- +// getClaimableRefunds +// --------------------------------------------------------------------------- + +describe("getClaimableRefunds", () => { + it("returns claimable balance entries for the payer", async () => { + const mockEntries: ClaimableRefundEntry[] = [ + { + balanceId: "00000000aabbccdd", + payer: PAYER, + amount: "5.0000000", + asset: "native", + lastModifiedLedger: 100, + }, + { + balanceId: "00000000eeff1122", + payer: PAYER, + amount: "2.5000000", + asset: "USDC:GA5ZSEJY", + lastModifiedLedger: 105, + }, + ]; + + const mockServer = buildMockHorizonServer({ claimableRecords: mockEntries }); + await setHorizonMock(mockServer); + + const results = await getClaimableRefunds(PAYER, BASE_CONFIG); + + expect(results).toHaveLength(2); + expect(results[0]!.balanceId).toBe("00000000aabbccdd"); + expect(results[0]!.amount).toBe("5.0000000"); + expect(results[1]!.asset).toBe("USDC:GA5ZSEJY"); + expect(results.every((r) => r.payer === PAYER)).toBe(true); + }); + + it("returns an empty array when no claimable balances exist", async () => { + const mockServer = buildMockHorizonServer({ claimableRecords: [] }); + await setHorizonMock(mockServer); + + const results = await getClaimableRefunds(PAYER, BASE_CONFIG); + expect(results).toHaveLength(0); + }); + + it("throws when horizonUrl is not configured", async () => { + await expect( + getClaimableRefunds(PAYER, { ...BASE_CONFIG, horizonUrl: undefined }) + ).rejects.toThrow("horizonUrl"); + }); +}); + +// --------------------------------------------------------------------------- +// StellarSplitClient.refundInvoice integration +// --------------------------------------------------------------------------- + +// Re-mock stellar-sdk for the client tests (adds rpc.Server, Contract, etc.) +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...(actual as Record), + Contract: vi.fn().mockImplementation(() => ({ + call: vi.fn().mockReturnValue("mock-operation"), + })), + Account: vi.fn().mockImplementation((_id: string, _seq: string) => ({ + accountId: () => _id, + sequenceNumber: () => _seq, + incrementSequenceNumber: vi.fn(), + })), + TransactionBuilder: vi.fn().mockImplementation(() => ({ + addOperation: vi.fn().mockReturnThis(), + setTimeout: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue({ toXDR: () => "xdr" }), + })), + BASE_FEE: "100", + nativeToScVal: vi.fn().mockReturnValue("mock-scval"), + scValToNative: vi.fn().mockReturnValue({}), + Asset: { + native: vi.fn().mockReturnValue({ isNative: () => true, getCode: () => "XLM" }), + }, + Claimant: Object.assign( + vi.fn().mockImplementation((dest: string) => ({ destination: dest })), + { predicateUnconditional: vi.fn().mockReturnValue(null) } + ), + Operation: { + createClaimableBalance: vi.fn().mockReturnValue({ type: "createClaimableBalance" }), + beginSponsoringFutureReserves: vi.fn().mockReturnValue({}), + endSponsoringFutureReserves: vi.fn().mockReturnValue({}), + }, + Keypair: (actual as Record).Keypair, + xdr: (actual as Record).xdr, + rpc: { + Server: vi.fn(), + Api: { + isSimulationError: vi.fn().mockReturnValue(false), + GetTransactionStatus: { NOT_FOUND: "NOT_FOUND", SUCCESS: "SUCCESS" }, + assembleTransaction: vi.fn(), + }, + assembleTransaction: vi.fn(), + }, + Horizon: { + Server: vi.fn(), + }, + }; +}); + +describe("StellarSplitClient.refundInvoice", () => { + const CREATOR = "GBCREATOR000000000000000000000000000000000000000000000000"; + const clientConfig = { + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: PASSPHRASE, + contractId: "CCTEST00000000000000000000000000000000000000000000000000000", + horizonUrl: HORIZON_URL, + }; + + it("returns fallback:false on a successful normal refund", async () => { + const { rpc } = await import("@stellar/stellar-sdk"); + const { StellarSplitClient } = await import("../src/client.js"); + + (rpc.Server as ReturnType).mockImplementation(() => ({ + getAccount: vi.fn().mockResolvedValue({ + accountId: () => CREATOR, + sequenceNumber: () => "1", + incrementSequenceNumber: vi.fn(), + }), + simulateTransaction: vi.fn().mockResolvedValue({ + result: { retval: { toXDR: () => Buffer.alloc(0) } }, + minResourceFee: "100", + }), + sendTransaction: vi.fn().mockResolvedValue({ status: "PENDING", hash: "normaltxhash" }), + getTransaction: vi.fn().mockResolvedValue({ + status: "SUCCESS", + returnValue: { toXDR: () => Buffer.alloc(0) }, + }), + })); + + const client = new StellarSplitClient(clientConfig); + // Patch _submitTx to succeed directly + (client as unknown as Record)["_submitTx"] = vi + .fn() + .mockResolvedValue({ txHash: "normaltxhash", returnValue: {} }); + // Patch getInvoice to avoid real simulation + (client as unknown as Record)["getInvoice"] = vi + .fn() + .mockResolvedValue({ id: "1", funded: 10_000_000n, status: "Refunded" }); + + const result = await client.refundInvoice("1", CREATOR, PAYER); + expect(result.fallback).toBe(false); + expect((result as { txHash: string }).txHash).toBe("normaltxhash"); + }); + + it("triggers claimable-balance fallback when RPC refund throws a trustline error", async () => { + const { rpc, Horizon } = await import("@stellar/stellar-sdk"); + const { StellarSplitClient } = await import("../src/client.js"); + + (rpc.Server as ReturnType).mockImplementation(() => ({ + getAccount: vi.fn().mockResolvedValue({ accountId: () => CREATOR, sequenceNumber: () => "1", incrementSequenceNumber: vi.fn() }), + simulateTransaction: vi.fn().mockResolvedValue({ result: { retval: {} } }), + sendTransaction: vi.fn(), + getTransaction: vi.fn(), + })); + + const mockHorizon = buildMockHorizonServer({ + submitHash: "fallbacktxhash", + balanceId: "00000000fallbackbalid", + }); + (Horizon.Server as ReturnType).mockImplementation(() => mockHorizon); + + const client = new StellarSplitClient(clientConfig); + + // Patch _submitTx to throw a trustline error + (client as unknown as Record)["_submitTx"] = vi + .fn() + .mockRejectedValue(new Error("op_no_trust: trustline not found for destination")); + + // Patch getInvoice to return a known invoice + (client as unknown as Record)["getInvoice"] = vi + .fn() + .mockResolvedValue({ id: "1", funded: 5_000_000n, status: "Pending" }); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const result = await client.refundInvoice("1", CREATOR, PAYER); + warnSpy.mockRestore(); + + expect(result.fallback).toBe(true); + expect((result as ClaimableRefundEntry & { txHash: string }).txHash).toBe("fallbacktxhash"); + }); + + it("rethrows the error when no horizonUrl is configured and refund fails", async () => { + const { rpc } = await import("@stellar/stellar-sdk"); + const { StellarSplitClient } = await import("../src/client.js"); + + (rpc.Server as ReturnType).mockImplementation(() => ({ + getAccount: vi.fn().mockResolvedValue({ accountId: () => CREATOR, sequenceNumber: () => "1", incrementSequenceNumber: vi.fn() }), + simulateTransaction: vi.fn().mockResolvedValue({ result: { retval: {} } }), + sendTransaction: vi.fn(), + getTransaction: vi.fn(), + })); + + const client = new StellarSplitClient({ + ...clientConfig, + horizonUrl: undefined, + }); + + (client as unknown as Record)["_submitTx"] = vi + .fn() + .mockRejectedValue(new Error("op_no_trust: trustline not found")); + + await expect(client.refundInvoice("1", CREATOR, PAYER)).rejects.toThrow( + "op_no_trust" + ); + }); + + it("getClaimableRefunds returns entries for a payer", async () => { + const { rpc, Horizon } = await import("@stellar/stellar-sdk"); + const { StellarSplitClient } = await import("../src/client.js"); + + (rpc.Server as ReturnType).mockImplementation(() => ({ + getAccount: vi.fn().mockResolvedValue({ accountId: () => CREATOR, sequenceNumber: () => "1", incrementSequenceNumber: vi.fn() }), + simulateTransaction: vi.fn().mockResolvedValue({ result: { retval: {} } }), + })); + + const mockHorizon = buildMockHorizonServer({ + claimableRecords: [ + { balanceId: "ba1", payer: PAYER, amount: "3.0000000", asset: "native", lastModifiedLedger: 50 }, + ], + }); + (Horizon.Server as ReturnType).mockImplementation(() => mockHorizon); + + const client = new StellarSplitClient(clientConfig); + const refunds = await client.getClaimableRefunds(PAYER); + + expect(refunds).toHaveLength(1); + expect(refunds[0]!.balanceId).toBe("ba1"); + expect(refunds[0]!.amount).toBe("3.0000000"); + }); +}); 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(); + }); +});