154 lines
7.3 KiB
JavaScript
154 lines
7.3 KiB
JavaScript
|
|
// 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, /<Uint8Array: 0x[0-9a-f]+>/, "byte arrays use the Uint8Array marker");
|
||
|
|
assert.match(wire, /<bigint: \d+n>/, "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("<Uint8Array"), "hand-rolled JSON loses the byte-array encoding wallets require");
|
||
|
|
|
||
|
|
const params = JSON.parse(wire);
|
||
|
|
assert.deepEqual(Object.keys(params).sort(), ["broadcast", "sourceOutputs", "transaction", "userPrompt"]);
|
||
|
|
assert.equal(params.broadcast, true);
|
||
|
|
assert.equal(params.sourceOutputs.length, tx.transaction.inputs.length,
|
||
|
|
"one sourceOutput per input — the wallet needs both halves to know what it is spending");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("every sourceOutput describes the coin being spent, not just the outpoint", async () => {
|
||
|
|
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/);
|
||
|
|
});
|