247 lines
17 KiB
TypeScript
Executable File
247 lines
17 KiB
TypeScript
Executable File
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { generateKeyPairSync, sign, randomUUID } from 'node:crypto';
|
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { NETWORK, nftTransferMessage, nftTradeMessage, type NftTransfer, type NftTrade } from '../shared/testnet.ts';
|
|
import { SALE_CANDIDATE, saleTermsMessage, saleCompletionMessage, saleCancellationMessage,
|
|
saleTermsSchema, type SaleTerms, type SaleTransaction } from '../shared/sale-candidate.ts';
|
|
import { applySaleBlock, initialSaleState, saleContext, saleDecision, saleId, saleStateRoot, termsForCustodyOrder,
|
|
type SaleState, type CandidateTransaction } from '../server/testnet/sale-candidate.ts';
|
|
import { address, transactionSchema, CHAIN_ID } from '../server/testnet/consensus.ts';
|
|
import { CustodyMarket } from '../server/market/custody.ts';
|
|
import { CUSTODY_POLICY, custodyMessage, type CustodyCommand, type CustodyAction, type CustodyOrder, type CustodyListing } from '../shared/custody.ts';
|
|
|
|
function identity() {
|
|
const k = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
const publicKey = k.publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
|
const owner = address(publicKey);
|
|
return { owner, authorization: (message: string) => ({ publicKey, signature: sign('sha256', Buffer.from(message),
|
|
{ key: k.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') }) };
|
|
}
|
|
const seller = identity(), buyer = identity(), host = identity(), attacker = identity();
|
|
const card = 'a'.repeat(64), second = 'b'.repeat(64);
|
|
// These heights are deliberately tiny test parameters, NOT production confirmation-policy approval.
|
|
const context = saleContext({ coinbaseMaturity: 2, lockDepth: 2, outcomeDepth: 3, maxLockBlocks: 30, maxTransactions: 64 });
|
|
function fixture(overrides: Partial<SaleTerms> = {}) {
|
|
const terms: SaleTerms = { protocol: SALE_CANDIDATE, network: NETWORK, chainId: context.chainId,
|
|
policyDigest: context.policyDigest, custodianId: randomUUID(), orderId: randomUUID(), cardHash: card,
|
|
sequence: 0, seller: seller.owner, buyer: buyer.owner, host: host.owner, priceAtomicXmr: '100000000000',
|
|
feeBps: 250, lockUntilHeight: 12, expiresHeight: 20, ...overrides };
|
|
const state = initialSaleState({ [card]: { owner: seller.owner, sequence: 0, mintedHeight: 1, pageId: 1 },
|
|
[second]: { owner: buyer.owner, sequence: 0, mintedHeight: 1, pageId: 2 } }, 9);
|
|
const lock: SaleTransaction = { type: 'sale-lock', terms, sellerAuthorization: seller.authorization(saleTermsMessage(terms)),
|
|
buyerAuthorization: buyer.authorization(saleTermsMessage(terms)) };
|
|
const ref: { protocol: typeof SALE_CANDIDATE; chainId: string; saleId: string } = {
|
|
protocol: SALE_CANDIDATE, chainId: context.chainId, saleId: saleId(terms) };
|
|
const completion = { ...ref, reservationDigest: 'f'.repeat(64) };
|
|
const complete: SaleTransaction = { type: 'sale-complete', ...completion, hostAuthorization: host.authorization(saleCompletionMessage(completion)) };
|
|
const cancel: SaleTransaction = { type: 'sale-cancel', ...ref, hostAuthorization: host.authorization(saleCancellationMessage(ref)),
|
|
buyerAuthorization: buyer.authorization(saleCancellationMessage(ref)) };
|
|
const expire: SaleTransaction = { type: 'sale-expire', ...ref };
|
|
return { terms, state, lock, complete, cancel, expire };
|
|
}
|
|
const apply = (s: SaleState, txs: unknown[] = []) => applySaleBlock(s, txs, context);
|
|
function at(s: SaleState, height: number) { while (s.height < height) s = apply(s); return s; }
|
|
function gift(sequence: number): CandidateTransaction {
|
|
const terms = { network: NETWORK, chainId: context.chainId, cardHash: card, from: seller.owner, to: attacker.owner, sequence };
|
|
const signature = seller.authorization(nftTransferMessage(terms));
|
|
return { type: 'native-transfer', transfer: { ...terms, ...signature } };
|
|
}
|
|
function bundle(sequence: number): CandidateTransaction {
|
|
const terms = { type: 'trade' as const, network: NETWORK, chainId: context.chainId, proposer: seller.owner, counterparty: buyer.owner,
|
|
offered: [{ cardHash: card, sequence }], requested: [{ cardHash: second, sequence: 0 }] };
|
|
const a = seller.authorization(nftTradeMessage(terms)), b = buyer.authorization(nftTradeMessage(terms));
|
|
return { type: 'native-trade', trade: { ...terms, proposerPublicKey: a.publicKey, proposerSignature: a.signature,
|
|
counterpartyPublicKey: b.publicKey, counterpartySignature: b.signature } };
|
|
}
|
|
|
|
test('sale candidate is domain separated and all candidate transactions remain rejected by active consensus', () => {
|
|
const f = fixture(); assert.notEqual(context.chainId, CHAIN_ID);
|
|
for (const tx of [f.lock, f.complete, f.cancel, f.expire]) assert.equal(transactionSchema.safeParse(tx).success, false);
|
|
const transfer = gift(0) as { type: 'native-transfer'; transfer: NftTransfer };
|
|
const trade = bundle(0) as { type: 'native-trade'; trade: NftTrade };
|
|
assert.equal(transactionSchema.safeParse(transfer.transfer).success, false);
|
|
assert.equal(transactionSchema.safeParse(trade.trade).success, false);
|
|
assert.throws(() => applySaleBlock(f.state, [f.lock], { ...context, chainId: CHAIN_ID }), /context/);
|
|
assert.equal(saleTermsSchema.safeParse({ ...f.terms, unexpected: true }).success, false);
|
|
assert.equal(saleTermsSchema.safeParse({ ...f.terms, priceAtomicXmr: '1.0' }).success, false);
|
|
});
|
|
|
|
test('lock requires both parties, commits all economic/context/window fields and never changes owner', () => {
|
|
const f = fixture(), locked = apply(f.state, [f.lock]);
|
|
assert.equal(locked.nfts[card].owner, seller.owner); assert.equal(locked.nfts[card].sequence, 1);
|
|
assert.equal(locked.locks[card], saleId(f.terms)); assert.equal(f.state.nfts[card].sequence, 0);
|
|
assert.throws(() => apply(f.state, [{ ...f.lock, buyerAuthorization: attacker.authorization(saleTermsMessage(f.terms)) }]), /both trader/);
|
|
for (const patch of [{ orderId: randomUUID() }, { custodianId: randomUUID() }, { priceAtomicXmr: '1' },
|
|
{ feeBps: 500 }, { lockUntilHeight: 13 }, { expiresHeight: 21 }, { buyer: attacker.owner },
|
|
{ host: attacker.owner }, { sequence: 1 }, { policyDigest: 'c'.repeat(64) }]) {
|
|
assert.throws(() => apply(f.state, [{ ...f.lock, terms: { ...f.terms, ...patch } }]));
|
|
}
|
|
const changedPolicy = saleContext({ ...context.policy, outcomeDepth: 4 });
|
|
assert.notEqual(changedPolicy.chainId, context.chainId);
|
|
assert.notEqual(saleId({ ...f.terms, custodianId: randomUUID() }), saleId(f.terms));
|
|
});
|
|
|
|
test('host cannot redirect delivery or alter signed reservation evidence, and completion waits for lock depth', () => {
|
|
const f = fixture(), locked = apply(f.state, [f.lock]);
|
|
assert.throws(() => apply(locked, [f.complete]), /mature lock window/);
|
|
const mature = at(locked, 11);
|
|
assert.throws(() => apply(mature, [{ ...f.complete, reservationDigest: 'e'.repeat(64) }]), /host authorization/);
|
|
assert.throws(() => apply(mature, [{ ...f.complete, hostAuthorization: attacker.authorization(saleCompletionMessage(f.complete as Extract<SaleTransaction, { type: 'sale-complete' }>)) }]), /host authorization/);
|
|
assert.throws(() => apply(mature, [{ ...f.complete, to: attacker.owner }]));
|
|
const done = apply(mature, [f.complete]);
|
|
assert.equal(done.nfts[card].owner, buyer.owner); assert.equal(done.nfts[card].sequence, 2);
|
|
assert.equal(done.sales[saleId(f.terms)].reservationDigest, 'f'.repeat(64));
|
|
assert.equal(saleDecision(done, f.terms, context).action, 'hold');
|
|
assert.equal(saleDecision(at(done, 14), f.terms, context).action, 'settle');
|
|
assert.equal(saleDecision(at(done, 14), f.terms, context, 'settled').action, 'none');
|
|
});
|
|
|
|
test('locked cards cannot escape through free transfers or any leg of an atomic trade', () => {
|
|
const f = fixture(), locked = apply(f.state, [f.lock]);
|
|
assert.throws(() => apply(locked, [gift(1)]), /locked/);
|
|
assert.throws(() => apply(locked, [bundle(1)]), /locked/);
|
|
assert.equal(apply(f.state, [gift(0)]).nfts[card].owner, attacker.owner);
|
|
const trade = apply(f.state, [bundle(0)]);
|
|
assert.equal(trade.nfts[card].owner, buyer.owner); assert.equal(trade.nfts[second].owner, seller.owner);
|
|
// A gift before lock invalidates the seller's stale lock authorization.
|
|
assert.throws(() => apply(f.state, [gift(0), f.lock]), /stale/);
|
|
});
|
|
|
|
test('cancellation needs buyer and host, releases only after canonical depth and invalidates all old signatures', () => {
|
|
const f = fixture(), locked = apply(f.state, [f.lock]);
|
|
assert.throws(() => apply(locked, [{ ...f.cancel, buyerAuthorization: attacker.authorization(saleCancellationMessage(f.cancel as Extract<SaleTransaction, { type: 'sale-cancel' }>)) }]), /host and buyer/);
|
|
const cancelled = apply(locked, [f.cancel]);
|
|
assert.equal(cancelled.nfts[card].owner, seller.owner); assert.equal(cancelled.nfts[card].sequence, 2);
|
|
assert.equal(saleDecision(cancelled, f.terms, context).action, 'hold');
|
|
assert.equal(saleDecision(at(cancelled, 13), f.terms, context).action, 'refund');
|
|
assert.throws(() => apply(cancelled, [f.complete]), /No active/);
|
|
assert.throws(() => apply(cancelled, [f.lock]), /stale/);
|
|
assert.throws(() => apply(cancelled, [gift(0)]), /stale/);
|
|
assert.throws(() => apply(cancelled, [gift(1)]), /stale/);
|
|
assert.equal(apply(cancelled, [gift(2)]).nfts[card].owner, attacker.owner);
|
|
});
|
|
|
|
test('expiry is permissionless exactly at expiry height, but never a website-clock-only release', () => {
|
|
const f = fixture(), locked = apply(f.state, [f.lock]);
|
|
assert.throws(() => apply(locked, [f.expire]), /not expired/);
|
|
const before = at(locked, 19);
|
|
assert.throws(() => apply(before, [f.complete]), /outside/);
|
|
assert.throws(() => apply(before, [f.cancel]), /permissionless expiry/);
|
|
assert.throws(() => apply(before, [gift(1)]), /locked/); // Explicit expiry transaction required, no implicit timer.
|
|
const expired = apply(before, [f.expire]);
|
|
assert.equal(expired.nfts[card].owner, seller.owner); assert.equal(expired.nfts[card].sequence, 2);
|
|
assert.equal(saleDecision(expired, f.terms, context).action, 'hold');
|
|
assert.equal(saleDecision(at(expired, 22), f.terms, context).action, 'refund');
|
|
const last = apply(at(locked, 18), [f.complete]); assert.equal(last.sales[saleId(f.terms)].status, 'completed');
|
|
});
|
|
|
|
test('withheld lock has a strict admission deadline and missing lock refunds wait beyond that deadline', () => {
|
|
const f = fixture();
|
|
assert.equal(saleDecision(at(f.state, 12), f.terms, context).action, 'hold');
|
|
assert.equal(apply(at(f.state, 11), [f.lock]).sales[saleId(f.terms)].lockHeight, 12);
|
|
assert.throws(() => apply(at(f.state, 12), [f.lock]), /height window/);
|
|
assert.equal(saleDecision(at(f.state, 14), f.terms, context).action, 'hold');
|
|
assert.equal(saleDecision(at(f.state, 15), f.terms, context).action, 'refund');
|
|
});
|
|
|
|
test('one canonical outcome wins: cancel/complete race rejects the whole invalid block, never partially updates state', () => {
|
|
const f = fixture(), ready = at(apply(f.state, [f.lock]), 11), prior = structuredClone(ready);
|
|
assert.throws(() => apply(ready, [f.complete, f.cancel]), /No active/);
|
|
assert.throws(() => apply(ready, [f.cancel, f.complete]), /No active/);
|
|
assert.deepEqual(ready, prior);
|
|
assert.throws(() => apply(ready, [f.complete, f.complete]), /Duplicate/);
|
|
const completed = apply(ready, [f.complete]), cancelled = apply(ready, [f.cancel]);
|
|
assert.equal(completed.sales[saleId(f.terms)].status, 'completed');
|
|
assert.equal(cancelled.sales[saleId(f.terms)].status, 'cancelled');
|
|
// Reorg to a sibling outcome after a financial posting must quarantine rather than pay/refund again.
|
|
assert.equal(saleDecision(at(cancelled, 14), f.terms, context, 'settled').action, 'review');
|
|
assert.equal(saleDecision(at(completed, 14), f.terms, context, 'refunded').action, 'review');
|
|
assert.equal(saleDecision(at(completed, 14), f.terms, context, 'review').action, 'review');
|
|
});
|
|
|
|
test('cross-market competing locks are exclusive and cancelled order IDs cannot be recycled under fresh terms', () => {
|
|
const f = fixture(), otherMarket = fixture({ custodianId: randomUUID() });
|
|
const locked = apply(f.state, [f.lock]);
|
|
assert.throws(() => apply(locked, [otherMarket.lock]), /locked/);
|
|
const cancelled = apply(locked, [f.cancel]);
|
|
const replay = fixture({ ...f.terms, sequence: 2, priceAtomicXmr: '2' });
|
|
assert.throws(() => apply(cancelled, [replay.lock]), /already used/);
|
|
const nextOrder = fixture({ sequence: 2 });
|
|
assert.equal(apply(cancelled, [nextOrder.lock]).nfts[card].sequence, 3);
|
|
});
|
|
|
|
test('policy/height bounds, immature issuance, sequence exhaustion and transaction limits reject safely', () => {
|
|
const f = fixture();
|
|
assert.throws(() => saleContext({ ...context.policy, maxLockBlocks: 1 }), /window/);
|
|
assert.throws(() => apply(f.state, Array(65).fill(f.lock)), /budget/);
|
|
assert.throws(() => applySaleBlock({ ...f.state, height: Number.MAX_SAFE_INTEGER }, [], context), /exhausted/);
|
|
const tooEarly = initialSaleState(f.state.nfts, 1); assert.throws(() => apply(tooEarly, [f.lock]), /Immature/);
|
|
for (const sequence of [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER - 1]) {
|
|
const huge = fixture({ sequence }); huge.state.nfts[card].sequence = sequence;
|
|
assert.throws(() => apply(huge.state, [huge.lock]), /exhausted/);
|
|
}
|
|
assert.throws(() => apply(f.state, [fixture({ expiresHeight: 100 }).lock]), /window/);
|
|
});
|
|
|
|
test('candidate drafts from real sandbox reservations without signing, changing balances or activating NFT delivery', async t => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'wikideck-sale-draft-'));
|
|
const market = await CustodyMarket.open(dir, { feeBps: 250, card: () => ({ title: 'Draft test' }) });
|
|
t.after(async () => { market.close(); await rm(dir, { recursive: true, force: true }); });
|
|
const execute = (who: typeof buyer, action: CustodyAction) => {
|
|
const unsigned: Omit<CustodyCommand, 'signature'> = { version: 1, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK,
|
|
chainId: CHAIN_ID, custodianId: market.id, actor: who.owner, publicKey: '', nonce: randomUUID(), expiresAt: Date.now() + 60000, action };
|
|
const publicKey = who.authorization('lookup').publicKey; unsigned.publicKey = publicKey;
|
|
return market.execute({ ...unsigned, signature: who.authorization(custodyMessage(unsigned)).signature });
|
|
};
|
|
execute(buyer, { type: 'mock-credit' });
|
|
const listing = execute(seller, { type: 'list', cardHash: card, sequence: 0, priceAtomicXmr: '1', feeBps: 250 }) as CustodyListing;
|
|
const order = execute(buyer, { type: 'reserve', listingId: listing.id, priceAtomicXmr: '1', feeBps: 250 }) as CustodyOrder;
|
|
const before = market.audit(), options = { custodianId: market.id, host: host.owner, feeBps: 250, lockUntilHeight: 12, expiresHeight: 20 };
|
|
const terms = termsForCustodyOrder(order, context, options);
|
|
assert.equal(terms.priceAtomicXmr, '1'); assert.equal(terms.orderId, order.id);
|
|
assert.equal(terms.custodianId, market.id); assert.deepEqual(market.audit(), before);
|
|
assert.equal(market.info().nftSettlement, false);
|
|
assert.throws(() => termsForCustodyOrder({ ...order, authorAtomicXmr: '1' }, context, options), /allocations/);
|
|
assert.throws(() => termsForCustodyOrder({ ...order, status: 'cancelled' }, context, options), /awaiting-lock/);
|
|
});
|
|
|
|
test('enumerated adversarial histories preserve exclusivity and deterministic replay across snapshot restores', () => {
|
|
const f = fixture(), start = apply(f.state, [f.lock]);
|
|
let explored = 0;
|
|
const explore = (state: SaleState, depth: number) => {
|
|
if (depth === 0) return;
|
|
const choices = [[], [f.complete], [f.cancel], [f.expire], [gift(state.nfts[card].sequence)], [bundle(state.nfts[card].sequence)]];
|
|
for (const txs of choices) {
|
|
const before = structuredClone(state);
|
|
try {
|
|
const next = apply(state, txs);
|
|
assert.deepEqual(next, apply(JSON.parse(JSON.stringify(state)), txs));
|
|
const sale = next.sales[saleId(f.terms)];
|
|
if (sale.status === 'locked') assert.equal(next.nfts[card].owner, seller.owner);
|
|
assert.equal(!!next.locks[card], sale.status === 'locked');
|
|
explored++; explore(next, depth - 1);
|
|
} catch (e) {
|
|
if (e instanceof assert.AssertionError) throw e;
|
|
}
|
|
assert.deepEqual(state, before);
|
|
}
|
|
};
|
|
// Reach the exact expiry boundary too; short horizons mostly contain correctly rejected actions.
|
|
explore(start, 12); assert.ok(explored > 100, `Explored only ${explored} accepted transitions`);
|
|
});
|
|
|
|
test('candidate state commitment binds ownership, lock authority, terminal outcomes and order-use tombstones', () => {
|
|
const f = fixture(), state = apply(f.state, [f.lock]), root = saleStateRoot(state, context);
|
|
assert.equal(root, saleStateRoot(JSON.parse(JSON.stringify(state)), context));
|
|
for (const mutate of [
|
|
(s: SaleState) => { s.nfts[card].sequence++; },
|
|
(s: SaleState) => { s.sales[saleId(f.terms)].terms.host = attacker.owner; },
|
|
(s: SaleState) => { s.sales[saleId(f.terms)].status = 'cancelled'; },
|
|
(s: SaleState) => { delete s.locks[card]; },
|
|
(s: SaleState) => { s.orders = {}; },
|
|
]) { const copy = structuredClone(state); mutate(copy); assert.notEqual(saleStateRoot(copy, context), root); }
|
|
});
|