Disposable canonical-event inbox rehearsal
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user