diff --git a/app/walletFingerprintAnalysis.js b/app/walletFingerprintAnalysis.js new file mode 100644 index 000000000..6c9ebfaef --- /dev/null +++ b/app/walletFingerprintAnalysis.js @@ -0,0 +1,1086 @@ +"use strict"; + +const CATALOG = require("./walletFingerprints.json"); +const WALLETS = Object.keys(CATALOG.wallets); +const SIGNERS = Object.keys(CATALOG.signers || {}); + +const STRONG_LOWR_SIGS = 6; + +const REFERENCES = { + "Anti-fee-sniping": "https://bitcoinops.org/en/topics/fee-sniping/", + "nVersion": "https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki", + "RBF signaling": "https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki", + "nSequence value": "https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki", + "Input script types": "https://en.bitcoin.it/wiki/Privacy#Wallet_fingerprinting", + "Public keys": "https://en.bitcoin.it/wiki/Privacy#Wallet_fingerprinting", + "Low-R grinding": "https://bitcoinops.org/en/topics/low-r-grinding/", + "Signature hash type": "https://en.bitcoin.it/wiki/OP_CHECKSIG", + "OP_RETURN output": "https://en.bitcoin.it/wiki/OP_RETURN", + "Outputs": "https://bitcoinops.org/en/topics/payment-batching/", + "Output ordering": "https://github.com/bitcoin/bips/blob/master/bip-0069.mediawiki", + "Input ordering": "https://github.com/bitcoin/bips/blob/master/bip-0069.mediawiki", + "Address reuse": "https://en.bitcoin.it/wiki/Privacy#Address_reuse", + "Detected change output": "https://en.bitcoin.it/wiki/Privacy#Change_address_detection", + "Change type": "https://en.bitcoin.it/wiki/Privacy#Change_address_detection", + "Signing device": "https://en.bitcoin.it/wiki/Privacy#Wallet_fingerprinting" +}; + +const TYPE_MAP = { + "pubkeyhash": "p2pkh", + "scripthash": "p2sh", + "witness_v0_keyhash": "p2wpkh", + "witness_v0_scripthash": "p2wsh", + "witness_v1_taproot": "p2tr", + "nulldata": "op_return", + "pubkey": "p2pk", + "multisig": "multisig" +}; + +function mapType(coreType) { + if (!coreType) { + return "unknown"; + } + + return TYPE_MAP[coreType] || coreType; +} + +function toSats(value) { + if (value == null) { + return null; + } + + return Math.round(Number(value) * 1e8); +} + +function normalize(tx, txInputs) { + const inputs = []; + for (let i = 0; i < tx.vin.length; i++) { + const vin = tx.vin[i]; + const prevout = txInputs ? txInputs[i] : null; + + inputs.push({ + prevoutKey: `${vin.txid}:${vin.vout}`, + txid: vin.txid, + vout: vin.vout, + sequence: vin.sequence, + witness: vin.txinwitness || [], + scriptSigAsm: (vin.scriptSig && vin.scriptSig.asm) || "", + type: prevout ? mapType(prevout.scriptPubKey && prevout.scriptPubKey.type) : "unknown", + address: prevout ? ((prevout.scriptPubKey && prevout.scriptPubKey.address) || null) : null, + valueSat: prevout ? toSats(prevout.value) : null + }); + } + + const outputs = tx.vout.map((vout) => ({ + type: mapType(vout.scriptPubKey && vout.scriptPubKey.type), + address: (vout.scriptPubKey && vout.scriptPubKey.address) || null, + scriptHex: (vout.scriptPubKey && vout.scriptPubKey.hex) || "", + valueSat: toSats(vout.value) + })); + + return { inputs, outputs }; +} + +function uniqueTypes(items) { + return Array.from(new Set(items.map((x) => x.type))); +} + +function looksLikePubkey(hex) { + if (!hex || /[^0-9a-fA-F]/.test(hex)) { + return false; + } + + if (hex.length === 66 && (hex.substring(0, 2) === "02" || hex.substring(0, 2) === "03")) { + return true; + } + + return hex.length === 130 && hex.substring(0, 2) === "04"; +} + +function sigAndPubkeyHex(input) { + // Single-key witness spend, native (P2WPKH) or nested in P2SH (BIP49): [signature, pubkey] + if (input.witness.length === 2 && looksLikePubkey(input.witness[1])) { + return { sig: input.witness[0], pubkey: input.witness[1] }; + } + + if (input.scriptSigAsm) { + const parts = input.scriptSigAsm.trim().split(/\s+/).filter(Boolean); + + // P2PKH: + if (parts.length === 2 && looksLikePubkey(parts[1])) { + return { sig: parts[0], pubkey: parts[1] }; + } + + // P2PK: + if (parts.length === 1 && parts[0].substring(0, 2) === "30") { + return { sig: parts[0], pubkey: null }; + } + } + + return { sig: null, pubkey: null }; +} + +// A DER signature as it appears on chain: 0x30, a length that matches the remaining bytes, +// and a trailing sighash byte. Checking the length guards against mistaking a redeem script +// or witness script that happens to start with 0x30 for a signature. +function looksLikeDerSig(hex) { + if (!hex || hex.length < 16 || hex.substring(0, 2) !== "30" || /[^0-9a-fA-F]/.test(hex)) { + return false; + } + + const declared = parseInt(hex.substring(2, 4), 16); + if (isNaN(declared)) { + return false; + } + + // Witness items carry the trailing sighash byte; Bitcoin Core's scriptSig asm strips it + // and renders it separately as "[ALL]", so the bare form is two bytes shorter. + if ((declared + 2) * 2 === hex.length) { + return true; + } + + if ((declared + 3) * 2 !== hex.length) { + return false; + } + + return [0x01, 0x02, 0x03, 0x81, 0x82, 0x83].includes(parseInt(hex.substring(hex.length - 2), 16)); +} + +// Every ECDSA signature in an input, whatever the script shape. Single-key spends carry one; +// multisig carries m of them, in the witness stack for P2WSH or in the scriptSig for P2SH. +function allSignatures(input) { + const sigs = []; + + for (const item of input.witness) { + if (looksLikeDerSig(item)) { + sigs.push(item); + } + } + + if (sigs.length === 0 && input.scriptSigAsm) { + for (const token of input.scriptSigAsm.trim().split(/\s+/)) { + const hex = token.replace(/\[[A-Z|]+\]$/, ""); + if (looksLikeDerSig(hex)) { + sigs.push(hex); + } + } + } + + return sigs; +} + +const SIGHASH_NAMES = { + 0x01: "SIGHASH_ALL", + 0x02: "SIGHASH_NONE", + 0x03: "SIGHASH_SINGLE", + 0x81: "SIGHASH_ALL|ANYONECANPAY", + 0x82: "SIGHASH_NONE|ANYONECANPAY", + 0x83: "SIGHASH_SINGLE|ANYONECANPAY" +}; + +function sighashName(byte) { + return SIGHASH_NAMES[byte] || ("0x" + byte.toString(16)); +} + +function sighashOf(input) { + // Taproot key-path spend: a single 64-byte (implicit SIGHASH_DEFAULT) or 65-byte witness element + if ((input.type === "p2tr" || input.type === "unknown") && input.witness.length === 1) { + const w = input.witness[0]; + if (w.length === 128) { + return "SIGHASH_DEFAULT"; + } + if (w.length === 130) { + return sighashName(parseInt(w.substring(128), 16)); + } + + return null; + } + + // Single-key witness spend: the raw DER signature ends with the sighash byte + if (input.witness.length === 2 && looksLikePubkey(input.witness[1])) { + const wsig = input.witness[0]; + if (wsig.substring(0, 2) === "30" && wsig.length >= 4) { + return sighashName(parseInt(wsig.substring(wsig.length - 2), 16)); + } + + return null; + } + + // Legacy scriptSig: Bitcoin Core renders the sighash type as an annotation, e.g. "[ALL]" + if (input.scriptSigAsm) { + const m = input.scriptSigAsm.match(/\[([A-Z|]+)\]/); + if (m) { + return "SIGHASH_" + m[1]; + } + } + + // Multisig and other multi-element witnesses: read the flag off the signatures themselves + const sigs = allSignatures(input); + if (sigs.length > 0) { + return sighashName(parseInt(sigs[0].substring(sigs[0].length - 2), 16)); + } + + return null; +} + +function compressedKeysOnly(inputs) { + for (const input of inputs) { + const { pubkey } = sigAndPubkeyHex(input); + if (pubkey && pubkey.length >= 2 && pubkey.substring(0, 2) === "04") { + return false; + } + } + + return true; +} + +// A taproot key-path spend is a single 64-byte (or 65-byte with an explicit sighash byte) +// witness element, and a script-path spend ends with a control block. Both are visible in +// the witness, so a taproot spend can still be recognized without previous-output data. +function witnessLooksTaproot(input) { + if (input.type === "p2tr") { + return true; + } + + if (input.type !== "unknown") { + return false; + } + + if (input.witness.length === 1) { + return input.witness[0].length === 128 || input.witness[0].length === 130; + } + + if (input.witness.length >= 2) { + const control = input.witness[input.witness.length - 1]; + const lead = control.substring(0, 2); + return control.length >= 66 && (control.length - 2) % 64 === 0 && (lead === "c0" || lead === "c1"); + } + + return false; +} + +// Walk an OP_RETURN scriptPubKey and report the structure the signing firmwares check: +// total size, how many pushes it carries, and whether it uses OP_PUSHDATA2/OP_PUSHDATA4. +function parseOpReturn(scriptHex) { + const bytes = []; + for (let i = 0; i < scriptHex.length; i += 2) { + bytes.push(parseInt(scriptHex.substring(i, i + 2), 16)); + } + + const result = { scriptBytes: bytes.length, pushes: 0, pushdata2: false, parsed: true }; + let offset = 1; + + while (offset < bytes.length) { + const opcode = bytes[offset++]; + let dataLen = 0; + + if (opcode <= 75) { + dataLen = opcode; + } else if (opcode === 0x4c) { + dataLen = bytes[offset++]; + } else if (opcode === 0x4d) { + result.pushdata2 = true; + dataLen = bytes[offset] + (bytes[offset + 1] << 8); + offset += 2; + } else if (opcode === 0x4e) { + result.pushdata2 = true; + dataLen = bytes[offset] + (bytes[offset + 1] << 8) + (bytes[offset + 2] << 16) + (bytes[offset + 3] << 24); + offset += 4; + } else if (opcode === 0x00 || (opcode >= 0x4f && opcode <= 0x60)) { + // OP_0, OP_1NEGATE and OP_1..OP_16 are pushes that carry no following bytes + dataLen = 0; + } else { + result.parsed = false; + return result; + } + + if (isNaN(dataLen) || offset + dataLen > bytes.length) { + result.parsed = false; + return result; + } + + offset += dataLen; + result.pushes++; + } + + return result; +} + +function uncompressedKeyOutsideP2pk(inputs) { + for (const input of inputs) { + if (input.type === "p2pk") { + continue; + } + + const { pubkey } = sigAndPubkeyHex(input); + if (pubkey && pubkey.substring(0, 2) === "04") { + return true; + } + } + + return false; +} + +function ecdsaSignatureStats(inputs) { + let examined = 0; + let lowR = 0; + let highR = 0; + + for (const input of inputs) { + for (const sig of allSignatures(input)) { + const rLen = parseInt(sig.substring(6, 8), 16); + if (isNaN(rLen)) { + continue; + } + + examined++; + if (rLen > 32) { + highR++; + } else { + lowR++; + } + } + } + + return { examined, lowR, highR }; +} + +function bip69InputsSorted(inputs) { + if (inputs.length <= 1) { + return true; + } + + const keys = inputs.map((i) => `${i.txid}:${String(i.vout).padStart(10, "0")}`); + const sorted = keys.slice().sort(); + return JSON.stringify(keys) === JSON.stringify(sorted); +} + +function bip69OutputsSorted(outputs) { + if (outputs.length <= 1) { + return true; + } + + const pairs = outputs.map((o) => [o.valueSat, o.scriptHex]); + const sorted = pairs.slice().sort((a, b) => { + if (a[0] !== b[0]) { + return a[0] - b[0]; + } + + return a[1].localeCompare(b[1]); + }); + + return JSON.stringify(pairs) === JSON.stringify(sorted); +} + +function getChangeIndex(inputs, outputs) { + if (outputs.length === 1) { + return -1; + } + + const inputTypes = inputs.map((i) => i.type); + const outputTypes = outputs.map((o) => o.type); + + if (new Set(inputTypes).size === 1) { + const matchCount = outputTypes.filter((t) => t === inputTypes[0]).length; + if (matchCount === 1) { + return outputTypes.indexOf(inputTypes[0]); + } + } + + const inputAddrs = new Set(inputs.map((i) => i.address).filter(Boolean)); + const sharedAddrIndexes = []; + outputs.forEach((o, idx) => { + if (o.address && inputAddrs.has(o.address)) { + sharedAddrIndexes.push(idx); + } + }); + if (sharedAddrIndexes.length === 1) { + return sharedAddrIndexes[0]; + } + + const inputValues = inputs.map((i) => i.valueSat).filter((v) => v != null); + if (outputs.length === 2 && inputValues.length === inputs.length && inputValues.length > 0) { + const minInput = Math.min(...inputValues); + const belowMinInput = []; + outputs.forEach((o, idx) => { + if (o.valueSat != null && o.valueSat < minInput) { + belowMinInput.push(idx); + } + }); + if (belowMinInput.length === 1) { + return belowMinInput[0]; + } + } + + const nonRoundIndexes = []; + outputs.forEach((o, idx) => { + if (o.valueSat != null && o.valueSat % 100 !== 0) { + nonRoundIndexes.push(idx); + } + }); + if (nonRoundIndexes.length === 1) { + return nonRoundIndexes[0]; + } + + return -2; +} + +function addressReuse(inputs, outputs) { + const inputAddrs = new Set(inputs.map((i) => i.address).filter(Boolean)); + return outputs.some((o) => o.address && inputAddrs.has(o.address)); +} + +function matchCatalog(f) { + if (f.version !== 1 && f.version !== 2) { + return []; + } + + if (f.compressed === false) { + return []; + } + + return WALLETS.filter((name) => { + const p = CATALOG.wallets[name]; + + if (p.version !== f.version) { + return false; + } + + if (f.antiFeeSniping && p.anti_fee_sniping === "never") { + return false; + } + if (!f.antiFeeSniping && p.anti_fee_sniping === "always") { + return false; + } + + if (f.rbf) { + if (p.rbf === "never") { + return false; + } + if (p.rbf === "native_segwit_only" && f.haveInputData && !f.onlyNativeSegwit) { + return false; + } + } else { + if (p.rbf === "always") { + return false; + } + if (p.rbf === "native_segwit_only" && f.onlyNativeSegwit) { + return false; + } + } + + if (p.nsequence && f.sequences && f.sequences.length > 0) { + const allowed = p.nsequence.map((h) => parseInt(h, 16)); + if (!f.sequences.every((s) => allowed.includes(s))) { + return false; + } + } + + if (f.haveInputData && f.multiType && p.multi_type_vin === "no") { + return false; + } + + if (f.lowR === "high" && p.low_r === "always") { + return false; + } + if (f.lowR === "strong_low" && p.low_r === "no") { + return false; + } + + if (f.opReturn && p.op_return === "no") { + return false; + } + + if (f.batched && p.batching === "single") { + return false; + } + + if (f.outputsBip69 === false && p.bip69 === "always") { + return false; + } + if (f.inputsBip69 === false && p.bip69 === "always") { + return false; + } + + if (f.haveInputData && f.reuse === true && p.address_reuse === "no") { + return false; + } + if (f.haveInputData && f.reuse === false && p.address_reuse === "yes") { + return false; + } + + if (f.changePosition === "not_last" && p.change_position === "last") { + return false; + } + + if (f.changeType === "payment" && p.change_type !== "payment") { + return false; + } + if (f.changeType === "input" && p.change_type === "payment") { + return false; + } + + return true; + }); +} + +// A signing device only leaves fingerprints in the signatures and in what it refuses +// to sign, so it is matched on its own facts instead of the wallet catalog. Returns the +// reason the device is incompatible with this transaction, or null if it is still possible. +// Rules a signer carries that this transaction could not test, either because they need +// previous-output data or because the firmware is permissive enough that they never exclude. +function untestedConstraints(name, f) { + const p = CATALOG.signers[name]; + const gaps = []; + + if (p.input_types && !f.inputTypes) { + gaps.push("which input scripts it can spend"); + } + if (p.max_fee_percent != null && f.feePercent == null) { + gaps.push("its fee ceiling"); + } + if (p.max_fee_rate_sat_vb != null && f.feeRateSatVb == null) { + gaps.push("its fee rate ceiling"); + } + if (p.output_types === "any" && p.op_return_max_pushes == null && p.op_return_max_script_bytes == null) { + gaps.push("its output rules, which never exclude anything because the firmware accepts any script"); + } + + return gaps; +} + +function article(word) { + return "aeiou".includes(word.charAt(0)) ? "an" : "a"; +} + +function signerEliminationReason(name, f) { + const p = CATALOG.signers[name]; + + // Grinding is a firmware behavior, so it can only rule the signer out for + // transactions mined after the firmware that introduced it. + const grinding = p.low_r === "always" + && (!p.low_r_since_height || !f.referenceHeight || f.referenceHeight >= p.low_r_since_height); + + if (f.lowR === "high" && grinding) { + return { + rule: "statistical", + text: p.low_r_since_height + ? `a high-R signature, and it has ground every signature since block ${p.low_r_since_height}` + : "a high-R signature, and it grinds every signature" + }; + } + + if (f.lowR === "strong_low" && p.low_r === "no") { + return { rule: "statistical", text: "deliberate low-R grinding, which it never does" }; + } + + if (p.input_types && f.inputTypes) { + const unsupported = f.inputTypes.filter((t) => t !== "unknown" && !p.input_types.includes(t)); + if (unsupported.length > 0) { + const note = p.input_types_note ? ` (${p.input_types_note})` : ""; + return { rule: "capability", text: `${article(unsupported[0])} ${unsupported.join(", ")} input, which its firmware cannot spend${note}` }; + } + } + + if (p.output_types && p.output_types !== "any" && f.outputTypes) { + const unsupported = f.outputTypes.filter((t) => !p.output_types.includes(t)); + if (unsupported.length > 0) { + return { rule: "capability", text: `${article(unsupported[0])} ${unsupported.join(", ")} output, which its firmware cannot pay to` }; + } + } + + for (const o of f.opReturns) { + if (p.op_return_max_script_bytes != null && o.scriptBytes > p.op_return_max_script_bytes) { + return { rule: "capability", text: `an OP_RETURN output of ${o.scriptBytes} script bytes, above the ${p.op_return_max_script_bytes} its firmware accepts` }; + } + if (p.op_return_max_pushes != null && o.parsed && o.pushes > p.op_return_max_pushes) { + return { rule: "capability", text: `an OP_RETURN output carrying ${o.pushes} pushes, and its firmware only ever writes ${p.op_return_max_pushes}` }; + } + if (p.op_return_pushdata2 === false && o.pushdata2) { + return { rule: "capability", text: "an OP_RETURN output using OP_PUSHDATA2 or OP_PUSHDATA4, which its firmware rejects" }; + } + if (p.op_return_nonzero_value === false && o.valueSat > 0) { + return { rule: "capability", text: "an OP_RETURN output carrying value, and its firmware requires a zero amount" }; + } + } + + if (p.max_fee_percent != null && f.feePercent != null && f.feePercent >= p.max_fee_percent) { + return { rule: "capability", text: `a fee worth ${f.feePercent.toFixed(1)} percent of the outputs, at or above the ${p.max_fee_percent} percent its firmware refuses to sign` }; + } + + if (p.max_fee_rate_sat_vb != null && f.feeRateSatVb != null && f.feeRateSatVb > p.max_fee_rate_sat_vb) { + return { rule: "capability", text: `a fee rate of ${Math.round(f.feeRateSatVb)} sat/vB, above the ${p.max_fee_rate_sat_vb} its firmware refuses to sign` }; + } + + if (f.uncompressedOutsideP2pk && p.uncompressed_keys !== "yes") { + return { rule: "capability", text: p.uncompressed_keys === "p2pk_only" + ? "an uncompressed public key outside a P2PK input" + : "an uncompressed public key, which it never signs for" }; + } + + if (p.sighash && f.sighashes.length > 0) { + const refused = f.sighashes.filter((s) => !p.sighash.includes(s)); + if (refused.length > 0) { + return { rule: "capability", text: refused.join(", ") + ", which it will not sign with default settings" }; + } + } + + return null; +} + +function matchSigners(f) { + return SIGNERS.filter((name) => signerEliminationReason(name, f) === null); +} + +// A device is "likely" only when every other profiled device is excluded by a firmware +// capability or policy, which the device would refuse outright. Exclusions that rest on +// signature statistics (grinding) are weaker, because coordinator software grinds too. +function signerStrength(candidates, eliminated, f) { + if (candidates.length !== 1) { + return "possible"; + } + + const allCapability = eliminated.every((name) => signerEliminationReason(name, f).rule === "capability"); + return allCapability ? "likely" : "possible"; +} + +function analyzeTransaction(tx, txInputs, txBlockHeight, currentBlockHeight, extraSignatures) { + if (!tx || !tx.vin || !tx.vout || (tx.vin[0] && tx.vin[0].coinbase)) { + return { available: false }; + } + + const { inputs, outputs } = normalize(tx, txInputs); + const haveInputData = inputs.every((i) => i.type !== "unknown"); + + const signals = []; + const add = (label, value, implication, privacy) => signals.push({ label, value, implication, privacy }); + + const inTypes = uniqueTypes(inputs); + const referenceHeight = (txBlockHeight && txBlockHeight > 0) ? txBlockHeight : currentBlockHeight; + + const facts = { + version: tx.version, + haveInputData, + antiFeeSniping: tx.locktime > 0, + rbf: inputs.some((i) => i.sequence != null && i.sequence < 0xfffffffe), + sequences: Array.from(new Set(inputs.map((i) => i.sequence).filter((s) => s != null))), + onlyNativeSegwit: haveInputData && inTypes.every((t) => t === "p2wpkh"), + multiType: inTypes.length > 1, + compressed: compressedKeysOnly(inputs), + uncompressedOutsideP2pk: uncompressedKeyOutsideP2pk(inputs), + referenceHeight, + taprootSpend: inputs.some(witnessLooksTaproot), + inputTypes: haveInputData + ? inTypes + : (inputs.some(witnessLooksTaproot) ? ["p2tr"] : null), + outputTypes: Array.from(new Set(outputs.map((o) => o.type))), + opReturns: outputs + .filter((o) => o.type === "op_return") + .map((o) => Object.assign(parseOpReturn(o.scriptHex), { valueSat: o.valueSat })), + feePercent: null, + feeRateSatVb: null, + sighashes: [], + lowR: "none", + opReturn: outputs.some((o) => o.type === "op_return"), + batched: outputs.length > 2, + outputsBip69: outputs.length > 1 ? bip69OutputsSorted(outputs) : null, + inputsBip69: inputs.length > 1 ? bip69InputsSorted(inputs) : null, + reuse: haveInputData ? addressReuse(inputs, outputs) : null, + changePosition: "none", + changeType: "none" + }; + + // The fee is only knowable when every previous output is available. + if (haveInputData && inputs.every((i) => i.valueSat != null)) { + const totalIn = inputs.reduce((sum, i) => sum + i.valueSat, 0); + const totalOut = outputs.reduce((sum, o) => sum + (o.valueSat || 0), 0); + const fee = totalIn - totalOut; + if (fee >= 0 && totalOut > 0) { + facts.feePercent = (fee * 100) / totalOut; + } + if (fee >= 0 && tx.vsize > 0) { + facts.feeRateSatVb = fee / tx.vsize; + } + } + + if (!facts.antiFeeSniping) { + add("Anti-fee-sniping", "No (nLockTime = 0)", + "Most wallets leave nLockTime at 0, which does not narrow much but rules out the wallets that always set it (Sparrow and Bull Bitcoin, plus Bitcoin Core and Electrum which set it most of the time).", + null); + } else { + let detail = `nLockTime = ${tx.locktime}`; + if (referenceHeight && referenceHeight > 0) { + const delta = referenceHeight - tx.locktime; + detail += (delta >= 0 && delta < 100) ? " (near chain tip)" : ` (${delta} blocks below tip)`; + } + + add("Anti-fee-sniping", detail, + "nLockTime set near the chain tip is the anti-fee-sniping pattern. Only wallets that do this are possible: Bitcoin Core, Electrum, Sparrow, Bull Bitcoin, Liana, Nunchuk and Wasabi.", + "A non-zero locktime narrows the wallet to the anti-fee-sniping set."); + } + + if (tx.version === 1) { + add("nVersion", "1", "Transaction version 1 is used by Trust Wallet, Trezor and Ledger.", null); + } else if (tx.version === 2) { + add("nVersion", "2", "Version 2 rules out the wallets that still emit version-1 transactions (Ledger, Trezor, Trust).", null); + } else { + add("nVersion", String(tx.version), "Non-standard transaction version.", null); + } + + if (facts.rbf) { + add("RBF signaling", "Yes (nSequence < 0xFFFFFFFE)", + "Opt-in Replace-By-Fee. Wallets that default to no RBF (Coinbase, Exodus, Wasabi) are ruled out.", null); + } else { + add("RBF signaling", "No (nSequence >= 0xFFFFFFFE)", + "No RBF opt-in. The wallets that always signal RBF are ruled out: Bitcoin Core, Electrum, Ledger, Trezor, Trust, Sparrow, Bull Bitcoin, Liana and Nunchuk. Wallets that default to no RBF remain possible: Coinbase, Exodus, Wasabi, Cake (when spending unconfirmed coins), and Blue Wallet for its legacy, P2SH and taproot wallets.", + null); + } + + if (facts.sequences.length > 0) { + const seqHexes = facts.sequences.map((s) => "0x" + s.toString(16).padStart(8, "0")); + add("nSequence value", seqHexes.join(", "), + "The exact nSequence value is wallet-specific: 0x80000000 is Blue Wallet's native segwit wallet; 0xFFFFFFFD is used by most RBF wallets (Bitcoin Core, Electrum, Sparrow, Liana, Bull Bitcoin, Cake); 0xFFFFFFFF by wallets that default to no RBF (Coinbase, Exodus, Wasabi).", + null); + } + + if (haveInputData) { + if (inTypes.length > 1) { + add("Input script types", inTypes.join(", "), + "Spending more than one address type in one transaction is uncommon; most wallets use a single type.", + "Mixing input types both fingerprints the wallet and links otherwise-separate address types to one owner."); + } else { + add("Input script types", inTypes[0] || "n/a", "All inputs share one script type.", null); + } + } + + // Signatures and public keys are carried in the input scripts and witnesses, so these + // checks work from the raw transaction alone and must not be gated on previous-output + // data, which a pruned node cannot supply. + { + const pubkeysSeen = inputs.filter((i) => sigAndPubkeyHex(i).pubkey).length; + + if (facts.compressed && pubkeysSeen > 0) { + add("Public keys", "Compressed", "Compressed ECDSA public keys (standard for all modern wallets).", null); + } else if (!facts.compressed) { + add("Public keys", "Uncompressed key present", + "Uncompressed public keys are legacy behavior and rare today.", + "An uncompressed key is a strong, unusual fingerprint."); + } + + const sigStats = ecdsaSignatureStats(inputs); + const linked = extraSignatures || { low: 0, high: 0, linkedTxids: [] }; + const linkedCount = linked.linkedTxids ? linked.linkedTxids.length : 0; + const totalHigh = sigStats.highR + (linked.high || 0); + const totalLow = sigStats.lowR + (linked.low || 0); + const totalExamined = totalLow + totalHigh; + const across = linkedCount > 0 ? ` across this transaction and ${linkedCount} linked transaction(s)` : ""; + + if (totalExamined > 0) { + if (totalHigh > 0) { + facts.lowR = "high"; + add("Low-R grinding", `No (${totalHigh} of ${totalExamined} ECDSA signature(s)${across} have a 33-byte R value)`, + "A wallet that grinds for low-R signatures would never produce a high-R one, so a high-R signature rules out the grinding wallets (Bitcoin Core, Electrum, Sparrow, Bull Bitcoin, Liana) and the signing devices that always grind (Coldcard).", + null); + } else if (totalExamined >= STRONG_LOWR_SIGS) { + facts.lowR = "strong_low"; + add("Low-R grinding", `Yes (all ${totalExamined} ECDSA signature(s)${across} are low-R)`, + `The chance of ${totalExamined} low-R signatures occurring by luck is about 1 in ${Math.pow(2, totalExamined)}, so this is strong evidence of deliberate low-R grinding (Bitcoin Core, Electrum, Sparrow, Bull Bitcoin, Liana). The grinding can also come from the signing device rather than the software that built the transaction, since a Coldcard grinds every signature.`, + null); + } else { + facts.lowR = "low"; + add("Low-R grinding", `All ${totalExamined} ECDSA signature(s)${across} are low-R (32-byte R or smaller)`, + `A non-grinding wallet still produces a low-R signature about half the time, so this is weak evidence (roughly 1 in ${Math.pow(2, totalExamined)}). Following the change chain to gather more of this wallet's signatures would strengthen or refute it.`, + null); + } + } + } + + const sighashes = Array.from(new Set(inputs.map(sighashOf).filter(Boolean))); + facts.sighashes = sighashes; + const nonStandardSighash = sighashes.filter((s) => s !== "SIGHASH_ALL" && s !== "SIGHASH_DEFAULT"); + if (nonStandardSighash.length > 0) { + add("Signature hash type", sighashes.join(", "), + "Most wallets sign every input with SIGHASH_ALL (or SIGHASH_DEFAULT for taproot). A different flag is uncommon and usually indicates a collaborative transaction such as a coinjoin or a PSBT signed across wallets.", + "A non-default sighash flag is a strong and unusual fingerprint."); + } + + if (facts.opReturn) { + add("OP_RETURN output", "Yes", + "This transaction embeds data in an OP_RETURN output, which several wallets never create.", null); + } + + if (facts.batched) { + add("Outputs", `${outputs.length} (batched)`, + "More than two outputs (batched payment) is something several single-recipient wallets never do.", null); + } + + if (outputs.length > 1) { + if (facts.outputsBip69) { + add("Output ordering", "BIP-69 (lexicographic)", + "Outputs are sorted per BIP-69. Deterministic ordering is itself a fingerprint; only some wallets do it.", null); + } else { + add("Output ordering", "Not BIP-69", + "Outputs are not in BIP-69 order, ruling out wallets that always sort (Electrum, Trezor).", null); + } + } + + if (inputs.length > 1) { + if (facts.inputsBip69) { + add("Input ordering", "BIP-69 (lexicographic)", "Inputs are sorted per BIP-69.", null); + } else { + add("Input ordering", "Not BIP-69", + "Inputs are not in BIP-69 order, ruling out wallets that always sort (Electrum, Trezor).", null); + } + } + + if (haveInputData) { + if (facts.reuse) { + add("Address reuse", "Yes (an output reuses an input address)", + "Paying back to an address that was just spent. Most modern wallets avoid this.", + "Address reuse directly links transactions and is one of the most damaging privacy leaks."); + } else { + add("Address reuse", "No", "No input address is reused as an output.", null); + } + + const changeIndex = getChangeIndex(inputs, outputs); + if (changeIndex >= 0) { + const isLast = changeIndex === outputs.length - 1; + facts.changePosition = isLast ? "last" : "not_last"; + add("Detected change output", `index ${changeIndex}${isLast ? " (last)" : " (not last)"}`, + "Heuristic change detection (single matching script type / reused address / non-round amount).", + "A predictable change position lets an observer separate the payment from the change."); + + const changeType = outputs[changeIndex].type; + const otherOutputTypes = outputs.filter((_, i) => i !== changeIndex).map((o) => o.type); + const matchesInput = inTypes.includes(changeType); + const matchesOutput = otherOutputTypes.includes(changeType); + if (matchesOutput && !matchesInput) { + facts.changeType = "payment"; + add("Change type", "Matches the payment output type", + "Bitcoin Core derives change matching the payment type; other wallets match the input type.", null); + } else if (matchesInput && !matchesOutput) { + facts.changeType = "input"; + add("Change type", "Matches the input type", + "Change script type follows the inputs, which is what most non-Core wallets do.", null); + } + } + } + + const candidates = matchCatalog(facts); + const signerCandidates = matchSigners(facts); + + // Nothing in a transaction positively identifies a signing device, so the row is only + // worth showing when at least one device has been eliminated. Reporting "not ruled out" + // when no device is eliminated would fire on almost every ordinary transaction. + const signersEliminated = SIGNERS.filter((name) => !signerCandidates.includes(name)); + + let signerVerdict = null; + let signerVerdictClass = null; + + if (signersEliminated.length > 0) { + const preface = "A signing device does not build the transaction, so it is matched only on the signatures and on what it refuses to sign. "; + const because = "Ruled out by " + signersEliminated.map((name) => `${name}: ${signerEliminationReason(name, facts).text}`).join("; ") + "."; + + let tail = ""; + if (signerCandidates.length === 0) { + signerVerdict = signersEliminated.join(", ") + " ruled out"; + signerVerdictClass = "secondary"; + } else { + const strength = signerStrength(signerCandidates, signersEliminated, facts); + signerVerdict = signerCandidates.join(", ") + " " + strength; + signerVerdictClass = strength === "likely" ? "success" : "info"; + + tail = strength === "likely" + ? ` Every other profiled device is excluded by a firmware rule it would refuse outright, which leaves ${signerCandidates.join(", ")} as the only profiled device that could have signed this.` + : ` That leaves ${signerCandidates.join(", ")}, on exclusions that rest on signature statistics rather than firmware limits, so coordinator software could account for them equally well.`; + + const gaps = signerCandidates + .map((name) => ({ name, gaps: untestedConstraints(name, facts) })) + .filter((entry) => entry.gaps.length > 0) + .map((entry) => `${entry.name} (${entry.gaps.join(", ")})`); + + if (gaps.length > 0) { + tail += " This transaction could not test " + gaps.join("; ") + "."; + } + } + + add("Signing device", signerVerdict, preface + because + tail, null); + } + + signals.forEach((s) => { s.reference = REFERENCES[s.label] || null; }); + + let verdict; + let verdictClass; + if (candidates.length === 0) { + verdict = "Other / none of the profiled wallets"; + verdictClass = "secondary"; + } else if (candidates.length === 1) { + verdict = candidates[0]; + verdictClass = "success"; + } else { + verdict = `Unclear: ${candidates.join(", ")}`; + verdictClass = "warning"; + } + + return { + available: true, + haveInputData, + signals, + walletCandidates: candidates, + verdict, + verdictClass, + signerCandidates, + signerVerdict, + signerVerdictClass, + disclaimer: "Fingerprints are heuristic and probabilistic. A transaction may match a wallet it was not made with or unlisted wallet. It is also possible that the transaction was created and signed using different wallets." + }; +} + +// The witness script of a P2WSH spend, or the redeem script of a P2SH one, identifies the +// multisig quorum. Two inputs carrying the same script belong to the same wallet, which lets +// signatures be pooled across transactions without needing previous-output data. +function quorumScripts(inputs) { + const scripts = new Set(); + + for (const input of inputs) { + if (input.witness.length >= 3) { + const last = input.witness[input.witness.length - 1]; + if (last && !looksLikeDerSig(last)) { + scripts.add(last); + } + continue; + } + + if (input.scriptSigAsm) { + const tokens = input.scriptSigAsm.trim().split(/\s+/).filter(Boolean); + const last = tokens[tokens.length - 1]; + if (tokens.length >= 3 && last && !looksLikeDerSig(last.replace(/\[[A-Z|]+\]$/, ""))) { + scripts.add(last); + } + } + } + + return scripts; +} + +// Signatures from inputs that belong to the same quorum as the transaction being analyzed. +function quorumSignatureStats(inputs, scripts) { + const matching = inputs.filter((input) => { + for (const script of quorumScripts([input])) { + if (scripts.has(script)) { + return true; + } + } + + return false; + }); + + return ecdsaSignatureStats(matching); +} + +async function findSelfChangeParent(tx, txInputs, fetchTxWithInputs, seen) { + const start = normalize(tx, txInputs); + if (!start.inputs.every((i) => i.type !== "unknown")) { + return null; + } + + for (let i = 0; i < tx.vin.length; i++) { + const vin = tx.vin[i]; + if (!vin || vin.coinbase || !vin.txid || seen.has(vin.txid)) { + continue; + } + + const parent = await fetchTxWithInputs(vin.txid); + if (!parent || !parent.tx || !parent.tx.vout) { + continue; + } + + const parentNorm = normalize(parent.tx, parent.txInputs); + if (!parentNorm.inputs.every((p) => p.type !== "unknown")) { + continue; + } + + const parentChange = getChangeIndex(parentNorm.inputs, parentNorm.outputs); + if (parentChange >= 0 && parentChange === vin.vout) { + return parent; + } + } + + return null; +} + +async function gatherLinkedSignatures(startTx, startTxInputs, fetchTxWithInputs, maxHops) { + const result = { low: 0, high: 0, linkedTxids: [] }; + if (typeof fetchTxWithInputs !== "function" || !startTx || !startTx.txid) { + return result; + } + + const seen = new Set([startTx.txid]); + let curTx = startTx; + let curInputs = startTxInputs; + + for (let hop = 0; hop < maxHops; hop++) { + let parent = null; + try { + parent = await findSelfChangeParent(curTx, curInputs, fetchTxWithInputs, seen); + } catch (err) { + break; + } + + if (!parent) { + break; + } + + const stats = ecdsaSignatureStats(normalize(parent.tx, parent.txInputs).inputs); + result.low += stats.lowR; + result.high += stats.highR; + result.linkedTxids.push(parent.tx.txid); + + seen.add(parent.tx.txid); + curTx = parent.tx; + curInputs = parent.txInputs; + } + + // Multisig gives a second route that does not need previous-output data: a parent input + // carrying the same witness or redeem script is the same quorum, so its signatures come + // from the same set of devices and can be pooled. + const scripts = quorumScripts(normalize(startTx, startTxInputs).inputs); + if (scripts.size > 0) { + for (const vin of startTx.vin) { + if (result.linkedTxids.length >= maxHops) { + break; + } + if (!vin || vin.coinbase || !vin.txid || seen.has(vin.txid)) { + continue; + } + + let parent = null; + try { + parent = await fetchTxWithInputs(vin.txid); + } catch (err) { + continue; + } + + if (!parent || !parent.tx || !parent.tx.vin) { + continue; + } + + seen.add(vin.txid); + const stats = quorumSignatureStats(normalize(parent.tx, parent.txInputs).inputs, scripts); + if (stats.examined > 0) { + result.low += stats.lowR; + result.high += stats.highR; + result.linkedTxids.push(parent.tx.txid); + } + } + } + + return result; +} + +module.exports = { + analyzeTransaction, + gatherLinkedSignatures, + WALLETS, + SIGNERS +}; diff --git a/app/walletFingerprintAnalysis.test.js b/app/walletFingerprintAnalysis.test.js new file mode 100644 index 000000000..3f88c310a --- /dev/null +++ b/app/walletFingerprintAnalysis.test.js @@ -0,0 +1,723 @@ +"use strict"; + +const assert = require("assert"); +const { analyzeTransaction, gatherLinkedSignatures } = require("./walletFingerprintAnalysis.js"); + +const lowRSig = "3044" + "0220" + "aa".repeat(32) + "0220" + "aa".repeat(32) + "01"; +const highRSig = "3045" + "0221" + "bb".repeat(33) + "0220" + "bb".repeat(32) + "01"; +const compressedPk = "02" + "cc".repeat(32); +const uncompressedPk = "04" + "dd".repeat(64); + +function p2wpkhInput(txid, vout, sequence, sig, pk) { + return { txid, vout, sequence, txinwitness: [sig, pk], scriptSig: { asm: "" } }; +} + +function prevout(type, address, valueBtc) { + return { scriptPubKey: { type, address }, value: valueBtc }; +} + +function out(type, address, valueBtc, hex) { + return { scriptPubKey: { type, address, hex }, value: valueBtc }; +} + +let pass = 0; +process.on("exit", () => console.log(`\n${pass} checks passed`)); +function check(name, cond) { + assert.ok(cond, name); + pass++; + console.log("ok -", name); +} + +{ + const tx = { + version: 2, + locktime: 839990, + vin: [p2wpkhInput("aa".repeat(32), 0, 0xfffffffd, lowRSig, compressedPk)], + vout: [ + out("witness_v0_keyhash", "bc1qchg", 0.00412345, "0014" + "11".repeat(20)), + out("witness_v0_keyhash", "bc1qpay", 0.005, "0014" + "99".repeat(20)) + ] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.01) }; + const r = analyzeTransaction(tx, txInputs, 840000, 840000); + + check("electrum: available", r.available === true); + check("electrum: Core and Electrum among candidates", r.walletCandidates.includes("Bitcoin Core") && r.walletCandidates.includes("Electrum")); + check("electrum: has signals", r.signals.length > 5); + check("electrum: anti-fee-sniping signal present", r.signals.some((s) => s.label === "Anti-fee-sniping" && /840|tip/i.test(s.value + s.implication))); + check("electrum: low-R signal is probabilistic, not a grinding claim", r.signals.some((s) => s.label === "Low-R grinding" && /weak evidence|half the time/.test(s.implication) && !/grinds nonces/.test(s.implication))); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [p2wpkhInput("ab".repeat(32), 1, 0xffffffff, highRSig, compressedPk)], + vout: [ + out("witness_v0_keyhash", "bc1qpay", 0.02, "0014" + "22".repeat(20)), + out("witness_v0_keyhash", "bc1qchg", 0.00499999, "0014" + "33".repeat(20)) + ] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin2", 0.0251) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("coinbase-wallet: Coinbase among candidates", r.walletCandidates.includes("Coinbase Wallet")); + check("coinbase-wallet: no-RBF signal", r.signals.some((s) => s.label === "RBF signaling" && /No/.test(s.value))); + check("coinbase-wallet: high-R reliably rules out grinders", r.signals.some((s) => s.label === "Low-R grinding" && /^No \(/.test(s.value) && /33-byte/.test(s.value))); + check("p2wpkh no-RBF: Blue Wallet discarded (a native segwit Blue Wallet would have signaled RBF)", !r.walletCandidates.includes("Blue Wallet")); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [p2wpkhInput("ac".repeat(32), 0, 0xffffffff, lowRSig, uncompressedPk)], + vout: [out("witness_v0_keyhash", "bc1qx", 0.01, "0014" + "44".repeat(20))] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin3", 0.02) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("uncompressed: no candidates", r.walletCandidates.length === 0); + check("uncompressed: flagged in signals", r.signals.some((s) => s.label === "Public keys" && /Uncompressed/.test(s.value))); +} + +{ + const tx = { version: 1, locktime: 0, vin: [{ coinbase: "deadbeef" }], vout: [out("witness_v0_keyhash", "bc1q", 6.25, "0014" + "55".repeat(20))] }; + const r = analyzeTransaction(tx, {}, 840000, 840000); + check("coinbase tx: not available", r.available === false); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [{ txid: "ad".repeat(32), vout: 0, sequence: 0xfffffffd, txinwitness: [], scriptSig: { asm: "" } }], + vout: [out("witness_v0_keyhash", "bc1qy", 0.01, "0014" + "66".repeat(20))] + }; + const r = analyzeTransaction(tx, null, 0, 840000); + check("no-prevout: available", r.available === true); + check("no-prevout: haveInputData false", r.haveInputData === false); + check("no-prevout: no reuse signal (needs previous outputs)", !r.signals.some((s) => s.label === "Address reuse")); + check("no-prevout: no low-R signal when the input carries no signature", !r.signals.some((s) => s.label === "Low-R grinding")); +} + +{ + // a pruned node supplies no previous outputs, but the signatures are in the raw tx + const tx = { + version: 2, + locktime: 0, + vin: Array.from({ length: 6 }, (_, i) => ({ + txid: "c8".repeat(32), vout: i, sequence: 0xfffffffd, + scriptSig: { asm: lowRSig + " " + compressedPk }, txinwitness: [] + })), + vout: [out("pubkeyhash", "1Q41", 0.04, "76a914" + "11".repeat(20) + "88ac")] + }; + const r = analyzeTransaction(tx, null, 840001, 840002); + + check("pruned: low-R is analyzed without previous outputs", r.signals.some((s) => s.label === "Low-R grinding" && /all 6/.test(s.value))); + check("pruned: strong low-R still eliminates the non-grinding devices", !r.signerCandidates.includes("Trezor device") && !r.signerCandidates.includes("Ledger device")); + check("pruned: grinding-based survival is graded possible, not likely", r.signerVerdict === "Coldcard possible"); + check("pruned: input-type dependent checks stay skipped", r.haveInputData === false && !r.signals.some((s) => s.label === "Input script types")); +} + +{ + // taproot key-path spend recognized from the witness alone, with no previous outputs + const tx = { + version: 2, + locktime: 0, + vin: [{ txid: "c9".repeat(32), vout: 0, sequence: 0xfffffffd, txinwitness: ["ab".repeat(64)], scriptSig: { asm: "" } }], + vout: [out("witness_v1_taproot", "bc1ptr", 0.01, "5120" + "77".repeat(32))] + }; + const r = analyzeTransaction(tx, null, 840001, 840002); + + check("pruned: a taproot witness rules out Coldcard without previous outputs", !r.signerCandidates.includes("Coldcard")); + check("pruned: the taproot-capable devices survive", r.signerVerdict === "Trezor device, Ledger device possible"); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [{ txid: "ae".repeat(32), vout: 0, sequence: 0xfffffffd, txinwitness: ["ab".repeat(64)], scriptSig: { asm: "" } }], + vout: [out("witness_v1_taproot", "bc1ptr", 0.01, "5120" + "77".repeat(32))] + }; + const txInputs = { 0: prevout("witness_v1_taproot", "bc1pin", 0.02) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("taproot-only: available", r.available === true); + check("taproot-only: no low-R signal (no ECDSA sigs examined)", !r.signals.some((s) => s.label === "Low-R grinding")); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [{ txid: "ba".repeat(32), vout: 0, sequence: 0xffffffff, scriptSig: { asm: lowRSig + " " + compressedPk }, txinwitness: [] }], + vout: [ + out("pubkeyhash", "1pay", 0.02, "76a914" + "22".repeat(20) + "88ac"), + out("pubkeyhash", "1chg", 0.00499999, "76a914" + "33".repeat(20) + "88ac") + ] + }; + const txInputs = { 0: prevout("pubkeyhash", "1in", 0.0251) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("bluewallet legacy p2pkh no-RBF: Blue Wallet still a candidate", r.walletCandidates.includes("Blue Wallet")); + check("bluewallet legacy p2pkh no-RBF: RBF text mentions Blue Wallet", r.signals.some((s) => s.label === "RBF signaling" && /Blue Wallet/.test(s.implication))); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [{ txid: "bb".repeat(32), vout: 0, sequence: 0xffffffff, scriptSig: { asm: "" }, txinwitness: [lowRSig, compressedPk] }], + vout: [out("scripthash", "3pay", 0.01, "a914" + "44".repeat(20) + "87")] + }; + const txInputs = { 0: prevout("scripthash", "3in", 0.02) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("bluewallet bip49 p2sh no-RBF: Blue Wallet still a candidate", r.walletCandidates.includes("Blue Wallet")); +} + +{ + const tx = { + version: 2, + locktime: 839995, + vin: [p2wpkhInput("ca".repeat(32), 0, 0xfffffffd, lowRSig, compressedPk)], + vout: [ + out("witness_v0_keyhash", "bc1qpay", 0.005, "0014" + "11".repeat(20)), + out("witness_v0_keyhash", "bc1qchg", 0.00412345, "0014" + "99".repeat(20)) + ] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.01) }; + const r = analyzeTransaction(tx, txInputs, 840000, 840000); + + check("sparrow: anti-fee-sniping tx keeps Sparrow a candidate", r.walletCandidates.includes("Sparrow")); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [p2wpkhInput("cb".repeat(32), 0, 0xfffffffd, lowRSig, compressedPk)], + vout: [ + out("witness_v0_keyhash", "bc1qchg", 0.00412345, "0014" + "99".repeat(20)), + out("witness_v0_keyhash", "bc1qpay", 0.005, "0014" + "11".repeat(20)) + ] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.01) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("nunchuk: non-last change keeps Nunchuk (random change position)", r.walletCandidates.includes("Nunchuk")); + check("nunchuk: non-last change drops the change-last wallets (Sparrow)", !r.walletCandidates.includes("Sparrow")); +} + +{ + const tx = { + version: 2, + locktime: 839995, + vin: [p2wpkhInput("da".repeat(32), 0, 0xfffffffd, lowRSig, compressedPk)], + vout: [ + out("witness_v0_keyhash", "bc1qpay", 0.005, "0014" + "11".repeat(20)), + out("witness_v0_keyhash", "bc1qchg", 0.00412345, "0014" + "99".repeat(20)) + ] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.01) }; + const extra = { low: 6, high: 0, linkedTxids: ["a", "b", "c", "d", "e", "f"] }; + const r = analyzeTransaction(tx, txInputs, 840000, 840000, extra); + + check("compound strong-low: low-R signal becomes strong evidence", r.signals.some((s) => s.label === "Low-R grinding" && /strong evidence/.test(s.implication) && /linked transaction/.test(s.value))); + check("compound strong-low: a non-grinder (Nunchuk) is ruled out", !r.walletCandidates.includes("Nunchuk")); + check("compound strong-low: a grinder (Sparrow) survives", r.walletCandidates.includes("Sparrow")); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [p2wpkhInput("db".repeat(32), 0, 0xfffffffd, lowRSig, compressedPk)], + vout: [out("witness_v0_keyhash", "bc1qx", 0.01, "0014" + "11".repeat(20))] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.02) }; + const extra = { low: 3, high: 1, linkedTxids: ["a", "b", "c", "d"] }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002, extra); + + check("compound high-R from linked tx rules out grinders", r.signals.some((s) => s.label === "Low-R grinding" && /^No \(/.test(s.value)) && !r.walletCandidates.includes("Sparrow")); +} + +{ + const tx = { + version: 2, + locktime: 839990, + vin: [p2wpkhInput("fa".repeat(32), 0, 0xfffffffd, lowRSig, compressedPk)], + vout: [ + out("witness_v0_keyhash", "bc1qchg", 0.00412345, "0014" + "11".repeat(20)), + out("witness_v0_keyhash", "bc1qpay", 0.005, "0014" + "99".repeat(20)) + ] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.01) }; + const r = analyzeTransaction(tx, txInputs, 840000, 840000); + + check("references: RBF signal links to BIP-125", r.signals.some((s) => s.label === "RBF signaling" && s.reference === "https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki")); + check("references: Low-R signal links to Optech topic", r.signals.some((s) => s.label === "Low-R grinding" && s.reference === "https://bitcoinops.org/en/topics/low-r-grinding/")); + check("references: every signal has a reference url", r.signals.every((s) => typeof s.reference === "string" && s.reference.startsWith("https://"))); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [p2wpkhInput("ab".repeat(32), 0, 0x80000000, lowRSig, compressedPk)], + vout: [out("witness_v0_keyhash", "bc1qx", 0.01, "0014" + "11".repeat(20))] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.02) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("nsequence 0x80000000: Blue Wallet kept", r.walletCandidates.includes("Blue Wallet")); + check("nsequence 0x80000000: an fd-family wallet (Cake) is ruled out", !r.walletCandidates.includes("Cake Wallet")); + check("nsequence signal shows the exact value", r.signals.some((s) => s.label === "nSequence value" && /0x80000000/.test(s.value))); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [p2wpkhInput("ac".repeat(32), 0, 0xfffffffd, lowRSig, compressedPk)], + vout: [out("witness_v0_keyhash", "bc1qx", 0.01, "0014" + "11".repeat(20))] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.02) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("nsequence 0xfffffffd native-segwit: Blue Wallet ruled out (it uses 0x80000000)", !r.walletCandidates.includes("Blue Wallet")); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [ + p2wpkhInput("ea".repeat(32), 0, 0xfffffffd, lowRSig, compressedPk), + p2wpkhInput("eb".repeat(32), 0, 0xfffffffd, lowRSig, compressedPk) + ], + vout: [ + out("witness_v0_keyhash", "bc1qchg", 0.0009, "0014" + "11".repeat(20)), + out("witness_v0_keyhash", "bc1qpay", 0.006, "0014" + "99".repeat(20)) + ] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin1", 0.003), 1: prevout("witness_v0_keyhash", "bc1qin2", 0.004) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("UIH: detects change smaller than the smallest input even when both amounts are round", r.signals.some((s) => s.label === "Detected change output" && /index 0/.test(s.value) && /not last/.test(s.value))); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [{ txid: "fb".repeat(32), vout: 0, sequence: 0xfffffffd, scriptSig: { asm: "" }, txinwitness: [lowRSig, compressedPk] }], + vout: [out("scripthash", "3pay", 0.01, "a914" + "44".repeat(20) + "87")] + }; + const txInputs = { 0: prevout("scripthash", "3in", 0.02) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("nested-segwit (P2SH-P2WPKH): low-R signal is now produced", r.signals.some((s) => s.label === "Low-R grinding")); + check("nested-segwit (P2SH-P2WPKH): compressed key recognized", r.signals.some((s) => s.label === "Public keys" && /Compressed/.test(s.value))); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [{ txid: "fc".repeat(32), vout: 0, sequence: 0xffffffff, scriptSig: { asm: "" }, txinwitness: [lowRSig, uncompressedPk] }], + vout: [out("scripthash", "3x", 0.01, "a914" + "44".repeat(20) + "87")] + }; + const txInputs = { 0: prevout("scripthash", "3in", 0.02) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("nested-segwit uncompressed key: flagged and no candidates", r.signals.some((s) => s.label === "Public keys" && /Uncompressed/.test(s.value)) && r.walletCandidates.length === 0); +} + +{ + const p2pkSig = lowRSig; + const tx = { + version: 2, + locktime: 0, + vin: [{ txid: "fd".repeat(32), vout: 0, sequence: 0xffffffff, scriptSig: { asm: p2pkSig }, txinwitness: [] }], + vout: [out("pubkeyhash", "1x", 0.01, "76a914" + "44".repeat(20) + "88ac")] + }; + const txInputs = { 0: prevout("pubkey", "1in", 0.02) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("P2PK input: low-R signal is produced from the bare signature", r.signals.some((s) => s.label === "Low-R grinding")); +} + +{ + const acpSig = "3044" + "0220" + "aa".repeat(32) + "0220" + "aa".repeat(32) + "83"; + const tx = { + version: 2, + locktime: 0, + vin: [p2wpkhInput("fe".repeat(32), 0, 0xffffffff, acpSig, compressedPk)], + vout: [out("witness_v0_keyhash", "bc1qx", 0.01, "0014" + "11".repeat(20))] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.02) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("sighash: a non-ALL flag surfaces a Signature hash type signal", r.signals.some((s) => s.label === "Signature hash type" && /ANYONECANPAY/.test(s.value))); + check("sighash: signal carries a reference url", r.signals.some((s) => s.label === "Signature hash type" && typeof s.reference === "string" && s.reference.startsWith("https://"))); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [{ txid: "ef".repeat(32), vout: 0, sequence: 0xffffffff, scriptSig: { asm: "" }, txinwitness: ["ab".repeat(64) + "83"] }], + vout: [out("witness_v1_taproot", "bc1ptr", 0.01, "5120" + "77".repeat(32))] + }; + const txInputs = { 0: prevout("witness_v1_taproot", "bc1pin", 0.02) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("sighash: taproot 65-byte witness with explicit SIGHASH_SINGLE|ANYONECANPAY is surfaced", r.signals.some((s) => s.label === "Signature hash type" && /SINGLE\|ANYONECANPAY/.test(s.value))); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [p2wpkhInput("df".repeat(32), 0, 0xffffffff, lowRSig, compressedPk)], + vout: [out("witness_v0_keyhash", "bc1qx", 0.01, "0014" + "11".repeat(20))] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.02) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("sighash: a plain SIGHASH_ALL transaction adds no sighash signal", !r.signals.some((s) => s.label === "Signature hash type")); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [p2wpkhInput("c1".repeat(32), 0, 0xfffffffd, lowRSig, compressedPk)], + vout: [ + out("witness_v0_keyhash", "bc1qpay", 0.01, "0014" + "11".repeat(20)), + out("witness_v0_keyhash", "bc1qchg", 0.00987654, "0014" + "22".repeat(20)) + ] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.02) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("signer: an ordinary low-R spend eliminates nobody", r.signerCandidates.length === 3); + check("signer: nothing eliminated means no verdict is reported", r.signerVerdict === null); + check("signer: nothing eliminated means no signing device row", !r.signals.some((s) => s.label === "Signing device")); +} + +{ + // six low-R signatures is the threshold at which grinding is treated as deliberate + const tx = { + version: 2, + locktime: 0, + vin: Array.from({ length: 6 }, (_, i) => p2wpkhInput("c7".repeat(32), i, 0xfffffffd, lowRSig, compressedPk)), + vout: [out("witness_v0_keyhash", "bc1qx", 0.0599, "0014" + "11".repeat(20))] + }; + const txInputs = {}; + for (let i = 0; i < 6; i++) { + txInputs[i] = prevout("witness_v0_keyhash", "bc1qin" + i, 0.01); + } + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("signer: deliberate low-R grinding eliminates the non-grinding devices", !r.signerCandidates.includes("Trezor device") && !r.signerCandidates.includes("Ledger device")); + check("signer: the grinding device survives", r.signerCandidates.includes("Coldcard")); + check("signer: verdict names the survivor with a strength grade", r.signerVerdict === "Coldcard possible"); + check("signer: signing device row names the reason for each elimination", r.signals.some((s) => s.label === "Signing device" && /Trezor device: deliberate low-R grinding, which it never does/.test(s.implication))); + check("signer: signer verdict is separate from the wallet verdict", r.verdict !== r.signerVerdict); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [p2wpkhInput("c2".repeat(32), 0, 0xfffffffd, highRSig, compressedPk)], + vout: [out("witness_v0_keyhash", "bc1qx", 0.0199, "0014" + "11".repeat(20))] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.02) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("signer: high-R rules out Coldcard", !r.signerCandidates.includes("Coldcard")); + check("signer: the non-grinding devices survive a high-R signature", r.signerVerdict === "Trezor device, Ledger device possible"); + check("signer: high-R implication names the grinding signers", r.signals.some((s) => s.label === "Low-R grinding" && /Coldcard/.test(s.implication))); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [p2wpkhInput("c3".repeat(32), 0, 0xfffffffd, highRSig, compressedPk)], + vout: [out("witness_v0_keyhash", "bc1qx", 0.0199, "0014" + "11".repeat(20))] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.02) }; + const r = analyzeTransaction(tx, txInputs, 600000, 840002); + + check("signer: high-R before firmware 4.1.2 does not rule out Coldcard", r.signerCandidates.includes("Coldcard")); + check("signer: nothing eliminated at that height, so no verdict", r.signerVerdict === null); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [{ txid: "c4".repeat(32), vout: 0, sequence: 0xfffffffd, txinwitness: ["ab".repeat(64)], scriptSig: { asm: "" } }], + vout: [out("witness_v1_taproot", "bc1ptr", 0.01, "5120" + "77".repeat(32))] + }; + const txInputs = { 0: prevout("witness_v1_taproot", "bc1pin", 0.02) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("signer: a taproot spend rules out Coldcard mainline firmware", !r.signerCandidates.includes("Coldcard")); + check("signer: Trezor and Ledger sign taproot, so they survive", r.signerVerdict === "Trezor device, Ledger device possible"); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [p2wpkhInput("c5".repeat(32), 0, 0xfffffffd, lowRSig.slice(0, -2) + "02", compressedPk)], + vout: [out("witness_v0_keyhash", "bc1qx", 0.0199, "0014" + "11".repeat(20))] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.02) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("signer: SIGHASH_NONE rules out every profiled device", r.signerCandidates.length === 0); + check("signer: all-eliminated verdict", r.signerVerdict === "Coldcard, Trezor device, Ledger device ruled out"); +} + +{ + const tx = { + version: 2, + locktime: 0, + vin: [p2wpkhInput("c6".repeat(32), 0, 0xfffffffd, lowRSig, uncompressedPk)], + vout: [out("witness_v0_keyhash", "bc1qx", 0.0199, "0014" + "11".repeat(20))] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.02) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("signer: an uncompressed key outside a P2PK input rules out every profiled device", r.signerCandidates.length === 0); +} + +(async () => { + const pk = compressedPk; + const chainOut = [ + out("witness_v0_keyhash", "bc1qpay", 0.005, "0014" + "11".repeat(20)), + out("witness_v0_keyhash", "bc1qchg", 0.00412345, "0014" + "99".repeat(20)) + ]; + const parentTxid = "ee".repeat(32); + const gpTxid = "ff".repeat(32); + const unknownTxid = "12".repeat(32); + + const parentTx = { txid: parentTxid, vin: [{ txid: gpTxid, vout: 1, txinwitness: [lowRSig, pk], scriptSig: { asm: "" } }], vout: chainOut }; + const gpTx = { txid: gpTxid, vin: [{ txid: unknownTxid, vout: 1, txinwitness: [lowRSig, pk], scriptSig: { asm: "" } }], vout: chainOut }; + const chainInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.02) }; + + const fetcher = async (txid) => { + if (txid === parentTxid) return { tx: parentTx, txInputs: chainInputs }; + if (txid === gpTxid) return { tx: gpTx, txInputs: chainInputs }; + return null; + }; + + const startTx = { txid: "aa".repeat(32), vin: [{ txid: parentTxid, vout: 1, txinwitness: [lowRSig, pk], scriptSig: { asm: "" } }], vout: chainOut }; + const startInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.02) }; + + const r = await gatherLinkedSignatures(startTx, startInputs, fetcher, 4); + check("graph walk: follows the 2-hop self-change chain", r.linkedTxids.length === 2); + check("graph walk: collects low-R sigs from linked txs", r.low === 2 && r.high === 0); + + const none = await gatherLinkedSignatures(startTx, startInputs, null, 4); + check("graph walk: no fetcher returns empty", none.linkedTxids.length === 0); + +})(); + +{ + // Runes-style OP_RETURN: OP_RETURN OP_13 <18-byte push>, two pushes, so not Trezor + const tx = { + version: 2, + locktime: 0, + vin: [p2wpkhInput("d1".repeat(32), 0, 0xfffffffd, lowRSig, compressedPk)], + vout: [ + out("nulldata", null, 0, "6a5d1214011400ff7f818cec82d08bc0a88281d215"), + out("witness_v0_keyhash", "bc1qx", 0.0099, "0014" + "11".repeat(20)) + ] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.01) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("op_return: a two-push OP_RETURN rules out Trezor", !r.signerCandidates.includes("Trezor device")); + check("op_return: Coldcard and Ledger accept it", r.signerCandidates.includes("Coldcard") && r.signerCandidates.includes("Ledger device")); +} + +{ + // OP_RETURN over the 83-byte script limit Ledger enforces + const tx = { + version: 2, + locktime: 0, + vin: [p2wpkhInput("d2".repeat(32), 0, 0xfffffffd, lowRSig, compressedPk)], + vout: [ + out("nulldata", null, 0, "6a4c96" + "ab".repeat(150)), + out("witness_v0_keyhash", "bc1qx", 0.0099, "0014" + "11".repeat(20)) + ] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.01) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("op_return: an oversized OP_RETURN rules out Ledger", !r.signerCandidates.includes("Ledger device")); + check("op_return: Coldcard has no size limit and survives", r.signerCandidates.includes("Coldcard")); +} + +{ + // pay-to-anchor output: Trezor rejects witness v1 with a 2-byte program, Ledger allows it + const tx = { + version: 2, + locktime: 0, + vin: [p2wpkhInput("d3".repeat(32), 0, 0xfffffffd, lowRSig, compressedPk)], + vout: [ + out("anchor", "bc1pfeas", 0.00000330, "51024e73"), + out("witness_v0_keyhash", "bc1qx", 0.0099, "0014" + "11".repeat(20)) + ] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.01) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("anchor: an anchor output rules out Trezor", !r.signerCandidates.includes("Trezor device")); + check("anchor: Ledger accepts undefined segwit programs", r.signerCandidates.includes("Ledger device")); +} + +{ + // a fee at or above 10 percent of the outputs is refused by Coldcard firmware + const tx = { + version: 2, + locktime: 0, + vin: [p2wpkhInput("d4".repeat(32), 0, 0xfffffffd, lowRSig, compressedPk)], + vout: [out("witness_v0_keyhash", "bc1qx", 0.008, "0014" + "11".repeat(20))] + }; + const txInputs = { 0: prevout("witness_v0_keyhash", "bc1qin", 0.01) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("fee: a 25 percent fee rules out Coldcard", !r.signerCandidates.includes("Coldcard")); + check("fee: the reason names the fee ratio", r.signals.some((s) => s.label === "Signing device" && /percent of the outputs/.test(s.implication))); +} + +{ + // P2PK input: only Coldcard implements bare pubkey spends + const tx = { + version: 2, + locktime: 0, + vin: [{ txid: "d5".repeat(32), vout: 0, sequence: 0xfffffffd, scriptSig: { asm: lowRSig }, txinwitness: [] }], + vout: [out("witness_v0_keyhash", "bc1qx", 0.0099, "0014" + "11".repeat(20))] + }; + const txInputs = { 0: prevout("pubkey", null, 0.01) }; + const r = analyzeTransaction(tx, txInputs, 840001, 840002); + + check("p2pk: a bare pubkey input rules out Trezor and Ledger", !r.signerCandidates.includes("Trezor device") && !r.signerCandidates.includes("Ledger device")); + check("p2pk: Coldcard is the only device that can spend it", r.signerVerdict === "Coldcard likely"); +} + +{ + // the survivor's untestable rules must be stated, so survival is not read as evidence + const tx = { + version: 1, + locktime: 0, + vin: [p2wpkhInput("d6".repeat(32), 0, 0xffffffff, lowRSig.slice(0, -2) + "81", compressedPk)], + vout: [out("witness_v0_keyhash", "bc1qx", 0.01, "0014" + "11".repeat(20))] + }; + const r = analyzeTransaction(tx, null, 960349, 960358); + + check("survivor: a firmware-policy exclusion grades likely", r.signerVerdict === "Coldcard likely"); + check("survivor: row explains what the grade rests on", r.signals.some((s) => s.label === "Signing device" && /only profiled device that could have signed this/.test(s.implication))); + check("survivor: row lists the checks that could not run", r.signals.some((s) => s.label === "Signing device" && /could not test .*fee ceiling/.test(s.implication))); + check("survivor: row notes rules that never exclude", r.signals.some((s) => s.label === "Signing device" && /never exclude anything/.test(s.implication))); +} + +{ + // a taproot input excludes the stock firmware, but the EDGE build spends taproot, + // so the exclusion must carry that caveat rather than reading as absolute + const tx = { + version: 2, + locktime: 0, + vin: [{ txid: "d7".repeat(32), vout: 0, sequence: 0xfffffffd, txinwitness: ["ab".repeat(64)], scriptSig: { asm: "" } }], + vout: [out("witness_v1_taproot", "bc1ptr", 0.01, "5120" + "77".repeat(32))] + }; + const r = analyzeTransaction(tx, null, 840001, 840002); + + check("edge: a taproot input still excludes the stock Coldcard", !r.signerCandidates.includes("Coldcard")); + check("edge: the exclusion names the EDGE firmware caveat", r.signals.some((s) => s.label === "Signing device" && /EDGE build adds taproot spending/.test(s.implication))); +} + +{ + // P2WSH 2-of-3 multisig: signatures sit in the witness stack alongside the script + const wsig = "3044" + "0220" + "aa".repeat(32) + "0220" + "aa".repeat(32) + "01"; + const witnessScript = "52" + "21" + "02" + "bb".repeat(32) + "21" + "02" + "cc".repeat(32) + "21" + "02" + "dd".repeat(32) + "53ae"; + const tx = { + version: 2, + locktime: 840000, + vin: Array.from({ length: 3 }, (_, i) => ({ + txid: "e1".repeat(32), vout: i, sequence: 0xfffffffd, + txinwitness: ["", wsig, wsig, witnessScript], scriptSig: { asm: "" } + })), + vout: [out("witness_v0_keyhash", "bc1qx", 0.05, "0014" + "11".repeat(20))] + }; + const r = analyzeTransaction(tx, null, 840001, 840002); + + check("multisig: signatures in a P2WSH witness are counted", r.signals.some((s) => s.label === "Low-R grinding" && /all 6/.test(s.value))); + check("multisig: the witness script is not mistaken for a signature", !/all 9/.test(JSON.stringify(r.signals))); + check("multisig: grinding eliminates the non-grinding devices", r.signerVerdict === "Coldcard possible"); +} + +{ + // P2SH legacy multisig: Bitcoin Core's asm strips the sighash byte and annotates it + const bare = "3044" + "0220" + "aa".repeat(32) + "0220" + "aa".repeat(32); + const redeem = "52" + "21" + "02" + "bb".repeat(32) + "21" + "02" + "cc".repeat(32) + "52ae"; + const tx = { + version: 2, + locktime: 840000, + vin: Array.from({ length: 3 }, (_, i) => ({ + txid: "e2".repeat(32), vout: i, sequence: 0xfffffffd, txinwitness: [], + scriptSig: { asm: `0 ${bare}[ALL] ${bare}[ALL] ${redeem}` } + })), + vout: [out("witness_v0_keyhash", "bc1qx", 0.05, "0014" + "11".repeat(20))] + }; + const r = analyzeTransaction(tx, null, 840001, 840002); + + check("multisig: signatures in a legacy P2SH scriptSig are counted", r.signals.some((s) => s.label === "Low-R grinding" && /all 6/.test(s.value))); + check("multisig: the redeem script is not mistaken for a signature", r.signerVerdict === "Coldcard possible"); +} + +(async () => { + // multisig quorum pooling: a parent spending the same witness script is the same wallet, + // so its signatures join the count without needing previous-output data + const wsig = "3044" + "0220" + "aa".repeat(32) + "0220" + "aa".repeat(32) + "01"; + const hsig = "3045" + "0221" + "bb".repeat(33) + "0220" + "bb".repeat(32) + "01"; + const quorum = "52" + "21" + "02" + "bb".repeat(32) + "21" + "02" + "cc".repeat(32) + "21" + "02" + "dd".repeat(32) + "53ae"; + const otherQuorum = "52" + "21" + "02" + "ee".repeat(32) + "21" + "02" + "ff".repeat(32) + "52ae"; + + const msIn = (txid, vout, script, sig) => ({ txid, vout, sequence: 0xfffffffd, txinwitness: ["", sig, sig, script], scriptSig: { asm: "" } }); + const parentTxid = "f1".repeat(32); + const strangerTxid = "f2".repeat(32); + + const startTx = { txid: "f0".repeat(32), vin: [msIn(parentTxid, 0, quorum, wsig)], vout: [] }; + const parentTx = { txid: parentTxid, vin: [msIn("f9".repeat(32), 0, quorum, wsig), msIn("f9".repeat(32), 1, quorum, wsig)], vout: [] }; + + const fetcher = async (txid) => (txid === parentTxid ? { tx: parentTx, txInputs: null } : null); + const r = await gatherLinkedSignatures(startTx, null, fetcher, 4); + + check("quorum: a same-script parent is pooled without prevout data", r.linkedTxids.includes(parentTxid)); + check("quorum: its signatures are counted", r.low === 4 && r.high === 0); + + // a parent from a different quorum must not be pooled + const strangerTx = { txid: strangerTxid, vin: [msIn("f8".repeat(32), 0, otherQuorum, wsig)], vout: [] }; + const startTx2 = { txid: "f3".repeat(32), vin: [msIn(strangerTxid, 0, quorum, wsig)], vout: [] }; + const r2 = await gatherLinkedSignatures(startTx2, null, async (t) => (t === strangerTxid ? { tx: strangerTx, txInputs: null } : null), 4); + + check("quorum: a different wallet's transaction is not pooled", r2.linkedTxids.length === 0 && r2.low === 0); + + // a high-R signature in the pool must count against the grinders, not be dropped + const parentHigh = { txid: parentTxid, vin: [msIn("f7".repeat(32), 0, quorum, hsig)], vout: [] }; + const r3 = await gatherLinkedSignatures(startTx, null, async (t) => (t === parentTxid ? { tx: parentHigh, txInputs: null } : null), 4); + + check("quorum: pooled high-R signatures are counted too", r3.high === 2 && r3.low === 0); + +})(); diff --git a/app/walletFingerprints.json b/app/walletFingerprints.json new file mode 100644 index 000000000..6fecb77c0 --- /dev/null +++ b/app/walletFingerprints.json @@ -0,0 +1,54 @@ +{ + "source": "Schema inspired by https://github.com/arminsabouri/walletfingerprints.info ; wallet values derived by reading each wallet's transaction-construction source, signer values by reading each signing device's firmware.", + "vocabulary": { + "version": [1, 2], + "anti_fee_sniping": ["always", "sometimes", "never"], + "rbf": ["always", "sometimes", "never", "native_segwit_only"], + "multi_type_vin": ["yes", "no"], + "low_r": ["always", "no", "unknown"], + "op_return": ["yes", "no"], + "batching": ["multi", "single"], + "bip69": ["always", "never"], + "address_reuse": ["yes", "no"], + "change_position": ["last", "any"], + "change_type": ["input", "payment"], + "nsequence": "list of exact nSequence hex values the wallet uses; omit the field when not known (treated as any)" + }, + "wallets": { + "Bitcoin Core": { "version": 2, "anti_fee_sniping": "always", "rbf": "always", "multi_type_vin": "yes", "low_r": "always", "op_return": "yes", "batching": "multi", "bip69": "never", "address_reuse": "no", "change_position": "any", "change_type": "payment", "nsequence": ["0xfffffffd", "0xfffffffe", "0xffffffff"] }, + "Electrum": { "version": 2, "anti_fee_sniping": "always", "rbf": "always", "multi_type_vin": "no", "low_r": "always", "op_return": "yes", "batching": "multi", "bip69": "always", "address_reuse": "no", "change_position": "any", "change_type": "input", "nsequence": ["0xfffffffd", "0xfffffffe", "0xffffffff"] }, + "Blue Wallet": { "version": 2, "anti_fee_sniping": "never", "rbf": "native_segwit_only", "multi_type_vin": "no", "low_r": "no", "op_return": "no", "batching": "multi", "bip69": "never", "address_reuse": "no", "change_position": "last", "change_type": "input", "nsequence": ["0x80000000", "0xffffffff"] }, + "Coinbase Wallet": { "version": 2, "anti_fee_sniping": "never", "rbf": "never", "multi_type_vin": "yes", "low_r": "no", "op_return": "no", "batching": "single", "bip69": "never", "address_reuse": "no", "change_position": "last", "change_type": "input", "nsequence": ["0xffffffff"] }, + "Exodus Wallet": { "version": 2, "anti_fee_sniping": "never", "rbf": "never", "multi_type_vin": "no", "low_r": "no", "op_return": "no", "batching": "single", "bip69": "never", "address_reuse": "yes", "change_position": "any", "change_type": "input", "nsequence": ["0xffffffff"] }, + "Trust Wallet": { "version": 1, "anti_fee_sniping": "never", "rbf": "always", "multi_type_vin": "no", "low_r": "no", "op_return": "no", "batching": "single", "bip69": "never", "address_reuse": "yes", "change_position": "any", "change_type": "input" }, + "Trezor": { "version": 1, "anti_fee_sniping": "never", "rbf": "always", "multi_type_vin": "no", "low_r": "no", "op_return": "yes", "batching": "multi", "bip69": "always", "address_reuse": "no", "change_position": "any", "change_type": "input" }, + "Ledger": { "version": 1, "anti_fee_sniping": "never", "rbf": "always", "multi_type_vin": "no", "low_r": "no", "op_return": "no", "batching": "single", "bip69": "never", "address_reuse": "no", "change_position": "last", "change_type": "input" }, + "Sparrow": { "version": 2, "anti_fee_sniping": "always", "rbf": "always", "multi_type_vin": "no", "low_r": "always", "op_return": "yes", "batching": "multi", "bip69": "never", "address_reuse": "no", "change_position": "last", "change_type": "input", "nsequence": ["0xfffffffd", "0xfffffffe", "0xffffffff"] }, + "Bull Bitcoin": { "version": 2, "anti_fee_sniping": "always", "rbf": "always", "multi_type_vin": "yes", "low_r": "always", "op_return": "no", "batching": "multi", "bip69": "never", "address_reuse": "no", "change_position": "last", "change_type": "input", "nsequence": ["0xfffffffd", "0xfffffffe", "0xffffffff"] }, + "Cake Wallet": { "version": 2, "anti_fee_sniping": "never", "rbf": "sometimes", "multi_type_vin": "yes", "low_r": "always", "op_return": "yes", "batching": "multi", "bip69": "never", "address_reuse": "no", "change_position": "last", "change_type": "input", "nsequence": ["0xfffffffd", "0xffffffff"] }, + "Liana": { "version": 2, "anti_fee_sniping": "sometimes", "rbf": "always", "multi_type_vin": "no", "low_r": "always", "op_return": "no", "batching": "multi", "bip69": "never", "address_reuse": "no", "change_position": "last", "change_type": "input", "nsequence": ["0xfffffffd"] }, + "Nunchuk": { "version": 2, "anti_fee_sniping": "sometimes", "rbf": "always", "multi_type_vin": "yes", "low_r": "no", "op_return": "yes", "batching": "multi", "bip69": "never", "address_reuse": "no", "change_position": "any", "change_type": "input" }, + "Wasabi": { "version": 2, "anti_fee_sniping": "sometimes", "rbf": "never", "multi_type_vin": "yes", "low_r": "no", "op_return": "no", "batching": "multi", "bip69": "never", "address_reuse": "no", "change_position": "last", "change_type": "input", "nsequence": ["0xfffffffe", "0xffffffff"] } + }, + "signer_vocabulary": { + "note": "A signing device does not build the transaction, so version, locktime, nSequence, output order and change selection all come from the coordinator software. Only the fingerprints left by the signing itself are listed here, and they are matched separately from the wallet catalog. The Trezor and Ledger entries in that catalog describe their coordinator apps (Trezor Suite, Ledger Live) building a transaction, which is a different question from the device signing one under any coordinator.", + "low_r": ["always", "no", "unknown"], + "low_r_since_height": "block height from which low_r applies, approximating the release of the firmware that introduced grinding (Coldcard 4.1.2, July 2021); below it a high-R signature does not rule the signer out", + "input_types": "input script types the firmware can spend; an input outside this list rules the signer out", + "input_types_note": "caveat appended when an input type excludes this signer, for cases where an alternate firmware edition lifts the limit", + "output_types": "output script types the firmware can pay to, or any; an output outside this list rules the signer out", + "op_return_max_script_bytes": "largest OP_RETURN scriptPubKey the firmware accepts, null for no limit", + "op_return_max_pushes": "most data pushes the firmware accepts inside an OP_RETURN, null for no limit", + "op_return_pushdata2": "whether the firmware accepts OP_PUSHDATA2 or OP_PUSHDATA4 inside an OP_RETURN", + "op_return_nonzero_value": "whether the firmware accepts an OP_RETURN output that carries value", + "max_fee_percent": "firmware refuses when the fee is at least this percent of the total output value", + "max_fee_rate_sat_vb": "firmware refuses above this fee rate", + "uncompressed_keys": ["yes", "p2pk_only", "no"], + "sighash": "list of sighash flags the signer will sign with its default settings" + }, + "signers": { + "Coldcard": { "low_r": "always", "low_r_since_height": 694000, "input_types": ["p2pk", "p2pkh", "p2sh", "p2wpkh", "p2wsh"], "input_types_note": "this applies to the mainline firmware every model ships with; the opt-in EDGE build adds taproot spending, so a taproot input excludes a stock Coldcard but not an EDGE one", "output_types": "any", "op_return_max_script_bytes": null, "op_return_max_pushes": null, "op_return_pushdata2": true, "op_return_nonzero_value": true, "max_fee_percent": 10, "uncompressed_keys": "p2pk_only", "sighash": ["SIGHASH_ALL", "SIGHASH_SINGLE", "SIGHASH_ALL|ANYONECANPAY", "SIGHASH_SINGLE|ANYONECANPAY"] }, + "Trezor device": { "low_r": "no", "input_types": ["p2pkh", "p2sh", "p2wpkh", "p2wsh", "p2tr"], "output_types": ["p2pkh", "p2sh", "p2wpkh", "p2wsh", "p2tr", "op_return"], "op_return_max_script_bytes": null, "op_return_max_pushes": 1, "op_return_pushdata2": true, "op_return_nonzero_value": false, "max_fee_rate_sat_vb": 20000, "uncompressed_keys": "no", "sighash": ["SIGHASH_DEFAULT", "SIGHASH_ALL"] }, + "Ledger device": { "low_r": "no", "input_types": ["p2pkh", "p2sh", "p2wpkh", "p2wsh", "p2tr"], "output_types": ["p2pkh", "p2sh", "p2wpkh", "p2wsh", "p2tr", "witness_unknown", "anchor", "op_return"], "op_return_max_script_bytes": 83, "op_return_max_pushes": 5, "op_return_pushdata2": false, "op_return_nonzero_value": true, "uncompressed_keys": "no", "sighash": ["SIGHASH_DEFAULT", "SIGHASH_ALL"] } + } +} diff --git a/routes/baseRouter.js b/routes/baseRouter.js index fde2c60a3..63a144c04 100644 --- a/routes/baseRouter.js +++ b/routes/baseRouter.js @@ -27,6 +27,7 @@ const coreApi = require("./../app/api/coreApi.js"); const addressApi = require("./../app/api/addressApi.js"); const rpcApi = require("./../app/api/rpcApi.js"); const btcQuotes = require("./../app/coins/btcQuotes.js"); +const walletFingerprintAnalysis = require("./../app/walletFingerprintAnalysis.js"); const forceCsrf = csrfApi({ ignoreMethods: [] }); @@ -1437,6 +1438,31 @@ router.get("/tx/:transactionId", asyncHandler(async (req, res, next) => { await utils.awaitPromises(promises); + await utils.timePromise("tx.walletFingerprintAnalysis", async () => { + try { + const getblockchaininfo = await coreApi.getBlockchainInfo(); + const tipHeight = getblockchaininfo ? getblockchaininfo.blocks : -1; + const txBlockHeight = (res.locals.result.getblock && res.locals.result.getblock.height) || -1; + + const fetchTxWithInputs = async (linkedTxid) => { + const linked = await coreApi.getRawTransactionsWithInputs([linkedTxid], -1); + return { tx: linked.transactions[0], txInputs: linked.txInputsByTransaction[linkedTxid] || {} }; + }; + + let extraSignatures = { low: 0, high: 0, linkedTxids: [] }; + try { + extraSignatures = await walletFingerprintAnalysis.gatherLinkedSignatures(tx, res.locals.result.txInputs, fetchTxWithInputs, 4); + } catch (graphErr) { + utils.logError("walletFingerprintAnalysisGraph", graphErr); + } + + res.locals.walletFingerprintAnalysis = walletFingerprintAnalysis.analyzeTransaction(tx, res.locals.result.txInputs, txBlockHeight, tipHeight, extraSignatures); + + } catch (err) { + utils.logError("walletFingerprintAnalysis", err); + } + }, perfResults); + if (global.specialTransactions && global.specialTransactions[txid]) { let funInfo = global.specialTransactions[txid]; diff --git a/views/transaction.pug b/views/transaction.pug index b8d221f51..762b559c3 100644 --- a/views/transaction.pug +++ b/views/transaction.pug @@ -79,6 +79,11 @@ block content a.btn.btn-primary.btn-sm.me-2(href=`./changeSetting?name=txPageShowTechDetails&value=true`, title="Display technical details for this transaction.", data-bs-toggle="tooltip") i.bi-plus-square.me-2 | Technical Details + + if (`${userSettings["txPageShowFingerprints"]}` == "false") + a.btn.btn-danger.btn-sm.me-2(href=`./changeSetting?name=txPageShowFingerprints&value=true`, title="Display wallet fingerprint analysis for this transaction.", data-bs-toggle="tooltip") + i.bi-plus-square.me-2 + | Wallet Fingerprints - var isTxConfirmed = true; @@ -278,6 +283,41 @@ block content +txIoDetails(tx, txInputs, totalIOValues, blockHeight) + if (walletFingerprintAnalysis && walletFingerprintAnalysis.available) + - var wfa = walletFingerprintAnalysis; + +contentSection("Wallet Fingerprints", true, "txPageShowFingerprints", true) + p.mb-1 + span.fw-bold.me-2 Likely wallet: + span.badge(class=`text-bg-${wfa.verdictClass}`) #{wfa.verdict} + if (wfa.signerVerdict) + p.mb-3 + span.fw-bold.me-2 Signing device: + span.badge(class=`text-bg-${wfa.signerVerdictClass}`) #{wfa.signerVerdict} + .table-responsive + table.table.table-sm.small.mb-2 + thead + tr + th Signal + th Observed + th What it means + tbody + each signal in wfa.signals + tr + td.text-nowrap.fw-bold #{signal.label} + td #{signal.value} + td + | #{signal.implication} + if (signal.reference) + a.ms-1.text-decoration-none(href=signal.reference, target="_blank", rel="noopener noreferrer", title="Learn more") + i.bi-book + if (signal.privacy) + .text-warning.mt-1 + i.bi-exclamation-triangle.me-1 + | #{signal.privacy} + if (!wfa.haveInputData) + p.text-muted.small.fst-italic Some checks were skipped because the previous-output data for this transaction's inputs was not available. + p.text-muted.small.fst-italic #{wfa.disclaimer} + if (mempoolDetails) if (mempoolDetails.ancestors.length > 0) - var ancestorDesc = "Ancestor Transactions are transactions whose outputs are being spent by this transaction. All ancestors must be confirmed before this transaction can be confirmed (though they can be confirmed in the same block).";