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
2 changes: 1 addition & 1 deletion packages/hdwallet-keepkey/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/hdwallet-keepkey/src/typeRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const AllMessages = ([] as Array<[string, core.Constructor<jspb.Message>]>)
.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<jspb.Message>]) => {
registry[entry[0].toUpperCase()] = entry[1];
Expand Down
45 changes: 27 additions & 18 deletions packages/hdwallet-keepkey/src/zcash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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));

Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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[] = [];
Expand All @@ -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);
Expand Down
21 changes: 13 additions & 8 deletions packages/hdwallet-keepkey/src/zcash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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[] = [];
Expand All @@ -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];
Expand Down Expand Up @@ -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}`);
}
Expand All @@ -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) {
Expand Down
Loading