Repository Public
1// Dogecoin (legacy P2PKH through the `btc` family) and Bitcoin Cash (plugin family `bch`).2// Every signed transaction is checked by an implementation that shares no code with the builder:3// bitcoinjs-lib (Dogecoin: independent decoding and legacy signature hash) and libauth's Bitcoin Cash virtual machine.4import { describe, expect, it } from 'vitest';5import fc from 'fast-check';6import * as bitcoin from 'bitcoinjs-lib';7import {8 cashAddressToLockingBytecode, createVirtualMachineBCH, decodeTransaction, encodeCashAddress, encodeTransaction, hashTransaction,9 lockingBytecodeToCashAddress,10} from '@bitauth/libauth';11import { secp256k1 } from '@noble/curves/secp256k1.js';12import { ripemd160 } from '@noble/hashes/legacy.js';13import { sha256 } from '@noble/hashes/sha2.js';14import { createBase58check, hex } from '@scure/base';15import { HDKey } from '@scure/bip32';16import { mnemonicToSeedSync } from '@scure/bip39';17import { p2pkh } from '@scure/btc-signer';18import {19 bchPlugin, bchSighash, bchTxid, btcParams, buildBchTx, buildBtcTx, canonicalBytes, cashaddrDecode, cashaddrEncode, checkPolicy, decodeBchTx,20 DEFAULT_POLICY, encodeBchInputs, encodeBchTx, estimateBchSize, estimateBtcVsize, intentHash, isValidAddress, NETWORKS, normalizeAddress,21 pluginFor, type Intent, type KeyHandle,22} from '../src/index.ts';2324// Public BIP-39 test vector mnemonic — holds no funds, never used outside tests.25const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';26const root = HDKey.fromMasterSeed(mnemonicToSeedSync(MNEMONIC));27const derive = (path: string) => { const k = root.derive(path); return { priv: k.privateKey!, pub: k.publicKey! }; };28/** Same shape as the signer's key handle: the plugin only ever sees this callback. */29const handleOf = ({ priv, pub }: { priv: Uint8Array; pub: Uint8Array }): KeyHandle => ({30 curve: 'secp256k1', publicKey: pub,31 signDigest: (digest) => {32 const sig = secp256k1.sign(digest, priv, { prehash: false, lowS: true, format: 'recovered' });33 const compact = sig.slice(1);34 return { compact, recovery: sig[0]!, der: secp256k1.Signature.fromBytes(compact, 'compact').toBytes('der') };35 },36});37const hash160 = (b: Uint8Array) => ripemd160(sha256(b));38const now = 1_800_000_000;39const base = { v: 1 as const, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a11', keyId: 'spw-0123456789abcdef', createdAt: now, expiresAt: now + 120, policyVersion: 3 };4041// ------------------------------------------------------------------------------------------------ Dogecoin4243const DOGE = NETWORKS.dogecoin;44const DOGE_JS: bitcoin.Network = { messagePrefix: '\x19Dogecoin Signed Message:\n', bech32: 'doge', bip32: { public: 0x02facafd, private: 0x02fac398 }, pubKeyHash: 0x1e, scriptHash: 0x16, wif: 0x9e };45const dogeKey = derive("m/44'/3'/0'/0/0");46// first address of m/44'/3'/0'/0 for this seed in bip_utils' published vectors (tests/bip/bip44/test_bip44.py, Dogecoin)47const DOGE_ADDR = 'DBus3bamQjgJULBJtYXpEzDWQRwF5iwxgC';48const DOGE_TO = 'DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L';4950/** A synthetic previous transaction paying `value` to `addr` at output 1 (output 0 pays someone else). */51function prevTx(addr: string, value: bigint, salt: number): { hex: string; txid: string } {52 const t = new bitcoin.Transaction();53 t.version = 1;54 t.addInput(new Uint8Array(32).fill(salt), 0, 0xffffffff, Uint8Array.of(0x51));55 t.addOutput(bitcoin.address.toOutputScript(DOGE_TO, DOGE_JS), 123_456_789n);56 t.addOutput(bitcoin.address.toOutputScript(addr, DOGE_JS), value);57 return { hex: t.toHex(), txid: t.getId() };58}59const prevA = prevTx(DOGE_ADDR, 30_000_000_000n, 7), prevB = prevTx(DOGE_ADDR, 5_000_000_000n, 9);60const dogeAux = { prevTx: { [prevA.txid]: prevA.hex, [prevB.txid]: prevB.hex } };61const dogeIntent: Intent = { ...base, network: 'dogecoin', chainRef: DOGE.chainRef, asset: 'native', symbol: 'DOGE', decimals: 8, amount: '32000000000',62 from: DOGE_ADDR, to: DOGE_TO, maxFee: '100000000',63 btc: { inputs: [{ txid: prevA.txid, vout: 1, value: '30000000000' }, { txid: prevB.txid, vout: 1, value: '5000000000' }], change: '2996000000', fee: '4000000' } };6465function signDoge(i: Intent, aux: Record<string, unknown> = dogeAux): string {66 const tx = buildBtcTx(i, aux);67 tx.sign(dogeKey.priv);68 tx.finalize();69 return tx.hex;70}7172/**73 * Independent check of a signed legacy transaction: decode with bitcoinjs-lib, then evaluate each input's P2PKH script74 * (OP_DUP OP_HASH160 <hash> OP_EQUALVERIFY OP_CHECKSIG) with bitcoinjs-lib's signature hash and strict signature decoding.75 */76function verifyLegacy(raw: string, prevouts: { script: Uint8Array; value: bigint }[]): { tx: bitcoin.Transaction; fee: bigint } {77 const tx = bitcoin.Transaction.fromHex(raw);78 expect(tx.toHex()).toBe(raw);79 expect(tx.hasWitnesses()).toBe(false);80 expect(tx.ins.length).toBe(prevouts.length);81 tx.ins.forEach((input, k) => {82 const chunks = bitcoin.script.decompile(input.script)!;83 expect(chunks.length).toBe(2);84 const [sigWithType, pub] = chunks as [Uint8Array, Uint8Array];85 const lock = bitcoin.script.decompile(prevouts[k]!.script)!;86 expect(lock.length).toBe(5);87 expect([lock[0], lock[1], lock[3], lock[4]]).toEqual([bitcoin.opcodes.OP_DUP, bitcoin.opcodes.OP_HASH160, bitcoin.opcodes.OP_EQUALVERIFY, bitcoin.opcodes.OP_CHECKSIG]);88 expect(hex.encode(hash160(pub))).toBe(hex.encode(lock[2] as Uint8Array)); // OP_EQUALVERIFY89 const { signature, hashType } = bitcoin.script.signature.decode(sigWithType); // strict DER90 expect(hashType).toBe(bitcoin.Transaction.SIGHASH_ALL);91 const digest = tx.hashForSignature(k, prevouts[k]!.script, hashType);92 expect(secp256k1.verify(signature, digest, pub, { prehash: false, lowS: true }), `input ${k}`).toBe(true); // OP_CHECKSIG93 });94 const fee = prevouts.reduce((s, p) => s + p.value, 0n) - tx.outs.reduce((s, o) => s + o.value, 0n);95 return { tx, fee };96}9798describe('Dogecoin (legacy P2PKH)', () => {99 const lock = bitcoin.address.toOutputScript(DOGE_ADDR, DOGE_JS);100101 it('registry: coin type 3, legacy P2PKH, Dogecoin Core soft dust limit, default-deny caps', () => {102 expect(DOGE).toMatchObject({ family: 'btc', coinType: 3, chainRef: '1a91e3dace36e2be3bf030a65679fe821aa1d6ef92e7c9902eb318182c355691' });103 expect(DOGE.utxo).toMatchObject({ pubKeyHash: 0x1e, scriptHash: 0x16, kind: 'pkh', dust: 1_000_000 });104 expect(DOGE.utxo!.bech32).toBeUndefined();105 expect(DOGE.rpc.length).toBe(2);106 expect(DEFAULT_POLICY.perTx['dogecoin:DOGE']).toBe('70000000000');107 expect(DEFAULT_POLICY.maxFee.dogecoin).toBe('100000000');108 expect(checkPolicy(DEFAULT_POLICY, dogeIntent, 0n, now, false)).toEqual({ ok: true });109 expect(checkPolicy(DEFAULT_POLICY, { ...dogeIntent, amount: '70000000001' }, 0n, now, false).ok).toBe(false);110 expect(checkPolicy(DEFAULT_POLICY, { ...dogeIntent, maxFee: '100000001' }, 0n, now, false).ok).toBe(false);111 });112113 it('derives the published address for the test mnemonic (m/44\'/3\'/0\'/0/0), also with bitcoinjs-lib', () => {114 expect(p2pkh(dogeKey.pub, btcParams(DOGE)).address).toBe(DOGE_ADDR);115 expect(bitcoin.payments.p2pkh({ pubkey: dogeKey.pub, network: DOGE_JS }).address).toBe(DOGE_ADDR);116 });117118 it('validates Dogecoin addresses only', () => {119 expect(normalizeAddress('dogecoin', DOGE_ADDR)).toBe(DOGE_ADDR);120 expect(normalizeAddress('dogecoin', 'A2ZTaCjZFTMrYqebSpLZLE7WNeB6EgUDXs')).toBe('A2ZTaCjZFTMrYqebSpLZLE7WNeB6EgUDXs'); // P2SH (0x16)121 for (const bad of ['DBus3bamQjgJULBJtYXpEzDWQRwF5iwxgD', '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', 'LaMT348PWRnrqeeWArpwQPbuanpXDZGEUz',122 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', 'ltc1qjmxnz78nmc8nq77wuxh25n2es7rzm5c2rkk4wh', 'bitcoincash:qqyx49mu0kkn9ftfj6hje6g2wfer34yfnq5tahq3q6'])123 expect(isValidAddress('dogecoin', bad), bad).toBe(false);124 expect(isValidAddress('bitcoin', DOGE_ADDR)).toBe(false);125 expect(isValidAddress('litecoin', DOGE_ADDR)).toBe(false);126 });127128 it('builds and signs a two-input transfer that bitcoinjs-lib decodes and verifies input by input', () => {129 const raw = signDoge(dogeIntent);130 const { tx, fee } = verifyLegacy(raw, [{ script: lock, value: 30_000_000_000n }, { script: lock, value: 5_000_000_000n }]);131 expect(tx.ins.map((x) => `${hex.encode(Uint8Array.from(x.hash).reverse())}:${x.index}`)).toEqual([`${prevA.txid}:1`, `${prevB.txid}:1`]);132 expect(tx.outs.length).toBe(2);133 expect(bitcoin.address.fromOutputScript(tx.outs[0]!.script, DOGE_JS)).toBe(DOGE_TO);134 expect(tx.outs[0]!.value).toBe(32_000_000_000n);135 expect(bitcoin.address.fromOutputScript(tx.outs[1]!.script, DOGE_JS)).toBe(DOGE_ADDR); // change only to the wallet136 expect(tx.outs[1]!.value).toBe(2_996_000_000n);137 expect(fee).toBe(4_000_000n);138 expect(tx.locktime).toBe(0);139 // the size estimate used for the fee is an upper bound of the real size140 expect(estimateBtcVsize(2, DOGE_TO, true, DOGE)).toBeGreaterThanOrEqual(raw.length / 2);141 // no change output when the intent says so; P2SH destination142 const p2sh = 'A2ZTaCjZFTMrYqebSpLZLE7WNeB6EgUDXs';143 const one = signDoge({ ...dogeIntent, to: p2sh, amount: '29990000000', btc: { inputs: [dogeIntent.btc!.inputs[0]!], change: '0', fee: '10000000' } });144 const r = verifyLegacy(one, [{ script: lock, value: 30_000_000_000n }]);145 expect(r.tx.outs.length).toBe(1);146 expect(bitcoin.address.fromOutputScript(r.tx.outs[0]!.script, DOGE_JS)).toBe(p2sh);147 expect(r.fee).toBe(10_000_000n);148 });149150 it('a signature made over other data does not verify (the check above is not vacuous)', () => {151 const tx = bitcoin.Transaction.fromHex(signDoge(dogeIntent));152 const [sigWithType, pub] = bitcoin.script.decompile(tx.ins[0]!.script) as [Uint8Array, Uint8Array];153 const { signature } = bitcoin.script.signature.decode(sigWithType);154 tx.outs[0]!.value += 1n;155 expect(secp256k1.verify(signature, tx.hashForSignature(0, lock, 1), pub, { prehash: false })).toBe(false);156 });157158 it('verifies the unauthenticated previous transactions against the approved inputs', () => {159 const b = dogeIntent.btc!;160 expect(() => buildBtcTx(dogeIntent)).toThrow(/missing previous transaction/);161 expect(() => buildBtcTx(dogeIntent, { prevTx: { [prevA.txid]: prevA.hex } })).toThrow(/missing previous transaction/);162 // a previous transaction that is not the one named by the input163 expect(() => buildBtcTx(dogeIntent, { prevTx: { ...dogeAux.prevTx, [prevA.txid]: prevB.hex } })).toThrow(/does not match input txid/);164 // an inflated input value (legacy signatures do not commit to it: the difference would silently become fee)165 expect(() => buildBtcTx({ ...dogeIntent, btc: { ...b, inputs: [{ ...b.inputs[0]!, value: '31000000000' }, b.inputs[1]!], change: '3996000000' } }, dogeAux)).toThrow(/does not match the approved input/);166 // an output that does not pay the wallet167 expect(() => buildBtcTx({ ...dogeIntent, amount: '100000000', btc: { ...b, inputs: [{ ...b.inputs[0]!, vout: 0, value: '123456789' }], change: '19456789' } }, dogeAux)).toThrow(/does not match the approved input/);168 expect(() => buildBtcTx({ ...dogeIntent, btc: { ...b, inputs: [{ ...b.inputs[0]!, vout: 2 }, b.inputs[1]!] } }, dogeAux)).toThrow();169 });170171 it('fails closed', () => {172 const b = dogeIntent.btc!;173 const bad = (m: Partial<Intent>, re: RegExp) => expect(() => buildBtcTx({ ...dogeIntent, ...m }, dogeAux), re.source).toThrow(re);174 bad({ btc: { ...b, fee: '4000001' } }, /inputs != outputs/);175 bad({ maxFee: '3999999' }, /fee exceeds/);176 bad({ amount: '999999', btc: { ...b, change: '34995000001' } }, /amount below dust/);177 bad({ amount: '34995500000', btc: { ...b, change: '500000' } }, /change below dust/);178 bad({ btc: { ...b, inputs: [b.inputs[0]!, b.inputs[0]!] } }, /duplicate/);179 bad({ chainRef: NETWORKS.litecoin.chainRef }, /chain identity/);180 bad({ asset: 'DOGE' }, /asset/);181 bad({ symbol: 'LTC' }, /asset/);182 bad({ decimals: 6 }, /asset/);183 bad({ to: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa' }, /./);184 bad({ to: ` ${DOGE_TO}` }, /./);185 bad({ from: 'A2ZTaCjZFTMrYqebSpLZLE7WNeB6EgUDXs' }, /P2PKH/);186 expect(() => canonicalBytes({ ...dogeIntent, btc: undefined, x: { inputs: 'a' } })).toThrow(/chain section/);187 });188189 it('changing any intent field changes the signed bytes, the approval hash, or throws', () => {190 const ref = signDoge(dogeIntent), refHash = hex.encode(intentHash(dogeIntent)), b = dogeIntent.btc!;191 const muts: Partial<Intent>[] = [192 { to: 'DMhCxuxFe98xQhic6QEFdw7gWBCy6u9jMS' },193 { amount: '32000000001', btc: { ...b, change: '2995999999' } },194 { btc: { ...b, change: '2995000000', fee: '5000000' } },195 { btc: { ...b, inputs: [b.inputs[1]!, b.inputs[0]!] } },196 { amount: '32000000001' }, { maxFee: '1' }, { from: DOGE_TO }, { network: 'litecoin' }, { chainRef: 'x' },197 ];198 for (const m of muts) {199 let out: string | null = null;200 try { out = signDoge({ ...dogeIntent, ...m }); } catch { /* refused */ }201 expect(out, JSON.stringify(m)).not.toBe(ref);202 let h: string | null = null;203 try { h = hex.encode(intentHash({ ...dogeIntent, ...m })); } catch { /* refused */ }204 expect(h).not.toBe(refHash);205 }206 });207});208209// ------------------------------------------------------------------------------------------------ CashAddr210211const BCH = NETWORKS.bitcoincash;212const b58 = createBase58check(sha256);213214describe('CashAddr (specification vectors)', () => {215 // https://github.com/bitcoincashorg/bitcoincash.org/blob/master/spec/cashaddr.md — "Examples of address translation"216 const TRANSLATION: [string, string][] = [217 ['1BpEi6DfDAUFd7GtittLSdBeYJvcoaVggu', 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a'],218 ['1KXrWXciRDZUpQwQmuM1DbwsKDLYAYsVLR', 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'],219 ['16w1D5WRVKJuZUsSRzdLp9w3YGcgoxDXb', 'bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r'],220 ['3CWFddi6m4ndiGyKqzYvsFYagqDLPVMTzC', 'bitcoincash:ppm2qsznhks23z7629mms6s4cwef74vcwvn0h829pq'],221 ['3LDsS579y7sruadqu11beEJoTjdFiFCdX4', 'bitcoincash:pr95sy3j9xwd2ap32xkykttr4cvcu7as4yc93ky28e'],222 ['31nwvkZwyPdgzjBJZXfDmSWsC4ZLKpYyUw', 'bitcoincash:pqq3728yw0y47sqn6l2na30mcw6zm78dzq5ucqzc37'],223 ];224 // same document — "Larger Test Vectors": [type, address, payload]225 const LARGER: [number, string, string][] = [226 [0, 'bitcoincash:qr6m7j9njldwwzlg9v7v53unlr4jkmx6eylep8ekg2', 'F5BF48B397DAE70BE82B3CCA4793F8EB2B6CDAC9'],227 [1, 'bchtest:pr6m7j9njldwwzlg9v7v53unlr4jkmx6eyvwc0uz5t', 'F5BF48B397DAE70BE82B3CCA4793F8EB2B6CDAC9'],228 [1, 'pref:pr6m7j9njldwwzlg9v7v53unlr4jkmx6ey65nvtks5', 'F5BF48B397DAE70BE82B3CCA4793F8EB2B6CDAC9'],229 [15, 'prefix:0r6m7j9njldwwzlg9v7v53unlr4jkmx6ey3qnjwsrf', 'F5BF48B397DAE70BE82B3CCA4793F8EB2B6CDAC9'],230 [0, 'bitcoincash:q9adhakpwzztepkpwp5z0dq62m6u5v5xtyj7j3h2ws4mr9g0', '7ADBF6C17084BC86C1706827B41A56F5CA32865925E946EA'],231 [1, 'bchtest:p9adhakpwzztepkpwp5z0dq62m6u5v5xtyj7j3h2u94tsynr', '7ADBF6C17084BC86C1706827B41A56F5CA32865925E946EA'],232 [1, 'pref:p9adhakpwzztepkpwp5z0dq62m6u5v5xtyj7j3h2khlwwk5v', '7ADBF6C17084BC86C1706827B41A56F5CA32865925E946EA'],233 [15, 'prefix:09adhakpwzztepkpwp5z0dq62m6u5v5xtyj7j3h2p29kc2lp', '7ADBF6C17084BC86C1706827B41A56F5CA32865925E946EA'],234 [0, 'bitcoincash:qgagf7w02x4wnz3mkwnchut2vxphjzccwxgjvvjmlsxqwkcw59jxxuz', '3A84F9CF51AAE98A3BB3A78BF16A6183790B18719126325BFC0C075B'],235 [1, 'bchtest:pgagf7w02x4wnz3mkwnchut2vxphjzccwxgjvvjmlsxqwkcvs7md7wt', '3A84F9CF51AAE98A3BB3A78BF16A6183790B18719126325BFC0C075B'],236 [1, 'pref:pgagf7w02x4wnz3mkwnchut2vxphjzccwxgjvvjmlsxqwkcrsr6gzkn', '3A84F9CF51AAE98A3BB3A78BF16A6183790B18719126325BFC0C075B'],237 [15, 'prefix:0gagf7w02x4wnz3mkwnchut2vxphjzccwxgjvvjmlsxqwkc5djw8s9g', '3A84F9CF51AAE98A3BB3A78BF16A6183790B18719126325BFC0C075B'],238 [0, 'bitcoincash:qvch8mmxy0rtfrlarg7ucrxxfzds5pamg73h7370aa87d80gyhqxq5nlegake', '3173EF6623C6B48FFD1A3DCC0CC6489B0A07BB47A37F47CFEF4FE69DE825C060'],239 [1, 'bchtest:pvch8mmxy0rtfrlarg7ucrxxfzds5pamg73h7370aa87d80gyhqxq7fqng6m6', '3173EF6623C6B48FFD1A3DCC0CC6489B0A07BB47A37F47CFEF4FE69DE825C060'],240 [1, 'pref:pvch8mmxy0rtfrlarg7ucrxxfzds5pamg73h7370aa87d80gyhqxq4k9m7qf9', '3173EF6623C6B48FFD1A3DCC0CC6489B0A07BB47A37F47CFEF4FE69DE825C060'],241 [15, 'prefix:0vch8mmxy0rtfrlarg7ucrxxfzds5pamg73h7370aa87d80gyhqxqsh6jgp6w', '3173EF6623C6B48FFD1A3DCC0CC6489B0A07BB47A37F47CFEF4FE69DE825C060'],242 [0, 'bitcoincash:qnq8zwpj8cq05n7pytfmskuk9r4gzzel8qtsvwz79zdskftrzxtar994cgutavfklv39gr3uvz', 'C07138323E00FA4FC122D3B85B9628EA810B3F381706385E289B0B25631197D194B5C238BEB136FB'],243 [1, 'bchtest:pnq8zwpj8cq05n7pytfmskuk9r4gzzel8qtsvwz79zdskftrzxtar994cgutavfklvmgm6ynej', 'C07138323E00FA4FC122D3B85B9628EA810B3F381706385E289B0B25631197D194B5C238BEB136FB'],244 [1, 'pref:pnq8zwpj8cq05n7pytfmskuk9r4gzzel8qtsvwz79zdskftrzxtar994cgutavfklv0vx5z0w3', 'C07138323E00FA4FC122D3B85B9628EA810B3F381706385E289B0B25631197D194B5C238BEB136FB'],245 [15, 'prefix:0nq8zwpj8cq05n7pytfmskuk9r4gzzel8qtsvwz79zdskftrzxtar994cgutavfklvwsvctzqy', 'C07138323E00FA4FC122D3B85B9628EA810B3F381706385E289B0B25631197D194B5C238BEB136FB'],246 [0, 'bitcoincash:qh3krj5607v3qlqh5c3wq3lrw3wnuxw0sp8dv0zugrrt5a3kj6ucysfz8kxwv2k53krr7n933jfsunqex2w82sl', 'E361CA9A7F99107C17A622E047E3745D3E19CF804ED63C5C40C6BA763696B98241223D8CE62AD48D863F4CB18C930E4C'],247 [1, 'bchtest:ph3krj5607v3qlqh5c3wq3lrw3wnuxw0sp8dv0zugrrt5a3kj6ucysfz8kxwv2k53krr7n933jfsunqnzf7mt6x', 'E361CA9A7F99107C17A622E047E3745D3E19CF804ED63C5C40C6BA763696B98241223D8CE62AD48D863F4CB18C930E4C'],248 [1, 'pref:ph3krj5607v3qlqh5c3wq3lrw3wnuxw0sp8dv0zugrrt5a3kj6ucysfz8kxwv2k53krr7n933jfsunqjntdfcwg', 'E361CA9A7F99107C17A622E047E3745D3E19CF804ED63C5C40C6BA763696B98241223D8CE62AD48D863F4CB18C930E4C'],249 [15, 'prefix:0h3krj5607v3qlqh5c3wq3lrw3wnuxw0sp8dv0zugrrt5a3kj6ucysfz8kxwv2k53krr7n933jfsunqakcssnmn', 'E361CA9A7F99107C17A622E047E3745D3E19CF804ED63C5C40C6BA763696B98241223D8CE62AD48D863F4CB18C930E4C'],250 [0, 'bitcoincash:qmvl5lzvdm6km38lgga64ek5jhdl7e3aqd9895wu04fvhlnare5937w4ywkq57juxsrhvw8ym5d8qx7sz7zz0zvcypqscw8jd03f', 'D9FA7C4C6EF56DC4FF423BAAE6D495DBFF663D034A72D1DC7D52CBFE7D1E6858F9D523AC0A7A5C34077638E4DD1A701BD017842789982041'],251 [1, 'bchtest:pmvl5lzvdm6km38lgga64ek5jhdl7e3aqd9895wu04fvhlnare5937w4ywkq57juxsrhvw8ym5d8qx7sz7zz0zvcypqs6kgdsg2g', 'D9FA7C4C6EF56DC4FF423BAAE6D495DBFF663D034A72D1DC7D52CBFE7D1E6858F9D523AC0A7A5C34077638E4DD1A701BD017842789982041'],252 [1, 'pref:pmvl5lzvdm6km38lgga64ek5jhdl7e3aqd9895wu04fvhlnare5937w4ywkq57juxsrhvw8ym5d8qx7sz7zz0zvcypqsammyqffl', 'D9FA7C4C6EF56DC4FF423BAAE6D495DBFF663D034A72D1DC7D52CBFE7D1E6858F9D523AC0A7A5C34077638E4DD1A701BD017842789982041'],253 [15, 'prefix:0mvl5lzvdm6km38lgga64ek5jhdl7e3aqd9895wu04fvhlnare5937w4ywkq57juxsrhvw8ym5d8qx7sz7zz0zvcypqsgjrqpnw8', 'D9FA7C4C6EF56DC4FF423BAAE6D495DBFF663D034A72D1DC7D52CBFE7D1E6858F9D523AC0A7A5C34077638E4DD1A701BD017842789982041'],254 [0, 'bitcoincash:qlg0x333p4238k0qrc5ej7rzfw5g8e4a4r6vvzyrcy8j3s5k0en7calvclhw46hudk5flttj6ydvjc0pv3nchp52amk97tqa5zygg96mtky5sv5w', 'D0F346310D5513D9E01E299978624BA883E6BDA8F4C60883C10F28C2967E67EC77ECC7EEEAEAFC6DA89FAD72D11AC961E164678B868AEEEC5F2C1DA08884175B'],255 [1, 'bchtest:plg0x333p4238k0qrc5ej7rzfw5g8e4a4r6vvzyrcy8j3s5k0en7calvclhw46hudk5flttj6ydvjc0pv3nchp52amk97tqa5zygg96mc773cwez', 'D0F346310D5513D9E01E299978624BA883E6BDA8F4C60883C10F28C2967E67EC77ECC7EEEAEAFC6DA89FAD72D11AC961E164678B868AEEEC5F2C1DA08884175B'],256 [1, 'pref:plg0x333p4238k0qrc5ej7rzfw5g8e4a4r6vvzyrcy8j3s5k0en7calvclhw46hudk5flttj6ydvjc0pv3nchp52amk97tqa5zygg96mg7pj3lh8', 'D0F346310D5513D9E01E299978624BA883E6BDA8F4C60883C10F28C2967E67EC77ECC7EEEAEAFC6DA89FAD72D11AC961E164678B868AEEEC5F2C1DA08884175B'],257 [15, 'prefix:0lg0x333p4238k0qrc5ej7rzfw5g8e4a4r6vvzyrcy8j3s5k0en7calvclhw46hudk5flttj6ydvjc0pv3nchp52amk97tqa5zygg96ms92w6845', 'D0F346310D5513D9E01E299978624BA883E6BDA8F4C60883C10F28C2967E67EC77ECC7EEEAEAFC6DA89FAD72D11AC961E164678B868AEEEC5F2C1DA08884175B'],258 ];259260 it('translates the specification\'s legacy addresses', () => {261 for (const [legacy, cash] of TRANSLATION) {262 const raw = b58.decode(legacy), type = raw[0] === 0 ? 0 : 1;263 expect(cashaddrEncode('bitcoincash', type, raw.slice(1))).toBe(cash);264 const d = cashaddrDecode(cash);265 expect([d.prefix, d.type, hex.encode(d.hash)]).toEqual(['bitcoincash', type, hex.encode(raw.slice(1))]);266 expect(normalizeAddress('bitcoincash', cash)).toBe(cash);267 if (type === 0) expect(normalizeAddress('bitcoincash', legacy)).toBe(cash);268 else expect(() => normalizeAddress('bitcoincash', legacy)).toThrow(/ambiguous/); // "3…" may be a Bitcoin SegWit script269 }270 });271272 it('encodes and decodes every larger test vector (160 to 512 bits, four prefixes)', () => {273 expect(LARGER.length).toBe(32);274 for (const [type, addr, payload] of LARGER) {275 const prefix = addr.split(':')[0]!;276 expect(cashaddrEncode(prefix, type, hex.decode(payload.toLowerCase()))).toBe(addr);277 const d = cashaddrDecode(addr);278 expect([d.prefix, d.type, hex.encode(d.hash).toUpperCase()]).toEqual([prefix, type, payload]);279 expect(cashaddrDecode(addr.split(':')[1]!, prefix).type).toBe(type); // prefix supplied by context280 expect(cashaddrDecode(addr.toUpperCase()).type).toBe(type);281 }282 });283284 it('rejects every single-character substitution, mixed case, wrong prefix and malformed strings', () => {285 const good = 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a', body = good.split(':')[1]!;286 for (let k = 0; k < body.length; k++) for (const ch of 'qpzry9x8gf2tvdw0s3jn54khce6mua7l') {287 if (ch === body[k]) continue;288 expect(() => cashaddrDecode(`bitcoincash:${body.slice(0, k)}${ch}${body.slice(k + 1)}`)).toThrow();289 }290 expect(() => cashaddrDecode('bitcoincash:Qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a')).toThrow(/mixed/);291 expect(() => cashaddrDecode(body)).toThrow(/prefix/);292 expect(() => cashaddrDecode(body, 'bchtest')).toThrow(/checksum/); // the prefix is part of the checksum293 expect(() => cashaddrDecode(`bchtest:${body}`)).toThrow(/checksum/);294 expect(() => cashaddrDecode('bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6b')).toThrow();295 expect(() => cashaddrDecode('bitcoincash:')).toThrow();296 expect(() => cashaddrDecode('a:b:c')).toThrow();297 expect(() => cashaddrEncode('bitcoincash', 0, new Uint8Array(21))).toThrow();298 });299300 it('agrees with libauth on random hashes', () => {301 fc.assert(fc.property(fc.uint8Array({ minLength: 20, maxLength: 20 }), fc.boolean(), (h, sh) => {302 const ours = cashaddrEncode('bitcoincash', sh ? 1 : 0, h);303 const theirs = encodeCashAddress({ prefix: 'bitcoincash', type: sh ? 'p2sh' : 'p2pkh', payload: h });304 return typeof theirs !== 'string' && theirs.address === ours && normalizeAddress('bitcoincash', ours) === ours;305 }), { numRuns: 200 });306 });307308 it('the wallet accepts only Bitcoin Cash P2PKH / P2SH destinations and always returns the prefixed lowercase form', () => {309 const a = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy';310 expect(normalizeAddress('bitcoincash', a.split(':')[1]!)).toBe(a);311 expect(normalizeAddress('bitcoincash', a.toUpperCase())).toBe(a);312 expect(normalizeAddress('bitcoincash', ` ${a} `)).toBe(a);313 const h20 = hex.decode('f5bf48b397dae70be82b3cca4793f8eb2b6cdac9');314 for (const bad of [315 'bchtest:pr6m7j9njldwwzlg9v7v53unlr4jkmx6eyvwc0uz5t', // testnet316 cashaddrEncode('bitcoincash', 2, h20), cashaddrEncode('bitcoincash', 3, h20), // token-aware types317 cashaddrEncode('bitcoincash', 1, new Uint8Array(32).fill(7)), // 32-byte script hash318 cashaddrEncode('simpleledger', 0, h20), cashaddrEncode('ecash', 0, h20),319 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', 'DBus3bamQjgJULBJtYXpEzDWQRwF5iwxgC', 'LaMT348PWRnrqeeWArpwQPbuanpXDZGEUz',320 '1BpEi6DfDAUFd7GtittLSdBeYJvcoaVggv', '0x9858EfFD232B4033E47d90003D41EC34EcaEda94', '',321 ]) expect(isValidAddress('bitcoincash', bad), bad).toBe(false);322 expect(isValidAddress('bitcoin', a)).toBe(false);323 });324});325326// ------------------------------------------------------------------------------------------------ Bitcoin Cash327328const bchKey = derive("m/44'/145'/0'/0/0"), bchHandle = handleOf(bchKey);329// first address of m/44'/145'/0'/0 for this seed in bip_utils' published vectors (tests/bip/bip44/test_bip44.py, BitcoinCash)330const BCH_ADDR = 'bitcoincash:qqyx49mu0kkn9ftfj6hje6g2wfer34yfnq5tahq3q6', BCH_LEGACY = '1mW6fDEMjKrDHvLvoEsaeLxSCzZBf3Bfg';331const BCH_TO = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy';332const IN_A = { txid: 'aa'.repeat(32), vout: 1, value: 150_000n }, IN_B = { txid: '0123456789abcdef'.repeat(4), vout: 0, value: 60_000n };333const bchIntent: Intent = { ...base, network: 'bitcoincash', chainRef: BCH.chainRef, asset: 'native', symbol: 'BCH', decimals: 8, amount: '180000',334 from: BCH_ADDR, to: BCH_TO, maxFee: '50000', x: { inputs: encodeBchInputs([IN_A, IN_B]), change: '29600', fee: '400' } };335const plugin = pluginFor(BCH);336const vm = createVirtualMachineBCH(true); // standard (relay) rules337const lockOf = (addr: string) => { const r = cashAddressToLockingBytecode(addr); if (typeof r === 'string') throw new Error(r); return r.bytecode; };338339/** libauth decodes the transaction and runs every input through the Bitcoin Cash virtual machine. */340function vmVerify(raw: string, sources: { addr: string; value: bigint }[]): string | true {341 const tx = decodeTransaction(hex.decode(raw));342 if (typeof tx === 'string') return tx;343 return vm.verify({ transaction: tx, sourceOutputs: sources.map((s) => ({ lockingBytecode: lockOf(s.addr), valueSatoshis: s.value })) });344}345346describe('Bitcoin Cash signature digest (Bitcoin Cash Node vectors)', () => {347 // bitcoin-cash-node src/test/data/sighash.json: every vector whose hash type is SIGHASH_ALL|FORKID (low byte 0x41; the348 // upper bits are a random fork id). [raw transaction, script code, input index, hash type, digest]; the amount is 0.349 const VECTORS: [string, string, number, number, string][] = [350 ['13938a7d011e4545c281cbd8c6c85c796194285adb908ab052755cf65b60c5f7fc7e0e125c020000000953530000ac6a526a00d36774ce04d94bb802000000000600516353ac63f9f9fe000000000000b0123a05000000000251ace0b7c10400000000055351abab6300000000', '5300655100', 0, 467995457, '6bfb3e169f83f480b12c45223182650ae9dfa1e6be45e42a70b4bfdbf75cf923'],351 ['753ca84702758209899178670a37c457207494591a31ece76b9654e35d9e38e3e0879462e2030000000952ab515253acab63656d0120aae21ec8043302ace9d58d296672649452875895946eaf44942c410b16df1cef2f020000000400635200ffffffff0348b17202000000000665526a525365d8ada10300000000056563510051d47f54030000000009536aab65abac0052652963ff8d', '', 0, -1911846847, 'd550d784646703f7db64c8755a5cd58b8ea736e6e344542bd206c21cc569a82e'],352 ['ddadc1eb045d16b79907449cc9f00f7f74e4b8dc891417c1ffed225d5f6665980ca500d3df0000000005acab53ac5277506c8885fe8846264f4e1d2721b4a4d0795bd55d2cf5a43164713a42b813995408024d0200000007abab00635353abffffffff6992b2678018777756025009c13fec6e53b7d1b59ae5f36cb771c61a43a8161d010000000353ababffffffff0c59a711c690c00a13a9d3a9125d3b45db372c26841d9b902932310246695b1c01000000046a51ac006176e522016ef66d0300000000010000000000', 'ab6a6a63516aac65', 0, -1625231295, 'd6fe4e12b301525576c14ca489d6edb914c5326f02c63ed5097b78ad6c51d827'],353 ['b70fefc0023bff2a865f7dada7a5f57b8ea9351a4cae4dd639de6cd5ae4e0156df0e4628b4010000000763655153526365ffffffff3a1fbc317ab7a6a7cfcb6e28fef05f5a7c0da6b874d8e61259da947d14a0e9070000000003ac65abffffffff0291e50903000000000251ac299d31050000000002630000000000', 'ab6aacacac5200', 1, -358440639, 'b23f645bdf00519876d9e9fad62f25e94403ee8add3af9294c9acda93ea3e5b9'],354 ];355 it('reproduces the published digests and re-encodes the vectors byte for byte', () => {356 for (const [raw, script, index, hashType, digest] of VECTORS) {357 const tx = decodeBchTx(hex.decode(raw));358 expect(hex.encode(encodeBchTx(tx))).toBe(raw);359 expect((hashType >>> 0) & 0xff).toBe(0x41);360 expect(hex.encode(bchSighash(tx, index, hex.decode(script), 0n, hashType >>> 0).reverse())).toBe(digest); // uint256 is printed reversed361 }362 });363 it('refuses any other signature hash type', () => {364 const tx = decodeBchTx(hex.decode(VECTORS[0]![0]));365 for (const t of [0x01, 0x40, 0x42, 0x43, 0xc1, 0x61, 0x81]) expect(() => bchSighash(tx, 0, new Uint8Array(0), 0n, t)).toThrow(/SIGHASH_ALL/);366 expect(() => bchSighash(tx, 5, new Uint8Array(0), 0n)).toThrow(/index/);367 expect(() => decodeBchTx(hex.decode(VECTORS[0]![0] + '00'))).toThrow(/trailing/);368 expect(() => decodeBchTx(hex.decode(VECTORS[0]![0].slice(0, -2)))).toThrow(/truncated/);369 });370});371372describe('Bitcoin Cash plugin', () => {373 it('registry and policy', () => {374 expect(BCH).toMatchObject({ family: 'bch', coinType: 145, chainRef: '0000000000000000029e471c41818d24b8b74c911071c4ef0b4a0509f9b5a8ce' });375 expect(BCH.utxo).toMatchObject({ cashaddr: 'bitcoincash', kind: 'pkh', dust: 546, refHeight: 661648 });376 expect(BCH.chainRef).not.toBe(NETWORKS.bitcoin.chainRef); // the genesis block is shared with Bitcoin and identifies nothing377 expect(BCH.rpc.length).toBe(2);378 expect(plugin).toBe(bchPlugin);379 expect(plugin.path(BCH)).toBe("m/44'/145'/0'/0/0");380 expect(DEFAULT_POLICY.perTx['bitcoincash:BCH']).toBe('20000000');381 expect(checkPolicy(DEFAULT_POLICY, bchIntent, 0n, now, false)).toEqual({ ok: true });382 expect(checkPolicy(DEFAULT_POLICY, { ...bchIntent, amount: '20000001' }, 0n, now, false).ok).toBe(false);383 expect(checkPolicy(DEFAULT_POLICY, { ...bchIntent, maxFee: '50001' }, 0n, now, false).ok).toBe(false);384 });385386 it('derives the published address for the test mnemonic (m/44\'/145\'/0\'/0/0), also with libauth', () => {387 expect(plugin.address(BCH, bchKey.pub)).toBe(BCH_ADDR);388 expect(normalizeAddress('bitcoincash', BCH_LEGACY)).toBe(BCH_ADDR);389 const theirs = encodeCashAddress({ prefix: 'bitcoincash', type: 'p2pkh', payload: hash160(bchKey.pub) });390 expect(typeof theirs !== 'string' && theirs.address).toBe(BCH_ADDR);391 expect(() => plugin.address(BCH, new Uint8Array(65))).toThrow();392 expect(() => plugin.address(NETWORKS.dogecoin, bchKey.pub)).toThrow();393 });394395 it('signs a two-input transfer that libauth decodes identically and its virtual machine accepts', () => {396 const { signDocs, describe: d } = plugin.build(bchIntent, BCH);397 expect(signDocs.length).toBe(2);398 expect(d).toEqual({ to: BCH_TO, amount: '180000', change: '29600', fee: '400', inputs: '2' });399 const signed = plugin.sign(bchIntent, BCH, bchHandle);400 const tx = decodeTransaction(hex.decode(signed.raw));401 if (typeof tx === 'string') throw new Error(tx);402 expect(hex.encode(encodeTransaction(tx))).toBe(signed.raw); // byte-identical after libauth's decode/encode403 expect(hashTransaction(hex.decode(signed.raw))).toBe(signed.txid);404 expect(bchTxid(hex.decode(signed.raw))).toBe(signed.txid);405 expect(tx.version).toBe(2);406 expect(tx.locktime).toBe(0);407 expect(tx.inputs.map((x) => `${hex.encode(x.outpointTransactionHash)}:${x.outpointIndex}:${x.sequenceNumber}`)).toEqual([`${IN_A.txid}:1:4294967295`, `${IN_B.txid}:0:4294967295`]);408 expect(tx.outputs.map((o) => [(lockingBytecodeToCashAddress({ bytecode: o.lockingBytecode, prefix: 'bitcoincash' }) as { address: string }).address, o.valueSatoshis, o.token]))409 .toEqual([[BCH_TO, 180_000n, undefined], [BCH_ADDR, 29_600n, undefined]]); // change only to the wallet410 const sources = [{ addr: BCH_ADDR, value: IN_A.value }, { addr: BCH_ADDR, value: IN_B.value }];411 expect(vmVerify(signed.raw, sources)).toBe(true);412 // every signature is over the digest the builder announced, and ends with SIGHASH_ALL|FORKID413 tx.inputs.forEach((input, k) => {414 const sigLen = input.unlockingBytecode[0]!, sig = input.unlockingBytecode.slice(1, 1 + sigLen), pub = input.unlockingBytecode.slice(2 + sigLen);415 expect(sig[sig.length - 1]).toBe(0x41);416 expect(hex.encode(pub)).toBe(hex.encode(bchKey.pub));417 expect(secp256k1.verify(sig.slice(0, -1), signDocs[k]!, pub, { prehash: false, lowS: true, format: 'der' })).toBe(true);418 });419 // the fee estimate is an upper bound of the real size, and the fee pays at least 1 sat/byte420 expect(estimateBchSize(2, false, true)).toBeGreaterThanOrEqual(signed.raw.length / 2);421 expect(400).toBeGreaterThanOrEqual(signed.raw.length / 2);422 expect(hex.encode(encodeBchTx(decodeBchTx(hex.decode(signed.raw))))).toBe(signed.raw);423 });424425 it('the virtual machine rejects the transaction when an input value, a source script or any byte differs', () => {426 const signed = plugin.sign(bchIntent, BCH, bchHandle);427 const good = [{ addr: BCH_ADDR, value: IN_A.value }, { addr: BCH_ADDR, value: IN_B.value }];428 expect(vmVerify(signed.raw, good)).toBe(true);429 // the digest commits to the input values: a lying indexer cannot turn the difference into fee430 expect(vmVerify(signed.raw, [{ addr: BCH_ADDR, value: IN_A.value + 1n }, good[1]!])).not.toBe(true);431 expect(vmVerify(signed.raw, [good[0]!, { addr: BCH_TO, value: IN_B.value }])).not.toBe(true);432 const t = decodeBchTx(hex.decode(signed.raw));433 t.outputs[0]!.value += 1n;434 expect(vmVerify(hex.encode(encodeBchTx(t)), good)).not.toBe(true);435 const u = decodeBchTx(hex.decode(signed.raw));436 u.outputs[1]!.script = u.outputs[0]!.script;437 expect(vmVerify(hex.encode(encodeBchTx(u)), good)).not.toBe(true);438 });439440 it('single input, no change, pay-to-script-hash destination', () => {441 const p2sh = 'bitcoincash:ppm2qsznhks23z7629mms6s4cwef74vcwvn0h829pq';442 const i: Intent = { ...bchIntent, to: p2sh, amount: '149800', x: { inputs: encodeBchInputs([IN_A]), change: '0', fee: '200' } };443 const signed = plugin.sign(i, BCH, bchHandle);444 const tx = decodeTransaction(hex.decode(signed.raw));445 if (typeof tx === 'string') throw new Error(tx);446 expect(tx.outputs.length).toBe(1);447 expect(hex.encode(tx.outputs[0]!.lockingBytecode)).toBe(hex.encode(lockOf(p2sh)));448 expect(vmVerify(signed.raw, [{ addr: BCH_ADDR, value: IN_A.value }])).toBe(true);449 expect(estimateBchSize(1, true, false)).toBeGreaterThanOrEqual(signed.raw.length / 2);450 });451452 it('is deterministic and agrees with the standalone builder', () => {453 expect(plugin.sign(bchIntent, BCH, bchHandle)).toEqual(plugin.sign(bchIntent, BCH, bchHandle));454 const unsigned = buildBchTx(bchIntent, BCH);455 expect(unsigned.inputs.every((x) => x.script.length === 0)).toBe(true);456 expect(unsigned.outputs.map((o) => o.value)).toEqual([180_000n, 29_600n]);457 });458459 it('fails closed', () => {460 const x = bchIntent.x!;461 const bad = (m: Partial<Intent>, re: RegExp) => {462 expect(() => plugin.build({ ...bchIntent, ...m }, BCH), re.source).toThrow(re);463 expect(() => plugin.sign({ ...bchIntent, ...m }, BCH, bchHandle), re.source).toThrow(re);464 };465 bad({ chainRef: NETWORKS.bitcoin.chainRef }, /chain identity/);466 bad({ network: 'bitcoin' }, /another network/);467 bad({ asset: 'bitcoincash:qqyx49mu0kkn9ftfj6hje6g2wfer34yfnq5tahq3q6' }, /asset/);468 bad({ symbol: 'BTC' }, /asset/);469 bad({ decimals: 6 }, /asset/);470 bad({ memo: 'hello' }, /memo/);471 bad({ to: 'qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy' }, /destination not normalized/);472 bad({ to: BCH_TO.toUpperCase() }, /destination not normalized/);473 bad({ to: '1KXrWXciRDZUpQwQmuM1DbwsKDLYAYsVLR' }, /destination not normalized/);474 bad({ to: 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuz' }, /checksum/);475 bad({ to: 'bchtest:pr6m7j9njldwwzlg9v7v53unlr4jkmx6eyvwc0uz5t' }, /not a Bitcoin Cash address/);476 bad({ to: BCH_ADDR }, /destination equals source/);477 bad({ from: 'bitcoincash:ppm2qsznhks23z7629mms6s4cwef74vcwvn0h829pq' }, /source must be P2PKH/);478 bad({ from: BCH_LEGACY }, /source not normalized/);479 bad({ maxFee: '399' }, /fee exceeds/);480 bad({ x: { ...x, fee: '0', change: '30000' } }, /fee must be positive/);481 bad({ x: { ...x, fee: '401' } }, /inputs != outputs/);482 bad({ amount: '180001' }, /inputs != outputs/);483 bad({ amount: '0', x: { ...x, change: '209600' } }, /dust/);484 bad({ amount: '545', x: { ...x, change: '209055' } }, /amount below dust/);485 bad({ amount: '209055', x: { ...x, change: '545' } }, /change below dust/);486 bad({ x: { inputs: x.inputs!, fee: '400' } }, /unknown or missing/);487 bad({ x: { ...x, locktime: '1' } }, /unknown or missing/);488 bad({ x: { ...x, sighash: '65' } }, /unknown or missing/);489 bad({ x: undefined }, /chain section/);490 bad({ btc: { inputs: [{ txid: IN_A.txid, vout: 1, value: '150000' }], change: '0', fee: '400' } }, /chain section/);491 bad({ x: { ...x, change: '029600' } }, /malformed/);492 bad({ x: { ...x, fee: '-400' } }, /malformed/);493 bad({ x: { ...x, inputs: `${x.inputs},${x.inputs!.split(',')[0]}`, change: '179600' } }, /duplicate input/);494 bad({ x: { ...x, inputs: '' } }, /malformed input/);495 bad({ x: { ...x, inputs: `${'AA'.repeat(32)}:1:210000` } }, /malformed input/);496 bad({ x: { ...x, inputs: `${IN_A.txid}:1:0210000` } }, /malformed input/);497 bad({ x: { ...x, inputs: `${IN_A.txid}:4294967296:210000` } }, /malformed input/);498 bad({ x: { ...x, inputs: `${IN_A.txid}:1:210000 ` } }, /malformed input/);499 bad({ x: { ...x, inputs: `${IN_A.txid}:1:0,${IN_B.txid}:0:210000` } }, /input value/);500 bad({ x: { ...x, inputs: Array.from({ length: 51 }, (_, k) => `${IN_A.txid}:${k}:1000`).join(',') } }, /number of inputs/);501 // a key that does not control the source address, or a handle that cannot sign digests502 expect(() => plugin.sign(bchIntent, BCH, handleOf(dogeKey))).toThrow(/does not control/);503 expect(() => plugin.sign(bchIntent, BCH, { curve: 'ed25519', publicKey: new Uint8Array(32) })).toThrow(/secp256k1/);504 expect(() => plugin.sign(bchIntent, BCH, { ...bchHandle, signDigest: (dg) => handleOf(dogeKey).signDigest!(dg) })).toThrow(/self-check/);505 expect(() => plugin.build(bchIntent, NETWORKS.dogecoin)).toThrow(/not a Bitcoin Cash network/);506 // the canonical intent refuses a chain section of the wrong family507 expect(() => canonicalBytes({ ...bchIntent, x: undefined, btc: { inputs: [{ txid: IN_A.txid, vout: 1, value: '150000' }], change: '0', fee: '400' } })).toThrow(/chain section/);508 });509510 it('changing any intent field changes the signed bytes, the approval hash, or throws', () => {511 const ref = plugin.sign(bchIntent, BCH, bchHandle).raw, refHash = hex.encode(intentHash(bchIntent)), x = bchIntent.x!;512 const muts: Partial<Intent>[] = [513 { to: 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a' },514 { amount: '180001', x: { ...x, change: '29599' } },515 { x: { ...x, change: '29500', fee: '500' } },516 { x: { ...x, inputs: encodeBchInputs([IN_B, IN_A]) } },517 { x: { ...x, inputs: encodeBchInputs([{ ...IN_A, vout: 2 }, IN_B]) } },518 { x: { ...x, inputs: encodeBchInputs([{ ...IN_A, value: 150_001n }, IN_B]), change: '29601' } },519 { amount: '180001' }, { maxFee: '1' }, { from: BCH_TO }, { chainRef: 'x' }, { network: 'dogecoin' }, { memo: '1' },520 ];521 for (const m of muts) {522 let out: string | null = null;523 try { out = plugin.sign({ ...bchIntent, ...m }, BCH, bchHandle).raw; } catch { /* refused */ }524 expect(out, JSON.stringify(m)).not.toBe(ref);525 let h: string | null = null;526 try { h = hex.encode(intentHash({ ...bchIntent, ...m })); } catch { /* refused */ }527 expect(h).not.toBe(refHash);528 }529 // properties the approval hash covers but the transaction does not: the hash still changes530 for (const m of [{ maxFee: '49999' }, { expiresAt: now + 121 }, { proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a12' }])531 expect(hex.encode(intentHash({ ...bchIntent, ...m }))).not.toBe(refHash);532 });533534 it('random valid transfers always pass libauth\'s virtual machine', () => {535 fc.assert(fc.property(536 fc.array(fc.tuple(fc.uint8Array({ minLength: 32, maxLength: 32 }), fc.nat(5), fc.bigInt(1000n, 5_000_000n)), { minLength: 1, maxLength: 6 }),537 fc.bigInt(546n, 900n), fc.bigInt(1n, 400n), fc.boolean(),538 (ins, amount, fee, sh) => {539 const inputs = ins.map(([h, vout, value], k) => ({ txid: hex.encode(Uint8Array.from([k, ...h.slice(1)])), vout, value }));540 const total = inputs.reduce((s, u) => s + u.value, 0n), change = total - amount - fee;541 const i: Intent = { ...bchIntent, to: sh ? 'bitcoincash:ppm2qsznhks23z7629mms6s4cwef74vcwvn0h829pq' : BCH_TO, amount: amount.toString(),542 x: { inputs: encodeBchInputs(inputs), change: (change < 546n ? 0n : change).toString(), fee: (change < 546n ? fee + change : fee).toString() }, maxFee: '100000' };543 if (change < 546n && fee + change > 100_000n) return true;544 const signed = plugin.sign(i, BCH, bchHandle);545 return vmVerify(signed.raw, inputs.map((u) => ({ addr: BCH_ADDR, value: u.value }))) === true;546 }), { numRuns: 60 });547 });548});549