randomx
This commit is contained in:
+320
-69
@@ -1,76 +1,327 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { access, mkdtemp, readFile, rm, unlink } from 'node:fs/promises';
|
||||
import { constants, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { delimiter, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { generateKeyPairSync, sign } from 'node:crypto';
|
||||
import { address, powHash } from '../server/chain.ts';
|
||||
import { claimMessage, transferMessage, type Challenge, type ChainState } from '../shared/protocol.ts';
|
||||
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';
|
||||
|
||||
test('HTTP mine, signed transfer, replay rejection and persisted restart', { timeout: 60000 }, async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wikideck-test-'));
|
||||
let child: ChildProcess | undefined;
|
||||
async function start(): Promise<string> {
|
||||
// Exercise the same native TypeScript runtime and bind address as Docker.
|
||||
child = spawn(process.execPath, ['--experimental-strip-types', 'server/index.ts'], {
|
||||
env: { ...process.env, HOST: '0.0.0.0', PORT: '0', NODE_ENV: 'production', WIKIDECK_DATA_DIR: dir, WIKIDECK_OFFLINE: '1' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Server startup timeout')), 15000);
|
||||
let output = '';
|
||||
child!.stdout!.on('data', chunk => {
|
||||
output += chunk.toString();
|
||||
const match = output.match(/http:\/\/0\.0\.0\.0:\d+/);
|
||||
if (match) { clearTimeout(timer); resolve(match[0].replace('0.0.0.0', '127.0.0.1')); }
|
||||
});
|
||||
child!.stderr!.on('data', chunk => { output += chunk.toString(); });
|
||||
child!.once('exit', () => { clearTimeout(timer); reject(new Error(output)); });
|
||||
child!.once('error', reject);
|
||||
});
|
||||
}
|
||||
async function stop() {
|
||||
if (child && child.exitCode === null) { const closed = once(child, 'exit'); child.kill(); await closed; }
|
||||
}
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const binary = resolve(process.env.RANDOMX_BINARY ?? join(root, 'native', 'build',
|
||||
`wikideck-randomx${process.platform === 'win32' ? '.exe' : ''}`));
|
||||
|
||||
function wallet() {
|
||||
// Ephemeral, independent P-256 keys; no credentials or private keys go to the nodes.
|
||||
const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
||||
return { publicKey, owner: address(publicKey), signed: (message: string) =>
|
||||
sign('sha256', Buffer.from(message), { key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') };
|
||||
}
|
||||
|
||||
function tamperSignature(signature: string): string {
|
||||
const bytes = Buffer.from(signature, 'base64');
|
||||
bytes[0] ^= 1;
|
||||
return bytes.toString('base64');
|
||||
}
|
||||
|
||||
async function bounded<T>(promise: Promise<T>, milliseconds: number, label: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
let base = await start();
|
||||
const health = await fetch(`${base}/healthz`);
|
||||
assert.equal(health.status, 200);
|
||||
assert.deepEqual(await health.json(), { status: 'ok' });
|
||||
assert.equal(health.headers.get('cache-control'), 'no-store');
|
||||
assert.equal(health.headers.has('ratelimit'), false);
|
||||
const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
||||
const owner = address(publicKey);
|
||||
const signed = (message: string) => sign('sha256', Buffer.from(message), { key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64');
|
||||
const post = (path: string, body: unknown, origin?: string) => fetch(`${base}/api/${path}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', ...(origin ? { Origin: origin } : {}) }, body: JSON.stringify(body),
|
||||
});
|
||||
const { challenge } = await (await fetch(`${base}/api/state`)).json() as { challenge: Challenge };
|
||||
assert.equal(challenge.article.source, 'offline');
|
||||
assert.equal((await post('mine', {})).status, 400);
|
||||
assert.equal((await post('mine', {}, 'https://evil.example')).status, 403);
|
||||
let nonce = 0;
|
||||
let hash = await powHash(challenge, owner, nonce);
|
||||
while (!hash.startsWith('0'.repeat(challenge.difficulty))) hash = await powHash(challenge, owner, ++nonce);
|
||||
const proof = { seed: challenge.seed, nonce, hash, owner, publicKey, signature: signed(claimMessage(challenge, owner, nonce, hash)) };
|
||||
const accepted = await post('mine', proof);
|
||||
assert.equal(accepted.status, 201, await accepted.text());
|
||||
assert.equal((await post('mine', proof)).status, 409);
|
||||
const body = { cardHash: hash, from: owner, to: 'b'.repeat(64), sequence: 0 };
|
||||
const transfer = { ...body, publicKey, signature: signed(transferMessage(body)) };
|
||||
assert.equal((await post('transfer', { ...transfer, to: 'c'.repeat(64) })).status, 403);
|
||||
assert.equal((await post('transfer', transfer)).status, 201);
|
||||
assert.equal((await post('transfer', transfer)).status, 403);
|
||||
assert.equal((await fetch(`${base}/api/missing`)).status, 404);
|
||||
await stop();
|
||||
base = await start();
|
||||
const restored = await (await fetch(`${base}/api/chain`)).json() as ChainState;
|
||||
assert.equal(restored.blocks.length, 1);
|
||||
assert.equal(restored.blocks[0].hash, hash);
|
||||
assert.equal(restored.transfers.length, 1);
|
||||
assert.equal(restored.transfers[0].to, 'b'.repeat(64));
|
||||
} finally { await stop(); await rm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
return await Promise.race([promise, new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);
|
||||
})]);
|
||||
} finally { clearTimeout(timer); }
|
||||
}
|
||||
|
||||
test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and durable restart',
|
||||
{ timeout: 180_000 }, async t => {
|
||||
try { await access(binary, constants.X_OK); }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
t.skip(`Native RandomX binary absent: ${binary}. Build the reference engine as documented in docs/randomx-native.md, or set RANDOMX_BINARY. No fake PoW fallback.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const directory = await mkdtemp(join(tmpdir(), 'wikideck-randomx-http-'));
|
||||
const firstDir = join(directory, 'node-1');
|
||||
const secondDir = join(directory, 'node-2');
|
||||
const originalPath = process.env.PATH;
|
||||
// The local MinGW build depends on these DLLs. Inherit the caller's PATH elsewhere.
|
||||
const mingw = `${process.env.SystemDrive ?? 'C:'}/Strawberry/c/bin`;
|
||||
if (process.platform === 'win32' && existsSync(mingw)) {
|
||||
process.env.PATH = `${mingw}${delimiter}${originalPath ?? ''}`;
|
||||
}
|
||||
type RunningNode = {
|
||||
child: ChildProcess; closed: Promise<void>; output: () => string; base: string;
|
||||
};
|
||||
const nodes: RunningNode[] = [];
|
||||
let engine: RandomX | undefined;
|
||||
const abort = () => {
|
||||
engine?.close();
|
||||
for (const { child } of nodes) {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
||||
}
|
||||
};
|
||||
t.signal.addEventListener('abort', abort, { once: true });
|
||||
|
||||
async function start(dataDir: string, peer?: string): Promise<RunningNode> {
|
||||
t.signal.throwIfAborted();
|
||||
const child = spawn(process.execPath, ['--experimental-strip-types', 'server/index.ts'], {
|
||||
cwd: root, windowsHide: true, shell: false,
|
||||
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' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let output = '';
|
||||
const node: RunningNode = { child, base: '', output: () => output,
|
||||
closed: new Promise(resolve => child.once('close', () => resolve())) };
|
||||
nodes.push(node); // Register before startup can fail, so finally owns every child.
|
||||
const ready = new Promise<string>((resolve, reject) => {
|
||||
const log = (chunk: Buffer) => {
|
||||
output = (output + chunk.toString()).slice(-16_384);
|
||||
const match = /Wikideck RandomX testnet: (http:\/\/127\.0\.0\.1:\d+)/.exec(output);
|
||||
if (match) resolve(match[1]);
|
||||
};
|
||||
child.stdout!.on('data', log);
|
||||
child.stderr!.on('data', chunk => { output = (output + chunk.toString()).slice(-16_384); });
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => reject(new Error(`Node exited (${signal ?? code}): ${output}`)));
|
||||
});
|
||||
try { node.base = await bounded(ready, 30_000, 'Node startup'); }
|
||||
catch (error) { throw new Error(`${String(error)}\n${output}`); }
|
||||
return node;
|
||||
}
|
||||
|
||||
async function stop(node: RunningNode): Promise<void> {
|
||||
if (node.child.exitCode === null && node.child.signalCode === null) node.child.kill('SIGTERM');
|
||||
try { await bounded(node.closed, 5000, 'Node shutdown'); }
|
||||
catch {
|
||||
node.child.kill('SIGKILL');
|
||||
await bounded(node.closed, 5000, 'Forced node shutdown');
|
||||
}
|
||||
assert.ok(node.child.exitCode !== null || node.child.signalCode !== null, node.output());
|
||||
}
|
||||
|
||||
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(15_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()}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
async function json<T>(base: string, path: string, expected = 200, body?: unknown): Promise<T> {
|
||||
return (await request(base, path, expected, body)).json() as Promise<T>;
|
||||
}
|
||||
const snapshot = (base: string) => json<TestnetSnapshot>(base, '/api/chain');
|
||||
async function waitFor<T>(label: string, read: () => Promise<T>, predicate: (value: T) => boolean): Promise<T> {
|
||||
const until = Date.now() + 25_000;
|
||||
let value: T;
|
||||
do {
|
||||
value = await read();
|
||||
if (predicate(value)) return value;
|
||||
await delay(500, undefined, { signal: t.signal });
|
||||
} while (Date.now() < until);
|
||||
assert.fail(`${label} did not converge: ${JSON.stringify(value)}`);
|
||||
}
|
||||
async function mine(base: string, miner: ReturnType<typeof wallet>): Promise<TestnetBlock> {
|
||||
const template = await json<MiningTemplate>(base, '/api/mining/template', 200,
|
||||
{ owner: miner.owner, publicKey: miner.publicKey });
|
||||
const header = { ...template.header };
|
||||
assert.equal(header.network, NETWORK);
|
||||
assert.equal(header.chainId, CHAIN_ID);
|
||||
assert.equal(header.target, MAX_TARGET); // Initial target is approximately one success per 16 hashes.
|
||||
assert.equal(template.nonceOffset, 32);
|
||||
assert.equal(template.blob, miningBlob(header).toString('hex'));
|
||||
const until = Date.now() + 45_000;
|
||||
for (let batch = 0; batch < 16 && Date.now() < until; batch++) {
|
||||
t.signal.throwIfAborted();
|
||||
const result = await bounded(engine!.mine(header.key, miningBlob(header), header.target,
|
||||
template.nonceOffset, 64), Math.min(45_000, until - Date.now()), 'Native mining batch');
|
||||
if (result) {
|
||||
header.nonce = result.nonce;
|
||||
const blob = miningBlob(header);
|
||||
assert.equal(await engine!.hash(header.key, blob), result.hash);
|
||||
assert.ok(BigInt(`0x${result.hash}`) <= BigInt(`0x${header.target}`));
|
||||
const hash = sha256(blob);
|
||||
return { ...header, hash, tokenId: mintId(header), powHash: result.hash,
|
||||
signature: miner.signed(blockClaimMessage(hash)), transactions: template.transactions };
|
||||
}
|
||||
header.nonce += 64;
|
||||
}
|
||||
assert.fail('Native RandomX mining exhausted its bounded search (1024 nonces / 45s)');
|
||||
}
|
||||
async function submit(base: string, block: TestnetBlock) {
|
||||
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');
|
||||
|
||||
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);
|
||||
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');
|
||||
assert.equal(health.headers.has('ratelimit'), false);
|
||||
const initial = await snapshot(first.base);
|
||||
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, CONSENSUS.coinbaseMaturity);
|
||||
assert.deepEqual(initial.blocks, []);
|
||||
assert.deepEqual(await json(first.base, '/p2p/status'),
|
||||
{ network: NETWORK, chainId: CHAIN_ID, size: 0, tip: GENESIS_HASH });
|
||||
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/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',
|
||||
headers: { 'Content-Type': 'application/json' }, body: '{',
|
||||
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)}`]) {
|
||||
await (await request(first.base, path, 404)).text();
|
||||
}
|
||||
for (const offset of ['-1', '1.5', '10001', 'nope']) {
|
||||
await (await request(first.base, `/p2p/blocks?offset=${offset}`, 400)).text();
|
||||
}
|
||||
|
||||
const block1 = await mine(first.base, alice);
|
||||
assert.equal(block1.height, 1);
|
||||
await submit(first.base, block1);
|
||||
const unsigned = { network: NETWORK, chainId: CHAIN_ID, cardHash: block1.tokenId,
|
||||
from: alice.owner, to: bob.owner, sequence: 0 };
|
||||
const transfer: NftTransfer = { ...unsigned, publicKey: alice.publicKey,
|
||||
signature: alice.signed(nftTransferMessage(unsigned)) };
|
||||
const immature = await json<{ error: string }>(first.base, '/api/transfer', 400, transfer);
|
||||
assert.match(immature.error, /immature/i);
|
||||
const block2 = await mine(first.base, alice);
|
||||
assert.equal(block2.height, 2);
|
||||
await submit(first.base, block2);
|
||||
|
||||
// A truly independent process reconstructs the canonical chain over HTTP and checks real work.
|
||||
const second = await start(secondDir, first.base);
|
||||
const synced = await waitFor('Second node canonical sync', () => snapshot(second.base),
|
||||
state => state.tip === block2.hash && state.peers === 1);
|
||||
assert.deepEqual(synced.blocks, [block1, block2]);
|
||||
assert.equal(synced.cumulativeWork, (2n * workFor(MAX_TARGET)).toString());
|
||||
const peerStatus = await json<{ peers: { url: string; connected: boolean }[] }>(second.base, '/api/peers');
|
||||
assert.deepEqual(peerStatus.peers, [{ url: first.base, connected: true }]);
|
||||
|
||||
const badTransfer = await json<{ error: string }>(second.base, '/api/transfer', 400,
|
||||
{ ...transfer, signature: tamperSignature(transfer.signature) });
|
||||
assert.match(badTransfer.error, /signature/i);
|
||||
const pending = { id: transferId(transfer), status: 'pending',
|
||||
message: 'Ownership changes only when this transaction is mined.' };
|
||||
assert.deepEqual(await json(second.base, '/api/transfer', 202, transfer), pending);
|
||||
assert.deepEqual(await json(second.base, '/api/transfer', 202, transfer), pending); // Pending duplicates are idempotent.
|
||||
await waitFor('Transaction gossip to first node', () => snapshot(first.base),
|
||||
state => state.pendingTransfers.length === 1);
|
||||
for (const node of [first, second]) {
|
||||
const state = await snapshot(node.base);
|
||||
assert.deepEqual(state.pendingTransfers, [transfer]);
|
||||
assert.deepEqual(state.transfers, []);
|
||||
const metadata = await json<Record<string, unknown>>(node.base, `/api/nfts/${block1.tokenId}`);
|
||||
assert.equal(metadata.owner, alice.owner); // Mempool never changes canonical ownership.
|
||||
assert.equal(metadata.sequence, 0);
|
||||
assert.equal(metadata.confirmations, 2);
|
||||
}
|
||||
|
||||
const block3 = await mine(second.base, bob);
|
||||
assert.equal(block3.height, 3);
|
||||
assert.deepEqual(block3.transactions, [transfer]);
|
||||
// Reject validly encoded signature and PoW tampering before accepting this same block ID.
|
||||
const beforeInvalid = await persisted(secondDir);
|
||||
const badSignature = await json<{ error: string }>(second.base, '/api/blocks', 400,
|
||||
{ ...block3, signature: tamperSignature(block3.signature) });
|
||||
assert.match(badSignature.error, /miner signature/i);
|
||||
const badProof = await json<{ error: string }>(second.base, '/api/blocks', 400,
|
||||
{ ...block3, powHash: block3.powHash === '0'.repeat(64) ? '0'.repeat(63) + '1' : '0'.repeat(64) });
|
||||
assert.match(badProof.error, /RandomX proof/i);
|
||||
assert.equal(await persisted(secondDir), beforeInvalid, 'Invalid blocks must not touch durable state');
|
||||
assert.equal((await snapshot(second.base)).tip, block2.hash);
|
||||
await submit(second.base, block3);
|
||||
const gossiped = await waitFor('Block gossip to first node', () => snapshot(first.base),
|
||||
state => state.tip === block3.hash);
|
||||
const canonical = await snapshot(second.base);
|
||||
assert.deepEqual(gossiped.blocks, canonical.blocks);
|
||||
assert.deepEqual(canonical.blocks, [block1, block2, block3]);
|
||||
assert.deepEqual(canonical.transfers, [transfer]);
|
||||
assert.deepEqual(canonical.pendingTransfers, []);
|
||||
assert.equal(canonical.cumulativeWork, (3n * workFor(MAX_TARGET)).toString());
|
||||
for (const node of [first, second]) {
|
||||
const replay = await json<{ error: string }>(node.base, '/api/transfer', 400, transfer);
|
||||
assert.match(replay.error, /owner\/sequence/i);
|
||||
assert.deepEqual(await json(node.base, `/api/nfts/${block1.tokenId}`), {
|
||||
network: NETWORK, chainId: CHAIN_ID, tokenId: block1.tokenId, owner: bob.owner, sequence: 1,
|
||||
blockHash: block1.hash, confirmations: 3, article: block1.article, stateRoot: block3.stateRoot,
|
||||
});
|
||||
const inventory = await json<{ blocks: TestnetBlock[]; next: null }>(node.base, '/p2p/blocks?offset=0');
|
||||
assert.deepEqual(inventory, { blocks: canonical.blocks, next: null });
|
||||
assert.deepEqual(await json(node.base, '/p2p/blocks?offset=3'), { blocks: [], next: null });
|
||||
}
|
||||
|
||||
await stop(second); // Stop its outbound sync before restarting its peer.
|
||||
await stop(first);
|
||||
if (process.platform === 'win32') {
|
||||
// Windows kill(SIGTERM) bypasses the JS handler. Explicit crash-recovery simulation:
|
||||
// only our isolated lock, only after stop() confirmed process exit/stdio closure.
|
||||
const lock = join(firstDir, 'testnet-node.lock');
|
||||
try {
|
||||
assert.equal(await readFile(lock, 'utf8'), String(first.child.pid));
|
||||
await unlink(lock);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
} else {
|
||||
assert.equal(first.child.exitCode, 0, first.output());
|
||||
assert.equal(existsSync(join(firstDir, 'testnet-node.lock')), false);
|
||||
}
|
||||
first = await start(firstDir); // Ledger.open revalidates every persisted block using native RandomX.
|
||||
const restored = await snapshot(first.base);
|
||||
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,
|
||||
blockHash: block1.hash, confirmations: 3, article: block1.article, stateRoot: block3.stateRoot,
|
||||
});
|
||||
assert.deepEqual(await json(first.base, '/api/blocks', 200, block3), { accepted: false, reorg: false });
|
||||
await (await request(first.base, '/api/transfer', 400, transfer)).text();
|
||||
t.diagnostic('Real native RandomX: 3 mined blocks, 2 independent HTTP nodes, transfer gossip/confirmation, rejection and restart checks passed.');
|
||||
} finally {
|
||||
engine?.close();
|
||||
const stopped = await Promise.allSettled(nodes.map(stop));
|
||||
t.signal.removeEventListener('abort', abort);
|
||||
if (originalPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = originalPath;
|
||||
// Do not remove a directory if a child could still be writing to it.
|
||||
if (stopped.every(result => result.status === 'fulfilled')) {
|
||||
await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
} else {
|
||||
throw new AggregateError(stopped.filter(result => result.status === 'rejected')
|
||||
.map(result => result.reason), `Child cleanup failed; isolated data retained at ${directory}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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';
|
||||
|
||||
test('Merkle commitments distinguish padded leaves, ordering, and node/leaf domains',()=>{
|
||||
const a=sha256('a'),b=sha256('b'),c=sha256('c');
|
||||
assert.notEqual(merkleRoot([a,b,c]),merkleRoot([a,b,c,c]));
|
||||
assert.notEqual(merkleRoot([a,b]),merkleRoot([b,a]));
|
||||
assert.notEqual(merkleRoot([a]),a);
|
||||
assert.equal(merkleRoot([]),sha256(`${NETWORK}:empty`));
|
||||
});
|
||||
test('RandomX keys rotate only after the protocol delay using an ancestor, never a miner-selected seed',()=>{
|
||||
const ancestors=Array.from({length:4160},(_,i)=>({hash:sha256(`block${i+1}`)} as TestnetBlock));
|
||||
assert.equal(expectedKey(ancestors.slice(0,2047),2048),INITIAL_KEY);
|
||||
assert.equal(expectedKey(ancestors.slice(0,2110),2111),INITIAL_KEY);
|
||||
assert.equal(expectedKey(ancestors.slice(0,2111),2112),ancestors[2047].hash);
|
||||
assert.equal(expectedKey(ancestors.slice(0,4158),4159),ancestors[2047].hash);
|
||||
assert.equal(expectedKey(ancestors.slice(0,4159),4160),ancestors[4095].hash);
|
||||
});
|
||||
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));
|
||||
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}));
|
||||
assert.ok(BigInt(`0x${expectedTarget(fast)}`)<BigInt(`0x${MAX_TARGET}`));
|
||||
assert.match(CHAIN_ID,/^[a-f0-9]{64}$/);
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
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 { 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';
|
||||
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: NftState };
|
||||
function branch(): Branch { return { blocks: [], state: {} }; }
|
||||
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, 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-'));
|
||||
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') };
|
||||
}
|
||||
|
||||
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(JSON.parse(await readFile(file, 'utf8')).version, 1);
|
||||
if (process.platform !== 'win32') assert.equal((await stat(file)).mode & 0o777, 0o600);
|
||||
const candidate = await mine(ledger.template(alice.owner, alice.publicKey));
|
||||
assert.deepEqual(await ledger.submit(candidate), { accepted: true, reorg: false });
|
||||
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);
|
||||
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 < 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());
|
||||
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());
|
||||
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());
|
||||
assert.deepEqual(restarted.snapshot().pendingTransfers, [tx, tx2]);
|
||||
const block = await mine(restarted.template(alice.owner, alice.publicKey));
|
||||
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));
|
||||
assert.deepEqual((await Ledger.open(dir, new FakePow())).snapshot(), snapshot);
|
||||
const next = await mine(ledger.template(bob.owner, bob.publicKey), 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, catalog, transaction/state commitments leave ledger unchanged', async t => {
|
||||
const { ledger } = await fixture(t);
|
||||
const valid = await extend(branch());
|
||||
for (const changed of [ { previousHash: 'aa'.repeat(32) }, { chainId: 'bb'.repeat(32) },
|
||||
{ network: 'another-network' }, { article: { ...valid.article, extract: 'tampered catalog' } },
|
||||
{ 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.
|
||||
assert.equal(ledger.size, 1);
|
||||
});
|
||||
|
||||
test('startup rechecks every proof, rejects tampering and mismatched manifests without resetting disk', 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);
|
||||
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');
|
||||
await assert.rejects(Ledger.open(dir, new FakePow()));
|
||||
});
|
||||
|
||||
test('failed atomic replacement never commits memory, and queued mutations recover', async t => {
|
||||
const { ledger, file } = await fixture(t);
|
||||
const saved = `${file}.saved`;
|
||||
await rename(file, saved);
|
||||
await mkdir(file);
|
||||
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);
|
||||
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('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);
|
||||
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());
|
||||
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('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);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import test from 'node:test';
|
||||
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';
|
||||
|
||||
const subtle = webcrypto.subtle as SubtleCrypto;
|
||||
// Ephemeral test credentials only; no production identities or secrets.
|
||||
const password = randomBytes(24).toString('base64');
|
||||
const encode = (bytes: ArrayBuffer) => Buffer.from(bytes).toString('base64');
|
||||
async function generate() {
|
||||
const keys = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
|
||||
return { ...keys, publicKeyText: encode(await subtle.exportKey('spki', keys.publicKey)) };
|
||||
}
|
||||
const fixture = (async () => {
|
||||
const keys = await generate();
|
||||
return { ...keys, backup: await encryptWallet(keys.privateKey, keys.publicKeyText, password) };
|
||||
})();
|
||||
|
||||
function mutateBase64(value: string): string {
|
||||
const bytes = Buffer.from(value, 'base64');
|
||||
bytes[0] ^= 1;
|
||||
return bytes.toString('base64');
|
||||
}
|
||||
|
||||
// Independent format writer permits testing authenticated but invalid plaintext:
|
||||
// this exercises key validation *after* successful GCM authentication.
|
||||
async function seal(pkcs8: ArrayBuffer, header: WalletBackup): Promise<WalletBackup> {
|
||||
const material = await subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']);
|
||||
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.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)) };
|
||||
}
|
||||
|
||||
test('Node WebCrypto backup roundtrip preserves SPKI SHA256 identity and signing key', async () => {
|
||||
const { backup, privateKey, publicKeyText } = await fixture;
|
||||
const wallet = await decryptWallet(JSON.parse(JSON.stringify(backup)), password);
|
||||
const spki = Buffer.from(publicKeyText, 'base64');
|
||||
assert.equal(wallet.address, createHash('sha256').update(spki).digest('hex'));
|
||||
assert.match(wallet.address, /^[a-f0-9]{64}$/);
|
||||
assert.equal(wallet.publicKey, publicKeyText);
|
||||
assert.equal(wallet.privateKey.extractable, true);
|
||||
assert.equal(wallet.privateKey.type, 'private');
|
||||
assert.equal(wallet.privateKey.algorithm.name, 'ECDSA');
|
||||
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');
|
||||
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-2');
|
||||
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);
|
||||
assert.notEqual(again.salt, backup.salt);
|
||||
assert.notEqual(again.iv, backup.iv);
|
||||
assert.notEqual(again.ciphertext, backup.ciphertext);
|
||||
});
|
||||
|
||||
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' });
|
||||
const publicKey = native.publicKey.export({ format: 'der', type: 'spki' }).toString('base64');
|
||||
const privateKey = await subtle.importKey('pkcs8', pkcs8, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign']);
|
||||
const restored = await decryptWallet(await encryptWallet(privateKey, publicKey, password), password);
|
||||
assert.equal(restored.address, createHash('sha256').update(Buffer.from(publicKey, 'base64')).digest('hex'));
|
||||
const message = Buffer.from('native miner identity');
|
||||
const nativeSignature = nativeSign('sha256', message, { key: native.privateKey, dsaEncoding: 'ieee-p1363' });
|
||||
const verifyingKey = await subtle.importKey('spki', Buffer.from(restored.publicKey, 'base64'),
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
|
||||
assert.equal(await subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, verifyingKey, nativeSignature, message), true);
|
||||
assert.equal(createPrivateKey({ key: Buffer.from(await subtle.exportKey('pkcs8', restored.privateKey)),
|
||||
format: 'der', type: 'pkcs8' }).asymmetricKeyType, 'ec');
|
||||
});
|
||||
|
||||
test('wrong password and modifications of ciphertext, salt, IV, or public key fail', async () => {
|
||||
const { backup } = await fixture;
|
||||
await assert.rejects(decryptWallet(backup, randomBytes(24).toString('base64')), /Cannot unlock/);
|
||||
for (const field of ['ciphertext', 'salt', 'iv'] as const) {
|
||||
await assert.rejects(decryptWallet({ ...backup, [field]: mutateBase64(backup[field]) }, password), /Cannot unlock/);
|
||||
}
|
||||
const other = await generate();
|
||||
await assert.rejects(decryptWallet({ ...backup, publicKey: other.publicKeyText }, password), /Cannot unlock/);
|
||||
});
|
||||
|
||||
test('mismatched key pairs fail on export and after authenticated decryption', async () => {
|
||||
const { backup, privateKey } = await fixture;
|
||||
const other = await generate();
|
||||
await assert.rejects(encryptWallet(privateKey, other.publicKeyText, password), /does not match/);
|
||||
const mismatched = await seal(await subtle.exportKey('pkcs8', privateKey), { ...backup, publicKey: other.publicKeyText });
|
||||
await assert.rejects(decryptWallet(mismatched, password), /mismatched wallet keys/);
|
||||
});
|
||||
|
||||
test('wrong key types, non-exportable keys, wrong curves, and invalid PKCS8 are rejected', async () => {
|
||||
const { backup, publicKey, publicKeyText } = await fixture;
|
||||
const nonexportable = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']);
|
||||
const ecdh = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey']);
|
||||
const p384 = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-384' }, true, ['sign', 'verify']);
|
||||
for (const key of [publicKey, nonexportable.privateKey, ecdh.privateKey, p384.privateKey]) {
|
||||
await assert.rejects(encryptWallet(key, publicKeyText, password), /exportable P-256 ECDSA/);
|
||||
}
|
||||
const p384Backup = await seal(await subtle.exportKey('pkcs8', p384.privateKey), backup);
|
||||
await assert.rejects(decryptWallet(p384Backup, password), /Cannot unlock/);
|
||||
await assert.rejects(decryptWallet(await seal(new Uint8Array(32).buffer, backup), password), /Cannot unlock/);
|
||||
});
|
||||
|
||||
test('strict format, bounded fields, canonical base64, and fixed KDF work reject malformed input', async () => {
|
||||
const { backup } = await fixture;
|
||||
for (const iterations of [0, -1, 1, 599999, 600001, 1e12, Infinity, NaN, '600000', null]) {
|
||||
await assert.rejects(decryptWallet({ ...backup, iterations }, password), /Unsupported/);
|
||||
}
|
||||
const invalid: unknown[] = [null, undefined, [], 'x'.repeat(32769), {},
|
||||
{ ...backup, extra: 'unexpected' }, { ...backup, version: 2 },
|
||||
{ ...backup, network: 'wikideck-demo' }, { ...backup, format: 'other' },
|
||||
{ ...backup, kdf: 'scrypt' }, { ...backup, cipher: 'AES-CBC' },
|
||||
{ ...backup, salt: 'AA==' }, { ...backup, iv: null },
|
||||
{ ...backup, publicKey: 'a'.repeat(32769) }, { ...backup, ciphertext: 'a'.repeat(32769) },
|
||||
{ ...backup, ciphertext: '***=' }, { ...backup, salt: backup.salt.replace(/==$/, 'AB') },
|
||||
{ ...backup, salt: `${backup.salt}\n` },
|
||||
Object.assign(Object.create({ inherited: true }), backup),
|
||||
];
|
||||
const missing = { ...backup } as Partial<WalletBackup>;
|
||||
delete missing.publicKey;
|
||||
invalid.push(missing);
|
||||
const accessor = { ...backup };
|
||||
Object.defineProperty(accessor, 'publicKey', { get() { throw new Error('Getter must not run'); } });
|
||||
invalid.push(accessor);
|
||||
for (const value of invalid) await assert.rejects(decryptWallet(value, password));
|
||||
const { privateKey, publicKeyText } = await fixture;
|
||||
for (const bad of ['', 'x'.repeat(11), 'x'.repeat(1025)]) {
|
||||
await assert.rejects(encryptWallet(privateKey, publicKeyText, bad), /Password/);
|
||||
}
|
||||
await assert.rejects(decryptWallet(backup, 'x'.repeat(1025)), /Password/);
|
||||
});
|
||||
|
||||
test('wallet persistence commits before restore resolves, isolates demo DB, retries and recovers after failures', async () => {
|
||||
// Small storage-boundary double: deliberately separate get success and commit.
|
||||
// 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 failWrites = 0;
|
||||
let permanentFailure = false;
|
||||
let lockCalls = 0;
|
||||
let commits = 0;
|
||||
const opened: string[] = [];
|
||||
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();
|
||||
} } } : {} });
|
||||
Object.defineProperty(globalThis, 'indexedDB', { configurable: true, value: {
|
||||
open(name: string) {
|
||||
opened.push(name);
|
||||
const request = { result: {
|
||||
close() {},
|
||||
transaction(_store: string, mode: string) {
|
||||
let replacement: Wallet | undefined;
|
||||
const tx = {
|
||||
error: null as DOMException | null,
|
||||
oncomplete: () => {}, onabort: () => {}, onerror: () => {},
|
||||
objectStore: () => ({
|
||||
get: () => {
|
||||
const get = { result: stored, onsuccess: () => {} };
|
||||
queueMicrotask(() => {
|
||||
get.onsuccess();
|
||||
setImmediate(() => {
|
||||
if (mode === 'readwrite' && (permanentFailure || failWrites-- > 0)) {
|
||||
tx.error = new DOMException('Simulated storage failure', permanentFailure ? 'QuotaExceededError' : 'AbortError');
|
||||
tx.onabort();
|
||||
} else {
|
||||
if (replacement) stored = structuredClone(replacement);
|
||||
commits++;
|
||||
tx.oncomplete();
|
||||
}
|
||||
});
|
||||
});
|
||||
return get;
|
||||
},
|
||||
put: (value: Wallet) => { replacement = value; },
|
||||
}),
|
||||
};
|
||||
return tx;
|
||||
},
|
||||
}, onsuccess: () => {} };
|
||||
queueMicrotask(() => request.onsuccess());
|
||||
return request;
|
||||
},
|
||||
} });
|
||||
try {
|
||||
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);
|
||||
const { backup } = await fixture;
|
||||
const expected = await decryptWallet(backup, password);
|
||||
installNavigator(true);
|
||||
failWrites = 1;
|
||||
const before = commits;
|
||||
const recovered = await restoreWallet(backup, password);
|
||||
assert.ok(commits > before);
|
||||
assert.equal(stored?.address, expected.address);
|
||||
assert.equal(recovered.address, expected.address);
|
||||
assert.equal((await getWallet()).address, expected.address);
|
||||
assert.ok(lockCalls >= 2);
|
||||
assert.ok(opened.every(name => name === 'wikideck-testnet-wallet-v2'));
|
||||
permanentFailure = true;
|
||||
await assert.rejects(restoreWallet(await exportWallet(initial[0], password), password), { name: 'QuotaExceededError' });
|
||||
assert.equal(stored?.address, expected.address);
|
||||
permanentFailure = false;
|
||||
await assert.rejects(restoreWallet(backup, randomBytes(24).toString('base64')));
|
||||
assert.equal((await getWallet()).address, expected.address);
|
||||
failWrites = 5;
|
||||
const attempts = opened.length;
|
||||
await assert.rejects(restoreWallet(backup, password), { name: 'AbortError' });
|
||||
assert.equal(opened.length - attempts, 3);
|
||||
failWrites = 0;
|
||||
assert.equal((await restoreWallet(backup, password)).address, expected.address);
|
||||
} finally {
|
||||
if (originalIDB) Object.defineProperty(globalThis, 'indexedDB', originalIDB);
|
||||
else Reflect.deleteProperty(globalThis, 'indexedDB');
|
||||
if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator);
|
||||
else Reflect.deleteProperty(globalThis, 'navigator');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user