Skip to content
Open
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
3 changes: 2 additions & 1 deletion packages/wasm-utxo/.mocharc.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"extensions": ["ts", "tsx", "js", "jsx"],
"ignore": ["test/benchmark/**", "test/integrationLocalRpc/**"],
"ignore": ["test/benchmark/**", "test/integrationLocalRpc/**", "test/setupWasmRandomness.ts"],
"require": ["test/setupWasmRandomness.ts"],
"node-option": ["import=tsx/esm", "experimental-wasm-modules"]
}
24 changes: 22 additions & 2 deletions packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import { BitGoPsbt as WasmBitGoPsbt, zcash_branch_id_for_height } from "../wasm/wasm_utxo.js";
import {
BitGoPsbt as WasmBitGoPsbt,
zcash_branch_id_for_height,
zcash_ironwood_version_group_id,
} from "../wasm/wasm_utxo.js";
import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js";
import { BitGoPsbt, type CreateEmptyOptions, type HydrationUnspent } from "./BitGoPsbt.js";
import { ZcashTransaction, type ITransaction } from "../transaction.js";

/** Zcash network names */
export type ZcashNetworkName = "zcash" | "zcashTest" | "zec" | "tzec";

/**
* Zcash v6 (Ironwood) version group id (0xd884b698). Its presence marks a PSBT as v6 — see
* `ZcashIronwoodBitGoPsbt`.
*
* Read from the wasm layer rather than hard-coded, so it cannot drift from
* `ZCASH_IRONWOOD_VERSION_GROUP_ID` in `src/zcash/transaction.rs`: a divergence would make the
* v4/v6 discrimination in {@link ZcashBitGoPsbt.fromBytes} silently classify v6 bytes as v4.
*/
export const IRONWOOD_VERSION_GROUP_ID: number = zcash_ironwood_version_group_id();

/** Options for creating an empty Zcash PSBT (preferred method using block height) */
export type CreateEmptyZcashOptions = CreateEmptyOptions & {
/** Block height to determine consensus branch ID automatically */
Expand Down Expand Up @@ -137,11 +151,17 @@ export class ZcashBitGoPsbt extends BitGoPsbt {
*
* @param bytes - The PSBT bytes
* @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec")
* @throws Error if the deserialized PSBT is a v6 (Ironwood) PSBT — use
* {@link ZcashIronwoodBitGoPsbt.fromBytes} instead
* @returns A ZcashBitGoPsbt instance
*/
static override fromBytes(bytes: Uint8Array, network: ZcashNetworkName): ZcashBitGoPsbt {
const wasm = WasmBitGoPsbt.from_bytes(bytes, network);
return new ZcashBitGoPsbt(wasm);
const psbt = new ZcashBitGoPsbt(wasm);
if (psbt.versionGroupId === IRONWOOD_VERSION_GROUP_ID) {
throw new Error("this is a v6 (Ironwood) PSBT: use ZcashIronwoodBitGoPsbt.fromBytes instead");
}
return psbt;
}

/**
Expand Down
163 changes: 163 additions & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { BitGoPsbt as WasmBitGoPsbt } from "../wasm/wasm_utxo.js";
import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js";
import {
IRONWOOD_VERSION_GROUP_ID,
ZcashBitGoPsbt,
type ZcashNetworkName,
} from "./ZcashBitGoPsbt.js";

/**
* A fresh ZIP-302 "no memo" memo field: the `0xf6` marker byte followed by 511 zero bytes. The
* default for {@link ZcashIronwoodBitGoPsbt.addIronwoodOutput}'s `memo` option.
*
* Not the same as an all-zeros memo, which decodes as a *text* memo holding the empty string and is
* rendered as such by wallets. Exported so callers can pass it explicitly, and so the distinction is
* visible rather than buried in a default.
*
* A function rather than a shared constant: a module-level `Uint8Array` is mutable, so one caller
* writing into it would silently change the default memo for every later output.
*/
export function zip302NoMemo(): Uint8Array {
const memo = new Uint8Array(512);
memo[0] = 0xf6;
return memo;
}

/**
* A Zcash **v6 (Ironwood / NU6.3)** shielding PSBT.
*
* Distinct from the generic `ZcashBitGoPsbt` (v4/Sapling-shaped transactions): a v6 PSBT carries
* its shielded side as an orchard PCZT in the proprietary map rather than Sapling fields, and its
* lifecycle mirrors the microservice build → sign → combine flow rather than the v4 ZIP-243
* signing path.
*
* @example
* ```typescript
* const psbt = ZcashIronwoodBitGoPsbt.createIronwood("zcash", walletKeys, { blockHeight });
* psbt.addWalletInput(...);
* psbt.addWalletOutput(...);
* psbt.addIronwoodOutput(recipient, amount, { anchor });
* const sighash = psbt.ironwoodTransparentSighash(0);
* // ... sign sighash externally, then:
* psbt.addIronwoodSignature(0, pubkey, sig);
* const tx = psbt.combineIronwoodProof(proof);
* ```
*/
export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt {
/**
* Not applicable to v6: use {@link createIronwood}. Inherited only because JS statics are
* inherited — calling it would hand back a v4-shaped `ZcashBitGoPsbt`.
*/
static override createEmpty(): never {
throw new Error("use ZcashIronwoodBitGoPsbt.createIronwood to build a v6 (Ironwood) PSBT");
}

/**
* Create an empty Zcash **v6 (Ironwood)** shielding PSBT, with the consensus branch ID
* determined from block height.
*
* Add transparent inputs/outputs with the usual `addWalletInput` / `addWalletOutput`, the
* shielded output with {@link addIronwoodOutput}, then sign the transparent inputs over
* {@link ironwoodTransparentSighash} and finish with {@link combineIronwoodProof}.
*
* @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec")
* @param walletKeys - The wallet's root keys (sets global xpubs in the PSBT)
* @param options - Options including blockHeight (at/after NU6.3 activation)
*/
static createIronwood(
network: ZcashNetworkName,
walletKeys: WalletKeysArg,
options: { blockHeight: number; lockTime?: number; expiryHeight?: number },
): ZcashIronwoodBitGoPsbt {
const keys = RootWalletKeys.from(walletKeys);
const wasm = WasmBitGoPsbt.create_empty_zcash_v6_at_height(
network,
keys.wasm,
options.blockHeight,
options.lockTime,
options.expiryHeight,
);
return new ZcashIronwoodBitGoPsbt(wasm);
}

/**
* Deserialize a v6 (Ironwood) Zcash PSBT from bytes.
*
* @param bytes - The PSBT bytes
* @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec")
* @throws Error if the deserialized PSBT is not a v6 (Ironwood) PSBT
* @returns A ZcashIronwoodBitGoPsbt instance
*/
static override fromBytes(bytes: Uint8Array, network: ZcashNetworkName): ZcashIronwoodBitGoPsbt {
const wasm = WasmBitGoPsbt.from_bytes(bytes, network);
const psbt = new ZcashIronwoodBitGoPsbt(wasm);
if (psbt.versionGroupId !== IRONWOOD_VERSION_GROUP_ID) {
throw new Error(
"not a v6 (Ironwood) PSBT: use ZcashBitGoPsbt.fromBytes for v4/Sapling-shaped PSBTs",
);
}
return psbt;
}

/**
* Add the shielded Ironwood output (Constructor role). Stores the orchard PCZT in the PSBT.
*
* @param recipient - 43-byte raw Orchard/Ironwood address
* @param amount - note value in zatoshi
* @param options.anchor - 32-byte Ironwood note-commitment-tree root
* @param options.memo - optional 512-byte memo (defaults to the ZIP-302 "no memo" encoding)
* @param options.ovk - optional 32-byte outgoing viewing key (omit for a keyless build)
*/
addIronwoodOutput(
recipient: Uint8Array,
amount: bigint,
options: { anchor: Uint8Array; memo?: Uint8Array; ovk?: Uint8Array },
): void {
const memo = options.memo ?? zip302NoMemo();
this.wasm.add_ironwood_output(recipient, amount, options.ovk, options.anchor, memo);
}

/**
* The canonical (display-order) ZIP-244 v6 txid as a lowercase hex string, matching the
* `getId()` convention used by the other transaction/PSBT wrappers. Defined once the
* transparent inputs/outputs and the Ironwood output are in place; unchanged by signing or
* proving.
*/
ironwoodTxid(): string {
return this.wasm.ironwood_v6_txid();
}

/**
* The ZIP-244 per-input transparent sighash (32 bytes) the key controlling transparent input
* `index` must sign.
*/
ironwoodTransparentSighash(index: number): Uint8Array {
return this.wasm.ironwood_v6_transparent_sighash(index);
}

/**
* Ingest a transparent-input signature returned by the client/HSM, after verifying it against
* {@link ironwoodTransparentSighash} for that input.
*
* @param index - transparent input index
* @param pubkey - the signing public key
* @param sig - DER ECDSA signature with the trailing SIGHASH_ALL byte (as in a scriptSig)
*/
addIronwoodSignature(index: number, pubkey: Uint8Array, sig: Uint8Array): void {
this.wasm.add_ironwood_v6_signature(index, pubkey, sig);
}

/**
* Transaction Extractor role: given the external prover's `proof` bytes, finalize the
* transparent inputs, apply the shielded binding signature, and return the broadcast-ready v6
* transaction bytes. Requires every transparent input to be signed via
* {@link addIronwoodSignature}.
*
* Terminal: the wasm binding drops the stored PCZT on success, so any later Ironwood call on this
* PSBT throws — including after a `serialize`/{@link fromBytes} round-trip, since the PCZT is gone
* from the bytes too. Build a fresh PSBT instead.
*/
combineIronwoodProof(proof: Uint8Array): Uint8Array {
return this.wasm.combine_ironwood_proof(proof);
}
}
4 changes: 4 additions & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,12 @@ export {
ZcashBitGoPsbt,
type ZcashNetworkName,
type CreateEmptyZcashOptions,
IRONWOOD_VERSION_GROUP_ID,
} from "./ZcashBitGoPsbt.js";

// Zcash v6 (Ironwood / NU6.3) shielding PSBT
export { ZcashIronwoodBitGoPsbt, zip302NoMemo } from "./ZcashIronwoodBitGoPsbt.js";

// Zcash ZIP-316 Unified Address
export { ZcashUnifiedAddress } from "./ZcashUnifiedAddress.js";

Expand Down
15 changes: 15 additions & 0 deletions packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,21 @@ pub fn get_ironwood_pczt(psbt: &miniscript::bitcoin::psbt::Psbt) -> Option<Vec<u
get_zec_v6(psbt, ZecV6KeySubtype::IronwoodPczt)
}

/// Remove the serialized Ironwood (v6) PCZT bundle, returning whether one was present.
///
/// Used to make extraction terminal: once `combine_ironwood_proof` has produced the broadcast-ready
/// transaction, dropping the PCZT means any further Ironwood operation on that PSBT — including
/// after a `serialize`/`deserialize` round-trip — fails loudly instead of silently re-running
/// against state that has already been spent.
pub fn take_ironwood_pczt(psbt: &mut miniscript::bitcoin::psbt::Psbt) -> bool {
let key = miniscript::bitcoin::psbt::raw::ProprietaryKey {
prefix: BITGO_ZEC_V6.to_vec(),
subtype: ZecV6KeySubtype::IronwoodPczt as u8,
key: vec![],
};
psbt.proprietary.remove(&key).is_some()
}

/// Store the Zcash v6 (Ironwood) header params — `version_group_id` and `expiry_height` — under the
/// `BITGO_ZEC_V6` namespace. `version_group_id`'s presence marks the PSBT as v6; `expiry_height` is
/// always written too (even when 0, a valid "no expiry" value, so it can be told apart from absent).
Expand Down
Loading
Loading