Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/SoroStreamClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2300,6 +2300,35 @@ export class SoroStreamClient<TEventData = Record<string, unknown>> {
* 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<BuildUnsignedXdrParams>,
): Promise<string> {
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,
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
138 changes: 137 additions & 1 deletion src/serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Network, string> = {
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.
Expand Down Expand Up @@ -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();
}
41 changes: 41 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
152 changes: 152 additions & 0 deletions test/offline_xdr_signing.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading