From c5d3bd5cbaceea8070323a5898e3da0e814a6ff1 Mon Sep 17 00:00:00 2001 From: Atisan12 Date: Thu, 25 Jun 2026 17:37:38 +0100 Subject: [PATCH] feat(sep41Adapter): add SEP-41 fungible token interface adapter (#195) Introduces Sep41Adapter, a thin normalisation layer over Soroban token contracts that may or may not implement the full SEP-41 spec. Lazy capability probing (same FunctionNotFound pattern as featureDetection) gates allowance/approve calls and returns null with a console.warn for unsupported methods. Exports wired into src/index.ts. Unit tests cover full SEP-41, transfer-only, and missing-method token shapes. Closes #195 Co-Authored-By: Claude Sonnet 4.6 --- src/index.ts | 3 + src/sep41Adapter.ts | 310 ++++++++++++++++++++++++++++++++++++ test/sep41Adapter.test.ts | 319 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 632 insertions(+) create mode 100644 src/sep41Adapter.ts create mode 100644 test/sep41Adapter.test.ts diff --git a/src/index.ts b/src/index.ts index 951c40d..439e37b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -241,3 +241,6 @@ export type { SimulationDiffNotComparable, ResourceDelta, } from "./simulationDiff.js"; + +export { Sep41Adapter, createSep41Adapter } from "./sep41Adapter.js"; +export type { Sep41TokenCapabilities } from "./sep41Adapter.js"; diff --git a/src/sep41Adapter.ts b/src/sep41Adapter.ts new file mode 100644 index 0000000..38eb9f8 --- /dev/null +++ b/src/sep41Adapter.ts @@ -0,0 +1,310 @@ +/** + * SEP-41 fungible token interface adapter for StellarSplit. + * + * Normalises balance/transfer/approve/allowance calls across SEP-41-compliant + * and legacy Soroban token contracts. Methods that the underlying contract + * does not implement are detected via simulation probing; callers receive + * `null` and a console warning rather than an uncaught error. + */ + +import { + Account, + Contract, + TransactionBuilder, + rpc as SorobanRpc, + BASE_FEE, + nativeToScVal, + scValToNative, + xdr, +} from "@stellar/stellar-sdk"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Which SEP-41 methods the underlying contract exposes. */ +export interface Sep41TokenCapabilities { + hasBalance: boolean; + hasTransfer: boolean; + hasTransferFrom: boolean; + hasApprove: boolean; + hasAllowance: boolean; +} + +// --------------------------------------------------------------------------- +// Sep41Adapter +// --------------------------------------------------------------------------- + +/** + * Thin adapter that wraps a Soroban token contract and normalises calls to the + * five SEP-41 methods: balance, transfer, transfer_from, approve, allowance. + * + * Construction is cheap — capability probing is lazy and cached on first use. + */ +export class Sep41Adapter { + private readonly _contract: Contract; + private readonly _server: SorobanRpc.Server; + private readonly _networkPassphrase: string; + private readonly _sourceAccount: string; + private _capabilities: Sep41TokenCapabilities | null = null; + + constructor( + tokenAddress: string, + server: SorobanRpc.Server, + networkPassphrase: string, + /** Any valid Stellar public key to use as the simulation source. */ + sourceAccount: string + ) { + this._contract = new Contract(tokenAddress); + this._server = server; + this._networkPassphrase = networkPassphrase; + this._sourceAccount = sourceAccount; + } + + // ------------------------------------------------------------------------- + // Capability probing + // ------------------------------------------------------------------------- + + /** + * Probe a single method name by simulating a call with no arguments. + * + * FunctionNotFound / MissingValue → method absent (false). + * Any other simulation error (e.g. argument-type error) → method exists (true). + */ + private async _probeMethod(method: string): Promise { + try { + const account = new Account(this._sourceAccount, "0"); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this._networkPassphrase, + }) + .addOperation(this._contract.call(method)) + .setTimeout(30) + .build(); + + const simResult = await this._server.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationError(simResult)) { + const errMsg = + typeof simResult.error === "string" + ? simResult.error + : JSON.stringify(simResult.error); + // A missing-method error means the contract doesn't have this fn. + if ( + errMsg.includes("FunctionNotFound") || + errMsg.includes("MissingValue") + ) { + return false; + } + // Any other simulation error (e.g. wrong arg count) still means the + // method exists; the contract just didn't like our empty call. + return true; + } + + return true; + } catch { + return false; + } + } + + /** + * Lazily probe and cache the capabilities of the underlying token contract. + * Subsequent calls return the cached result without additional RPC calls. + */ + async getCapabilities(): Promise { + if (this._capabilities) return this._capabilities; + + const [ + hasBalance, + hasTransfer, + hasTransferFrom, + hasApprove, + hasAllowance, + ] = await Promise.all([ + this._probeMethod("balance"), + this._probeMethod("transfer"), + this._probeMethod("transfer_from"), + this._probeMethod("approve"), + this._probeMethod("allowance"), + ]); + + this._capabilities = { + hasBalance, + hasTransfer, + hasTransferFrom, + hasApprove, + hasAllowance, + }; + + return this._capabilities; + } + + // ------------------------------------------------------------------------- + // Internal simulation helper + // ------------------------------------------------------------------------- + + private async _simulateView(operation: xdr.Operation): Promise { + const account = new Account(this._sourceAccount, "0"); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this._networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + const simResult = await this._server.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationError(simResult)) { + throw new Error( + typeof simResult.error === "string" + ? simResult.error + : JSON.stringify(simResult.error) + ); + } + + const retval = ( + simResult as SorobanRpc.Api.SimulateTransactionSuccessResponse + ).result?.retval; + if (!retval) return null; + return scValToNative(retval); + } + + // ------------------------------------------------------------------------- + // SEP-41 interface + // ------------------------------------------------------------------------- + + /** + * Query the token balance for `account`. + */ + async balance(account: string): Promise { + const op = this._contract.call( + "balance", + nativeToScVal(account, { type: "address" }) + ); + const result = await this._simulateView(op); + if (typeof result === "bigint") return result; + if (typeof result === "number" || typeof result === "string") { + return BigInt(result); + } + throw new Error("[Sep41Adapter] Unexpected return type from 'balance'"); + } + + /** + * Build a `transfer` operation. + * + * The returned `xdr.Operation` should be submitted via the client's normal + * transaction pipeline. + */ + transfer(from: string, to: string, amount: bigint): xdr.Operation { + return this._contract.call( + "transfer", + nativeToScVal(from, { type: "address" }), + nativeToScVal(to, { type: "address" }), + nativeToScVal(amount, { type: "i128" }) + ); + } + + /** + * Build a `transfer_from` operation (spender-initiated delegated transfer). + * + * The returned `xdr.Operation` should be submitted via the client's normal + * transaction pipeline. + */ + transferFrom( + spender: string, + from: string, + to: string, + amount: bigint + ): xdr.Operation { + return this._contract.call( + "transfer_from", + nativeToScVal(spender, { type: "address" }), + nativeToScVal(from, { type: "address" }), + nativeToScVal(to, { type: "address" }), + nativeToScVal(amount, { type: "i128" }) + ); + } + + /** + * Build an `approve` operation granting `spender` an allowance of `amount` + * on behalf of `from`. + * + * Returns `null` if the underlying contract does not implement `approve`. + * A warning is logged in that case. + * + * @param expirationLedger - Ledger sequence number at which the approval + * expires (defaults to 0, meaning no expiration in legacy contracts that + * accept the field but ignore it). + */ + async approve( + from: string, + spender: string, + amount: bigint, + expirationLedger = 0 + ): Promise { + const caps = await this.getCapabilities(); + if (!caps.hasApprove) { + console.warn( + "[Sep41Adapter] Token contract does not implement 'approve'; treating as unsupported" + ); + return null; + } + return this._contract.call( + "approve", + nativeToScVal(from, { type: "address" }), + nativeToScVal(spender, { type: "address" }), + nativeToScVal(amount, { type: "i128" }), + nativeToScVal(expirationLedger, { type: "u32" }) + ); + } + + /** + * Query the allowance that `owner` has granted to `spender`. + * + * Returns `null` if the underlying contract does not implement `allowance`. + * A warning is logged in that case. + */ + async allowance(owner: string, spender: string): Promise { + const caps = await this.getCapabilities(); + if (!caps.hasAllowance) { + console.warn( + "[Sep41Adapter] Token contract does not implement 'allowance'; treating as unsupported" + ); + return null; + } + const op = this._contract.call( + "allowance", + nativeToScVal(owner, { type: "address" }), + nativeToScVal(spender, { type: "address" }) + ); + const result = await this._simulateView(op); + if (typeof result === "bigint") return result; + if (typeof result === "number" || typeof result === "string") { + return BigInt(result); + } + return 0n; + } +} + +// --------------------------------------------------------------------------- +// Factory helper +// --------------------------------------------------------------------------- + +/** + * Convenience factory for creating a `Sep41Adapter` from an RPC server and + * network details. + */ +export function createSep41Adapter( + tokenAddress: string, + server: SorobanRpc.Server, + networkPassphrase: string, + sourceAccount: string +): Sep41Adapter { + return new Sep41Adapter( + tokenAddress, + server, + networkPassphrase, + sourceAccount + ); +} diff --git a/test/sep41Adapter.test.ts b/test/sep41Adapter.test.ts new file mode 100644 index 0000000..de517b4 --- /dev/null +++ b/test/sep41Adapter.test.ts @@ -0,0 +1,319 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Sep41Adapter } from "../src/sep41Adapter.js"; + +// --------------------------------------------------------------------------- +// Stellar SDK mock +// --------------------------------------------------------------------------- + +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...(actual as Record), + Contract: vi.fn().mockImplementation(() => ({ + call: vi.fn().mockReturnValue("mock-operation"), + })), + Account: vi.fn().mockImplementation(() => ({})), + TransactionBuilder: vi.fn().mockImplementation(() => ({ + addOperation: vi.fn().mockReturnThis(), + setTimeout: vi.fn().mockReturnThis(), + build: vi.fn().mockReturnValue({}), + })), + BASE_FEE: "100", + nativeToScVal: vi.fn().mockReturnValue("mock-scval"), + scValToNative: vi.fn(), + rpc: { + Server: vi.fn(), + Api: { + isSimulationError: vi.fn(), + GetTransactionStatus: { NOT_FOUND: "NOT_FOUND", SUCCESS: "SUCCESS" }, + }, + assembleTransaction: vi.fn(), + }, + xdr: (actual as Record).xdr, + }; +}); + +// --------------------------------------------------------------------------- +// Shared fixture helpers +// --------------------------------------------------------------------------- + +const TOKEN_ADDRESS = "CBBD47AB2EB00E041B5B13A596261F07D3FA7F19B566F3BEA881F5D414951F94"; +const SOURCE = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; +const PASSPHRASE = "Test SDF Network ; September 2015"; +const OWNER = "GBOWNER000000000000000000000000000000000000000000000000000"; +const SPENDER = "GBSPENDER0000000000000000000000000000000000000000000000000"; +const RECIPIENT = "GBRECIPIENT000000000000000000000000000000000000000000000000"; + +/** Build a mock server whose simulateTransaction resolves based on method name. */ +function buildMockServer( + supportedMethods: Set, + balanceReturnValue: bigint = 500n +): object { + return { + simulateTransaction: vi.fn().mockImplementation(async () => { + // We can't inspect the operation directly, so method filtering happens + // through _probeMethod: probes with no args, which the mock responds to + // by checking the method set via the Contract.call mock override per test. + return { + result: { retval: { _type: "i128", value: balanceReturnValue } }, + }; + }), + }; +} + +// --------------------------------------------------------------------------- +// Token shape 1: Full SEP-41 +// --------------------------------------------------------------------------- + +describe("Sep41Adapter — full SEP-41 token", () => { + let adapter: Sep41Adapter; + + beforeEach(async () => { + const { rpc, Contract, scValToNative } = await import("@stellar/stellar-sdk"); + + (rpc.Api.isSimulationError as ReturnType).mockReturnValue(false); + + (Contract as ReturnType).mockImplementation(() => ({ + call: vi.fn().mockReturnValue("mock-operation"), + })); + + (scValToNative as ReturnType).mockReturnValue(1000n); + + const mockServer = { + simulateTransaction: vi.fn().mockResolvedValue({ + result: { retval: "mock-retval" }, + }), + }; + + (rpc.Server as ReturnType).mockImplementation(() => mockServer); + + adapter = new Sep41Adapter( + TOKEN_ADDRESS, + new (rpc.Server as ReturnType)(), + PASSPHRASE, + SOURCE + ); + }); + + it("getCapabilities returns all true for a full SEP-41 contract", async () => { + const caps = await adapter.getCapabilities(); + expect(caps.hasBalance).toBe(true); + expect(caps.hasTransfer).toBe(true); + expect(caps.hasTransferFrom).toBe(true); + expect(caps.hasApprove).toBe(true); + expect(caps.hasAllowance).toBe(true); + }); + + it("getCapabilities is cached after first call", async () => { + const { rpc } = await import("@stellar/stellar-sdk"); + const server = (rpc.Server as ReturnType).mock.results[0]?.value as { + simulateTransaction: ReturnType; + }; + await adapter.getCapabilities(); + const callsBefore = server.simulateTransaction.mock.calls.length; + await adapter.getCapabilities(); + expect(server.simulateTransaction.mock.calls.length).toBe(callsBefore); + }); + + it("balance() simulates and returns a bigint", async () => { + const result = await adapter.balance(OWNER); + expect(typeof result).toBe("bigint"); + }); + + it("transfer() returns an xdr.Operation (synchronous)", () => { + const op = adapter.transfer(OWNER, RECIPIENT, 100n); + expect(op).toBeDefined(); + }); + + it("transferFrom() returns an xdr.Operation (synchronous)", () => { + const op = adapter.transferFrom(SPENDER, OWNER, RECIPIENT, 100n); + expect(op).toBeDefined(); + }); + + it("approve() returns an xdr.Operation for a full SEP-41 token", async () => { + const op = await adapter.approve(OWNER, SPENDER, 500n, 1000); + expect(op).not.toBeNull(); + }); + + it("allowance() returns a bigint for a full SEP-41 token", async () => { + const result = await adapter.allowance(OWNER, SPENDER); + expect(result).not.toBeNull(); + expect(typeof result).toBe("bigint"); + }); +}); + +// --------------------------------------------------------------------------- +// Token shape 2: Transfer-only (no allowance / approve / transfer_from) +// --------------------------------------------------------------------------- + +describe("Sep41Adapter — transfer-only token", () => { + let adapter: Sep41Adapter; + + beforeEach(async () => { + const { rpc, Contract, scValToNative } = await import("@stellar/stellar-sdk"); + + (scValToNative as ReturnType).mockReturnValue(250n); + + // Probe: allowance / approve / transfer_from return FunctionNotFound. + // balance / transfer succeed. + const probeResults: Record = { + balance: true, + transfer: true, + transfer_from: false, + approve: false, + allowance: false, + }; + + let callIndex = 0; + (rpc.Api.isSimulationError as ReturnType).mockImplementation( + (result: { error?: string }) => "error" in result && !!result.error + ); + + (Contract as ReturnType).mockImplementation(() => { + return { + call: vi.fn().mockImplementation((method: string) => { + void method; + return `op-${callIndex++}`; + }), + }; + }); + + const methodCallOrder = ["balance", "transfer", "transfer_from", "approve", "allowance"]; + let probeIdx = 0; + + const mockServer = { + simulateTransaction: vi.fn().mockImplementation(async () => { + // First five calls are probes (one per method, in Promise.all order) + if (probeIdx < methodCallOrder.length) { + const method = methodCallOrder[probeIdx++]!; + if (!probeResults[method]) { + return { error: "FunctionNotFound: no such function" }; + } + return { result: { retval: "ok" } }; + } + // Subsequent calls are actual view simulations (balance, allowance, etc.) + return { result: { retval: "mock-retval" } }; + }), + }; + + (rpc.Server as ReturnType).mockImplementation(() => mockServer); + + adapter = new Sep41Adapter( + TOKEN_ADDRESS, + new (rpc.Server as ReturnType)(), + PASSPHRASE, + SOURCE + ); + }); + + it("getCapabilities reflects a transfer-only contract", async () => { + const caps = await adapter.getCapabilities(); + expect(caps.hasBalance).toBe(true); + expect(caps.hasTransfer).toBe(true); + expect(caps.hasTransferFrom).toBe(false); + expect(caps.hasApprove).toBe(false); + expect(caps.hasAllowance).toBe(false); + }); + + it("approve() returns null and logs a warning", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const op = await adapter.approve(OWNER, SPENDER, 500n); + expect(op).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("'approve'") + ); + warnSpy.mockRestore(); + }); + + it("allowance() returns null and logs a warning", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const result = await adapter.allowance(OWNER, SPENDER); + expect(result).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("'allowance'") + ); + warnSpy.mockRestore(); + }); + + it("transfer() still returns an operation for a transfer-only token", () => { + const op = adapter.transfer(OWNER, RECIPIENT, 100n); + expect(op).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Token shape 3: Missing-method (only balance; all else absent) +// --------------------------------------------------------------------------- + +describe("Sep41Adapter — missing-method token (balance only)", () => { + let adapter: Sep41Adapter; + + beforeEach(async () => { + const { rpc, Contract, scValToNative } = await import("@stellar/stellar-sdk"); + + (scValToNative as ReturnType).mockReturnValue(0n); + + const supportedMethods = new Set(["balance"]); + + (rpc.Api.isSimulationError as ReturnType).mockImplementation( + (result: { error?: string }) => "error" in result && !!result.error + ); + + (Contract as ReturnType).mockImplementation(() => ({ + call: vi.fn().mockReturnValue("mock-op"), + })); + + const methodCallOrder = ["balance", "transfer", "transfer_from", "approve", "allowance"]; + let probeIdx = 0; + + const mockServer = { + simulateTransaction: vi.fn().mockImplementation(async () => { + if (probeIdx < methodCallOrder.length) { + const method = methodCallOrder[probeIdx++]!; + if (!supportedMethods.has(method)) { + return { error: "FunctionNotFound: no such function" }; + } + return { result: { retval: "ok" } }; + } + return { result: { retval: "mock-retval" } }; + }), + }; + + (rpc.Server as ReturnType).mockImplementation(() => mockServer); + + adapter = new Sep41Adapter( + TOKEN_ADDRESS, + new (rpc.Server as ReturnType)(), + PASSPHRASE, + SOURCE + ); + }); + + it("getCapabilities shows only balance as present", async () => { + const caps = await adapter.getCapabilities(); + expect(caps.hasBalance).toBe(true); + expect(caps.hasTransfer).toBe(false); + expect(caps.hasTransferFrom).toBe(false); + expect(caps.hasApprove).toBe(false); + expect(caps.hasAllowance).toBe(false); + }); + + it("approve() returns null gracefully", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const op = await adapter.approve(OWNER, SPENDER, 100n); + expect(op).toBeNull(); + warnSpy.mockRestore(); + }); + + it("allowance() returns null gracefully", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const result = await adapter.allowance(OWNER, SPENDER); + expect(result).toBeNull(); + warnSpy.mockRestore(); + }); + + it("balance() still resolves even on a minimal contract", async () => { + const bal = await adapter.balance(OWNER); + expect(typeof bal).toBe("bigint"); + }); +});