import { log } from "@ledgerhq/logs"; import type Transport from "@ledgerhq/hw-transport"; import { hashPublicKey } from "./hashPublicKey"; import { getWalletPublicKey } from "./getWalletPublicKey"; import type { AddressFormat } from "./getWalletPublicKey"; import { getTrustedInput } from "./getTrustedInput"; import { startUntrustedHashTransactionInput } from "./startUntrustedHashTransactionInput"; import { serializeTransaction } from "./serializeTransaction"; import { getTrustedInputBIP143 } from "./getTrustedInputBIP143"; import { compressPublicKey } from "./compressPublicKey"; import { signTransaction } from "./signTransaction"; import { hashOutputFull, provideOutputFullChangePath } from "./finalizeInput"; import { getAppAndVersion } from "./getAppAndVersion"; import type { TransactionOutput, Transaction } from "./types"; import { DEFAULT_LOCKTIME, DEFAULT_SEQUENCE, SIGHASH_ALL, OP_DUP, OP_HASH160, HASH_SIZE, OP_EQUALVERIFY, OP_CHECKSIG, ZCASH_ACTIVATION_HEIGHTS, } from "./constants"; import { shouldUseTrustedInputForSegwit } from "./shouldUseTrustedInputForSegwit"; export type { AddressFormat }; const defaultsSignTransaction = { lockTime: DEFAULT_LOCKTIME, sigHashType: SIGHASH_ALL, segwit: false, additionals: [], onDeviceStreaming: _e => {}, onDeviceSignatureGranted: () => {}, onDeviceSignatureRequested: () => {}, }; export const getZcashBranchId = (blockHeight: number | null | undefined): Buffer => { const branchId = Buffer.alloc(4); if (!blockHeight || blockHeight >= ZCASH_ACTIVATION_HEIGHTS.NU6_1) { // NOTE: null and undefined should default to latest version branchId.writeUInt32LE(0x4dec4df0, 0); } else if (blockHeight >= ZCASH_ACTIVATION_HEIGHTS.NU6) { branchId.writeUInt32LE(0xc8e71055, 0); } else if (blockHeight >= ZCASH_ACTIVATION_HEIGHTS.NU5) { branchId.writeUInt32LE(0xc2d6d0b4, 0); } else if (blockHeight >= ZCASH_ACTIVATION_HEIGHTS.CANOPY) { branchId.writeUInt32LE(0xe9ff75a6, 0); } else if (blockHeight >= ZCASH_ACTIVATION_HEIGHTS.HEARTWOOD) { branchId.writeUInt32LE(0xf5b9230b, 0); } else if (blockHeight >= ZCASH_ACTIVATION_HEIGHTS.BLOSSOM) { branchId.writeUInt32LE(0x2bb40e60, 0); } else if (blockHeight >= ZCASH_ACTIVATION_HEIGHTS.SAPLING) { branchId.writeUInt32LE(0x76b809bb, 0); } else { branchId.writeUInt32LE(0x5ba81b19, 0); } return branchId; }; export const getDefaultVersions = ({ isZcash, sapling, isDecred, expiryHeight, }: { isZcash: boolean; sapling: boolean; isDecred: boolean; expiryHeight: Buffer | undefined; }): { defaultVersion: Buffer } => { const defaultVersion = Buffer.alloc(4); if (!!expiryHeight && !isDecred) { if (isZcash) { defaultVersion.writeUInt32LE(0x80000005, 0); } else if (sapling) { defaultVersion.writeUInt32LE(0x80000004, 0); } else { defaultVersion.writeUInt32LE(0x80000003, 0); } } else { defaultVersion.writeUInt32LE(1, 0); } return { defaultVersion }; }; /** * */ export type CreateTransactionArg = { inputs: Array< [ Transaction, number, string | null | undefined, number | null | undefined, (number | null | undefined)?, ] >; associatedKeysets: string[]; changePath?: string; outputScriptHex: string; lockTime?: number; blockHeight?: number; sigHashType?: number; segwit?: boolean; additionals: Array; expiryHeight?: Buffer; useTrustedInputForSegwit?: boolean; onDeviceStreaming?: (arg0: { progress: number; total: number; index: number }) => void; onDeviceSignatureRequested?: () => void; onDeviceSignatureGranted?: () => void; }; export async function createTransaction( transport: Transport, arg: CreateTransactionArg, ): Promise { const signTx = { ...defaultsSignTransaction, ...arg }; const { inputs, associatedKeysets, blockHeight, changePath, outputScriptHex, lockTime, sigHashType, segwit, additionals, expiryHeight, onDeviceStreaming, onDeviceSignatureGranted, onDeviceSignatureRequested, } = signTx; let useTrustedInputForSegwit = signTx.useTrustedInputForSegwit; if (useTrustedInputForSegwit === undefined) { try { const a = await getAppAndVersion(transport); useTrustedInputForSegwit = shouldUseTrustedInputForSegwit(a); } catch (e: any) { if (e.statusCode === 0x6d00) { useTrustedInputForSegwit = false; } else { throw e; } } } // loop: 0 or 1 (before and after) // i: index of the input being streamed // i goes on 0...n, inluding n. in order for the progress value to go to 1 // we normalize the 2 loops to make a global percentage const notify = (loop, i) => { const { length } = inputs; if (length < 3) return; // there is not enough significant event to worth notifying (aka just use a spinner) const index = length * loop + i; const total = 2 * length; const progress = index / total; onDeviceStreaming({ progress, total, index, }); }; const isDecred = additionals.includes("decred"); const isZcash = additionals.includes("zcash"); const sapling = additionals.includes("sapling"); const bech32 = segwit && additionals.includes("bech32"); const useBip143 = segwit || (!!additionals && (additionals.includes("abc") || additionals.includes("gold") || additionals.includes("bip143"))) || (!!expiryHeight && !isDecred); // Inputs are provided as arrays of [transaction, output_index, optional redeem script, optional sequence] // associatedKeysets are provided as arrays of [path] const lockTimeBuffer = Buffer.alloc(4); lockTimeBuffer.writeUInt32LE(lockTime, 0); const nullScript = Buffer.alloc(0); const nullPrevout = Buffer.alloc(0); const { defaultVersion } = getDefaultVersions({ isZcash, sapling, isDecred, expiryHeight, }); // Default version to 2 for XST not to have timestamp const trustedInputs: Array = []; const regularOutputs: Array = []; const signatures: Buffer[] = []; const publicKeys: Buffer[] = []; let firstRun = true; const resuming = false; const targetTransaction: Transaction = { inputs: [], version: defaultVersion, timestamp: Buffer.alloc(0), }; const outputScript = Buffer.from(outputScriptHex, "hex"); notify(0, 0); // first pass on inputs to get trusted inputs for (const input of inputs) { if (!resuming) { if (isZcash) { input[0].consensusBranchId = getZcashBranchId(input[4]); } const trustedInput = useBip143 && !useTrustedInputForSegwit ? getTrustedInputBIP143(input[1], input[0], additionals) : await getTrustedInput(transport, input[1], input[0], additionals); log("hw", "got trustedInput=" + trustedInput); const sequence = Buffer.alloc(4); sequence.writeUInt32LE( input.length >= 4 && typeof input[3] === "number" ? input[3] : DEFAULT_SEQUENCE, 0, ); trustedInputs.push({ trustedInput: true, value: Buffer.from(trustedInput, "hex"), sequence, }); } const { outputs } = input[0]; const index = input[1]; if (outputs && index <= outputs.length - 1) { regularOutputs.push(outputs[index]); } if (expiryHeight && !isDecred) { targetTransaction.nVersionGroupId = Buffer.from( // nVersionGroupId is 0x26A7270A for zcash NU5 upgrade // refer to https://github.com/zcash/zcash/blob/master/src/primitives/transaction.h isZcash ? [0x0a, 0x27, 0xa7, 0x26] : sapling ? [0x85, 0x20, 0x2f, 0x89] : [0x70, 0x82, 0xc4, 0x03], ); targetTransaction.nExpiryHeight = expiryHeight; // For sapling : valueBalance (8), nShieldedSpend (1), nShieldedOutput (1), nJoinSplit (1) // Overwinter : use nJoinSplit (1) targetTransaction.extraData = Buffer.from( sapling ? [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] : [0x00], ); } else if (isDecred) { targetTransaction.nExpiryHeight = expiryHeight; } } targetTransaction.inputs = inputs.map((input, idx) => { const sequence = Buffer.alloc(4); sequence.writeUInt32LE( input.length >= 4 && typeof input[3] === "number" ? input[3] : DEFAULT_SEQUENCE, 0, ); return { script: isZcash ? regularOutputs[idx].script : nullScript, prevout: nullPrevout, sequence, }; }); if (!resuming) { // Collect public keys const result: { publicKey: string; bitcoinAddress: string; chainCode: string; }[] = []; for (let i = 0; i < inputs.length; i++) { const r = await getWalletPublicKey(transport, { path: associatedKeysets[i], }); notify(0, i + 1); result.push(r); } for (let i = 0; i < result.length; i++) { publicKeys.push(compressPublicKey(Buffer.from(result[i].publicKey, "hex"))); } } onDeviceSignatureRequested(); targetTransaction.consensusBranchId = getZcashBranchId(blockHeight); if (useBip143) { // Do the first run with all inputs await startUntrustedHashTransactionInput( transport, true, targetTransaction, trustedInputs, true, !!expiryHeight, additionals, useTrustedInputForSegwit, ); if (!resuming && changePath) { await provideOutputFullChangePath(transport, changePath); } await hashOutputFull(transport, outputScript); } if (!!expiryHeight && !isDecred) { await signTransaction(transport, "", lockTime, SIGHASH_ALL, expiryHeight); } // Do the second run with the individual transaction for (let i = 0; i < inputs.length; i++) { const input = inputs[i]; const script = inputs[i].length >= 3 && typeof input[2] === "string" ? Buffer.from(input[2], "hex") : !segwit ? regularOutputs[i].script : Buffer.concat([ Buffer.from([OP_DUP, OP_HASH160, HASH_SIZE]), hashPublicKey(publicKeys[i]), Buffer.from([OP_EQUALVERIFY, OP_CHECKSIG]), ]); const pseudoTX = Object.assign({}, targetTransaction); const pseudoTrustedInputs = useBip143 ? [trustedInputs[i]] : trustedInputs; if (useBip143) { pseudoTX.inputs = [{ ...pseudoTX.inputs[i], script }]; } else { pseudoTX.inputs[i].script = script; } await startUntrustedHashTransactionInput( transport, !useBip143 && firstRun, pseudoTX, pseudoTrustedInputs, useBip143, !!expiryHeight && !isDecred, additionals, useTrustedInputForSegwit, ); if (!useBip143) { if (!resuming && changePath) { await provideOutputFullChangePath(transport, changePath); } await hashOutputFull(transport, outputScript, additionals); } if (firstRun) { onDeviceSignatureGranted(); notify(1, 0); } const signature = await signTransaction( transport, associatedKeysets[i], lockTime, sigHashType, expiryHeight, additionals, ); notify(1, i + 1); signatures.push(signature); targetTransaction.inputs[i].script = nullScript; if (firstRun) { firstRun = false; } } targetTransaction.version = defaultVersion; targetTransaction.consensusBranchId = getZcashBranchId(blockHeight); // Populate the final input scripts for (let i = 0; i < inputs.length; i++) { if (segwit) { targetTransaction.witness = Buffer.alloc(0); if (!bech32) { targetTransaction.inputs[i].script = Buffer.concat([ Buffer.from("160014", "hex"), hashPublicKey(publicKeys[i]), ]); } } else { const signatureSize = Buffer.alloc(1); const keySize = Buffer.alloc(1); signatureSize[0] = signatures[i].length; keySize[0] = publicKeys[i].length; targetTransaction.inputs[i].script = Buffer.concat([ signatureSize, signatures[i], keySize, publicKeys[i], ]); } const offset = useBip143 && !useTrustedInputForSegwit ? 0 : 4; targetTransaction.inputs[i].prevout = trustedInputs[i].value.slice(offset, offset + 0x24); } targetTransaction.locktime = lockTimeBuffer; let result = Buffer.concat([ serializeTransaction(targetTransaction, false, targetTransaction.timestamp, additionals), outputScript, ]); if (segwit && !isDecred) { let witness = Buffer.alloc(0); for (let i = 0; i < inputs.length; i++) { const tmpScriptData = Buffer.concat([ Buffer.from("02", "hex"), Buffer.from([signatures[i].length]), signatures[i], Buffer.from([publicKeys[i].length]), publicKeys[i], ]); witness = Buffer.concat([witness, tmpScriptData]); } result = Buffer.concat([result, witness]); } // from to https://zips.z.cash/zip-0225, zcash is different with other coins, the lock_time and nExpiryHeight fields are before the inputs and outputs if (!isZcash) { result = Buffer.concat([result, lockTimeBuffer]); if (expiryHeight) { result = Buffer.concat([ result, targetTransaction.nExpiryHeight || Buffer.alloc(0), targetTransaction.extraData || Buffer.alloc(0), ]); } } if (isDecred) { let decredWitness = Buffer.from([targetTransaction.inputs.length]); inputs.forEach((input, inputIndex) => { decredWitness = Buffer.concat([ decredWitness, Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), Buffer.from([0x00, 0x00, 0x00, 0x00]), //Block height Buffer.from([0xff, 0xff, 0xff, 0xff]), //Block index Buffer.from([targetTransaction.inputs[inputIndex].script.length]), targetTransaction.inputs[inputIndex].script, ]); }); result = Buffer.concat([result, decredWitness]); } if (isZcash) { result = Buffer.concat([result, Buffer.from([0x00, 0x00, 0x00])]); } return result.toString("hex"); }