This commit is contained in:
2026-10-20 03:14:08 -04:00
parent b1d2b5c10e
commit 9850fd8554
28 changed files with 1256 additions and 26 deletions
+32 -2
View File
@@ -6,7 +6,7 @@ import { constants, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { generateKeyPairSync, sign } from 'node:crypto';
import { generateKeyPairSync, randomUUID, sign } from 'node:crypto';
import { DatabaseSync } from 'node:sqlite';
import { setTimeout as delay } from 'node:timers/promises';
import { RandomX } from '../server/testnet/randomx.ts';
@@ -14,6 +14,8 @@ import { address, CHAIN_ID, GENESIS_HASH, MAX_TARGET, miningBlob, mintId, sha256
transferId, workFor } from '../server/testnet/consensus.ts';
import { NETWORK, CONSENSUS, blockClaimMessage, nftTransferMessage,
type MiningTemplate, type NftTransfer, type TestnetBlock, type TestnetSnapshot } from '../shared/testnet.ts';
import { marketMessage, type MarketAction, type MarketInfo, type MarketListing, type MarketOrder,
type MarketActivity } from '../shared/market.ts';
const root = fileURLToPath(new URL('../', import.meta.url));
const wikipediaMock = resolve(root, 'tests', 'mock-wikipedia.mjs');
@@ -81,7 +83,8 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
env: { ...process.env, HOST: '127.0.0.1', PORT: '0', NODE_ENV: 'production',
WIKIDECK_DATA_DIR: dataDir, RANDOMX_BINARY: binary,
WIKIDECK_PEERS: peer ?? '', WIKIDECK_ALLOW_PRIVATE_PEERS: peer ? '1' : '0',
PUBLIC_ORIGIN: 'http://127.0.0.1:3001' },
PUBLIC_ORIGIN: 'http://127.0.0.1:3001', WIKIDECK_PAYMENT_MODE: 'mock',
WIKIDECK_MARKET_FEE_BPS: '250', WIKIDECK_MARKET_CONFIRMATIONS: '2' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
@@ -131,6 +134,11 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
return (await request(base, path, expected, body)).json() as Promise<T>;
}
const snapshot = (base: string) => json<TestnetSnapshot>(base, '/api/chain');
async function marketCommand<T>(base: string, info: MarketInfo, who: ReturnType<typeof wallet>, action: MarketAction) {
const terms = { network: NETWORK, chainId: CHAIN_ID, marketId: info.marketId, actor: who.owner,
publicKey: who.publicKey, nonce: randomUUID(), expiresAt: Date.now() + 60000, action };
return json<T>(base, '/api/market/command', 200, { ...terms, signature: who.signed(marketMessage(terms)) });
}
async function waitFor<T>(label: string, read: () => Promise<T>, predicate: (value: T) => boolean): Promise<T> {
const until = Date.now() + 25_000;
let value: T;
@@ -248,6 +256,19 @@ 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]);
const market = await json<{ info: MarketInfo; listings: MarketListing[] }>(first.base, '/api/market');
assert.equal(market.info.paymentMode, 'mock');
assert.deepEqual(market.listings, []);
await (await request(first.base, '/api/market/command', 400, {})).text();
await (await request(first.base, '/api/market/command', 403, {}, { Origin: 'https://evil.example' })).text();
const sale = await marketCommand<MarketListing>(first.base, market.info, alice, { type: 'list',
cardHash: block1.tokenId, sequence: 0, kind: 'fixed', priceCents: 1500, endsAt: null, feeBps: 250 });
assert.equal((await json<{ listings: MarketListing[] }>(first.base, '/api/market')).listings[0].id, sale.id);
const order = await marketCommand<MarketOrder>(first.base, market.info, bob, { type: 'buy', listingId: sale.id,
priceCents: 1500, feeBps: 250 });
assert.equal(order.invoice.address, null);
assert.equal(order.invoice.amountXmr, '0.100000000000');
await marketCommand(first.base, market.info, bob, { type: 'mock-pay', orderId: order.id });
await (await request(first.base, `/api/decks/${'z'.repeat(64)}`, 400)).text();
// A truly independent process reconstructs the canonical chain over HTTP and checks real work.
@@ -262,6 +283,8 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
const badTransfer = await json<{ error: string }>(second.base, '/api/transfer', 400,
{ ...transfer, signature: tamperSignature(transfer.signature) });
assert.match(badTransfer.error, /signature/i);
await marketCommand(first.base, market.info, alice, { type: 'deliver', orderId: order.id, transfer });
assert.equal((await marketCommand<MarketActivity>(first.base, market.info, bob, { type: 'activity' })).orders[0].status, 'delivery-pending');
const pending = { id: transferId(transfer), status: 'pending',
message: 'Ownership changes only when this transaction is mined.' };
assert.deepEqual(await json(second.base, '/api/transfer', 202, transfer), pending);
@@ -299,6 +322,10 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
assert.deepEqual(canonical.blocks, [block1, block2, block3]);
assert.deepEqual(canonical.transfers, [transfer]);
assert.deepEqual(canonical.pendingTransfers, []);
const settling = await marketCommand<MarketActivity>(first.base, market.info, bob, { type: 'activity' });
assert.equal(settling.orders[0].deliveryConfirmations, 1);
assert.equal(settling.orders[0].payout, 'not-started');
assert.equal((await json<{ listings: MarketListing[] }>(second.base, '/api/market')).listings.length, 0);
assert.equal(canonical.cumulativeWork, (3n * workFor(MAX_TARGET)).toString());
for (const node of [first, second]) {
const replay = await json<{ error: string }>(node.base, '/api/transfer', 400, transfer);
@@ -331,6 +358,9 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
}
first = await start(firstDir); // Ledger.open revalidates every persisted block using native RandomX.
const restored = await snapshot(first.base);
const restoredMarket = await json<{ info: MarketInfo }>(first.base, '/api/market');
assert.equal(restoredMarket.info.marketId, market.info.marketId);
assert.equal((await marketCommand<MarketActivity>(first.base, market.info, bob, { type: 'activity' })).orders[0].id, order.id);
assert.deepEqual(restored, { ...canonical, peers: 0 });
assert.deepEqual(await json(first.base, `/api/nfts/${block1.tokenId}`), {
network: NETWORK, chainId: CHAIN_ID, tokenId: block1.tokenId, owner: bob.owner, sequence: 1,
+290
View File
@@ -0,0 +1,290 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { generateKeyPairSync, randomUUID, sign } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { DatabaseSync } from 'node:sqlite';
import { Market, marketConfig } from '../server/market/store.ts';
import { mockQuote, paymentMode } from '../server/market/payments.ts';
import { ADDITIONAL_MARKET_FEE, marketMessage, parseUsd, type MarketAction, type MarketListing, type MarketOrder,
type MarketActivity } from '../shared/market.ts';
import { NETWORK, nftTransferMessage, type NftTransfer, type TestnetSnapshot, type TestnetBlock } from '../shared/testnet.ts';
import { address, CHAIN_ID, GENESIS_HASH, transferId, verifySignature } from '../server/testnet/consensus.ts';
function identity() {
const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
return { publicKey, actor: address(publicKey), signed: (text: string) => sign('sha256', Buffer.from(text),
{ key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') };
}
const alice = identity(), bob = identity(), carol = identity();
// Unit-test chain boundary. Production always uses Ledger + reference RandomX; no selectable fake PoW.
function chainFixture() {
const mint = { tokenId: 'a'.repeat(64), height: 1, owner: alice.actor,
article: { title: 'A collector’s discovery', image: 'https://upload.wikimedia.org/test.jpg' }, transactions: [] } as unknown as TestnetBlock;
const state: TestnetSnapshot = { network: NETWORK, chainId: CHAIN_ID, genesis: GENESIS_HASH,
tip: 'b'.repeat(64), blocks: [mint, { ...mint, tokenId: 'c'.repeat(64), height: 2 }],
transfers: [], pendingTransfers: [], peers: 0, algorithm: 'RandomX', maturity: 2, cumulativeWork: '2' };
return { state, snapshot: () => structuredClone(state), submitTransfer: async (raw: unknown) => {
const tx = raw as NftTransfer;
assert.ok(verifySignature(tx.publicKey, tx.from, nftTransferMessage(tx), tx.signature));
const id = transferId(tx);
if (!state.pendingTransfers.some(t => transferId(t as NftTransfer) === id)) state.pendingTransfers.push(tx);
return id;
} };
}
async function fixture(t: { after(fn: () => Promise<void>): void }) {
const dir = await mkdtemp(join(tmpdir(), 'wikideck-market-'));
const chain = chainFixture();
let time = 1_800_000_000_000;
let market = await Market.open(dir, chain, { feeBps: 250, confirmations: 2, usdCentsPerXmr: 15000 }, () => 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,
publicKey: who.publicKey, nonce: randomUUID(), expiresAt: time + 60000, action };
return { ...terms, signature: who.signed(marketMessage(terms)) };
};
const run = <T = MarketListing | MarketOrder>(who: typeof alice, action: MarketAction) => market.execute(envelope(who, action)) as Promise<T>;
const list = (kind: 'fixed' | 'auction' = 'fixed') => run<MarketListing>(alice, { type: 'list', cardHash: chain.state.blocks[0].tokenId,
sequence: 0, kind, priceCents: 1500, endsAt: kind === 'auction' ? time + 60000 : null, feeBps: 250 });
const buy = (l: MarketListing, who = bob, priceCents = l.priceCents) => run<MarketOrder>(who,
{ type: 'buy', listingId: l.id, priceCents, feeBps: l.feeBps });
const orders = async (who = bob) => (await run<MarketActivity>(who, { type: 'activity' })).orders;
const delivery = (o: MarketOrder, to = bob) => {
const terms = { network: NETWORK, chainId: CHAIN_ID, cardHash: o.cardHash, from: alice.actor, to: to.actor, sequence: o.sequence };
return { ...terms, publicKey: alice.publicKey, signature: alice.signed(nftTransferMessage(terms)) };
};
const confirm = (tx: NftTransfer) => {
const height = chain.state.blocks.length + 1;
chain.state.blocks.push({ ...chain.state.blocks[0], height, transactions: [tx] });
chain.state.transfers.push(tx); chain.state.pendingTransfers = [];
};
return { dir, chain, envelope, 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,
{ feeBps: 500, confirmations: 5, usdCentsPerXmr: 20000 }, () => time); } };
}
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));
const quote = mockQuote(1500, 250, 15000);
assert.equal(quote.invoice.amountXmr, '0.100000000000');
assert.equal(quote.feeXmr, '0.002500000000');
assert.equal(quote.sellerNetXmr, '0.096500000000');
assert.deepEqual(quote.additionalFee, { bps: 100,
address: '44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv',
amountXmr: '0.001000000000', payout: 'not-started' });
assert.equal(quote.invoice.address, null);
assert.equal(mockQuote(1, 0, 3).invoice.amountXmr, '0.333333333334');
assert.equal(paymentMode({}), 'mock');
for (const mode of ['live','shkeeper','testnet','']) assert.throws(() => paymentMode({ WIKIDECK_PAYMENT_MODE: mode }));
for (const value of ['-1','5001','250.5','x','']) assert.throws(() => marketConfig({ WIKIDECK_MARKET_FEE_BPS: value }));
assert.equal(marketConfig({ WIKIDECK_MARKET_FEE_BPS: '0' }).feeBps, 0);
const noHostingFee = mockQuote(1500, 0, 15000);
assert.equal(noHostingFee.additionalFee.amountXmr, '0.001000000000');
assert.equal(noHostingFee.sellerNetXmr, '0.099000000000');
const atomic = (text: string) => BigInt(text.replace('.', ''));
for (const cents of [1, 19, 1500, 100_000_000]) for (const bps of [0, 250, 5000]) {
const q = mockQuote(cents, bps, 19999);
const gross = atomic(q.invoice.amountXmr);
assert.equal(atomic(q.additionalFee.amountXmr), gross * 100n / 10000n);
assert.equal(atomic(q.feeXmr) + atomic(q.additionalFee.amountXmr) + atomic(q.sellerNetXmr), gross);
assert.ok(atomic(q.sellerNetXmr) >= 0n);
}
});
test('market commands bind actor, instance, network, expiry, and every listing term', async t => {
const f = await fixture(t);
const original = f.envelope(alice, { type: 'list', cardHash: f.chain.state.blocks[0].tokenId, sequence: 0,
kind: 'fixed', priceCents: 1500, endsAt: null, feeBps: 250 });
for (const bad of [
{ ...original, actor: bob.actor }, { ...original, marketId: randomUUID() },
{ ...original, chainId: '0'.repeat(64) }, { ...original, expiresAt: f.now - 1 },
{ ...original, expiresAt: f.now + 600_000 },
{ ...original, action: { ...original.action, priceCents: 1 } },
]) await assert.rejects(async () => f.market.execute(bad), /signature/);
await assert.rejects(async () => f.market.execute({ ...original, network: 'wrong-network' }));
const first = await f.market.execute(original);
const message = JSON.parse(marketMessage(original));
assert.equal(message[1], 'market-command-v2');
assert.deepEqual(message.at(-1), [100, ADDITIONAL_MARKET_FEE.address]);
message[message.length - 1] = [0, ADDITIONAL_MARKET_FEE.address];
await assert.rejects(async () => f.market.execute({ ...original, signature: alice.signed(JSON.stringify(message)) }), /signature/);
assert.deepEqual(await f.market.execute(original), first);
const changed = { ...original, action: { ...original.action, priceCents: 1600 } };
await assert.rejects(async () => f.market.execute({ ...changed, signature: alice.signed(marketMessage(changed)) }), /Nonce/);
assert.equal(f.market.browse().listings.length, 1);
});
test('listings reject unknown, immature, stale, foreign, duplicate, and pending cards', async t => {
const f = await fixture(t);
const base = { type: 'list' as const, cardHash: f.chain.state.blocks[0].tokenId, sequence: 0,
kind: 'fixed' as const, priceCents: 1500, endsAt: null, feeBps: 250 };
await assert.rejects(f.run(bob, base), /Card/);
await assert.rejects(f.run(alice, { ...base, cardHash: '0'.repeat(64) }), /Card/);
await assert.rejects(f.run(alice, { ...base, sequence: 1 }), /Card/);
await assert.rejects(f.run(alice, { ...base, feeBps: 0 }), /fee changed/);
f.chain.state.blocks.pop();
await assert.rejects(f.run(alice, base), /Card/);
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 2 });
f.chain.state.pendingTransfers.push(f.delivery({ cardHash: base.cardHash, sequence: 0 } as MarketOrder));
await assert.rejects(f.run(alice, base), /pending/);
f.chain.state.pendingTransfers = [];
const listing = await f.list();
await assert.rejects(f.list(), /already listed/);
await f.restart();
assert.equal(f.market.browse().listings[0].id, listing.id);
await f.run(alice, { type: 'cancel', listingId: listing.id });
assert.equal(f.market.browse().listings.length, 0);
});
test('fixed-price reservations are exclusive, idempotent, private and expire without charging', async t => {
const f = await fixture(t), listing = await f.list();
await assert.rejects(f.buy(listing, alice), /own card/);
const request = f.envelope(bob, { type: 'buy', listingId: listing.id, priceCents: 1500, feeBps: 250 });
const results = await Promise.allSettled([f.market.execute(request), f.buy(listing, carol)]);
assert.equal(results[0].status, 'fulfilled'); assert.equal(results[1].status, 'rejected');
const order = (results[0] as PromiseFulfilledResult<MarketOrder>).value;
assert.deepEqual(await f.market.execute(request), order);
assert.equal((await f.orders(carol)).length, 0);
assert.equal((await f.orders(alice)).length, 1);
await assert.rejects(f.run(carol, { type: 'mock-pay', orderId: order.id }), /Only the order buyer/);
await assert.rejects(f.run(alice, { type: 'cancel', listingId: listing.id }), /available/);
f.advance(30 * 60000);
assert.equal((await f.orders())[0].status, 'expired');
assert.equal(f.market.browse().listings[0].status, 'open');
await f.buy(listing, carol);
});
test('auction bids are signed, increasing, deadline-bound, uncancellable, and winner-only', async t => {
const f = await fixture(t), l = await f.list('auction');
await assert.rejects(f.buy(l), /winner/);
await assert.rejects(f.run(alice, { type: 'bid', listingId: l.id, priceCents: 1500 }), /own card/);
await assert.rejects(f.run(bob, { type: 'bid', listingId: l.id, priceCents: 1499 }), /Bid/);
await f.run(bob, { type: 'bid', listingId: l.id, priceCents: 1500 });
await assert.rejects(f.run(carol, { type: 'bid', listingId: l.id, priceCents: 1500 }), /Bid/);
await assert.rejects(f.run(alice, { type: 'cancel', listingId: l.id }), /cannot be cancelled/);
await f.run(carol, { type: 'bid', listingId: l.id, priceCents: 1600 });
f.advance(60000);
await assert.rejects(f.run(bob, { type: 'bid', listingId: l.id, priceCents: 1700 }), /not accepting/);
await assert.rejects(f.buy(l, bob, 1600), /winner/);
await assert.rejects(f.buy(l, carol, 1500), /terms changed/);
const order = await f.buy(l, carol, 1600);
assert.equal(order.priceCents, 1600); assert.equal(order.payBy, f.now + 30 * 60000);
assert.equal(order.additionalFee?.bps, 100);
assert.equal(order.additionalFee?.address, ADDITIONAL_MARKET_FEE.address);
});
test('auctions without bids or unpaid winners expire without a silent runner-up sale', async t => {
const f = await fixture(t), l = await f.list('auction');
f.advance(60000); assert.equal(f.market.browse().listings.length, 0);
const next = await f.list('auction');
await f.run(bob, { type: 'bid', listingId: next.id, priceCents: 1500 });
f.advance(31 * 60000);
assert.equal(f.market.browse().listings.length, 0);
await assert.rejects(f.buy(next), /available/);
});
test('mock payment cannot release a card: seller signature, exact buyer, and canonical depth required', async t => {
const f = await fixture(t), l = await f.list(), o = await f.buy(l);
await assert.rejects(f.run(alice, { type: 'deliver', orderId: o.id, transfer: f.delivery(o) }), /paid order seller/);
await f.run(bob, { type: 'mock-pay', orderId: o.id });
await assert.rejects(f.run(bob, { type: 'deliver', orderId: o.id, transfer: f.delivery(o) }), /paid order seller/);
await assert.rejects(f.run(alice, { type: 'deliver', orderId: o.id, transfer: f.delivery(o, carol) }), /match this sale/);
const transfer = f.delivery(o);
await f.run(alice, { type: 'deliver', orderId: o.id, transfer });
assert.equal((await f.orders())[0].payout, 'not-started');
assert.equal((await f.orders())[0].additionalFee?.payout, 'not-started');
f.confirm(transfer);
assert.equal((await f.orders())[0].status, 'delivery-pending');
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] });
const completed = (await f.orders())[0];
assert.equal(completed.status, 'complete'); assert.equal(completed.payout, 'mock-paid');
assert.equal(completed.feeXmr, '0.002500000000'); assert.equal(completed.sellerNetXmr, '0.096500000000');
assert.equal(completed.additionalFee?.amountXmr, '0.001000000000');
assert.equal(completed.additionalFee?.address, ADDITIONAL_MARKET_FEE.address);
assert.equal(completed.additionalFee?.payout, 'mock-paid');
assert.equal(f.market.browse().listings.length, 0);
});
test('settlement policy and quote survive restart/config changes; reorg never triggers duplicate payout', async t => {
const f = await fixture(t), l = await f.list(), o = await f.buy(l);
await f.run(bob, { type: 'mock-pay', orderId: o.id });
const tx = f.delivery(o);
await f.run(alice, { type: 'deliver', orderId: o.id, transfer: tx });
f.confirm(tx);
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] });
assert.equal((await f.orders())[0].status, 'complete');
const id = f.market.id;
await f.restart(); assert.equal(f.market.id, id);
const restored = (await f.orders())[0];
assert.equal(restored.status, 'complete'); assert.equal(restored.requiredConfirmations, 2);
assert.equal(restored.invoice.amountXmr, o.invoice.amountXmr);
assert.equal(restored.feeBps, 250);
assert.equal(restored.additionalFee?.address, ADDITIONAL_MARKET_FEE.address);
assert.equal(restored.additionalFee?.amountXmr, '0.001000000000');
assert.equal(restored.additionalFee?.payout, 'mock-paid');
f.chain.state.blocks.splice(2); f.chain.state.transfers = [];
assert.equal((await f.orders())[0].status, 'review');
f.confirm(tx); f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] });
assert.equal((await f.orders())[0].status, 'review');
assert.equal((await f.orders())[0].payout, 'review');
assert.equal((await f.orders())[0].additionalFee?.payout, 'review');
});
test('failed seller delivery offers a full mock refund, never a seller payout', async t => {
const f = await fixture(t), l = await f.list(), o = await f.buy(l);
await f.run(bob, { type: 'mock-pay', orderId: o.id });
await assert.rejects(f.run(bob, { type: 'refund', orderId: o.id }), /Refund requires/);
f.advance(48 * 3600_000);
assert.equal((await f.orders())[0].status, 'refund-required');
await f.run(bob, { type: 'refund', orderId: o.id });
assert.equal((await f.orders())[0].status, 'refunded');
assert.equal((await f.orders())[0].payout, 'not-started');
assert.equal((await f.orders())[0].additionalFee?.payout, 'not-started');
assert.equal(f.market.browse().listings.length, 0);
});
test('stale listings are removed and changed cards cannot be paid for', async t => {
const f = await fixture(t), l = await f.list(), o = await f.buy(l);
f.chain.state.transfers.push(f.delivery(o, carol));
await assert.rejects(f.run(bob, { type: 'mock-pay', orderId: o.id }), /Seller card changed/);
f.advance(30 * 60000);
await f.orders(); assert.equal(f.market.browse().listings.length, 0);
});
test('old open listings require fresh seller fee authorization and cannot be bought', async t => {
const f = await fixture(t), l = await f.list();
const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
try {
delete l.additionalFee;
db.prepare('UPDATE listings SET data=? WHERE id=?').run(JSON.stringify(l), l.id);
} finally { db.close(); }
assert.equal(f.market.browse().listings.length, 0);
await assert.rejects(f.buy(l), /available/);
assert.equal((await f.list()).additionalFee?.bps, 100);
});
test('existing reserved orders retain their agreed net; the additional fee is never retroactive', async t => {
const f = await fixture(t), l = await f.list(), order = await f.buy(l);
const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
try {
delete l.additionalFee;
delete order.additionalFee;
order.sellerNetXmr = '0.097500000000';
db.prepare('UPDATE listings SET data=? WHERE id=?').run(JSON.stringify({ ...l, status: 'reserved' }), l.id);
db.prepare('UPDATE orders SET data=? WHERE id=?').run(JSON.stringify(order), order.id);
} finally { db.close(); }
await f.restart();
await f.run(bob, { type: 'mock-pay', orderId: order.id });
const tx = f.delivery(order);
await f.run(alice, { type: 'deliver', orderId: order.id, transfer: tx });
f.confirm(tx);
for (let i = 0; i < 4; i++) f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4 + i, transactions: [] });
const restored = (await f.orders())[0];
assert.equal(restored.status, 'complete');
assert.equal(restored.additionalFee, undefined);
assert.equal(restored.sellerNetXmr, '0.097500000000');
});
+87
View File
@@ -0,0 +1,87 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { NETWORK, CONSENSUS } from '../shared/testnet.ts';
import { networkProfile } from '../shared/network.ts';
import { CHAIN_ID, GENESIS_TIME, GENESIS_HASH, MAX_TARGET } from '../server/testnet/consensus.ts';
import { paymentMode } from '../server/market/payments.ts';
function script(code: string, env: Record<string,string> = {}) {
return execFileSync(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], {
env: { ...process.env, WIKIDECK_NETWORK: 'mainnet', ...env }, encoding: 'utf8', timeout: 30000,
}).trim();
}
test('default testnet v6 manifest is unchanged and unknown network profiles fail closed', () => {
const hash = (text: string) => createHash('sha256').update(text).digest('hex');
assert.equal(NETWORK, 'wikideck-randomx-testnet-6');
assert.equal(GENESIS_TIME, 1_792_411_200);
assert.equal(CHAIN_ID, hash(JSON.stringify(['wikideck-randomx-testnet-6', CONSENSUS, 1_792_411_200, MAX_TARGET])));
assert.equal(networkProfile(undefined), 'testnet');
assert.throws(() => networkProfile('production')); assert.throws(() => networkProfile(''));
});
test('mainnet candidate has distinct deterministic genesis, signatures and strict transaction schemas', () => {
const output = JSON.parse(script(`
import { NETWORK, nftTransferMessage } from './shared/testnet.ts';
import { CHAIN_ID, GENESIS_HASH, transferSchema } from './server/testnet/consensus.ts';
console.log(JSON.stringify({ NETWORK, CHAIN_ID, GENESIS_HASH,
message:nftTransferMessage({chainId:CHAIN_ID,cardHash:'a'.repeat(64),from:'b'.repeat(64),to:'c'.repeat(64),sequence:0}),
acceptsTestnet:transferSchema.safeParse({network:'wikideck-randomx-testnet-6',chainId:${JSON.stringify(CHAIN_ID)},
cardHash:'a'.repeat(64),from:'b'.repeat(64),to:'c'.repeat(64),sequence:0,publicKey:'AA==',signature:'A'.repeat(86)+'=='}).success }));
`));
assert.equal(output.NETWORK, 'wikideck-randomx-mainnet-1');
assert.notEqual(output.CHAIN_ID, CHAIN_ID); assert.notEqual(output.GENESIS_HASH, GENESIS_HASH);
assert.match(output.message, /wikideck-randomx-mainnet-1/); assert.equal(output.acceptsTestnet, false);
});
test('mainnet startup is blocked before resources open, while manifest inspection needs no launch approval', () => {
const args = ['--experimental-strip-types', 'server/index.ts'];
const env = { ...process.env, WIKIDECK_NETWORK: 'mainnet', WIKIDECK_MAINNET_LAUNCH: '', WIKIDECK_MAINNET_REVIEW_ACK: '' };
const result = spawnSync(process.execPath, args, { env, encoding: 'utf8', timeout: 30000 });
assert.notEqual(result.status, 0); assert.match(result.stderr, /launch-gated/);
const manifest = JSON.parse(execFileSync(process.execPath, [...args, '--network-manifest'], { env, encoding: 'utf8' }));
assert.equal(manifest.profile, 'mainnet'); assert.equal(manifest.paymentMode, 'mock'); assert.equal(manifest.audited, false);
assert.equal(manifest.launchStatus, 'candidate-gated');
});
test('mainnet gates require genesis time, chain-specific approval, HTTPS, independent peers, and mock payment', () => {
const result = JSON.parse(script(`
import { assertLaunchAllowed } from './server/launch.ts';
import { CHAIN_ID, GENESIS_TIME } from './server/testnet/consensus.ts';
const env={WIKIDECK_MAINNET_LAUNCH:CHAIN_ID,WIKIDECK_MAINNET_REVIEW_ACK:'independent-review-complete',
PUBLIC_ORIGIN:'https://market.example',WIKIDECK_PEERS:'https://peer-a.example,https://peer-b.example'};
const results=[];
for(const [changes,now] of [[{},0],[{WIKIDECK_MAINNET_LAUNCH:'wrong'},Infinity],
[{PUBLIC_ORIGIN:'http://market.example'},Infinity],[{WIKIDECK_PEERS:''},Infinity],
[{WIKIDECK_PEERS:'https://peer-a.example,https://peer-a.example/'},Infinity],
[{WIKIDECK_ALLOW_PRIVATE_PEERS:'1'},Infinity],[{WIKIDECK_PAYMENT_MODE:'shkeeper'},Infinity],[{},Infinity]]) {
try { assertLaunchAllowed({...env,...changes},now); results.push(true); } catch { results.push(false); }
}
console.log(JSON.stringify(results));
`));
assert.deepEqual(result, [false,false,false,false,false,false,false,true]);
assert.throws(() => paymentMode({ WIKIDECK_NETWORK: 'mainnet', WIKIDECK_PAYMENT_MODE: 'shkeeper' }));
});
test('mainnet ledger and market files use isolated folders; testnet backup headers are rejected', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wikideck-network-'));
try {
const result = JSON.parse(script(`
import { Ledger } from './server/testnet/ledger.ts';
import { Market } from './server/market/store.ts';
import { decryptWallet } from './shared/wallet-backup.ts';
import { existsSync } from 'node:fs'; import { join } from 'node:path';
const ledger=await Ledger.open(process.env.PROFILE_TEST_DIR,{hash:async()=>{throw Error('unused')},close(){}});
const market=await Market.open(process.env.PROFILE_TEST_DIR,ledger);
let rejected=false;
try {await decryptWallet({format:'wikideck-wallet-backup',version:1,network:'wikideck-randomx-testnet-6',
kdf:'PBKDF2-SHA256',iterations:600000,cipher:'AES-256-GCM',salt:'',iv:'',publicKey:'',ciphertext:''},'password');}
catch(e){rejected=/network/.test(e.message);}
console.log(JSON.stringify({ledger:existsSync(join(process.env.PROFILE_TEST_DIR,'mainnet','ledger.sqlite')),
market:existsSync(join(process.env.PROFILE_TEST_DIR,'mainnet','market.sqlite')),
testnet:existsSync(join(process.env.PROFILE_TEST_DIR,'testnet')),rejected}));
market.close();ledger.close();
`, { PROFILE_TEST_DIR: dir }));
assert.deepEqual(result, { ledger: true, market: true, testnet: false, rejected: true });
} finally { await rm(dir, { recursive: true, force: true }); }
});