SPB Git

Repository Public

bch.ts 15.8 KB 264 lines typescript

1// Bitcoin Cash: P2PKH transfers only. Pure module (no network access).2//3// Bitcoin Cash signatures use the BIP143-style digest with SIGHASH_FORKID (0x41), which @scure/btc-signer does not implement,4// so the transaction serialization and the digest are implemented here (about a hundred lines) and proven in the tests against5// libauth's virtual machine, the Bitcoin Cash Node sighash vectors and the CashAddr specification vectors.6//   digest:   https://github.com/bitcoincashorg/bitcoincash.org/blob/master/spec/replay-protected-sighash.md7//   CashAddr: https://github.com/bitcoincashorg/bitcoincash.org/blob/master/spec/cashaddr.md8// The digest commits to the value of every input, so previous transactions are not needed: a wrong input value in the intent9// yields a signature the network rejects, never a larger fee.10import { secp256k1 } from '@noble/curves/secp256k1.js';11import { ripemd160 } from '@noble/hashes/legacy.js';12import { sha256 } from '@noble/hashes/sha2.js';13import { createBase58check, hex } from '@scure/base';14import { baseUnits } from '../amounts.ts';15import type { FamilyPlugin, KeyHandle, Signed } from '../families.ts';16import type { Intent } from '../intent.ts';17import type { NetworkDef } from '../networks.ts';1819// ---------------------------------------------------------------- CashAddr2021const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';22const GEN = [0x98f2bc8e61n, 0x79b76d99e2n, 0xf33e5fb3c4n, 0xae2eabe2a8n, 0x1e4f43e470n];23const HASH_SIZES = [20, 24, 28, 32, 40, 48, 56, 64];2425function polymod(values: number[]): bigint {26  let c = 1n;27  for (const d of values) {28    const c0 = c >> 35n;29    c = ((c & 0x07ffffffffn) << 5n) ^ BigInt(d);30    for (let k = 0; k < 5; k++) if ((c0 >> BigInt(k)) & 1n) c ^= GEN[k]!;31  }32  return c ^ 1n;33}34const prefixValues = (prefix: string) => [...[...prefix].map((ch) => ch.charCodeAt(0) & 0x1f), 0];3536/** Regroup bits. Decoding (5→8) is strict: no extra group and zero padding only. */37function regroup(data: number[], from: number, to: number, pad: boolean): number[] {38  let acc = 0, bits = 0;39  const out: number[] = [], mask = (1 << to) - 1;40  for (const v of data) {41    acc = (acc << from) | v; bits += from;42    while (bits >= to) { bits -= to; out.push((acc >> bits) & mask); }43    acc &= (1 << bits) - 1;44  }45  if (pad) { if (bits > 0) out.push((acc << (to - bits)) & mask); }46  else if (bits >= from || acc !== 0) throw new Error('invalid CashAddr padding');47  return out;48}4950export interface CashAddr { prefix: string; /** 0 = P2PKH, 1 = P2SH */ type: number; hash: Uint8Array }5152export function cashaddrEncode(prefix: string, type: number, hash: Uint8Array): string {53  const size = HASH_SIZES.indexOf(hash.length);54  if (size < 0 || !Number.isInteger(type) || type < 0 || type > 15 || !/^[a-z]+$/.test(prefix)) throw new Error('invalid CashAddr parts');55  const payload = regroup([(type << 3) | size, ...hash], 8, 5, true);56  const mod = polymod([...prefixValues(prefix), ...payload, 0, 0, 0, 0, 0, 0, 0, 0]);57  const check = Array.from({ length: 8 }, (_, k) => Number((mod >> BigInt(5 * (7 - k))) & 31n));58  return `${prefix}:${[...payload, ...check].map((v) => CHARSET[v]).join('')}`;59}6061/** Decode and verify the checksum. A string without a prefix is checked against `defaultPrefix` (the prefix is part of the checksum). */62export function cashaddrDecode(addr: string, defaultPrefix?: string): CashAddr {63  if (addr !== addr.toLowerCase() && addr !== addr.toUpperCase()) throw new Error('mixed-case CashAddr');64  const lower = addr.toLowerCase(), parts = lower.split(':');65  if (parts.length > 2) throw new Error('invalid CashAddr');66  const prefix = parts.length === 2 ? parts[0]! : defaultPrefix, body = parts[parts.length - 1]!;67  if (!prefix || !/^[a-z]+$/.test(prefix)) throw new Error('CashAddr prefix missing');68  if (body.length < 42 || body.length > 112) throw new Error('invalid CashAddr length');69  const values = [...body].map((ch) => CHARSET.indexOf(ch));70  if (values.some((v) => v < 0)) throw new Error('invalid CashAddr character');71  if (polymod([...prefixValues(prefix), ...values]) !== 0n) throw new Error('bad CashAddr checksum');72  const bytes = regroup(values.slice(0, -8), 5, 8, false), version = bytes[0]!;73  if (version & 0x80) throw new Error('reserved CashAddr version bit');74  const hash = Uint8Array.from(bytes.slice(1));75  if (hash.length !== HASH_SIZES[version & 7]) throw new Error('CashAddr hash length mismatch');76  return { prefix, type: version >> 3, hash };77}7879// ---------------------------------------------------------------- transaction8081const SIGHASH_ALL_FORKID = 0x41;82const SEQUENCE = 0xffffffff; // final: Bitcoin Cash has no replace-by-fee, and the lock time is 083const MAX_MONEY = 21_000_000n * 100_000_000n;84export const BCH_MAX_INPUTS = 50;8586export interface BchTx {87  version: number; locktime: number;88  inputs: { txid: string; vout: number; sequence: number; script: Uint8Array }[];89  outputs: { value: bigint; script: Uint8Array }[];90}9192const cat = (...p: Uint8Array[]): Uint8Array => { const o = new Uint8Array(p.reduce((n, x) => n + x.length, 0)); let k = 0; for (const x of p) { o.set(x, k); k += x.length; } return o; };93const u32 = (n: number): Uint8Array => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n >>> 0, true); return b; };94const u64 = (n: bigint): Uint8Array => { if (n < 0n || n > 0xffffffffffffffffn) throw new Error('value out of range'); const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, n, true); return b; };95const varint = (n: number): Uint8Array => {96  if (!Number.isSafeInteger(n) || n < 0 || n > 0xffffffff) throw new Error('length out of range');97  if (n < 0xfd) return Uint8Array.of(n);98  if (n <= 0xffff) return Uint8Array.of(0xfd, n & 0xff, n >> 8);99  return cat(Uint8Array.of(0xfe), u32(n));100};101const hash256 = (b: Uint8Array) => sha256(sha256(b));102const hash160 = (b: Uint8Array) => ripemd160(sha256(b));103const outpoint = (i: { txid: string; vout: number }) => cat(hex.decode(i.txid).reverse(), u32(i.vout));104const encOutput = (o: { value: bigint; script: Uint8Array }) => cat(u64(o.value), varint(o.script.length), o.script);105/** minimal push of 1 to 75 bytes */106const push = (b: Uint8Array) => { if (b.length < 1 || b.length > 75) throw new Error('push out of range'); return cat(Uint8Array.of(b.length), b); };107const p2pkhScript = (h: Uint8Array) => cat(Uint8Array.of(0x76, 0xa9, 0x14), h, Uint8Array.of(0x88, 0xac));108const p2shScript = (h: Uint8Array) => cat(Uint8Array.of(0xa9, 0x14), h, Uint8Array.of(0x87));109110export function encodeBchTx(t: BchTx): Uint8Array {111  return cat(u32(t.version), varint(t.inputs.length),112    ...t.inputs.map((i) => cat(outpoint(i), varint(i.script.length), i.script, u32(i.sequence))),113    varint(t.outputs.length), ...t.outputs.map(encOutput), u32(t.locktime));114}115116/** Strict decoder (no trailing bytes); used to check the builder against published vectors and other implementations. */117export function decodeBchTx(raw: Uint8Array): BchTx {118  const dv = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);119  let o = 0;120  const need = (n: number) => { if (o + n > raw.length) throw new Error('truncated transaction'); };121  const r32 = () => { need(4); const v = dv.getUint32(o, true); o += 4; return v; };122  const rvar = () => { need(1); const f = raw[o++]!; if (f < 0xfd) return f; if (f === 0xfd) { need(2); const v = dv.getUint16(o, true); o += 2; return v; } if (f === 0xfe) return r32(); throw new Error('unsupported length'); };123  const rbytes = (n: number) => { need(n); const b = raw.slice(o, o + n); o += n; return b; };124  const version = r32();125  const inputs = Array.from({ length: rvar() }, () => { const txid = hex.encode(rbytes(32).reverse()), vout = r32(), script = rbytes(rvar()); return { txid, vout, script, sequence: r32() }; });126  const outputs = Array.from({ length: rvar() }, () => { need(8); const value = dv.getBigUint64(o, true); o += 8; return { value, script: rbytes(rvar()) }; });127  const locktime = r32();128  if (o !== raw.length) throw new Error('trailing bytes after transaction');129  return { version, locktime, inputs, outputs };130}131132/**133 * Replay-protected signature digest of one input. `hashType` is the 32-bit signature hash type; only134 * SIGHASH_ALL | SIGHASH_FORKID is implemented (low byte 0x41; the upper 24 bits are the fork id, 0 on Bitcoin Cash).135 */136export function bchSighash(t: BchTx, index: number, scriptCode: Uint8Array, value: bigint, hashType: number = SIGHASH_ALL_FORKID): Uint8Array {137  if ((hashType & 0xff) !== SIGHASH_ALL_FORKID) throw new Error('only SIGHASH_ALL|FORKID is supported');138  const i = t.inputs[index];139  if (!i) throw new Error('input index out of range');140  return hash256(cat(141    u32(t.version), hash256(cat(...t.inputs.map(outpoint))), hash256(cat(...t.inputs.map((x) => u32(x.sequence)))),142    outpoint(i), varint(scriptCode.length), scriptCode, u64(value), u32(i.sequence),143    hash256(cat(...t.outputs.map(encOutput))), u32(t.locktime), u32(hashType),144  ));145}146147export const bchTxid = (raw: Uint8Array) => hex.encode(hash256(raw).reverse());148149// ---------------------------------------------------------------- family plugin150151const b58check = createBase58check(sha256);152const prefixOf = (net: NetworkDef) => { const p = net.utxo?.cashaddr; if (net.family !== 'bch' || !net.utxo || !p) throw new Error(`${net.id} is not a Bitcoin Cash network`); return p; };153154function normalizeAddress(net: NetworkDef, addr: string): string {155  const prefix = prefixOf(net), a = addr.trim();156  if (!a.includes(':') && /^[13][1-9A-HJ-NP-Za-km-z]{24,34}$/.test(a)) {157    // Legacy Base58 form. Only pay-to-public-key-hash is accepted: the same key controls the coins whatever the chain the158    // address was created for. A legacy "3…" address is refused because on Bitcoin it usually is a SegWit script that159    // cannot be spent on Bitcoin Cash.160    const raw = b58check.decode(a);161    if (raw.length !== 21) throw new Error('invalid legacy address');162    if (raw[0] !== net.utxo!.pubKeyHash) throw new Error('legacy script address is ambiguous; use the CashAddr form (bitcoincash:p…)');163    return cashaddrEncode(prefix, 0, raw.slice(1));164  }165  const d = cashaddrDecode(a, prefix);166  if (d.prefix !== prefix) throw new Error(`address is not a ${net.name} address`);167  if (d.type !== 0 && d.type !== 1) throw new Error('unsupported CashAddr type (token-aware addresses are not supported)');168  if (d.hash.length !== 20) throw new Error('unsupported CashAddr hash size');169  return cashaddrEncode(prefix, d.type, d.hash);170}171172const scriptOf = (net: NetworkDef, normalized: string): Uint8Array => { const d = cashaddrDecode(normalized, prefixOf(net)); return d.type === 0 ? p2pkhScript(d.hash) : p2shScript(d.hash); };173174const UINT = /^(0|[1-9]\d{0,15})$/;175/** `x.inputs`: "<txid>:<vout>:<value>" joined by commas, in signing order. */176export const encodeBchInputs = (inputs: { txid: string; vout: number; value: bigint | string | number }[]) => inputs.map((u) => `${u.txid}:${u.vout}:${u.value}`).join(',');177function parseInputs(s: string): { txid: string; vout: number; value: bigint }[] {178  const items = s.split(',');179  if (items.length < 1 || items.length > BCH_MAX_INPUTS) throw new Error('unsupported number of inputs');180  const seen = new Set<string>();181  return items.map((it) => {182    const m = /^([0-9a-f]{64}):(0|[1-9]\d{0,9}):(0|[1-9]\d{0,15})$/.exec(it);183    if (!m) throw new Error('malformed input');184    const vout = Number(m[2]), value = BigInt(m[3]!);185    if (vout > 0xffffffff) throw new Error('malformed input');186    if (value <= 0n || value > MAX_MONEY) throw new Error('input value out of range');187    const k = `${m[1]}:${vout}`;188    if (seen.has(k)) throw new Error('duplicate input');189    seen.add(k);190    return { txid: m[1]!, vout, value };191  });192}193194/** Rebuild the unsigned transaction from the intent alone. Inputs are P2PKH outputs of `from`; change returns to `from`. */195function plan(i: Intent, net: NetworkDef) {196  prefixOf(net);197  if (i.network !== net.id) throw new Error('intent is for another network');198  if (i.chainRef !== net.chainRef) throw new Error('chain identity mismatch');199  if (i.asset !== 'native' || i.decimals !== net.native.decimals || i.symbol !== net.native.symbol) throw new Error('asset mismatch');200  if (i.memo !== undefined) throw new Error(`${net.name} transfers carry no memo`);201  if (i.evm || i.btc || i.sol || !i.x) throw new Error('intent chain section does not match network');202  const keys = Object.keys(i.x).sort().join(',');203  if (keys !== 'change,fee,inputs') throw new Error('unknown or missing chain parameters');204  if (!UINT.test(i.x.change!) || !UINT.test(i.x.fee!)) throw new Error('malformed chain parameters');205  if (normalizeAddress(net, i.from) !== i.from) throw new Error('source not normalized');206  if (normalizeAddress(net, i.to) !== i.to) throw new Error('destination not normalized');207  if (i.to === i.from) throw new Error('destination equals source');208  const from = cashaddrDecode(i.from);209  if (from.type !== 0) throw new Error('source must be P2PKH');210  const dust = BigInt(net.utxo!.dust), amount = baseUnits(i.amount), change = BigInt(i.x.change!), fee = BigInt(i.x.fee!);211  if (amount <= 0n || amount < dust) throw new Error('amount below dust');212  if (amount > MAX_MONEY || change > MAX_MONEY) throw new Error('amount out of range');213  if (change !== 0n && change < dust) throw new Error('change below dust');214  if (fee <= 0n) throw new Error('fee must be positive');215  if (fee > baseUnits(i.maxFee)) throw new Error('fee exceeds approved maximum');216  const inputs = parseInputs(i.x.inputs!);217  if (inputs.reduce((s, u) => s + u.value, 0n) !== amount + change + fee) throw new Error('inputs != outputs + fee');218  const lock = p2pkhScript(from.hash);219  const tx: BchTx = {220    version: 2, locktime: 0,221    inputs: inputs.map((u) => ({ txid: u.txid, vout: u.vout, sequence: SEQUENCE, script: new Uint8Array(0) })),222    outputs: [{ value: amount, script: scriptOf(net, i.to) }, ...(change > 0n ? [{ value: change, script: lock }] : [])],223  };224  return { tx, inputs, lock, fromHash: from.hash, amount, change, fee };225}226227/** The unsigned transaction a Bitcoin Cash intent describes (throws on anything unsupported). */228export const buildBchTx = (i: Intent, net: NetworkDef): BchTx => plan(i, net).tx;229230/** Upper bound of the serialized size: 10 + 149 per P2PKH input (72-byte signature) + outputs. */231export const estimateBchSize = (nInputs: number, toIsP2sh: boolean, hasChange: boolean): number => 10 + 149 * nInputs + (toIsP2sh ? 32 : 34) + (hasChange ? 34 : 0);232export const bchIsP2sh = (net: NetworkDef, normalized: string): boolean => cashaddrDecode(normalized, prefixOf(net)).type === 1;233234export const bchPlugin: FamilyPlugin = {235  family: 'bch',236  curve: 'secp256k1',237  path: (net) => `m/44'/${net.coinType ?? 145}'/0'/0/0`,238  address(net, publicKey) {239    if (publicKey.length !== 33) throw new Error('compressed public key required');240    return cashaddrEncode(prefixOf(net), 0, hash160(publicKey));241  },242  normalizeAddress,243  build(i, net) {244    const p = plan(i, net);245    return {246      signDocs: p.inputs.map((u, k) => bchSighash(p.tx, k, p.lock, u.value)),247      describe: { to: i.to, amount: p.amount.toString(), change: p.change.toString(), fee: p.fee.toString(), inputs: String(p.inputs.length) },248    };249  },250  sign(i: Intent, net: NetworkDef, key: KeyHandle): Signed {251    const p = plan(i, net);252    if (key.curve !== 'secp256k1' || !key.signDigest || key.publicKey.length !== 33) throw new Error('secp256k1 signing key required');253    if (hex.encode(hash160(key.publicKey)) !== hex.encode(p.fromHash)) throw new Error('signing key does not control the source address');254    p.inputs.forEach((u, k) => {255      const digest = bchSighash(p.tx, k, p.lock, u.value);256      const sig = key.signDigest!(digest);257      if (!secp256k1.verify(sig.compact, digest, key.publicKey, { prehash: false, lowS: true })) throw new Error('signature self-check failed');258      p.tx.inputs[k]!.script = cat(push(cat(sig.der, Uint8Array.of(SIGHASH_ALL_FORKID))), push(key.publicKey));259    });260    const raw = encodeBchTx(p.tx);261    return { raw: hex.encode(raw), txid: bchTxid(raw) };262  },263};264
Files main
Menu

Theme