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..21214220 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, @@ -84,7 +87,7 @@ const SHIELD_REQUEST = { rk: "dd".repeat(32), out_ciphertext: "ee".repeat(80), value: 0, - is_spend: true, + is_spend: false, }, ], }; @@ -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,30 +146,32 @@ 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)); const signedPczt = new ZcashMessages.ZcashSignedPCZT(); - signedPczt.addSignatures(new Uint8Array(64).fill(0x42)); - signedPczt.addSignatures(new Uint8Array(64).fill(0x43)); const readResponse = jest.fn().mockResolvedValue({ message_enum: Messages.MessageType.MESSAGETYPE_ZCASHSIGNEDPCZT, @@ -283,13 +288,13 @@ describe("zcashSignPczt — shield tx (1 output, 1 input, 2 actions)", () => { expect(Buffer.from(capturedActionMsg[0].getRecipient_asU8()).toString("hex")).toBe("ab".repeat(43)); expect(capturedActionMsg[0].getRseed_asU8()).toHaveLength(32); expect(Buffer.from(capturedActionMsg[0].getRseed_asU8()).toString("hex")).toBe("cd".repeat(32)); - // action[1] is the dummy spend (is_spend=true) — value 0, no recipient/rseed - expect(capturedActionMsg[1].getIsSpend()).toBe(true); + // action[1] is also a dummy spend — transparent shielding has no Ironwood spend authorization. + expect(capturedActionMsg[1].getIsSpend()).toBe(false); expect(capturedActionMsg[1].getValue()).toBe(0); expect(capturedActionMsg[1].getRecipient_asU8()).toHaveLength(0); - // Orchard sigs returned, transparent sigs attached (from ZcashTransparentSigned) - expect(result).toHaveLength(2); + // Ironwood sigs returned, transparent sigs attached (from ZcashTransparentSigned) + expect(result).toHaveLength(0); expect(result._transparentSignatures).toHaveLength(1); }); @@ -384,13 +389,15 @@ describe("zcashSignPczt — shield tx (1 output, 1 input, 2 actions)", () => { throw new Error(`unexpected call: ${mtype}`); }); - const orchardOnlyRequest = { + const ironwoodOnlyRequest = { ...SHIELD_REQUEST, + bundle_meta: { ...SHIELD_REQUEST.bundle_meta, value_balance: 15000 }, + actions: SHIELD_REQUEST.actions.map((action, index) => ({ ...action, is_spend: index === 1 })), transparent_inputs: [], transparent_outputs: [], }; - const result = await zcashSignPczt(makeMockTransport(call), orchardOnlyRequest, SIGHASH); + const result = await zcashSignPczt(makeMockTransport(call), ironwoodOnlyRequest, SIGHASH); expect(calls).toEqual([ Messages.MessageType.MESSAGETYPE_ZCASHSIGNPCZT, @@ -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,14 +476,13 @@ 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[] = []; const signedPczt = new ZcashMessages.ZcashSignedPCZT(); signedPczt.addSignatures(new Uint8Array(64).fill(0x42)); - signedPczt.addSignatures(new Uint8Array(64).fill(0x43)); const call = jest.fn().mockImplementation((mtype: number, msg: any) => { calls.push(mtype); @@ -492,7 +500,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); @@ -566,7 +574,7 @@ describe("zcashSignPczt — deshield tx (1 output, 0 inputs, 2 actions)", () => expect(capturedActionMsg[1].getRseed_asU8()).toHaveLength(32); // Deshield has a transparent phase (outputs) but no ECDSA sigs (no transparent inputs) - expect(result).toHaveLength(2); + expect(result).toHaveLength(1); expect((result as any)._transparentSignatures).toEqual([]); }); }); diff --git a/packages/hdwallet-keepkey/src/zcash.ts b/packages/hdwallet-keepkey/src/zcash.ts index b32d06f3..5ee9fe63 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,9 @@ 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) { diff --git a/yarn.lock b/yarn.lock index c43deb2b..7ac797ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1151,10 +1151,10 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@keepkey/device-protocol@npm:@bithighlander/device-protocol@7.16.0": - version "7.16.0" - resolved "https://registry.yarnpkg.com/@bithighlander/device-protocol/-/device-protocol-7.16.0.tgz#b492a791ddd35ad1e47129cbabce427f43a974bc" - integrity sha512-Wo6Kyy2xBiGFLsDfOZElVP8bijYsem7iBzJ1IepurpyM9sT7TaSeGw9s4kq5pZyXZb9nWkmYNYq7VE3YFvvl6Q== +"@keepkey/device-protocol@npm:@bithighlander/device-protocol@7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@bithighlander/device-protocol/-/device-protocol-7.17.0.tgz#23450420224f25475b336687504594d67496b120" + integrity sha512-Nw7hb5ZTqEHyOWvrdb8o7rvT+H79X9EJHR9O7VIv0xQ2711o0Q3Dga8YSYW21MWU7wBoNQ87NcJ9XLEKW+gdJQ== dependencies: google-protobuf "^3.7.0-rc.2" pbjs "^0.0.5"