From fb05dda6069ab4ee72e11afd7f19433ca1e10994 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 30 Jul 2026 16:11:33 -0300 Subject: [PATCH] feat(zcash): forward Ironwood PCZT metadata --- packages/hdwallet-keepkey/package.json | 2 +- packages/hdwallet-keepkey/src/typeRegistry.ts | 2 +- packages/hdwallet-keepkey/src/zcash.test.ts | 45 +++++++++++-------- packages/hdwallet-keepkey/src/zcash.ts | 21 +++++---- 4 files changed, 42 insertions(+), 28 deletions(-) diff --git a/packages/hdwallet-keepkey/package.json b/packages/hdwallet-keepkey/package.json index 0cae5d61..79141034 100644 --- a/packages/hdwallet-keepkey/package.json +++ b/packages/hdwallet-keepkey/package.json @@ -17,7 +17,7 @@ "dependencies": { "@ethereumjs/common": "^2.4.0", "@ethereumjs/tx": "^3.3.0", - "@keepkey/device-protocol": "npm:@bithighlander/device-protocol@7.16.0", + "@keepkey/device-protocol": "npm:@bithighlander/device-protocol@7.17.0", "@keepkey/hdwallet-core": "1.53.16", "@keepkey/proto-tx-builder": "^0.9.1", "@shapeshiftoss/bitcoinjs-lib": "5.2.0-shapeshift.2", diff --git a/packages/hdwallet-keepkey/src/typeRegistry.ts b/packages/hdwallet-keepkey/src/typeRegistry.ts index 76b91568..8b923c27 100644 --- a/packages/hdwallet-keepkey/src/typeRegistry.ts +++ b/packages/hdwallet-keepkey/src/typeRegistry.ts @@ -38,7 +38,7 @@ const AllMessages = ([] as Array<[string, core.Constructor]>) .concat(Object.entries(TonMessages)) .concat(Object.entries(TronMessages)) .concat(Object.entries(MayachainMessages)) - .concat(Object.entries(ZcashMessages)); + .concat(Object.entries(omit(ZcashMessages, "ZcashShieldedPool", "ZcashShieldedPoolMap"))); const upperCasedMessageClasses = AllMessages.reduce((registry, entry: [string, core.Constructor]) => { registry[entry[0].toUpperCase()] = entry[1]; diff --git a/packages/hdwallet-keepkey/src/zcash.test.ts b/packages/hdwallet-keepkey/src/zcash.test.ts index ad42d57b..f86a048c 100644 --- a/packages/hdwallet-keepkey/src/zcash.test.ts +++ b/packages/hdwallet-keepkey/src/zcash.test.ts @@ -15,14 +15,16 @@ import * as ZcashMessages from "@keepkey/device-protocol/lib/messages-zcash_pb"; import { zcashSignPczt } from "./zcash"; // Realistic signing request that mirrors what the Rust sidecar returns for a -// shield transaction: 1 transparent input, 1 transparent output, 2 Orchard actions. +// NU6.3 shield transaction: 1 transparent input, 1 transparent output, +// 2 Ironwood actions. const SHIELD_REQUEST = { n_actions: 2, + pool: "ironwood" as const, account: 0, - branch_id: 0x4dec4df0, + branch_id: 0x37a5165b, header_fields: { - tx_version: 5, - version_group_id: 0x26a7270a, + tx_version: 6, + version_group_id: 0xd884b698, lock_time: 0, expiry_height: 0, }, @@ -31,6 +33,7 @@ const SHIELD_REQUEST = { transparent: "f6424c87af931906154bc15c40fa50b9323fc99271e5c1a98c2d9cc214eb9f94", // sapling intentionally absent — firmware rejects it if set orchard: "a8554ee3a53af330a6b6cf56112a203d3d028f2e421cb494a3f590161d27414a", + ironwood: "d3a4b955c966b1bb59ebb541584c5e8fb51b5d10d76308b63767db4fefc01e59", }, bundle_meta: { flags: 3, @@ -126,11 +129,11 @@ describe("zcashSignPczt — shield tx (1 output, 1 input, 2 actions)", () => { const msg = capturedMsg[0]; // Transaction header fields (firmware recomputes header_digest from these) - expect(msg.getTxVersion()).toBe(5); - expect(msg.getVersionGroupId()).toBe(0x26a7270a); + expect(msg.getTxVersion()).toBe(6); + expect(msg.getVersionGroupId()).toBe(0xd884b698); expect(msg.getLockTime()).toBe(0); expect(msg.getExpiryHeight()).toBe(0); - expect(msg.getBranchId()).toBe(0x4dec4df0); + expect(msg.getBranchId()).toBe(0x37a5165b); // Sub-digests expect(Buffer.from(msg.getHeaderDigest_asU8()).toString("hex")).toBe( @@ -143,24 +146,28 @@ describe("zcashSignPczt — shield tx (1 output, 1 input, 2 actions)", () => { expect(Buffer.from(msg.getOrchardDigest_asU8()).toString("hex")).toBe( "a8554ee3a53af330a6b6cf56112a203d3d028f2e421cb494a3f590161d27414a" ); + expect(Buffer.from(msg.getIronwoodDigest_asU8()).toString("hex")).toBe( + "d3a4b955c966b1bb59ebb541584c5e8fb51b5d10d76308b63767db4fefc01e59" + ); + expect(msg.getShieldedPool()).toBe(1); // Transparent counts expect(msg.getNTransparentOutputs()).toBe(1); expect(msg.getNTransparentInputs()).toBe(1); - // Orchard + // Ironwood reuses the Orchard-family bundle metadata fields. expect(msg.getNActions()).toBe(2); expect(msg.getOrchardFlags()).toBe(3); }); - it("follows full output→input→Orchard protocol sequence", async () => { + it("follows full output→input→Ironwood protocol sequence", async () => { const calls: number[] = []; const capturedOutputMsg: ZcashMessages.ZcashTransparentOutput[] = []; const capturedInputMsg: ZcashMessages.ZcashTransparentInput[] = []; const capturedActionMsg: ZcashMessages.ZcashPCZTAction[] = []; // Firmware 7.15+ sends ZcashTransparentSigned + ZcashSignedPCZT back-to-back after - // the last Orchard action. readResponse is called without sending to drain the second. + // the last Ironwood action. readResponse is called without sending to drain the second. const transparentSigned = new ZcashMessages.ZcashTransparentSigned(); transparentSigned.addSignatures(new Uint8Array(71).fill(0x30)); @@ -288,7 +295,7 @@ describe("zcashSignPczt — shield tx (1 output, 1 input, 2 actions)", () => { expect(capturedActionMsg[1].getValue()).toBe(0); expect(capturedActionMsg[1].getRecipient_asU8()).toHaveLength(0); - // Orchard sigs returned, transparent sigs attached (from ZcashTransparentSigned) + // Ironwood sigs returned, transparent sigs attached (from ZcashTransparentSigned) expect(result).toHaveLength(2); expect(result._transparentSignatures).toHaveLength(1); }); @@ -402,18 +409,20 @@ describe("zcashSignPczt — shield tx (1 output, 1 input, 2 actions)", () => { }); }); -// Deshield (Z→T): 1 transparent output, 0 transparent inputs, 2 Orchard actions. +// Deshield (Z→T): 1 transparent output, 0 transparent inputs, 2 Ironwood actions. // After the last transparent output firmware sends ZcashPCZTActionAck (not TransparentAck) -// because there are no inputs — it finalises the transparent digest and opens Orchard directly. +// because there are no inputs — it finalises the transparent digest and opens Ironwood directly. const DESHIELD_REQUEST = { n_actions: 2, + pool: "ironwood" as const, account: 0, - branch_id: 0x4dec4df0, - header_fields: { tx_version: 5, version_group_id: 0x26a7270a, lock_time: 0, expiry_height: 0 }, + branch_id: 0x37a5165b, + header_fields: { tx_version: 6, version_group_id: 0xd884b698, lock_time: 0, expiry_height: 0 }, digests: { header: "59bc2475723880114749687687be420e7e3389ce82e0ad6b9ba62e0a28457d3d", transparent: "0a259ca3000000000000000000000000000000000000000000000000000000ff", orchard: "d0f62785000000000000000000000000000000000000000000000000000000ff", + ironwood: "d3a4b955c966b1bb59ebb541584c5e8fb51b5d10d76308b63767db4fefc01e59", }, bundle_meta: { flags: 3, @@ -427,7 +436,7 @@ const DESHIELD_REQUEST = { transparent_inputs: [], actions: [ { - // Change output back to Orchard — is_spend=false, value is the OUTPUT note value (change amount) + // Change output back to Ironwood — is_spend=false, value is the OUTPUT note value. index: 0, alpha: "aa".repeat(32), cv_net: "bb".repeat(32), @@ -467,7 +476,7 @@ const DESHIELD_REQUEST = { }; describe("zcashSignPczt — deshield tx (1 output, 0 inputs, 2 actions)", () => { - it("follows output→ZcashPCZTActionAck→Orchard protocol (no TransparentAck after last output)", async () => { + it("follows output→ZcashPCZTActionAck→Ironwood protocol (no TransparentAck after last output)", async () => { const calls: number[] = []; const capturedOutputMsg: ZcashMessages.ZcashTransparentOutput[] = []; const capturedActionMsg: ZcashMessages.ZcashPCZTAction[] = []; @@ -492,7 +501,7 @@ describe("zcashSignPczt — deshield tx (1 output, 0 inputs, 2 actions)", () => } // Step 2: ZcashTransparentOutput → ZcashPCZTActionAck(0) - // No inputs → firmware finalises transparent digest and opens Orchard directly. + // No inputs → firmware finalises transparent digest and opens Ironwood directly. // Regression: previously threw "expected TransparentAck after output 0, got ZCASHPCZTACTIONACK" if (mtype === Messages.MessageType.MESSAGETYPE_ZCASHTRANSPARENTOUTPUT) { capturedOutputMsg.push(msg); diff --git a/packages/hdwallet-keepkey/src/zcash.ts b/packages/hdwallet-keepkey/src/zcash.ts index b32d06f3..fb5cde5b 100644 --- a/packages/hdwallet-keepkey/src/zcash.ts +++ b/packages/hdwallet-keepkey/src/zcash.ts @@ -116,9 +116,10 @@ export async function zcashSignPczt( transport: Transport, signingRequest: { n_actions: number; + pool?: "orchard" | "ironwood"; account?: number; branch_id?: number; - digests?: { header?: string; transparent?: string; sapling?: string; orchard?: string }; + digests?: { header?: string; transparent?: string; sapling?: string; orchard?: string; ironwood?: string }; bundle_meta?: { flags: number; value_balance: number; anchor: string }; header_fields?: { tx_version: number; version_group_id: number; lock_time: number; expiry_height: number }; actions: Array<{ @@ -157,6 +158,7 @@ export async function zcashSignPczt( signMsg.setBranchId(signingRequest.branch_id ?? 0x37519621); signMsg.setAddressNList([0x80000000 + 32, 0x80000000 + 133, 0x80000000 + account]); signMsg.setAccount(account); + signMsg.setShieldedPool(signingRequest.pool === "ironwood" ? 1 : 0); const totalZat = Math.round(parseFloat(signingRequest.display.amount.replace(" ZEC", "")) * 1e8); const feeZat = Math.round(parseFloat(signingRequest.display.fee.replace(" ZEC", "")) * 1e8); @@ -179,9 +181,10 @@ export async function zcashSignPczt( if (d.transparent) signMsg.setTransparentDigest(hexToBytes(d.transparent)); if (d.sapling) signMsg.setSaplingDigest(hexToBytes(d.sapling)); if (d.orchard) signMsg.setOrchardDigest(hexToBytes(d.orchard)); + if (d.ironwood) signMsg.setIronwoodDigest(hexToBytes(d.ironwood)); } - // Orchard bundle metadata + // Orchard-family bundle metadata (field names are shared with Ironwood) const bm = signingRequest.bundle_meta; if (bm) { signMsg.setOrchardFlags(bm.flags); @@ -204,6 +207,8 @@ export async function zcashSignPczt( transparent_digest: d?.transparent ? d.transparent.slice(0, 8) + "..." : undefined, sapling_digest: d?.sapling ?? "(absent)", orchard_digest: d?.orchard ? d.orchard.slice(0, 8) + "..." : undefined, + ironwood_digest: d?.ironwood ? d.ironwood.slice(0, 8) + "..." : undefined, + shielded_pool: signingRequest.pool ?? "orchard", orchard_flags: bm?.flags, orchard_value_balance: bm?.value_balance, n_transparent_outputs: nTransparentOutputs, @@ -272,7 +277,7 @@ export async function zcashSignPczt( console.info(`[zcash-pczt] ← output[${i}] response:`, response.message_type, response.message_enum); // After the last output with no transparent inputs, firmware skips straight - // to Orchard and sends ZcashPCZTActionAck(0) instead of TransparentAck. + // to the shielded action stream and sends ZcashPCZTActionAck(0). if (response.message_enum === Messages.MessageType.MESSAGETYPE_ZCASHPCZTACTIONACK) { break; } @@ -344,7 +349,7 @@ export async function zcashSignPczt( // Firmware 7.15+: after last input, sends ZcashPCZTActionAck(0) and buffers // transparent ECDSA sigs internally — they come out with ZcashTransparentSigned - // BEFORE ZcashSignedPCZT after the last Orchard action. + // BEFORE ZcashSignedPCZT after the last shielded action. if (response.message_enum === Messages.MessageType.MESSAGETYPE_ZCASHPCZTACTIONACK) { break; } @@ -368,7 +373,7 @@ export async function zcashSignPczt( } } - // Step 3: Stream Orchard actions to device. + // Step 3: Stream Orchard/Ironwood actions to device. // Firmware always sends ZcashPCZTActionAck before each action (including the first — // the ack after the last transparent input doubles as action[0] ack). const orchardSignatures: string[] = []; @@ -377,7 +382,7 @@ export async function zcashSignPczt( if (response.message_enum === Messages.MessageType.MESSAGETYPE_ZCASHSIGNEDPCZT) { break; } - throw new Error(`zcash: unexpected response during Orchard signing: ${response.message_type}`); + throw new Error(`zcash: unexpected response during shielded signing: ${response.message_type}`); } const action = signingRequest.actions[i]; @@ -440,7 +445,7 @@ export async function zcashSignPczt( console.info(`[zcash-pczt] ← readResponse:`, response.message_type, response.message_enum); } - // Step 5: Collect Orchard signatures + // Step 5: Collect active-pool signatures if (response.message_enum !== Messages.MessageType.MESSAGETYPE_ZCASHSIGNEDPCZT) { throw new Error(`zcash: expected ZcashSignedPCZT, got ${response.message_type}`); } @@ -451,7 +456,7 @@ export async function zcashSignPczt( } console.info( - `[zcash-pczt] DONE: ${orchardSignatures.length} Orchard sig(s), ${transparentSignatures.length} transparent sig(s)` + `[zcash-pczt] DONE: ${orchardSignatures.length} ${signingRequest.pool ?? "orchard"} sig(s), ${transparentSignatures.length} transparent sig(s)` ); if (!hasTransparentPhase) {