Mainnet
This commit is contained in:
+10
-8
@@ -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);
|
||||
|
||||
@@ -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[]=[];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user