argus/test/register-tx.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

359 lines
13 KiB
JavaScript

// Offline validation of the certificate-minting tx builder.
// No network, no funds — synthetic UTXOs only. Run: node --test test/
import test from "node:test";
import assert from "node:assert/strict";
import {
buildRegistrationTx,
buildUpdateTx,
buildPrepTx,
normalizeName,
encodePayload,
opReturnScript,
hasGenesisInput,
NoGenesisInputError,
InsufficientFundsError,
BEACON,
CERTIFICATE_DUST_SAT,
BEACON_DUST_SAT,
} from "../src/lib/register-tx.js";
import { signTransaction, txidOf } from "../src/lib/sign-tx.js";
import { BuiltInWallet } from "../src/lib/wallet-web.js";
import { addressToLockingBytecode, toTokenAddress } from "../src/lib/chain.js";
import { parseOpReturn } from "../src/lib/bns.js";
import {
encodeTransaction,
decodeTransaction,
binToHex,
hexToBin,
binToUtf8,
} from "@bitauth/libauth";
// A fixed phrase so the test is deterministic. TEST ONLY — never funded.
const TEST_MNEMONIC =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
const FEE_ADDRESS = "bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl";
const utxo = (txid, vout, sats, wallet, token) => ({
txid,
vout,
satoshis: BigInt(sats),
address: wallet.address,
lockingBytecode: addressToLockingBytecode(wallet.address),
...(token ? { token } : {}),
});
const A = "1111111111111111111111111111111111111111111111111111111111111111";
const B = "2222222222222222222222222222222222222222222222222222222222222222";
const C = "3333333333333333333333333333333333333333333333333333333333333333";
async function fixture() {
const wallet = await BuiltInWallet.fromMnemonic(TEST_MNEMONIC);
return {
wallet,
base: {
ownerAddress: wallet.address,
ownerTokenAddress: wallet.tokenAddress,
feeAddress: FEE_ADDRESS,
addressToLockingBytecode,
},
};
}
test("normalizeName keeps .bch bare and other TLDs qualified", () => {
assert.equal(normalizeName("Alice"), "alice");
assert.equal(normalizeName("alice.bch"), "alice");
assert.equal(normalizeName("alice.p2p"), "alice.p2p");
assert.equal(normalizeName("www.alice.p2p"), "alice.p2p");
assert.throws(() => normalizeName("bad_name.bch"));
assert.throws(() => normalizeName("this-label-is-far-too-long-to-be-a-valid-bns-name.bch"));
});
test("payload round-trips through the on-chain parser", () => {
const script = opReturnScript(encodePayload("REG", "alice.p2p", { u: "https://x.example" }));
const parsed = parseOpReturn(binToHex(script));
assert.deepEqual(parsed, { o: "REG", n: "alice.p2p", r: { u: "https://x.example" } });
});
test("payload over 200 bytes is rejected", () => {
assert.throws(() => encodePayload("REG", "alice", { h: "x".repeat(300) }), /records too large/);
});
test("registration mints the certificate to the buyer, not to us", async () => {
const { wallet, base } = await fixture();
const built = buildRegistrationTx({
...base,
name: "alice.p2p",
records: { u: "https://example.org" },
feeSats: 5000n,
utxos: [utxo(A, 0, 100_000, wallet)],
});
const cert = built.transaction.outputs[built.outputMap.certificate];
assert.ok(cert.token, "certificate output carries token data");
assert.equal(
binToHex(cert.lockingBytecode),
binToHex(addressToLockingBytecode(wallet.tokenAddress)),
"certificate is locked to the buyer's own key",
);
assert.equal(binToUtf8(cert.token.nft.commitment), "alice.p2p");
assert.equal(cert.token.nft.capability, "none");
assert.equal(cert.valueSatoshis, CERTIFICATE_DUST_SAT);
});
test("category ID equals the genesis input's txid (CashTokens consensus rule)", async () => {
const { wallet, base } = await fixture();
const built = buildRegistrationTx({
...base,
name: "alice",
utxos: [utxo(B, 3, 50_000, wallet), utxo(A, 0, 50_000, wallet)],
});
assert.equal(built.inputUtxos[0].vout, 0, "input 0 is the genesis input");
assert.equal(built.inputUtxos[0].txid, A);
assert.equal(built.category, A, "category ID is the genesis outpoint's txid");
const cert = built.transaction.outputs[built.outputMap.certificate];
assert.equal(binToHex(cert.token.category), A);
});
test("no vout-0 coin -> NoGenesisInputError (and prep tx fixes it)", async () => {
const { wallet, base } = await fixture();
const utxos = [utxo(A, 1, 100_000, wallet), utxo(B, 2, 100_000, wallet)];
assert.equal(hasGenesisInput(utxos), false);
assert.throws(
() => buildRegistrationTx({ ...base, name: "alice", utxos }),
NoGenesisInputError,
);
const prep = buildPrepTx({ ownerAddress: wallet.address, utxos, addressToLockingBytecode });
assert.equal(
binToHex(prep.transaction.outputs[0].lockingBytecode),
binToHex(addressToLockingBytecode(wallet.address)),
"prep tx output 0 pays the owner, making a genesis-eligible coin",
);
});
test("change lands at vout 0 so the next registration is self-sufficient", async () => {
const { wallet, base } = await fixture();
const built = buildRegistrationTx({
...base,
name: "alice",
feeSats: 1000n,
utxos: [utxo(A, 0, 100_000, wallet)],
});
assert.equal(built.outputMap.change, 0);
assert.ok(built.costs.changeSats > 0n);
assert.equal(
binToHex(built.transaction.outputs[0].lockingBytecode),
binToHex(addressToLockingBytecode(wallet.address)),
);
});
test("beacon, service fee and OP_RETURN are all present and correct", async () => {
const { wallet, base } = await fixture();
const built = buildRegistrationTx({
...base,
name: "alice.p2p",
records: { ip: "203.0.113.9" },
feeSats: 25_000n,
utxos: [utxo(A, 0, 200_000, wallet)],
});
const o = built.transaction.outputs;
assert.equal(o[built.outputMap.beacon].valueSatoshis, BEACON_DUST_SAT);
assert.equal(
binToHex(o[built.outputMap.beacon].lockingBytecode),
binToHex(addressToLockingBytecode(BEACON)),
);
assert.equal(o[built.outputMap.serviceFee].valueSatoshis, 25_000n);
assert.equal(
binToHex(o[built.outputMap.serviceFee].lockingBytecode),
binToHex(addressToLockingBytecode(FEE_ADDRESS)),
);
assert.deepEqual(parseOpReturn(binToHex(o[built.outputMap.opReturn].lockingBytecode)), {
o: "REG",
n: "alice.p2p",
r: { ip: "203.0.113.9" },
});
assert.equal(o[built.outputMap.opReturn].valueSatoshis, 0n);
});
test("the books balance: inputs = outputs + chain fee", async () => {
const { wallet, base } = await fixture();
const built = buildRegistrationTx({
...base,
name: "alice",
feeSats: 7777n,
utxos: [utxo(A, 0, 100_000, wallet)],
});
const outSum = built.transaction.outputs.reduce((s, o) => s + o.valueSatoshis, 0n);
const inSum = built.inputUtxos.reduce((s, u) => s + u.satoshis, 0n);
assert.equal(inSum, outSum + built.costs.chainFeeSats);
assert.equal(inSum, 100_000n);
});
test("the fee actually covers the transaction at 1 sat/byte", async () => {
const { wallet, base } = await fixture();
const built = buildRegistrationTx({
...base,
name: "alice",
feeSats: 1000n,
utxos: [utxo(A, 0, 60_000, wallet), utxo(B, 1, 60_000, wallet)],
});
const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h));
const size = hexToBin(signed.hex).length;
assert.ok(
built.costs.chainFeeSats >= BigInt(size),
`fee ${built.costs.chainFeeSats} must cover ${size} bytes`,
);
});
test("certificate UTXOs are never spent as change", async () => {
const { wallet, base } = await fixture();
const certToken = {
category: C,
amount: 0n,
nft: { capability: "none", commitment: binToHex(new TextEncoder().encode("held.p2p")) },
};
const built = buildRegistrationTx({
...base,
name: "alice",
utxos: [
utxo(A, 0, 100_000, wallet),
utxo(C, 0, 1000, wallet, certToken), // an already-owned name — must not be touched
],
});
assert.ok(
built.inputUtxos.every((u) => !u.token),
"no token-bearing UTXO may be selected as a funding coin",
);
});
test("insufficient funds is reported, not silently under-paid", async () => {
const { wallet, base } = await fixture();
assert.throws(
() => buildRegistrationTx({ ...base, name: "alice", feeSats: 50_000n, utxos: [utxo(A, 0, 2000, wallet)] }),
InsufficientFundsError,
);
});
test("signing produces a well-formed tx that survives an encode/decode round trip", async () => {
const { wallet, base } = await fixture();
const built = buildRegistrationTx({
...base,
name: "alice.p2p",
records: { u: "https://example.org" },
feeSats: 5000n,
utxos: [utxo(A, 0, 100_000, wallet)],
});
const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h));
const redecoded = decodeTransaction(hexToBin(signed.hex));
assert.notEqual(typeof redecoded, "string", `decode failed: ${redecoded}`);
assert.equal(binToHex(encodeTransaction(redecoded)), signed.hex, "encode/decode is lossless");
// Byte order: what we put in the outpoint is what comes back out.
assert.equal(binToHex(redecoded.inputs[0].outpointTransactionHash), A);
const cert = redecoded.outputs.find((o) => o.token?.nft);
assert.equal(binToHex(cert.token.category), A);
assert.equal(binToUtf8(cert.token.nft.commitment), "alice.p2p");
// Every input is really signed (placeholder is all-zero pushes).
for (const i of redecoded.inputs) {
assert.equal(i.unlockingBytecode.length, 100);
assert.ok(i.unlockingBytecode.some((b) => b !== 0), "input carries a real signature");
}
assert.match(signed.txid, /^[0-9a-f]{64}$/);
assert.equal(signed.txid, txidOf(hexToBin(signed.hex)));
});
test("the resolver's own rules accept the transaction we build", async () => {
const { wallet, base } = await fixture();
const built = buildRegistrationTx({
...base,
name: "alice.p2p",
records: { u: "https://example.org" },
utxos: [utxo(A, 0, 100_000, wallet)],
});
const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h));
const tx = decodeTransaction(hexToBin(signed.hex));
// Mirror of buildIndex()'s REG validation.
let payload = null;
for (const o of tx.outputs) payload = parseOpReturn(binToHex(o.lockingBytecode)) ?? payload;
assert.ok(payload, "a BNS1 payload is discoverable");
assert.equal(payload.o, "REG");
const commitment = binToHex(new TextEncoder().encode(payload.n));
const mint = tx.outputs.find((o) => o.token?.nft && binToHex(o.token.nft.commitment) === commitment);
assert.ok(mint, "the same tx mints a certificate whose commitment is the name");
const paysBeacon = tx.outputs.some(
(o) =>
binToHex(o.lockingBytecode) === binToHex(addressToLockingBytecode(BEACON)) &&
o.valueSatoshis === BEACON_DUST_SAT,
);
assert.ok(paysBeacon, "the beacon is paid so the registration is discoverable");
});
test("update tx moves the certificate back to the owner as ownership proof", async () => {
const { wallet, base } = await fixture();
const certUtxo = utxo(C, 1, 1000, wallet, {
category: C,
amount: 0n,
nft: { capability: "none", commitment: binToHex(new TextEncoder().encode("alice.p2p")) },
});
const built = buildUpdateTx({
...base,
name: "alice.p2p",
records: { ip: "198.51.100.7" },
certificateUtxo: certUtxo,
utxos: [utxo(A, 0, 50_000, wallet)],
});
assert.equal(built.inputUtxos[0], certUtxo, "the certificate is spent — that is the proof");
const cert = built.transaction.outputs[built.outputMap.certificate];
assert.equal(binToHex(cert.token.category), C, "same category survives the update");
assert.deepEqual(
parseOpReturn(binToHex(built.transaction.outputs[built.outputMap.opReturn].lockingBytecode)),
{ o: "UPD", n: "alice.p2p", r: { ip: "198.51.100.7" } },
);
const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h));
assert.notEqual(typeof decodeTransaction(hexToBin(signed.hex)), "string");
});
test("updating the wrong name is refused", async () => {
const { wallet, base } = await fixture();
const certUtxo = utxo(C, 1, 1000, wallet, {
category: C,
amount: 0n,
nft: { capability: "none", commitment: binToHex(new TextEncoder().encode("bob.p2p")) },
});
assert.throws(
() =>
buildUpdateTx({
...base,
name: "alice.p2p",
certificateUtxo: certUtxo,
utxos: [utxo(A, 0, 50_000, wallet)],
}),
/commitment mismatch/,
);
});
test("wallet: encrypt/decrypt round trip, wrong password rejected", async () => {
const wallet = await BuiltInWallet.fromMnemonic(TEST_MNEMONIC);
const blob = await wallet.encrypt("correct horse battery staple");
assert.ok(!blob.includes("abandon"), "the seed phrase is not stored in the clear");
const back = await BuiltInWallet.decrypt(blob, "correct horse battery staple");
assert.equal(back.mnemonic, TEST_MNEMONIC);
assert.equal(back.address, wallet.address);
await assert.rejects(() => BuiltInWallet.decrypt(blob, "wrong"), /wrong password/);
});
test("wallet: token address and plain address share one key", async () => {
const wallet = await BuiltInWallet.fromMnemonic(TEST_MNEMONIC);
assert.match(wallet.address, /^bchtest:q/);
assert.match(wallet.tokenAddress, /^bchtest:z/);
assert.equal(
binToHex(addressToLockingBytecode(wallet.address)),
binToHex(addressToLockingBytecode(wallet.tokenAddress)),
);
assert.equal(toTokenAddress(wallet.address), wallet.tokenAddress);
assert.ok(wallet.keyFor(binToHex(addressToLockingBytecode(wallet.address))));
});