Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/trezor-zcash-pczt-versions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@swapkit/wallet-hardware": patch
---

Support Trezor Zcash PCZT signing for both transparent v4 and v5 transactions.
88 changes: 75 additions & 13 deletions packages/wallet-hardware/src/trezor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }];
Expand Down Expand Up @@ -333,14 +333,13 @@ async function decodeOutputAddress(script: Uint8Array): Promise<string | undefin
return undefined;
}

async function extractSignaturesFromSignedTx(signedTxHex: string, pczt: PCZT): Promise<PCZT> {
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<PCZT> {
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) {
Expand All @@ -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[],
Expand Down Expand Up @@ -550,7 +612,7 @@ async function getTrezorWallet<T extends Chain>({
});
}

return extractSignaturesFromSignedTx(result.payload.serializedTx, pczt);
return extractSignaturesFromSignedZcashTx(result.payload.serializedTx, pczt);
},

signTransaction: async (tx: ZcashTransaction, utxoInputs: UTXOType[]) => {
Expand All @@ -562,15 +624,15 @@ async function getTrezorWallet<T extends Chain>({
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) {
Expand Down
100 changes: 100 additions & 0 deletions packages/wallet-hardware/test/trezor-zcash-pczt.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});