diff --git a/src/SoroStreamClient.ts b/src/SoroStreamClient.ts index 9abdf50..9db235a 100644 --- a/src/SoroStreamClient.ts +++ b/src/SoroStreamClient.ts @@ -2300,6 +2300,35 @@ export class SoroStreamClient> { * console.log("Stream created:", streamId, txHash); * ``` */ + + /** + * Constructs and serialises an unsigned transaction XDR for offline/air-gapped signing (issue #438). + * + * @param operation - Operation name string or xdr.Operation instance. + * @param params - Optional parameters and arguments for the operation. + * @returns Unsigned transaction envelope as a base64 XDR string. + */ + async buildUnsignedXdr( + operation: xdr.Operation | string, + params?: Partial, + ): Promise { + const sender = params?.sourceAccount + ? (typeof params.sourceAccount === "string" ? params.sourceAccount : params.sourceAccount.accountId()) + : (this.walletAdapter ? await this.walletAdapter.getPublicKey() : undefined); + + if (!sender) { + throw new Error("sourceAccount or a connected wallet adapter is required to build unsigned XDR"); + } + + return buildUnsignedXdr(operation, { + contractId: this.contract.address().toString(), + network: this.network, + contractVersion: params?.contractVersion ?? 'v1', + sourceAccount: sender, + ...params, + }); + } + async createStream( params: CreateStreamParams, signal?: AbortSignal, diff --git a/src/index.ts b/src/index.ts index 969294d..ac782ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,8 +75,9 @@ export { } from './utils.js'; export type { StreamMetadataFields, SimulateStreamParams, StreamSimulationResult, StreamSimulationSnapshot } from './utils.js'; export { templates } from './templates.js'; -export { serializeStream, deserializeStream } from './serialization.js'; +export { serializeStream, deserializeStream, buildUnsignedXdr } from './serialization.js'; export type { SerializedStream } from './serialization.js'; +export type { BuildUnsignedXdrParams } from './types.js'; export { getTransactionHistory, getAddressActivity } from './horizon.js'; export type { StreamTransaction, diff --git a/src/serialization.ts b/src/serialization.ts index 27eeb75..6fd36f4 100644 --- a/src/serialization.ts +++ b/src/serialization.ts @@ -5,7 +5,18 @@ * to work with the structured clone algorithm and Web Workers. */ -import type { Stream } from './types.js'; +import { Account, Contract, Memo, Networks, TransactionBuilder, xdr } from "@stellar/stellar-sdk"; +import type { BuildUnsignedXdrParams, Network, Stream } from "./types.js"; +import { createContractEncoder } from "./contractEncoders.js"; + +const NETWORK_PASSPHRASES: Record = { + mainnet: "Public Global Stellar Network ; September 2015", + testnet: "Test SDF Network ; September 2015", + futurenet: "Test SDF Future Network ; October 2022", +}; + +const BASE_FEE = "100"; + /** * Serialized representation of a Stream with BigInt fields converted to strings. @@ -96,3 +107,128 @@ export function deserializeStream(data: SerializedStream): Stream { ...(data.lockUntil !== undefined ? { lockUntil: data.lockUntil } : {}), }; } + +/** + * Constructs and serialises a Soroban contract transaction to unsigned XDR + * without broadcasting it, enabling air-gapped or server-side signing workflows (issue #438). + * + * @param operation - The contract operation (an xdr.Operation or method name string). + * @param params - Parameters containing sourceAccount, contractId, network, and operation arguments. + * @returns Base64 encoded unsigned transaction XDR string. + */ +export function buildUnsignedXdr( + operation: xdr.Operation | string, + params: BuildUnsignedXdrParams, +): string { + const sourceAddress = + typeof params.sourceAccount === "string" + ? params.sourceAccount + : params.sourceAccount.accountId(); + + const account = + typeof params.sourceAccount === "string" + ? new Account(params.sourceAccount, String(params.sequenceNumber ?? "0")) + : params.sourceAccount; + + const networkPassphrase = + params.networkPassphrase ?? + (params.network && params.network in NETWORK_PASSPHRASES ? NETWORK_PASSPHRASES[params.network as Network] : undefined) ?? + Networks.TESTNET; + + let op: xdr.Operation; + if (typeof operation === "string") { + if (!params.contractId) { + throw new Error("contractId is required when operation is specified as a string"); + } + const contract = new Contract(params.contractId); + const encoder = createContractEncoder(contract, params.contractVersion ?? "v1"); + const sender = params.sender ?? sourceAddress; + + switch (operation) { + case "createStream": + op = encoder.createStream(sender, { + recipient: params.recipient!, + token: params.token!, + amount: typeof params.amount === "bigint" ? params.amount : BigInt(params.amount ?? 0), + durationSeconds: Number(params.durationSeconds ?? 0), + startTime: params.startTime, + cliffSeconds: params.cliffSeconds, + autoRenew: params.autoRenew ?? false, + namespace: params.namespace, + }); + break; + case "withdraw": + op = encoder.withdraw(String(params.streamId ?? ""), params.recipient ?? sender); + break; + case "cancelStream": + op = encoder.cancelStream(String(params.streamId ?? ""), sender); + break; + case "topUp": + op = encoder.topUp( + String(params.streamId ?? ""), + sender, + typeof params.amount === "bigint" ? params.amount : BigInt(params.amount ?? 0), + ); + break; + case "updateFlowRate": + op = encoder.updateFlowRate( + String(params.streamId ?? ""), + sender, + typeof params.newFlowRate === "bigint" + ? params.newFlowRate + : BigInt(params.newFlowRate ?? 0), + ); + break; + case "pauseStream": + op = encoder.pauseStream(String(params.streamId ?? ""), sender); + break; + case "resumeStream": + op = encoder.resumeStream(String(params.streamId ?? ""), sender); + break; + case "transferStream": + op = encoder.transferStream(String(params.streamId ?? ""), sender, params.newRecipient!); + break; + case "setOperator": + op = encoder.setOperator( + String(params.streamId ?? ""), + sender, + params.operator!, + Boolean(params.approved), + ); + break; + case "operatorCancelStream": + op = encoder.operatorCancelStream(String(params.streamId ?? ""), params.operator ?? sender); + break; + case "operatorTopUp": + op = encoder.operatorTopUp( + String(params.streamId ?? ""), + params.operator ?? sender, + typeof params.amount === "bigint" ? params.amount : BigInt(params.amount ?? 0), + ); + break; + case "addDelegate": + op = encoder.addDelegate(params.delegator ?? sender, params.delegate!); + break; + case "revokeDelegate": + op = encoder.revokeDelegate(params.delegator ?? sender, params.delegate!); + break; + default: + throw new Error(`Unsupported operation name: ${operation}`); + } + } else { + op = operation; + } + + let builder = new TransactionBuilder(account, { + fee: String(params.fee ?? BASE_FEE), + networkPassphrase, + }); + + if (params.memo) { + builder = builder.addMemo(Memo.text(params.memo)); + } + + builder = builder.addOperation(op); + const tx = builder.setTimeout(params.timeout ?? 30).build(); + return tx.toXDR(); +} diff --git a/src/types.ts b/src/types.ts index 75e9de1..0717cf1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1630,3 +1630,44 @@ export interface StreamHealthResult { /** Human-readable diagnostics messages (empty when status is healthy). */ diagnostics: string[]; } + +/** Parameter options for buildUnsignedXdr helper (issue #438). */ +export interface BuildUnsignedXdrParams { + /** The deployed contract address (required if operation is a method name string). */ + contractId?: string; + /** Source account public key (Stellar address) or Account instance. */ + sourceAccount: string | any; + /** Sequence number for the transaction (default: "0"). */ + sequenceNumber?: string | number | bigint; + /** Target network ("testnet" | "mainnet" | "futurenet"). */ + network?: Network; + /** Network passphrase override. */ + networkPassphrase?: string; + /** Base fee in stroops (default: "100"). */ + fee?: string | number; + /** Transaction timeout in seconds (default: 30). */ + timeout?: number; + /** Optional transaction memo string. */ + memo?: string; + /** Contract version ("v1" | "v2"). */ + contractVersion?: ContractVersion; + + // Operation arguments + recipient?: string; + token?: string; + amount?: bigint | string | number; + durationSeconds?: number; + startTime?: number; + cliffSeconds?: number; + autoRenew?: boolean; + namespace?: string; + streamId?: string; + sender?: string; + newFlowRate?: bigint | string | number; + newRecipient?: string; + operator?: string; + approved?: boolean; + delegate?: string; + delegator?: string; + [key: string]: any; +} diff --git a/test/offline_xdr_signing.test.ts b/test/offline_xdr_signing.test.ts new file mode 100644 index 0000000..6bb8df0 --- /dev/null +++ b/test/offline_xdr_signing.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from "vitest"; +import { Account, Networks, TransactionBuilder } from "@stellar/stellar-sdk"; +import { buildUnsignedXdr } from "../src/serialization.js"; +import { SoroStreamClient } from "../src/SoroStreamClient.js"; +import { Keypair } from "@stellar/stellar-sdk"; + +describe("Issue #438: Unsigned XDR serialization helper for offline/air-gapped signing", () => { + const contractId = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"; + const sourceKey = Keypair.random().publicKey(); + const recipientKey = Keypair.random().publicKey(); + const tokenAddress = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + + it("builds valid unsigned XDR for createStream operation", () => { + const xdrStr = buildUnsignedXdr("createStream", { + contractId, + sourceAccount: sourceKey, + recipient: recipientKey, + token: tokenAddress, + amount: 1000_0000000n, + durationSeconds: 3600, + network: "testnet", + }); + + expect(typeof xdrStr).toBe("string"); + expect(xdrStr.length).toBeGreaterThan(0); + + const tx = TransactionBuilder.fromXDR(xdrStr, Networks.TESTNET); + expect(tx.source).toBe(sourceKey); + expect(tx.operations.length).toBe(1); + expect(tx.operations[0]!.type).toBe("invokeHostFunction"); + }); + + it("builds valid unsigned XDR for withdraw and cancel operations", () => { + const withdrawXdr = buildUnsignedXdr("withdraw", { + contractId, + sourceAccount: recipientKey, + streamId: "123", + recipient: recipientKey, + network: "testnet", + }); + const withdrawTx = TransactionBuilder.fromXDR(withdrawXdr, Networks.TESTNET); + expect(withdrawTx.operations.length).toBe(1); + + const cancelXdr = buildUnsignedXdr("cancelStream", { + contractId, + sourceAccount: sourceKey, + streamId: "123", + network: "testnet", + }); + const cancelTx = TransactionBuilder.fromXDR(cancelXdr, Networks.TESTNET); + expect(cancelTx.operations.length).toBe(1); + }); + + it("supports topUp, updateFlowRate, pause, resume, and transferStream", () => { + const topUpXdr = buildUnsignedXdr("topUp", { + contractId, + sourceAccount: sourceKey, + streamId: "123", + amount: 500_0000000n, + }); + expect(TransactionBuilder.fromXDR(topUpXdr, Networks.TESTNET)).toBeDefined(); + + const pauseXdr = buildUnsignedXdr("pauseStream", { + contractId, + sourceAccount: sourceKey, + streamId: "123", + }); + expect(TransactionBuilder.fromXDR(pauseXdr, Networks.TESTNET)).toBeDefined(); + + const resumeXdr = buildUnsignedXdr("resumeStream", { + contractId, + sourceAccount: sourceKey, + streamId: "123", + }); + expect(TransactionBuilder.fromXDR(resumeXdr, Networks.TESTNET)).toBeDefined(); + + const transferXdr = buildUnsignedXdr("transferStream", { + contractId, + sourceAccount: sourceKey, + streamId: "123", + newRecipient: recipientKey, + }); + expect(TransactionBuilder.fromXDR(transferXdr, Networks.TESTNET)).toBeDefined(); + }); + + it("accepts custom Account object, sequence number, memo, fee, and timeout", () => { + const account = new Account(sourceKey, "42"); + const xdrStr = buildUnsignedXdr("withdraw", { + contractId, + sourceAccount: account, + streamId: "1", + fee: "200", + memo: "offline-signing", + timeout: 60, + }); + + const tx = TransactionBuilder.fromXDR(xdrStr, Networks.TESTNET); + expect(tx.sequence).toBe("43"); + expect(String(tx.fee)).toBe("200"); + expect(tx.memo.value?.toString()).toBe("offline-signing"); + expect(tx.timeBounds?.maxTime).toBeDefined(); + }); + + it("supports passing raw xdr.Operation directly", () => { + const contract = new (require("@stellar/stellar-sdk").Contract)(contractId); + const op = contract.call("withdraw", require("@stellar/stellar-sdk").nativeToScVal("1", { type: "string" })); + + const xdrStr = buildUnsignedXdr(op, { + sourceAccount: sourceKey, + network: "testnet", + }); + + const tx = TransactionBuilder.fromXDR(xdrStr, Networks.TESTNET); + expect(tx.operations.length).toBe(1); + }); + + it("supports client.buildUnsignedXdr method", async () => { + const client = new SoroStreamClient({ + network: "testnet", + contractId, + }); + + const xdrStr = await client.buildUnsignedXdr("createStream", { + sourceAccount: sourceKey, + recipient: recipientKey, + token: tokenAddress, + amount: 100n, + durationSeconds: 60, + }); + + expect(typeof xdrStr).toBe("string"); + const tx = TransactionBuilder.fromXDR(xdrStr, Networks.TESTNET); + expect(tx.source).toBe(sourceKey); + }); + + it("throws error when contractId is missing for string operation name", () => { + expect(() => + buildUnsignedXdr("createStream", { + sourceAccount: sourceKey, + }) + ).toThrow("contractId is required"); + }); + + it("throws error for unsupported operation name", () => { + expect(() => + buildUnsignedXdr("invalidOpName", { + contractId, + sourceAccount: sourceKey, + }) + ).toThrow("Unsupported operation name"); + }); +});