diff --git a/packages/wasm-solana/js/builder.ts b/packages/wasm-solana/js/builder.ts index adc61d7..9ff1608 100644 --- a/packages/wasm-solana/js/builder.ts +++ b/packages/wasm-solana/js/builder.ts @@ -6,6 +6,8 @@ */ import { BuilderNamespace } from "./wasm/wasm_solana.js"; +import { Transaction } from "./transaction.js"; +import { VersionedTransaction } from "./versioned.js"; // ============================================================================= // Nonce Types @@ -463,14 +465,14 @@ export interface TransactionIntent { /** * Build a Solana transaction from a high-level intent. * - * This function takes a declarative TransactionIntent and produces serialized - * transaction bytes that can be signed and submitted to the network. + * This function takes a declarative TransactionIntent and produces a Transaction + * object that can be inspected, signed, and serialized. * - * The returned transaction is unsigned - signatures should be added before - * broadcasting. + * The returned transaction is unsigned - signatures should be added via + * `addSignature()` before serializing with `toBytes()` and broadcasting. * * @param intent - The transaction intent describing what to build - * @returns Serialized unsigned transaction bytes (Uint8Array) + * @returns A Transaction object that can be inspected, signed, and serialized * @throws Error if the intent cannot be built (e.g., invalid addresses) * * @example @@ -478,33 +480,43 @@ export interface TransactionIntent { * import { buildTransaction } from '@bitgo/wasm-solana'; * * // Build a simple SOL transfer - * const txBytes = buildTransaction({ + * const tx = buildTransaction({ * feePayer: sender, * nonce: { type: 'blockhash', value: blockhash }, * instructions: [ - * { type: 'transfer', from: sender, to: recipient, lamports: '1000000' } + * { type: 'transfer', from: sender, to: recipient, lamports: 1000000n } * ] * }); * - * // The returned bytes can be signed and broadcast + * // Inspect the transaction + * console.log(tx.feePayer); + * console.log(tx.recentBlockhash); + * + * // Get the signable payload for signing + * const payload = tx.signablePayload(); + * + * // Add signature and serialize + * tx.addSignature(signerPubkey, signature); + * const txBytes = tx.toBytes(); * ``` * * @example * ```typescript * // Build with durable nonce and priority fee - * const txBytes = buildTransaction({ + * const tx = buildTransaction({ * feePayer: sender, * nonce: { type: 'durable', address: nonceAccount, authority: sender, value: nonceValue }, * instructions: [ * { type: 'computeBudget', unitLimit: 200000, unitPrice: 5000 }, - * { type: 'transfer', from: sender, to: recipient, lamports: '1000000' }, + * { type: 'transfer', from: sender, to: recipient, lamports: 1000000n }, * { type: 'memo', message: 'BitGo transfer' } * ] * }); * ``` */ -export function buildTransaction(intent: TransactionIntent): Uint8Array { - return BuilderNamespace.build_transaction(intent); +export function buildTransaction(intent: TransactionIntent): Transaction { + const wasm = BuilderNamespace.build_transaction(intent); + return Transaction.fromWasm(wasm); } // ============================================================================= @@ -560,17 +572,17 @@ export interface RawVersionedTransactionData { * * This function is used for the `fromVersionedTransactionData()` path where we already * have pre-compiled versioned data (indexes + ALT refs). No instruction compilation - * is needed - we just serialize the raw structure to bytes. + * is needed - we just serialize the raw structure. * * @param data - Raw versioned transaction data - * @returns Serialized unsigned versioned transaction bytes (Uint8Array) + * @returns A VersionedTransaction object that can be inspected, signed, and serialized * @throws Error if the data is invalid * * @example * ```typescript * import { buildFromVersionedData } from '@bitgo/wasm-solana'; * - * const txBytes = buildFromVersionedData({ + * const tx = buildFromVersionedData({ * staticAccountKeys: ['pubkey1', 'pubkey2', ...], * addressLookupTables: [ * { accountKey: 'altPubkey', writableIndexes: [0, 1], readonlyIndexes: [2] } @@ -585,8 +597,14 @@ export interface RawVersionedTransactionData { * }, * recentBlockhash: 'blockhash' * }); + * + * // Inspect, sign, and serialize + * console.log(tx.feePayer); + * tx.addSignature(signerPubkey, signature); + * const txBytes = tx.toBytes(); * ``` */ -export function buildFromVersionedData(data: RawVersionedTransactionData): Uint8Array { - return BuilderNamespace.build_from_versioned_data(data); +export function buildFromVersionedData(data: RawVersionedTransactionData): VersionedTransaction { + const wasm = BuilderNamespace.build_from_versioned_data(data); + return VersionedTransaction.fromWasm(wasm); } diff --git a/packages/wasm-solana/js/index.ts b/packages/wasm-solana/js/index.ts index 835f88f..54b3d68 100644 --- a/packages/wasm-solana/js/index.ts +++ b/packages/wasm-solana/js/index.ts @@ -45,6 +45,7 @@ export { // Type exports export type { AccountMeta, Instruction } from "./transaction.js"; export type { + TransactionInput, ParsedTransaction, DurableNonce, InstructionParams, diff --git a/packages/wasm-solana/js/parser.ts b/packages/wasm-solana/js/parser.ts index f7e018b..c27355e 100644 --- a/packages/wasm-solana/js/parser.ts +++ b/packages/wasm-solana/js/parser.ts @@ -8,6 +8,13 @@ */ import { ParserNamespace } from "./wasm/wasm_solana.js"; +import type { Transaction } from "./transaction.js"; +import type { VersionedTransaction } from "./versioned.js"; + +/** + * Input type for parseTransaction - accepts bytes or Transaction objects. + */ +export type TransactionInput = Uint8Array | Transaction | VersionedTransaction; // ============================================================================= // Instruction Types - matching BitGoJS InstructionParams @@ -273,10 +280,10 @@ export interface ParsedTransaction { // ============================================================================= /** - * Parse a serialized Solana transaction into structured data. + * Parse a Solana transaction into structured data. * * This is the main entry point for transaction parsing. It deserializes the - * transaction bytes and decodes all instructions into semantic types. + * transaction and decodes all instructions into semantic types. * * All monetary amounts (amount, fee, lamports, poolTokens) are returned as bigint * directly from WASM - no post-processing needed. @@ -285,17 +292,22 @@ export interface ParsedTransaction { * Consumers (like BitGoJS) may choose to filter NonceAdvance from instructionsData * since that info is also available in durableNonce. * - * @param bytes - The raw transaction bytes (wire format) + * @param input - Raw transaction bytes, Transaction, or VersionedTransaction * @returns A ParsedTransaction with all instructions decoded * @throws Error if the transaction cannot be parsed * * @example * ```typescript - * import { parseTransaction } from '@bitgo/wasm-solana'; + * import { parseTransaction, buildTransaction, Transaction } from '@bitgo/wasm-solana'; * + * // From bytes * const txBytes = Buffer.from(base64EncodedTx, 'base64'); * const parsed = parseTransaction(txBytes); * + * // Directly from a Transaction object (no roundtrip through bytes) + * const tx = buildTransaction(intent); + * const parsed = parseTransaction(tx); + * * console.log(parsed.feePayer); * for (const instr of parsed.instructionsData) { * if (instr.type === 'Transfer') { @@ -304,6 +316,8 @@ export interface ParsedTransaction { * } * ``` */ -export function parseTransaction(bytes: Uint8Array): ParsedTransaction { +export function parseTransaction(input: TransactionInput): ParsedTransaction { + // If input is a Transaction or VersionedTransaction, extract bytes + const bytes = input instanceof Uint8Array ? input : input.toBytes(); return ParserNamespace.parse_transaction(bytes) as ParsedTransaction; } diff --git a/packages/wasm-solana/js/transaction.ts b/packages/wasm-solana/js/transaction.ts index 2c8be11..32e8f86 100644 --- a/packages/wasm-solana/js/transaction.ts +++ b/packages/wasm-solana/js/transaction.ts @@ -57,6 +57,14 @@ export class Transaction { return new Transaction(wasm); } + /** + * Create a Transaction from a WasmTransaction instance. + * @internal Used by builder functions + */ + static fromWasm(wasm: WasmTransaction): Transaction { + return new Transaction(wasm); + } + /** * Get the fee payer address as a base58 string * Returns null if there are no account keys (shouldn't happen for valid transactions) diff --git a/packages/wasm-solana/js/versioned.ts b/packages/wasm-solana/js/versioned.ts index 8c91b2a..82c695e 100644 --- a/packages/wasm-solana/js/versioned.ts +++ b/packages/wasm-solana/js/versioned.ts @@ -90,6 +90,14 @@ export class VersionedTransaction { return VersionedTransaction.fromBytes(bytes); } + /** + * Create a VersionedTransaction from a WasmVersionedTransaction instance. + * @internal Used by builder functions + */ + static fromWasm(wasm: WasmVersionedTransaction): VersionedTransaction { + return new VersionedTransaction(wasm); + } + /** * Create a versioned transaction from raw MessageV0 data. * @@ -120,10 +128,9 @@ export class VersionedTransaction { * ``` */ static fromVersionedData(data: RawVersionedTransactionData): VersionedTransaction { - // Build the transaction bytes using WASM - const bytes = BuilderNamespace.build_from_versioned_data(data); - // Parse the bytes to create a VersionedTransaction - return VersionedTransaction.fromBytes(bytes); + // Build the transaction using WASM and wrap in TypeScript class + const wasm = BuilderNamespace.build_from_versioned_data(data); + return VersionedTransaction.fromWasm(wasm); } /** diff --git a/packages/wasm-solana/src/wasm/builder.rs b/packages/wasm-solana/src/wasm/builder.rs index 20d7996..d22a56c 100644 --- a/packages/wasm-solana/src/wasm/builder.rs +++ b/packages/wasm-solana/src/wasm/builder.rs @@ -1,10 +1,11 @@ //! WASM binding for transaction building. //! //! Exposes transaction building functions: -//! - `buildTransaction` - Creates transactions from a high-level intent structure -//! - `buildFromVersionedData` - Creates versioned transactions from raw MessageV0 data +//! - `buildTransaction` - Creates a Transaction from a high-level intent structure +//! - `buildFromVersionedData` - Creates a VersionedTransaction from raw MessageV0 data use crate::builder; +use crate::wasm::transaction::{WasmTransaction, WasmVersionedTransaction}; use wasm_bindgen::prelude::*; /// Namespace for transaction building operations. @@ -46,22 +47,26 @@ impl BuilderNamespace { /// /// # Returns /// - /// Serialized unsigned transaction bytes (Uint8Array). + /// A `Transaction` object that can be inspected, signed, and serialized. /// The transaction will have empty signature placeholders that can be - /// filled in later by signing. + /// filled in later by signing via `addSignature()`. /// /// @param intent - The transaction intent as a JSON object - /// @returns Serialized transaction bytes + /// @returns Transaction object #[wasm_bindgen] - pub fn build_transaction(intent: JsValue) -> Result, JsValue> { + pub fn build_transaction(intent: JsValue) -> Result { // Deserialize the intent from JavaScript let intent: builder::TransactionIntent = serde_wasm_bindgen::from_value(intent).map_err(|e| { JsValue::from_str(&format!("Failed to parse transaction intent: {}", e)) })?; - // Build the transaction - builder::build_transaction(intent).map_err(|e| JsValue::from_str(&e.to_string())) + // Build the transaction bytes + let bytes = + builder::build_transaction(intent).map_err(|e| JsValue::from_str(&e.to_string()))?; + + // Wrap in WasmTransaction for rich API access + WasmTransaction::from_bytes(&bytes).map_err(|e| JsValue::from_str(&e.to_string())) } /// Build a versioned transaction directly from raw MessageV0 data. @@ -91,9 +96,9 @@ impl BuilderNamespace { /// ``` /// /// @param data - Raw versioned transaction data as a JSON object - /// @returns Serialized versioned transaction bytes (unsigned) + /// @returns VersionedTransaction object #[wasm_bindgen] - pub fn build_from_versioned_data(data: JsValue) -> Result, JsValue> { + pub fn build_from_versioned_data(data: JsValue) -> Result { // Deserialize the raw versioned data from JavaScript let data: builder::RawVersionedTransactionData = serde_wasm_bindgen::from_value(data) .map_err(|e| { @@ -103,7 +108,11 @@ impl BuilderNamespace { )) })?; - // Build the versioned transaction - builder::build_from_raw_versioned_data(&data).map_err(|e| JsValue::from_str(&e.to_string())) + // Build the versioned transaction bytes + let bytes = builder::build_from_raw_versioned_data(&data) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + // Wrap in WasmVersionedTransaction for rich API access + WasmVersionedTransaction::from_bytes(&bytes).map_err(|e| JsValue::from_str(&e.to_string())) } } diff --git a/packages/wasm-solana/test/builder.ts b/packages/wasm-solana/test/builder.ts index 8aaa18f..85047dd 100644 --- a/packages/wasm-solana/test/builder.ts +++ b/packages/wasm-solana/test/builder.ts @@ -25,12 +25,12 @@ describe("buildTransaction", () => { instructions: [{ type: "transfer", from: SENDER, to: RECIPIENT, lamports: 1000000n }], }; - const txBytes = buildTransaction(intent); + const tx = buildTransaction(intent); + const txBytes = tx.toBytes(); assert.ok(txBytes instanceof Uint8Array); assert.ok(txBytes.length > 0); - // Parse it back to verify structure - const parsed = parseTransaction(txBytes); + const parsed = parseTransaction(tx); assert.strictEqual(parsed.feePayer, SENDER); assert.strictEqual(parsed.nonce, BLOCKHASH); assert.strictEqual(parsed.instructionsData.length, 1); @@ -51,8 +51,8 @@ describe("buildTransaction", () => { ], }; - const txBytes = buildTransaction(intent); - const parsed = parseTransaction(txBytes); + const tx = buildTransaction(intent); + const parsed = parseTransaction(tx); const transfer = parsed.instructionsData[0]; assert.strictEqual(transfer.type, "Transfer"); @@ -76,8 +76,8 @@ describe("buildTransaction", () => { ], }; - const txBytes = buildTransaction(intent); - const parsed = parseTransaction(txBytes); + const tx = buildTransaction(intent); + const parsed = parseTransaction(tx); assert.strictEqual(parsed.instructionsData.length, 2); assert.strictEqual(parsed.instructionsData[0].type, "Transfer"); @@ -102,8 +102,8 @@ describe("buildTransaction", () => { ], }; - const txBytes = buildTransaction(intent); - const parsed = parseTransaction(txBytes); + const tx = buildTransaction(intent); + const parsed = parseTransaction(tx); assert.strictEqual(parsed.instructionsData.length, 2); assert.strictEqual(parsed.instructionsData[0].type, "SetComputeUnitLimit"); @@ -125,8 +125,8 @@ describe("buildTransaction", () => { ], }; - const txBytes = buildTransaction(intent); - const parsed = parseTransaction(txBytes); + const tx = buildTransaction(intent); + const parsed = parseTransaction(tx); assert.strictEqual(parsed.instructionsData.length, 2); assert.strictEqual(parsed.instructionsData[0].type, "SetPriorityFee"); @@ -158,8 +158,8 @@ describe("buildTransaction", () => { instructions: [{ type: "transfer", from: SENDER, to: RECIPIENT, lamports: 1000000n }], }; - const txBytes = buildTransaction(intent); - const parsed = parseTransaction(txBytes); + const tx = buildTransaction(intent); + const parsed = parseTransaction(tx); // Should have 2 instructions: NonceAdvance + Transfer assert.strictEqual(parsed.instructionsData.length, 2); @@ -197,8 +197,8 @@ describe("buildTransaction", () => { ], }; - const txBytes = buildTransaction(intent); - const parsed = parseTransaction(txBytes); + const tx = buildTransaction(intent); + const parsed = parseTransaction(tx); assert.strictEqual(parsed.instructionsData.length, 1); assert.strictEqual(parsed.instructionsData[0].type, "CreateAccount"); @@ -258,10 +258,11 @@ describe("buildTransaction", () => { ], }; - const txBytes1 = buildTransaction(intent); - const txBytes2 = buildTransaction(intent); + const tx1 = buildTransaction(intent); + const tx2 = buildTransaction(intent); - assert.deepStrictEqual(txBytes1, txBytes2); + // Compare serialized bytes since Transaction objects have different wasm pointers + assert.deepStrictEqual(tx1.toBytes(), tx2.toBytes()); }); }); @@ -284,8 +285,8 @@ describe("buildTransaction", () => { ], }; - const txBytes = buildTransaction(intent); - const parsed = parseTransaction(txBytes); + const tx = buildTransaction(intent); + const parsed = parseTransaction(tx); assert.strictEqual(parsed.instructionsData.length, 1); assert.strictEqual(parsed.instructionsData[0].type, "StakeInitialize"); @@ -312,8 +313,8 @@ describe("buildTransaction", () => { ], }; - const txBytes = buildTransaction(intent); - const parsed = parseTransaction(txBytes); + const tx = buildTransaction(intent); + const parsed = parseTransaction(tx); assert.strictEqual(parsed.instructionsData.length, 1); assert.strictEqual(parsed.instructionsData[0].type, "StakingDelegate"); @@ -339,8 +340,8 @@ describe("buildTransaction", () => { ], }; - const txBytes = buildTransaction(intent); - const parsed = parseTransaction(txBytes); + const tx = buildTransaction(intent); + const parsed = parseTransaction(tx); assert.strictEqual(parsed.instructionsData.length, 1); assert.strictEqual(parsed.instructionsData[0].type, "StakingDeactivate"); @@ -367,8 +368,8 @@ describe("buildTransaction", () => { ], }; - const txBytes = buildTransaction(intent); - const parsed = parseTransaction(txBytes); + const tx = buildTransaction(intent); + const parsed = parseTransaction(tx); assert.strictEqual(parsed.instructionsData.length, 1); assert.strictEqual(parsed.instructionsData[0].type, "StakingWithdraw"); @@ -413,8 +414,8 @@ describe("buildTransaction", () => { ], }; - const txBytes = buildTransaction(intent); - const parsed = parseTransaction(txBytes); + const tx = buildTransaction(intent); + const parsed = parseTransaction(tx); // Parser returns individual instructions; combining is done in BitGoJS wasmInstructionCombiner assert.strictEqual(parsed.instructionsData.length, 3); @@ -463,8 +464,8 @@ describe("buildTransaction", () => { ], }; - const txBytes = buildTransaction(intent); - const parsed = parseTransaction(txBytes); + const tx = buildTransaction(intent); + const parsed = parseTransaction(tx); assert.strictEqual(parsed.instructionsData.length, 1); assert.strictEqual(parsed.instructionsData[0].type, "TokenTransfer"); @@ -493,8 +494,8 @@ describe("buildTransaction", () => { ], }; - const txBytes = buildTransaction(intent); - const parsed = parseTransaction(txBytes); + const tx = buildTransaction(intent); + const parsed = parseTransaction(tx); assert.strictEqual(parsed.instructionsData.length, 1); assert.strictEqual(parsed.instructionsData[0].type, "CreateAssociatedTokenAccount"); @@ -521,8 +522,8 @@ describe("buildTransaction", () => { ], }; - const txBytes = buildTransaction(intent); - const parsed = parseTransaction(txBytes); + const tx = buildTransaction(intent); + const parsed = parseTransaction(tx); assert.strictEqual(parsed.instructionsData.length, 1); assert.strictEqual(parsed.instructionsData[0].type, "CloseAssociatedTokenAccount"); @@ -559,8 +560,8 @@ describe("buildTransaction", () => { ], }; - const txBytes = buildTransaction(intent); - const parsed = parseTransaction(txBytes); + const tx = buildTransaction(intent); + const parsed = parseTransaction(tx); assert.strictEqual(parsed.instructionsData.length, 3); assert.strictEqual(parsed.instructionsData[0].type, "CreateAssociatedTokenAccount"); @@ -602,8 +603,8 @@ describe("buildTransaction", () => { ], }; - const txBytes = buildTransaction(intent); - const parsed = parseTransaction(txBytes); + const tx = buildTransaction(intent); + const parsed = parseTransaction(tx); assert.strictEqual(parsed.instructionsData.length, 1); assert.strictEqual(parsed.instructionsData[0].type, "StakePoolDepositSol"); @@ -639,8 +640,8 @@ describe("buildTransaction", () => { ], }; - const txBytes = buildTransaction(intent); - const parsed = parseTransaction(txBytes); + const tx = buildTransaction(intent); + const parsed = parseTransaction(tx); assert.strictEqual(parsed.instructionsData.length, 1); assert.strictEqual(parsed.instructionsData[0].type, "StakePoolWithdrawStake"); @@ -682,8 +683,8 @@ describe("buildTransaction", () => { ], }; - const txBytes = buildTransaction(intent); - const parsed = parseTransaction(txBytes); + const tx = buildTransaction(intent); + const parsed = parseTransaction(tx); assert.strictEqual(parsed.instructionsData.length, 2); assert.strictEqual(parsed.instructionsData[0].type, "CreateAssociatedTokenAccount");