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
359 lines
13 KiB
JavaScript
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))));
|
|
});
|