Disposable canonical-event inbox rehearsal

This commit is contained in:
2026-10-20 18:50:02 -04:00
parent ca8ff48628
commit e1fbf5e5af
7 changed files with 342 additions and 6 deletions
+161 -1
View File
@@ -21,12 +21,17 @@ import { createBackup } from '../../server/recovery.ts';
import { CUSTODY_POLICY, custodyMessage, type CustodyAction, type CustodyCommand, type CustodyActivity,
type CustodyListing, type CustodyOrder, type CustodyWithdrawal } from '../../shared/custody.ts';
import { NETWORK } from '../../shared/network.ts';
import { CHAIN_ID, address } from '../../server/testnet/consensus.ts';
import { CHAIN_ID, GENESIS_HASH, address, sha256 } from '../../server/testnet/consensus.ts';
import { saleContext, termsForCustodyOrder, saleId } from '../../server/testnet/sale-candidate.ts';
import { SALE_CANDIDATE, saleTermsMessage, saleCompletionMessage, saleCancellationMessage,
type SaleTransaction } from '../../shared/sale-candidate.ts';
import { replayReservedSale, type SaleRehearsal } from '../../server/market/sale-rehearsal.ts';
import { keccak } from 'hash-wasm';
import type { CanonicalInboxPage } from '../../server/market/canonical-inbox.ts';
import { Ledger } from '../../server/testnet/ledger.ts';
import { GENESIS_TIME, makeTemplate, miningBlob, mintId, transition } from '../../server/testnet/consensus.ts';
import { deriveStats } from '../../shared/enrichment.ts';
import { blockClaimMessage, type NftStateView, type TestnetBlock } from '../../shared/testnet.ts';
if (process.env.WIKIDECK_PG_DISPOSABLE_TEST !== '1') throw new Error('PostgreSQL tests require the isolated disposable compose fixture');
const config: PoolConfig = { host: '127.0.0.1', database: 'custody_test', user: 'postgres', ssl: false, max: 4,
@@ -67,6 +72,65 @@ function saleTranscript(order: CustodyOrder, custodianId: string, feeBps = 250)
hostAuthorization: authorization(host, saleCancellationMessage(reference)), buyerAuthorization: authorization(buyer, saleCancellationMessage(reference)) };
return { input, complete, cancel, reservationDigest };
}
function canonicalPage(streamId: string, all: CanonicalInboxPage['events'], start: number, count: number): CanonicalInboxPage {
const events = all.slice(start, start + count), cursor = events.at(-1)?.sequence ?? start;
const previousAnchor = start === 0 ? GENESIS_HASH : all[start - 1].eventHash;
const anchor = events.at(-1)?.eventHash ?? previousAnchor;
return { streamId, previousAnchor, anchor, events, cursor, head: all.length, caughtUp: cursor === all.length };
}
function canonicalEvents(streamId: string) {
const rows: CanonicalInboxPage['events'] = [];
let anchor = GENESIS_HASH;
const add = (type: 'connect' | 'disconnect', height: number, hash: string, previousHash: string, transactionIds: string[] = []) => {
const sequence = rows.length + 1;
const eventHash = sha256(JSON.stringify(['wikideck-canonical-event-v1', streamId, sequence, type,
height, hash, previousHash, transactionIds, anchor]));
rows.push({ sequence, type, height, hash, previousHash, transactionIds, eventHash }); anchor = eventHash;
};
const a = 'a'.repeat(64), b = 'b'.repeat(64), c = 'c'.repeat(64);
add('connect', 1, a, GENESIS_HASH, ['1'.repeat(64)]);
add('disconnect', 1, a, GENESIS_HASH, ['1'.repeat(64)]);
add('connect', 1, b, GENESIS_HASH, ['2'.repeat(64)]);
add('connect', 2, c, b, ['3'.repeat(64)]);
return rows;
}
function reanchorCanonicalEvents(streamId: string, rows: CanonicalInboxPage['events']) {
let anchor = GENESIS_HASH;
for (const event of rows) {
event.eventHash = sha256(JSON.stringify(['wikideck-canonical-event-v1', streamId, event.sequence, event.type,
event.height, event.hash, event.previousHash, event.transactionIds, anchor]));
anchor = event.eventHash;
}
}
class FixturePow {
async hash(key: string, blob: Uint8Array): Promise<string> { return sha256(Buffer.concat([Buffer.from(key, 'hex'), Buffer.from(blob)])); }
close(): void {}
}
function fixtureArticle(pageId = 50000) {
return { pageId, title: 'Canonical inbox fixture', extract: `Synthetic local-only canonical observer test fixture ${pageId}.`,
image: 'https://upload.wikimedia.org/wikipedia/commons/test-image.jpg', url: `https://en.wikipedia.org/?curid=${pageId}`,
revisionId: 1, source: 'live' as const, category: 'Culture' };
}
function fixtureStats() {
const pageBytes = 2048, languageCount = 3;
return { capturedAt: '2026-10-19T00:00:00.000Z', revisionId: 1, pageBytes, languageCount,
languagesTruncated: false, pageviews: { status: 'unavailable' as const, months: [], latestMonth: '2026-09',
total: null, dailyAverage: null, trendPercent: null }, stats: deriveStats(pageBytes, languageCount, null) };
}
async function fixtureBlock(ancestors: TestnetBlock[], state: NftStateView, pageId = 50000) {
const miner = seller;
const template = makeTemplate(ancestors, state, miner.actor, miner.publicKey, [], fixtureArticle(pageId), fixtureStats(),
GENESIS_TIME + (ancestors.length + 1) * 30);
const header = structuredClone(template.header), pow = new FixturePow();
for (header.nonce = 0; header.nonce < 100000; header.nonce++) {
const blob = miningBlob(header), powHash = await pow.hash(header.key, blob);
if (BigInt(`0x${powHash}`) > BigInt(`0x${header.target}`)) continue;
const hash = sha256(blob);
return { ...header, hash, tokenId: mintId(header), powHash,
signature: miner.sign(blockClaimMessage(hash)), transactions: [] } satisfies TestnetBlock;
}
throw new Error('Canonical fixture nonce search exhausted');
}
const options = { feeBps: 250, card: (token: string, owner: string, sequence: number) =>
token === hash && owner === seller.actor && sequence === 0 ? { title: 'PostgreSQL fixture' } : undefined };
function envelope(id: string, who: typeof buyer, action: CustodyAction, nonce: string = randomUUID()): CustodyCommand {
@@ -422,6 +486,7 @@ test('sale rehearsal requires explicit disposable instance consent and exact tra
const o = await f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 }) as CustodyOrder;
const t1 = saleTranscript(o, f.market.id);
assert.throws(() => f.market.observeSaleRehearsal(o.id, t1.input), /unavailable/);
assert.throws(() => f.market.observeCanonicalEvents({}), /unavailable/);
const rehearsal = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
try {
const wrong = structuredClone(t1.input); wrong.lock = { ...t1.input.lock, terms: { ...t1.input.lock.terms, priceAtomicXmr: '1' } } as typeof wrong.lock;
@@ -440,6 +505,101 @@ test('sale rehearsal requires explicit disposable instance consent and exact tra
} finally { await rehearsal.close(); }
});
test('canonical connect/disconnect inbox resumes bounded pages, retries idempotently, and audits after reconnect', async t => {
const f = await fixture(t);
const observer = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
const streamId = randomUUID(), events = canonicalEvents(streamId);
const first = canonicalPage(streamId, events, 0, 2), second = canonicalPage(streamId, events, 2, 2);
try {
const accepted = await observer.observeCanonicalEvents(first);
assert.equal(accepted.cursor, 2); assert.equal(accepted.height, 0); assert.equal(accepted.tip, GENESIS_HASH);
assert.equal((await observer.observeCanonicalEvents(first)).replayed, true);
// A second consumer may have fetched a larger page before the first writer advanced the inbox.
const overlapping = await observer.observeCanonicalEvents(canonicalPage(streamId, events, 0, 4));
assert.equal(overlapping.cursor, 4); assert.equal(overlapping.replayed, false);
const stale = await observer.observeCanonicalEvents({ ...first, head: 2, caughtUp: true });
assert.equal(stale.cursor, 4); assert.equal(stale.head, 4); assert.equal(stale.replayed, true);
const tip = await observer.observeCanonicalEvents(second);
assert.equal(tip.cursor, 4); assert.equal(tip.head, 4); assert.equal(tip.height, 2); assert.equal(tip.tip, 'c'.repeat(64));
assert.equal(tip.replayed, true);
assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'canonical-inbox:v1:event:%'")).rows[0].n, 4);
await observer.audit();
} finally { await observer.close(); }
const reopened = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
try {
const replay = await reopened.observeCanonicalEvents(second);
assert.equal(replay.cursor, 4); assert.equal(replay.height, 2); assert.equal(replay.replayed, true);
await reopened.audit();
} finally { await reopened.close(); }
});
test('gated adapter consumes actual validated local Ledger connect and reorg pages', async t => {
const dir = await mkdtemp(join(tmpdir(), 'wikideck-pg-local-ledger-'));
const pow = new FixturePow(), ledger = await Ledger.open(dir, pow), f = await fixture(t);
const observer = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
t.after(async () => { await observer.close(); ledger.close(); pow.close(); await rm(dir, { recursive: true, force: true }); });
const canonical = await fixtureBlock([], {});
await ledger.submit(canonical);
const first = await observer.observeLedgerCanonicalEvents(ledger);
assert.equal(first.cursor, 1); assert.equal(first.height, 1); assert.equal(first.tip, canonical.hash);
const side = await fixtureBlock([], {}, 50001);
const sideResult = await ledger.submit(side); // Equal-work branch remains noncanonical and emits no event.
assert.equal(sideResult.accepted, true);
assert.equal(ledger.canonicalHead(), 1);
const sideChain = [side]; let sideState = transition({}, side, []), sideChild = side;
let reorgResult = { accepted: true, reorg: false };
for (let i = 0; i < 16 && !reorgResult.reorg; i++) {
sideChild = await fixtureBlock(sideChain, sideState, 50002 + i);
reorgResult = await ledger.submit(sideChild);
sideState = transition(sideState, sideChild, []); sideChain.push(sideChild);
}
assert.equal(reorgResult.reorg, true, `Expected longer-work side branch to win; events=${JSON.stringify(ledger.canonicalEvents(0))}`);
const expectedCursor = sideChain.length + 2; // initial connect, old-tip disconnect, then each side-branch connect
assert.equal(ledger.canonicalHead(), expectedCursor);
const reorg = await observer.observeLedgerCanonicalEvents(ledger);
assert.equal(reorg.cursor, expectedCursor); assert.equal(reorg.height, sideChain.length); assert.equal(reorg.tip, sideChild.hash);
assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'canonical-inbox:v1:event:%'")).rows[0].n, expectedCursor);
await observer.audit();
});
test('canonical inbox rejects a disconnected block with a forged but rehashed ancestor', async t => {
const f = await fixture(t), observer = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
const streamId = randomUUID(), events = canonicalEvents(streamId);
try {
events[1].previousHash = '9'.repeat(64);
reanchorCanonicalEvents(streamId, events);
await assert.rejects(observer.observeCanonicalEvents(canonicalPage(streamId, events, 0, 2)), /disconnect-ancestry-mismatch/);
assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'canonical-inbox:v1:event:%'")).rows[0].n, 0);
await assert.rejects(PostgresCustody.open(f.runtime, options), /unresolved-integrity/);
} finally { await observer.close(); }
});
test('canonical inbox rejects tampered event anchors, persists an integrity hold and refuses another stream', async t => {
const f = await fixture(t), observer = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
const streamId = randomUUID(), events = canonicalEvents(streamId);
try {
const tampered = canonicalPage(streamId, events, 0, 2);
tampered.events[0].transactionIds = ['f'.repeat(64)];
await assert.rejects(observer.observeCanonicalEvents(tampered), /canonical-inbox-event-hash-invalid/);
assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'canonical-inbox:v1:event:%'")).rows[0].n, 0);
await assert.rejects(PostgresCustody.open(f.runtime, options), /unresolved-integrity/);
} finally { await observer.close(); }
});
test('canonical inbox startup audit detects owner-side history mutation and durably fences writers', async t => {
const f = await fixture(t), observer = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
const streamId = randomUUID(), events = canonicalEvents(streamId);
try {
await observer.observeCanonicalEvents(canonicalPage(streamId, events, 0, 2));
const changed = { ...events[0], transactionIds: ['f'.repeat(64)] };
await f.admin.query('UPDATE wk_custody.custody_meta SET value=$1 WHERE key=$2',
[JSON.stringify(changed), `canonical-inbox:v1:event:${'1'.padStart(16, '0')}`]);
await assert.rejects(observer.audit(), /canonical-inbox-event-anchor-invalid/);
assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'")).rows[0].n, 1);
await assert.rejects(PostgresCustody.open(f.runtime, options), /unresolved-integrity/);
} finally { await observer.close(); }
});
test('signed candidate completion settles actual PostgreSQL balances once across pools and reconnect', async t => {
const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' });
const l = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '100000000000', feeBps: 250 }) as CustodyListing;