|
| 1 | +import { BitGoPsbt as WasmBitGoPsbt } from "../wasm/wasm_utxo.js"; |
| 2 | +import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js"; |
| 3 | +import { |
| 4 | + IRONWOOD_VERSION_GROUP_ID, |
| 5 | + ZcashBitGoPsbt, |
| 6 | + type ZcashNetworkName, |
| 7 | +} from "./ZcashBitGoPsbt.js"; |
| 8 | + |
| 9 | +/** |
| 10 | + * Options for creating an empty Zcash v6 (Ironwood) shielding PSBT. |
| 11 | + * |
| 12 | + * Deliberately narrower than `CreateEmptyZcashOptions`: `version` and `versionGroupId` are fixed by |
| 13 | + * the v6 format, so accepting them would only let a caller ask for something that is then ignored. |
| 14 | + */ |
| 15 | +export type CreateEmptyIronwoodOptions = { |
| 16 | + /** Block height to determine the consensus branch ID automatically (at/after NU6.3 activation) */ |
| 17 | + blockHeight: number; |
| 18 | + /** Lock time (default: 0) */ |
| 19 | + lockTime?: number; |
| 20 | + /** Zcash transaction expiry height */ |
| 21 | + expiryHeight?: number; |
| 22 | +}; |
| 23 | + |
| 24 | +/** |
| 25 | + * Options for creating an empty Zcash v6 (Ironwood) shielding PSBT with an explicit consensus |
| 26 | + * branch ID. |
| 27 | + * |
| 28 | + * Like {@link CreateEmptyIronwoodOptions}, this omits `version` and `versionGroupId` — both are |
| 29 | + * fixed by the v6 format. The consensus branch ID is *not* fixed: it tracks the active network |
| 30 | + * upgrade, so it stays a caller-supplied parameter exactly as it is for v4. |
| 31 | + */ |
| 32 | +export type CreateEmptyIronwoodWithConsensusBranchIdOptions = { |
| 33 | + /** Zcash consensus branch ID (e.g. the NU6.3 branch ID) */ |
| 34 | + consensusBranchId: number; |
| 35 | + /** Lock time (default: 0) */ |
| 36 | + lockTime?: number; |
| 37 | + /** Zcash transaction expiry height */ |
| 38 | + expiryHeight?: number; |
| 39 | +}; |
| 40 | + |
| 41 | +/** |
| 42 | + * A fresh ZIP-302 "no memo" memo field: the `0xf6` marker byte followed by 511 zero bytes. The |
| 43 | + * default for {@link ZcashIronwoodBitGoPsbt.addShieldedOutput}'s `memo` option. |
| 44 | + * |
| 45 | + * Not the same as an all-zeros memo, which decodes as a *text* memo holding the empty string and is |
| 46 | + * rendered as such by wallets. Exported so callers can pass it explicitly, and so the distinction is |
| 47 | + * visible rather than buried in a default. |
| 48 | + * |
| 49 | + * A function rather than a shared constant: a module-level `Uint8Array` is mutable, so one caller |
| 50 | + * writing into it would silently change the default memo for every later output. |
| 51 | + */ |
| 52 | +export function zip302NoMemo(): Uint8Array { |
| 53 | + const memo = new Uint8Array(512); |
| 54 | + memo[0] = 0xf6; |
| 55 | + return memo; |
| 56 | +} |
| 57 | + |
| 58 | +/** |
| 59 | + * A Zcash **v6 (Ironwood / NU6.3)** shielding PSBT. |
| 60 | + * |
| 61 | + * Distinct from the generic `ZcashBitGoPsbt` (v4/Sapling-shaped transactions): a v6 PSBT carries |
| 62 | + * its shielded side as an orchard PCZT in the proprietary map rather than Sapling fields, and its |
| 63 | + * lifecycle mirrors the microservice build → sign → combine flow rather than the v4 ZIP-243 |
| 64 | + * signing path. |
| 65 | + * |
| 66 | + * Method names carry no `ironwood` prefix — the class name already says it. They follow the base |
| 67 | + * `BitGoPsbt` vocabulary (`createEmpty`, `fromBytes`, `addOutput`-style adders, `getId`) so the v6 |
| 68 | + * surface reads the same as the v4 one; the wasm bindings keep their `ironwood_v6_*` names because |
| 69 | + * they live on the single flat `BitGoPsbt` struct, where the prefix is what disambiguates them. |
| 70 | + * |
| 71 | + * @example |
| 72 | + * ```typescript |
| 73 | + * const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcash", walletKeys, { blockHeight }); |
| 74 | + * psbt.addWalletInput(...); |
| 75 | + * psbt.addWalletOutput(...); |
| 76 | + * psbt.addShieldedOutput(recipient, amount, { anchor }); |
| 77 | + * const sighash = psbt.transparentSighash(0); |
| 78 | + * // ... sign sighash externally, then: |
| 79 | + * psbt.addTransparentSignature(0, pubkey, sig); |
| 80 | + * const tx = psbt.combineProof(proof); |
| 81 | + * ``` |
| 82 | + */ |
| 83 | +export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt { |
| 84 | + /** |
| 85 | + * Create an empty Zcash **v6 (Ironwood)** shielding PSBT, with the consensus branch ID |
| 86 | + * determined from block height. |
| 87 | + * |
| 88 | + * Add transparent inputs/outputs with the usual `addWalletInput` / `addWalletOutput`, the |
| 89 | + * shielded output with {@link addShieldedOutput}, then sign the transparent inputs over |
| 90 | + * {@link transparentSighash} and finish with {@link combineProof}. |
| 91 | + * |
| 92 | + * @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec") |
| 93 | + * @param walletKeys - The wallet's root keys (sets global xpubs in the PSBT) |
| 94 | + * @param options - Options including blockHeight (at/after NU6.3 activation) |
| 95 | + */ |
| 96 | + static override createEmpty( |
| 97 | + network: ZcashNetworkName, |
| 98 | + walletKeys: WalletKeysArg, |
| 99 | + options: CreateEmptyIronwoodOptions, |
| 100 | + ): ZcashIronwoodBitGoPsbt { |
| 101 | + const keys = RootWalletKeys.from(walletKeys); |
| 102 | + const wasm = WasmBitGoPsbt.create_empty_zcash_v6_at_height( |
| 103 | + network, |
| 104 | + keys.wasm, |
| 105 | + options.blockHeight, |
| 106 | + options.lockTime, |
| 107 | + options.expiryHeight, |
| 108 | + ); |
| 109 | + return new ZcashIronwoodBitGoPsbt(wasm); |
| 110 | + } |
| 111 | + |
| 112 | + /** |
| 113 | + * Create an empty Zcash **v6 (Ironwood)** shielding PSBT with an explicit consensus branch ID. |
| 114 | + * |
| 115 | + * **Advanced use only.** Prefer {@link createEmpty}, which derives the branch ID from a block |
| 116 | + * height and rejects heights before NU6.3 activation. Reach for this only when you already know |
| 117 | + * the branch ID — regtest, a future upgrade, or replaying a known-good value. |
| 118 | + * |
| 119 | + * Only the *version group ID* is fixed by the v6 format; the consensus branch ID tracks the active |
| 120 | + * network upgrade and remains caller-supplied, exactly as for v4. |
| 121 | + * |
| 122 | + * @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec") |
| 123 | + * @param walletKeys - The wallet's root keys (sets global xpubs in the PSBT) |
| 124 | + * @param options - Options including the required consensusBranchId |
| 125 | + */ |
| 126 | + static override createEmptyWithConsensusBranchId( |
| 127 | + network: ZcashNetworkName, |
| 128 | + walletKeys: WalletKeysArg, |
| 129 | + options: CreateEmptyIronwoodWithConsensusBranchIdOptions, |
| 130 | + ): ZcashIronwoodBitGoPsbt { |
| 131 | + const keys = RootWalletKeys.from(walletKeys); |
| 132 | + const wasm = WasmBitGoPsbt.create_empty_zcash_v6( |
| 133 | + network, |
| 134 | + keys.wasm, |
| 135 | + options.consensusBranchId, |
| 136 | + options.lockTime, |
| 137 | + options.expiryHeight, |
| 138 | + ); |
| 139 | + return new ZcashIronwoodBitGoPsbt(wasm); |
| 140 | + } |
| 141 | + |
| 142 | + /** |
| 143 | + * Not applicable to v6, and not implementable: a broadcast v6 transaction carries the shielded |
| 144 | + * side as a proof plus a binding signature, while every v6 operation here needs the PCZT — which |
| 145 | + * holds witness data (`rseed`, `rcv`, `alpha`, the note plaintext) that is deliberately *not* |
| 146 | + * recoverable from the transaction. Decoding the transparent skeleton alone would yield a PSBT |
| 147 | + * that `getId`, `transparentSighash`, `combineProof`, and even `serialize`/{@link fromBytes} all |
| 148 | + * reject. Inherited only because JS statics are inherited. |
| 149 | + */ |
| 150 | + static override fromNetworkFormat(): never { |
| 151 | + throw new Error( |
| 152 | + "not supported for v6 (Ironwood): the PCZT witness data cannot be recovered from a broadcast " + |
| 153 | + "transaction; rebuild the PSBT with createEmpty", |
| 154 | + ); |
| 155 | + } |
| 156 | + |
| 157 | + /** |
| 158 | + * Not applicable to v6: the legacy half-signed format is a v4-era p2ms encoding with no way to |
| 159 | + * carry the PCZT, and no v6 transaction has ever been produced in it. Inherited only because JS |
| 160 | + * statics are inherited. |
| 161 | + */ |
| 162 | + static override fromHalfSignedLegacyTransaction(): never { |
| 163 | + throw new Error( |
| 164 | + "not supported for v6 (Ironwood): the legacy half-signed format is v4-only; " + |
| 165 | + "rebuild the PSBT with createEmpty", |
| 166 | + ); |
| 167 | + } |
| 168 | + |
| 169 | + /** |
| 170 | + * Deserialize a v6 (Ironwood) Zcash PSBT from bytes. |
| 171 | + * |
| 172 | + * @param bytes - The PSBT bytes |
| 173 | + * @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec") |
| 174 | + * @throws Error if the deserialized PSBT is not a v6 (Ironwood) PSBT |
| 175 | + * @returns A ZcashIronwoodBitGoPsbt instance |
| 176 | + */ |
| 177 | + static override fromBytes(bytes: Uint8Array, network: ZcashNetworkName): ZcashIronwoodBitGoPsbt { |
| 178 | + const wasm = WasmBitGoPsbt.from_bytes(bytes, network); |
| 179 | + const psbt = new ZcashIronwoodBitGoPsbt(wasm); |
| 180 | + if (psbt.versionGroupId !== IRONWOOD_VERSION_GROUP_ID) { |
| 181 | + throw new Error( |
| 182 | + "not a v6 (Ironwood) PSBT: use ZcashBitGoPsbt.fromBytes for v4/Sapling-shaped PSBTs", |
| 183 | + ); |
| 184 | + } |
| 185 | + return psbt; |
| 186 | + } |
| 187 | + |
| 188 | + /** |
| 189 | + * Add the shielded output (Constructor role). Stores the orchard PCZT in the PSBT. |
| 190 | + * |
| 191 | + * Named for the shielded/transparent split rather than the note type, leaving room for a |
| 192 | + * transparent-output variant alongside the inherited `addOutput` / `addWalletOutput`. |
| 193 | + * |
| 194 | + * @param recipient - 43-byte raw Orchard/Ironwood address |
| 195 | + * @param amount - note value in zatoshi |
| 196 | + * @param options.anchor - 32-byte Ironwood note-commitment-tree root |
| 197 | + * @param options.memo - optional 512-byte memo (defaults to the ZIP-302 "no memo" encoding) |
| 198 | + * @param options.ovk - optional 32-byte outgoing viewing key (omit for a keyless build) |
| 199 | + */ |
| 200 | + addShieldedOutput( |
| 201 | + recipient: Uint8Array, |
| 202 | + amount: bigint, |
| 203 | + options: { anchor: Uint8Array; memo?: Uint8Array; ovk?: Uint8Array }, |
| 204 | + ): void { |
| 205 | + const memo = options.memo ?? zip302NoMemo(); |
| 206 | + this.wasm.add_ironwood_output(recipient, amount, options.ovk, options.anchor, memo); |
| 207 | + } |
| 208 | + |
| 209 | + /** |
| 210 | + * The canonical (display-order) ZIP-244 v6 txid as a lowercase hex string, matching |
| 211 | + * `ITransaction.getId()`. Defined once the transparent inputs/outputs and the shielded output are |
| 212 | + * in place; unchanged by signing or proving. |
| 213 | + */ |
| 214 | + getId(): string { |
| 215 | + return this.wasm.ironwood_v6_txid(); |
| 216 | + } |
| 217 | + |
| 218 | + /** |
| 219 | + * The ZIP-244 per-input transparent sighash (32 bytes) the key controlling transparent input |
| 220 | + * `index` must sign. |
| 221 | + */ |
| 222 | + transparentSighash(index: number): Uint8Array { |
| 223 | + return this.wasm.ironwood_v6_transparent_sighash(index); |
| 224 | + } |
| 225 | + |
| 226 | + /** |
| 227 | + * Ingest a transparent-input signature returned by the client/HSM, after verifying it against |
| 228 | + * {@link transparentSighash} for that input. |
| 229 | + * |
| 230 | + * @param index - transparent input index |
| 231 | + * @param pubkey - the signing public key |
| 232 | + * @param sig - DER ECDSA signature with the trailing SIGHASH_ALL byte (as in a scriptSig) |
| 233 | + */ |
| 234 | + addTransparentSignature(index: number, pubkey: Uint8Array, sig: Uint8Array): void { |
| 235 | + this.wasm.add_ironwood_v6_signature(index, pubkey, sig); |
| 236 | + } |
| 237 | + |
| 238 | + /** |
| 239 | + * Transaction Extractor role: given the external prover's `proof` bytes, finalize the |
| 240 | + * transparent inputs, apply the shielded binding signature, and return the broadcast-ready v6 |
| 241 | + * transaction bytes. Requires every transparent input to be signed via |
| 242 | + * {@link addTransparentSignature}. |
| 243 | + * |
| 244 | + * Terminal: the wasm binding drops the stored PCZT on success, so any later v6 call on this PSBT |
| 245 | + * throws — including after a `serialize`/{@link fromBytes} round-trip, since the PCZT is gone |
| 246 | + * from the bytes too. Build a fresh PSBT instead. On failure nothing is dropped, so a call that |
| 247 | + * errors (bad proof, unsigned input) leaves the PSBT retryable. |
| 248 | + */ |
| 249 | + combineProof(proof: Uint8Array): Uint8Array { |
| 250 | + return this.wasm.combine_ironwood_proof(proof); |
| 251 | + } |
| 252 | +} |
0 commit comments