Repository Public
1// Dogecoin and Bitcoin Cash through the real Signer (passkey approval, policy, journal), verified by independent libraries.2import { createHash } from 'node:crypto';3import { mkdtempSync } from 'node:fs';4import { tmpdir } from 'node:os';5import { join } from 'node:path';6import { describe, expect, it } from 'vitest';7import * as bitcoin from 'bitcoinjs-lib';8import { cashAddressToLockingBytecode, createVirtualMachineBCH, decodeTransaction, hashTransaction } from '@bitauth/libauth';9import { secp256k1 } from '@noble/curves/secp256k1.js';10import { hex } from '@scure/base';11import { encodeBchInputs, intentHash, NETWORKS, verifyAuditChain, type Intent } from '@spw/core';12import { TestPasskey } from '../../../tests/helpers/passkey.ts';13import { deriveKeys, utxoPath } from '../src/keys.ts';14import { Signer } from '../src/signer.ts';15import { Store } from '../src/store.ts';1617// Public BIP-39 test vector mnemonic — holds no funds, never used outside tests.18const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';19const RP = 'wallet.test', ORIGIN = 'https://wallet.test';20const keys = deriveKeys(TEST_MNEMONIC);21const DOGE_JS: bitcoin.Network = { messagePrefix: '\x19Dogecoin Signed Message:\n', bech32: 'doge', bip32: { public: 0x02facafd, private: 0x02fac398 }, pubKeyHash: 0x1e, scriptHash: 0x16, wif: 0x9e };2223async function setup() {24 const dir = mkdtempSync(join(tmpdir(), 'spw-signer-utxo-'));25 const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));26 store.state.setupTokenHash = createHash('sha256').update('setup-token').digest('hex');27 const t = Math.floor(Date.now() / 1000);28 const signer = new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => t);29 const pk = new TestPasskey(RP, ORIGIN);30 await signer.enroll({ credential: pk.credential(), setupToken: 'setup-token' });31 let seq = 0;32 const common = () => ({ v: 1 as const, proposalId: `6f1c1f0e-8f5f-4b1e-9c56-${String(++seq).padStart(12, '0')}`, keyId: keys.keyId, createdAt: t, expiresAt: t + 120, policyVersion: 3 });33 const sign = async (i: Intent, aux?: Record<string, unknown>) => signer.sign(i, pk.assert(intentHash(i)), aux);34 return { store, common, sign };35}3637describe('addresses (published vectors for the test mnemonic)', () => {38 it('Dogecoin m/44\'/3\'/0\'/0/0 and Bitcoin Cash m/44\'/145\'/0\'/0/0; existing addresses and the key id are unchanged', () => {39 expect(utxoPath(NETWORKS.dogecoin)).toBe("m/44'/3'/0'/0/0");40 expect(keys.addresses.dogecoin).toBe('DBus3bamQjgJULBJtYXpEzDWQRwF5iwxgC');41 expect(keys.addresses.bitcoincash).toBe('bitcoincash:qqyx49mu0kkn9ftfj6hje6g2wfer34yfnq5tahq3q6');42 expect(keys.addresses.btc).toBe('bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu');43 expect(keys.addresses.litecoin).toBe('ltc1qjmxnz78nmc8nq77wuxh25n2es7rzm5c2rkk4wh');44 expect(keys.keyId).toBe(deriveKeys(TEST_MNEMONIC).keyId);45 });46});4748describe('Dogecoin through the signer', () => {49 const from = keys.addresses.dogecoin!, to = 'DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L';50 const lock = bitcoin.address.toOutputScript(from, DOGE_JS);51 const prev = new bitcoin.Transaction();52 prev.version = 1;53 prev.addInput(new Uint8Array(32).fill(3), 4, 0xffffffff, Uint8Array.of(0x51));54 prev.addOutput(lock, 12_000_000_000n);55 const aux = { prevTx: { [prev.getId()]: prev.toHex() } };56 const intent = (c: object): Intent => ({ ...(c as ReturnType<Awaited<ReturnType<typeof setup>>['common']>), network: 'dogecoin', chainRef: NETWORKS.dogecoin.chainRef,57 asset: 'native', symbol: 'DOGE', decimals: 8, amount: '10000000000', from, to, maxFee: '100000000',58 btc: { inputs: [{ txid: prev.getId(), vout: 0, value: '12000000000' }], change: '1997740000', fee: '2260000' } });5960 it('signs a legacy transfer; bitcoinjs-lib verifies the input script and the amounts', async () => {61 const { sign, common, store } = await setup();62 const r = await sign(intent(common()), aux);63 const tx = bitcoin.Transaction.fromHex(r.raw);64 expect(tx.getId()).toBe(r.txid);65 expect(tx.ins.length).toBe(1);66 const [sigWithType, pub] = bitcoin.script.decompile(tx.ins[0]!.script) as [Uint8Array, Uint8Array];67 expect(bitcoin.payments.p2pkh({ pubkey: pub, network: DOGE_JS }).address).toBe(from);68 const { signature, hashType } = bitcoin.script.signature.decode(sigWithType);69 expect(hashType).toBe(bitcoin.Transaction.SIGHASH_ALL);70 expect(secp256k1.verify(signature, tx.hashForSignature(0, lock, hashType), pub, { prehash: false, lowS: true })).toBe(true);71 expect(tx.outs.map((o) => [bitcoin.address.fromOutputScript(o.script, DOGE_JS), o.value])).toEqual([[to, 10_000_000_000n], [from, 1_997_740_000n]]);72 expect(12_000_000_000n - tx.outs.reduce((s, o) => s + o.value, 0n)).toBe(2_260_000n);73 expect(verifyAuditChain(store.state.journal)).toBeNull();74 });7576 it('refuses: no previous transaction, a forged one, an inflated input, foreign addresses, limits', async () => {77 const { sign, common } = await setup();78 await expect(sign(intent(common()))).rejects.toThrow(/previous transaction/);79 const forged = new bitcoin.Transaction();80 forged.version = 1;81 forged.addInput(new Uint8Array(32).fill(3), 4, 0xffffffff, Uint8Array.of(0x51));82 forged.addOutput(lock, 13_000_000_000n);83 await expect(sign(intent(common()), { prevTx: { [prev.getId()]: forged.toHex() } })).rejects.toThrow(/does not match input txid/);84 const inflated = intent(common());85 inflated.btc = { inputs: [{ txid: prev.getId(), vout: 0, value: '13000000000' }], change: '2997740000', fee: '2260000' };86 await expect(sign(inflated, aux)).rejects.toThrow(/does not match the approved input/);87 await expect(sign({ ...intent(common()), to: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' }, aux)).rejects.toThrow();88 await expect(sign({ ...intent(common()), to: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa' }, aux)).rejects.toThrow();89 await expect(sign({ ...intent(common()), to: from }, aux)).rejects.toThrow(/destination equals source/);90 await expect(sign({ ...intent(common()), from: to }, aux)).rejects.toThrow(/source/);91 await expect(sign({ ...intent(common()), chainRef: NETWORKS.litecoin.chainRef }, aux)).rejects.toThrow(/chain/);92 await expect(sign({ ...intent(common()), maxFee: '100000001' }, aux)).rejects.toThrow(/fee ceiling/);93 await expect(sign({ ...intent(common()), amount: '70000000001' }, aux)).rejects.toThrow(/per-transaction/);94 await sign(intent(common()), aux); // still usable after refusals95 });96});9798describe('Bitcoin Cash through the signer', () => {99 const from = keys.addresses.bitcoincash!, to = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy';100 const inputs = [{ txid: 'cd'.repeat(32), vout: 0, value: 400_000n }, { txid: 'ef'.repeat(32), vout: 3, value: 100_000n }];101 const intent = (c: object): Intent => ({ ...(c as ReturnType<Awaited<ReturnType<typeof setup>>['common']>), network: 'bitcoincash', chainRef: NETWORKS.bitcoincash.chainRef,102 asset: 'native', symbol: 'BCH', decimals: 8, amount: '450000', from, to, maxFee: '50000',103 x: { inputs: encodeBchInputs(inputs), change: '49600', fee: '400' } });104 const vm = createVirtualMachineBCH(true);105 const lockOf = (a: string) => { const r = cashAddressToLockingBytecode(a); if (typeof r === 'string') throw new Error(r); return r.bytecode; };106107 it('signs a transfer that libauth\'s virtual machine accepts under standard rules', async () => {108 const { sign, common, store } = await setup();109 const r = await sign(intent(common()));110 expect(hashTransaction(hex.decode(r.raw))).toBe(r.txid);111 const tx = decodeTransaction(hex.decode(r.raw));112 if (typeof tx === 'string') throw new Error(tx);113 expect(tx.outputs.map((o) => [hex.encode(o.lockingBytecode), o.valueSatoshis])).toEqual([[hex.encode(lockOf(to)), 450_000n], [hex.encode(lockOf(from)), 49_600n]]);114 const sourceOutputs = inputs.map((u) => ({ lockingBytecode: lockOf(from), valueSatoshis: u.value }));115 expect(vm.verify({ transaction: tx, sourceOutputs })).toBe(true);116 expect(vm.verify({ transaction: tx, sourceOutputs: [{ ...sourceOutputs[0]!, valueSatoshis: 400_001n }, sourceOutputs[1]!] })).not.toBe(true);117 expect(verifyAuditChain(store.state.journal)).toBeNull();118 });119120 it('refuses: unbalanced inputs, fee above the ceiling, unknown parameters, foreign or legacy-form addresses, replay', async () => {121 const { sign, common } = await setup();122 const x = intent(common()).x!;123 await expect(sign({ ...intent(common()), x: { ...x, fee: '401' } })).rejects.toThrow(/inputs != outputs/);124 await expect(sign({ ...intent(common()), maxFee: '399' })).rejects.toThrow(/fee exceeds/);125 await expect(sign({ ...intent(common()), maxFee: '50001' })).rejects.toThrow(/fee ceiling/);126 await expect(sign({ ...intent(common()), x: { ...x, locktime: '5' } })).rejects.toThrow(/unknown or missing/);127 await expect(sign({ ...intent(common()), to: '1KXrWXciRDZUpQwQmuM1DbwsKDLYAYsVLR' })).rejects.toThrow(/not normalized/);128 await expect(sign({ ...intent(common()), to: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' })).rejects.toThrow();129 await expect(sign({ ...intent(common()), to: from })).rejects.toThrow(/destination equals source/);130 await expect(sign({ ...intent(common()), from: to })).rejects.toThrow(/source/);131 await expect(sign({ ...intent(common()), chainRef: NETWORKS.bitcoin.chainRef })).rejects.toThrow(/chain/);132 await expect(sign({ ...intent(common()), amount: '20000001' })).rejects.toThrow(/per-transaction/);133 const { x: _x, ...noSection } = intent(common());134 await expect(sign({ ...noSection, btc: { inputs: [{ txid: 'cd'.repeat(32), vout: 0, value: '500000' }], change: '49600', fee: '400' } } as Intent)).rejects.toThrow(/chain section/);135 const ok = intent(common());136 await sign(ok);137 await expect(sign(ok)).rejects.toThrow(/already signed/);138 });139});140