SPB Git

Repository Public

utxo.test.ts 9.7 KB 140 lines typescript

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
Files main
Menu

Theme