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
1 change: 0 additions & 1 deletion packages/wasm-utxo/.mocharc.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
{
"extensions": ["ts", "tsx", "js", "jsx"],
"spec": ["test/**/*.ts"],
"ignore": ["test/benchmark/**"],
"node-option": ["import=tsx/esm", "experimental-wasm-modules"]
}
1 change: 1 addition & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ export class BitGoPsbt implements IPsbtIntrospectionWithAddress {
inputOptions.vout,
inputOptions.value,
inputOptions.sequence,
inputOptions.prevTx,
);
}

Expand Down
10 changes: 10 additions & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,13 @@ export function supportsScriptType(coin: CoinName, scriptType: ScriptType): bool
export function createOpReturnScript(data?: Uint8Array): Uint8Array {
return FixedScriptWalletNamespace.create_op_return_script(data);
}

/**
* Get the P2SH-P2PK output script for a compressed public key
*
* @param pubkey - The compressed public key bytes (33 bytes)
* @returns The P2SH-P2PK output script as a Uint8Array
*/
export function p2shP2pkOutputScript(pubkey: Uint8Array): Uint8Array {
return FixedScriptWalletNamespace.p2sh_p2pk_output_script(pubkey);
}
28 changes: 28 additions & 0 deletions packages/wasm-utxo/js/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export interface ITransaction {
export class Transaction implements ITransaction {
private constructor(private _wasm: WasmTransaction) {}

/**
* Create an empty transaction (version 1, locktime 0)
*/
static create(): Transaction {
return new Transaction(WasmTransaction.create());
}

static fromBytes(bytes: Uint8Array): Transaction {
return new Transaction(WasmTransaction.from_bytes(bytes));
}
Expand All @@ -27,6 +34,27 @@ export class Transaction implements ITransaction {
return new Transaction(wasm);
}

/**
* Add an input to the transaction
* @param txid - Previous transaction ID (hex string)
* @param vout - Output index being spent
* @param sequence - Optional sequence number (default: 0xFFFFFFFF)
* @returns The index of the newly added input
*/
addInput(txid: string, vout: number, sequence?: number): number {
return this._wasm.add_input(txid, vout, sequence);
}

/**
* Add an output to the transaction
* @param script - Output script (scriptPubKey)
* @param value - Value in satoshis
* @returns The index of the newly added output
*/
addOutput(script: Uint8Array, value: bigint): number {
return this._wasm.add_output(script, value);
}

toBytes(): Uint8Array {
return this._wasm.to_bytes();
}
Expand Down
2 changes: 1 addition & 1 deletion packages/wasm-utxo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
],
"scripts": {
"test": "npm run test:mocha && npm run test:wasm-pack && npm run test:imports",
"test:mocha": "mocha --recursive test",
"test:mocha": "mocha --recursive 'test/**/*.ts'",
"test:benchmark": "mocha test/benchmark/signing.ts --timeout 600000",
"test:wasm-pack": "npm run test:wasm-pack-node && npm run test:wasm-pack-chrome",
"test:wasm-pack-node": "./scripts/wasm-pack-test.sh --node",
Expand Down
19 changes: 18 additions & 1 deletion packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,22 @@ impl FixedScriptWalletNamespace {
Ok(builder.into_script().to_bytes())
}

/// Get the P2SH-P2PK output script for a compressed public key
///
/// # Arguments
/// * `pubkey` - The compressed public key bytes (33 bytes)
///
/// # Returns
/// The P2SH-P2PK output script as bytes
#[wasm_bindgen]
pub fn p2sh_p2pk_output_script(pubkey: &[u8]) -> Result<Vec<u8>, WasmUtxoError> {
use crate::fixed_script_wallet::wallet_scripts::ScriptP2shP2pk;
use miniscript::bitcoin::CompressedPublicKey;
let pubkey = CompressedPublicKey::from_slice(pubkey)
.map_err(|e| WasmUtxoError::new(&format!("Invalid pubkey: {}", e)))?;
Ok(ScriptP2shP2pk::new(pubkey).output_script().into_bytes())
}

/// Get all chain code metadata for building TypeScript lookup tables
///
/// Returns an array of [chainCode, scriptType, scope] tuples where:
Expand Down Expand Up @@ -550,6 +566,7 @@ impl BitGoPsbt {
vout: u32,
value: u64,
sequence: Option<u32>,
prev_tx: Option<Vec<u8>>,
) -> Result<usize, WasmUtxoError> {
use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::ReplayProtectionOptions;
use miniscript::bitcoin::{CompressedPublicKey, Txid};
Expand All @@ -567,7 +584,7 @@ impl BitGoPsbt {
let options = ReplayProtectionOptions {
sequence,
sighash_type: None,
prev_tx: None,
prev_tx: prev_tx.as_deref(),
};

Ok(self
Expand Down
58 changes: 58 additions & 0 deletions packages/wasm-utxo/src/wasm/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,64 @@ impl WasmTransaction {

#[wasm_bindgen]
impl WasmTransaction {
/// Create an empty transaction (version 1, locktime 0)
pub fn create() -> WasmTransaction {
use miniscript::bitcoin::{absolute::LockTime, transaction::Version, Transaction};
WasmTransaction {
tx: Transaction {
version: Version::ONE,
lock_time: LockTime::ZERO,
input: vec![],
output: vec![],
},
}
}

/// Add an input to the transaction
///
/// # Arguments
/// * `txid` - The transaction ID (hex string) of the output being spent
/// * `vout` - The output index being spent
/// * `sequence` - Optional sequence number (default: 0xFFFFFFFF)
///
/// # Returns
/// The index of the newly added input
pub fn add_input(
&mut self,
txid: &str,
vout: u32,
sequence: Option<u32>,
) -> Result<usize, WasmUtxoError> {
use miniscript::bitcoin::{transaction::Sequence, OutPoint, ScriptBuf, TxIn, Txid};
use std::str::FromStr;
let txid = Txid::from_str(txid)
.map_err(|e| WasmUtxoError::new(&format!("Invalid txid: {}", e)))?;
self.tx.input.push(TxIn {
previous_output: OutPoint { txid, vout },
script_sig: ScriptBuf::new(),
sequence: sequence.map(Sequence).unwrap_or(Sequence::MAX),
witness: Default::default(),
});
Ok(self.tx.input.len() - 1)
}

/// Add an output to the transaction
///
/// # Arguments
/// * `script` - The output script (scriptPubKey)
/// * `value` - The value in satoshis
///
/// # Returns
/// The index of the newly added output
pub fn add_output(&mut self, script: &[u8], value: u64) -> usize {
use miniscript::bitcoin::{Amount, ScriptBuf, TxOut};
self.tx.output.push(TxOut {
value: Amount::from_sat(value),
script_pubkey: ScriptBuf::from(script.to_vec()),
});
self.tx.output.len() - 1
}

/// Deserialize a transaction from bytes
///
/// # Arguments
Expand Down
39 changes: 39 additions & 0 deletions packages/wasm-utxo/test/fixedScript/p2shP2pkOutputScript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import assert from "node:assert";
import { fixedScriptWallet } from "../../js/index.js";

// Compressed public key for private key 0x01 * 32
const PUBKEY = Buffer.from(
"031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f",
"hex",
);

describe("p2shP2pkOutputScript", function () {
it("should produce expected P2SH output script", function () {
const script = fixedScriptWallet.p2shP2pkOutputScript(PUBKEY);

// P2SH output scripts are always 23 bytes: OP_HASH160 <20-byte-hash> OP_EQUAL
assert.strictEqual(script.length, 23);
assert.strictEqual(script[0], 0xa9);
assert.strictEqual(script[1], 0x14);
assert.strictEqual(script[22], 0x87);

assert.strictEqual(
Buffer.from(script).toString("hex"),
"a9140c79ca26388c7130abaa079b1968288911d3677387",
);
});

it("should produce different scripts for different keys", function () {
const otherPubkey = Buffer.from(
"024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766",
"hex",
);
const script1 = fixedScriptWallet.p2shP2pkOutputScript(PUBKEY);
const script2 = fixedScriptWallet.p2shP2pkOutputScript(otherPubkey);
assert.notDeepStrictEqual(script1, script2);
});

it("should reject an invalid public key", function () {
assert.throws(() => fixedScriptWallet.p2shP2pkOutputScript(new Uint8Array(32)));
});
});
85 changes: 85 additions & 0 deletions packages/wasm-utxo/test/transaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import assert from "node:assert";
import { Transaction } from "../js/transaction.js";
import { fixedScriptWallet } from "../js/index.js";

describe("Transaction builder", function () {
it("should create an empty transaction", function () {
const tx = Transaction.create();
const bytes = tx.toBytes();
assert.ok(bytes.length > 0, "serialized transaction should not be empty");

// Round-trip: the deserialized transaction should produce the same bytes
const tx2 = Transaction.fromBytes(bytes);
assert.deepStrictEqual(tx2.toBytes(), bytes);
});

it("should add an input and return index 0", function () {
const tx = Transaction.create();
const txid = "a".repeat(64);
const idx = tx.addInput(txid, 0);
assert.strictEqual(idx, 0);
});

it("should add multiple inputs with incrementing indices", function () {
const tx = Transaction.create();
const txid = "b".repeat(64);
assert.strictEqual(tx.addInput(txid, 0), 0);
assert.strictEqual(tx.addInput(txid, 1), 1);
assert.strictEqual(tx.addInput(txid, 2), 2);
});

it("should add an output and return index 0", function () {
const tx = Transaction.create();
// OP_RETURN script
const script = fixedScriptWallet.createOpReturnScript();
const idx = tx.addOutput(script, 0n);
assert.strictEqual(idx, 0);
});

it("should add multiple outputs with incrementing indices", function () {
const tx = Transaction.create();
const script = fixedScriptWallet.createOpReturnScript();
assert.strictEqual(tx.addOutput(script, 1000n), 0);
assert.strictEqual(tx.addOutput(script, 2000n), 1);
});

it("should round-trip a transaction with inputs and outputs", function () {
const tx = Transaction.create();
const txid = "c".repeat(64);
tx.addInput(txid, 0);
tx.addInput(txid, 1, 0xfffffffe);

const script = fixedScriptWallet.createOpReturnScript(new Uint8Array([0xde, 0xad]));
tx.addOutput(script, 50000n);

const bytes = tx.toBytes();
const tx2 = Transaction.fromBytes(bytes);
assert.deepStrictEqual(tx2.toBytes(), bytes);
assert.strictEqual(tx2.getId(), tx.getId());
assert.strictEqual(tx2.getVSize(), tx.getVSize());
});

it("should produce a valid txid", function () {
const tx = Transaction.create();
tx.addInput("a".repeat(64), 0);
tx.addOutput(fixedScriptWallet.createOpReturnScript(), 0n);
const txid = tx.getId();
assert.strictEqual(txid.length, 64);
assert.match(txid, /^[0-9a-f]{64}$/);
});

it("should reject an invalid txid", function () {
const tx = Transaction.create();
assert.throws(() => tx.addInput("not-a-valid-txid", 0));
});

it("should accept custom sequence number", function () {
const tx = Transaction.create();
const txid = "d".repeat(64);
tx.addInput(txid, 0, 0);
// If we can round-trip it, the sequence was accepted
const bytes = tx.toBytes();
const tx2 = Transaction.fromBytes(bytes);
assert.deepStrictEqual(tx2.toBytes(), bytes);
});
});