// Validates the payload the external-wallet path puts on the wire. // // WalletConnect v2 was dropped as a transport (2026-07-27), but these tests are // NOT obsolete: WizardConnect's `SignTransactionRequest.transaction` is a // `WcSignTransactionRequest` from @bch-wc2/interfaces — the identical WC2 tx // object — and it is encoded with the same libauth `stringify` markers. The // shape outlived the transport. // // We cannot press "approve" on someone's phone from here, so this is the next // best thing: prove that what we would send is byte-for-byte what a wallet // expects to receive. The failure mode this guards against is the one Deviant // actually hit (snapshot 0057) — plain JSON.stringify silently drops Uint8Array // and bigint, and the wallet rejects the request on schema validation. import test from "node:test"; import assert from "node:assert/strict"; import { stringify, hexToBin, binToHex, decodeTransaction, encodeTransaction } from "@bitauth/libauth"; import { buildRegistrationTx, toWcTransactionObject } from "../src/lib/register-tx.js"; import { addressToLockingBytecode } from "../src/lib/chain.js"; import { BuiltInWallet } from "../src/lib/wallet-web.js"; import { pubkeyToAddress, pubkeyToLockingBytecode, describeGenesisRequirement } from "../src/lib/connect/wallet-session.js"; import { PATHS } from "../src/lib/connect/wizardconnect.js"; const MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; const A = "1111111111111111111111111111111111111111111111111111111111111111"; async function built() { const wallet = await BuiltInWallet.fromMnemonic(MNEMONIC); return { wallet, tx: buildRegistrationTx({ name: "wallet-test.p2p", ownerAddress: wallet.address, ownerTokenAddress: wallet.tokenAddress, records: { u: "https://example.org" }, utxos: [{ txid: A, vout: 0, satoshis: 100_000n, address: wallet.address, lockingBytecode: addressToLockingBytecode(wallet.address), }], feeAddress: "bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl", feeSats: 10_000n, addressToLockingBytecode, }), }; } test("WC2 payload carries the libauth extended-JSON markers wallets validate", async () => { const { tx } = await built(); const wc = toWcTransactionObject(tx, { broadcast: true, userPrompt: "Register wallet-test.p2p" }); const wire = stringify(wc); assert.match(wire, //, "byte arrays use the Uint8Array marker"); assert.match(wire, //, "satoshi values use the bigint marker"); // Why stringify is mandatory: plain JSON cannot even represent this object — // it throws on the bigint satoshi values, and would silently flatten every // Uint8Array to {} if it got past them. assert.throws(() => JSON.stringify(wc), /BigInt/); const naive = JSON.stringify(wc, (_k, v) => (typeof v === "bigint" ? Number(v) : v)); assert.ok(!naive.includes(" { const { tx, wallet } = await built(); for (const [i, src] of tx.sourceOutputs.entries()) { // Input half assert.ok(src.outpointTransactionHash instanceof Uint8Array); assert.equal(typeof src.outpointIndex, "number"); assert.ok(src.unlockingBytecode instanceof Uint8Array); // Output half — what the wallet checks ownership against assert.ok(src.lockingBytecode instanceof Uint8Array); assert.equal(typeof src.valueSatoshis, "bigint"); assert.equal( binToHex(src.lockingBytecode), binToHex(addressToLockingBytecode(wallet.address)), `input ${i} is locked to the connected wallet, so it will actually sign it`, ); } }); test("unsigned inputs carry the placeholder shape a wallet expects to replace", async () => { const { tx } = await built(); for (const input of tx.transaction.inputs) { assert.equal(input.unlockingBytecode.length, 100, "cashscript placeholderP2PKHUnlocker width"); assert.equal(input.unlockingBytecode[0], 0x41, "push 65-byte signature slot"); assert.equal(input.unlockingBytecode[66], 0x21, "push 33-byte pubkey slot"); assert.ok(input.unlockingBytecode.every((b, i) => i === 0 || i === 66 || b === 0), "slots are empty"); } }); test("the transaction survives the wire as encodable bytes", async () => { const { tx } = await built(); const encoded = encodeTransaction(tx.transaction); const back = decodeTransaction(encoded); assert.notEqual(typeof back, "string"); assert.equal(binToHex(encodeTransaction(back)), binToHex(encoded)); }); test("WizardConnect inputPaths triplets are [inputIndex, pathName, addressIndex]", async () => { const { tx, wallet } = await built(); // Stand in for the manager's xpub-derived set: our own key at receive/0. const known = new Map([[binToHex(addressToLockingBytecode(wallet.address)), { pathName: "receive", index: 0 }]]); const inputPaths = tx.sourceOutputs.map((src, i) => { const a = known.get(binToHex(src.lockingBytecode)); return [i, a.pathName, a.index]; }); assert.equal(inputPaths.length, tx.transaction.inputs.length); for (const [inputIndex, pathName, addressIndex] of inputPaths) { assert.equal(typeof inputIndex, "number"); assert.equal(typeof pathName, "string"); assert.equal(typeof addressIndex, "number", "address index is a number in the triplet, not a bigint"); assert.ok(pathName in PATHS, `"${pathName}" maps to a BIP44 branch`); } assert.equal(PATHS.receive, 0); assert.equal(PATHS.change, 1); assert.equal(PATHS.defi, 7); }); test("a coin the connected wallet does not own is refused, not silently sent", async () => { const { tx } = await built(); const known = new Map(); // wallet knows nothing assert.throws(() => { tx.sourceOutputs.forEach((src, i) => { if (!known.get(binToHex(src.lockingBytecode))) { throw new Error(`input ${i} is not from this wallet`); } }); }, /not from this wallet/); }); test("pubkey -> address derivation matches the wallet's own", async () => { const wallet = await BuiltInWallet.fromMnemonic(MNEMONIC); const entry = wallet.receive[0]; assert.equal(pubkeyToAddress(entry.publicKey, "bchtest", false), wallet.address); assert.equal(pubkeyToAddress(entry.publicKey, "bchtest", true), wallet.tokenAddress); assert.equal(binToHex(pubkeyToLockingBytecode(entry.publicKey)), binToHex(entry.lockingBytecode)); }); test("the genesis requirement is explained honestly, with the approval count", () => { assert.equal(describeGenesisRequirement(true).approvals, 1); const need = describeGenesisRequirement(false); assert.equal(need.approvals, 2); assert.match(need.explanation, /only once/); assert.match(need.explanation, /not a fee we charge/); });