504 lines
27 KiB
TypeScript
504 lines
27 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { generateKeyPairSync, sign } from 'node:crypto';
|
|
import { mkdtemp, readFile, writeFile, rm, rename, mkdir, stat, truncate } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
import dns from 'node:dns/promises';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
import { Ledger } from '../server/testnet/ledger.ts';
|
|
import { PeerNetwork } from '../server/testnet/peers.ts';
|
|
import { CHAIN_ID, GENESIS_HASH, GENESIS_TIME, MAX_TARGET, address, makeTemplate, miningBlob,
|
|
mintId, sha256, stateRoot, transferId, transition, workFor } from '../server/testnet/consensus.ts';
|
|
import { NETWORK, CONSENSUS, blockClaimMessage, nftTransferMessage, testnetOwnerOf,
|
|
type MiningTemplate, type NftState, type NftStateLayer, type NftStateView,
|
|
type NftTransfer, type TestnetBlock } from '../shared/testnet.ts';
|
|
import { deriveStats } from '../shared/enrichment.ts';
|
|
import type { PowEngine } from '../server/testnet/randomx.ts';
|
|
|
|
// Deliberately test-only: production has no fake PoW selector or fallback.
|
|
class FakePow implements PowEngine {
|
|
calls = 0;
|
|
async hash(key: string, blob: Uint8Array): Promise<string> {
|
|
this.calls++;
|
|
return sha256(Buffer.concat([Buffer.from(key, 'hex'), Buffer.from(blob)]));
|
|
}
|
|
close(): void {}
|
|
}
|
|
function identity() {
|
|
const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
const publicKey = keys.publicKey.export({ format: 'der', type: 'spki' }).toString('base64');
|
|
return { publicKey, owner: address(publicKey), sign: (message: string) =>
|
|
sign('sha256', Buffer.from(message), { key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') };
|
|
}
|
|
const alice = identity(), bob = identity(), carol = identity();
|
|
type Identity = ReturnType<typeof identity>;
|
|
const pow = new FakePow();
|
|
async function mine(template: MiningTemplate, miner: Identity = alice): Promise<TestnetBlock> {
|
|
const header = structuredClone(template.header);
|
|
assert.ok(BigInt(`0x${header.target}`) <= BigInt(`0x${MAX_TARGET}`));
|
|
for (header.nonce = 0; header.nonce < 1_000_000; header.nonce++) {
|
|
const blob = miningBlob(header);
|
|
const powHash = await pow.hash(header.key, blob);
|
|
if (BigInt(`0x${powHash}`) > BigInt(`0x${header.target}`)) continue;
|
|
const hash = sha256(blob);
|
|
return { ...header, hash, tokenId: mintId(header), powHash,
|
|
signature: miner.sign(blockClaimMessage(hash)), transactions: structuredClone(template.transactions) };
|
|
}
|
|
throw new Error('Test nonce search exhausted');
|
|
}
|
|
type Branch = { blocks: TestnetBlock[]; state: NftStateView };
|
|
function branch(): Branch { return { blocks: [], state: {} }; }
|
|
function articleFor(_ancestors: TestnetBlock[]) {
|
|
const pageId = 50_000;
|
|
return { pageId, title: `Test article ${pageId}`, extract: `A deterministic test article snapshot for Wikipedia page ${pageId}.`,
|
|
image: 'https://upload.wikimedia.org/wikipedia/commons/test-image.jpg',
|
|
url: `https://en.wikipedia.org/?curid=${pageId}`, revisionId: 1, source: 'live' as const, category: 'Culture' };
|
|
}
|
|
function statsSnapshot() {
|
|
const pageBytes = 2048, languageCount = 3;
|
|
return { capturedAt: '2026-10-19T00:00:00.000Z', revisionId: 1, pageBytes, languageCount,
|
|
languagesTruncated: false, pageviews: { status: 'unavailable' as const, months: [], latestMonth: '2026-09',
|
|
total: null, dailyAverage: null, trendPercent: null }, stats: deriveStats(pageBytes, languageCount, null) };
|
|
}
|
|
async function extend(chain: Branch, miner: Identity = alice, transactions: NftTransfer[] = [],
|
|
spacing = 30): Promise<TestnetBlock> {
|
|
const now = GENESIS_TIME + (chain.blocks.length + 1) * spacing;
|
|
const block = await mine(makeTemplate(chain.blocks, chain.state, miner.owner, miner.publicKey,
|
|
transactions, articleFor(chain.blocks), statsSnapshot(), now), miner);
|
|
chain.state = transition(chain.state, block, transactions);
|
|
chain.blocks.push(block);
|
|
return block;
|
|
}
|
|
function transfer(cardHash: string, from: Identity = alice, to: Identity = bob, sequence = 0): NftTransfer {
|
|
const tx = { network: NETWORK, chainId: CHAIN_ID, cardHash, from: from.owner, to: to.owner,
|
|
sequence, publicKey: from.publicKey };
|
|
return { ...tx, signature: from.sign(nftTransferMessage(tx)) };
|
|
}
|
|
async function fixture(t: { after: (fn: () => Promise<void>) => void }) {
|
|
const dir = await mkdtemp(join(tmpdir(), 'wikideck-testnet-'));
|
|
const engine = new FakePow();
|
|
const ledger = await Ledger.open(dir, engine);
|
|
t.after(async () => { ledger.close(); engine.close(); await rm(dir, { recursive: true, force: true }); });
|
|
return { dir, ledger, engine, file: join(dir, 'testnet', 'ledger.sqlite') };
|
|
}
|
|
|
|
test('testnet genesis is empty, manifest-bound, isolated and defensively copied', async t => {
|
|
const { dir, ledger, file } = await fixture(t);
|
|
const initial = ledger.snapshot();
|
|
assert.equal(initial.network, NETWORK);
|
|
assert.equal(initial.chainId, CHAIN_ID);
|
|
assert.equal(initial.genesis, GENESIS_HASH);
|
|
assert.equal(initial.tip, GENESIS_HASH);
|
|
assert.equal(initial.cumulativeWork, '0');
|
|
assert.equal(initial.algorithm, 'RandomX');
|
|
assert.equal(initial.maturity, 2);
|
|
assert.equal(initial.peers, 0);
|
|
assert.deepEqual(initial.blocks, []);
|
|
assert.deepEqual(initial.transfers, []);
|
|
assert.deepEqual(initial.pendingTransfers, []);
|
|
assert.equal(stateRoot({}), sha256(`${NETWORK}:empty`));
|
|
await assert.rejects(readFile(join(dir, 'chain.json')), /ENOENT/);
|
|
assert.equal((await stat(file)).isFile(), true);
|
|
const database = new DatabaseSync(file);
|
|
assert.equal((database.prepare('SELECT COUNT(*) AS count FROM blocks').get() as { count: number }).count, 0);
|
|
database.close();
|
|
const candidate = await mine(ledger.template(alice.owner, alice.publicKey, articleFor([]), statsSnapshot()));
|
|
assert.deepEqual(await ledger.submit(candidate), { accepted: true, reorg: false });
|
|
const persisted = new DatabaseSync(file);
|
|
assert.equal((persisted.prepare('SELECT COUNT(*) AS count FROM blocks').get() as { count: number }).count, 1);
|
|
persisted.close();
|
|
candidate.article.title = 'caller mutation';
|
|
const copy = ledger.block(candidate.hash)!;
|
|
copy.article.title = 'getter mutation';
|
|
assert.notEqual(ledger.block(candidate.hash)!.article.title, copy.article.title);
|
|
assert.equal(ledger.size, 1);
|
|
assert.throws(() => ledger.inventory(-1, 32));
|
|
assert.throws(() => ledger.inventory(0, 0));
|
|
});
|
|
|
|
test('DAG retains equal-work branches, serializes duplicates and selects strictly greater cumulative work', async t => {
|
|
const { ledger, dir } = await fixture(t);
|
|
t.mock.method(Date, 'now', () => (GENESIS_TIME + CONSENSUS.targetSeconds * 20) * 1000);
|
|
const slow = branch(), fast = branch();
|
|
for (let i = 0; i < 12; i++) await ledger.submit(await extend(slow, alice, [], CONSENSUS.targetSeconds));
|
|
for (let i = 0; i < 10; i++) {
|
|
const result = await ledger.submit(await extend(fast, bob, [], 1));
|
|
assert.equal(result.reorg, false);
|
|
assert.equal(ledger.snapshot().tip, slow.blocks.at(-1)!.hash);
|
|
}
|
|
const hard = await extend(fast, bob, [], 1);
|
|
assert.ok(workFor(hard.target) > workFor(slow.blocks.at(-1)!.target));
|
|
assert.equal(hard.height, 11); // A shorter, harder branch wins over height 12.
|
|
const results = await Promise.all([ledger.submit(hard), ledger.submit(hard), ledger.submit(hard)]);
|
|
assert.deepEqual(results, [{ accepted: true, reorg: true },
|
|
{ accepted: false, reorg: false }, { accepted: false, reorg: false }]);
|
|
assert.equal(ledger.snapshot().tip, hard.hash);
|
|
assert.deepEqual(ledger.snapshot().blocks, fast.blocks);
|
|
assert.equal(ledger.size, 23);
|
|
assert.equal(ledger.snapshot().cumulativeWork, fast.blocks.reduce((n, b) => n + workFor(b.target), 0n).toString());
|
|
const stored = ledger.allBlocks;
|
|
const seen = new Set([GENESIS_HASH]);
|
|
for (const b of stored) { assert.ok(seen.has(b.previousHash)); seen.add(b.hash); }
|
|
assert.deepEqual(ledger.inventory(10, 3), stored.slice(10, 13));
|
|
const restart = await Ledger.open(dir, new FakePow());
|
|
t.after(async () => { restart.close(); });
|
|
assert.deepEqual(restart.snapshot(), ledger.snapshot());
|
|
assert.deepEqual(restart.allBlocks, stored);
|
|
});
|
|
|
|
test('equal-work ties preserve the current tip across restart and only extension triggers reorg', async t => {
|
|
const { dir, ledger } = await fixture(t);
|
|
const a = branch(), b = branch();
|
|
const a1 = await extend(a), b1 = await extend(b, bob);
|
|
await ledger.submit(a1);
|
|
assert.deepEqual(await ledger.submit(b1), { accepted: true, reorg: false });
|
|
assert.equal(ledger.snapshot().tip, a1.hash);
|
|
const restart = await Ledger.open(dir, new FakePow());
|
|
t.after(async () => { restart.close(); });
|
|
assert.equal(restart.snapshot().tip, a1.hash);
|
|
const b2 = await extend(b, bob);
|
|
assert.deepEqual(await restart.submit(b2), { accepted: true, reorg: true });
|
|
assert.equal(restart.snapshot().tip, b2.hash);
|
|
});
|
|
|
|
test('NFT maturity, ordered mempool, block commitments and replay protection', async t => {
|
|
const { ledger, dir } = await fixture(t);
|
|
const chain = branch();
|
|
const minted = await extend(chain);
|
|
await ledger.submit(minted);
|
|
const tx = transfer(minted.tokenId);
|
|
await assert.rejects(ledger.submitTransfer(tx), /immature/);
|
|
await assert.rejects(async () => ledger.submit(await extend({ blocks: [...chain.blocks], state: structuredClone(chain.state) }, alice, [tx])), /immature/);
|
|
await ledger.submit(await extend(chain));
|
|
assert.equal(await ledger.submitTransfer(tx), transferId(tx));
|
|
const tx2 = transfer(minted.tokenId, bob, carol, 1);
|
|
await Promise.all([ledger.submitTransfer(tx2), ledger.submitTransfer(tx)]);
|
|
assert.deepEqual(ledger.snapshot().pendingTransfers, [tx, tx2]);
|
|
assert.equal(testnetOwnerOf(ledger.snapshot(), minted.tokenId), alice.owner); // Unconfirmed is not ownership.
|
|
const restarted = await Ledger.open(dir, new FakePow());
|
|
t.after(async () => { restarted.close(); });
|
|
assert.deepEqual(restarted.snapshot().pendingTransfers, [tx, tx2]);
|
|
const block = await mine(restarted.template(alice.owner, alice.publicKey,
|
|
articleFor(restarted.snapshot().blocks), statsSnapshot()));
|
|
assert.deepEqual(block.transactions, [tx, tx2]);
|
|
await restarted.submit(block);
|
|
assert.equal(testnetOwnerOf(restarted.snapshot(), minted.tokenId), carol.owner);
|
|
assert.deepEqual(restarted.snapshot().transfers, [tx, tx2]);
|
|
assert.deepEqual(restarted.snapshot().pendingTransfers, []);
|
|
await assert.rejects(restarted.submitTransfer(tx), /owner\/sequence/);
|
|
await assert.rejects(restarted.submitTransfer(transfer(minted.tokenId, carol, alice, 0)), /owner\/sequence/);
|
|
});
|
|
|
|
test('reorg rolls back NFT ownership and requeues only still-valid disconnected transfers in order', async t => {
|
|
const { ledger, dir } = await fixture(t);
|
|
const original = branch();
|
|
const minted = await extend(original);
|
|
await ledger.submit(minted);
|
|
await ledger.submit(await extend(original));
|
|
const fork: Branch = structuredClone(original);
|
|
const tx = transfer(minted.tokenId);
|
|
const tx2 = transfer(minted.tokenId, bob, carol, 1);
|
|
const committed = await extend(original, alice, [tx, tx2]);
|
|
await ledger.submit(committed);
|
|
const orphanTx = transfer(committed.tokenId);
|
|
await ledger.submit(await extend(original));
|
|
await ledger.submitTransfer(orphanTx);
|
|
for (let i = 0; i < 3; i++) await ledger.submit(await extend(fork, bob));
|
|
const snapshot = ledger.snapshot();
|
|
assert.equal(snapshot.tip, fork.blocks.at(-1)!.hash);
|
|
assert.equal(testnetOwnerOf(snapshot, minted.tokenId), alice.owner);
|
|
assert.equal(testnetOwnerOf(snapshot, committed.tokenId), undefined);
|
|
assert.deepEqual(snapshot.transfers, []);
|
|
assert.deepEqual(snapshot.pendingTransfers, [tx, tx2]); // Orphan issuance is discarded.
|
|
assert.ok(ledger.block(committed.hash));
|
|
const restored = await Ledger.open(dir, new FakePow());
|
|
t.after(async () => { restored.close(); });
|
|
assert.deepEqual(restored.snapshot(), snapshot);
|
|
const next = await mine(ledger.template(bob.owner, bob.publicKey,
|
|
articleFor(ledger.snapshot().blocks), statsSnapshot()), bob);
|
|
await ledger.submit(next);
|
|
assert.equal(testnetOwnerOf(ledger.snapshot(), minted.tokenId), carol.owner);
|
|
});
|
|
|
|
test('reorg removes mempool conflicts and does not requeue already committed transfers', async t => {
|
|
const { ledger } = await fixture(t);
|
|
const a = branch();
|
|
const minted = await extend(a);
|
|
await ledger.submit(minted);
|
|
await ledger.submit(await extend(a));
|
|
const b: Branch = structuredClone(a);
|
|
const tx = transfer(minted.tokenId);
|
|
await ledger.submit(await extend(a, alice, [tx]));
|
|
await ledger.submitTransfer(transfer(minted.tokenId, bob, alice, 1));
|
|
await ledger.submit(await extend(b, bob, [tx, transfer(minted.tokenId, bob, carol, 1)]));
|
|
await ledger.submit(await extend(b, bob));
|
|
assert.deepEqual(ledger.snapshot().pendingTransfers, []);
|
|
assert.equal(testnetOwnerOf(ledger.snapshot(), minted.tokenId), carol.owner);
|
|
});
|
|
|
|
test('invalid parent, manifest, proof, article/stat snapshots, and commitments leave ledger unchanged', async t => {
|
|
const { ledger } = await fixture(t);
|
|
const valid = await extend(branch());
|
|
assert.notEqual(mintId(valid), mintId({ ...valid,
|
|
statsSnapshot: { ...valid.statsSnapshot, capturedAt: '2026-10-19T00:00:01.000Z' } }));
|
|
for (const changed of [ { previousHash: 'aa'.repeat(32) }, { chainId: 'bb'.repeat(32) },
|
|
{ network: 'another-network' }, { article: { ...valid.article, extract: 'tampered snapshot' } },
|
|
{ article: { ...valid.article, image: 'https://evil.example/image.jpg' } },
|
|
{ article: { ...valid.article, image: undefined } },
|
|
{ statsSnapshot: { ...valid.statsSnapshot, capturedAt: '2026-10-19T00:00:01.000Z' } },
|
|
{ statsSnapshot: { ...valid.statsSnapshot, stats: { ...valid.statsSnapshot.stats, depth: 0 } } },
|
|
{ stateRoot: '00'.repeat(32) }, { transactionsRoot: '00'.repeat(32) },
|
|
{ tokenId: '00'.repeat(32) }, { powHash: '00'.repeat(32) }, { nonce: valid.nonce + 1 },
|
|
{ signature: bob.sign(blockClaimMessage(valid.hash)) } ]) {
|
|
await assert.rejects(ledger.submit({ ...valid, ...changed }));
|
|
assert.equal(ledger.size, 0);
|
|
}
|
|
await assert.rejects(ledger.submitTransfer({ ...transfer('aa'.repeat(32)), chainId: 'bb'.repeat(32) }));
|
|
await ledger.submit(valid); // Queue recovers from failed validations.
|
|
const repeated = await mine(ledger.template(alice.owner, alice.publicKey, valid.article, valid.statsSnapshot));
|
|
assert.equal(repeated.article.pageId, valid.article.pageId);
|
|
assert.notEqual(repeated.tokenId, valid.tokenId);
|
|
await ledger.submit(repeated);
|
|
assert.equal(ledger.size, 2);
|
|
});
|
|
|
|
test('block state is stored as a one-entry delta that shares its parent state layer', async () => {
|
|
const parent: NftState = Object.assign(Object.create(null) as NftState, {
|
|
['a'.repeat(64)]: { owner: alice.owner, sequence: 0, mintedHeight: 1, pageId: 50_000 },
|
|
});
|
|
const chain: Branch = { blocks: [], state: parent };
|
|
await extend(chain);
|
|
const layer = chain.state as NftStateLayer;
|
|
assert.equal(layer.kind, 'layer');
|
|
assert.equal(layer.parent, parent);
|
|
assert.equal(Object.keys(layer.changes).length, 1);
|
|
});
|
|
|
|
test('SQLite startup rechecks every proof and fails closed on database corruption or manifest mismatch', async t => {
|
|
const { dir, ledger, file } = await fixture(t);
|
|
const chain = branch();
|
|
await ledger.submit(await extend(chain));
|
|
await ledger.submit(await extend(chain));
|
|
const checking = new FakePow();
|
|
const verified = await Ledger.open(dir, checking);
|
|
t.after(async () => { verified.close(); });
|
|
assert.equal(checking.calls, 2);
|
|
const originalDb = new DatabaseSync(file);
|
|
const row = originalDb.prepare('SELECT hash, block_json FROM blocks ORDER BY sequence LIMIT 1').get() as { hash: string; block_json: string };
|
|
const originalBlock = JSON.parse(row.block_json) as TestnetBlock;
|
|
originalDb.close();
|
|
verified.close();
|
|
ledger.close();
|
|
|
|
const malformed = new DatabaseSync(file);
|
|
malformed.prepare('UPDATE blocks SET block_json = ? WHERE hash = ?').run('{truncated', row.hash);
|
|
malformed.close();
|
|
await assert.rejects(Ledger.open(dir, new FakePow()));
|
|
const restoreMalformed = new DatabaseSync(file);
|
|
restoreMalformed.prepare('UPDATE blocks SET block_json = ? WHERE hash = ?').run(row.block_json, row.hash);
|
|
restoreMalformed.close();
|
|
|
|
const tampered = new DatabaseSync(file);
|
|
tampered.prepare('UPDATE blocks SET block_json = ? WHERE hash = ?')
|
|
.run(JSON.stringify({ ...originalBlock, stateRoot: '00'.repeat(32) }), row.hash);
|
|
tampered.close();
|
|
await assert.rejects(Ledger.open(dir, new FakePow()), /state commitment/);
|
|
const restoreTampered = new DatabaseSync(file);
|
|
restoreTampered.prepare('UPDATE blocks SET block_json = ? WHERE hash = ?').run(row.block_json, row.hash);
|
|
restoreTampered.prepare("UPDATE metadata SET value = 'wrong' WHERE key = 'network'").run();
|
|
restoreTampered.close();
|
|
await assert.rejects(Ledger.open(dir, new FakePow()), /mismatch/);
|
|
const restoreManifest = new DatabaseSync(file);
|
|
restoreManifest.prepare('UPDATE metadata SET value = ? WHERE key = ?').run(NETWORK, 'network');
|
|
restoreManifest.close();
|
|
await assert.rejects(Ledger.open(dir, { hash: async () => 'ff'.repeat(32), close() {} }), /RandomX proof/);
|
|
});
|
|
|
|
test('failed SQLite transaction never commits memory, and later mutations recover', async t => {
|
|
const { ledger, file } = await fixture(t);
|
|
const database = new DatabaseSync(file);
|
|
database.exec("CREATE TRIGGER reject_block BEFORE INSERT ON blocks BEGIN SELECT RAISE(ABORT, 'test persistence failure'); END;");
|
|
database.close();
|
|
const valid = await extend(branch());
|
|
await assert.rejects(ledger.submit(valid));
|
|
assert.equal(ledger.size, 0);
|
|
assert.equal(ledger.snapshot().tip, GENESIS_HASH);
|
|
const removeTrigger = new DatabaseSync(file);
|
|
removeTrigger.exec('DROP TRIGGER reject_block');
|
|
removeTrigger.close();
|
|
await ledger.submit(valid);
|
|
assert.equal(ledger.size, 1);
|
|
});
|
|
|
|
test('legacy JSON ledger imports once into SQLite without altering its source file', async t => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'wikideck-legacy-ledger-'));
|
|
const block = await extend(branch());
|
|
const original = { version: 1, network: NETWORK, chainId: CHAIN_ID, blocks: [block], pendingTransfers: [] };
|
|
const legacyFile = join(dir, 'testnet', 'ledger.json');
|
|
await mkdir(join(dir, 'testnet'), { recursive: true });
|
|
await writeFile(legacyFile, JSON.stringify(original));
|
|
const ledger = await Ledger.open(dir, new FakePow());
|
|
t.after(async () => { ledger.close(); await rm(dir, { recursive: true, force: true }); });
|
|
assert.deepEqual(ledger.snapshot().blocks, [block]);
|
|
assert.deepEqual(JSON.parse(await readFile(legacyFile, 'utf8')), original);
|
|
const database = new DatabaseSync(join(dir, 'testnet', 'ledger.sqlite'));
|
|
assert.equal((database.prepare("SELECT value FROM metadata WHERE key = 'legacy_json_migrated'").get() as { value: string }).value, '1');
|
|
database.close();
|
|
});
|
|
|
|
test('mempool is ordered, capped at 256, and templates include at most 64 without new issuance', async t => {
|
|
const { ledger, dir } = await fixture(t);
|
|
const chain = branch();
|
|
const minted = await extend(chain);
|
|
await ledger.submit(minted);
|
|
await ledger.submit(await extend(chain));
|
|
const transactions = Array.from({ length: 256 }, (_, sequence) => transfer(minted.tokenId,
|
|
sequence % 2 === 0 ? alice : bob, sequence % 2 === 0 ? bob : alice, sequence));
|
|
await Promise.all(transactions.map(tx => ledger.submitTransfer(tx)));
|
|
assert.deepEqual(ledger.snapshot().pendingTransfers, transactions);
|
|
await assert.rejects(ledger.submitTransfer(transfer(minted.tokenId, alice, bob, 256)), /256/);
|
|
const template = ledger.template(alice.owner, alice.publicKey,
|
|
articleFor(ledger.snapshot().blocks), statsSnapshot());
|
|
assert.deepEqual(template.transactions, transactions.slice(0, 64));
|
|
await assert.rejects(ledger.submitTransfer(transfer(mintId(template.header))), /256/);
|
|
const block = await mine(template);
|
|
await ledger.submit(block);
|
|
assert.deepEqual(ledger.snapshot().pendingTransfers, transactions.slice(64));
|
|
await assert.rejects(ledger.submitTransfer(transfer(block.tokenId)), /immature/);
|
|
const restarted = await Ledger.open(dir, new FakePow());
|
|
t.after(async () => { restarted.close(); });
|
|
assert.deepEqual(restarted.snapshot(), ledger.snapshot());
|
|
});
|
|
|
|
async function server(t: { after: (fn: () => Promise<void>) => void },
|
|
handler: (req: IncomingMessage, res: ServerResponse) => void) {
|
|
const instance = createServer(handler);
|
|
await new Promise<void>(resolve => instance.listen(0, '127.0.0.1', resolve));
|
|
t.after(() => new Promise<void>((resolve, reject) => {
|
|
instance.close(error => error ? reject(error) : resolve()); instance.closeAllConnections();
|
|
}));
|
|
const addr = instance.address();
|
|
assert.ok(addr && typeof addr !== 'string');
|
|
return `http://127.0.0.1:${addr.port}`;
|
|
}
|
|
function json(res: ServerResponse, body: unknown) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)); }
|
|
async function until(check: () => boolean) {
|
|
const deadline = Date.now() + 5000;
|
|
while (!check()) { if (Date.now() > deadline) assert.fail('Peer operation timed out'); await delay(10); }
|
|
}
|
|
|
|
test('peer configuration rejects unsafe origins; explicit private permission enables local sync', async t => {
|
|
const { ledger } = await fixture(t);
|
|
for (const url of ['http://example.com', 'https://localhost', 'https://foo.localhost',
|
|
'https://127.1', 'https://10.1.2.3', 'https://169.254.169.254', 'https://[::1]',
|
|
'https://[::ffff:127.0.0.1]', 'https://[fc00::1]', 'https://[fe80::1]',
|
|
'https://[2002:7f00:1::]', 'https://example.com/path', 'https://user:pass@example.com',
|
|
'https://example.com/?q=1', 'https://example.com/#fragment', 'https://example.com/?',
|
|
'https://example.com/#', 'https://example.com/path/..', 'https://@example.com', 'ftp://example.com']) {
|
|
assert.throws(() => new PeerNetwork({ ledger, urls: [url], allowPrivate: false }), url);
|
|
}
|
|
const chain = branch();
|
|
const b = await extend(chain);
|
|
const paths: string[] = [];
|
|
const url = await server(t, (req, res) => {
|
|
paths.push(req.url!);
|
|
if (req.url === '/p2p/status') json(res, { network: NETWORK, chainId: CHAIN_ID, size: 1 });
|
|
else if (req.url === '/p2p/blocks?offset=0') json(res, { blocks: [b], next: null });
|
|
else if (req.url === '/p2p/mempool') json(res, { transactions: [] });
|
|
else json(res, { accepted: true });
|
|
});
|
|
const network = new PeerNetwork({ ledger, urls: [url], allowPrivate: true });
|
|
t.after(async () => network.close());
|
|
network.start(); network.start();
|
|
await until(() => network.status()[0].connected);
|
|
assert.equal(ledger.size, 1);
|
|
assert.equal(ledger.snapshot().peers, 1);
|
|
await network.broadcastBlock(b);
|
|
assert.ok(paths.includes('/p2p/block'));
|
|
await assert.rejects(network.broadcastTransfer(transfer(b.tokenId)), /unaccepted/);
|
|
network.close();
|
|
assert.equal(ledger.snapshot().peers, 0);
|
|
});
|
|
|
|
|
|
test('unknown SQLite files are rejected without adding or replacing tables', async t => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'wikideck-foreign-sqlite-'));
|
|
const file = join(dir, 'testnet', 'ledger.sqlite');
|
|
await mkdir(join(dir, 'testnet'), { recursive: true });
|
|
const foreign = new DatabaseSync(file);
|
|
foreign.exec('CREATE TABLE operator_data(value TEXT NOT NULL); INSERT INTO operator_data VALUES(\'keep\');');
|
|
foreign.close();
|
|
t.after(async () => { await rm(dir, { recursive: true, force: true }); });
|
|
await assert.rejects(Ledger.open(dir, new FakePow()), /metadata missing/);
|
|
const verify = new DatabaseSync(file);
|
|
assert.deepEqual((verify.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all() as { name: string }[])
|
|
.map(row => row.name), ['operator_data']);
|
|
assert.equal((verify.prepare('SELECT value FROM operator_data').get() as { value: string }).value, 'keep');
|
|
verify.close();
|
|
});
|
|
test('peers verify DNS, manifests, pagination, payloads and consensus; redirects are not followed', async t => {
|
|
const { ledger } = await fixture(t);
|
|
const chain = branch();
|
|
const b = await extend(chain);
|
|
let redirected = false;
|
|
for (const mode of ['manifest', 'pagination', 'proof', 'oversized', 'redirect'] as const) {
|
|
const url = await server(t, (req, res) => {
|
|
if (req.url === '/redirect-target') { redirected = true; json(res, {}); return; }
|
|
if (mode === 'redirect') { res.writeHead(302, { location: '/redirect-target' }); res.end(); return; }
|
|
if (mode === 'oversized') { res.setHeader('content-type', 'application/json'); res.end(' '.repeat(2 * 1024 * 1024 + 1)); return; }
|
|
if (req.url === '/p2p/status') json(res, { network: NETWORK, chainId: mode === 'manifest' ? 'bad' : CHAIN_ID, size: 1 });
|
|
else json(res, mode === 'pagination' ? { blocks: [b], next: 0 } : { blocks: [{ ...b, powHash: '00'.repeat(32) }], next: null });
|
|
});
|
|
const network = new PeerNetwork({ ledger, urls: [url], allowPrivate: true });
|
|
network.start();
|
|
await until(() => Boolean(network.status()[0].error));
|
|
assert.equal(network.status()[0].connected, false);
|
|
assert.equal(ledger.size, 0);
|
|
network.close();
|
|
}
|
|
assert.equal(redirected, false);
|
|
// Deterministic DNS rebinding defense test: no external resolver/network dependency.
|
|
const lookupMock = t.mock.method(dns, 'lookup', async () => [
|
|
{ address: '93.184.216.34', family: 4 }, { address: '127.0.0.1', family: 4 },
|
|
]);
|
|
const dnsNetwork = new PeerNetwork({ ledger, urls: ['https://peer.example'], allowPrivate: false });
|
|
t.after(async () => dnsNetwork.close());
|
|
dnsNetwork.start();
|
|
await until(() => Boolean(dnsNetwork.status()[0].error));
|
|
assert.equal(dnsNetwork.status()[0].connected, false);
|
|
assert.match(dnsNetwork.status()[0].error!, /non-public/);
|
|
lookupMock.mock.restore();
|
|
});
|
|
|
|
test('peer sync pages at most 32 blocks per pass, preserves cursor and skips known blocks', async t => {
|
|
const { ledger, engine } = await fixture(t);
|
|
const chain = branch();
|
|
for (let i = 0; i < 33; i++) await extend(chain, alice, [], 30);
|
|
await ledger.submit(chain.blocks[0]);
|
|
const offsets: number[] = [];
|
|
const url = await server(t, (req, res) => {
|
|
if (req.url === '/p2p/status') json(res, { network: NETWORK, chainId: CHAIN_ID, size: chain.blocks.length });
|
|
else if (req.url === '/p2p/mempool') json(res, { transactions: [] });
|
|
else {
|
|
const offset = Number(new URL(req.url!, 'http://localhost').searchParams.get('offset'));
|
|
offsets.push(offset);
|
|
const blocks = chain.blocks.slice(offset, offset + 32);
|
|
json(res, { blocks, next: offset + blocks.length < chain.blocks.length ? offset + blocks.length : null });
|
|
}
|
|
});
|
|
const network = new PeerNetwork({ ledger, urls: [url], allowPrivate: true, intervalMs: 1000 });
|
|
t.after(async () => network.close());
|
|
network.start();
|
|
await until(() => network.status()[0].connected);
|
|
assert.equal(ledger.size, 32);
|
|
assert.deepEqual(offsets, [0]);
|
|
await until(() => ledger.size === 33);
|
|
assert.deepEqual(offsets, [0, 32]);
|
|
assert.equal(engine.calls, 33); // The already-known first block was not revalidated.
|
|
assert.deepEqual(ledger.snapshot().blocks, chain.blocks);
|
|
assert.equal(CONSENSUS.maxTransactions, 64);
|
|
});
|