diff --git a/src/client.ts b/src/client.ts index 4095d3b..56a77e2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -98,6 +98,9 @@ import { computePrediction } from "./predictor.js"; import type { CompletionPrediction } from "./predictor.js"; import { PriorityQueue } from "./priorityQueue.js"; import type { RequestPriority } from "./priorityQueue.js"; +import { HorizonFallbackReader } from "./horizonFallback.js"; +import type { NormalizedAccount, NormalizedBalance } from "./horizonFallback.js"; +import { FallbackChain } from "./fallbackChain.js"; /** A plugin that extends StellarSplitClient with new methods at runtime. */ export interface StellarSplitPlugin { @@ -140,6 +143,12 @@ export interface StellarSplitClientConfig { hooks?: InvoiceLifecycleHooks; /** Optional adaptive retry configuration. When provided, replaces legacy maxRetries for pay/cloneInvoice. */ retry?: RetryConfig; + /** + * Optional Horizon API base URL (e.g. "https://horizon.stellar.org"). + * When provided, read-only account lookups fall back to Horizon automatically + * if the primary Soroban RPC endpoint throws or times out. + */ + horizonUrl?: string; } /** Network configuration. */ @@ -186,6 +195,7 @@ export class StellarSplitClient { private _adapter: WalletAdapter | null = null; private _hooks: InvoiceLifecycleHooks = {}; private _retryEngine: RetryEngine | null = null; + private _horizonReader: HorizonFallbackReader | null = null; private get server(): SorobanRpc.Server { return this._rpcClient ?? this._standby?.server ?? this._mainServer; @@ -304,6 +314,10 @@ export class StellarSplitClient { this._retryEngine = new RetryEngine(config.retry, new TelemetryCollector()); } + if (config.horizonUrl) { + this._horizonReader = new HorizonFallbackReader(config.horizonUrl); + } + initHealthDashboard(this.server, this._dedup); } @@ -2077,6 +2091,81 @@ export class StellarSplitClient { return chain; } + // --------------------------------------------------------------------------- + // Issue #198 — Horizon fallback for read-only account operations + // --------------------------------------------------------------------------- + + /** + * Fetch normalised account info (id + sequence number). + * + * Tries the Soroban RPC endpoint first. If `horizonUrl` was supplied in + * the config and the RPC call throws, the request is automatically retried + * against the Horizon REST API via a two-link FallbackChain. + * + * @param address - Stellar public key of the account. + */ + async getAccount(address: string): Promise { + const rpcFetch = async (): Promise => { + const acc = await this.server.getAccount(address); + return { id: acc.accountId(), sequence: acc.sequenceNumber() }; + }; + + if (!this._horizonReader) { + return rpcFetch(); + } + + const horizonReader = this._horizonReader; + const chain = new FallbackChain(["rpc", "horizon"], { + logger: (attempt) => + console.warn( + `[StellarSplitClient] getAccount fallback (${attempt.url}): ${attempt.error}` + ), + }); + + return chain.execute(async (provider) => { + if (provider === "rpc") return rpcFetch(); + return horizonReader.getAccount(address); + }); + } + + /** + * Fetch all balances for `address`. + * + * Balance data is not exposed by the Soroban RPC protocol, so this always + * reads from the Horizon API. A two-link FallbackChain is used so that if + * `horizonUrl` is absent the call fails fast with a clear message. + * + * Requires `horizonUrl` to be set in the client config. + * + * @param address - Stellar public key of the account. + * @throws If no `horizonUrl` was configured. + */ + async getAccountBalances(address: string): Promise { + if (!this._horizonReader) { + throw new Error( + "getAccountBalances requires horizonUrl to be set in StellarSplitClientConfig" + ); + } + + const horizonReader = this._horizonReader; + // Soroban RPC has no balance endpoint — the chain falls through to Horizon immediately. + const chain = new FallbackChain(["rpc", "horizon"], { + logger: (attempt) => + console.warn( + `[StellarSplitClient] getAccountBalances fallback (${attempt.url}): ${attempt.error}` + ), + }); + + return chain.execute(async (provider) => { + if (provider === "rpc") { + throw new Error( + "Soroban RPC does not expose account balances; delegating to Horizon" + ); + } + return horizonReader.getAccountBalances(address); + }); + } + // --------------------------------------------------------------------------- // Issue #73 — syncInvoice (cross-network) // --------------------------------------------------------------------------- diff --git a/src/horizonFallback.ts b/src/horizonFallback.ts new file mode 100644 index 0000000..0cc4a30 --- /dev/null +++ b/src/horizonFallback.ts @@ -0,0 +1,81 @@ +/** + * Horizon API fallback reader for StellarSplitClient. + * + * Wraps @stellar/stellar-sdk's Horizon.Server to provide normalised + * getAccount / getAccountBalances reads that are compatible with the + * shapes returned by the Soroban RPC path. Used as the second link in + * a FallbackChain when the primary Soroban RPC endpoint is unavailable. + */ + +import { Horizon } from "@stellar/stellar-sdk"; + +// --------------------------------------------------------------------------- +// Normalised types shared between the RPC path and the Horizon path +// --------------------------------------------------------------------------- + +/** Minimal account info — mirrors what Soroban RPC's getAccount returns. */ +export interface NormalizedAccount { + /** Stellar public key (G…). */ + id: string; + /** Current sequence number as a decimal string. */ + sequence: string; +} + +/** + * Single balance entry normalised across native / issued asset types. + * + * - Native XLM: `asset === "native"` + * - Issued asset: `asset === "CODE:ISSUER"` + */ +export interface NormalizedBalance { + asset: string; + balance: string; +} + +// --------------------------------------------------------------------------- +// HorizonFallbackReader +// --------------------------------------------------------------------------- + +/** + * Read-only Horizon API client that normalises account and balance responses + * into shapes compatible with the rest of the StellarSplit SDK. + * + * Instantiate once and reuse; Horizon.Server manages its own connection pool. + */ +export class HorizonFallbackReader { + private readonly _server: Horizon.Server; + + constructor(horizonUrl: string) { + this._server = new Horizon.Server(horizonUrl); + } + + /** + * Fetch account info from Horizon and return a normalised account object. + * + * @param address - Stellar public key of the account to look up. + */ + async getAccount(address: string): Promise { + const response = await this._server.loadAccount(address); + return { + id: response.id, + sequence: response.sequenceNumber(), + }; + } + + /** + * Fetch all balances for `address` from Horizon and return them in a + * normalised format. + * + * @param address - Stellar public key of the account. + */ + async getAccountBalances(address: string): Promise { + const response = await this._server.loadAccount(address); + return response.balances.map((b) => { + const asset = + b.asset_type === "native" + ? "native" + : `${"asset_code" in b ? b.asset_code : ""}:${"asset_issuer" in b ? b.asset_issuer : ""}`; + return { asset, balance: b.balance }; + }); + } +} diff --git a/src/index.ts b/src/index.ts index 951c40d..2ce01ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -241,3 +241,9 @@ 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"; diff --git a/src/sep41Adapter.ts b/src/sep41Adapter.ts new file mode 100644 index 0000000..38eb9f8 --- /dev/null +++ b/src/sep41Adapter.ts @@ -0,0 +1,310 @@ +/** + * SEP-41 fungible token interface adapter for StellarSplit. + * + * Normalises balance/transfer/approve/allowance calls across SEP-41-compliant + * and legacy Soroban token contracts. Methods that the underlying contract + * does not implement are detected via simulation probing; callers receive + * `null` and a console warning rather than an uncaught error. + */ + +import { + Account, + Contract, + TransactionBuilder, + rpc as SorobanRpc, + BASE_FEE, + nativeToScVal, + scValToNative, + xdr, +} from "@stellar/stellar-sdk"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Which SEP-41 methods the underlying contract exposes. */ +export interface Sep41TokenCapabilities { + hasBalance: boolean; + hasTransfer: boolean; + hasTransferFrom: boolean; + hasApprove: boolean; + hasAllowance: boolean; +} + +// --------------------------------------------------------------------------- +// Sep41Adapter +// --------------------------------------------------------------------------- + +/** + * Thin adapter that wraps a Soroban token contract and normalises calls to the + * five SEP-41 methods: balance, transfer, transfer_from, approve, allowance. + * + * Construction is cheap — capability probing is lazy and cached on first use. + */ +export class Sep41Adapter { + private readonly _contract: Contract; + private readonly _server: SorobanRpc.Server; + private readonly _networkPassphrase: string; + private readonly _sourceAccount: string; + private _capabilities: Sep41TokenCapabilities | null = null; + + constructor( + tokenAddress: string, + server: SorobanRpc.Server, + networkPassphrase: string, + /** Any valid Stellar public key to use as the simulation source. */ + sourceAccount: string + ) { + this._contract = new Contract(tokenAddress); + this._server = server; + this._networkPassphrase = networkPassphrase; + this._sourceAccount = sourceAccount; + } + + // ------------------------------------------------------------------------- + // Capability probing + // ------------------------------------------------------------------------- + + /** + * Probe a single method name by simulating a call with no arguments. + * + * FunctionNotFound / MissingValue → method absent (false). + * Any other simulation error (e.g. argument-type error) → method exists (true). + */ + private async _probeMethod(method: string): Promise { + try { + const account = new Account(this._sourceAccount, "0"); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this._networkPassphrase, + }) + .addOperation(this._contract.call(method)) + .setTimeout(30) + .build(); + + const simResult = await this._server.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationError(simResult)) { + const errMsg = + typeof simResult.error === "string" + ? simResult.error + : JSON.stringify(simResult.error); + // A missing-method error means the contract doesn't have this fn. + if ( + errMsg.includes("FunctionNotFound") || + errMsg.includes("MissingValue") + ) { + return false; + } + // Any other simulation error (e.g. wrong arg count) still means the + // method exists; the contract just didn't like our empty call. + return true; + } + + return true; + } catch { + return false; + } + } + + /** + * Lazily probe and cache the capabilities of the underlying token contract. + * Subsequent calls return the cached result without additional RPC calls. + */ + async getCapabilities(): Promise { + if (this._capabilities) return this._capabilities; + + const [ + hasBalance, + hasTransfer, + hasTransferFrom, + hasApprove, + hasAllowance, + ] = await Promise.all([ + this._probeMethod("balance"), + this._probeMethod("transfer"), + this._probeMethod("transfer_from"), + this._probeMethod("approve"), + this._probeMethod("allowance"), + ]); + + this._capabilities = { + hasBalance, + hasTransfer, + hasTransferFrom, + hasApprove, + hasAllowance, + }; + + return this._capabilities; + } + + // ------------------------------------------------------------------------- + // Internal simulation helper + // ------------------------------------------------------------------------- + + private async _simulateView(operation: xdr.Operation): Promise { + const account = new Account(this._sourceAccount, "0"); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this._networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + const simResult = await this._server.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationError(simResult)) { + throw new Error( + typeof simResult.error === "string" + ? simResult.error + : JSON.stringify(simResult.error) + ); + } + + const retval = ( + simResult as SorobanRpc.Api.SimulateTransactionSuccessResponse + ).result?.retval; + if (!retval) return null; + return scValToNative(retval); + } + + // ------------------------------------------------------------------------- + // SEP-41 interface + // ------------------------------------------------------------------------- + + /** + * Query the token balance for `account`. + */ + async balance(account: string): Promise { + const op = this._contract.call( + "balance", + nativeToScVal(account, { type: "address" }) + ); + const result = await this._simulateView(op); + if (typeof result === "bigint") return result; + if (typeof result === "number" || typeof result === "string") { + return BigInt(result); + } + throw new Error("[Sep41Adapter] Unexpected return type from 'balance'"); + } + + /** + * Build a `transfer` operation. + * + * The returned `xdr.Operation` should be submitted via the client's normal + * transaction pipeline. + */ + transfer(from: string, to: string, amount: bigint): xdr.Operation { + return this._contract.call( + "transfer", + nativeToScVal(from, { type: "address" }), + nativeToScVal(to, { type: "address" }), + nativeToScVal(amount, { type: "i128" }) + ); + } + + /** + * Build a `transfer_from` operation (spender-initiated delegated transfer). + * + * The returned `xdr.Operation` should be submitted via the client's normal + * transaction pipeline. + */ + transferFrom( + spender: string, + from: string, + to: string, + amount: bigint + ): xdr.Operation { + return this._contract.call( + "transfer_from", + nativeToScVal(spender, { type: "address" }), + nativeToScVal(from, { type: "address" }), + nativeToScVal(to, { type: "address" }), + nativeToScVal(amount, { type: "i128" }) + ); + } + + /** + * Build an `approve` operation granting `spender` an allowance of `amount` + * on behalf of `from`. + * + * Returns `null` if the underlying contract does not implement `approve`. + * A warning is logged in that case. + * + * @param expirationLedger - Ledger sequence number at which the approval + * expires (defaults to 0, meaning no expiration in legacy contracts that + * accept the field but ignore it). + */ + async approve( + from: string, + spender: string, + amount: bigint, + expirationLedger = 0 + ): Promise { + const caps = await this.getCapabilities(); + if (!caps.hasApprove) { + console.warn( + "[Sep41Adapter] Token contract does not implement 'approve'; treating as unsupported" + ); + return null; + } + return this._contract.call( + "approve", + nativeToScVal(from, { type: "address" }), + nativeToScVal(spender, { type: "address" }), + nativeToScVal(amount, { type: "i128" }), + nativeToScVal(expirationLedger, { type: "u32" }) + ); + } + + /** + * Query the allowance that `owner` has granted to `spender`. + * + * Returns `null` if the underlying contract does not implement `allowance`. + * A warning is logged in that case. + */ + async allowance(owner: string, spender: string): Promise { + const caps = await this.getCapabilities(); + if (!caps.hasAllowance) { + console.warn( + "[Sep41Adapter] Token contract does not implement 'allowance'; treating as unsupported" + ); + return null; + } + const op = this._contract.call( + "allowance", + nativeToScVal(owner, { type: "address" }), + nativeToScVal(spender, { type: "address" }) + ); + const result = await this._simulateView(op); + if (typeof result === "bigint") return result; + if (typeof result === "number" || typeof result === "string") { + return BigInt(result); + } + return 0n; + } +} + +// --------------------------------------------------------------------------- +// Factory helper +// --------------------------------------------------------------------------- + +/** + * Convenience factory for creating a `Sep41Adapter` from an RPC server and + * network details. + */ +export function createSep41Adapter( + tokenAddress: string, + server: SorobanRpc.Server, + networkPassphrase: string, + sourceAccount: string +): Sep41Adapter { + return new Sep41Adapter( + tokenAddress, + server, + networkPassphrase, + sourceAccount + ); +} diff --git a/test/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"); + }); +});