Working version Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
2026-11-01 22:07:51 +00:00
parent 1edf2e6200
commit 636f5b9c77
80 changed files with 4065 additions and 549 deletions
+52 -9
View File
@@ -7,12 +7,16 @@ import { tmpdir } from 'node:os';
import { delimiter, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { generateKeyPairSync, sign } from 'node:crypto';
import { DatabaseSync } from 'node:sqlite';
import { setTimeout as delay } from 'node:timers/promises';
import { RandomX } from '../server/testnet/randomx.ts';
import { address, CHAIN_ID, GENESIS_HASH, MAX_TARGET, miningBlob, mintId, sha256,
transferId, workFor } from '../server/testnet/consensus.ts';
import { NETWORK, CONSENSUS, blockClaimMessage, nftTransferMessage,
type MiningTemplate, type NftTransfer, type TestnetBlock, type TestnetSnapshot } from '../shared/testnet.ts';
import { StateClient } from '../shared/rpc-client.ts';
import { createBackup,restoreBackup } from '../server/recovery.ts';
import { Ledger } from '../server/testnet/ledger.ts';
const root = fileURLToPath(new URL('../', import.meta.url));
const wikipediaMock = resolve(root, 'tests', 'mock-wikipedia.mjs');
@@ -80,7 +84,8 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
env: { ...process.env, HOST: '127.0.0.1', PORT: '0', NODE_ENV: 'production',
WIKIDECK_DATA_DIR: dataDir, RANDOMX_BINARY: binary,
WIKIDECK_PEERS: peer ?? '', WIKIDECK_ALLOW_PRIVATE_PEERS: peer ? '1' : '0',
PUBLIC_ORIGIN: 'http://127.0.0.1:3001' },
PUBLIC_ORIGIN: 'http://127.0.0.1:3001',
WIKIDECK_RPC_WRITES_PER_SECOND: '50' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
@@ -116,11 +121,11 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
async function request(base: string, path: string, expected = 200, body?: unknown,
headers: Record<string, string> = {}): Promise<Response> {
const response = await fetch(`${base}${path}`, {
method: body === undefined ? 'GET' : 'POST', redirect: 'error',
signal: AbortSignal.any([t.signal, AbortSignal.timeout(30_000)]),
headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
method: body === undefined ? 'GET' : 'POST', redirect: 'error',
signal: AbortSignal.any([t.signal, AbortSignal.timeout(30_000)]),
headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (response.status !== expected) {
assert.fail(`${path}: expected ${expected}, got ${response.status}: ${await response.text()}`);
}
@@ -171,13 +176,23 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
assert.deepEqual(await json(base, '/api/blocks', 201, block), { accepted: true, reorg: false });
assert.deepEqual(await json(base, '/api/blocks', 200, block), { accepted: false, reorg: false });
}
const persisted = (dir: string) => readFile(join(dir, 'testnet', 'ledger.json'), 'utf8');
const persisted = (dir: string) => {
const db = new DatabaseSync(join(dir, 'testnet', 'ledger.sqlite'));
try {
return JSON.stringify({
blocks: db.prepare('SELECT hash, previous_hash, block_json FROM blocks ORDER BY sequence').all(),
pending: db.prepare('SELECT position, id, transaction_json FROM pending_transfers ORDER BY position').all(),
});
} finally { db.close(); }
};
try {
engine = new RandomX({ binary }); // Always the actual reference RandomX engine, never a test double.
const alice = wallet(), bob = wallet();
assert.notEqual(alice.owner, bob.owner);
let first = await start(firstDir);
await assert.rejects(start(firstDir), /Data directory locked/,
'a second server process must fail closed rather than run a second writer on the same data directory');
const health = await request(first.base, '/healthz');
assert.deepEqual(await health.json(), { status: 'ok', network: NETWORK });
assert.equal(health.headers.get('cache-control'), 'no-store');
@@ -186,6 +201,10 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
await (await request(first.base, '/api/wikipedia/not-a-page', 400)).text();
await (await request(first.base, '/api/wikipedia/9999999999', 503)).text();
const initial = await snapshot(first.base);
const pagedClient=new StateClient(((path: string,init?: RequestInit)=>fetch(new URL(path,first.base),init)) as typeof fetch);
assert.deepEqual(await pagedClient.load(),initial);
await (await request(first.base,'/api/canonical?tip=bad',400)).text();
await (await request(first.base,`/api/canonical?tip=${GENESIS_HASH}&limit=17`,400)).text();
assert.equal(initial.network, NETWORK);
assert.equal(initial.chainId, CHAIN_ID);
assert.equal(initial.genesis, GENESIS_HASH);
@@ -199,6 +218,8 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
await (await request(first.base, '/api/mining/template', 400, {})).text();
await (await request(first.base, '/api/blocks', 400, {})).text();
await (await request(first.base, '/api/transfer', 400, {})).text();
await (await request(first.base, '/api/trades', 400, {})).text();
await (await request(first.base, `/api/decks/${'x'.repeat(64)}`, 400)).text();
await (await request(first.base, '/api/mining/template', 403, {}, { Origin: 'https://evil.example' })).text();
await (await request(first.base, '/api/mining/template', 415, {}, { 'Content-Type': 'text/plain' })).text();
const malformed = await fetch(`${first.base}/api/blocks`, { method: 'POST',
@@ -206,10 +227,14 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
signal: AbortSignal.any([t.signal, AbortSignal.timeout(5000)]) });
assert.equal(malformed.status, 400);
assert.deepEqual(await malformed.json(), { error: 'Invalid request encoding' });
for (const path of ['/api/missing', '/p2p/missing', `/api/nfts/${'0'.repeat(64)}`]) {
for (const path of ['/api/missing', '/api/market', '/api/custody', '/api/monero/deposit-config',
'/p2p/missing', `/api/nfts/${'0'.repeat(64)}`]) {
await (await request(first.base, path, 404)).text();
}
for (const offset of ['-1', '1.5', '10001', 'nope']) {
for (const path of ['/api/market/command', '/api/custody/command', '/api/monero/deposit-address']) {
await (await request(first.base, path, 404, {})).text();
}
for (const offset of ['-1', '1.5', '9007199254740992', 'nope']) {
await (await request(first.base, `/p2p/blocks?offset=${offset}`, 400)).text();
}
@@ -231,6 +256,17 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
const block2 = await mine(first.base, alice);
assert.equal(block2.height, 2);
await submit(first.base, block2);
const aliceDeck = await json<{ address: string; tip: string; total: number; nfts: { block: TestnetBlock }[] }>(
first.base, `/api/decks/${alice.owner}`);
assert.equal(aliceDeck.address, alice.owner);
assert.equal(aliceDeck.tip, block2.hash);
assert.equal(aliceDeck.total, 2);
assert.deepEqual(aliceDeck.nfts.map(item => item.block.tokenId), [block1.tokenId, block2.tokenId]);
assert.equal((await json<{ owner: string; sequence: number }>(first.base, `/api/nfts/${block1.tokenId}`)).owner, alice.owner);
await request(first.base, '/api/nfts/not-a-token', 400);
await request(first.base, `/api/decks/${alice.owner}?tip=${block1.hash}`, 409);
assert.equal((await json<{ total: number }>(first.base, `/api/decks/${alice.owner}?tip=${block2.hash}&limit=1`)).total, 2);
await (await request(first.base, `/api/decks/${'z'.repeat(64)}`, 400)).text();
// A truly independent process reconstructs the canonical chain over HTTP and checks real work.
const second = await start(secondDir, first.base);
@@ -311,8 +347,15 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
assert.equal(first.child.exitCode, 0, first.output());
assert.equal(existsSync(join(firstDir, 'testnet-node.lock')), false);
}
const backupDir=join(directory,'stopped-pair-backup'),drillDir=join(directory,'isolated-restore-drill');
const backupManifest = await createBackup(firstDir,backupDir);await restoreBackup(backupDir,drillDir);
const drillLedger=await Ledger.open(drillDir,engine!); // Revalidate real RandomX proofs from the copied backup.
try {
assert.deepEqual(drillLedger.snapshot(),{...canonical,peers:0});
}finally{drillLedger.close();}
first = await start(firstDir); // Ledger.open revalidates every persisted block using native RandomX.
const restored = await snapshot(first.base);
assert.deepEqual(await pagedClient.load(),restored);
assert.deepEqual(restored, { ...canonical, peers: 0 });
assert.deepEqual(await json(first.base, `/api/nfts/${block1.tokenId}`), {
network: NETWORK, chainId: CHAIN_ID, tokenId: block1.tokenId, owner: bob.owner, sequence: 1,
+16
View File
@@ -0,0 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
test('original W branding is restored and shared by favicon and image placeholders', async () => {
const read = (file: string) => readFile(new URL(`../${file}`, import.meta.url), 'utf8');
const [html, app, card, icon] = await Promise.all([
read('index.html'), read('src/App.tsx'), read('src/Card.tsx'), read('public/original-logo.svg'),
]);
assert.match(html, /rel="icon" type="image\/svg\+xml" href="\/original-logo.svg"/);
assert.match(app, /className="brand-mark" aria-hidden="true">W/);
assert.doesNotMatch(app + card + html, /Wikideck\.png/);
assert.equal((icon.match(/<svg\b/g) ?? []).length, 1);
assert.match(icon, /viewBox="0 0 64 64"/);
assert.match(icon, /fill="#d4f77d"/);
});
+56
View File
@@ -0,0 +1,56 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { CONSENSUS } from '../shared/testnet.ts';
import { MAX_TARGET, workFor } from '../server/testnet/consensus.ts';
import { calibrationReport, expectedHashes, idealCatchup, simulate, targetForHashrate } from '../server/analysis/calibration.ts';
test('calibration math uses inclusive target probability and bounded explicit hashrate assumptions',()=>{
assert.equal(expectedHashes(MAX_TARGET),16);
assert.equal(workFor(MAX_TARGET),16n);
assert.equal(targetForHashrate(1),((1n<<256n)/900n-1n).toString(16).padStart(64,'0'));
assert.ok(Math.abs(expectedHashes(targetForHashrate(100))-90000)<1e-8);
assert.equal(targetForHashrate(1,1),MAX_TARGET);
for(const value of [0,-1,NaN,Infinity,1.5,1e15])assert.throws(()=>targetForHashrate(value));
assert.throws(()=>expectedHashes('bad'));
});
test('deterministic traces preserve exact retarget boundaries and show asymmetric stall recovery',()=>{
const steady=simulate('steady'),rapid=simulate('rapid'),slow=simulate('slow'),stall=simulate('stall');
assert.deepEqual(simulate('rapid'),rapid);
assert.ok(steady.every(p=>p.target===MAX_TARGET));
assert.equal(rapid[8].nextTarget,MAX_TARGET);
assert.equal(BigInt(`0x${rapid[9].nextTarget}`),BigInt(`0x${MAX_TARGET}`)/4n);
assert.equal(rapid[10].target,rapid[9].nextTarget);
assert.ok(slow.every(p=>p.target===MAX_TARGET)); // Cannot ease above MAX_TARGET.
assert.equal(stall[20].wallTime-stall[19].wallTime,7*86400);
assert.equal(stall[20].target,MAX_TARGET); // No emergency wall-clock difficulty decay.
assert.equal(stall[20].nextTarget,MAX_TARGET);
assert.ok(BigInt(rapid.at(-1)!.cumulativeWork)>BigInt(steady.at(-1)!.cumulativeWork));
assert.throws(()=>simulate('rapid',1001));
});
test('median-minimum and future-edge traces are timestamp-policy inputs, not valid mined chains',()=>{
const minimum=simulate('median-minimum'),future=simulate('future-edge'),steady=simulate('steady');
assert.ok(minimum.at(-1)!.timestamp<steady.at(-1)!.timestamp);
assert.ok(BigInt(`0x${minimum.at(-1)!.target}`)<BigInt(`0x${MAX_TARGET}`));
assert.ok(future.every(p=>p.timestamp-p.wallTime===CONSENSUS.maxFutureSeconds));
assert.equal(future[9].nextTarget,MAX_TARGET); // Constant timestamp offset cancels in retarget differences.
const report=calibrationReport();assert.equal(report.consensusChanged,false);
assert.match(report.scope,/no valid blocks/i);assert.match(report.decision,/NO-GO/);
});
test('ideal catchup probabilities are explicitly conditioned on known current deficit',()=>{
assert.equal(idealCatchup(.5,10),1);assert.equal(idealCatchup(.9,10),1);
assert.equal(idealCatchup(.25,0),1);assert.equal(idealCatchup(0,10),0);
assert.ok(Math.abs(idealCatchup(.25,10)-(1/3)**10)<1e-15);
assert.ok(idealCatchup(.4,10)>idealCatchup(.25,10));
for(const q of [-1,NaN,Infinity,1.1])assert.throws(()=>idealCatchup(q,10));
});
test('analysis CLI is offline and benchmark refuses CPU work without consent',()=>{
const output=execFileSync(process.execPath,['--experimental-strip-types','scripts/consensus-analysis.ts'],{encoding:'utf8',timeout:30000});
assert.equal(JSON.parse(output).initialExpectedHashes,16);
const denied=spawnSync(process.execPath,['--experimental-strip-types','scripts/benchmark-randomx.ts'],{
encoding:'utf8',timeout:30000,env:{...process.env,RANDOMX_BINARY:'/nonexistent-no-engine-must-start'}});
assert.notEqual(denied.status,0);assert.match(denied.stderr,/consent required/);
const bad=spawnSync(process.execPath,['--experimental-strip-types','scripts/benchmark-randomx.ts','--consent','--hashes','1000000'],{
encoding:'utf8',timeout:30000,env:{...process.env,RANDOMX_BINARY:'/nonexistent'}});
assert.notEqual(bad.status,0);assert.match(bad.stderr,/Invalid --hashes/);
});
+166
View File
@@ -0,0 +1,166 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { NETWORK, CONSENSUS } from '../shared/testnet.ts';
import { networkProfile } from '../shared/network.ts';
import { CHAIN_ID, GENESIS_TIME, GENESIS_HASH, MAX_TARGET } from '../server/testnet/consensus.ts';
function script(code: string, env: Record<string,string> = {}) {
return execFileSync(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], {
env: { ...process.env, WIKIDECK_NETWORK: 'mainnet', WIKIDECK_GENESIS_TIME: '1793491200', ...env }, encoding: 'utf8', timeout: 30000,
}).trim();
}
test('default profile is mainnet while testnet remains an explicit isolated profile', () => {
const hash = (text: string) => createHash('sha256').update(text).digest('hex');
assert.equal(NETWORK, 'wikideck-randomx-testnet-6');
assert.equal(GENESIS_TIME, 1_792_411_200);
assert.equal(CHAIN_ID, hash(JSON.stringify(['wikideck-randomx-testnet-6', CONSENSUS, 1_792_411_200, MAX_TARGET])));
assert.equal(networkProfile(undefined), 'mainnet');
assert.equal(networkProfile('testnet'), 'testnet');
assert.throws(() => networkProfile('production')); assert.throws(() => networkProfile(''));
});
test('mainnet candidate has distinct deterministic genesis, signatures and strict transaction schemas', () => {
const output = JSON.parse(script(`
import { NETWORK, nftTransferMessage } from './shared/testnet.ts';
import { CHAIN_ID, GENESIS_HASH, transferSchema } from './server/testnet/consensus.ts';
console.log(JSON.stringify({ NETWORK, CHAIN_ID, GENESIS_HASH,
message:nftTransferMessage({chainId:CHAIN_ID,cardHash:'a'.repeat(64),from:'b'.repeat(64),to:'c'.repeat(64),sequence:0}),
acceptsTestnet:transferSchema.safeParse({network:'wikideck-randomx-testnet-6',chainId:${JSON.stringify(CHAIN_ID)},
cardHash:'a'.repeat(64),from:'b'.repeat(64),to:'c'.repeat(64),sequence:0,publicKey:'AA==',signature:'A'.repeat(86)+'=='}).success }));
`));
assert.equal(output.NETWORK, 'wikideck-randomx-mainnet-1');
assert.notEqual(output.CHAIN_ID, CHAIN_ID); assert.notEqual(output.GENESIS_HASH, GENESIS_HASH);
assert.match(output.message, /wikideck-randomx-mainnet-1/); assert.equal(output.acceptsTestnet, false);
});
test('mainnet startup is blocked before resources open, while manifest inspection needs no launch approval', () => {
const args = ['--experimental-strip-types', 'server/index.ts'];
const env = { ...process.env, WIKIDECK_NETWORK: 'mainnet', WIKIDECK_GENESIS_TIME: '1793491200', WIKIDECK_MAINNET_LAUNCH: '', WIKIDECK_MAINNET_RISK_ACK: '' };
const result = spawnSync(process.execPath, args, { env, encoding: 'utf8', timeout: 30000 });
assert.notEqual(result.status, 0); assert.match(result.stderr, /launch-gated/);
const manifest = JSON.parse(execFileSync(process.execPath, [...args, '--network-manifest'], { env, encoding: 'utf8' }));
assert.equal(manifest.profile, 'mainnet'); assert.equal(manifest.audited, false);
assert.equal(manifest.launchStatus, 'candidate-gated');
assert.equal(manifest.privatePeersSupported, true);
});
test('mainnet gates require genesis time, chain-specific approval, HTTPS browser origin, and distinct peers', () => {
const result = JSON.parse(script(`
import { assertLaunchAllowed } from './server/launch.ts';
import { CHAIN_ID, GENESIS_TIME } from './server/testnet/consensus.ts';
const env={WIKIDECK_MAINNET_LAUNCH:CHAIN_ID,WIKIDECK_MAINNET_RISK_ACK:'operator-accepts-unaudited-risk',
PUBLIC_ORIGIN:'https://wikideck.example',WIKIDECK_PEERS:'https://peer-a.example,https://peer-b.example'};
const results=[];
for(const [changes,now] of [[{},0],[{WIKIDECK_MAINNET_LAUNCH:'wrong'},Infinity],
[{WIKIDECK_MAINNET_RISK_ACK:'not-accepted'},Infinity],
[{PUBLIC_ORIGIN:'http://wikideck.example'},Infinity],[{WIKIDECK_PEERS:''},Infinity],
[{WIKIDECK_PEERS:'https://peer-a.example,https://peer-a.example/'},Infinity],
[{WIKIDECK_ALLOW_PRIVATE_PEERS:'1'},Infinity],[{},Infinity]]) {
try { assertLaunchAllowed({...env,...changes},now); results.push(true); } catch { results.push(false); }
}
console.log(JSON.stringify(results));
`));
assert.deepEqual(result, [false,false,false,false,false,false,true,true]);
});
test('private mainnet cluster peers require explicit opt-in and preserve every other launch gate', () => {
const result = JSON.parse(script(`
import { assertLaunchAllowed } from './server/launch.ts';
import { CHAIN_ID, GENESIS_TIME } from './server/testnet/consensus.ts';
const env={WIKIDECK_MAINNET_LAUNCH:CHAIN_ID,WIKIDECK_MAINNET_RISK_ACK:'operator-accepts-unaudited-risk',
PUBLIC_ORIGIN:'https://wikideck.example',WIKIDECK_ALLOW_PRIVATE_PEERS:'1',
WIKIDECK_PEERS:'http://wikideck-peer-a.wikideck.svc.cluster.local:80,http://wikideck-peer-b.wikideck.svc.cluster.local:80'};
const cases=[[{},GENESIS_TIME*1000], [{},GENESIS_TIME*1000-1],
[{WIKIDECK_ALLOW_PRIVATE_PEERS:'0'},Infinity], [{WIKIDECK_ALLOW_PRIVATE_PEERS:'true'},Infinity],
[{WIKIDECK_MAINNET_LAUNCH:'wrong'},Infinity], [{WIKIDECK_MAINNET_RISK_ACK:''},Infinity],
[{PUBLIC_ORIGIN:'http://wikideck.example'},Infinity], [{WIKIDECK_PEERS:''},Infinity],
[{WIKIDECK_PEERS:'http://peer-a:80,http://peer-a/'},Infinity],
[{WIKIDECK_PEERS:'http://peer-a,http://user:password@peer-b'},Infinity],
[{WIKIDECK_PEERS:'http://peer-a,http://peer-b/p2p'},Infinity],
[{WIKIDECK_PEERS:'http://peer-a,http://peer-b?x=1'},Infinity],
[{WIKIDECK_PEERS:'http://peer-a,https://wikideck.example'},Infinity],
[{WIKIDECK_PEERS:'http://peer-a,ftp://peer-b'},Infinity]];
console.log(JSON.stringify(cases.map(([changes,now])=>{
try {assertLaunchAllowed({...env,...changes},now);return true;}catch{return false;}
})));
`));
assert.deepEqual(result, [true, ...Array(13).fill(false)]);
});
test('default mainnet genesis can launch on November 1 and matches browser, Docker and Compose identities', () => {
const result = JSON.parse(script(`
delete process.env.WIKIDECK_GENESIS_TIME;
delete process.env.WIKIDECK_IMAGE_NETWORK;
delete process.env.WIKIDECK_IMAGE_GENESIS_TIME;
const { assertLaunchAllowed, launchManifest } = await import('./server/launch.ts');
const { expectedChainIdentity } = await import('./shared/chain-identity.ts');
const manifest = launchManifest();
assertLaunchAllowed({WIKIDECK_MAINNET_LAUNCH:manifest.chainId,
WIKIDECK_MAINNET_RISK_ACK:'operator-accepts-unaudited-risk',PUBLIC_ORIGIN:'https://wikideck.eom.dev',
WIKIDECK_ALLOW_PRIVATE_PEERS:'1',
WIKIDECK_PEERS:'http://wikideck-peer-a.wikideck.svc.cluster.local:80,http://wikideck-peer-b.wikideck.svc.cluster.local:80'},
Date.UTC(2026,10,1));
console.log(JSON.stringify({manifest,identity:await expectedChainIdentity()}));
`));
assert.equal(result.manifest.genesisTime, 1_793_491_200);
assert.equal(result.manifest.chainId, result.identity.chainId);
assert.equal(result.manifest.genesis, result.identity.genesis);
const dockerfile = readFileSync(new URL('../Dockerfile', import.meta.url), 'utf8');
assert.equal(dockerfile.match(/^ARG WIKIDECK_GENESIS_TIME=1793491200\r?$/gm)?.length, 2);
for (const name of ['compose.yaml', 'compose.mainnet.yaml']) {
const compose = readFileSync(new URL('../' + name, import.meta.url), 'utf8');
assert.equal(compose.match(/WIKIDECK_GENESIS_TIME:-1793491200/g)?.length, 2);
}
});
test('runtime cannot silently change the image-baked mainnet genesis', () => {
const result = spawnSync(process.execPath, ['--experimental-strip-types', 'server/index.ts', '--network-manifest'], {
env: { ...process.env, WIKIDECK_NETWORK: 'mainnet', WIKIDECK_GENESIS_TIME: '1793491200',
WIKIDECK_IMAGE_NETWORK: 'mainnet', WIKIDECK_IMAGE_GENESIS_TIME: '1798761600' },
encoding: 'utf8', timeout: 30000,
});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Runtime genesis time does not match/);
});
test('mainnet ledger uses its isolated folder; testnet backup headers are rejected', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wikideck-network-'));
try {
const result = JSON.parse(script(`
import { Ledger } from './server/testnet/ledger.ts';
import { decryptWallet } from './shared/wallet-backup.ts';
import { existsSync } from 'node:fs'; import { join } from 'node:path';
const ledger=await Ledger.open(process.env.PROFILE_TEST_DIR,{hash:async()=>{throw Error('unused')},close(){}});
let rejected=false;
try {await decryptWallet({format:'wikideck-wallet-backup',version:1,network:'wikideck-randomx-testnet-6',
kdf:'PBKDF2-SHA256',iterations:600000,cipher:'AES-256-GCM',salt:'',iv:'',publicKey:'',ciphertext:''},'password');}
catch(e){rejected=/network/.test(e.message);}
console.log(JSON.stringify({ledger:existsSync(join(process.env.PROFILE_TEST_DIR,'mainnet','ledger.sqlite')),
testnet:existsSync(join(process.env.PROFILE_TEST_DIR,'testnet')),rejected}));
ledger.close();
`, { PROFILE_TEST_DIR: dir }));
assert.deepEqual(result, { ledger: true, testnet: false, rejected: true });
} finally { await rm(dir, { recursive: true, force: true }); }
});
test('operator preflight is read-only, fails closed, and does not disclose credentials', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wikideck-preflight-'));
try {
const { existsSync } = await import('node:fs');
const dataDir = join(dir, 'must-not-be-created');
const secret = 'test-only-sensitive-value-must-not-appear';
const result = spawnSync(process.execPath, ['--experimental-strip-types', 'server/index.ts', '--launch-preflight'], {
env: { ...process.env, WIKIDECK_NETWORK: 'mainnet', WIKIDECK_GENESIS_TIME: '1793491200', WIKIDECK_DATA_DIR: dataDir,
WIKIDECK_MAINNET_LAUNCH: '', WIKIDECK_MAINNET_RISK_ACK: '', PAYMENT_PROVIDER_API_KEY: secret },
encoding: 'utf8', timeout: 30000,
});
assert.equal(result.status, 1);
const report = JSON.parse(result.stdout);
assert.equal(report.productionReady, false);
assert.equal(report.decision, 'NO-GO');
assert.ok(report.checks.some((c: { id: string; status: string }) => c.id === 'consensus-launch-calibration' && c.status === 'blocked'));
assert.equal(result.stdout.includes(secret), false);
assert.equal(existsSync(dataDir), false);
} finally { await rm(dir, { recursive: true, force: true }); }
});
+48
View File
@@ -0,0 +1,48 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { trustedProxies, operationsPort, startOperations } from '../server/operations.ts';
import express from 'express';
import { once } from 'node:events';
import { createServer } from 'node:net';
import { checkOperations } from '../server/operations-check.ts';
test('proxy trust is explicit literal IP/CIDR only; defaults disabled',()=>{
assert.deepEqual(trustedProxies({}),[]);
assert.deepEqual(trustedProxies({WIKIDECK_TRUSTED_PROXIES:'127.0.0.1,::1,172.20.1.0/24'}),['127.0.0.1','::1','172.20.1.0/24']);
for(const value of ['true','1','loopback','localhost','0.0.0.0/0','::/0','127.0.0.1/33','127.0.0.1,','http://proxy'])assert.throws(()=>trustedProxies({WIKIDECK_TRUSTED_PROXIES:value}));
assert.equal(operationsPort({}),undefined);
for(const value of ['0','-1','65536','x'])assert.throws(()=>operationsPort({WIKIDECK_OPS_PORT:value}));
});
test('untrusted forwarded headers do not alter client IP; explicit loopback proxy is recognized',async t=>{
const app=express();app.get('/ip',(req,res)=>{res.json({ip:req.ip});});
const server=app.listen(0,'127.0.0.1');await once(server,'listening');
t.after(()=>{server.closeAllConnections();server.close();});
const a=server.address();assert.ok(a&&typeof a==='object');const url=`http://127.0.0.1:${a.port}/ip`;
const request=()=>fetch(url,{headers:{'X-Forwarded-For':'203.0.113.12'}}).then(r=>r.json());
assert.equal((await request()).ip,'127.0.0.1');
app.set('trust proxy',trustedProxies({WIKIDECK_TRUSTED_PROXIES:'127.0.0.1'}));
assert.equal((await request()).ip,'203.0.113.12');
});
test('private operations listener exposes bounded aggregates only on separate loopback port',async t=>{
const reserve=createServer();reserve.listen(0,'127.0.0.1');await once(reserve,'listening');
const a=reserve.address();assert.ok(a&&typeof a==='object');await new Promise<void>(resolve=>reserve.close(()=>resolve()));
const ops=startOperations(a.port,()=>({network:'test',native:{available:true}}));t.after(()=>ops.close());
const r=await fetch(`http://127.0.0.1:${a.port}/status`,{signal:AbortSignal.timeout(5000)});
assert.equal(r.status,200);assert.equal(r.headers.get('cache-control'),'no-store');
const data=await r.json();assert.equal(data.network,'test');assert.equal(data.native.available,true);
assert.ok(data.nodeRssBytes>0);assert.equal('privateKey' in data,false);
assert.equal((await fetch(`http://127.0.0.1:${a.port}/metrics`)).status,404);
assert.equal(ops.address?.()?.address,'127.0.0.1');
});
test('operations checks identify node, peer, native-engine, and disk pressure',()=>{
const now=Date.now();
const status={schema:'wikideck-operations-v1',observedAt:new Date(now).toISOString(),
native:{available:true,queued:0},ledger:{height:10,mutationQueue:0,tipAgeSeconds:60},
peers:{configured:2,connected:2},eventLoop:{meanMs:20},disk:{availableBytes:1024**3},diskSampledAt:now};
assert.equal(checkOperations(status,now).healthy,true);
const bad={...status,native:{available:false,queued:50},peers:{configured:2,connected:0},disk:null,
ledger:{...status.ledger,mutationQueue:192}};
const report=checkOperations(bad,now);
for(const warning of ['native-engine-unavailable','queue-pressure','peer-isolation','disk-sample-unavailable-or-stale'])assert.ok(report.warnings.includes(warning));
assert.throws(()=>checkOperations({}));
});
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
test('project overview focuses on mining and player-to-player card trades',async()=>{
const readme=await readFile(new URL('../README.md',import.meta.url),'utf8');
assert.match(readme,/Mine a block with the native RandomX CPU miner to earn a card/);
assert.match(readme,/algorithm also used by Monero/);
assert.match(readme,/consumer CPUs competitive/);
assert.match(readme,/trade cards directly with another player/);
assert.match(readme,/docs\/card-only-production-plan\.md/);
assert.doesNotMatch(readme,/## Peer ownership and customization/);
assert.match(readme,/does not yet contain a project LICENSE file/);
const plan=await readFile(new URL('../docs/card-only-production-plan.md',import.meta.url),'utf8');
assert.match(plan,/Markets and real money/);
assert.match(plan,/Mainnet or valuable cards/);
for(const name of ['../compose.yaml','../compose.testnet.yaml','../compose.mainnet.yaml'])
assert.doesNotMatch(await readFile(new URL(name,import.meta.url),'utf8'),/MARKET|CUSTODY|PAYMENT_MODE/);
});
test('homepage presents mining, collections, and direct card trading without marketplace UI',async()=>{
const app=await readFile(new URL('../src/App.tsx',import.meta.url),'utf8');
assert.match(app,/Mine with your CPU/);
assert.match(app,/Direct card-for-card trades/);
assert.match(app,/designed to resist specialized ASICs/);
assert.match(app,/ASIC resistance is a design goal, not a guarantee/);
assert.match(app,/Trade cards directly with another collector/);
assert.doesNotMatch(app,/marketplace|custody preview|fixed-price listings/i);
assert.match(app,/<details className="mining-details"><summary>Want to run a miner\? Setup &amp; safety details/);
assert.match(app,/--consent/); // Documentation-only change: runnable miner invocation keeps its existing required flag.
});
+69
View File
@@ -0,0 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdtemp,rm,writeFile,readFile,mkdir,access,stat } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { DatabaseSync } from 'node:sqlite';
import { Ledger } from '../server/testnet/ledger.ts';
import { NETWORK } from '../shared/network.ts';
import { CHAIN_ID } from '../server/testnet/consensus.ts';
import { createBackup,verifyBackup,restoreBackup } from '../server/recovery.ts';
test('stopped-node backup snapshots only the ledger and replays it in an isolated destination',async t=>{
const root=await mkdtemp(join(tmpdir(),'wikideck-recovery-'));t.after(()=>rm(root,{recursive:true,force:true}));
const source=join(root,'source');
const pow={hash:async()=>{throw new Error('Empty ledger should require no proof work');},close(){}};
const ledger=await Ledger.open(source,pow),snapshot=ledger.snapshot();ledger.close();
const backup=join(root,'backup');const manifest=await createBackup(source,backup);
assert.equal(manifest.version,2);assert.deepEqual(manifest.files.map(file=>file.name),['ledger.sqlite']);
assert.deepEqual(await verifyBackup(backup),manifest);
const dest=join(root,'restore');assert.equal((await restoreBackup(backup,dest)).proofsRevalidated,false);
const restored=await Ledger.open(dest,pow);
assert.deepEqual(restored.snapshot(),snapshot);restored.close();
await assert.rejects(restoreBackup(backup,dest),/EEXIST/);
await assert.rejects(createBackup(source,backup),/EEXIST/);
await assert.rejects(createBackup(source,join(source,'inside')),/outside/);
await writeFile(join(source,'testnet-node.lock'),'simulated-running');
await assert.rejects(createBackup(source,join(root,'locked-backup')),/locked/);
assert.equal(await readFile(join(source,'testnet-node.lock'),'utf8'),'simulated-running');
});
test('new ledger backup refuses to omit legacy journals, and old paired backups remain portable',async t=>{
const root=await mkdtemp(join(tmpdir(),'wikideck-recovery-legacy-'));t.after(()=>rm(root,{recursive:true,force:true}));
const source=join(root,'source'),ledger=await Ledger.open(source,{hash:async()=>'',close(){}});ledger.close();
const legacyFile=join(source,'testnet','market.sqlite');
const legacyDb=new DatabaseSync(legacyFile);legacyDb.exec('CREATE TABLE legacy_archive(value TEXT)');legacyDb.close();
await assert.rejects(createBackup(source,join(root,'blocked-backup')),/Legacy financial data/);
const backup=join(root,'legacy-backup');await mkdir(backup);
const ledgerCopy=join(backup,'ledger.sqlite');
const ledgerDb=new DatabaseSync(join(source,'testnet','ledger.sqlite'),{readOnly:true});
try{ledgerDb.prepare('VACUUM INTO ?').run(ledgerCopy);}finally{ledgerDb.close();}
const marketCopy=join(backup,'market.sqlite');
const marketDb=new DatabaseSync(legacyFile,{readOnly:true});
try{marketDb.prepare('VACUUM INTO ?').run(marketCopy);}finally{marketDb.close();}
const fileInfo=async(name:string,path:string)=>({name,bytes:(await stat(path)).size,
sha256:createHash('sha256').update(await readFile(path)).digest('hex')});
const oldManifest={format:'wikideck-stopped-backup',version:1,network:NETWORK,chainId:CHAIN_ID,
profile:'testnet',createdAt:new Date().toISOString(),files:[await fileInfo('ledger.sqlite',ledgerCopy),await fileInfo('market.sqlite',marketCopy)]};
await writeFile(join(backup,'manifest.json'),JSON.stringify(oldManifest));
assert.equal((await verifyBackup(backup)).version,1);
const restored=join(root,'legacy-restore');await restoreBackup(backup,restored);
assert.equal((await stat(join(restored,'testnet','market.sqlite'))).size,(await stat(marketCopy)).size);
});
test('backup verifier rejects tampering, sidecars, network mismatch and incomplete packages',async t=>{
const root=await mkdtemp(join(tmpdir(),'wikideck-recovery-bad-'));t.after(()=>rm(root,{recursive:true,force:true}));
const source=join(root,'source'),ledger=await Ledger.open(source,{hash:async()=>'',close(){}});
ledger.close();const backup=join(root,'backup');await createBackup(source,backup);
const original=await readFile(join(backup,'manifest.json'),'utf8');
await writeFile(join(backup,'ledger.sqlite-wal'),'unexpected');await assert.rejects(verifyBackup(backup),/sidecar/);
await rm(join(backup,'ledger.sqlite-wal'));
await writeFile(join(backup,'manifest.json'),original.replace('wikideck-randomx-testnet-6','wrong-network'));await assert.rejects(verifyBackup(backup));
await writeFile(join(backup,'manifest.json'),original);
await writeFile(join(backup,'ledger.sqlite'),Buffer.concat([await readFile(join(backup,'ledger.sqlite')),Buffer.from('tampered')]));
await assert.rejects(verifyBackup(backup),/hash\/size/);
const missing=join(root,'incomplete');await mkdir(missing);await assert.rejects(verifyBackup(missing));
await assert.rejects(access(join(source,'testnet-node.lock'))); // maintenance lock released, not silently left behind
});
+73
View File
@@ -0,0 +1,73 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import { once } from 'node:events';
import { RpcBudget, RpcError, resourceConfig } from '../server/resources.ts';
test('global admission has bounded concurrency, write tokens, refill and idempotent release', () => {
let time = 0;
const config = { ...resourceConfig({}), requestsPerSecond: 3, writesPerSecond: 1, active: 2 };
const budget = new RpcBudget(config, () => time);
const a = budget.acquire(true);
assert.throws(() => budget.acquire(true), /admission/);
const b = budget.acquire(false);
assert.throws(() => budget.acquire(false), /admission/);
a(); a(); b();
assert.equal(budget.status().active, 0);
budget.acquire(false)();
assert.throws(() => budget.acquire(false), /admission/);
time = 1000;
budget.acquire(true)();
});
test('response bytes are bounded globally and refill without per-client storage', () => {
let time = 0;
const budget = new RpcBudget({ ...resourceConfig({}), responseBytes: 50, bytesPerSecond: 60 }, () => time);
assert.throws(() => budget.encode('x'.repeat(50)), /response limit/);
budget.encode('x'.repeat(28)); budget.encode('x'.repeat(28));
assert.throws(() => budget.encode('a'), /byte budget/);
time = 1000; assert.equal(budget.encode('a'), '"a"');
for (const value of ['-1','0','Infinity','1.2','']) assert.throws(() => resourceConfig({ WIKIDECK_RPC_MAX_ACTIVE: value }));
});
test('isolated HTTP burst sheds excess work, preserves peer/health lanes and releases slots', async t => {
const app = express(), cfg = { ...resourceConfig({}), active: 4, requestsPerSecond: 1000 };
const publicBudget = new RpcBudget(cfg), peerBudget = new RpcBudget(cfg);
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
app.get('/healthz', (_req,res) => { res.json({ status:'ok' }); });
app.use('/api',publicBudget.middleware()); app.use('/p2p',peerBudget.middleware());
app.get('/api/hold',async (_req,res)=>{ await gate; res.json({ok:true}); });
app.get('/p2p/status',(_req,res)=>{ res.json({ok:true}); });
const server=app.listen(0,'127.0.0.1'); await once(server,'listening');
const address=server.address(); assert.ok(address&&typeof address==='object');
const base=`http://127.0.0.1:${address.port}`;
t.after(()=>{release();server.closeAllConnections();server.close();});
const requests=Array.from({length:40},()=>fetch(`${base}/api/hold`,{signal:AbortSignal.timeout(5000)}));
// The first rejected response proves requests reached admission while accepted requests remain held.
const first=await Promise.race(requests); assert.equal(first.status,503);
assert.equal((await fetch(`${base}/p2p/status`)).status,200);
assert.equal((await fetch(`${base}/healthz`)).status,200);
release();
const responses=await Promise.all(requests);
const counts=responses.reduce((out,r)=>{out[r.status]=(out[r.status]??0)+1;return out;},{} as Record<number,number>);
assert.equal(counts[200],4); assert.equal(counts[503],36);
for(const r of responses)await r.text();
assert.equal(publicBudget.status().active,0);
t.diagnostic(JSON.stringify({requests:40,accepted:counts[200],shed:counts[503],peerLane:'available',health:'available'}));
});
test('oversized response errors restore JSON handling and do not recurse', async t => {
const app=express(),budget=new RpcBudget({...resourceConfig({}),responseBytes:128});
app.use(budget.middleware());
app.get('/large',(_req,res)=>{res.json({data:'x'.repeat(1000)});});
app.use((error:Error,_req:express.Request,res:express.Response,_next:express.NextFunction)=>{
res.status(error instanceof RpcError?error.status:500).json({error:error.message});
});
const server=app.listen(0,'127.0.0.1');await once(server,'listening');
t.after(()=>{server.closeAllConnections();server.close();});
const a=server.address();assert.ok(a&&typeof a==='object');
const r=await fetch(`http://127.0.0.1:${a.port}/large`,{signal:AbortSignal.timeout(5000)});
assert.equal(r.status,413);assert.match((await r.json()).error,/response limit/);
assert.equal(budget.status().active,0);
});
+67
View File
@@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { StateClient } from '../shared/rpc-client.ts';
import { NETWORK, type TestnetBlock } from '../shared/testnet.ts';
import { expectedChainIdentity } from '../shared/chain-identity.ts';
const { genesis, chainId } = await expectedChainIdentity();
const block=(height:number,previousHash:string):TestnetBlock=>({ network:NETWORK,chainId,height,
hash:height.toString(16).padStart(64,'0'),previousHash,transactions:[] }) as unknown as TestnetBlock;
test('default browser-style fetch receives the global receiver, not StateClient',async()=>{
const original=globalThis.fetch;
let calls=0;
globalThis.fetch=async function(this:unknown,input:RequestInfo|URL,init?:RequestInit){
assert.equal(this,globalThis,'native browser fetch would throw Illegal invocation for another receiver');
assert.equal(input,'/api/summary');assert.equal(init?.redirect,'error');calls++;
return Response.json({network:NETWORK,chainId,genesis,tip:genesis,height:0,revision:0,
cumulativeWork:'0',pendingTransfers:[],peers:0,algorithm:'RandomX',maturity:2});
};
try{const state=await new StateClient().load();assert.equal(state.tip,genesis);assert.equal(calls,2);}
finally{globalThis.fetch=original;}
});
test('browser rejects a node with the same network label but a different chain identity', async () => {
const client = new StateClient((async () => Response.json({ network: NETWORK, chainId: 'f'.repeat(64),
genesis: '0'.repeat(64), tip: '0'.repeat(64), height: 0, revision: 0,
cumulativeWork: '0', pendingTransfers: [], peers: 0, algorithm: 'RandomX', maturity: 2 })) as typeof fetch);
await assert.rejects(client.load(), /Incompatible node summary/);
});
function fixture(count:number){
const blocks:TestnetBlock[]=[];for(let i=0;i<count;i++)blocks.push(block(i+1,blocks.at(-1)?.hash??genesis));
const calls:string[]=[];
let malformed=false,conflict=false;
const fetcher=(async(path: string)=>{
calls.push(path); const url=new URL(path,'http://localhost');
const tip=blocks.at(-1)?.hash??genesis;
if(url.pathname==='/api/summary')return Response.json({network:NETWORK,chainId,genesis,tip,height:blocks.length,
cumulativeWork:String(blocks.length),pendingTransfers:[],peers:0,algorithm:'RandomX',maturity:2,revision:0});
if(conflict){conflict=false;return Response.json({error:'changed'},{status:409});}
const offset=Number(url.searchParams.get('offset')),limit=Number(url.searchParams.get('limit'));
const page=blocks.slice(offset,offset+limit);
return Response.json({network:NETWORK,chainId,tip,height:blocks.length,offset,blocks:page,
next:malformed?offset:(offset+page.length<blocks.length?offset+page.length:null)});
}) as typeof fetch;
return {blocks,calls,client:new StateClient(fetcher),malform:()=>{malformed=true;},conflict:()=>{conflict=true;}};
}
test('paged state loads contiguous history and reuses cache when unchanged or appended',async()=>{
const f=fixture(35);const initial=await f.client.load();assert.equal(initial.blocks.length,35);
assert.equal(f.calls.filter(p=>p.includes('canonical')).length,3);
f.calls.length=0;await f.client.load();assert.equal(f.calls.length,2);
f.blocks.push(block(36,f.blocks.at(-1)!.hash));f.calls.length=0;
assert.equal((await f.client.load()).blocks.length,36);
assert.equal(f.calls.filter(p=>p.includes('canonical')).length,2); // prefix check + append
});
test('paged state rejects malformed pagination, continuity and excessive history',async()=>{
const bad=fixture(17);bad.malform();await assert.rejects(bad.client.load(),/pagination/);
const broken=fixture(3);broken.blocks[1].previousHash='a'.repeat(64);await assert.rejects(broken.client.load(),/continuity/);
const big=fixture(10001);await assert.rejects(big.client.load(),/history limit/);
});
test('tip conflict restarts once; parallel refreshes share bounded work',async()=>{
const f=fixture(20);f.conflict();
const [a,b]=await Promise.all([f.client.load(),f.client.load()]);
assert.equal(a,b);assert.equal(a.blocks.length,20);
assert.equal(f.calls.filter(p=>p==='/api/summary').length,3);
});
test('oversized node responses are rejected before JSON parsing',async()=>{
const client=new StateClient((async()=>new Response('{}',{headers:{'Content-Type':'application/json','Content-Length':'99999999'}})) as typeof fetch);
await assert.rejects(client.load(),/page limit/);
});
+2 -2
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { merkleRoot, sha256, expectedKey, INITIAL_KEY, expectedTarget, MAX_TARGET, CHAIN_ID } from '../server/testnet/consensus.ts';
import { NETWORK, type TestnetBlock } from '../shared/testnet.ts';
import { CONSENSUS, NETWORK, type TestnetBlock } from '../shared/testnet.ts';
test('Merkle commitments distinguish padded leaves, ordering, and node/leaf domains',()=>{
const a=sha256('a'),b=sha256('b'),c=sha256('c');
@@ -20,7 +20,7 @@ test('RandomX keys rotate only after the protocol delay using an ancestor, never
});
test('difficulty retargets deterministically at exact windows and is bounded',()=>{
assert.equal(expectedTarget([]),MAX_TARGET);
const blocks=Array.from({length:10},(_,i)=>({target:MAX_TARGET,createdAt:1000+i*30}as TestnetBlock));
const blocks=Array.from({length:10},(_,i)=>({target:MAX_TARGET,createdAt:1000+i*CONSENSUS.targetSeconds}as TestnetBlock));
assert.equal(expectedTarget(blocks.slice(0,9)),MAX_TARGET);
assert.equal(expectedTarget(blocks),MAX_TARGET);
const fast=blocks.map((b,i)=>({...b,createdAt:1000+i}));
+336 -39
View File
@@ -6,13 +6,15 @@ 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 NftTransfer, type TestnetBlock } from '../shared/testnet.ts';
mintId, sha256, stateRoot, transferId, transition, validateBlock, workFor } from '../server/testnet/consensus.ts';
import { NETWORK, CONSENSUS, blockClaimMessage, nftTransferMessage, testnetOwnerOf, transactionCardIds,
nftTradeMessage, type MiningTemplate, type NftState, type NftStateLayer, type NftStateView,
type NftTrade, type NftTransfer, type TestnetBlock } from '../shared/testnet.ts';
import { deriveStats } from '../shared/enrichment.ts';
import type { PowEngine } from '../server/testnet/randomx.ts';
@@ -47,7 +49,7 @@ async function mine(template: MiningTemplate, miner: Identity = alice): Promise<
}
throw new Error('Test nonce search exhausted');
}
type Branch = { blocks: TestnetBlock[]; state: NftState };
type Branch = { blocks: TestnetBlock[]; state: NftStateView };
function branch(): Branch { return { blocks: [], state: {} }; }
function articleFor(_ancestors: TestnetBlock[]) {
const pageId = 50_000;
@@ -75,12 +77,21 @@ function transfer(cardHash: string, from: Identity = alice, to: Identity = bob,
sequence, publicKey: from.publicKey };
return { ...tx, signature: from.sign(nftTransferMessage(tx)) };
}
function trade(offered: TestnetBlock[], requested: TestnetBlock[], proposer: Identity = alice,
counterparty: Identity = bob): NftTrade {
const terms = { network: NETWORK, chainId: CHAIN_ID, proposer: proposer.owner, counterparty: counterparty.owner,
offered: offered.map(block => ({ cardHash: block.tokenId, sequence: 0 })),
requested: requested.map(block => ({ cardHash: block.tokenId, sequence: 0 })) };
const message = nftTradeMessage(terms);
return { type: 'trade', ...terms, proposerPublicKey: proposer.publicKey, proposerSignature: proposer.sign(message),
counterpartyPublicKey: counterparty.publicKey, counterpartySignature: counterparty.sign(message) };
}
async function fixture(t: { after: (fn: () => Promise<void>) => void }) {
const dir = await mkdtemp(join(tmpdir(), 'wikideck-testnet-'));
t.after(() => rm(dir, { recursive: true, force: true }));
const engine = new FakePow();
const ledger = await Ledger.open(dir, engine);
return { dir, ledger, engine, file: join(dir, 'testnet', 'ledger.json') };
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 => {
@@ -99,23 +110,98 @@ test('testnet genesis is empty, manifest-bound, isolated and defensively copied'
assert.deepEqual(initial.pendingTransfers, []);
assert.equal(stateRoot({}), sha256(`${NETWORK}:empty`));
await assert.rejects(readFile(join(dir, 'chain.json')), /ENOENT/);
assert.equal(JSON.parse(await readFile(file, 'utf8')).version, 1);
if (process.platform !== 'win32') assert.equal((await stat(file)).mode & 0o777, 0o600);
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);
const summary=ledger.summary();
assert.equal(summary.height,1);assert.equal(summary.tip,candidate.hash);
const page=ledger.canonicalPage(summary.tip,0,1);
assert.equal(page.blocks.length,1);assert.equal(page.next,null);
page.blocks[0].article.title='mutated page';
assert.notEqual(ledger.canonicalPage(summary.tip,0).blocks[0].article.title,'mutated page');
assert.throws(()=>ledger.canonicalPage(GENESIS_HASH,0),/tip changed/);
assert.throws(()=>ledger.canonicalPage(summary.tip,0,17),/Invalid/);
assert.throws(()=>ledger.legacySnapshot(0),/Full snapshot disabled/);
assert.equal(ledger.legacySnapshot(1).blocks.length,1);
assert.throws(() => ledger.inventory(-1, 32));
assert.throws(() => ledger.inventory(0, 0));
});
test('timestamp attack boundaries reject at median and beyond future limit before proof work',async t=>{
const {ledger,engine}=await fixture(t);
const now=GENESIS_TIME+CONSENSUS.targetSeconds*20;
t.mock.method(Date,'now',()=>now*1000);
const candidate=await mine(ledger.template(alice.owner,alice.publicKey,articleFor([]),statsSnapshot()));
const calls=engine.calls;
await assert.rejects(ledger.submit({...candidate,createdAt:GENESIS_TIME}),/timestamp/);
await assert.rejects(ledger.submit({...candidate,createdAt:now+CONSENSUS.maxFutureSeconds+1}),/timestamp/);
assert.equal(engine.calls,calls);assert.equal(ledger.size,0);
// Full validation reaches commitment checks for the exact allowed future edge, not timestamp rejection.
await assert.rejects(ledger.submit({...candidate,createdAt:now+CONSENSUS.maxFutureSeconds}),/block ID/);
assert.equal(engine.calls,calls);
});
test('historical replay survives a local clock rollback after accepting the future-skew boundary',async t=>{
const dir=await mkdtemp(join(tmpdir(),'wikideck-clock-replay-'));
const admissionTime=GENESIS_TIME+CONSENSUS.targetSeconds*20;
const powEngine=new FakePow();
const ledger=await Ledger.open(dir,powEngine,()=>admissionTime);
const template=makeTemplate([],{},alice.owner,alice.publicKey,[],articleFor([]),statsSnapshot(),
admissionTime+CONSENSUS.maxFutureSeconds);
const block=await mine(template);
assert.equal(block.createdAt,admissionTime+CONSENSUS.maxFutureSeconds);
assert.deepEqual(await ledger.submit(block),{accepted:true,reorg:false});
const accepted=ledger.snapshot();ledger.close();
const restarted=await Ledger.open(dir,new FakePow(),()=>admissionTime-CONSENSUS.maxFutureSeconds-1);
t.after(async()=>{restarted.close();await rm(dir,{recursive:true,force:true});});
assert.deepEqual(restarted.snapshot(),accepted);
});
test('legacy JSON import cannot seed future-dated blocks, even after restart',async t=>{
const dir=await mkdtemp(join(tmpdir(),'wikideck-future-legacy-'));
const admissionTime=GENESIS_TIME+CONSENSUS.targetSeconds*20;
const networkDir=join(dir,'testnet');await mkdir(networkDir,{recursive:true});
const template=makeTemplate([],{},alice.owner,alice.publicKey,[],articleFor([]),statsSnapshot(),
admissionTime+CONSENSUS.maxFutureSeconds+1);
const futureBlock=await mine(template);
await writeFile(join(networkDir,'ledger.json'),JSON.stringify({version:1,network:NETWORK,chainId:CHAIN_ID,
blocks:[futureBlock],pendingTransfers:[]}));
for(let attempt=0;attempt<2;attempt++) {
await assert.rejects(Ledger.open(dir,new FakePow(),()=>admissionTime),/timestamp/);
}
t.after(async()=>{await rm(dir,{recursive:true,force:true});});
});
test('invalid claimed RandomX proof is rejected before full NFT-state enumeration',async()=>{
const template=makeTemplate([],{},alice.owner,alice.publicKey,[],articleFor([]),statsSnapshot());
const badHeader={...template.header,stateRoot:'f'.repeat(64)};
const blob=miningBlob(badHeader),hash=sha256(blob);
const candidate:TestnetBlock={...badHeader,hash,tokenId:mintId(badHeader),powHash:'0'.repeat(64),
signature:alice.sign(blockClaimMessage(hash)),transactions:[]};
let enumerations=0,hashCalls=0;
const parentState=new Proxy(Object.create(null) as NftState,{ownKeys(target){enumerations++;return Reflect.ownKeys(target);}});
const verifier:PowEngine={async hash(){hashCalls++;return 'f'.repeat(64);},close(){}};
await assert.rejects(validateBlock(candidate,[],parentState,verifier),/Invalid RandomX proof/);
assert.equal(hashCalls,1);
assert.equal(enumerations,0,'invalid work must be rejected before materializing the full NFT state');
});
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, [], 30));
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);
@@ -136,10 +222,30 @@ test('DAG retains equal-work branches, serializes duplicates and selects strictl
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('a deeper competing fork disconnects the old chain only after cumulative work wins',async t=>{
const {ledger}=await fixture(t);const original=branch(),competitor=branch();
t.mock.method(Date,'now',()=>(GENESIS_TIME+CONSENSUS.targetSeconds*100)*1000);
for(let i=0;i<30;i++)await ledger.submit(await extend(original,alice,[],CONSENSUS.targetSeconds));
assert.equal(ledger.snapshot().tip,original.blocks.at(-1)!.hash);
for(let i=0;i<15;i++){
const result=await ledger.submit(await extend(competitor,bob,[],1));
assert.equal(result.reorg,false);
assert.equal(ledger.snapshot().tip,original.blocks.at(-1)!.hash);
}
assert.equal(competitor.blocks.at(-1)!.height,15);
assert.notEqual(competitor.blocks.at(-1)!.target,MAX_TARGET);
const winning=await ledger.submit(await extend(competitor,bob,[],1));
assert.deepEqual(winning,{accepted:true,reorg:true});
assert.ok(ledger.snapshot().cumulativeWork===competitor.blocks.reduce((sum,block)=>sum+workFor(block.target),0n).toString());
assert.deepEqual(ledger.snapshot().blocks,competitor.blocks);
assert.equal(ledger.size,46,'both branches remain available in the DAG');
});
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();
@@ -148,6 +254,7 @@ test('equal-work ties preserve the current tip across restart and only extension
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 });
@@ -169,6 +276,7 @@ test('NFT maturity, ordered mempool, block commitments and replay protection', a
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()));
@@ -181,6 +289,56 @@ test('NFT maturity, ordered mempool, block commitments and replay protection', a
await assert.rejects(restarted.submitTransfer(transfer(minted.tokenId, carol, alice, 0)), /owner\/sequence/);
});
test('signed multi-card trade is validated, mempooled, mined, and applied atomically', async t => {
const { ledger } = await fixture(t);
const chain = branch();
const aliceFirst = await extend(chain, alice); await ledger.submit(aliceFirst);
const bobFirst = await extend(chain, bob); await ledger.submit(bobFirst);
const aliceSecond = await extend(chain, alice); await ledger.submit(aliceSecond);
const bobSecond = await extend(chain, bob); await ledger.submit(bobSecond);
await ledger.submit(await extend(chain, alice)); // All four NFTs are mature at the next inclusion height.
const offered = [aliceFirst, aliceSecond], requested = [bobFirst, bobSecond];
const invalid = trade(offered, requested);
const badTerms = { ...invalid, requested: invalid.requested.map((leg, index) =>
index === 1 ? { ...leg, sequence: 1 } : leg) };
const badMessage = nftTradeMessage(badTerms);
const badSequence = { ...badTerms, proposerSignature: alice.sign(badMessage), counterpartySignature: bob.sign(badMessage) };
const before = ledger.snapshot();
await assert.rejects(ledger.submitTransaction(badSequence), /missing, stale, or immature/);
const badSignature = { ...invalid, proposerSignature: bob.sign(nftTradeMessage(invalid)) };
await assert.rejects(ledger.submitTransaction(badSignature), /signature/i);
assert.deepEqual(ledger.snapshot().pendingTransfers, []);
for (const block of offered) assert.equal(testnetOwnerOf(before, block.tokenId), alice.owner);
for (const block of requested) assert.equal(testnetOwnerOf(before, block.tokenId), bob.owner);
const bundle = trade(offered, requested);
await ledger.submitTransaction(bundle);
assert.deepEqual(ledger.snapshot().pendingTransfers, [bundle]);
const template = ledger.template(alice.owner, alice.publicKey, articleFor(ledger.snapshot().blocks), statsSnapshot());
assert.deepEqual(template.transactions, [bundle]);
await ledger.submit(await mine(template));
const after = ledger.snapshot();
assert.equal(after.pendingTransfers.length, 0);
for (const block of offered) assert.equal(testnetOwnerOf(after, block.tokenId), bob.owner);
for (const block of requested) assert.equal(testnetOwnerOf(after, block.tokenId), alice.owner);
assert.deepEqual(after.transfers.at(-1), bundle);
for (const block of after.blocks) {
const indexed = ledger.nft(block.tokenId)!;
assert.equal(indexed.owner, testnetOwnerOf(after, block.tokenId));
assert.equal(indexed.sequence, after.transfers.filter(tx => transactionCardIds(tx).includes(block.tokenId)).length);
}
const deck = ledger.deck(alice.owner, 0, 2);
assert.equal(deck.total, after.blocks.filter(block => testnetOwnerOf(after, block.tokenId) === alice.owner).length);
assert.equal(deck.nfts.length, 2);
assert.equal(deck.next, 2);
assert.deepEqual(ledger.deck(alice.owner, deck.next!, 2).nfts.map(item => item.block.tokenId).sort(),
ledger.deck(alice.owner).nfts.slice(2).map(item => item.block.tokenId).sort());
assert.deepEqual(ledger.deck(alice.owner).nfts.map(item => item.block.tokenId).sort(),
after.blocks.filter(block => testnetOwnerOf(after, block.tokenId) === alice.owner)
.map(block => block.tokenId).sort());
});
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();
@@ -202,12 +360,99 @@ test('reorg rolls back NFT ownership and requeues only still-valid disconnected
assert.equal(testnetOwnerOf(snapshot, committed.tokenId), undefined);
assert.deepEqual(snapshot.transfers, []);
assert.deepEqual(snapshot.pendingTransfers, [tx, tx2]); // Orphan issuance is discarded.
assert.equal(ledger.nft(minted.tokenId)!.owner, alice.owner);
assert.equal(ledger.nft(minted.tokenId)!.sequence, 0);
assert.equal(ledger.nft(committed.tokenId), undefined);
assert.equal(ledger.canonicalTransaction(transferId(tx)), undefined);
assert.ok(ledger.block(committed.hash));
assert.deepEqual((await Ledger.open(dir, new FakePow())).snapshot(), snapshot);
const restored = await Ledger.open(dir, new FakePow());
t.after(async () => { restored.close(); });
assert.deepEqual(restored.snapshot(), snapshot);
assert.equal(restored.canonicalHead(), ledger.canonicalHead());
assert.deepEqual(restored.canonicalEvents(0), ledger.canonicalEvents(0));
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);
assert.equal(ledger.nft(minted.tokenId)!.owner, carol.owner);
assert.equal(ledger.nft(minted.tokenId)!.sequence, 2);
});
test('canonical event pages record reorg order durably but exclude side branches, duplicates and mempool', async t => {
const { ledger, file, dir } = await fixture(t), original = branch();
const first = await extend(original); await ledger.submit(first);
const second = await extend(original); await ledger.submit(second);
const fork: Branch = structuredClone(original);
const tx = transfer(first.tokenId);
await ledger.submitTransfer(tx);
const beforePending = ledger.canonicalHead();
assert.equal(beforePending, 2);
const third = await extend(original, alice, [tx]); await ledger.submit(third);
assert.equal(ledger.canonicalTransaction(transferId(tx))?.blockHash, third.hash);
const competing = await extend(fork, bob); await ledger.submit(competing);
assert.equal(ledger.canonicalHead(), 3); // Equal-work branch does not change canonical state.
const winner = await extend(fork, bob); await ledger.submit(winner);
assert.equal(ledger.canonicalHead(), 6);
assert.equal(ledger.canonicalTransaction(transferId(tx)), undefined);
const events = ledger.canonicalEvents(0).events;
assert.deepEqual(events.map(e => [e.type, e.hash]), [
['connect', first.hash], ['connect', second.hash], ['connect', third.hash],
['disconnect', third.hash], ['connect', competing.hash], ['connect', winner.hash],
]);
assert.deepEqual(events[3].transactionIds, [transferId(tx)]);
const page = ledger.canonicalEvents(2, 1);
assert.equal(page.cursor, 3); assert.equal(page.caughtUp, false); assert.equal(page.head, 6);
page.events[0].transactionIds.length = 0;
assert.equal(ledger.canonicalEvents(2, 1).events[0].transactionIds.length, 1);
for (const [after, limit] of [[-1, 1], [7, 1], [0, 65], [0, 0], [0.5, 1]]) {
assert.throws(() => ledger.canonicalEvents(after, limit), /cursor/);
}
await ledger.submit(winner); assert.equal(ledger.canonicalHead(), 6);
ledger.close();
const reopened = await Ledger.open(dir, new FakePow());
try {
assert.equal(reopened.canonicalHead(), 6); assert.deepEqual(reopened.canonicalEvents(0).events, events);
const db = new DatabaseSync(file);
try { assert.throws(() => db.exec("UPDATE canonical_events SET type='connect'"), /Immutable/); } finally { db.close(); }
} finally { reopened.close(); }
});
test('block, mempool, canonical indexes and events roll back together on storage failure', async t => {
const { ledger, file } = await fixture(t), chain = branch();
const candidate = await extend(chain), db = new DatabaseSync(file);
try {
db.exec("CREATE TRIGGER fail_event BEFORE INSERT ON canonical_events BEGIN SELECT RAISE(ABORT,'injected storage failure'); END");
await assert.rejects(ledger.submit(candidate), /injected storage failure/);
assert.equal(ledger.size, 0); assert.equal(ledger.canonicalHead(), 0); assert.equal(ledger.nft(candidate.tokenId), undefined);
assert.equal((db.prepare('SELECT COUNT(*) AS n FROM blocks').get() as { n: number }).n, 0);
db.exec('DROP TRIGGER fail_event');
await ledger.submit(candidate);
assert.equal(ledger.nft(candidate.tokenId)!.owner, alice.owner); assert.equal(ledger.canonicalHead(), 1);
} finally { db.close(); }
});
test('validated existing ledgers backfill indexes once and refuse corrupt ownership or event history', async t => {
const { ledger, file, dir } = await fixture(t), chain = branch();
await ledger.submit(await extend(chain)); await ledger.submit(await extend(chain));
const expected = ledger.snapshot(); ledger.close();
const db = new DatabaseSync(file);
try {
// Simulate the prior schema: preserve original blocks/metadata/pending exactly, remove only new derived tables.
db.exec('DROP TABLE canonical_undo; DROP TABLE canonical_transactions; DROP TABLE canonical_nfts; DROP TABLE canonical_blocks; DROP TABLE canonical_events; DROP TABLE canonical_meta;');
} finally { db.close(); }
const migrated = await Ledger.open(dir, new FakePow());
assert.deepEqual(migrated.snapshot(), expected); assert.equal(migrated.canonicalHead(), 2); migrated.close();
const corrupt = new DatabaseSync(file);
try { corrupt.prepare('UPDATE canonical_nfts SET owner=? WHERE token=?').run(bob.owner, chain.blocks[0].tokenId); }
finally { corrupt.close(); }
await assert.rejects(Ledger.open(dir, new FakePow()), /ownership index mismatch/);
const repair = new DatabaseSync(file);
try {
repair.prepare('UPDATE canonical_nfts SET owner=? WHERE token=?').run(alice.owner, chain.blocks[0].tokenId);
repair.exec('DROP TRIGGER canonical_events_no_update');
repair.exec("UPDATE canonical_events SET transactions_json='[\"wrong\"]' WHERE sequence=1");
} finally { repair.close(); }
await assert.rejects(Ledger.open(dir, new FakePow()), /event integrity mismatch/);
});
test('reorg removes mempool conflicts and does not requeue already committed transfers', async t => {
@@ -252,53 +497,88 @@ test('invalid parent, manifest, proof, article/stat snapshots, and commitments l
assert.equal(ledger.size, 2);
});
test('startup rechecks every proof, rejects tampering and mismatched manifests without resetting disk', async t => {
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();
await Ledger.open(dir, checking);
const verified = await Ledger.open(dir, checking);
t.after(async () => { verified.close(); });
assert.equal(checking.calls, 2);
const original = await readFile(file, 'utf8');
const disk = JSON.parse(original);
for (const changed of [ { ...disk, chainId: 'ff'.repeat(32) }, { ...disk, network: 'wrong' },
{ ...disk, version: 2 }, { ...disk, blocks: [...disk.blocks].reverse() },
{ ...disk, blocks: [disk.blocks[0], disk.blocks[0]] },
{ ...disk, blocks: [{ ...disk.blocks[0], stateRoot: '00'.repeat(32) }] },
{ ...disk, pendingTransfers: [transfer(chain.blocks[0].tokenId, bob)] } ]) {
const text = JSON.stringify(changed);
await writeFile(file, text);
await assert.rejects(Ledger.open(dir, new FakePow()));
assert.equal(await readFile(file, 'utf8'), text);
}
await writeFile(file, original);
await assert.rejects(Ledger.open(dir, { hash: async () => 'ff'.repeat(32), close() {} }), /RandomX proof/);
await writeFile(file, '{truncated');
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()), /block ID/);
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 atomic replacement never commits memory, and queued mutations recover', async t => {
test('failed SQLite transaction never commits memory, and later mutations recover', async t => {
const { ledger, file } = await fixture(t);
const saved = `${file}.saved`;
await rename(file, saved);
await mkdir(file);
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);
await rm(file, { recursive: true });
await rename(saved, file);
const removeTrigger = new DatabaseSync(file);
removeTrigger.exec('DROP TRIGGER reject_block');
removeTrigger.close();
await ledger.submit(valid);
assert.equal(ledger.size, 1);
});
test('bounded persistence rejects oversized files before reading or replaying proofs', async t => {
const { dir, file } = await fixture(t);
await truncate(file, 128 * 1024 * 1024 + 1);
const engine = new FakePow();
await assert.rejects(Ledger.open(dir, engine), /128 MiB/);
assert.equal(engine.calls, 0);
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 => {
@@ -321,6 +601,7 @@ test('mempool is ordered, capped at 256, and templates include at most 64 withou
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());
});
@@ -374,6 +655,22 @@ test('peer configuration rejects unsafe origins; explicit private permission ena
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();
+97 -23
View File
@@ -3,7 +3,8 @@ import assert from 'node:assert/strict';
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync,
randomBytes, sign as nativeSign, verify as nativeVerify, webcrypto } from 'node:crypto';
import { encryptWallet, decryptWallet, type WalletBackup } from '../shared/wallet-backup.ts';
import { getWallet, exportWallet, restoreWallet, sign, type Wallet } from '../src/wallet.ts';
import { expectedChainIdentity } from '../shared/chain-identity.ts';
import { getWallet, exportWallet, restoreWallet, protectWallet, unlockWallet, walletStatus, lockWallet, sign, touchWallet, WALLET_IDLE_MS } from '../src/wallet.ts';
const subtle = webcrypto.subtle as SubtleCrypto;
// Ephemeral test credentials only; no production identities or secrets.
@@ -31,6 +32,7 @@ async function seal(pkcs8: ArrayBuffer, header: WalletBackup): Promise<WalletBac
const key = await subtle.deriveKey({ name: 'PBKDF2', hash: 'SHA-256', iterations: 600000,
salt: Buffer.from(header.salt, 'base64') }, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
const aad = new TextEncoder().encode(JSON.stringify([header.format, header.version, header.network,
...(header.version === 2 ? [header.chainId] : []),
header.kdf, header.iterations, header.cipher, header.salt, header.iv, header.publicKey]));
return { ...header, ciphertext: encode(await subtle.encrypt({ name: 'AES-GCM', tagLength: 128,
iv: Buffer.from(header.iv, 'base64'), additionalData: aad }, key, pkcs8)) };
@@ -49,21 +51,34 @@ test('Node WebCrypto backup roundtrip preserves SPKI SHA256 identity and signing
assert.equal((wallet.privateKey.algorithm as EcKeyAlgorithm).namedCurve, 'P-256');
assert.deepEqual(Buffer.from(await subtle.exportKey('pkcs8', wallet.privateKey)),
Buffer.from(await subtle.exportKey('pkcs8', privateKey)));
const signature = await sign(wallet, 'portable identity');
const signature = encode(await subtle.sign({name:'ECDSA',hash:'SHA-256'},wallet.privateKey,new TextEncoder().encode('portable identity')));
assert.equal(nativeVerify('sha256', Buffer.from('portable identity'), {
key: createPublicKey({ key: spki, format: 'der', type: 'spki' }), dsaEncoding: 'ieee-p1363',
}, Buffer.from(signature, 'base64')), true);
assert.equal(backup.iterations, 600000);
assert.equal(backup.network, 'wikideck-randomx-testnet-4');
assert.equal(backup.version, 2);
assert.equal(backup.network, 'wikideck-randomx-testnet-6');
assert.equal(backup.chainId, (await expectedChainIdentity()).chainId);
assert.equal(Buffer.from(backup.salt, 'base64').length, 16);
assert.equal(Buffer.from(backup.iv, 'base64').length, 12);
assert.ok(Buffer.byteLength(JSON.stringify(backup)) < 32 * 1024);
const again = await exportWallet(wallet, password);
const again = await encryptWallet(wallet.privateKey,wallet.publicKey,password);
assert.notEqual(again.salt, backup.salt);
assert.notEqual(again.iv, backup.iv);
assert.notEqual(again.ciphertext, backup.ciphertext);
});
test('chain-bound v2 rejects a different chain and original testnet v1 backups remain recoverable', async () => {
const { backup, privateKey } = await fixture;
assert.ok(backup.version === 2);
await assert.rejects(decryptWallet({ ...backup, chainId: '0'.repeat(64) }, password), /different chain/);
const { chainId: _chainId, ...legacyFields } = backup;
const legacy: WalletBackup = { ...legacyFields, version: 1, ciphertext: '' };
const legacyBackup = await seal(await subtle.exportKey('pkcs8', privateKey), legacy);
assert.equal((await decryptWallet(legacyBackup, password)).address,
(await decryptWallet(backup, password)).address);
});
test('native Node miner PKCS8 imports into WebCrypto without changing address or signatures', async () => {
const native = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const pkcs8 = native.privateKey.export({ format: 'der', type: 'pkcs8' });
@@ -117,7 +132,7 @@ test('strict format, bounded fields, canonical base64, and fixed KDF work reject
await assert.rejects(decryptWallet({ ...backup, iterations }, password), /Unsupported/);
}
const invalid: unknown[] = [null, undefined, [], 'x'.repeat(32769), {},
{ ...backup, extra: 'unexpected' }, { ...backup, version: 2 },
{ ...backup, extra: 'unexpected' }, { ...backup, version: 3 },
{ ...backup, network: 'wikideck-demo' }, { ...backup, format: 'other' },
{ ...backup, kdf: 'scrypt' }, { ...backup, cipher: 'AES-CBC' },
{ ...backup, salt: 'AA==' }, { ...backup, iv: null },
@@ -145,15 +160,17 @@ test('wallet persistence commits before restore resolves, isolates demo DB, retr
// Browser IndexedDB/Web Locks are additionally checked against the real API.
const originalIDB = Object.getOwnPropertyDescriptor(globalThis, 'indexedDB');
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
let stored: Wallet | undefined;
let stored: Record<string, unknown> | undefined;
let failWrites = 0;
let permanentFailure = false;
let lockCalls = 0;
let commits = 0;
let beforeWrite: (()=>void)|undefined;
const opened: string[] = [];
const dbName = `wikideck-wallet-${(await expectedChainIdentity()).chainId}-v2`;
const installNavigator = (locks: boolean) => Object.defineProperty(globalThis, 'navigator', { configurable: true,
value: locks ? { locks: { request: async (name: string, callback: () => Promise<unknown>) => {
assert.equal(name, 'wikideck-testnet-wallet-v2:identity'); lockCalls++; return callback();
assert.equal(name, `${dbName}:identity`); lockCalls++; return callback();
} } } : {} });
Object.defineProperty(globalThis, 'indexedDB', { configurable: true, value: {
open(name: string) {
@@ -161,16 +178,20 @@ test('wallet persistence commits before restore resolves, isolates demo DB, retr
const request = { result: {
close() {},
transaction(_store: string, mode: string) {
let replacement: Wallet | undefined;
if(mode==='readwrite'&&beforeWrite){const mutate=beforeWrite;beforeWrite=undefined;mutate();}
let replacement: Record<string, unknown> | undefined;
let aborted=false;
const tx = {
error: null as DOMException | null,
oncomplete: () => {}, onabort: () => {}, onerror: () => {},
abort: () => { aborted=true;queueMicrotask(()=>tx.onabort()); },
objectStore: () => ({
get: () => {
const get = { result: stored, onsuccess: () => {} };
queueMicrotask(() => {
get.onsuccess();
setImmediate(() => {
if(aborted)return;
if (mode === 'readwrite' && (permanentFailure || failWrites-- > 0)) {
tx.error = new DOMException('Simulated storage failure', permanentFailure ? 'QuotaExceededError' : 'AbortError');
tx.onabort();
@@ -183,7 +204,7 @@ test('wallet persistence commits before restore resolves, isolates demo DB, retr
});
return get;
},
put: (value: Wallet) => { replacement = value; },
put: (value: Record<string,unknown>) => { replacement = value; },
}),
};
return tx;
@@ -194,35 +215,88 @@ test('wallet persistence commits before restore resolves, isolates demo DB, retr
},
} });
try {
lockWallet();
installNavigator(false);
const initial = await Promise.all(Array.from({ length: 4 }, () => getWallet()));
assert.equal(new Set(initial.map(wallet => wallet.address)).size, 1);
assert.equal(initial[0].privateKey.extractable, true);
assert.equal(await getWallet(),undefined);
assert.equal(await walletStatus(),'empty');
assert.equal(stored,undefined); // Startup never silently creates a plaintext key.
const initial = await protectWallet(password);
assert.equal('privateKey' in initial,false);
assert.equal((stored as Record<string,unknown>|undefined)?.format,'wikideck-local-vault');
assert.equal('privateKey' in stored!,false);
const exported=await exportWallet(initial,password);
assert.equal((await decryptWallet(exported,password)).address,initial.address);
const signature=await sign(initial,'session-signature');
const publicKey=await subtle.importKey('spki',Buffer.from(initial.publicKey,'base64'),{name:'ECDSA',namedCurve:'P-256'},false,['verify']);
assert.equal(await subtle.verify({name:'ECDSA',hash:'SHA-256'},publicKey,Buffer.from(signature,'base64'),new TextEncoder().encode('session-signature')),true);
lockWallet(); assert.equal(await getWallet(),undefined);
await assert.rejects(sign(initial,'locked'),/locked/);
assert.equal(await walletStatus(),'locked');
await assert.rejects(unlockWallet('incorrect-password'),/Cannot unlock/);
const unlocked=await unlockWallet(password);
assert.equal(unlocked.address,initial.address);
await assert.rejects(sign(initial,'stale handle'),/locked or changed/);
const pendingExport=exportWallet(unlocked,password);lockWallet();
await assert.rejects(pendingExport,/locked|changed/);
const unlocking=unlockWallet(password);lockWallet();
await assert.rejects(unlocking,/locked|changed/);
await unlockWallet(password);
const originalNow=Date.now;
try { const now=Date.now();Date.now=()=>now+WALLET_IDLE_MS+1;assert.equal(await getWallet(),undefined); }
finally {Date.now=originalNow;}
const { backup } = await fixture;
const expected = await decryptWallet(backup, password);
installNavigator(true);
failWrites = 1;
const before = commits;
const recovered = await restoreWallet(backup, password);
const recovered = await restoreWallet(backup, password,password,true);
assert.ok(commits > before);
assert.equal(stored?.address, expected.address);
assert.equal((stored as Record<string,unknown>|undefined)?.format,'wikideck-local-vault');
assert.equal(recovered.address, expected.address);
assert.equal((await getWallet()).address, expected.address);
assert.equal((await getWallet())?.address, expected.address);
assert.ok(lockCalls >= 2);
assert.ok(opened.every(name => name === 'wikideck-testnet-wallet-v2'));
assert.ok(opened.every(name => name === dbName));
permanentFailure = true;
await assert.rejects(restoreWallet(await exportWallet(initial[0], password), password), { name: 'QuotaExceededError' });
assert.equal(stored?.address, expected.address);
const preserved=JSON.stringify(stored);
await assert.rejects(restoreWallet(exported,password,password,true), { name: 'QuotaExceededError' });
assert.equal(JSON.stringify(stored),preserved);
permanentFailure = false;
await assert.rejects(restoreWallet(backup, randomBytes(24).toString('base64')));
assert.equal((await getWallet()).address, expected.address);
await assert.rejects(restoreWallet(backup,password,password),/replacement/);
await assert.rejects(restoreWallet(backup, randomBytes(24).toString('base64'),password,true));
assert.equal((await unlockWallet(password)).address, expected.address);
failWrites = 5;
const attempts = opened.length;
await assert.rejects(restoreWallet(backup, password), { name: 'AbortError' });
assert.equal(opened.length - attempts, 3);
await assert.rejects(restoreWallet(backup, password,password,true), { name: 'AbortError' });
assert.equal(opened.length - attempts, 4); // One read, three write attempts.
failWrites = 0;
assert.equal((await restoreWallet(backup, password)).address, expected.address);
assert.equal((await restoreWallet(backup, password,password,true)).address, expected.address);
// Simulate another tab replacing a vault between our initial read and commit; CAS must reject.
beforeWrite=()=>{stored={...stored,revision:'other-tab-revision'};};
await assert.rejects(restoreWallet(exported,password,password,true),/changed in another tab/);
assert.equal((stored as Record<string,unknown>|undefined)?.revision,'other-tab-revision');
const current=await unlockWallet(password);
stored={...(stored as Record<string,unknown>|undefined),revision:'another-tab-replaced'};
await assert.rejects(sign(current,'cross-tab stale signer'),/changed/);
assert.equal(await getWallet(),undefined);
const expiry=await unlockWallet(password),baseNow=Date.now,openedAt=Date.now();
try{
let time=openedAt;Date.now=()=>time;
// Trusted UI activity may extend idle time, never the four-hour absolute lifetime.
for(let i=0;i<49;i++){time+=WALLET_IDLE_MS-1000;touchWallet();}
await assert.rejects(sign(expiry,'absolute expiry'),/locked/);
}finally{Date.now=baseNow;}
// Seed the old same-origin plaintext shape; explicit migration must preserve its identity.
lockWallet(); stored=expected as unknown as Record<string,unknown>;
assert.equal(await walletStatus(),'legacy');assert.equal(await getWallet(),undefined);
permanentFailure=true;
await assert.rejects(protectWallet(password),{name:'QuotaExceededError'});
assert.equal(stored.privateKey,expected.privateKey); // No destructive migration on failed commit.
permanentFailure=false;
assert.equal((await protectWallet(password)).address,expected.address);
assert.equal('privateKey' in stored,false);
lockWallet();assert.equal((await unlockWallet(password)).address,expected.address);
} finally {
lockWallet();
if (originalIDB) Object.defineProperty(globalThis, 'indexedDB', originalIDB);
else Reflect.deleteProperty(globalThis, 'indexedDB');
if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator);