argus/test/connect-shape.test.mjs
Local Dev a45320db7d Initial commit — Silent Mode baseline (2026-07-29)
Snapshot of the decentralized-web stack at the point of the resolver+Theseus
rebuild deploy. Includes:

- Argus (BNS engine + resolver daemon + Sia gateway)
- AriadneResolver (Windows Inno installer bundle + Android APK sources +
  Firefox extension)
- TheseusNavigator (Electron browser)
- site/ (silentmode.st content, deployed to Sia at bns/silentmode/)
- design docs, roadmap, protocol spec

Secrets excluded via .gitignore: Argus/sia-s3.json, Argus/wallets.json,
Argus/ca/*.key,*.crt. Build outputs, node_modules, and bundled runtimes
also excluded.

Shipped hashes on dl.silentmode.st at this commit:
  AriadneResolver-Setup-0.1.0.exe    5bcb216eef31ea28ed767e4134ab74bd5ac69dfbd365fd249e9e6938e55c986a
  TheseusNavigator-Setup-0.0.1.exe   7c735e88bad2da3347145adba3016c8f626a18b8422289c8c6ba471972e2952b
  TheseusNavigator-0.0.1-portable.exe 008fd84445babeabb401b2bca40ea9466b24b0e6d6c85104da7640c5c5c84521
  ariadne-v0.2.apk                   635c8f04d44ef855a8390b9eeddb8cd2d50622e81cc4e5004e8daffc1bb0425c
2026-07-29 13:54:34 +02:00

153 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/);
});