Skip to content
Closed
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: 1 addition & 0 deletions packages/wasm-utxo/cli/src/psbt/add_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pub fn handle_add_input_command(
&descriptor,
sequence,
non_witness_utxo,
network,
)
.map_err(|e| anyhow!(e))
.with_context(|| format!("failed to add input {index}"))?;
Expand Down
26 changes: 20 additions & 6 deletions packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,19 @@ export type ParseOutputsOptions = {
};

export type HydrationUnspent =
| { chain: number; index: number; value: bigint } // wallet input
| { pubkey: Uint8Array; value: bigint }; // P2SH-P2PK replay protection input
| {
chain: number;
index: number;
value: bigint;
/** Full previous transaction; required for legacy P2SH on non-value-committing coins. */
prevTx?: Uint8Array;
}
| {
pubkey: Uint8Array;
value: bigint;
/** Full previous transaction; required for replay protection on non-value-committing coins. */
prevTx?: Uint8Array;
};

export class BitGoPsbt<TOutput extends ParsedOutput = ParsedOutput>
extends PsbtBase<WasmBitGoPsbt>
Expand Down Expand Up @@ -206,16 +217,18 @@ export class BitGoPsbt<TOutput extends ParsedOutput = ParsedOutput>
* (exactly 1 sig per wallet input) is moving to the caller.
*
* Extracts partial signatures from scriptSig/witness and creates a PSBT
* with proper wallet metadata (bip32Derivation, scripts, witnessUtxo).
* Only supports p2sh, p2shP2wsh, and p2wsh inputs (not taproot).
* with proper wallet metadata (bip32Derivation, scripts, and authenticated
* UTXO data). Legacy P2SH inputs require `prevTx` on non-value-committing
* networks. Only supports p2sh, p2shP2wsh, and p2wsh inputs (not taproot).
*
* Supports both Bitcoin-like coins (BTC, LTC, DOGE) and Dash (DASH).
* Zcash is NOT supported; use ZcashBitGoPsbt.fromNetworkFormat instead.
*
* @param txBytesOrTx - Transaction bytes or decoded transaction instance (Bitcoin-like or Dash)
* @param network - Network name
* @param walletKeys - The wallet's root keys
* @param unspents - Chain, index, and value for each input
* @param unspents - Input metadata; include `prevTx` for legacy P2SH on
* networks whose sighash does not commit the input amount
* @param _options - Reserved for future use and signature compatibility with subclasses
* @throws Error if transaction is Zcash (use ZcashBitGoPsbt.fromNetworkFormat instead)
*/
Expand Down Expand Up @@ -268,7 +281,8 @@ export class BitGoPsbt<TOutput extends ParsedOutput = ParsedOutput>
* @param txBytesOrTx - Transaction bytes or decoded Transaction/DashTransaction
* @param network - Network name
* @param walletKeys - The wallet's root keys
* @param unspents - Chain, index, and value for each input
* @param unspents - Input metadata; include `prevTx` for legacy P2SH on
* networks whose sighash does not commit the input amount
*/
static fromNetworkFormat(
txBytesOrTx: Uint8Array | Transaction | DashTransaction,
Expand Down
17 changes: 6 additions & 11 deletions packages/wasm-utxo/js/fixedScriptWallet/prevTx.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Previous-transaction inclusion policy for fixed-script wallet PSBT inputs.
*
* Decides whether a p2sh input requires the full previous transaction
* Decides whether a legacy p2sh input requires the full previous transaction
* (PSBT_IN_NON_WITNESS_UTXO) or can be signed from witness_utxo-only.
*
* This is a pure-JS module (no WASM initialization) so callers can evaluate
Expand All @@ -10,26 +10,21 @@
import { type CoinName, getMainnet } from "../coinName.js";

/**
* Whether a p2sh input requires the full previous transaction
* (PSBT_IN_NON_WITNESS_UTXO). Callers are expected to have already
* confirmed the input is p2sh (non-segwit) and that the tx format
* includes prevTx (e.g. "psbt", not "psbt-lite"); this predicate only
* answers the coin-level question.
* Whether a legacy p2sh input requires the full previous transaction
* (PSBT_IN_NON_WITNESS_UTXO). Callers can use this coin-level predicate
* to decide whether to fetch prevTx; the library also validates that a
* supplied prevTx matches the input outpoint and spent output.
*
* Returns false for value-committing coins whose sighash commits the
* input amount, making `non_witness_utxo` (full prevTx) cryptographically
* pointless for signing p2sh inputs — `witness_utxo` (value +
* scriptPubKey) suffices:
*
* - Zcash (`zec`/`tzec`): ZIP-243 transparent sighash commits the amount.
* Including prevTx also crashes wasm-utxo, whose consensus::deserialize
* rejects Zcash overwintered transactions.
* - BCH family (`bch`/`bcha`/`bsv`/`btg` + testnets): replay-protected
* BIP-143 sighash (SIGHASH_FORKID, the default for the whole family)
* commits the 8-byte value as preimage item #6. eCash is `bcha`/`tbcha`.
* For the BCH family, skipping prevTx is an optimization (no DB fetch)
* plus defense-in-depth, with the same fee-validation risk that the
* existing `psbt-lite` path already accepts for all coins.
* For these networks, witness_utxo is sufficient for legacy signing.
*
* Testnets are normalized via `getMainnet` before the switch. True
* otherwise.
Expand Down
81 changes: 59 additions & 22 deletions packages/wasm-utxo/js/testutils/AcidTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "../fixedScriptWallet/index.js";
import type { CoinName } from "../coinName.js";
import { coinNames, isMainnet } from "../coinName.js";
import { requiresPrevTxForP2sh } from "../fixedScriptWallet/prevTx.js";
import { getDefaultWalletKeys, getWalletKeysForSeed, getKeyTriple } from "./keys.js";
import type { Triple } from "../triple.js";

Expand All @@ -27,6 +28,21 @@ export type SignStage = (typeof signStages)[number];
export const txFormats = ["psbt", "psbt-lite"] as const;
export type TxFormat = (typeof txFormats)[number];

/** Build a synthetic previous transaction and its matching txid. */
export function createSyntheticPrevTx(
script: Uint8Array,
value: bigint,
vout = 0,
): { txid: string; prevTx: Uint8Array } {
const tx = Transaction.create();
tx.addInput("0".repeat(64), 0xffffffff);
for (let i = 0; i < vout; i++) {
tx.addOutput(new Uint8Array(0), 0n);
}
tx.addOutput(script, value);
return { txid: tx.getId(), prevTx: tx.toBytes() };
}

/**
* Utility type to make union variants mutually exclusive.
* For each variant T in the union, adds `?: never` for all keys from other variants.
Expand Down Expand Up @@ -270,21 +286,32 @@ export class AcidTest {
lockTime: 0,
});

// Build a fake previous transaction for non_witness_utxo (psbt format)
const usePrevTx = this.txFormat === "psbt" && !isZcash;
// Build synthetic previous transactions whose computed txids match their outpoints.
const usePrevTxForPsbt = this.txFormat === "psbt" && !isZcash;
const buildPrevTx = (
vout: number,
script: Uint8Array,
value: bigint,
): Uint8Array | undefined => {
if (!usePrevTx) return undefined;
const tx = Transaction.create();
tx.addInput("0".repeat(64), 0xffffffff);
for (let i = 0; i < vout; i++) {
tx.addOutput(new Uint8Array(0), 0n);
): { txid: string; bytes: Uint8Array } => {
const prevTx = createSyntheticPrevTx(script, value, vout);
return { txid: prevTx.txid, bytes: prevTx.prevTx };
};

const getPrevTx = (
real: { txid: string; vout: number; prevTx?: Uint8Array } | undefined,
vout: number,
script: Uint8Array,
value: bigint,
isLegacyP2sh: boolean,
): { txid: string; bytes: Uint8Array } | undefined => {
if (real?.prevTx) return { txid: real.txid, bytes: real.prevTx };
if (
usePrevTxForPsbt ||
(isLegacyP2sh && requiresPrevTxForP2sh(this.coin))
) {
return buildPrevTx(vout, script, value);
}
tx.addOutput(script, value);
return tx.toBytes();
return undefined;
};

if (options?.outpoints && options.outpoints.length !== this.inputs.length) {
Expand All @@ -297,12 +324,6 @@ export class AcidTest {
this.inputs.forEach((input, index) => {
const walletKeys = input.walletKeys ?? this.rootWalletKeys;
const real = options?.outpoints?.[index];
const outpoint = {
txid: real?.txid ?? "0".repeat(64),
vout: real?.vout ?? index,
value: input.value,
};

// scriptId variant: caller provides explicit chain + index
if (input.scriptId) {
const script = outputScript(
Expand All @@ -311,10 +332,20 @@ export class AcidTest {
input.scriptId.index,
this.coin,
);
const prevTx = getPrevTx(
real,
real?.vout ?? index,
script,
input.value,
ChainCode.is(input.scriptId.chain) &&
ChainCode.scriptType(input.scriptId.chain) === "p2sh",
);
psbt.addWalletInput(
{
...outpoint,
prevTx: real?.prevTx ?? buildPrevTx(index, script, input.value),
txid: real?.txid ?? prevTx?.txid ?? "0".repeat(64),
vout: real?.vout ?? index,
value: input.value,
prevTx: prevTx?.bytes,
},
walletKeys,
{ scriptId: input.scriptId, signPath: { signer: "user", cosigner: "bitgo" } },
Expand All @@ -327,10 +358,13 @@ export class AcidTest {
if (scriptType === "p2shP2pk") {
const ecpair = ECPair.fromPublicKey(this.getReplayProtectionKey().publicKey);
const script = p2shP2pkOutputScript(ecpair.publicKey);
const prevTx = getPrevTx(real, real?.vout ?? index, script, input.value, true);
psbt.addReplayProtectionInput(
{
...outpoint,
prevTx: real?.prevTx ?? buildPrevTx(index, script, input.value),
txid: real?.txid ?? prevTx?.txid ?? "0".repeat(64),
vout: real?.vout ?? index,
value: input.value,
prevTx: prevTx?.bytes,
},
ecpair,
);
Expand All @@ -346,11 +380,14 @@ export class AcidTest {
? { signer: "user", cosigner: "backup" }
: { signer: "user", cosigner: "bitgo" };
const script = outputScript(walletKeys, scriptId.chain, scriptId.index, this.coin);
const prevTx = getPrevTx(real, real?.vout ?? index, script, input.value, scriptType === "p2sh");

psbt.addWalletInput(
{
...outpoint,
prevTx: real?.prevTx ?? buildPrevTx(index, script, input.value),
txid: real?.txid ?? prevTx?.txid ?? "0".repeat(64),
vout: real?.vout ?? index,
value: input.value,
prevTx: prevTx?.bytes,
},
walletKeys,
{ scriptId, signPath },
Expand Down
7 changes: 6 additions & 1 deletion packages/wasm-utxo/src/dash/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
//! - if type != 0: varint payload_size + payload bytes

use miniscript::bitcoin::consensus::{Decodable, Encodable};
use miniscript::bitcoin::{Transaction, TxIn, TxOut, VarInt};
use miniscript::bitcoin::{hashes::{sha256d, Hash}, Transaction, TxIn, TxOut, Txid, VarInt};

/// Parsed Dash transaction fields needed for round-tripping.
#[derive(Debug, Clone)]
Expand Down Expand Up @@ -75,6 +75,11 @@ pub fn decode_dash_transaction_parts(bytes: &[u8]) -> Result<DashTransactionPart
})
}

/// Compute the txid over the complete Dash transaction wire bytes.
pub fn compute_dash_txid(bytes: &[u8]) -> Txid {
Txid::from_raw_hash(sha256d::Hash::hash(bytes))
}

/// Encode a Dash transaction back to bytes, including tx_type and extra payload.
pub fn encode_dash_transaction_parts(parts: &DashTransactionParts) -> Result<Vec<u8>, String> {
let mut bytes = Vec::new();
Expand Down
Loading
Loading