From a585a2215bf904e5dd86343238e5890ec5ca45ff Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Sun, 15 Mar 2026 20:46:46 +0000 Subject: [PATCH 1/3] feat(transaction): expose to_bytes() and from_bytes() for consensus serialization Add Transaction.to_bytes() and Transaction.from_bytes() methods that serialize/deserialize to consensus-encoded bytes (BIP144 format). This enables passing transactions between WASM modules (e.g. BDK -> LDK), broadcasting via external APIs, and storing raw transactions. wasm_bindgen automatically converts Vec <-> Uint8Array for the JS API. Closes #38 --- src/types/transaction.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/types/transaction.rs b/src/types/transaction.rs index ec9345b..5a255d7 100644 --- a/src/types/transaction.rs +++ b/src/types/transaction.rs @@ -1,6 +1,8 @@ use std::{ops::Deref, str::FromStr}; use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsError; +use bdk_wallet::bitcoin::consensus::{deserialize, serialize}; use bdk_wallet::bitcoin::{Transaction as BdkTransaction, Txid as BdkTxid}; use crate::result::JsResult; @@ -118,6 +120,24 @@ impl Transaction { Ok(output.into()) } + /// Serialize the transaction to consensus-encoded bytes (BIP144 format). + /// + /// Returns the raw transaction as a `Uint8Array` suitable for broadcasting, + /// passing to other WASM modules (e.g. LDK), or storing in IndexedDB. + pub fn to_bytes(&self) -> Vec { + serialize(&self.0) + } + + /// Deserialize a transaction from consensus-encoded bytes. + /// + /// Accepts a `Uint8Array` of raw transaction bytes (as produced by `to_bytes()` + /// or any standard Bitcoin serializer). + pub fn from_bytes(bytes: &[u8]) -> JsResult { + let tx: BdkTransaction = + deserialize(bytes).map_err(|e| JsError::new(&format!("Failed to deserialize transaction: {e}")))?; + Ok(Transaction(tx)) + } + #[wasm_bindgen(js_name = clone)] pub fn js_clone(&self) -> Transaction { self.clone() From efb531e68104e72f27187257f87ad82a0b4341d4 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Mon, 16 Mar 2026 10:33:54 +0000 Subject: [PATCH 2/3] test(transaction): add tests for to_bytes() and from_bytes() - Round-trip serialization with known mainnet transactions - Coinbase tx (block 170) and Satoshi's first spend - Error cases: empty bytes, truncated, garbage input - Property verification after deserialization - Multiple round-trip stability test --- tests/node/integration/transaction.test.ts | 194 +++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 tests/node/integration/transaction.test.ts diff --git a/tests/node/integration/transaction.test.ts b/tests/node/integration/transaction.test.ts new file mode 100644 index 0000000..63c5831 --- /dev/null +++ b/tests/node/integration/transaction.test.ts @@ -0,0 +1,194 @@ +import { + BdkError, + Transaction, + Txid, +} from "../../../pkg/bitcoindevkit"; + +/** + * Tests for Transaction.to_bytes() and Transaction.from_bytes() + * + * Uses a known mainnet coinbase transaction (block 170, first block with a + * non-coinbase tx) serialized as raw consensus bytes. + */ +describe("Transaction serialization", () => { + // Raw consensus-encoded bytes of the coinbase tx from block 170 + // txid: b1fea52486ce0c62bb442b530a3f0132b826c74e473d1f2c220bfa78111c5082 + const COINBASE_TX_HEX = + "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff0704ffff001d0102ffffffff0100f2052a0100000043410496b538e853519c726a2c91e61ec11600ae1390813a627c66fb8be7947be63c52da7589379515d4e0a604f8141781e62294721166bf621e73a82cbf2342c858eeac00000000"; + + // Known txid of the above transaction + const COINBASE_TXID = + "b1fea52486ce0c62bb442b530a3f0132b826c74e473d1f2c220bfa78111c5082"; + + // Satoshi's famous first spend (block 170) + // txid: f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16 + const FIRST_SPEND_TX_HEX = + "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000"; + + const FIRST_SPEND_TXID = + "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"; + + function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; + } + + function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } + + describe("from_bytes", () => { + it("deserializes a valid coinbase transaction", () => { + const txBytes = hexToBytes(COINBASE_TX_HEX); + const tx = Transaction.from_bytes(txBytes); + + expect(tx).toBeDefined(); + expect(tx.compute_txid().toString()).toBe(COINBASE_TXID); + expect(tx.is_coinbase).toBe(true); + }); + + it("deserializes a valid non-coinbase transaction", () => { + const txBytes = hexToBytes(FIRST_SPEND_TX_HEX); + const tx = Transaction.from_bytes(txBytes); + + expect(tx).toBeDefined(); + expect(tx.compute_txid().toString()).toBe(FIRST_SPEND_TXID); + expect(tx.is_coinbase).toBe(false); + expect(tx.input.length).toBe(1); + expect(tx.output.length).toBe(2); + }); + + it("throws on empty bytes", () => { + expect(() => { + Transaction.from_bytes(new Uint8Array(0)); + }).toThrow("Failed to deserialize transaction"); + }); + + it("throws on truncated bytes", () => { + const txBytes = hexToBytes(COINBASE_TX_HEX); + const truncated = txBytes.slice(0, 20); + + expect(() => { + Transaction.from_bytes(truncated); + }).toThrow("Failed to deserialize transaction"); + }); + + it("throws on random garbage bytes", () => { + const garbage = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); + + expect(() => { + Transaction.from_bytes(garbage); + }).toThrow("Failed to deserialize transaction"); + }); + + it("throws on single zero byte", () => { + expect(() => { + Transaction.from_bytes(new Uint8Array([0x00])); + }).toThrow("Failed to deserialize transaction"); + }); + }); + + describe("to_bytes", () => { + it("serializes a coinbase transaction to correct bytes", () => { + const originalBytes = hexToBytes(COINBASE_TX_HEX); + const tx = Transaction.from_bytes(originalBytes); + const serialized = tx.to_bytes(); + + expect(bytesToHex(serialized)).toBe(COINBASE_TX_HEX); + }); + + it("serializes a non-coinbase transaction to correct bytes", () => { + const originalBytes = hexToBytes(FIRST_SPEND_TX_HEX); + const tx = Transaction.from_bytes(originalBytes); + const serialized = tx.to_bytes(); + + expect(bytesToHex(serialized)).toBe(FIRST_SPEND_TX_HEX); + }); + + it("returns a Uint8Array", () => { + const txBytes = hexToBytes(COINBASE_TX_HEX); + const tx = Transaction.from_bytes(txBytes); + const serialized = tx.to_bytes(); + + expect(serialized).toBeInstanceOf(Uint8Array); + expect(serialized.length).toBeGreaterThan(0); + }); + }); + + describe("round-trip", () => { + it("round-trips a coinbase transaction", () => { + const originalBytes = hexToBytes(COINBASE_TX_HEX); + const tx1 = Transaction.from_bytes(originalBytes); + const serialized = tx1.to_bytes(); + const tx2 = Transaction.from_bytes(serialized); + + expect(tx2.compute_txid().toString()).toBe(tx1.compute_txid().toString()); + expect(tx2.is_coinbase).toBe(tx1.is_coinbase); + expect(tx2.input.length).toBe(tx1.input.length); + expect(tx2.output.length).toBe(tx1.output.length); + expect(tx2.total_size).toBe(tx1.total_size); + expect(tx2.vsize).toBe(tx1.vsize); + }); + + it("round-trips a non-coinbase transaction", () => { + const originalBytes = hexToBytes(FIRST_SPEND_TX_HEX); + const tx1 = Transaction.from_bytes(originalBytes); + const serialized = tx1.to_bytes(); + const tx2 = Transaction.from_bytes(serialized); + + expect(tx2.compute_txid().toString()).toBe(tx1.compute_txid().toString()); + expect(tx2.is_coinbase).toBe(tx1.is_coinbase); + expect(tx2.input.length).toBe(tx1.input.length); + expect(tx2.output.length).toBe(tx1.output.length); + expect(tx2.total_size).toBe(tx1.total_size); + expect(tx2.vsize).toBe(tx1.vsize); + expect(tx2.base_size).toBe(tx1.base_size); + }); + + it("preserves txid through multiple round-trips", () => { + const originalBytes = hexToBytes(FIRST_SPEND_TX_HEX); + let tx = Transaction.from_bytes(originalBytes); + + // Round-trip 3 times + for (let i = 0; i < 3; i++) { + const bytes = tx.to_bytes(); + tx = Transaction.from_bytes(bytes); + } + + expect(tx.compute_txid().toString()).toBe(FIRST_SPEND_TXID); + expect(bytesToHex(tx.to_bytes())).toBe(FIRST_SPEND_TX_HEX); + }); + }); + + describe("properties after deserialization", () => { + it("exposes correct properties on coinbase tx", () => { + const tx = Transaction.from_bytes(hexToBytes(COINBASE_TX_HEX)); + + expect(tx.is_coinbase).toBe(true); + expect(tx.is_explicitly_rbf).toBe(false); + expect(tx.input.length).toBe(1); + expect(tx.output.length).toBe(1); + expect(tx.total_size).toBeGreaterThan(0); + expect(tx.base_size).toBeGreaterThan(0); + expect(tx.vsize).toBeGreaterThan(0); + }); + + it("exposes correct properties on first-spend tx", () => { + const tx = Transaction.from_bytes(hexToBytes(FIRST_SPEND_TX_HEX)); + + expect(tx.is_coinbase).toBe(false); + expect(tx.input.length).toBe(1); + expect(tx.output.length).toBe(2); + + // First output: 10 BTC (1_000_000_000 sats) + expect(tx.tx_out(0).value.to_sat()).toBe(BigInt(1000000000)); + // Second output: 40 BTC (4_000_000_000 sats) + expect(tx.tx_out(1).value.to_sat()).toBe(BigInt(4000000000)); + }); + }); +}); From cc19ecc3c0c2d816d87b9cccd3dd724d53810d00 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Mon, 16 Mar 2026 10:45:54 +0000 Subject: [PATCH 3/3] revert: remove transaction serialization tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review — the implementation is trivial (thin wrapper around bitcoin::consensus::{serialize,deserialize}), tests add more maintenance burden than value. --- tests/node/integration/transaction.test.ts | 194 --------------------- 1 file changed, 194 deletions(-) delete mode 100644 tests/node/integration/transaction.test.ts diff --git a/tests/node/integration/transaction.test.ts b/tests/node/integration/transaction.test.ts deleted file mode 100644 index 63c5831..0000000 --- a/tests/node/integration/transaction.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { - BdkError, - Transaction, - Txid, -} from "../../../pkg/bitcoindevkit"; - -/** - * Tests for Transaction.to_bytes() and Transaction.from_bytes() - * - * Uses a known mainnet coinbase transaction (block 170, first block with a - * non-coinbase tx) serialized as raw consensus bytes. - */ -describe("Transaction serialization", () => { - // Raw consensus-encoded bytes of the coinbase tx from block 170 - // txid: b1fea52486ce0c62bb442b530a3f0132b826c74e473d1f2c220bfa78111c5082 - const COINBASE_TX_HEX = - "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff0704ffff001d0102ffffffff0100f2052a0100000043410496b538e853519c726a2c91e61ec11600ae1390813a627c66fb8be7947be63c52da7589379515d4e0a604f8141781e62294721166bf621e73a82cbf2342c858eeac00000000"; - - // Known txid of the above transaction - const COINBASE_TXID = - "b1fea52486ce0c62bb442b530a3f0132b826c74e473d1f2c220bfa78111c5082"; - - // Satoshi's famous first spend (block 170) - // txid: f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16 - const FIRST_SPEND_TX_HEX = - "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000"; - - const FIRST_SPEND_TXID = - "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16"; - - function hexToBytes(hex: string): Uint8Array { - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); - } - return bytes; - } - - function bytesToHex(bytes: Uint8Array): string { - return Array.from(bytes) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - } - - describe("from_bytes", () => { - it("deserializes a valid coinbase transaction", () => { - const txBytes = hexToBytes(COINBASE_TX_HEX); - const tx = Transaction.from_bytes(txBytes); - - expect(tx).toBeDefined(); - expect(tx.compute_txid().toString()).toBe(COINBASE_TXID); - expect(tx.is_coinbase).toBe(true); - }); - - it("deserializes a valid non-coinbase transaction", () => { - const txBytes = hexToBytes(FIRST_SPEND_TX_HEX); - const tx = Transaction.from_bytes(txBytes); - - expect(tx).toBeDefined(); - expect(tx.compute_txid().toString()).toBe(FIRST_SPEND_TXID); - expect(tx.is_coinbase).toBe(false); - expect(tx.input.length).toBe(1); - expect(tx.output.length).toBe(2); - }); - - it("throws on empty bytes", () => { - expect(() => { - Transaction.from_bytes(new Uint8Array(0)); - }).toThrow("Failed to deserialize transaction"); - }); - - it("throws on truncated bytes", () => { - const txBytes = hexToBytes(COINBASE_TX_HEX); - const truncated = txBytes.slice(0, 20); - - expect(() => { - Transaction.from_bytes(truncated); - }).toThrow("Failed to deserialize transaction"); - }); - - it("throws on random garbage bytes", () => { - const garbage = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); - - expect(() => { - Transaction.from_bytes(garbage); - }).toThrow("Failed to deserialize transaction"); - }); - - it("throws on single zero byte", () => { - expect(() => { - Transaction.from_bytes(new Uint8Array([0x00])); - }).toThrow("Failed to deserialize transaction"); - }); - }); - - describe("to_bytes", () => { - it("serializes a coinbase transaction to correct bytes", () => { - const originalBytes = hexToBytes(COINBASE_TX_HEX); - const tx = Transaction.from_bytes(originalBytes); - const serialized = tx.to_bytes(); - - expect(bytesToHex(serialized)).toBe(COINBASE_TX_HEX); - }); - - it("serializes a non-coinbase transaction to correct bytes", () => { - const originalBytes = hexToBytes(FIRST_SPEND_TX_HEX); - const tx = Transaction.from_bytes(originalBytes); - const serialized = tx.to_bytes(); - - expect(bytesToHex(serialized)).toBe(FIRST_SPEND_TX_HEX); - }); - - it("returns a Uint8Array", () => { - const txBytes = hexToBytes(COINBASE_TX_HEX); - const tx = Transaction.from_bytes(txBytes); - const serialized = tx.to_bytes(); - - expect(serialized).toBeInstanceOf(Uint8Array); - expect(serialized.length).toBeGreaterThan(0); - }); - }); - - describe("round-trip", () => { - it("round-trips a coinbase transaction", () => { - const originalBytes = hexToBytes(COINBASE_TX_HEX); - const tx1 = Transaction.from_bytes(originalBytes); - const serialized = tx1.to_bytes(); - const tx2 = Transaction.from_bytes(serialized); - - expect(tx2.compute_txid().toString()).toBe(tx1.compute_txid().toString()); - expect(tx2.is_coinbase).toBe(tx1.is_coinbase); - expect(tx2.input.length).toBe(tx1.input.length); - expect(tx2.output.length).toBe(tx1.output.length); - expect(tx2.total_size).toBe(tx1.total_size); - expect(tx2.vsize).toBe(tx1.vsize); - }); - - it("round-trips a non-coinbase transaction", () => { - const originalBytes = hexToBytes(FIRST_SPEND_TX_HEX); - const tx1 = Transaction.from_bytes(originalBytes); - const serialized = tx1.to_bytes(); - const tx2 = Transaction.from_bytes(serialized); - - expect(tx2.compute_txid().toString()).toBe(tx1.compute_txid().toString()); - expect(tx2.is_coinbase).toBe(tx1.is_coinbase); - expect(tx2.input.length).toBe(tx1.input.length); - expect(tx2.output.length).toBe(tx1.output.length); - expect(tx2.total_size).toBe(tx1.total_size); - expect(tx2.vsize).toBe(tx1.vsize); - expect(tx2.base_size).toBe(tx1.base_size); - }); - - it("preserves txid through multiple round-trips", () => { - const originalBytes = hexToBytes(FIRST_SPEND_TX_HEX); - let tx = Transaction.from_bytes(originalBytes); - - // Round-trip 3 times - for (let i = 0; i < 3; i++) { - const bytes = tx.to_bytes(); - tx = Transaction.from_bytes(bytes); - } - - expect(tx.compute_txid().toString()).toBe(FIRST_SPEND_TXID); - expect(bytesToHex(tx.to_bytes())).toBe(FIRST_SPEND_TX_HEX); - }); - }); - - describe("properties after deserialization", () => { - it("exposes correct properties on coinbase tx", () => { - const tx = Transaction.from_bytes(hexToBytes(COINBASE_TX_HEX)); - - expect(tx.is_coinbase).toBe(true); - expect(tx.is_explicitly_rbf).toBe(false); - expect(tx.input.length).toBe(1); - expect(tx.output.length).toBe(1); - expect(tx.total_size).toBeGreaterThan(0); - expect(tx.base_size).toBeGreaterThan(0); - expect(tx.vsize).toBeGreaterThan(0); - }); - - it("exposes correct properties on first-spend tx", () => { - const tx = Transaction.from_bytes(hexToBytes(FIRST_SPEND_TX_HEX)); - - expect(tx.is_coinbase).toBe(false); - expect(tx.input.length).toBe(1); - expect(tx.output.length).toBe(2); - - // First output: 10 BTC (1_000_000_000 sats) - expect(tx.tx_out(0).value.to_sat()).toBe(BigInt(1000000000)); - // Second output: 40 BTC (4_000_000_000 sats) - expect(tx.tx_out(1).value.to_sat()).toBe(BigInt(4000000000)); - }); - }); -});