Canonical indexes and recoverable market observation

This commit is contained in:
2026-10-20 14:35:45 -04:00
parent fc65e497a8
commit e48081f041
11 changed files with 583 additions and 54 deletions
+4
View File
@@ -288,6 +288,10 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
assert.equal(aliceDeck.tip, block2.hash);
assert.equal(aliceDeck.total, 2);
assert.deepEqual(aliceDeck.nfts.map(item => item.block.tokenId), [block1.tokenId, block2.tokenId]);
assert.equal((await json<{ owner: string; sequence: number }>(first.base, `/api/nfts/${block1.tokenId}`)).owner, alice.owner);
await request(first.base, '/api/nfts/not-a-token', 400);
await request(first.base, `/api/decks/${alice.owner}?tip=${block1.hash}`, 409);
assert.equal((await json<{ total: number }>(first.base, `/api/decks/${alice.owner}?tip=${block2.hash}&limit=1`)).total, 2);
const market = await json<{ info: MarketInfo; listings: MarketListingV3[] }>(first.base, '/api/market');
assert.equal(market.info.paymentMode, 'mock');
assert.deepEqual(market.listings, []);
+77 -5
View File
@@ -10,7 +10,8 @@ import { mockQuote, paymentMode } from '../server/market/payments.ts';
import { ADDITIONAL_MARKET_FEE, marketMessage, parseUsd, type MarketAction } from '../shared/market.ts';
import { marketActionV3Schema, marketMessageV3, type MarketActionV3, type MarketActivityV3,
type MarketListingV3, type MarketOrderV3 } from '../shared/market-v3.ts';
import { NETWORK, nftTransferMessage, type NftTransfer, type TestnetSnapshot, type TestnetBlock } from '../shared/testnet.ts';
import { NETWORK, nftTransferMessage, testnetOwnerOf, transactionCardIds, type NftTransfer, type TestnetSnapshot, type TestnetBlock } from '../shared/testnet.ts';
import type { CanonicalEvent } from '../server/testnet/canonical-index.ts';
import { address, CHAIN_ID, GENESIS_HASH, transferId, verifySignature } from '../server/testnet/consensus.ts';
function identity() {
@@ -35,11 +36,37 @@ function chainFixture() {
return id;
} };
}
async function fixture(t: { after(fn: () => Promise<void>): void }) {
async function fixture(t: { after(fn: () => Promise<void>): void }, indexed = false) {
const dir = await mkdtemp(join(tmpdir(), 'wikideck-market-'));
const chain = chainFixture();
// Test-only indexed adapter: production indexes/events are exercised separately by Ledger tests.
const events: CanonicalEvent[] = [];
const streamId = randomUUID();
const adapter = indexed ? { ...chain,
snapshot: (): TestnetSnapshot => { throw new Error('Indexed market must not request a full chain snapshot'); },
summary: () => ({ tip: chain.state.tip, revision: events.length, height: chain.state.blocks.length }),
pendingTransactions: () => structuredClone(chain.state.pendingTransfers),
nft: (token: string) => {
const b = chain.state.blocks.find(b => b.tokenId === token);
return b ? { block: b, owner: testnetOwnerOf(chain.state, token)!,
sequence: chain.state.transfers.filter(tx => transactionCardIds(tx).includes(token)).length,
confirmations: chain.state.blocks.length + 1 - b.height } : undefined;
},
canonicalTransaction: (id: string) => {
const b = chain.state.blocks.find(b => b.transactions.some(tx => transferId(tx as NftTransfer) === id));
return b ? { height: b.height, blockHash: b.hash ?? 'f'.repeat(64) } : undefined;
},
canonicalHead: () => events.length,
canonicalEvents: (after: number, limit = 64) => {
if (after > events.length) throw new Error('Canonical cursor ahead of restored ledger');
const page = events.slice(after, after + limit), cursor = page.at(-1)?.sequence ?? after;
return { streamId, previousAnchor: after ? events[after - 1].eventHash : GENESIS_HASH,
anchor: page.at(-1)?.eventHash ?? (after ? events[after - 1].eventHash : GENESIS_HASH),
events: page, cursor, head: events.length, caughtUp: cursor === events.length };
},
} : chain;
let time = 1_800_000_000_000;
let market = await Market.open(dir, chain, { feeBps: 250, confirmations: 2 }, () => time);
let market = await Market.open(dir, adapter, { feeBps: 250, confirmations: 2 }, () => time);
t.after(async () => { market.close(); await rm(dir, { recursive: true, force: true }); });
const envelope = (who: typeof alice, action: MarketAction) => {
const terms = { network: NETWORK, chainId: CHAIN_ID, marketId: market.id, actor: who.actor,
@@ -76,12 +103,57 @@ async function fixture(t: { after(fn: () => Promise<void>): void }) {
chain.state.blocks.push({ ...chain.state.blocks[0], height, transactions: [tx] });
chain.state.transfers.push(tx); chain.state.pendingTransfers = [];
};
return { dir, chain, envelope, envelopeV3, run, list, buy, orders, delivery, confirm,
return { dir, chain, events, envelope, envelopeV3, run, list, buy, orders, delivery, confirm,
get market() { return market; }, get now() { return time; }, advance: (ms: number) => { time += ms; },
restart: async () => { market.close(); market = await Market.open(dir, chain,
restart: async () => { market.close(); market = await Market.open(dir, adapter,
{ feeBps: 500, confirmations: 5 }, () => time); } };
}
test('indexed market uses bounded durable event catch-up and never requests full snapshots', async t => {
const f = await fixture(t, true);
for (let i = 1; i <= 130; i++) f.events.push({ sequence: i, type: 'connect', height: i,
hash: 'd'.repeat(64), previousHash: 'e'.repeat(64), transactionIds: [], eventHash: i.toString(16).padStart(64, '0') });
await assert.rejects(f.list(), /catching up/);
assert.equal(f.market.diagnostics().canonicalEventCursor, 64);
f.market.browse(); assert.equal(f.market.diagnostics().canonicalEventCursor, 128);
const l = await f.list(); assert.ok(l.id);
assert.equal(f.market.diagnostics().canonicalEventCursor, 130);
assert.equal(f.market.diagnostics().eventBacklog, false);
await f.restart(); assert.equal(f.market.diagnostics().canonicalEventCursor, 130);
f.market.browse(); assert.equal(f.market.diagnostics().canonicalEventCursor, 130);
const anchor = f.events[129].eventHash;
f.events[129].eventHash = 'f'.repeat(64); f.advance(1001);
assert.throws(() => f.market.browse(), /history changed/);
f.events[129].eventHash = anchor;
f.events.length = 0;
assert.throws(() => f.market.browse(), /cursor ahead/);
});
test('disconnect cursor and payout hold commit together and survive a crash/restart', async t => {
const f = await fixture(t, true), listing = await f.list(), order = await f.buy(listing);
await f.run(bob, { type: 'mock-pay', orderId: order.id });
const delivery = f.delivery(order); f.confirm(delivery);
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: f.chain.state.blocks.length + 1 });
f.events.push({ sequence: 1, type: 'connect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64), transactionIds: [transferId(delivery)], eventHash: '1'.repeat(64) });
assert.equal((await f.orders())[0].status, 'complete');
// The same delivery may already be canonical again; the intermediate disconnect must not be lost.
f.events.push({ sequence: 2, type: 'disconnect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64), transactionIds: [transferId(delivery)], eventHash: '2'.repeat(64) });
const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
try {
db.exec("CREATE TRIGGER fail_cursor BEFORE UPDATE ON market_meta WHEN NEW.key='canonical_event_cursor' BEGIN SELECT RAISE(ABORT,'injected cursor failure'); END");
assert.throws(() => f.market.browse(), /injected cursor failure/);
const persisted = db.prepare('SELECT data FROM orders WHERE id=?').get(order.id) as { data: string };
assert.equal(JSON.parse(persisted.data).status, 'complete');
assert.equal(f.market.diagnostics().canonicalEventCursor, 1);
db.exec('DROP TRIGGER fail_cursor');
} finally { db.close(); }
await f.restart();
const reviewed = (await f.orders())[0];
assert.equal(reviewed.status, 'review'); assert.equal(reviewed.payout, 'review');
assert.equal(f.market.diagnostics().canonicalEventCursor, 2);
await f.restart(); assert.equal((await f.orders())[0].status, 'review');
});
test('USD and mock XMR accounting use bounded integer amounts and exact fee splitting', () => {
assert.equal(parseUsd('15.01'), 1501); assert.equal(parseUsd('0.01'), 1);
for (const invalid of ['0', '-1', '1e3', '1.001', ' 1', 'NaN', '1000001', '01.00']) assert.throws(() => parseUsd(invalid));
+91 -1
View File
@@ -12,7 +12,7 @@ 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,
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';
@@ -259,6 +259,11 @@ test('signed multi-card trade is validated, mempooled, mined, and applied atomic
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);
@@ -291,14 +296,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));
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 => {