Skip to content

Commit 8c472ee

Browse files
Merge pull request #177 from BitGo/BTC-3047.add-build-tx
feat(wasm-utxo): add transaction builder API to WASM layer
2 parents 1d4e530 + 2bb3069 commit 8c472ee

9 files changed

Lines changed: 240 additions & 3 deletions

File tree

packages/wasm-utxo/.mocharc.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"extensions": ["ts", "tsx", "js", "jsx"],
3-
"spec": ["test/**/*.ts"],
43
"ignore": ["test/benchmark/**"],
54
"node-option": ["import=tsx/esm", "experimental-wasm-modules"]
65
}

packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,7 @@ export class BitGoPsbt implements IPsbtIntrospectionWithAddress {
403403
inputOptions.vout,
404404
inputOptions.value,
405405
inputOptions.sequence,
406+
inputOptions.prevTx,
406407
);
407408
}
408409

packages/wasm-utxo/js/fixedScriptWallet/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,13 @@ export function supportsScriptType(coin: CoinName, scriptType: ScriptType): bool
9898
export function createOpReturnScript(data?: Uint8Array): Uint8Array {
9999
return FixedScriptWalletNamespace.create_op_return_script(data);
100100
}
101+
102+
/**
103+
* Get the P2SH-P2PK output script for a compressed public key
104+
*
105+
* @param pubkey - The compressed public key bytes (33 bytes)
106+
* @returns The P2SH-P2PK output script as a Uint8Array
107+
*/
108+
export function p2shP2pkOutputScript(pubkey: Uint8Array): Uint8Array {
109+
return FixedScriptWalletNamespace.p2sh_p2pk_output_script(pubkey);
110+
}

packages/wasm-utxo/js/transaction.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ export interface ITransaction {
1616
export class Transaction implements ITransaction {
1717
private constructor(private _wasm: WasmTransaction) {}
1818

19+
/**
20+
* Create an empty transaction (version 1, locktime 0)
21+
*/
22+
static create(): Transaction {
23+
return new Transaction(WasmTransaction.create());
24+
}
25+
1926
static fromBytes(bytes: Uint8Array): Transaction {
2027
return new Transaction(WasmTransaction.from_bytes(bytes));
2128
}
@@ -27,6 +34,27 @@ export class Transaction implements ITransaction {
2734
return new Transaction(wasm);
2835
}
2936

37+
/**
38+
* Add an input to the transaction
39+
* @param txid - Previous transaction ID (hex string)
40+
* @param vout - Output index being spent
41+
* @param sequence - Optional sequence number (default: 0xFFFFFFFF)
42+
* @returns The index of the newly added input
43+
*/
44+
addInput(txid: string, vout: number, sequence?: number): number {
45+
return this._wasm.add_input(txid, vout, sequence);
46+
}
47+
48+
/**
49+
* Add an output to the transaction
50+
* @param script - Output script (scriptPubKey)
51+
* @param value - Value in satoshis
52+
* @returns The index of the newly added output
53+
*/
54+
addOutput(script: Uint8Array, value: bigint): number {
55+
return this._wasm.add_output(script, value);
56+
}
57+
3058
toBytes(): Uint8Array {
3159
return this._wasm.to_bytes();
3260
}

packages/wasm-utxo/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
],
4545
"scripts": {
4646
"test": "npm run test:mocha && npm run test:wasm-pack && npm run test:imports",
47-
"test:mocha": "mocha --recursive test",
47+
"test:mocha": "mocha --recursive 'test/**/*.ts'",
4848
"test:benchmark": "mocha test/benchmark/signing.ts --timeout 600000",
4949
"test:wasm-pack": "npm run test:wasm-pack-node && npm run test:wasm-pack-chrome",
5050
"test:wasm-pack-node": "./scripts/wasm-pack-test.sh --node",

packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,22 @@ impl FixedScriptWalletNamespace {
180180
Ok(builder.into_script().to_bytes())
181181
}
182182

183+
/// Get the P2SH-P2PK output script for a compressed public key
184+
///
185+
/// # Arguments
186+
/// * `pubkey` - The compressed public key bytes (33 bytes)
187+
///
188+
/// # Returns
189+
/// The P2SH-P2PK output script as bytes
190+
#[wasm_bindgen]
191+
pub fn p2sh_p2pk_output_script(pubkey: &[u8]) -> Result<Vec<u8>, WasmUtxoError> {
192+
use crate::fixed_script_wallet::wallet_scripts::ScriptP2shP2pk;
193+
use miniscript::bitcoin::CompressedPublicKey;
194+
let pubkey = CompressedPublicKey::from_slice(pubkey)
195+
.map_err(|e| WasmUtxoError::new(&format!("Invalid pubkey: {}", e)))?;
196+
Ok(ScriptP2shP2pk::new(pubkey).output_script().into_bytes())
197+
}
198+
183199
/// Get all chain code metadata for building TypeScript lookup tables
184200
///
185201
/// Returns an array of [chainCode, scriptType, scope] tuples where:
@@ -550,6 +566,7 @@ impl BitGoPsbt {
550566
vout: u32,
551567
value: u64,
552568
sequence: Option<u32>,
569+
prev_tx: Option<Vec<u8>>,
553570
) -> Result<usize, WasmUtxoError> {
554571
use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::ReplayProtectionOptions;
555572
use miniscript::bitcoin::{CompressedPublicKey, Txid};
@@ -567,7 +584,7 @@ impl BitGoPsbt {
567584
let options = ReplayProtectionOptions {
568585
sequence,
569586
sighash_type: None,
570-
prev_tx: None,
587+
prev_tx: prev_tx.as_deref(),
571588
};
572589

573590
Ok(self

packages/wasm-utxo/src/wasm/transaction.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,64 @@ impl WasmTransaction {
2121

2222
#[wasm_bindgen]
2323
impl WasmTransaction {
24+
/// Create an empty transaction (version 1, locktime 0)
25+
pub fn create() -> WasmTransaction {
26+
use miniscript::bitcoin::{absolute::LockTime, transaction::Version, Transaction};
27+
WasmTransaction {
28+
tx: Transaction {
29+
version: Version::ONE,
30+
lock_time: LockTime::ZERO,
31+
input: vec![],
32+
output: vec![],
33+
},
34+
}
35+
}
36+
37+
/// Add an input to the transaction
38+
///
39+
/// # Arguments
40+
/// * `txid` - The transaction ID (hex string) of the output being spent
41+
/// * `vout` - The output index being spent
42+
/// * `sequence` - Optional sequence number (default: 0xFFFFFFFF)
43+
///
44+
/// # Returns
45+
/// The index of the newly added input
46+
pub fn add_input(
47+
&mut self,
48+
txid: &str,
49+
vout: u32,
50+
sequence: Option<u32>,
51+
) -> Result<usize, WasmUtxoError> {
52+
use miniscript::bitcoin::{transaction::Sequence, OutPoint, ScriptBuf, TxIn, Txid};
53+
use std::str::FromStr;
54+
let txid = Txid::from_str(txid)
55+
.map_err(|e| WasmUtxoError::new(&format!("Invalid txid: {}", e)))?;
56+
self.tx.input.push(TxIn {
57+
previous_output: OutPoint { txid, vout },
58+
script_sig: ScriptBuf::new(),
59+
sequence: sequence.map(Sequence).unwrap_or(Sequence::MAX),
60+
witness: Default::default(),
61+
});
62+
Ok(self.tx.input.len() - 1)
63+
}
64+
65+
/// Add an output to the transaction
66+
///
67+
/// # Arguments
68+
/// * `script` - The output script (scriptPubKey)
69+
/// * `value` - The value in satoshis
70+
///
71+
/// # Returns
72+
/// The index of the newly added output
73+
pub fn add_output(&mut self, script: &[u8], value: u64) -> usize {
74+
use miniscript::bitcoin::{Amount, ScriptBuf, TxOut};
75+
self.tx.output.push(TxOut {
76+
value: Amount::from_sat(value),
77+
script_pubkey: ScriptBuf::from(script.to_vec()),
78+
});
79+
self.tx.output.len() - 1
80+
}
81+
2482
/// Deserialize a transaction from bytes
2583
///
2684
/// # Arguments
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import assert from "node:assert";
2+
import { fixedScriptWallet } from "../../js/index.js";
3+
4+
// Compressed public key for private key 0x01 * 32
5+
const PUBKEY = Buffer.from(
6+
"031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f",
7+
"hex",
8+
);
9+
10+
describe("p2shP2pkOutputScript", function () {
11+
it("should produce expected P2SH output script", function () {
12+
const script = fixedScriptWallet.p2shP2pkOutputScript(PUBKEY);
13+
14+
// P2SH output scripts are always 23 bytes: OP_HASH160 <20-byte-hash> OP_EQUAL
15+
assert.strictEqual(script.length, 23);
16+
assert.strictEqual(script[0], 0xa9);
17+
assert.strictEqual(script[1], 0x14);
18+
assert.strictEqual(script[22], 0x87);
19+
20+
assert.strictEqual(
21+
Buffer.from(script).toString("hex"),
22+
"a9140c79ca26388c7130abaa079b1968288911d3677387",
23+
);
24+
});
25+
26+
it("should produce different scripts for different keys", function () {
27+
const otherPubkey = Buffer.from(
28+
"024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766",
29+
"hex",
30+
);
31+
const script1 = fixedScriptWallet.p2shP2pkOutputScript(PUBKEY);
32+
const script2 = fixedScriptWallet.p2shP2pkOutputScript(otherPubkey);
33+
assert.notDeepStrictEqual(script1, script2);
34+
});
35+
36+
it("should reject an invalid public key", function () {
37+
assert.throws(() => fixedScriptWallet.p2shP2pkOutputScript(new Uint8Array(32)));
38+
});
39+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import assert from "node:assert";
2+
import { Transaction } from "../js/transaction.js";
3+
import { fixedScriptWallet } from "../js/index.js";
4+
5+
describe("Transaction builder", function () {
6+
it("should create an empty transaction", function () {
7+
const tx = Transaction.create();
8+
const bytes = tx.toBytes();
9+
assert.ok(bytes.length > 0, "serialized transaction should not be empty");
10+
11+
// Round-trip: the deserialized transaction should produce the same bytes
12+
const tx2 = Transaction.fromBytes(bytes);
13+
assert.deepStrictEqual(tx2.toBytes(), bytes);
14+
});
15+
16+
it("should add an input and return index 0", function () {
17+
const tx = Transaction.create();
18+
const txid = "a".repeat(64);
19+
const idx = tx.addInput(txid, 0);
20+
assert.strictEqual(idx, 0);
21+
});
22+
23+
it("should add multiple inputs with incrementing indices", function () {
24+
const tx = Transaction.create();
25+
const txid = "b".repeat(64);
26+
assert.strictEqual(tx.addInput(txid, 0), 0);
27+
assert.strictEqual(tx.addInput(txid, 1), 1);
28+
assert.strictEqual(tx.addInput(txid, 2), 2);
29+
});
30+
31+
it("should add an output and return index 0", function () {
32+
const tx = Transaction.create();
33+
// OP_RETURN script
34+
const script = fixedScriptWallet.createOpReturnScript();
35+
const idx = tx.addOutput(script, 0n);
36+
assert.strictEqual(idx, 0);
37+
});
38+
39+
it("should add multiple outputs with incrementing indices", function () {
40+
const tx = Transaction.create();
41+
const script = fixedScriptWallet.createOpReturnScript();
42+
assert.strictEqual(tx.addOutput(script, 1000n), 0);
43+
assert.strictEqual(tx.addOutput(script, 2000n), 1);
44+
});
45+
46+
it("should round-trip a transaction with inputs and outputs", function () {
47+
const tx = Transaction.create();
48+
const txid = "c".repeat(64);
49+
tx.addInput(txid, 0);
50+
tx.addInput(txid, 1, 0xfffffffe);
51+
52+
const script = fixedScriptWallet.createOpReturnScript(new Uint8Array([0xde, 0xad]));
53+
tx.addOutput(script, 50000n);
54+
55+
const bytes = tx.toBytes();
56+
const tx2 = Transaction.fromBytes(bytes);
57+
assert.deepStrictEqual(tx2.toBytes(), bytes);
58+
assert.strictEqual(tx2.getId(), tx.getId());
59+
assert.strictEqual(tx2.getVSize(), tx.getVSize());
60+
});
61+
62+
it("should produce a valid txid", function () {
63+
const tx = Transaction.create();
64+
tx.addInput("a".repeat(64), 0);
65+
tx.addOutput(fixedScriptWallet.createOpReturnScript(), 0n);
66+
const txid = tx.getId();
67+
assert.strictEqual(txid.length, 64);
68+
assert.match(txid, /^[0-9a-f]{64}$/);
69+
});
70+
71+
it("should reject an invalid txid", function () {
72+
const tx = Transaction.create();
73+
assert.throws(() => tx.addInput("not-a-valid-txid", 0));
74+
});
75+
76+
it("should accept custom sequence number", function () {
77+
const tx = Transaction.create();
78+
const txid = "d".repeat(64);
79+
tx.addInput(txid, 0, 0);
80+
// If we can round-trip it, the sequence was accepted
81+
const bytes = tx.toBytes();
82+
const tx2 = Transaction.fromBytes(bytes);
83+
assert.deepStrictEqual(tx2.toBytes(), bytes);
84+
});
85+
});

0 commit comments

Comments
 (0)