V2.1 (#21)
Working version Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
+336
-39
@@ -6,13 +6,15 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import dns from 'node:dns/promises';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { Ledger } from '../server/testnet/ledger.ts';
|
||||
import { PeerNetwork } from '../server/testnet/peers.ts';
|
||||
import { CHAIN_ID, GENESIS_HASH, GENESIS_TIME, MAX_TARGET, address, makeTemplate, miningBlob,
|
||||
mintId, sha256, stateRoot, transferId, transition, workFor } from '../server/testnet/consensus.ts';
|
||||
import { NETWORK, CONSENSUS, blockClaimMessage, nftTransferMessage, testnetOwnerOf,
|
||||
type MiningTemplate, type NftState, type NftTransfer, type TestnetBlock } from '../shared/testnet.ts';
|
||||
mintId, sha256, stateRoot, transferId, transition, validateBlock, workFor } from '../server/testnet/consensus.ts';
|
||||
import { NETWORK, CONSENSUS, blockClaimMessage, nftTransferMessage, testnetOwnerOf, transactionCardIds,
|
||||
nftTradeMessage, type MiningTemplate, type NftState, type NftStateLayer, type NftStateView,
|
||||
type NftTrade, type NftTransfer, type TestnetBlock } from '../shared/testnet.ts';
|
||||
import { deriveStats } from '../shared/enrichment.ts';
|
||||
import type { PowEngine } from '../server/testnet/randomx.ts';
|
||||
|
||||
@@ -47,7 +49,7 @@ async function mine(template: MiningTemplate, miner: Identity = alice): Promise<
|
||||
}
|
||||
throw new Error('Test nonce search exhausted');
|
||||
}
|
||||
type Branch = { blocks: TestnetBlock[]; state: NftState };
|
||||
type Branch = { blocks: TestnetBlock[]; state: NftStateView };
|
||||
function branch(): Branch { return { blocks: [], state: {} }; }
|
||||
function articleFor(_ancestors: TestnetBlock[]) {
|
||||
const pageId = 50_000;
|
||||
@@ -75,12 +77,21 @@ function transfer(cardHash: string, from: Identity = alice, to: Identity = bob,
|
||||
sequence, publicKey: from.publicKey };
|
||||
return { ...tx, signature: from.sign(nftTransferMessage(tx)) };
|
||||
}
|
||||
function trade(offered: TestnetBlock[], requested: TestnetBlock[], proposer: Identity = alice,
|
||||
counterparty: Identity = bob): NftTrade {
|
||||
const terms = { network: NETWORK, chainId: CHAIN_ID, proposer: proposer.owner, counterparty: counterparty.owner,
|
||||
offered: offered.map(block => ({ cardHash: block.tokenId, sequence: 0 })),
|
||||
requested: requested.map(block => ({ cardHash: block.tokenId, sequence: 0 })) };
|
||||
const message = nftTradeMessage(terms);
|
||||
return { type: 'trade', ...terms, proposerPublicKey: proposer.publicKey, proposerSignature: proposer.sign(message),
|
||||
counterpartyPublicKey: counterparty.publicKey, counterpartySignature: counterparty.sign(message) };
|
||||
}
|
||||
async function fixture(t: { after: (fn: () => Promise<void>) => void }) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wikideck-testnet-'));
|
||||
t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
const engine = new FakePow();
|
||||
const ledger = await Ledger.open(dir, engine);
|
||||
return { dir, ledger, engine, file: join(dir, 'testnet', 'ledger.json') };
|
||||
t.after(async () => { ledger.close(); engine.close(); await rm(dir, { recursive: true, force: true }); });
|
||||
return { dir, ledger, engine, file: join(dir, 'testnet', 'ledger.sqlite') };
|
||||
}
|
||||
|
||||
test('testnet genesis is empty, manifest-bound, isolated and defensively copied', async t => {
|
||||
@@ -99,23 +110,98 @@ test('testnet genesis is empty, manifest-bound, isolated and defensively copied'
|
||||
assert.deepEqual(initial.pendingTransfers, []);
|
||||
assert.equal(stateRoot({}), sha256(`${NETWORK}:empty`));
|
||||
await assert.rejects(readFile(join(dir, 'chain.json')), /ENOENT/);
|
||||
assert.equal(JSON.parse(await readFile(file, 'utf8')).version, 1);
|
||||
if (process.platform !== 'win32') assert.equal((await stat(file)).mode & 0o777, 0o600);
|
||||
assert.equal((await stat(file)).isFile(), true);
|
||||
const database = new DatabaseSync(file);
|
||||
assert.equal((database.prepare('SELECT COUNT(*) AS count FROM blocks').get() as { count: number }).count, 0);
|
||||
database.close();
|
||||
const candidate = await mine(ledger.template(alice.owner, alice.publicKey, articleFor([]), statsSnapshot()));
|
||||
assert.deepEqual(await ledger.submit(candidate), { accepted: true, reorg: false });
|
||||
const persisted = new DatabaseSync(file);
|
||||
assert.equal((persisted.prepare('SELECT COUNT(*) AS count FROM blocks').get() as { count: number }).count, 1);
|
||||
persisted.close();
|
||||
candidate.article.title = 'caller mutation';
|
||||
const copy = ledger.block(candidate.hash)!;
|
||||
copy.article.title = 'getter mutation';
|
||||
assert.notEqual(ledger.block(candidate.hash)!.article.title, copy.article.title);
|
||||
assert.equal(ledger.size, 1);
|
||||
const summary=ledger.summary();
|
||||
assert.equal(summary.height,1);assert.equal(summary.tip,candidate.hash);
|
||||
const page=ledger.canonicalPage(summary.tip,0,1);
|
||||
assert.equal(page.blocks.length,1);assert.equal(page.next,null);
|
||||
page.blocks[0].article.title='mutated page';
|
||||
assert.notEqual(ledger.canonicalPage(summary.tip,0).blocks[0].article.title,'mutated page');
|
||||
assert.throws(()=>ledger.canonicalPage(GENESIS_HASH,0),/tip changed/);
|
||||
assert.throws(()=>ledger.canonicalPage(summary.tip,0,17),/Invalid/);
|
||||
assert.throws(()=>ledger.legacySnapshot(0),/Full snapshot disabled/);
|
||||
assert.equal(ledger.legacySnapshot(1).blocks.length,1);
|
||||
assert.throws(() => ledger.inventory(-1, 32));
|
||||
assert.throws(() => ledger.inventory(0, 0));
|
||||
});
|
||||
|
||||
test('timestamp attack boundaries reject at median and beyond future limit before proof work',async t=>{
|
||||
const {ledger,engine}=await fixture(t);
|
||||
const now=GENESIS_TIME+CONSENSUS.targetSeconds*20;
|
||||
t.mock.method(Date,'now',()=>now*1000);
|
||||
const candidate=await mine(ledger.template(alice.owner,alice.publicKey,articleFor([]),statsSnapshot()));
|
||||
const calls=engine.calls;
|
||||
await assert.rejects(ledger.submit({...candidate,createdAt:GENESIS_TIME}),/timestamp/);
|
||||
await assert.rejects(ledger.submit({...candidate,createdAt:now+CONSENSUS.maxFutureSeconds+1}),/timestamp/);
|
||||
assert.equal(engine.calls,calls);assert.equal(ledger.size,0);
|
||||
// Full validation reaches commitment checks for the exact allowed future edge, not timestamp rejection.
|
||||
await assert.rejects(ledger.submit({...candidate,createdAt:now+CONSENSUS.maxFutureSeconds}),/block ID/);
|
||||
assert.equal(engine.calls,calls);
|
||||
});
|
||||
|
||||
test('historical replay survives a local clock rollback after accepting the future-skew boundary',async t=>{
|
||||
const dir=await mkdtemp(join(tmpdir(),'wikideck-clock-replay-'));
|
||||
const admissionTime=GENESIS_TIME+CONSENSUS.targetSeconds*20;
|
||||
const powEngine=new FakePow();
|
||||
const ledger=await Ledger.open(dir,powEngine,()=>admissionTime);
|
||||
const template=makeTemplate([],{},alice.owner,alice.publicKey,[],articleFor([]),statsSnapshot(),
|
||||
admissionTime+CONSENSUS.maxFutureSeconds);
|
||||
const block=await mine(template);
|
||||
assert.equal(block.createdAt,admissionTime+CONSENSUS.maxFutureSeconds);
|
||||
assert.deepEqual(await ledger.submit(block),{accepted:true,reorg:false});
|
||||
const accepted=ledger.snapshot();ledger.close();
|
||||
const restarted=await Ledger.open(dir,new FakePow(),()=>admissionTime-CONSENSUS.maxFutureSeconds-1);
|
||||
t.after(async()=>{restarted.close();await rm(dir,{recursive:true,force:true});});
|
||||
assert.deepEqual(restarted.snapshot(),accepted);
|
||||
});
|
||||
|
||||
test('legacy JSON import cannot seed future-dated blocks, even after restart',async t=>{
|
||||
const dir=await mkdtemp(join(tmpdir(),'wikideck-future-legacy-'));
|
||||
const admissionTime=GENESIS_TIME+CONSENSUS.targetSeconds*20;
|
||||
const networkDir=join(dir,'testnet');await mkdir(networkDir,{recursive:true});
|
||||
const template=makeTemplate([],{},alice.owner,alice.publicKey,[],articleFor([]),statsSnapshot(),
|
||||
admissionTime+CONSENSUS.maxFutureSeconds+1);
|
||||
const futureBlock=await mine(template);
|
||||
await writeFile(join(networkDir,'ledger.json'),JSON.stringify({version:1,network:NETWORK,chainId:CHAIN_ID,
|
||||
blocks:[futureBlock],pendingTransfers:[]}));
|
||||
for(let attempt=0;attempt<2;attempt++) {
|
||||
await assert.rejects(Ledger.open(dir,new FakePow(),()=>admissionTime),/timestamp/);
|
||||
}
|
||||
t.after(async()=>{await rm(dir,{recursive:true,force:true});});
|
||||
});
|
||||
|
||||
test('invalid claimed RandomX proof is rejected before full NFT-state enumeration',async()=>{
|
||||
const template=makeTemplate([],{},alice.owner,alice.publicKey,[],articleFor([]),statsSnapshot());
|
||||
const badHeader={...template.header,stateRoot:'f'.repeat(64)};
|
||||
const blob=miningBlob(badHeader),hash=sha256(blob);
|
||||
const candidate:TestnetBlock={...badHeader,hash,tokenId:mintId(badHeader),powHash:'0'.repeat(64),
|
||||
signature:alice.sign(blockClaimMessage(hash)),transactions:[]};
|
||||
let enumerations=0,hashCalls=0;
|
||||
const parentState=new Proxy(Object.create(null) as NftState,{ownKeys(target){enumerations++;return Reflect.ownKeys(target);}});
|
||||
const verifier:PowEngine={async hash(){hashCalls++;return 'f'.repeat(64);},close(){}};
|
||||
await assert.rejects(validateBlock(candidate,[],parentState,verifier),/Invalid RandomX proof/);
|
||||
assert.equal(hashCalls,1);
|
||||
assert.equal(enumerations,0,'invalid work must be rejected before materializing the full NFT state');
|
||||
});
|
||||
|
||||
test('DAG retains equal-work branches, serializes duplicates and selects strictly greater cumulative work', async t => {
|
||||
const { ledger, dir } = await fixture(t);
|
||||
t.mock.method(Date, 'now', () => (GENESIS_TIME + CONSENSUS.targetSeconds * 20) * 1000);
|
||||
const slow = branch(), fast = branch();
|
||||
for (let i = 0; i < 12; i++) await ledger.submit(await extend(slow, alice, [], 30));
|
||||
for (let i = 0; i < 12; i++) await ledger.submit(await extend(slow, alice, [], CONSENSUS.targetSeconds));
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const result = await ledger.submit(await extend(fast, bob, [], 1));
|
||||
assert.equal(result.reorg, false);
|
||||
@@ -136,10 +222,30 @@ test('DAG retains equal-work branches, serializes duplicates and selects strictl
|
||||
for (const b of stored) { assert.ok(seen.has(b.previousHash)); seen.add(b.hash); }
|
||||
assert.deepEqual(ledger.inventory(10, 3), stored.slice(10, 13));
|
||||
const restart = await Ledger.open(dir, new FakePow());
|
||||
t.after(async () => { restart.close(); });
|
||||
assert.deepEqual(restart.snapshot(), ledger.snapshot());
|
||||
assert.deepEqual(restart.allBlocks, stored);
|
||||
});
|
||||
|
||||
test('a deeper competing fork disconnects the old chain only after cumulative work wins',async t=>{
|
||||
const {ledger}=await fixture(t);const original=branch(),competitor=branch();
|
||||
t.mock.method(Date,'now',()=>(GENESIS_TIME+CONSENSUS.targetSeconds*100)*1000);
|
||||
for(let i=0;i<30;i++)await ledger.submit(await extend(original,alice,[],CONSENSUS.targetSeconds));
|
||||
assert.equal(ledger.snapshot().tip,original.blocks.at(-1)!.hash);
|
||||
for(let i=0;i<15;i++){
|
||||
const result=await ledger.submit(await extend(competitor,bob,[],1));
|
||||
assert.equal(result.reorg,false);
|
||||
assert.equal(ledger.snapshot().tip,original.blocks.at(-1)!.hash);
|
||||
}
|
||||
assert.equal(competitor.blocks.at(-1)!.height,15);
|
||||
assert.notEqual(competitor.blocks.at(-1)!.target,MAX_TARGET);
|
||||
const winning=await ledger.submit(await extend(competitor,bob,[],1));
|
||||
assert.deepEqual(winning,{accepted:true,reorg:true});
|
||||
assert.ok(ledger.snapshot().cumulativeWork===competitor.blocks.reduce((sum,block)=>sum+workFor(block.target),0n).toString());
|
||||
assert.deepEqual(ledger.snapshot().blocks,competitor.blocks);
|
||||
assert.equal(ledger.size,46,'both branches remain available in the DAG');
|
||||
});
|
||||
|
||||
test('equal-work ties preserve the current tip across restart and only extension triggers reorg', async t => {
|
||||
const { dir, ledger } = await fixture(t);
|
||||
const a = branch(), b = branch();
|
||||
@@ -148,6 +254,7 @@ test('equal-work ties preserve the current tip across restart and only extension
|
||||
assert.deepEqual(await ledger.submit(b1), { accepted: true, reorg: false });
|
||||
assert.equal(ledger.snapshot().tip, a1.hash);
|
||||
const restart = await Ledger.open(dir, new FakePow());
|
||||
t.after(async () => { restart.close(); });
|
||||
assert.equal(restart.snapshot().tip, a1.hash);
|
||||
const b2 = await extend(b, bob);
|
||||
assert.deepEqual(await restart.submit(b2), { accepted: true, reorg: true });
|
||||
@@ -169,6 +276,7 @@ test('NFT maturity, ordered mempool, block commitments and replay protection', a
|
||||
assert.deepEqual(ledger.snapshot().pendingTransfers, [tx, tx2]);
|
||||
assert.equal(testnetOwnerOf(ledger.snapshot(), minted.tokenId), alice.owner); // Unconfirmed is not ownership.
|
||||
const restarted = await Ledger.open(dir, new FakePow());
|
||||
t.after(async () => { restarted.close(); });
|
||||
assert.deepEqual(restarted.snapshot().pendingTransfers, [tx, tx2]);
|
||||
const block = await mine(restarted.template(alice.owner, alice.publicKey,
|
||||
articleFor(restarted.snapshot().blocks), statsSnapshot()));
|
||||
@@ -181,6 +289,56 @@ test('NFT maturity, ordered mempool, block commitments and replay protection', a
|
||||
await assert.rejects(restarted.submitTransfer(transfer(minted.tokenId, carol, alice, 0)), /owner\/sequence/);
|
||||
});
|
||||
|
||||
test('signed multi-card trade is validated, mempooled, mined, and applied atomically', async t => {
|
||||
const { ledger } = await fixture(t);
|
||||
const chain = branch();
|
||||
const aliceFirst = await extend(chain, alice); await ledger.submit(aliceFirst);
|
||||
const bobFirst = await extend(chain, bob); await ledger.submit(bobFirst);
|
||||
const aliceSecond = await extend(chain, alice); await ledger.submit(aliceSecond);
|
||||
const bobSecond = await extend(chain, bob); await ledger.submit(bobSecond);
|
||||
await ledger.submit(await extend(chain, alice)); // All four NFTs are mature at the next inclusion height.
|
||||
|
||||
const offered = [aliceFirst, aliceSecond], requested = [bobFirst, bobSecond];
|
||||
const invalid = trade(offered, requested);
|
||||
const badTerms = { ...invalid, requested: invalid.requested.map((leg, index) =>
|
||||
index === 1 ? { ...leg, sequence: 1 } : leg) };
|
||||
const badMessage = nftTradeMessage(badTerms);
|
||||
const badSequence = { ...badTerms, proposerSignature: alice.sign(badMessage), counterpartySignature: bob.sign(badMessage) };
|
||||
const before = ledger.snapshot();
|
||||
await assert.rejects(ledger.submitTransaction(badSequence), /missing, stale, or immature/);
|
||||
const badSignature = { ...invalid, proposerSignature: bob.sign(nftTradeMessage(invalid)) };
|
||||
await assert.rejects(ledger.submitTransaction(badSignature), /signature/i);
|
||||
assert.deepEqual(ledger.snapshot().pendingTransfers, []);
|
||||
for (const block of offered) assert.equal(testnetOwnerOf(before, block.tokenId), alice.owner);
|
||||
for (const block of requested) assert.equal(testnetOwnerOf(before, block.tokenId), bob.owner);
|
||||
|
||||
const bundle = trade(offered, requested);
|
||||
await ledger.submitTransaction(bundle);
|
||||
assert.deepEqual(ledger.snapshot().pendingTransfers, [bundle]);
|
||||
const template = ledger.template(alice.owner, alice.publicKey, articleFor(ledger.snapshot().blocks), statsSnapshot());
|
||||
assert.deepEqual(template.transactions, [bundle]);
|
||||
await ledger.submit(await mine(template));
|
||||
const after = ledger.snapshot();
|
||||
assert.equal(after.pendingTransfers.length, 0);
|
||||
for (const block of offered) assert.equal(testnetOwnerOf(after, block.tokenId), bob.owner);
|
||||
for (const block of requested) assert.equal(testnetOwnerOf(after, block.tokenId), alice.owner);
|
||||
assert.deepEqual(after.transfers.at(-1), bundle);
|
||||
for (const block of after.blocks) {
|
||||
const indexed = ledger.nft(block.tokenId)!;
|
||||
assert.equal(indexed.owner, testnetOwnerOf(after, block.tokenId));
|
||||
assert.equal(indexed.sequence, after.transfers.filter(tx => transactionCardIds(tx).includes(block.tokenId)).length);
|
||||
}
|
||||
const deck = ledger.deck(alice.owner, 0, 2);
|
||||
assert.equal(deck.total, after.blocks.filter(block => testnetOwnerOf(after, block.tokenId) === alice.owner).length);
|
||||
assert.equal(deck.nfts.length, 2);
|
||||
assert.equal(deck.next, 2);
|
||||
assert.deepEqual(ledger.deck(alice.owner, deck.next!, 2).nfts.map(item => item.block.tokenId).sort(),
|
||||
ledger.deck(alice.owner).nfts.slice(2).map(item => item.block.tokenId).sort());
|
||||
assert.deepEqual(ledger.deck(alice.owner).nfts.map(item => item.block.tokenId).sort(),
|
||||
after.blocks.filter(block => testnetOwnerOf(after, block.tokenId) === alice.owner)
|
||||
.map(block => block.tokenId).sort());
|
||||
});
|
||||
|
||||
test('reorg rolls back NFT ownership and requeues only still-valid disconnected transfers in order', async t => {
|
||||
const { ledger, dir } = await fixture(t);
|
||||
const original = branch();
|
||||
@@ -202,12 +360,99 @@ test('reorg rolls back NFT ownership and requeues only still-valid disconnected
|
||||
assert.equal(testnetOwnerOf(snapshot, committed.tokenId), undefined);
|
||||
assert.deepEqual(snapshot.transfers, []);
|
||||
assert.deepEqual(snapshot.pendingTransfers, [tx, tx2]); // Orphan issuance is discarded.
|
||||
assert.equal(ledger.nft(minted.tokenId)!.owner, alice.owner);
|
||||
assert.equal(ledger.nft(minted.tokenId)!.sequence, 0);
|
||||
assert.equal(ledger.nft(committed.tokenId), undefined);
|
||||
assert.equal(ledger.canonicalTransaction(transferId(tx)), undefined);
|
||||
assert.ok(ledger.block(committed.hash));
|
||||
assert.deepEqual((await Ledger.open(dir, new FakePow())).snapshot(), snapshot);
|
||||
const restored = await Ledger.open(dir, new FakePow());
|
||||
t.after(async () => { restored.close(); });
|
||||
assert.deepEqual(restored.snapshot(), snapshot);
|
||||
assert.equal(restored.canonicalHead(), ledger.canonicalHead());
|
||||
assert.deepEqual(restored.canonicalEvents(0), ledger.canonicalEvents(0));
|
||||
const next = await mine(ledger.template(bob.owner, bob.publicKey,
|
||||
articleFor(ledger.snapshot().blocks), statsSnapshot()), bob);
|
||||
await ledger.submit(next);
|
||||
assert.equal(testnetOwnerOf(ledger.snapshot(), minted.tokenId), carol.owner);
|
||||
assert.equal(ledger.nft(minted.tokenId)!.owner, carol.owner);
|
||||
assert.equal(ledger.nft(minted.tokenId)!.sequence, 2);
|
||||
});
|
||||
|
||||
test('canonical event pages record reorg order durably but exclude side branches, duplicates and mempool', async t => {
|
||||
const { ledger, file, dir } = await fixture(t), original = branch();
|
||||
const first = await extend(original); await ledger.submit(first);
|
||||
const second = await extend(original); await ledger.submit(second);
|
||||
const fork: Branch = structuredClone(original);
|
||||
const tx = transfer(first.tokenId);
|
||||
await ledger.submitTransfer(tx);
|
||||
const beforePending = ledger.canonicalHead();
|
||||
assert.equal(beforePending, 2);
|
||||
const third = await extend(original, alice, [tx]); await ledger.submit(third);
|
||||
assert.equal(ledger.canonicalTransaction(transferId(tx))?.blockHash, third.hash);
|
||||
const competing = await extend(fork, bob); await ledger.submit(competing);
|
||||
assert.equal(ledger.canonicalHead(), 3); // Equal-work branch does not change canonical state.
|
||||
const winner = await extend(fork, bob); await ledger.submit(winner);
|
||||
assert.equal(ledger.canonicalHead(), 6);
|
||||
assert.equal(ledger.canonicalTransaction(transferId(tx)), undefined);
|
||||
const events = ledger.canonicalEvents(0).events;
|
||||
assert.deepEqual(events.map(e => [e.type, e.hash]), [
|
||||
['connect', first.hash], ['connect', second.hash], ['connect', third.hash],
|
||||
['disconnect', third.hash], ['connect', competing.hash], ['connect', winner.hash],
|
||||
]);
|
||||
assert.deepEqual(events[3].transactionIds, [transferId(tx)]);
|
||||
const page = ledger.canonicalEvents(2, 1);
|
||||
assert.equal(page.cursor, 3); assert.equal(page.caughtUp, false); assert.equal(page.head, 6);
|
||||
page.events[0].transactionIds.length = 0;
|
||||
assert.equal(ledger.canonicalEvents(2, 1).events[0].transactionIds.length, 1);
|
||||
for (const [after, limit] of [[-1, 1], [7, 1], [0, 65], [0, 0], [0.5, 1]]) {
|
||||
assert.throws(() => ledger.canonicalEvents(after, limit), /cursor/);
|
||||
}
|
||||
await ledger.submit(winner); assert.equal(ledger.canonicalHead(), 6);
|
||||
ledger.close();
|
||||
const reopened = await Ledger.open(dir, new FakePow());
|
||||
try {
|
||||
assert.equal(reopened.canonicalHead(), 6); assert.deepEqual(reopened.canonicalEvents(0).events, events);
|
||||
const db = new DatabaseSync(file);
|
||||
try { assert.throws(() => db.exec("UPDATE canonical_events SET type='connect'"), /Immutable/); } finally { db.close(); }
|
||||
} finally { reopened.close(); }
|
||||
});
|
||||
|
||||
test('block, mempool, canonical indexes and events roll back together on storage failure', async t => {
|
||||
const { ledger, file } = await fixture(t), chain = branch();
|
||||
const candidate = await extend(chain), db = new DatabaseSync(file);
|
||||
try {
|
||||
db.exec("CREATE TRIGGER fail_event BEFORE INSERT ON canonical_events BEGIN SELECT RAISE(ABORT,'injected storage failure'); END");
|
||||
await assert.rejects(ledger.submit(candidate), /injected storage failure/);
|
||||
assert.equal(ledger.size, 0); assert.equal(ledger.canonicalHead(), 0); assert.equal(ledger.nft(candidate.tokenId), undefined);
|
||||
assert.equal((db.prepare('SELECT COUNT(*) AS n FROM blocks').get() as { n: number }).n, 0);
|
||||
db.exec('DROP TRIGGER fail_event');
|
||||
await ledger.submit(candidate);
|
||||
assert.equal(ledger.nft(candidate.tokenId)!.owner, alice.owner); assert.equal(ledger.canonicalHead(), 1);
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
test('validated existing ledgers backfill indexes once and refuse corrupt ownership or event history', async t => {
|
||||
const { ledger, file, dir } = await fixture(t), chain = branch();
|
||||
await ledger.submit(await extend(chain)); await ledger.submit(await extend(chain));
|
||||
const expected = ledger.snapshot(); ledger.close();
|
||||
const db = new DatabaseSync(file);
|
||||
try {
|
||||
// Simulate the prior schema: preserve original blocks/metadata/pending exactly, remove only new derived tables.
|
||||
db.exec('DROP TABLE canonical_undo; DROP TABLE canonical_transactions; DROP TABLE canonical_nfts; DROP TABLE canonical_blocks; DROP TABLE canonical_events; DROP TABLE canonical_meta;');
|
||||
} finally { db.close(); }
|
||||
const migrated = await Ledger.open(dir, new FakePow());
|
||||
assert.deepEqual(migrated.snapshot(), expected); assert.equal(migrated.canonicalHead(), 2); migrated.close();
|
||||
const corrupt = new DatabaseSync(file);
|
||||
try { corrupt.prepare('UPDATE canonical_nfts SET owner=? WHERE token=?').run(bob.owner, chain.blocks[0].tokenId); }
|
||||
finally { corrupt.close(); }
|
||||
await assert.rejects(Ledger.open(dir, new FakePow()), /ownership index mismatch/);
|
||||
const repair = new DatabaseSync(file);
|
||||
try {
|
||||
repair.prepare('UPDATE canonical_nfts SET owner=? WHERE token=?').run(alice.owner, chain.blocks[0].tokenId);
|
||||
repair.exec('DROP TRIGGER canonical_events_no_update');
|
||||
repair.exec("UPDATE canonical_events SET transactions_json='[\"wrong\"]' WHERE sequence=1");
|
||||
} finally { repair.close(); }
|
||||
await assert.rejects(Ledger.open(dir, new FakePow()), /event integrity mismatch/);
|
||||
});
|
||||
|
||||
test('reorg removes mempool conflicts and does not requeue already committed transfers', async t => {
|
||||
@@ -252,53 +497,88 @@ test('invalid parent, manifest, proof, article/stat snapshots, and commitments l
|
||||
assert.equal(ledger.size, 2);
|
||||
});
|
||||
|
||||
test('startup rechecks every proof, rejects tampering and mismatched manifests without resetting disk', async t => {
|
||||
test('block state is stored as a one-entry delta that shares its parent state layer', async () => {
|
||||
const parent: NftState = Object.assign(Object.create(null) as NftState, {
|
||||
['a'.repeat(64)]: { owner: alice.owner, sequence: 0, mintedHeight: 1, pageId: 50_000 },
|
||||
});
|
||||
const chain: Branch = { blocks: [], state: parent };
|
||||
await extend(chain);
|
||||
const layer = chain.state as NftStateLayer;
|
||||
assert.equal(layer.kind, 'layer');
|
||||
assert.equal(layer.parent, parent);
|
||||
assert.equal(Object.keys(layer.changes).length, 1);
|
||||
});
|
||||
|
||||
test('SQLite startup rechecks every proof and fails closed on database corruption or manifest mismatch', async t => {
|
||||
const { dir, ledger, file } = await fixture(t);
|
||||
const chain = branch();
|
||||
await ledger.submit(await extend(chain));
|
||||
await ledger.submit(await extend(chain));
|
||||
const checking = new FakePow();
|
||||
await Ledger.open(dir, checking);
|
||||
const verified = await Ledger.open(dir, checking);
|
||||
t.after(async () => { verified.close(); });
|
||||
assert.equal(checking.calls, 2);
|
||||
const original = await readFile(file, 'utf8');
|
||||
const disk = JSON.parse(original);
|
||||
for (const changed of [ { ...disk, chainId: 'ff'.repeat(32) }, { ...disk, network: 'wrong' },
|
||||
{ ...disk, version: 2 }, { ...disk, blocks: [...disk.blocks].reverse() },
|
||||
{ ...disk, blocks: [disk.blocks[0], disk.blocks[0]] },
|
||||
{ ...disk, blocks: [{ ...disk.blocks[0], stateRoot: '00'.repeat(32) }] },
|
||||
{ ...disk, pendingTransfers: [transfer(chain.blocks[0].tokenId, bob)] } ]) {
|
||||
const text = JSON.stringify(changed);
|
||||
await writeFile(file, text);
|
||||
await assert.rejects(Ledger.open(dir, new FakePow()));
|
||||
assert.equal(await readFile(file, 'utf8'), text);
|
||||
}
|
||||
await writeFile(file, original);
|
||||
await assert.rejects(Ledger.open(dir, { hash: async () => 'ff'.repeat(32), close() {} }), /RandomX proof/);
|
||||
await writeFile(file, '{truncated');
|
||||
const originalDb = new DatabaseSync(file);
|
||||
const row = originalDb.prepare('SELECT hash, block_json FROM blocks ORDER BY sequence LIMIT 1').get() as { hash: string; block_json: string };
|
||||
const originalBlock = JSON.parse(row.block_json) as TestnetBlock;
|
||||
originalDb.close();
|
||||
verified.close();
|
||||
ledger.close();
|
||||
|
||||
const malformed = new DatabaseSync(file);
|
||||
malformed.prepare('UPDATE blocks SET block_json = ? WHERE hash = ?').run('{truncated', row.hash);
|
||||
malformed.close();
|
||||
await assert.rejects(Ledger.open(dir, new FakePow()));
|
||||
const restoreMalformed = new DatabaseSync(file);
|
||||
restoreMalformed.prepare('UPDATE blocks SET block_json = ? WHERE hash = ?').run(row.block_json, row.hash);
|
||||
restoreMalformed.close();
|
||||
|
||||
const tampered = new DatabaseSync(file);
|
||||
tampered.prepare('UPDATE blocks SET block_json = ? WHERE hash = ?')
|
||||
.run(JSON.stringify({ ...originalBlock, stateRoot: '00'.repeat(32) }), row.hash);
|
||||
tampered.close();
|
||||
await assert.rejects(Ledger.open(dir, new FakePow()), /block ID/);
|
||||
const restoreTampered = new DatabaseSync(file);
|
||||
restoreTampered.prepare('UPDATE blocks SET block_json = ? WHERE hash = ?').run(row.block_json, row.hash);
|
||||
restoreTampered.prepare("UPDATE metadata SET value = 'wrong' WHERE key = 'network'").run();
|
||||
restoreTampered.close();
|
||||
await assert.rejects(Ledger.open(dir, new FakePow()), /mismatch/);
|
||||
const restoreManifest = new DatabaseSync(file);
|
||||
restoreManifest.prepare('UPDATE metadata SET value = ? WHERE key = ?').run(NETWORK, 'network');
|
||||
restoreManifest.close();
|
||||
await assert.rejects(Ledger.open(dir, { hash: async () => 'ff'.repeat(32), close() {} }), /RandomX proof/);
|
||||
});
|
||||
|
||||
test('failed atomic replacement never commits memory, and queued mutations recover', async t => {
|
||||
test('failed SQLite transaction never commits memory, and later mutations recover', async t => {
|
||||
const { ledger, file } = await fixture(t);
|
||||
const saved = `${file}.saved`;
|
||||
await rename(file, saved);
|
||||
await mkdir(file);
|
||||
const database = new DatabaseSync(file);
|
||||
database.exec("CREATE TRIGGER reject_block BEFORE INSERT ON blocks BEGIN SELECT RAISE(ABORT, 'test persistence failure'); END;");
|
||||
database.close();
|
||||
const valid = await extend(branch());
|
||||
await assert.rejects(ledger.submit(valid));
|
||||
assert.equal(ledger.size, 0);
|
||||
assert.equal(ledger.snapshot().tip, GENESIS_HASH);
|
||||
await rm(file, { recursive: true });
|
||||
await rename(saved, file);
|
||||
const removeTrigger = new DatabaseSync(file);
|
||||
removeTrigger.exec('DROP TRIGGER reject_block');
|
||||
removeTrigger.close();
|
||||
await ledger.submit(valid);
|
||||
assert.equal(ledger.size, 1);
|
||||
});
|
||||
|
||||
test('bounded persistence rejects oversized files before reading or replaying proofs', async t => {
|
||||
const { dir, file } = await fixture(t);
|
||||
await truncate(file, 128 * 1024 * 1024 + 1);
|
||||
const engine = new FakePow();
|
||||
await assert.rejects(Ledger.open(dir, engine), /128 MiB/);
|
||||
assert.equal(engine.calls, 0);
|
||||
test('legacy JSON ledger imports once into SQLite without altering its source file', async t => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wikideck-legacy-ledger-'));
|
||||
const block = await extend(branch());
|
||||
const original = { version: 1, network: NETWORK, chainId: CHAIN_ID, blocks: [block], pendingTransfers: [] };
|
||||
const legacyFile = join(dir, 'testnet', 'ledger.json');
|
||||
await mkdir(join(dir, 'testnet'), { recursive: true });
|
||||
await writeFile(legacyFile, JSON.stringify(original));
|
||||
const ledger = await Ledger.open(dir, new FakePow());
|
||||
t.after(async () => { ledger.close(); await rm(dir, { recursive: true, force: true }); });
|
||||
assert.deepEqual(ledger.snapshot().blocks, [block]);
|
||||
assert.deepEqual(JSON.parse(await readFile(legacyFile, 'utf8')), original);
|
||||
const database = new DatabaseSync(join(dir, 'testnet', 'ledger.sqlite'));
|
||||
assert.equal((database.prepare("SELECT value FROM metadata WHERE key = 'legacy_json_migrated'").get() as { value: string }).value, '1');
|
||||
database.close();
|
||||
});
|
||||
|
||||
test('mempool is ordered, capped at 256, and templates include at most 64 without new issuance', async t => {
|
||||
@@ -321,6 +601,7 @@ test('mempool is ordered, capped at 256, and templates include at most 64 withou
|
||||
assert.deepEqual(ledger.snapshot().pendingTransfers, transactions.slice(64));
|
||||
await assert.rejects(ledger.submitTransfer(transfer(block.tokenId)), /immature/);
|
||||
const restarted = await Ledger.open(dir, new FakePow());
|
||||
t.after(async () => { restarted.close(); });
|
||||
assert.deepEqual(restarted.snapshot(), ledger.snapshot());
|
||||
});
|
||||
|
||||
@@ -374,6 +655,22 @@ test('peer configuration rejects unsafe origins; explicit private permission ena
|
||||
assert.equal(ledger.snapshot().peers, 0);
|
||||
});
|
||||
|
||||
|
||||
test('unknown SQLite files are rejected without adding or replacing tables', async t => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wikideck-foreign-sqlite-'));
|
||||
const file = join(dir, 'testnet', 'ledger.sqlite');
|
||||
await mkdir(join(dir, 'testnet'), { recursive: true });
|
||||
const foreign = new DatabaseSync(file);
|
||||
foreign.exec('CREATE TABLE operator_data(value TEXT NOT NULL); INSERT INTO operator_data VALUES(\'keep\');');
|
||||
foreign.close();
|
||||
t.after(async () => { await rm(dir, { recursive: true, force: true }); });
|
||||
await assert.rejects(Ledger.open(dir, new FakePow()), /metadata missing/);
|
||||
const verify = new DatabaseSync(file);
|
||||
assert.deepEqual((verify.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all() as { name: string }[])
|
||||
.map(row => row.name), ['operator_data']);
|
||||
assert.equal((verify.prepare('SELECT value FROM operator_data').get() as { value: string }).value, 'keep');
|
||||
verify.close();
|
||||
});
|
||||
test('peers verify DNS, manifests, pagination, payloads and consensus; redirects are not followed', async t => {
|
||||
const { ledger } = await fixture(t);
|
||||
const chain = branch();
|
||||
|
||||
Reference in New Issue
Block a user