From 68ec67d47ccef8e936fd6f8c12a9bc22b3db3d35 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 13 May 2026 16:49:40 +0400 Subject: [PATCH] Support Trezor Zcash PCZT versions --- .changeset/trezor-zcash-pczt-versions.md | 5 + packages/wallet-hardware/src/trezor/index.ts | 88 ++++++++++++--- .../test/trezor-zcash-pczt.test.ts | 100 ++++++++++++++++++ 3 files changed, 180 insertions(+), 13 deletions(-) create mode 100644 .changeset/trezor-zcash-pczt-versions.md create mode 100644 packages/wallet-hardware/test/trezor-zcash-pczt.test.ts diff --git a/.changeset/trezor-zcash-pczt-versions.md b/.changeset/trezor-zcash-pczt-versions.md new file mode 100644 index 0000000..7da3f69 --- /dev/null +++ b/.changeset/trezor-zcash-pczt-versions.md @@ -0,0 +1,5 @@ +--- +"@swapkit/wallet-hardware": patch +--- + +Support Trezor Zcash PCZT signing for both transparent v4 and v5 transactions. diff --git a/packages/wallet-hardware/src/trezor/index.ts b/packages/wallet-hardware/src/trezor/index.ts index f3f258a..afab8f2 100644 --- a/packages/wallet-hardware/src/trezor/index.ts +++ b/packages/wallet-hardware/src/trezor/index.ts @@ -23,7 +23,7 @@ import { type UTXOType, } from "@swapkit/toolboxes/utxo"; import type { BTCNetwork, PCZT, Transaction, ZcashTransaction } from "@swapkit/utxo-signer"; -import { BCHSigHash, NETWORKS, ZcashConsensusBranchId, ZcashVersionGroupId } from "@swapkit/utxo-signer"; +import { BCHSigHash, NETWORKS } from "@swapkit/utxo-signer"; import { createWallet, getWalletSupportedChains, type HardwareExtendedPublicKeyInfo } from "@swapkit/wallet-core"; type TrezorBip32Derivation = [Uint8Array, { fingerprint: number; path: number[] }]; @@ -333,14 +333,13 @@ async function decodeOutputAddress(script: Uint8Array): Promise { - const { ZcashTransaction: ZcashTx, Script } = await import("@swapkit/utxo-signer"); - const signedTx = ZcashTx.fromHex(signedTxHex, { allowUnknownOutputs: true }); +export async function extractSignaturesFromSignedZcashTx(signedTxHex: string, pczt: PCZT): Promise { + const { Script } = await import("@swapkit/utxo-signer"); const signedPczt = pczt.clone(); + const inputScripts = extractTransparentZcashInputScripts(signedTxHex, pczt.getGlobal().txVersion); - for (let i = 0; i < signedTx.inputsLength; i++) { - const signedInput = signedTx.getInput(i); - const script = signedInput.script; + for (let i = 0; i < inputScripts.length; i++) { + const script = inputScripts[i]; if (script && script.length > 0) { const scriptParts = Script.decode(script); if (scriptParts.length >= 2) { @@ -351,6 +350,69 @@ async function extractSignaturesFromSignedTx(signedTxHex: string, pczt: PCZT): P return signedPczt; } +function extractTransparentZcashInputScripts(signedTxHex: string, txVersion: number) { + const buffer = Buffer.from(signedTxHex, "hex"); + let offset = 0; + const versionHeader = readUInt32LE(buffer, offset); + offset += 4; + const actualVersion = versionHeader & 0x7fffffff; + offset += 4; // versionGroupId + + if (actualVersion === 5 || txVersion === 5) { + offset += 12; // consensusBranchId + lockTime + expiryHeight + } + + const inputCount = readCompactSize(buffer, offset); + offset = inputCount.offset; + const scripts: Uint8Array[] = []; + + for (let i = 0; i < inputCount.value; i++) { + offset += 36; // txid + prevout index + const script = readCompactBytes(buffer, offset); + offset = script.offset; + offset += 4; // sequence + scripts.push(script.value); + } + + return scripts; +} + +function readCompactBytes(buffer: Buffer, offset: number) { + const length = readCompactSize(buffer, offset); + const start = length.offset; + const end = start + length.value; + + return { offset: end, value: new Uint8Array(buffer.subarray(start, end)) }; +} + +function readCompactSize(buffer: Buffer, offset: number): { offset: number; value: number } { + const first = buffer[offset]; + if (first === undefined) { + throw new SwapKitError({ + errorKey: "wallet_trezor_failed_to_sign_transaction", + info: { error: "Unexpected end of Zcash transaction" }, + }); + } + + if (first < 0xfd) return { offset: offset + 1, value: first }; + if (first === 0xfd) return { offset: offset + 3, value: buffer.readUInt16LE(offset + 1) }; + if (first === 0xfe) return { offset: offset + 5, value: buffer.readUInt32LE(offset + 1) }; + + const value = buffer.readBigUInt64LE(offset + 1); + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new SwapKitError({ + errorKey: "wallet_trezor_failed_to_sign_transaction", + info: { error: "Zcash varint too large" }, + }); + } + + return { offset: offset + 9, value: Number(value) }; +} + +function readUInt32LE(buffer: Buffer, offset: number) { + return buffer.readUInt32LE(offset); +} + function buildZcashTxInputsForTrezor( tx: ZcashTransaction, utxoInputs: UTXOType[], @@ -550,7 +612,7 @@ async function getTrezorWallet({ }); } - return extractSignaturesFromSignedTx(result.payload.serializedTx, pczt); + return extractSignaturesFromSignedZcashTx(result.payload.serializedTx, pczt); }, signTransaction: async (tx: ZcashTransaction, utxoInputs: UTXOType[]) => { @@ -562,15 +624,15 @@ async function getTrezorWallet({ const outputs = buildZcashTxOutputsForTrezor(tx, address_n, address, chain); const result = await TrezorConnect.signTransaction({ - branchId: ZcashConsensusBranchId.NU6, + branchId: tx.consensusBranchId, coin: "zcash", - expiry: 0, + expiry: tx.expiryHeight, inputs, - locktime: 0, + locktime: tx.lockTime, outputs: outputs as any, overwintered: true, - version: 4, - versionGroupId: ZcashVersionGroupId.SAPLING, + version: tx.version, + versionGroupId: tx.versionGroupId, }); if (result.success) { diff --git a/packages/wallet-hardware/test/trezor-zcash-pczt.test.ts b/packages/wallet-hardware/test/trezor-zcash-pczt.test.ts new file mode 100644 index 0000000..97c9e23 --- /dev/null +++ b/packages/wallet-hardware/test/trezor-zcash-pczt.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "bun:test"; +import { createPCZT, Script, ZcashConsensusBranchId, ZcashVersionGroupId } from "@swapkit/utxo-signer"; + +import { extractSignaturesFromSignedZcashTx } from "../src/trezor"; + +const signature = new Uint8Array([0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02, 0x01]); +const pubkey = new Uint8Array([0x02, ...Array(32).fill(1)]); +const scriptPubkey = new Uint8Array([0x76, 0xa9, 0x14, ...Array(20).fill(2), 0x88, 0xac]); +const scriptSig = Script.encode([signature, pubkey]); + +function uint32LE(value: number) { + const buffer = Buffer.alloc(4); + buffer.writeUInt32LE(value >>> 0); + return buffer; +} + +function uint64LE(value: bigint) { + const buffer = Buffer.alloc(8); + buffer.writeBigUInt64LE(value); + return buffer; +} + +function compactSize(value: number) { + return Buffer.from([value]); +} + +function transparentInput(script: Uint8Array) { + return Buffer.concat([ + Buffer.alloc(32, 1), + uint32LE(0), + compactSize(script.length), + Buffer.from(script), + uint32LE(0xffffffff), + ]); +} + +function transparentOutput() { + return Buffer.concat([uint64LE(1n), compactSize(scriptPubkey.length), Buffer.from(scriptPubkey)]); +} + +function signedZcashV4Hex() { + return Buffer.concat([ + uint32LE(0x80000004), + uint32LE(ZcashVersionGroupId.SAPLING), + compactSize(1), + transparentInput(scriptSig), + compactSize(1), + transparentOutput(), + uint32LE(0), + uint32LE(0), + Buffer.alloc(11), + ]).toString("hex"); +} + +function signedZcashV5Hex() { + return Buffer.concat([ + uint32LE(0x80000005), + uint32LE(ZcashVersionGroupId.NU5), + uint32LE(ZcashConsensusBranchId.NU6_1), + uint32LE(0), + uint32LE(0), + compactSize(1), + transparentInput(scriptSig), + compactSize(1), + transparentOutput(), + Buffer.from([0, 0, 0]), + ]).toString("hex"); +} + +function pcztForVersion(version: 4 | 5) { + const pczt = createPCZT({ + consensusBranchId: ZcashConsensusBranchId.NU6_1, + expiryHeight: 0, + lockTime: 0, + version, + versionGroupId: version === 5 ? ZcashVersionGroupId.NU5 : ZcashVersionGroupId.SAPLING, + }); + + pczt.addInput({ index: 0, scriptPubkey, txid: new Uint8Array(32).fill(1), value: 1n }); + + return pczt; +} + +describe("Trezor Zcash PCZT signing", () => { + it("extracts signatures from signed transparent v4 transactions", async () => { + const signedPczt = await extractSignaturesFromSignedZcashTx(signedZcashV4Hex(), pcztForVersion(4)); + const input = signedPczt.getInput(0); + + expect(input.partialSig?.[0]?.[0]).toEqual(pubkey); + expect(input.partialSig?.[0]?.[1]).toEqual(signature); + }); + + it("extracts signatures from signed transparent v5 transactions", async () => { + const signedPczt = await extractSignaturesFromSignedZcashTx(signedZcashV5Hex(), pcztForVersion(5)); + const input = signedPczt.getInput(0); + + expect(input.partialSig?.[0]?.[0]).toEqual(pubkey); + expect(input.partialSig?.[0]?.[1]).toEqual(signature); + }); +});