Canonical indexes and recoverable market observation
This commit is contained in:
+77
-5
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user