This commit is contained in:
2026-10-29 11:45:51 -04:00
parent b3a1179703
commit b427fa4217
35 changed files with 469 additions and 158 deletions
+10 -8
View File
@@ -11,15 +11,16 @@ import { CHAIN_ID, GENESIS_TIME, GENESIS_HASH, MAX_TARGET } from '../server/test
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', ...env }, encoding: 'utf8', timeout: 30000,
env: { ...process.env, WIKIDECK_NETWORK: 'mainnet', WIKIDECK_GENESIS_TIME: '1798761600', ...env }, encoding: 'utf8', timeout: 30000,
}).trim();
}
test('default testnet v6 manifest is unchanged and unknown network profiles fail closed', () => {
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), 'testnet');
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', () => {
@@ -37,7 +38,7 @@ test('mainnet candidate has distinct deterministic genesis, signatures and stric
});
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_MAINNET_LAUNCH: '', WIKIDECK_MAINNET_REVIEW_ACK: '' };
const env = { ...process.env, WIKIDECK_NETWORK: 'mainnet', WIKIDECK_GENESIS_TIME: '1798761600', 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' }));
@@ -48,10 +49,11 @@ test('mainnet gates require genesis time, chain-specific approval, HTTPS, and in
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_REVIEW_ACK:'independent-review-complete',
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]]) {
@@ -59,7 +61,7 @@ test('mainnet gates require genesis time, chain-specific approval, HTTPS, and in
}
console.log(JSON.stringify(results));
`));
assert.deepEqual(result, [false,false,false,false,false,false,true]);
assert.deepEqual(result, [false,false,false,false,false,false,false,true]);
});
test('mainnet ledger uses its isolated folder; testnet backup headers are rejected', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wikideck-network-'));
@@ -89,8 +91,8 @@ test('operator preflight is read-only, fails closed, and does not disclose crede
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_DATA_DIR: dataDir,
WIKIDECK_MAINNET_LAUNCH: '', WIKIDECK_MAINNET_REVIEW_ACK: '', PAYMENT_PROVIDER_API_KEY: secret },
env: { ...process.env, WIKIDECK_NETWORK: 'mainnet', WIKIDECK_GENESIS_TIME: '1798761600', 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);
+8 -1
View File
@@ -2,8 +2,9 @@ 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='0'.repeat(64),chainId='f'.repeat(64);
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()=>{
@@ -18,6 +19,12 @@ test('default browser-style fetch receives the global receiver, not StateClient'
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[]=[];
+66 -2
View File
@@ -11,7 +11,7 @@ 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';
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';
@@ -152,6 +152,51 @@ test('timestamp attack boundaries reject at median and beyond future limit befor
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);
@@ -182,6 +227,25 @@ test('DAG retains equal-work branches, serializes duplicates and selects strictl
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();
@@ -473,7 +537,7 @@ test('SQLite startup rechecks every proof and fails closed on database corruptio
tampered.prepare('UPDATE blocks SET block_json = ? WHERE hash = ?')
.run(JSON.stringify({ ...originalBlock, stateRoot: '00'.repeat(32) }), row.hash);
tampered.close();
await assert.rejects(Ledger.open(dir, new FakePow()), /state commitment/);
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();
+20 -4
View File
@@ -3,6 +3,7 @@ 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 { 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;
@@ -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)) };
@@ -54,7 +56,9 @@ test('Node WebCrypto backup roundtrip preserves SPKI SHA256 identity and signing
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-6');
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);
@@ -64,6 +68,17 @@ test('Node WebCrypto backup roundtrip preserves SPKI SHA256 identity and signing
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 },
@@ -152,9 +167,10 @@ test('wallet persistence commits before restore resolves, isolates demo DB, retr
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) {
@@ -239,7 +255,7 @@ test('wallet persistence commits before restore resolves, isolates demo DB, retr
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'));
assert.ok(opened.every(name => name === dbName));
permanentFailure = true;
const preserved=JSON.stringify(stored);
await assert.rejects(restoreWallet(exported,password,password,true), { name: 'QuotaExceededError' });