Remove markets
This commit is contained in:
+10
-98
@@ -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, randomUUID, sign } from 'node:crypto';
|
||||
import { generateKeyPairSync, 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,14 +14,9 @@ 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 { type MarketAction, type MarketInfo } from '../shared/market.ts';
|
||||
import { marketMessageV3, type MarketActionV3, type MarketActivityV3, type MarketListingV3, type MarketOrderV3 } from '../shared/market-v3.ts';
|
||||
import { StateClient } from '../shared/rpc-client.ts';
|
||||
import { createBackup,restoreBackup } from '../server/recovery.ts';
|
||||
import { Ledger } from '../server/testnet/ledger.ts';
|
||||
import { Market } from '../server/market/store.ts';
|
||||
import { CUSTODY_POLICY, custodyMessage, type CustodyCommand, type CustodyPage, type CustodyActivity } from '../shared/custody.ts';
|
||||
import { CustodyMarket } from '../server/market/custody.ts';
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const wikipediaMock = resolve(root, 'tests', 'mock-wikipedia.mjs');
|
||||
@@ -82,17 +77,14 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
|
||||
};
|
||||
t.signal.addEventListener('abort', abort, { once: true });
|
||||
|
||||
async function start(dataDir: string, peer?: string, marketRuntimeEnabled = true): Promise<RunningNode> {
|
||||
async function start(dataDir: string, peer?: string): Promise<RunningNode> {
|
||||
t.signal.throwIfAborted();
|
||||
const child = spawn(process.execPath, ['--import', wikipediaMock, '--experimental-strip-types', 'server/index.ts'], {
|
||||
cwd: root, windowsHide: true, shell: false,
|
||||
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', WIKIDECK_PAYMENT_MODE: 'mock',
|
||||
WIKIDECK_MARKET_RUNTIME_ENABLED: marketRuntimeEnabled ? '1' : '0',
|
||||
WIKIDECK_MARKET_FEE_BPS: '250', WIKIDECK_MARKET_CONFIRMATIONS: '2', WIKIDECK_CUSTODY_MODE: peer ? 'off' : 'sandbox',
|
||||
// This integration burst now includes signed custody reads/replay probes; admission limits have dedicated tests.
|
||||
PUBLIC_ORIGIN: 'http://127.0.0.1:3001',
|
||||
WIKIDECK_RPC_WRITES_PER_SECOND: '50' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
@@ -128,20 +120,12 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
|
||||
|
||||
async function request(base: string, path: string, expected = 200, body?: unknown,
|
||||
headers: Record<string, string> = {}): Promise<Response> {
|
||||
const until = Date.now() + 20_000;
|
||||
let response: Response;
|
||||
do {
|
||||
response = await fetch(`${base}${path}`, {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method: body === undefined ? 'GET' : 'POST', redirect: 'error',
|
||||
signal: AbortSignal.any([t.signal, AbortSignal.timeout(30_000)]),
|
||||
headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (path !== '/api/market/command' || response.status !== 503 || Date.now() >= until ||
|
||||
!(await response.clone().text()).includes('reconciliation is catching up')) break;
|
||||
const retryAfter = Number(response.headers.get('Retry-After'));
|
||||
await delay(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 25, undefined, { signal: t.signal });
|
||||
} while (Date.now() < until);
|
||||
if (response.status !== expected) {
|
||||
assert.fail(`${path}: expected ${expected}, got ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
@@ -151,19 +135,6 @@ 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 atomicFromCents = (cents: number) => ((BigInt(cents) * 1_000_000_000_000n + 14999n) / 15000n).toString();
|
||||
let current: MarketActionV3;
|
||||
if (action.type === 'list') current = { type: 'list', cardHash: action.cardHash, sequence: action.sequence,
|
||||
kind: action.kind, priceAtomicXmr: atomicFromCents(action.priceCents), endsAt: action.endsAt, feeBps: action.feeBps };
|
||||
else if (action.type === 'buy') current = { type: 'buy', listingId: action.listingId,
|
||||
priceAtomicXmr: atomicFromCents(action.priceCents), feeBps: action.feeBps };
|
||||
else if (action.type === 'bid') current = { type: 'bid', listingId: action.listingId, priceAtomicXmr: atomicFromCents(action.priceCents) };
|
||||
else current = action;
|
||||
const terms = { version: 3 as const, network: NETWORK, chainId: CHAIN_ID, marketId: info.marketId, actor: who.owner,
|
||||
publicKey: who.publicKey, nonce: randomUUID(), expiresAt: Date.now() + 60000, action: current };
|
||||
return json<T>(base, '/api/market/command', 200, { ...terms, signature: who.signed(marketMessageV3(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;
|
||||
@@ -219,25 +190,9 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
|
||||
engine = new RandomX({ binary }); // Always the actual reference RandomX engine, never a test double.
|
||||
const alice = wallet(), bob = wallet();
|
||||
assert.notEqual(alice.owner, bob.owner);
|
||||
const marketDisabledNode = await start(firstDir, undefined, false);
|
||||
await json(marketDisabledNode.base, '/api/summary');
|
||||
await request(marketDisabledNode.base, '/api/market', 404);
|
||||
await stop(marketDisabledNode);
|
||||
let first = await start(firstDir);
|
||||
await assert.rejects(start(firstDir), /Data directory locked/,
|
||||
'a second server process must fail closed rather than run another marketplace worker on the same data directory');
|
||||
const custody = await json<CustodyPage>(first.base, '/api/custody');
|
||||
assert.equal(custody.info.mode, 'sandbox'); assert.equal(custody.info.liveDeposits, false);
|
||||
const custodyTerms: Omit<CustodyCommand, 'signature'> = { version: 1, mode: 'sandbox', policy: CUSTODY_POLICY,
|
||||
network: NETWORK, chainId: CHAIN_ID, custodianId: custody.info.custodianId, actor: bob.owner,
|
||||
publicKey: bob.publicKey, nonce: randomUUID(), expiresAt: Date.now() + 60000, action: { type: 'mock-credit' } };
|
||||
const custodyCredit = { ...custodyTerms, signature: bob.signed(custodyMessage(custodyTerms)) };
|
||||
const credited = await json<CustodyActivity>(first.base, '/api/custody/command', 200, custodyCredit);
|
||||
assert.equal(credited.availableAtomicXmr, '1000000000000');
|
||||
assert.deepEqual(await json(first.base, '/api/custody/command', 200, custodyCredit), credited);
|
||||
await request(first.base, '/api/custody/command', 400, { ...custodyCredit, actor: alice.owner });
|
||||
await request(first.base, '/api/custody/command', 400, { ...custodyCredit, mode: 'live' });
|
||||
assert.match((await request(first.base, '/api/custody/command', 200, custodyCredit)).headers.get('cache-control') ?? '', /no-store/);
|
||||
'a second server process must fail closed rather than run a second writer on the same data directory');
|
||||
const health = await request(first.base, '/healthz');
|
||||
assert.deepEqual(await health.json(), { status: 'ok', network: NETWORK });
|
||||
assert.equal(health.headers.get('cache-control'), 'no-store');
|
||||
@@ -272,9 +227,13 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
|
||||
signal: AbortSignal.any([t.signal, AbortSignal.timeout(5000)]) });
|
||||
assert.equal(malformed.status, 400);
|
||||
assert.deepEqual(await malformed.json(), { error: 'Invalid request encoding' });
|
||||
for (const path of ['/api/missing', '/p2p/missing', `/api/nfts/${'0'.repeat(64)}`]) {
|
||||
for (const path of ['/api/missing', '/api/market', '/api/custody', '/api/monero/deposit-config',
|
||||
'/p2p/missing', `/api/nfts/${'0'.repeat(64)}`]) {
|
||||
await (await request(first.base, path, 404)).text();
|
||||
}
|
||||
for (const path of ['/api/market/command', '/api/custody/command', '/api/monero/deposit-address']) {
|
||||
await (await request(first.base, path, 404, {})).text();
|
||||
}
|
||||
for (const offset of ['-1', '1.5', '9007199254740992', 'nope']) {
|
||||
await (await request(first.base, `/p2p/blocks?offset=${offset}`, 400)).text();
|
||||
}
|
||||
@@ -307,25 +266,10 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
|
||||
await request(first.base, '/api/nfts/not-a-token', 400);
|
||||
await request(first.base, `/api/decks/${alice.owner}?tip=${block1.hash}`, 409);
|
||||
assert.equal((await json<{ total: number }>(first.base, `/api/decks/${alice.owner}?tip=${block2.hash}&limit=1`)).total, 2);
|
||||
const market = await json<{ info: MarketInfo; listings: MarketListingV3[] }>(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<MarketListingV3>(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: MarketListingV3[] }>(first.base, '/api/market')).listings[0].id, sale.id);
|
||||
const order = await marketCommand<MarketOrderV3>(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.
|
||||
const second = await start(secondDir, first.base);
|
||||
assert.deepEqual(await json(second.base, '/api/custody'), { enabled: false });
|
||||
await request(second.base, '/api/custody/command', 503, custodyCredit);
|
||||
const synced = await waitFor('Second node canonical sync', () => snapshot(second.base),
|
||||
state => state.tip === block2.hash && state.peers === 1);
|
||||
assert.deepEqual(synced.blocks, [block1, block2]);
|
||||
@@ -336,8 +280,6 @@ 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<MarketActivityV3>(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);
|
||||
@@ -375,12 +317,6 @@ 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 waitFor('Worker market delivery reconciliation',
|
||||
() => marketCommand<MarketActivityV3>(first.base, market.info, bob, { type: 'activity' }),
|
||||
activity => activity.orders[0]?.deliveryConfirmations === 1);
|
||||
assert.equal(settling.orders[0].deliveryConfirmations, 1);
|
||||
assert.equal(settling.orders[0].payout, 'not-started');
|
||||
assert.equal((await json<{ listings: MarketListingV3[] }>(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);
|
||||
@@ -413,37 +349,13 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
|
||||
}
|
||||
const backupDir=join(directory,'stopped-pair-backup'),drillDir=join(directory,'isolated-restore-drill');
|
||||
const backupManifest = await createBackup(firstDir,backupDir);await restoreBackup(backupDir,drillDir);
|
||||
assert.ok(backupManifest.files.some(f=>f.name==='custody-sandbox.sqlite'));
|
||||
const drillCustody = await CustodyMarket.open(drillDir, { feeBps: 250, card: () => undefined });
|
||||
try {
|
||||
assert.equal(drillCustody.id, custody.info.custodianId);
|
||||
assert.equal(drillCustody.audit().assetsAtomicXmr, '1000000000000');
|
||||
} finally { drillCustody.close(); }
|
||||
const drillLedger=await Ledger.open(drillDir,engine!); // Revalidate real RandomX proofs from the copied backup.
|
||||
try {
|
||||
assert.deepEqual(drillLedger.snapshot(),{...canonical,peers:0});
|
||||
const drillMarket=await Market.open(drillDir,drillLedger);
|
||||
try {assert.equal(drillMarket.id,market.info.marketId);}finally{drillMarket.close();}
|
||||
const db=new DatabaseSync(join(drillDir,'testnet','market.sqlite'),{readOnly:true});
|
||||
try {
|
||||
const row=db.prepare('SELECT data FROM orders WHERE id=?').get(order.id) as {data:string};
|
||||
assert.equal(JSON.parse(row.data).buyer,bob.owner);
|
||||
assert.ok((db.prepare('SELECT COUNT(*) AS n FROM receipts').get() as {n:number}).n>0);
|
||||
}finally{db.close();}
|
||||
}finally{drillLedger.close();}
|
||||
first = await start(firstDir); // Ledger.open revalidates every persisted block using native RandomX.
|
||||
const restartedCustody = await json<CustodyPage>(first.base, '/api/custody');
|
||||
assert.equal(restartedCustody.info.custodianId, custody.info.custodianId);
|
||||
const balanceTerms: Omit<CustodyCommand, 'signature'> = { ...custodyTerms, nonce: randomUUID(),
|
||||
expiresAt: Date.now() + 60000, action: { type: 'activity', offset: 0 } };
|
||||
const restoredBalance = await json<CustodyActivity>(first.base, '/api/custody/command', 200,
|
||||
{ ...balanceTerms, signature: bob.signed(custodyMessage(balanceTerms)) });
|
||||
assert.equal(restoredBalance.availableAtomicXmr, '1000000000000');
|
||||
const restored = await snapshot(first.base);
|
||||
assert.deepEqual(await pagedClient.load(),restored);
|
||||
const restoredMarket = await json<{ info: MarketInfo }>(first.base, '/api/market');
|
||||
assert.equal(restoredMarket.info.marketId, market.info.marketId);
|
||||
assert.equal((await marketCommand<MarketActivityV3>(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,
|
||||
|
||||
Reference in New Issue
Block a user