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,
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { assertCustodyDbActionAllowedOnNetwork, assertMainnetSchemaMigrationAcknowledged,
|
||||
assertMainnetCustodyOperationAcknowledged, assertPilotCustodyActionAllowed,
|
||||
assertPilotSchemaMigrationAcknowledged, assertPilotCustodyIdentityAcknowledged,
|
||||
assertPilotReservationGrantAcknowledged, assertPilotAddressGrantAcknowledged,
|
||||
assertPilotObserverGrantAcknowledged,
|
||||
assertPilotDepositReviewAcknowledged,
|
||||
parseCustodyDbArguments } from '../server/market/custody-db-args.ts';
|
||||
|
||||
test('custody database CLI parses two-argument deposit operations without shifting arguments', () => {
|
||||
assert.deepEqual(parseCustodyDbArguments(['deposit-prepare-credit', 'a'.repeat(64), `${'b'.repeat(64)}:${'c'.repeat(64)}`]), {
|
||||
action: 'deposit-prepare-credit', argument: 'a'.repeat(64), extraArgument: `${'b'.repeat(64)}:${'c'.repeat(64)}`,
|
||||
});
|
||||
assert.deepEqual(parseCustodyDbArguments(['deposit-prepare-resolve-hold', 'credit-id', 'hold-id']), {
|
||||
action: 'deposit-prepare-resolve-hold', argument: 'credit-id', extraArgument: 'hold-id',
|
||||
});
|
||||
assert.deepEqual(parseCustodyDbArguments(['grant-monero-observer-runtime', 'monero_observer']), {
|
||||
action: 'grant-monero-observer-runtime', argument: 'monero_observer', extraArgument: undefined,
|
||||
});
|
||||
assert.deepEqual(parseCustodyDbArguments(['grant-monero-deposit-allocator-runtime', 'monero_allocator']), {
|
||||
action: 'grant-monero-deposit-allocator-runtime', argument: 'monero_allocator', extraArgument: undefined,
|
||||
});
|
||||
assert.deepEqual(parseCustodyDbArguments(['grant-monero-withdrawal-runtime', 'monero_withdrawal']), {
|
||||
action: 'grant-monero-withdrawal-runtime', argument: 'monero_withdrawal', extraArgument: undefined,
|
||||
});
|
||||
assert.deepEqual(parseCustodyDbArguments(['grant-pilot-monero-review-runtime', 'pilot_deposit_review']), {
|
||||
action: 'grant-pilot-monero-review-runtime', argument: 'pilot_deposit_review', extraArgument: undefined,
|
||||
});
|
||||
assert.deepEqual(parseCustodyDbArguments(['pilot-deposit-prepare-review', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
`${'b'.repeat(64)}:${'c'.repeat(64)}`]), {
|
||||
action: 'pilot-deposit-prepare-review', argument: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
extraArgument: `${'b'.repeat(64)}:${'c'.repeat(64)}`,
|
||||
});
|
||||
assert.deepEqual(parseCustodyDbArguments(['initialize-pilot-custody-identity', 'AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA']), {
|
||||
action: 'initialize-pilot-custody-identity', argument: 'AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA', extraArgument: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('mainnet custody CLI permits only scoped Monero infrastructure grants and schema migration', () => {
|
||||
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('grant-monero-observer-runtime', true));
|
||||
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('grant-monero-deposit-allocator-runtime', true));
|
||||
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('grant-monero-withdrawal-runtime', true));
|
||||
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('migrate-mainnet-schema', true));
|
||||
assert.throws(() => assertCustodyDbActionAllowedOnNetwork('migrate-mainnet-schema', false), /requires the mainnet profile/);
|
||||
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('initialize-mainnet-custody-identity', true));
|
||||
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('deposit-stage-credit', true));
|
||||
assert.throws(() => assertCustodyDbActionAllowedOnNetwork('initialize-mainnet-custody-identity', false), /requires the mainnet profile/);
|
||||
assert.throws(() => assertMainnetSchemaMigrationAcknowledged('migrate-mainnet-schema', undefined), /acknowledgement/);
|
||||
assert.throws(() => assertMainnetSchemaMigrationAcknowledged('migrate-mainnet-schema', 'yes'), /acknowledgement/);
|
||||
assert.doesNotThrow(() => assertMainnetSchemaMigrationAcknowledged('migrate-mainnet-schema', 'explicit-schema-migration-only'));
|
||||
assert.doesNotThrow(() => assertMainnetSchemaMigrationAcknowledged('grant-monero-observer-runtime', undefined));
|
||||
for (const action of ['migrate', 'grant-runtime', 'audit'] as const) {
|
||||
assert.throws(() => assertCustodyDbActionAllowedOnNetwork(action, true), /only scoped/i);
|
||||
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork(action, false));
|
||||
}
|
||||
});
|
||||
|
||||
test('mainnet reviewed deposit operations require exact credit-only acknowledgement', () => {
|
||||
for (const action of ['initialize-mainnet-custody-identity', 'grant-monero-credit-runtime',
|
||||
'deposit-prepare-credit', 'deposit-stage-credit', 'deposit-prepare-release', 'deposit-release',
|
||||
'deposit-reconcile-holds', 'deposit-prepare-resolve-hold', 'deposit-resolve-hold'] as const) {
|
||||
assert.throws(() => assertMainnetCustodyOperationAcknowledged(action, undefined), /reviewed-credit-only/);
|
||||
assert.throws(() => assertMainnetCustodyOperationAcknowledged(action, 'yes'), /reviewed-credit-only/);
|
||||
assert.doesNotThrow(() => assertMainnetCustodyOperationAcknowledged(action, 'reviewed-credit-only'));
|
||||
}
|
||||
assert.doesNotThrow(() => assertMainnetCustodyOperationAcknowledged('grant-monero-observer-runtime', undefined));
|
||||
assert.doesNotThrow(() => assertMainnetCustodyOperationAcknowledged('grant-monero-deposit-allocator-runtime', undefined));
|
||||
assert.doesNotThrow(() => assertMainnetCustodyOperationAcknowledged('grant-monero-withdrawal-runtime', undefined));
|
||||
});
|
||||
|
||||
test('pilot custody CLI permits only pilot-scoped schema, identity, role, and review-preparation actions', () => {
|
||||
assert.doesNotThrow(() => assertPilotCustodyActionAllowed('migrate-pilot-schema', true));
|
||||
assert.doesNotThrow(() => assertPilotCustodyActionAllowed('initialize-pilot-custody-identity', true));
|
||||
assert.doesNotThrow(() => assertPilotCustodyActionAllowed('grant-pilot-deposit-reservation-runtime', true));
|
||||
assert.doesNotThrow(() => assertPilotCustodyActionAllowed('grant-pilot-monero-review-runtime', true));
|
||||
assert.doesNotThrow(() => assertPilotCustodyActionAllowed('pilot-deposit-prepare-review', true));
|
||||
assert.throws(() => assertPilotCustodyActionAllowed('grant-monero-observer-runtime', true), /pilot-specific schema/);
|
||||
assert.throws(() => assertPilotCustodyActionAllowed('deposit-stage-credit', true), /pilot-specific schema/);
|
||||
assert.throws(() => assertPilotCustodyActionAllowed('initialize-pilot-custody-identity', false), /requires the pilot profile/);
|
||||
assert.throws(() => assertPilotSchemaMigrationAcknowledged('migrate-pilot-schema', undefined), /acknowledgement/);
|
||||
assert.throws(() => assertPilotSchemaMigrationAcknowledged('migrate-pilot-schema', 'yes'), /acknowledgement/);
|
||||
assert.doesNotThrow(() => assertPilotSchemaMigrationAcknowledged('migrate-pilot-schema', 'explicit-pilot-schema-migration-only'));
|
||||
assert.throws(() => assertPilotCustodyIdentityAcknowledged('initialize-pilot-custody-identity', undefined, undefined), /no-funds acknowledgements/);
|
||||
assert.throws(() => assertPilotCustodyIdentityAcknowledged('initialize-pilot-custody-identity',
|
||||
'initialize-pilot-custody-v1', 'reviewed-credit-only'), /no-funds acknowledgements/);
|
||||
assert.doesNotThrow(() => assertPilotCustodyIdentityAcknowledged('initialize-pilot-custody-identity',
|
||||
'initialize-pilot-custody-v1', 'identity-only-no-funds'));
|
||||
assert.throws(() => assertPilotReservationGrantAcknowledged('grant-pilot-deposit-reservation-runtime', undefined), /acknowledgement/);
|
||||
assert.throws(() => assertPilotReservationGrantAcknowledged('grant-pilot-deposit-reservation-runtime', 'reviewed-credit-only'), /acknowledgement/);
|
||||
assert.doesNotThrow(() => assertPilotReservationGrantAcknowledged('grant-pilot-deposit-reservation-runtime', 'reservation-only-no-addresses'));
|
||||
assert.throws(() => assertPilotAddressGrantAcknowledged('grant-pilot-deposit-address-runtime', undefined), /acknowledgement/);
|
||||
assert.throws(() => assertPilotAddressGrantAcknowledged('grant-pilot-deposit-address-runtime', 'reservation-only-no-addresses'), /acknowledgement/);
|
||||
assert.doesNotThrow(() => assertPilotAddressGrantAcknowledged('grant-pilot-deposit-address-runtime', 'operator-triggered-addresses-only'));
|
||||
assert.throws(() => assertPilotObserverGrantAcknowledged('grant-pilot-monero-observer-runtime', undefined), /no-credit acknowledgement/);
|
||||
assert.throws(() => assertPilotObserverGrantAcknowledged('grant-pilot-monero-observer-runtime', 'reviewed-credit-only'), /no-credit acknowledgement/);
|
||||
assert.doesNotThrow(() => assertPilotObserverGrantAcknowledged('grant-pilot-monero-observer-runtime', 'pilot-observation-only-no-credit'));
|
||||
for (const action of ['grant-pilot-monero-review-runtime', 'pilot-deposit-prepare-review'] as const) {
|
||||
assert.throws(() => assertPilotDepositReviewAcknowledged(action, undefined), /acknowledgement/);
|
||||
assert.throws(() => assertPilotDepositReviewAcknowledged(action, 'yes'), /acknowledgement/);
|
||||
}
|
||||
assert.doesNotThrow(() => assertPilotDepositReviewAcknowledged('grant-pilot-monero-review-runtime', 'read-only-pilot-review-role-only'));
|
||||
assert.doesNotThrow(() => assertPilotDepositReviewAcknowledged('pilot-deposit-prepare-review', 'pilot-deposit-review-preparation-only'));
|
||||
assert.throws(() => assertPilotCustodyActionAllowed('pilot-deposit-prepare-review', false), /requires the pilot profile/);
|
||||
});
|
||||
|
||||
test('custody database CLI rejects unknown, missing, empty, and excess arguments', () => {
|
||||
for (const args of [[], ['unknown'], ['audit', 'extra'], ['deposit-prepare-credit', 'wallet'],
|
||||
['deposit-prepare-credit', 'wallet', 'observation', 'extra'], ['deposit-release', '']]) {
|
||||
assert.throws(() => parseCustodyDbArguments(args));
|
||||
}
|
||||
});
|
||||
@@ -1,253 +0,0 @@
|
||||
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 { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { CustodyMarket, custodyMode } from '../server/market/custody.ts';
|
||||
import { CUSTODY_POLICY, MOCK_CREDIT_ATOMIC, custodyCommandSchema, custodyMessage,
|
||||
type CustodyAction, type CustodyActivity, type CustodyListing, type CustodyOrder,
|
||||
type CustodyWithdrawal, type CustodyCommand } from '../shared/custody.ts';
|
||||
import { NETWORK } from '../shared/testnet.ts';
|
||||
import { address, CHAIN_ID } from '../server/testnet/consensus.ts';
|
||||
import { operatorMarketPolicy, validateMarketPolicy, type MarketPolicy } from '../server/market/policy.ts';
|
||||
import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts';
|
||||
import { keccak } from 'hash-wasm';
|
||||
|
||||
function identity() {
|
||||
const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
||||
return { publicKey, actor: address(publicKey), sign: (message: string) => sign('sha256', Buffer.from(message),
|
||||
{ key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') };
|
||||
}
|
||||
const seller = identity(), buyer = identity(), stranger = identity(), cardHash = 'a'.repeat(64);
|
||||
const evidence = `fixture:${'c'.repeat(64)}`, tx = `fixture:${'d'.repeat(64)}`;
|
||||
async function fixture(t: { after(fn: () => Promise<void>): void }) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wikideck-custody-'));
|
||||
let now = 1_800_000_000_000, present = true;
|
||||
const options = { feeBps: 250, now: () => now, card: (hash: string, owner: string, sequence: number) =>
|
||||
present && hash === cardHash && owner === seller.actor && sequence === 0 ? { title: 'Custody fixture' } : undefined };
|
||||
let market = await CustodyMarket.open(dir, options);
|
||||
t.after(async () => { market.close(); await rm(dir, { recursive: true, force: true }); });
|
||||
const envelope = (who: typeof buyer, action: CustodyAction, nonce: string = randomUUID()) => {
|
||||
const c: Omit<CustodyCommand, 'signature'> = { version: 1, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK,
|
||||
chainId: CHAIN_ID, custodianId: market.id, actor: who.actor, publicKey: who.publicKey,
|
||||
nonce, expiresAt: now + 60000, action };
|
||||
return { ...c, signature: who.sign(custodyMessage(c)) };
|
||||
};
|
||||
const execute = <T>(who: typeof buyer, action: CustodyAction) => market.execute(envelope(who, action)) as T;
|
||||
const activity = (who = buyer) => execute<CustodyActivity>(who, { type: 'activity', offset: 0 });
|
||||
const credit = (who = buyer) => execute<CustodyActivity>(who, { type: 'mock-credit' });
|
||||
const listing = (price = '100000000000') => execute<CustodyListing>(seller, { type: 'list', cardHash,
|
||||
sequence: 0, priceAtomicXmr: price, feeBps: 250 });
|
||||
const reserve = (l: CustodyListing, who = buyer) => execute<CustodyOrder>(who, { type: 'reserve', listingId: l.id,
|
||||
priceAtomicXmr: l.priceAtomicXmr, feeBps: l.feeBps });
|
||||
return { dir, envelope, execute, activity, credit, listing, reserve, get market() { return market; },
|
||||
changeCard: () => { present = false; }, advance: (ms: number) => { now += ms; },
|
||||
restart: async (policy: MarketPolicy = {}) => { market.close(); market = await CustodyMarket.open(dir, { ...options, ...policy }); } };
|
||||
}
|
||||
|
||||
async function feeAddress() {
|
||||
const prefix = Uint8Array.from([18, ...Array(64).fill(2)]);
|
||||
const data = Buffer.concat([prefix, Buffer.from((await keccak(prefix, 256)).slice(0, 8), 'hex')]);
|
||||
const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
const sizes: Record<number, number> = { 1: 2, 2: 3, 3: 5, 4: 6, 5: 7, 6: 9, 7: 10, 8: 11 };
|
||||
let encoded = '';
|
||||
for (let i = 0; i < data.length; i += 8) {
|
||||
const chunk = data.subarray(i, i + 8); let n = BigInt(`0x${chunk.toString('hex')}`), text = '';
|
||||
while (n) { text = alphabet[Number(n % 58n)] + text; n /= 58n; }
|
||||
encoded += text.padStart(sizes[chunk.length], '1');
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
test('operator market switches are strict and host fee defaults to fixed author with checksum validation', async () => {
|
||||
assert.deepEqual(operatorMarketPolicy({}), { marketEnabled: true, tradingEnabled: true, hostFeeAddress: ADDITIONAL_MARKET_FEE.address });
|
||||
for (const v of ['', 'true', 'false', 'yes', '2']) {
|
||||
assert.throws(() => operatorMarketPolicy({ WIKIDECK_MARKET_ENABLED: v }));
|
||||
assert.throws(() => operatorMarketPolicy({ WIKIDECK_MARKET_TRADING_ENABLED: v }));
|
||||
}
|
||||
await validateMarketPolicy(operatorMarketPolicy({}));
|
||||
await assert.rejects(validateMarketPolicy({ hostFeeAddress: '4'.repeat(95) }), /checksum|network/);
|
||||
});
|
||||
test('market disable hides public listings and rejects new business but retains private balances and safe release', async t => {
|
||||
const f = await fixture(t); f.credit(); const l = f.listing(), o = f.reserve(l);
|
||||
const w = f.execute<CustodyWithdrawal>(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '100', maxFeeAtomicXmr: '1' });
|
||||
await f.restart({ marketEnabled: false });
|
||||
assert.deepEqual(f.market.browse().listings, []); assert.equal(f.market.info().marketEnabled, false);
|
||||
assert.throws(() => f.listing('2'), /disabled/);
|
||||
assert.throws(() => f.credit(stranger), /disabled/);
|
||||
f.execute(buyer, { type: 'cancel-reservation', orderId: o.id });
|
||||
f.execute(buyer, { type: 'cancel-withdrawal', withdrawalId: w.id });
|
||||
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC);
|
||||
const withdrawal = f.execute<CustodyWithdrawal>(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' });
|
||||
assert.equal(withdrawal.status, 'reserved');
|
||||
});
|
||||
test('paused trading keeps browse and cancellations available, and fee recipients are signed and snapshotted', async t => {
|
||||
const f = await fixture(t), recipient = await feeAddress(); f.credit();
|
||||
await f.restart({ hostFeeAddress: recipient });
|
||||
assert.throws(() => f.listing(), /recipient/);
|
||||
const action = { type: 'list' as const, cardHash, sequence: 0, priceAtomicXmr: '1000', feeBps: 250, hostFeeAddress: recipient };
|
||||
const c = f.envelope(seller, action);
|
||||
assert.throws(() => f.market.execute({ ...c, action: { ...action, hostFeeAddress: ADDITIONAL_MARKET_FEE.address } }), /signature/);
|
||||
const l = f.market.execute(c) as CustodyListing;
|
||||
await f.restart({ hostFeeAddress: ADDITIONAL_MARKET_FEE.address, tradingEnabled: false });
|
||||
assert.equal(f.market.browse().listings[0].hostFeeAddress, recipient);
|
||||
assert.throws(() => f.reserve(l), /disabled/);
|
||||
await f.restart();
|
||||
assert.throws(() => f.reserve(l), /recipient/);
|
||||
const o = f.execute<CustodyOrder>(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250, hostFeeAddress: recipient });
|
||||
assert.equal(o.hostFeeAddress, recipient); assert.equal(o.authorAddress, ADDITIONAL_MARKET_FEE.address);
|
||||
f.market.settleFixture(o.id, evidence); assert.equal(f.market.audit().assetsAtomicXmr, MOCK_CREDIT_ATOMIC);
|
||||
});
|
||||
|
||||
test('custody mode is off by default and rejects all live configurations', () => {
|
||||
assert.equal(custodyMode({}), 'off'); assert.equal(custodyMode({ WIKIDECK_CUSTODY_MODE: 'sandbox' }), 'sandbox');
|
||||
for (const mode of ['live', 'mainnet', '', 'processor']) assert.throws(() => custodyMode({ WIKIDECK_CUSTODY_MODE: mode }));
|
||||
});
|
||||
|
||||
test('custody requires a separate consent domain and rejects malformed monetary encodings', async t => {
|
||||
const f = await fixture(t), c = f.envelope(buyer, { type: 'mock-credit' });
|
||||
assert.match(custodyMessage(c), /wikideck-custody-command-v1/);
|
||||
for (const patch of [{ actor: seller.actor }, { chainId: 'f'.repeat(64) }, { custodianId: randomUUID() },
|
||||
{ expiresAt: c.expiresAt + 1 }, { action: { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' } }]) {
|
||||
assert.throws(() => f.market.execute({ ...c, ...patch }));
|
||||
}
|
||||
for (const value of ['0', '-1', '01', '1.0', '1e2', ' 1', '1\n', '999999999999999999999999999999999']) {
|
||||
assert.equal(custodyCommandSchema.safeParse({ ...c, action: { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: value, maxFeeAtomicXmr: '0' } }).success, false);
|
||||
}
|
||||
assert.throws(() => f.market.execute({ ...c, mode: 'live' }));
|
||||
assert.throws(() => f.market.execute({ ...c, action: { type: 'withdraw', destination: 'real-address', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' } }));
|
||||
assert.equal(f.activity().availableAtomicXmr, '0');
|
||||
});
|
||||
|
||||
test('mock funding and replay receipts persist across restart and never credit twice', async t => {
|
||||
const f = await fixture(t), c = f.envelope(buyer, { type: 'mock-credit' });
|
||||
const first = f.market.execute(c);
|
||||
assert.deepEqual(f.market.execute(c), first);
|
||||
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC);
|
||||
assert.throws(() => f.credit(), /already claimed/);
|
||||
const changed = f.envelope(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' }, c.nonce);
|
||||
assert.throws(() => f.market.execute(changed), /Nonce already used/);
|
||||
await f.restart(); assert.deepEqual(f.market.execute(c), first);
|
||||
assert.equal(f.market.audit().journals, 1);
|
||||
f.advance(60001); assert.throws(() => f.market.execute(c), /expired/);
|
||||
});
|
||||
|
||||
test('funded fixed-price reservation and blocked intent commit together with private activity', async t => {
|
||||
const f = await fixture(t), l = f.listing();
|
||||
assert.throws(() => f.reserve(l), /Insufficient/);
|
||||
assert.equal(f.market.browse().listings[0].status, 'open');
|
||||
assert.equal(f.activity().orders.length, 0);
|
||||
f.credit(); const order = f.reserve(l);
|
||||
assert.equal(order.status, 'awaiting-lock'); assert.equal(order.sellerAtomicXmr, '96500000000');
|
||||
assert.equal(order.authorAtomicXmr, '1000000000'); assert.equal(order.hostAtomicXmr, '2500000000');
|
||||
assert.equal(f.activity().availableAtomicXmr, '900000000000');
|
||||
assert.equal(f.activity().purchaseReservedAtomicXmr, l.priceAtomicXmr);
|
||||
assert.equal(f.activity(seller).availableAtomicXmr, '0');
|
||||
assert.equal(f.activity(seller).orders.length, 1); assert.equal(f.activity(stranger).orders.length, 0);
|
||||
assert.throws(() => f.execute(stranger, { type: 'cancel-reservation', orderId: order.id }), /Only the buyer/);
|
||||
await f.restart();
|
||||
f.execute(buyer, { type: 'cancel-reservation', orderId: order.id });
|
||||
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC); assert.equal(f.activity().purchaseReservedAtomicXmr, '0');
|
||||
assert.throws(() => f.market.settleFixture(order.id, evidence), /not eligible/);
|
||||
assert.equal(f.market.audit().assetsAtomicXmr, MOCK_CREDIT_ATOMIC);
|
||||
});
|
||||
|
||||
test('card changes, self-purchases and changed amounts cannot reserve funds', async t => {
|
||||
const f = await fixture(t), l = f.listing(); f.credit();
|
||||
assert.throws(() => f.reserve(l, seller), /own card/);
|
||||
assert.throws(() => f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: '1', feeBps: 250 }), /terms changed/);
|
||||
f.changeCard(); assert.throws(() => f.reserve(l), /no longer available/);
|
||||
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC);
|
||||
});
|
||||
|
||||
test('offline canonical fixture settles exactly once with conserved fees and one-atomic-unit prices', async t => {
|
||||
const f = await fixture(t); f.credit(); const l = f.listing('1'), o = f.reserve(l);
|
||||
assert.equal(o.sellerAtomicXmr, '1'); assert.equal(o.hostAtomicXmr, '0'); assert.equal(o.authorAtomicXmr, '0');
|
||||
assert.throws(() => f.market.settleFixture(o.id, 'rpc-confirmed'), /offline fixture/);
|
||||
f.market.settleFixture(o.id, evidence);
|
||||
assert.equal(f.activity(seller).availableAtomicXmr, '1');
|
||||
assert.equal(f.activity().availableAtomicXmr, '999999999999');
|
||||
await f.restart(); f.market.settleFixture(o.id, evidence);
|
||||
assert.equal(f.activity(seller).availableAtomicXmr, '1');
|
||||
assert.throws(() => f.market.settleFixture(o.id, `fixture:${'b'.repeat(64)}`), /Conflicting/);
|
||||
assert.throws(() => f.execute(buyer, { type: 'cancel-reservation', orderId: o.id }), /Only the buyer/);
|
||||
assert.deepEqual(f.market.audit(), { journals: 3, assetsAtomicXmr: MOCK_CREDIT_ATOMIC, claimsAndRevenueAtomicXmr: MOCK_CREDIT_ATOMIC });
|
||||
});
|
||||
|
||||
test('competing signed reservations serialize and failed attempts leave no partial writes', async t => {
|
||||
const f = await fixture(t), l = f.listing('800000000000'); f.credit(); f.credit(stranger);
|
||||
const commands = [f.envelope(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 }),
|
||||
f.envelope(stranger, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 })];
|
||||
const results = await Promise.allSettled(commands.map(c => Promise.resolve().then(() => f.market.execute(c))));
|
||||
assert.equal(results.filter(r => r.status === 'fulfilled').length, 1);
|
||||
assert.equal(f.activity().orders.length + f.activity(stranger).orders.length, 1);
|
||||
assert.equal(f.market.audit().journals, 3);
|
||||
assert.throws(() => f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '300000000000', maxFeeAtomicXmr: '0' }), /Insufficient/);
|
||||
assert.equal(f.activity().withdrawals.length, 0);
|
||||
});
|
||||
|
||||
test('withdrawal cancellation releases maximum fee and prepared uncertainty blocks cancellation/replacement', async t => {
|
||||
const f = await fixture(t); f.credit();
|
||||
const make = () => f.execute<CustodyWithdrawal>(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '100000000000', maxFeeAtomicXmr: '1000' });
|
||||
const cancelled = make(); f.execute(buyer, { type: 'cancel-withdrawal', withdrawalId: cancelled.id });
|
||||
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC);
|
||||
const w = make(), attempt = randomUUID();
|
||||
assert.throws(() => f.market.prepareWithdrawalFixture(w.id, attempt, tx, '1001'), /fee cap/);
|
||||
assert.equal(f.activity().withdrawals[0].status, 'reserved');
|
||||
f.market.prepareWithdrawalFixture(w.id, attempt, tx, '400');
|
||||
await f.restart();
|
||||
assert.throws(() => f.execute(buyer, { type: 'cancel-withdrawal', withdrawalId: w.id }), /unprepared/);
|
||||
assert.throws(() => f.market.prepareWithdrawalFixture(w.id, randomUUID(), tx, '400'), /different terms/);
|
||||
assert.equal(f.market.prepareWithdrawalFixture(w.id, attempt, tx, '400').transactionId, tx);
|
||||
assert.throws(() => f.market.confirmWithdrawalFixture(w.id, `fixture:${'e'.repeat(64)}`), /mismatch/);
|
||||
f.market.confirmWithdrawalFixture(w.id, tx); f.market.confirmWithdrawalFixture(w.id, tx);
|
||||
assert.equal(f.activity().availableAtomicXmr, '899999999600'); assert.equal(f.activity().withdrawalReservedAtomicXmr, '0');
|
||||
assert.equal(f.market.audit().assetsAtomicXmr, '899999999600');
|
||||
});
|
||||
|
||||
test('multiple connections cannot overspend a balance and cancelled commands do not double-release', async t => {
|
||||
const f = await fixture(t); f.credit();
|
||||
const second = await CustodyMarket.open(f.dir, { feeBps: 250, now: () => 1_800_000_000_000, card: () => undefined });
|
||||
try {
|
||||
const c = f.envelope(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '900000000000', maxFeeAtomicXmr: '0' });
|
||||
const w = second.execute(c) as CustodyWithdrawal;
|
||||
assert.throws(() => f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '900000000000', maxFeeAtomicXmr: '0' }), /Insufficient/);
|
||||
const cancel = f.envelope(buyer, { type: 'cancel-withdrawal', withdrawalId: w.id });
|
||||
f.market.execute(cancel); second.execute(cancel);
|
||||
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC); second.audit();
|
||||
} finally { second.close(); }
|
||||
});
|
||||
|
||||
test('sandbox cap stops new work but never blocks release of existing unstarted holds', async t => {
|
||||
const f = await fixture(t); f.credit();
|
||||
const w = f.execute<CustodyWithdrawal>(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '100', maxFeeAtomicXmr: '1' });
|
||||
const db = new DatabaseSync(join(f.dir, 'testnet', 'custody-sandbox.sqlite'));
|
||||
try {
|
||||
const insert = db.prepare('INSERT INTO receipts VALUES(?,?,?)');
|
||||
db.exec('BEGIN'); for (let i = 0; i < 10000; i++) insert.run(`fixture:${i}`, 'fixture', '{}'); db.exec('COMMIT');
|
||||
assert.throws(() => f.listing(), /budget/);
|
||||
f.execute(buyer, { type: 'cancel-withdrawal', withdrawalId: w.id });
|
||||
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC);
|
||||
assert.throws(() => db.prepare("UPDATE entries SET debit='1'").run(), /Immutable/);
|
||||
assert.throws(() => db.prepare('DELETE FROM journals').run(), /Immutable/);
|
||||
db.prepare("UPDATE accounts SET balance='12' WHERE id=?").run(`customer:${buyer.actor}:available`);
|
||||
assert.throws(() => f.market.audit(), /projection mismatch/);
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
test('property-style repeated settlements conserve gross and never round up author/host fees', async t => {
|
||||
const f = await fixture(t); f.credit();
|
||||
let spent = 0n, received = 0n;
|
||||
for (let i = 1n; i <= 40n; i++) {
|
||||
const price = i * 137n, o = f.reserve(f.listing(price.toString()));
|
||||
f.market.settleFixture(o.id, `fixture:${i.toString(16).padStart(64, '0')}`);
|
||||
assert.equal(BigInt(o.authorAtomicXmr), price / 100n);
|
||||
assert.equal(BigInt(o.hostAtomicXmr), price * 250n / 10000n);
|
||||
spent += price; received += BigInt(o.sellerAtomicXmr);
|
||||
assert.equal(BigInt(f.activity().availableAtomicXmr) + spent, BigInt(MOCK_CREDIT_ATOMIC));
|
||||
assert.equal(BigInt(f.activity(seller).availableAtomicXmr), received);
|
||||
assert.equal(f.market.audit().assetsAtomicXmr, MOCK_CREDIT_ATOMIC);
|
||||
}
|
||||
});
|
||||
@@ -1,771 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { generateKeyPairSync, randomUUID, sign } from 'node:crypto';
|
||||
import { mkdir, 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 } from '../shared/market.ts';
|
||||
import { marketActionV3Schema, marketMessageV3, type MarketActionV3, type MarketActivityV3,
|
||||
type MarketListingV3, type MarketOrderV3 } from '../shared/market-v3.ts';
|
||||
import { NETWORK, nftTransferMessage, testnetOwnerOf, transactionCardIds, type NftTransfer, type TestnetSnapshot, type TestnetBlock } from '../shared/testnet.ts';
|
||||
import type { CanonicalEvent } from '../server/testnet/canonical-index.ts';
|
||||
import type { MarketPolicy } from '../server/market/policy.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 }, indexed = false, useWorker = false, workerLedger = false) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wikideck-market-'));
|
||||
const chain = chainFixture();
|
||||
// Test-only indexed adapter: production indexes/events are exercised separately by Ledger tests.
|
||||
const fEvents: CanonicalEvent[] = [];
|
||||
const streamId = randomUUID();
|
||||
const ledgerFile = join(dir, 'testnet', 'ledger.sqlite');
|
||||
if (workerLedger) {
|
||||
await mkdir(join(dir, 'testnet'), { recursive: true });
|
||||
const ledgerDb = new DatabaseSync(ledgerFile);
|
||||
ledgerDb.exec(`PRAGMA journal_mode=WAL; CREATE TABLE blocks(hash TEXT PRIMARY KEY,block_json TEXT NOT NULL);
|
||||
CREATE TABLE canonical_blocks(height INTEGER PRIMARY KEY,hash TEXT NOT NULL);
|
||||
CREATE TABLE canonical_nfts(token TEXT PRIMARY KEY,owner TEXT NOT NULL,sequence INTEGER NOT NULL,minted_height INTEGER NOT NULL,block_hash TEXT NOT NULL);
|
||||
CREATE TABLE canonical_transactions(id TEXT PRIMARY KEY,height INTEGER NOT NULL,block_hash TEXT NOT NULL);
|
||||
CREATE TABLE canonical_events(sequence INTEGER PRIMARY KEY,type TEXT NOT NULL);
|
||||
CREATE TABLE pending_transfers(position INTEGER PRIMARY KEY,id TEXT NOT NULL,transaction_json TEXT NOT NULL);`);
|
||||
const insertBlock = ledgerDb.prepare('INSERT INTO blocks VALUES(?,?)');
|
||||
const insertCanonicalBlock = ledgerDb.prepare('INSERT INTO canonical_blocks VALUES(?,?)');
|
||||
const insertNft = ledgerDb.prepare('INSERT INTO canonical_nfts VALUES(?,?,?,?,?)');
|
||||
chain.state.blocks.forEach((block, i) => {
|
||||
const hash = i === chain.state.blocks.length - 1 ? chain.state.tip : (i + 1).toString(16).padStart(64, '0');
|
||||
insertBlock.run(hash, JSON.stringify(block)); insertCanonicalBlock.run(block.height, hash);
|
||||
insertNft.run(block.tokenId, alice.actor, 0, block.height, hash);
|
||||
});
|
||||
ledgerDb.close();
|
||||
}
|
||||
const syncWorkerEvents = () => {
|
||||
if (!workerLedger) return;
|
||||
const ledgerDb = new DatabaseSync(ledgerFile);
|
||||
try {
|
||||
ledgerDb.exec('BEGIN IMMEDIATE'); ledgerDb.exec('DELETE FROM canonical_events');
|
||||
const insert = ledgerDb.prepare('INSERT INTO canonical_events(sequence,type) VALUES(?,?)');
|
||||
fEvents.forEach(event => insert.run(event.sequence, event.type)); ledgerDb.exec('COMMIT');
|
||||
} finally { ledgerDb.close(); }
|
||||
};
|
||||
const adapter = indexed ? { ...chain,
|
||||
snapshot: (): TestnetSnapshot => { throw new Error('Indexed market must not request a full chain snapshot'); },
|
||||
summary: () => ({ tip: chain.state.tip, revision: fEvents.length, height: chain.state.blocks.length }),
|
||||
pendingTransactions: () => structuredClone(chain.state.pendingTransfers),
|
||||
nft: (token: string) => {
|
||||
const b = chain.state.blocks.find(b => b.tokenId === token);
|
||||
return b ? { block: b, owner: testnetOwnerOf(chain.state, token)!,
|
||||
sequence: chain.state.transfers.filter(tx => transactionCardIds(tx).includes(token)).length,
|
||||
confirmations: chain.state.blocks.length + 1 - b.height } : undefined;
|
||||
},
|
||||
canonicalTransaction: (id: string) => {
|
||||
const b = chain.state.blocks.find(b => b.transactions.some(tx => transferId(tx as NftTransfer) === id));
|
||||
return b ? { height: b.height, blockHash: b.hash ?? 'f'.repeat(64) } : undefined;
|
||||
},
|
||||
canonicalHead: () => fEvents.length,
|
||||
canonicalEvents: (after: number, limit = 64) => {
|
||||
if (after > fEvents.length) throw new Error('Canonical cursor ahead of restored ledger');
|
||||
const page = fEvents.slice(after, after + limit), cursor = page.at(-1)?.sequence ?? after;
|
||||
return { streamId, previousAnchor: after ? fEvents[after - 1].eventHash : GENESIS_HASH,
|
||||
anchor: page.at(-1)?.eventHash ?? (after ? fEvents[after - 1].eventHash : GENESIS_HASH),
|
||||
events: page, cursor, head: fEvents.length, caughtUp: cursor === fEvents.length };
|
||||
},
|
||||
...(workerLedger ? { reconciliationDatabasePath: () => ledgerFile } : {}),
|
||||
} : chain;
|
||||
let time = 1_800_000_000_000;
|
||||
let market = await Market.open(dir, adapter, { feeBps: 250, confirmations: 2 }, () => time, { worker: useWorker });
|
||||
t.after(async () => { await 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 atomicFromCents = (cents: number) => ((BigInt(cents) * 1_000_000_000_000n + 14999n) / 15000n).toString();
|
||||
const toV3 = (action: MarketAction): MarketActionV3 => {
|
||||
if (action.type === 'list') return { type: 'list', cardHash: action.cardHash, sequence: action.sequence,
|
||||
kind: action.kind, priceAtomicXmr: atomicFromCents(action.priceCents), endsAt: action.endsAt, feeBps: action.feeBps };
|
||||
if (action.type === 'bid') return { type: 'bid', listingId: action.listingId, priceAtomicXmr: atomicFromCents(action.priceCents) };
|
||||
if (action.type === 'buy') return { type: 'buy', listingId: action.listingId,
|
||||
priceAtomicXmr: atomicFromCents(action.priceCents), feeBps: action.feeBps };
|
||||
return action;
|
||||
};
|
||||
const envelopeV3 = (who: typeof alice, action: MarketActionV3) => {
|
||||
const terms = { version: 3 as const, 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(marketMessageV3(terms)) };
|
||||
};
|
||||
const run = <T = MarketListingV3 | MarketOrderV3>(who: typeof alice, action: MarketAction) =>
|
||||
market.execute(envelopeV3(who, toV3(action))) as Promise<T>;
|
||||
const list = (kind: 'fixed' | 'auction' = 'fixed') => run<MarketListingV3>(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: MarketListingV3, who = bob, priceCents = 1500) => run<MarketOrderV3>(who,
|
||||
{ type: 'buy', listingId: l.id, priceCents, feeBps: l.feeBps });
|
||||
const orders = async (who = bob) => (await run<MarketActivityV3>(who, { type: 'activity' })).orders;
|
||||
const delivery = (o: Pick<MarketOrderV3, 'cardHash' | 'sequence'>, 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 = [];
|
||||
};
|
||||
const events = fEvents;
|
||||
return { dir, chain, events, syncWorkerEvents, ledgerFile, envelope, envelopeV3, run, list, buy, orders, delivery, confirm,
|
||||
get market() { return market; }, get now() { return time; }, advance: (ms: number) => { time += ms; },
|
||||
restart: async (policy: MarketPolicy = {}) => { await market.close(); market = await Market.open(dir, adapter,
|
||||
{ feeBps: 500, confirmations: 5, ...policy }, () => time, { worker: useWorker }); } };
|
||||
}
|
||||
|
||||
async function drainWorkerReconciliation(f: Awaited<ReturnType<typeof fixture>>, maxPasses = 2000): Promise<void> {
|
||||
for (let pass = 0; pass < maxPasses; pass++) {
|
||||
f.market.browse();
|
||||
if (!f.market.hasReconciliationBacklog() && !f.market.isReconciliationWorkerBusy()) return;
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
}
|
||||
assert.fail(`Worker reconciliation did not drain within the bounded test wait: ${JSON.stringify(f.market.diagnostics())}`);
|
||||
}
|
||||
test('mock market switches block new listings/bids/checkout while allowing paid-order delivery and private activity', 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 f.restart({ marketEnabled: false });
|
||||
assert.equal(f.market.info().marketEnabled, false); assert.deepEqual(f.market.browse().listings, []);
|
||||
await assert.rejects(f.list(), /disabled/);
|
||||
assert.equal((await f.orders())[0].id, o.id);
|
||||
const tx = f.delivery(o); await f.run(alice, { type: 'deliver', orderId: o.id, transfer: tx });
|
||||
assert.equal(f.chain.state.pendingTransfers.length, 1);
|
||||
});
|
||||
|
||||
test('indexed market uses bounded durable event catch-up and never requests full snapshots', async t => {
|
||||
const f = await fixture(t, true);
|
||||
for (let i = 1; i <= 130; i++) f.events.push({ sequence: i, type: 'connect', height: i,
|
||||
hash: 'd'.repeat(64), previousHash: 'e'.repeat(64), transactionIds: [], eventHash: i.toString(16).padStart(64, '0') });
|
||||
await assert.rejects(f.list(), /catching up/);
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 64);
|
||||
f.market.browse(); assert.equal(f.market.diagnostics().canonicalEventCursor, 128);
|
||||
const l = await f.list(); assert.ok(l.id);
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 130);
|
||||
assert.equal(f.market.diagnostics().eventBacklog, false);
|
||||
await f.restart(); assert.equal(f.market.diagnostics().canonicalEventCursor, 130);
|
||||
f.market.browse(); assert.equal(f.market.diagnostics().canonicalEventCursor, 130);
|
||||
const anchor = f.events[129].eventHash;
|
||||
f.events[129].eventHash = 'f'.repeat(64); f.advance(16_000);
|
||||
assert.throws(() => f.market.browse(), /history changed/);
|
||||
f.events[129].eventHash = anchor;
|
||||
f.events.length = 0;
|
||||
assert.throws(() => f.market.browse(), /cursor ahead/);
|
||||
});
|
||||
|
||||
test('market reconciliation keyset progress is bounded, durable and resumes after restart', async t => {
|
||||
const f = await fixture(t, true), db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
const ids = Array.from({ length: 260 }, (_, i) => `00000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`);
|
||||
try {
|
||||
const insertListing = db.prepare('INSERT INTO listings VALUES(?,?,?,?)');
|
||||
const insertOrder = db.prepare('INSERT INTO orders VALUES(?,?)');
|
||||
for (const [i, id] of ids.entries()) {
|
||||
const listingId = `10000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`;
|
||||
const cardHash = (i + 1).toString(16).padStart(64, '0');
|
||||
const target = i === ids.length - 1;
|
||||
insertListing.run(listingId, cardHash, target ? 'sold' : 'cancelled', JSON.stringify({ id: listingId, seller: alice.actor,
|
||||
cardHash, sequence: 0, status: target ? 'sold' : 'cancelled', kind: 'fixed' }));
|
||||
const order = target ? { id, termsVersion: 3, listingId, seller: alice.actor, buyer: bob.actor, cardHash, sequence: 0,
|
||||
priceAtomicXmr: '100000000000', feeBps: 250, createdAt: f.now, payBy: f.now + 1000, deliverBy: f.now + 2000,
|
||||
status: 'complete', payout: 'mock-paid', deliveryConfirmations: 2, requiredConfirmations: 2, transferId: '0'.repeat(64) }
|
||||
: { id, listingId, seller: alice.actor, buyer: bob.actor, cardHash, sequence: 0, status: 'refunded', createdAt: f.now };
|
||||
insertOrder.run(id, JSON.stringify(order));
|
||||
}
|
||||
} finally { db.close(); }
|
||||
const disconnectedTx = transferId({ network: NETWORK, chainId: CHAIN_ID,
|
||||
cardHash: (ids.length).toString(16).padStart(64, '0'), from: alice.actor, to: bob.actor, sequence: 0, publicKey: '', signature: '' });
|
||||
f.events.push({ sequence: 1, type: 'disconnect', height: 1, hash: 'd'.repeat(64), previousHash: GENESIS_HASH,
|
||||
transactionIds: [disconnectedTx], eventHash: 'e'.repeat(64) });
|
||||
|
||||
// First browse starts reconciliation and visits only the first fixed-size keyset page.
|
||||
f.market.browse();
|
||||
const progress = () => {
|
||||
const conn = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
try { return JSON.parse((conn.prepare('SELECT value FROM market_meta WHERE key=?').get('canonical_reconciliation_job') as { value: string }).value); }
|
||||
finally { conn.close(); }
|
||||
};
|
||||
assert.equal(progress().stage, 'orders'); assert.equal(progress().afterId, ids[31]);
|
||||
assert.equal(f.market.hasReconciliationBacklog(), true);
|
||||
await assert.rejects(f.list(), /catching up/); // Second 32-order batch; no new listing is admitted.
|
||||
assert.equal(progress().stage, 'orders'); assert.equal(progress().afterId, ids[63]);
|
||||
// The ledger advances while this durable multi-batch job is in flight.
|
||||
f.events.push({ sequence: 2, type: 'connect', height: 2, hash: 'f'.repeat(64), previousHash: 'd'.repeat(64),
|
||||
transactionIds: [], eventHash: '2'.repeat(64) });
|
||||
f.market.browse(); // Tip/revision changed: rewind and bind the updated event page.
|
||||
assert.equal(progress().eventPage.cursor, 2);
|
||||
assert.equal(progress().afterId, ids[31]);
|
||||
const tailStatus = () => {
|
||||
const conn = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
try { return JSON.parse((conn.prepare('SELECT data FROM orders WHERE id=?').get(ids[259]) as { data: string }).data).status; }
|
||||
finally { conn.close(); }
|
||||
};
|
||||
for (let i = 0; i < 12 && tailStatus() !== 'review'; i++) f.market.browse();
|
||||
assert.equal(tailStatus(), 'review'); // Disconnect reached the tail order before checkpoint advancement.
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 0);
|
||||
assert.equal(f.market.hasReconciliationBacklog(), true, 'new events appended during job must stay behind the work fence');
|
||||
const reviewDb = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
try {
|
||||
const row = reviewDb.prepare('SELECT data FROM orders WHERE id=?').get(ids[259]) as { data: string };
|
||||
assert.equal(JSON.parse(row.data).status, 'review'); // Disconnect reached the tail batch before the cursor advances.
|
||||
} finally { reviewDb.close(); }
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 0);
|
||||
|
||||
// Simulate process death after committed batch progress, then resume using the SQLite job cursor.
|
||||
await f.restart(); assert.equal(f.market.diagnostics().eventBacklog, true);
|
||||
for (let i = 0; i < 24 && f.market.hasReconciliationBacklog(); i++) f.market.browse();
|
||||
assert.equal(f.market.hasReconciliationBacklog(), false);
|
||||
const conn = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
try { assert.equal(conn.prepare('SELECT 1 FROM market_meta WHERE key=?').get('canonical_reconciliation_job'), undefined); }
|
||||
finally { conn.close(); }
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 2);
|
||||
});
|
||||
|
||||
test('bounded disconnect review batch commits before checkpoint advance and resumes after restart', async t => {
|
||||
const f = await fixture(t, true), listing = await f.list(), order = await f.buy(listing);
|
||||
await f.run(bob, { type: 'mock-pay', orderId: order.id });
|
||||
const delivery = f.delivery(order); f.confirm(delivery);
|
||||
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: f.chain.state.blocks.length + 1 });
|
||||
f.events.push({ sequence: 1, type: 'connect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64), transactionIds: [transferId(delivery)], eventHash: '1'.repeat(64) });
|
||||
const initiallyCompleted = (await f.orders())[0];
|
||||
assert.equal(initiallyCompleted.status, 'complete', JSON.stringify({ order: initiallyCompleted,
|
||||
job: new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')).prepare('SELECT value FROM market_meta WHERE key=?').get('canonical_reconciliation_job') }));
|
||||
// The same delivery may already be canonical again; the intermediate disconnect must not be lost.
|
||||
f.events.push({ sequence: 2, type: 'disconnect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64), transactionIds: [transferId(delivery)], eventHash: '2'.repeat(64) });
|
||||
const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
try {
|
||||
db.exec("CREATE TRIGGER fail_cursor BEFORE UPDATE ON market_meta WHEN NEW.key='canonical_event_cursor' BEGIN SELECT RAISE(ABORT,'injected cursor failure'); END");
|
||||
assert.throws(() => f.market.browse(), /injected cursor failure/);
|
||||
const persisted = db.prepare('SELECT data FROM orders WHERE id=?').get(order.id) as { data: string };
|
||||
assert.equal(JSON.parse(persisted.data).status, 'review');
|
||||
const job = JSON.parse((db.prepare("SELECT value FROM market_meta WHERE key='canonical_reconciliation_job'").get() as { value: string }).value);
|
||||
assert.equal(job.stage, 'listings'); // Order effects and work cursor committed atomically; event anchor still has not advanced.
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 1);
|
||||
db.exec('DROP TRIGGER fail_cursor');
|
||||
} finally { db.close(); }
|
||||
await f.restart();
|
||||
const reviewed = (await f.orders())[0];
|
||||
assert.equal(reviewed.status, 'review'); assert.equal(reviewed.payout, 'review');
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 2);
|
||||
await f.restart(); assert.equal((await f.orders())[0].status, 'review');
|
||||
});
|
||||
|
||||
test('market reconciliation batch failure rolls back order effects and resumes from durable keyset', async t => {
|
||||
const f = await fixture(t, true), listing = await f.list(), order = await f.buy(listing);
|
||||
await f.run(bob, { type: 'mock-pay', orderId: order.id });
|
||||
const delivery = f.delivery(order); f.confirm(delivery);
|
||||
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] });
|
||||
f.events.push({ sequence: 1, type: 'connect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64),
|
||||
transactionIds: [transferId(delivery)], eventHash: '1'.repeat(64) });
|
||||
assert.equal((await f.orders())[0].status, 'complete');
|
||||
f.events.push({ sequence: 2, type: 'disconnect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64),
|
||||
transactionIds: [transferId(delivery)], eventHash: '2'.repeat(64) });
|
||||
const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
try {
|
||||
db.exec("CREATE TRIGGER fail_review_batch BEFORE UPDATE ON orders WHEN json_extract(NEW.data,'$.status')='review' BEGIN SELECT RAISE(ABORT,'injected reconciliation batch failure'); END");
|
||||
assert.throws(() => f.market.browse(), /injected reconciliation batch failure/);
|
||||
const row = db.prepare('SELECT data FROM orders WHERE id=?').get(order.id) as { data: string };
|
||||
assert.equal(JSON.parse(row.data).status, 'complete');
|
||||
const job = JSON.parse((db.prepare("SELECT value FROM market_meta WHERE key='canonical_reconciliation_job'").get() as { value: string }).value);
|
||||
assert.equal(job.stage, 'orders'); assert.equal(job.afterId, null);
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 1);
|
||||
db.exec('DROP TRIGGER fail_review_batch');
|
||||
} finally { db.close(); }
|
||||
await f.restart();
|
||||
assert.equal((await f.orders())[0].status, 'review');
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 2);
|
||||
assert.equal(f.market.hasReconciliationBacklog(), false);
|
||||
});
|
||||
|
||||
test('worker-backed reconciliation commits reorg holds atomically and resumes after restart', async t => {
|
||||
const f = await fixture(t, true, true);
|
||||
await drainWorkerReconciliation(f);
|
||||
const listing = await f.list(), order = await f.buy(listing);
|
||||
await f.run(bob, { type: 'mock-pay', orderId: order.id });
|
||||
const delivery = f.delivery(order); f.confirm(delivery);
|
||||
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] });
|
||||
f.events.push({ sequence: 1, type: 'connect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64),
|
||||
transactionIds: [transferId(delivery)], eventHash: '1'.repeat(64) });
|
||||
await f.orders(); await drainWorkerReconciliation(f);
|
||||
assert.equal((await f.orders())[0].status, 'complete');
|
||||
|
||||
f.events.push({ sequence: 2, type: 'disconnect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64),
|
||||
transactionIds: [transferId(delivery)], eventHash: '2'.repeat(64) });
|
||||
const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
try {
|
||||
db.exec("CREATE TRIGGER fail_worker_review BEFORE UPDATE ON orders WHEN json_extract(NEW.data,'$.status')='review' BEGIN SELECT RAISE(ABORT,'injected worker batch failure'); END");
|
||||
} finally { db.close(); }
|
||||
f.market.browse();
|
||||
assert.equal(f.market.isReconciliationWorkerBusy(), true);
|
||||
assert.equal(f.market.diagnostics().eventBacklog, true);
|
||||
for (let i = 0; i < 100 && (f.market.isReconciliationWorkerBusy() || !f.market.diagnostics().reconciliationWorkerFailed); i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
}
|
||||
assert.equal(f.market.diagnostics().reconciliationWorkerFailed, true);
|
||||
assert.equal(f.market.isReconciliationWorkerBusy(), false);
|
||||
await assert.rejects(f.list(), /catching up/); // Do not admit new business while the worker commit is uncertain.
|
||||
assert.equal(f.market.isReconciliationWorkerBusy(), false); // Failure stays paused instead of hot-loop retrying.
|
||||
const check = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
try {
|
||||
assert.equal(JSON.parse((check.prepare('SELECT data FROM orders WHERE id=?').get(order.id) as { data: string }).data).status, 'complete');
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 1);
|
||||
check.exec('DROP TRIGGER fail_worker_review');
|
||||
} finally { check.close(); }
|
||||
|
||||
await f.restart();
|
||||
await drainWorkerReconciliation(f);
|
||||
const reviewed = (await f.orders())[0];
|
||||
assert.equal(reviewed.status, 'review'); assert.equal(reviewed.payout, 'review');
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 2);
|
||||
assert.equal(f.market.hasReconciliationBacklog(), false);
|
||||
});
|
||||
|
||||
test('worker reconciliation re-fences a canonical event appended during an in-flight batch', async t => {
|
||||
const f = await fixture(t, true, true), db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
const ids = Array.from({ length: 70 }, (_, i) => `20000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`);
|
||||
try {
|
||||
const insertListing = db.prepare('INSERT INTO listings VALUES(?,?,?,?)');
|
||||
const insertOrder = db.prepare('INSERT INTO orders VALUES(?,?)');
|
||||
for (const [i, id] of ids.entries()) {
|
||||
const listingId = `30000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`;
|
||||
const cardHash = (i + 1).toString(16).padStart(64, '0');
|
||||
insertListing.run(listingId, cardHash, 'cancelled', JSON.stringify({ id: listingId, cardHash, seller: alice.actor,
|
||||
sequence: 0, kind: 'fixed', status: 'cancelled' }));
|
||||
insertOrder.run(id, JSON.stringify({ id, listingId, seller: alice.actor, buyer: bob.actor, cardHash,
|
||||
sequence: 0, status: 'refunded' }));
|
||||
}
|
||||
} finally { db.close(); }
|
||||
f.events.push({ sequence: 1, type: 'connect', height: 1, hash: 'd'.repeat(64), previousHash: GENESIS_HASH,
|
||||
transactionIds: [], eventHash: '1'.repeat(64) });
|
||||
f.market.browse();
|
||||
assert.equal(f.market.isReconciliationWorkerBusy(), true);
|
||||
f.events.push({ sequence: 2, type: 'connect', height: 2, hash: 'e'.repeat(64), previousHash: 'd'.repeat(64),
|
||||
transactionIds: [], eventHash: '2'.repeat(64) });
|
||||
await drainWorkerReconciliation(f, 100);
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 2);
|
||||
assert.equal(f.market.hasReconciliationBacklog(), false);
|
||||
});
|
||||
|
||||
test('worker reads canonical facts from a separate read-only Ledger snapshot and re-fences event appends', async t => {
|
||||
const f = await fixture(t, true, true, true), db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
const soldCard = 'a'.repeat(64), deliveryId = transferId({ network: NETWORK, chainId: CHAIN_ID, cardHash: soldCard,
|
||||
from: alice.actor, to: bob.actor, sequence: 0, publicKey: '', signature: '' });
|
||||
try {
|
||||
const insertListing = db.prepare('INSERT INTO listings VALUES(?,?,?,?)');
|
||||
const insertOrder = db.prepare('INSERT INTO orders VALUES(?,?)');
|
||||
for (let i = 0; i < 70; i++) {
|
||||
const id = `40000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`;
|
||||
const listingId = `50000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`;
|
||||
const active = i === 0, cardHash = active ? soldCard : (i + 1).toString(16).padStart(64, '0');
|
||||
const listingStatus = active ? 'reserved' : 'cancelled';
|
||||
insertListing.run(listingId, cardHash, listingStatus, JSON.stringify({ id: listingId, cardHash, seller: alice.actor,
|
||||
sequence: 0, kind: 'fixed', status: listingStatus }));
|
||||
insertOrder.run(id, JSON.stringify({ id, listingId, seller: alice.actor, buyer: bob.actor, cardHash,
|
||||
sequence: 0, status: active ? 'paid' : 'refunded', payout: active ? 'not-started' : undefined,
|
||||
createdAt: f.now, payBy: f.now + 1000, deliverBy: f.now + 2000,
|
||||
requiredConfirmations: 2, deliveryConfirmations: 0 }));
|
||||
}
|
||||
} finally { db.close(); }
|
||||
const ledgerDb = new DatabaseSync(f.ledgerFile);
|
||||
try {
|
||||
ledgerDb.prepare('UPDATE canonical_nfts SET owner=?,sequence=1 WHERE token=?').run(bob.actor, soldCard);
|
||||
ledgerDb.prepare('INSERT INTO canonical_transactions VALUES(?,?,?)').run(deliveryId, 1, 'b'.repeat(64));
|
||||
} finally { ledgerDb.close(); }
|
||||
f.events.push({ sequence: 1, type: 'connect', height: 1, hash: 'd'.repeat(64), previousHash: GENESIS_HASH,
|
||||
transactionIds: [deliveryId], eventHash: '1'.repeat(64) });
|
||||
f.syncWorkerEvents();
|
||||
f.market.browse();
|
||||
assert.equal(f.market.isReconciliationWorkerBusy(), true);
|
||||
f.events.push({ sequence: 2, type: 'connect', height: 2, hash: 'e'.repeat(64), previousHash: 'd'.repeat(64),
|
||||
transactionIds: [], eventHash: '2'.repeat(64) });
|
||||
f.syncWorkerEvents();
|
||||
await drainWorkerReconciliation(f, 100);
|
||||
assert.equal(f.market.diagnostics().canonicalEventCursor, 2);
|
||||
assert.equal(f.market.hasReconciliationBacklog(), false);
|
||||
const orders = await f.orders();
|
||||
const completed = orders.find(order => order.cardHash === soldCard);
|
||||
assert.ok(completed);
|
||||
assert.equal(completed.status, 'complete'); assert.equal(completed.payout, 'mock-paid');
|
||||
const verifyDb = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
try {
|
||||
assert.equal((verifyDb.prepare('SELECT status FROM listings WHERE id=?').get(completed.listingId) as { status: string }).status, 'sold');
|
||||
} finally { verifyDb.close(); }
|
||||
});
|
||||
|
||||
test('worker reconciliation yields to event-loop timers during a large durable backlog', async t => {
|
||||
const f = await fixture(t, true, true, true), db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
try {
|
||||
const insertListing = db.prepare('INSERT INTO listings VALUES(?,?,?,?)');
|
||||
const insertOrder = db.prepare('INSERT INTO orders VALUES(?,?)');
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
const id = `60000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`;
|
||||
const listingId = `70000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`;
|
||||
const cardHash = (i + 1).toString(16).padStart(64, '0');
|
||||
const listing = { id: listingId, cardHash, seller: alice.actor, sequence: 0, kind: 'fixed', status: 'cancelled' };
|
||||
insertListing.run(listingId, cardHash, 'cancelled', JSON.stringify(listing));
|
||||
insertOrder.run(id, JSON.stringify({ id, listingId, seller: alice.actor, buyer: bob.actor,
|
||||
cardHash, sequence: 0, status: 'refunded' }));
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
||||
} finally { db.close(); }
|
||||
f.market.browse();
|
||||
assert.equal(f.market.isReconciliationWorkerBusy(), true);
|
||||
let timerRan = false;
|
||||
await new Promise<void>(resolve => setTimeout(() => { timerRan = true; resolve(); }, 0));
|
||||
assert.equal(timerRan, true);
|
||||
assert.equal(f.market.isReconciliationWorkerBusy(), true,
|
||||
'large reconciliation should still be in flight while the main-loop timer has run');
|
||||
});
|
||||
|
||||
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','processor','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('versioned market commands bind actor, instance, network, expiry, currency, and every price term', async t => {
|
||||
const f = await fixture(t);
|
||||
const original = f.envelopeV3(alice, { type: 'list', cardHash: f.chain.state.blocks[0].tokenId, sequence: 0,
|
||||
kind: 'fixed', priceAtomicXmr: '100000000000', 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, priceAtomicXmr: '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(marketMessageV3(original));
|
||||
assert.equal(message[1], 'market-command-v3');
|
||||
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, priceAtomicXmr: '200000000000' } };
|
||||
await assert.rejects(async () => f.market.execute({ ...changed, signature: alice.signed(marketMessageV3(changed)) }), /Nonce/);
|
||||
const legacy = f.envelope(alice, { type: 'list', cardHash: f.chain.state.blocks[1].tokenId, sequence: 0,
|
||||
kind: 'fixed', priceCents: 1500, endsAt: null, feeBps: 250 });
|
||||
assert.equal(JSON.parse(marketMessage(legacy))[1], 'market-command-v2');
|
||||
await assert.rejects(f.market.execute(legacy), /USD listing terms are retired/);
|
||||
assert.equal(f.market.browse().listings.length, 1);
|
||||
});
|
||||
|
||||
test('legacy USD listings are read-only until seller retires them with v3 authorization', async t => {
|
||||
const f = await fixture(t), cardHash = f.chain.state.blocks[0].tokenId, id = randomUUID();
|
||||
const legacy = { id, cardHash, sequence: 0, seller: alice.actor, title: 'Historical USD listing',
|
||||
image: 'https://upload.wikimedia.org/test.jpg', kind: 'fixed' as const, priceCents: 1500,
|
||||
feeBps: 250, createdAt: f.now, endsAt: null, status: 'open' as const, highestBid: null };
|
||||
const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
try { db.prepare('INSERT INTO listings VALUES(?,?,?,?)').run(id, cardHash, 'open', JSON.stringify(legacy)); }
|
||||
finally { db.close(); }
|
||||
const activity = await f.run<MarketActivityV3>(alice, { type: 'activity' });
|
||||
assert.equal(activity.listings[0].id, id);
|
||||
assert.equal((activity.listings[0] as typeof legacy).priceCents, 1500);
|
||||
assert.equal(activity.listings[0].status, 'invalid');
|
||||
await assert.rejects(f.run(bob, { type: 'buy', listingId: id, priceCents: 1500, feeBps: 250 }), /Legacy USD listings are read-only/);
|
||||
await assert.rejects(f.run(alice, { type: 'bid', listingId: id, priceCents: 1500 }), /Legacy USD listings are read-only/);
|
||||
const retired = await f.market.execute(f.envelopeV3(alice, { type: 'retire-legacy-listing', listingId: id })) as MarketListingV3;
|
||||
assert.equal(retired.status, 'cancelled');
|
||||
assert.equal((retired as unknown as typeof legacy).priceCents, 1500);
|
||||
});
|
||||
|
||||
test('legacy reserved USD orders retain signed terms across current-XMR upgrade and restart', async t => {
|
||||
const f = await fixture(t), listingId = randomUUID(), orderId = randomUUID(), cardHash = f.chain.state.blocks[0].tokenId;
|
||||
const listing = { id: listingId, cardHash, sequence: 0, seller: alice.actor, title: 'Legacy in-flight order',
|
||||
image: 'https://upload.wikimedia.org/test.jpg', kind: 'fixed' as const, priceCents: 1500, feeBps: 250,
|
||||
createdAt: f.now, endsAt: null, status: 'reserved' as const, highestBid: null,
|
||||
additionalFee: { bps: 100, address: ADDITIONAL_MARKET_FEE.address } };
|
||||
const quote = mockQuote(1500, 250, 15000);
|
||||
const order = { id: orderId, listingId, seller: alice.actor, buyer: bob.actor, cardHash, sequence: 0,
|
||||
priceCents: 1500, feeBps: 250, createdAt: f.now, payBy: f.now + 30 * 60_000,
|
||||
deliverBy: f.now + 30 * 60_000 + 48 * 60 * 60_000, status: 'awaiting-payment' as const,
|
||||
...quote, payout: 'not-started' as const, deliveryConfirmations: 0, requiredConfirmations: 2 };
|
||||
const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
|
||||
try {
|
||||
db.prepare('INSERT INTO listings VALUES(?,?,?,?)').run(listingId, cardHash, 'reserved', JSON.stringify(listing));
|
||||
db.prepare('INSERT INTO orders VALUES(?,?)').run(orderId, JSON.stringify(order));
|
||||
} finally { db.close(); }
|
||||
await assert.rejects(f.run(bob, { type: 'mock-pay', orderId }), /Legacy USD orders keep their original terms/);
|
||||
const historical = await f.market.execute(f.envelope(bob, { type: 'mock-pay', orderId })) as typeof order;
|
||||
assert.equal(historical.status, 'paid');
|
||||
assert.equal(historical.priceCents, 1500);
|
||||
assert.equal(historical.invoice.amountXmr, '0.100000000000');
|
||||
await f.restart();
|
||||
const restored = (await f.run<MarketActivityV3>(bob, { type: 'activity' })).orders[0] as typeof order;
|
||||
assert.equal(restored.priceCents, 1500);
|
||||
assert.equal(restored.invoice.amountXmr, '0.100000000000');
|
||||
assert.equal(restored.status, 'paid');
|
||||
});
|
||||
|
||||
test('native-XMR listing and checkout accept one atomic unit without conversion or fee minimums', async t => {
|
||||
const f = await fixture(t);
|
||||
const listing = await f.market.execute(f.envelopeV3(alice, { type: 'list', cardHash: f.chain.state.blocks[0].tokenId,
|
||||
sequence: 0, kind: 'fixed', priceAtomicXmr: '1', endsAt: null, feeBps: 250 })) as MarketListingV3;
|
||||
assert.equal(listing.priceAtomicXmr, '1');
|
||||
const order = await f.market.execute(f.envelopeV3(bob, { type: 'buy', listingId: listing.id,
|
||||
priceAtomicXmr: '1', feeBps: 250 })) as MarketOrderV3;
|
||||
assert.equal(order.invoice.amountXmr, '0.000000000001');
|
||||
assert.equal(order.feeXmr, '0.000000000000');
|
||||
assert.equal(order.additionalFee?.amountXmr, '0.000000000000');
|
||||
assert.equal(order.sellerNetXmr, '0.000000000001');
|
||||
const terms = { type: 'list', cardHash: f.chain.state.blocks[1].tokenId, sequence: 0,
|
||||
kind: 'fixed', priceAtomicXmr: '0', endsAt: null, feeBps: 250 };
|
||||
assert.equal(marketActionV3Schema.safeParse(terms).success, false);
|
||||
for (const invalid of ['01', '+1', '1.0', '1e0', '9'.repeat(23)]) {
|
||||
assert.equal(marketActionV3Schema.safeParse({ ...terms, priceAtomicXmr: invalid }).success, false);
|
||||
}
|
||||
});
|
||||
|
||||
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 }));
|
||||
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.envelopeV3(bob, { type: 'buy', listingId: listing.id, priceAtomicXmr: '100000000000', 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<MarketOrderV3>).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.priceAtomicXmr, '106666666667'); 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('direct canonical delivery outside the marketplace cannot also produce a refund', 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);
|
||||
f.confirm(tx); // No marketplace deliver command or saved transferId.
|
||||
const settling = (await f.orders())[0];
|
||||
assert.equal(settling.status, 'delivery-pending');
|
||||
assert.equal(settling.transferId, transferId(tx));
|
||||
await assert.rejects(f.run(bob, { type: 'refund', orderId: o.id }), /Refund requires/);
|
||||
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] });
|
||||
assert.equal((await f.orders())[0].status, 'complete');
|
||||
});
|
||||
|
||||
test('direct pending delivery is not a conflict, while unpaid delivery never triggers 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 });
|
||||
f.chain.state.pendingTransfers.push(f.delivery(o));
|
||||
f.advance(48 * 3600_000);
|
||||
assert.equal((await f.orders())[0].status, 'paid');
|
||||
await assert.rejects(f.run(bob, { type: 'refund', orderId: o.id }), /Refund requires/);
|
||||
});
|
||||
|
||||
test('canonical delivery without mock payment is held for review, not paid out', async t => {
|
||||
const f = await fixture(t), l = await f.list(), o = await f.buy(l);
|
||||
f.confirm(f.delivery(o));
|
||||
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('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');
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadMoneroDaemonProbeConfig, probeMoneroDaemon, type MoneroDaemonProbeTransport } from '../server/market/monero-daemon-probe.ts';
|
||||
|
||||
const config = { network: 'mainnet' as const, url: 'http://10.1.2.3:18081/json_rpc', allowPrivateHttp: true };
|
||||
function info(overrides: Record<string, unknown> = {}) {
|
||||
return { status: 'OK', nettype: 'mainnet', mainnet: true, stagenet: false, testnet: false,
|
||||
synchronized: true, busy_syncing: false, offline: false, untrusted: false,
|
||||
height: 3_000_000, height_without_bootstrap: 3_000_000, target_height: 3_000_000,
|
||||
version: '0.18.4.2-release', bootstrap_daemon_address: '', ...overrides };
|
||||
}
|
||||
function transportFor(result: Record<string, unknown>, options: { status?: number; type?: string; redirected?: boolean } = {}) {
|
||||
let request: { method?: string; redirect?: RequestRedirect; headers?: HeadersInit; body?: string } | undefined;
|
||||
const transport: MoneroDaemonProbeTransport = async (url, init) => {
|
||||
assert.equal(url, config.url);
|
||||
request = { method: init.method, redirect: init.redirect, headers: init.headers, body: init.body as string };
|
||||
const body = JSON.parse(init.body as string) as { id: string };
|
||||
return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, result }), {
|
||||
status: options.status ?? 200, headers: { 'content-type': options.type ?? 'application/json' },
|
||||
...((options.redirected === undefined) ? {} : { statusText: options.redirected ? 'Moved' : 'OK' }),
|
||||
});
|
||||
};
|
||||
return { transport, getRequest: () => request };
|
||||
}
|
||||
|
||||
test('daemon probe calls only read-only get_info and accepts a synchronized local Mainnet chain', async () => {
|
||||
const fixture = transportFor(info());
|
||||
const result = await probeMoneroDaemon(config, fixture.transport, Date.UTC(2026, 9, 22));
|
||||
assert.deepEqual(result, { network: 'mainnet', height: 3_000_000, version: '0.18.4.2-release',
|
||||
synchronized: true, bootstrapProxyUsed: false, checkedAt: '2026-10-22T00:00:00.000Z' });
|
||||
const request = fixture.getRequest()!;
|
||||
assert.equal(request.method, 'POST');
|
||||
assert.equal(request.redirect, 'error');
|
||||
assert.equal(JSON.parse(request.body!).method, 'get_info');
|
||||
assert.equal(new Headers(request.headers).has('authorization'), false);
|
||||
});
|
||||
|
||||
test('daemon probe rejects network mismatch, syncing, offline, bootstrap and untrusted replies', async () => {
|
||||
for (const override of [
|
||||
{ nettype: 'stagenet', mainnet: false, stagenet: true },
|
||||
{ synchronized: false }, { busy_syncing: true }, { offline: true }, { untrusted: true },
|
||||
{ height_without_bootstrap: 2_999_999 }, { bootstrap_daemon_address: 'node.example:18081' },
|
||||
{ testnet: true },
|
||||
]) {
|
||||
const fixture = transportFor(info(override));
|
||||
await assert.rejects(probeMoneroDaemon(config, fixture.transport), /network or synchronization/);
|
||||
}
|
||||
});
|
||||
|
||||
test('daemon probe rejects malformed, oversized, redirected and non-JSON responses', async () => {
|
||||
const badJson: MoneroDaemonProbeTransport = async (_url, init) => {
|
||||
const body = JSON.parse(init.body as string) as { id: string };
|
||||
return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: { ...info(), height: 'bad' } }),
|
||||
{ headers: { 'content-type': 'application/json' } });
|
||||
};
|
||||
await assert.rejects(probeMoneroDaemon(config, badJson), /Invalid Monero daemon RPC response/);
|
||||
const tooLarge: MoneroDaemonProbeTransport = async () => new Response('x'.repeat(70 * 1024),
|
||||
{ headers: { 'content-type': 'application/json' } });
|
||||
await assert.rejects(probeMoneroDaemon(config, tooLarge), /Invalid Monero daemon RPC response/);
|
||||
const redirected = transportFor(info(), { status: 302 });
|
||||
await assert.rejects(probeMoneroDaemon(config, redirected.transport), /response rejected/);
|
||||
const wrongType = transportFor(info(), { type: 'text/html' });
|
||||
await assert.rejects(probeMoneroDaemon(config, wrongType.transport), /response rejected/);
|
||||
const mismatchedId: MoneroDaemonProbeTransport = async () => new Response(JSON.stringify({
|
||||
jsonrpc: '2.0', id: '00000000-0000-4000-8000-000000000000', result: info(),
|
||||
}), { headers: { 'content-type': 'application/json' } });
|
||||
await assert.rejects(probeMoneroDaemon(config, mismatchedId), /response ID mismatch/);
|
||||
});
|
||||
|
||||
test('daemon probe requires explicit private literal endpoint and HTTP boundary acknowledgement', async () => {
|
||||
await assert.rejects(probeMoneroDaemon({ ...config, allowPrivateHttp: false }, async () => new Response()), /explicit private-network acknowledgement/);
|
||||
await assert.rejects(probeMoneroDaemon({ ...config, url: 'http://daemon.example:18081/json_rpc' }, async () => new Response()), /private literal IP/);
|
||||
await assert.rejects(probeMoneroDaemon({ ...config, url: 'http://8.8.8.8:18081/json_rpc' }, async () => new Response()), /private literal IP/);
|
||||
await assert.rejects(probeMoneroDaemon({ ...config, url: 'https://user:password@10.1.2.3:18081/json_rpc' }, async () => new Response()), /bare \/json_rpc/);
|
||||
await assert.rejects(probeMoneroDaemon({ ...config, url: 'http://10.1.2.3:18081/' }, async () => new Response()), /bare \/json_rpc/);
|
||||
});
|
||||
|
||||
test('daemon probe config loader requires a protected file and rejects unsafe configuration', async () => {
|
||||
await assert.rejects(loadMoneroDaemonProbeConfig({}), /config file is required/);
|
||||
const directory = await mkdtemp(join(tmpdir(), 'wikideck-monero-daemon-'));
|
||||
const path = join(directory, 'daemon.json');
|
||||
try {
|
||||
await writeFile(path, JSON.stringify(config), { mode: 0o600 });
|
||||
if (process.platform !== 'win32') await chmod(path, 0o600);
|
||||
const loaded = await loadMoneroDaemonProbeConfig({ WIKIDECK_MONERO_DAEMON_CONFIG_FILE: path });
|
||||
assert.deepEqual(loaded, config);
|
||||
await writeFile(path, JSON.stringify({ ...config, url: 'http://daemon.example:18081/json_rpc' }), { mode: 0o600 });
|
||||
await assert.rejects(loadMoneroDaemonProbeConfig({ WIKIDECK_MONERO_DAEMON_CONFIG_FILE: path }), /Invalid or inaccessible/);
|
||||
} finally { await rm(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmod, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadMoneroDepositReviewApproval, loadMoneroDepositReviewPolicy } from '../server/market/monero-deposit-review-config.ts';
|
||||
|
||||
const owner = 'a'.repeat(64);
|
||||
|
||||
async function privateFile(path: string, content: string) {
|
||||
await writeFile(path, content, { mode: 0o600 });
|
||||
await chmod(path, 0o600);
|
||||
}
|
||||
|
||||
test('deposit review policy requires an owner-only protected file and strict bounds', async t => {
|
||||
if (process.platform === 'win32') {
|
||||
await assert.rejects(loadMoneroDepositReviewPolicy({ WIKIDECK_MONERO_DEPOSIT_REVIEW_POLICY_FILE: 'C:\\private\\policy.json' }), /Invalid or inaccessible/);
|
||||
return;
|
||||
}
|
||||
const directory = await mkdtemp(join(tmpdir(), 'wikideck-deposit-review-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const policyPath = join(directory, 'policy.json');
|
||||
const env = { WIKIDECK_MONERO_DEPOSIT_REVIEW_POLICY_FILE: policyPath };
|
||||
const valid = { approvedReviewerOwners: [owner], minimumConfirmations: 10, maximumObservationAgeMs: 120_000 };
|
||||
await assert.rejects(loadMoneroDepositReviewPolicy({}), /required/);
|
||||
await privateFile(policyPath, JSON.stringify(valid));
|
||||
assert.deepEqual(await loadMoneroDepositReviewPolicy(env), valid);
|
||||
|
||||
await privateFile(policyPath, JSON.stringify({ ...valid, unexpected: true }));
|
||||
await assert.rejects(loadMoneroDepositReviewPolicy(env), /Invalid or inaccessible/);
|
||||
await privateFile(policyPath, JSON.stringify({ ...valid, minimumConfirmations: 2 }));
|
||||
await assert.rejects(loadMoneroDepositReviewPolicy(env), /Invalid or inaccessible/);
|
||||
|
||||
await privateFile(policyPath, JSON.stringify(valid)); await chmod(policyPath, 0o644);
|
||||
await assert.rejects(loadMoneroDepositReviewPolicy(env), /Invalid or inaccessible/);
|
||||
await rm(policyPath);
|
||||
await symlink(join(directory, 'missing-target.json'), policyPath);
|
||||
await assert.rejects(loadMoneroDepositReviewPolicy(env), /Invalid or inaccessible/);
|
||||
});
|
||||
|
||||
test('deposit approval file loader requires an absolute, bounded, private regular file', async t => {
|
||||
if (process.platform === 'win32') {
|
||||
await assert.rejects(loadMoneroDepositReviewApproval('C:\\private\\approval.json'), /Invalid or inaccessible/);
|
||||
return;
|
||||
}
|
||||
const directory = await mkdtemp(join(tmpdir(), 'wikideck-deposit-approval-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const approvalPath = join(directory, 'approval.json');
|
||||
const approval = { version: 1, signature: 'signed-review-fixture' };
|
||||
await assert.rejects(loadMoneroDepositReviewApproval('relative-approval.json'), /protected Monero deposit review approval/);
|
||||
await privateFile(approvalPath, JSON.stringify(approval));
|
||||
assert.deepEqual(await loadMoneroDepositReviewApproval(approvalPath), approval);
|
||||
|
||||
await privateFile(approvalPath, 'x'.repeat(64 * 1024 + 1));
|
||||
await assert.rejects(loadMoneroDepositReviewApproval(approvalPath), /protected Monero deposit review approval/);
|
||||
await privateFile(approvalPath, '{malformed');
|
||||
await assert.rejects(loadMoneroDepositReviewApproval(approvalPath), /protected Monero deposit review approval/);
|
||||
});
|
||||
@@ -1,292 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { keccak } from 'hash-wasm';
|
||||
import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts';
|
||||
import { createMoneroDepositAddressRpc, createMoneroWalletObserver, loadMoneroObserverConfig,
|
||||
moneroWalletFingerprint, type MoneroFixtureTransport } from '../server/market/monero-observer.ts';
|
||||
import { loadMoneroMtlsTransportConfig } from '../server/market/monero-mtls-transport.ts';
|
||||
import { createMoneroRpcGateway, loadMoneroRpcGatewayConfig,
|
||||
validateMoneroRpcGatewayRequest } from '../server/market/monero-rpc-gateway.ts';
|
||||
|
||||
const config = { network: 'mainnet' as const, url: 'http://127.0.0.1:18083/json_rpc' };
|
||||
function response(id: string, result: Record<string, unknown>, status = 200, type = 'application/json') {
|
||||
return new Response(JSON.stringify({ jsonrpc: '2.0', id, result }), { status, headers: { 'content-type': type } });
|
||||
}
|
||||
function rpcErrorResponse(id: string, code: number, message: string) {
|
||||
return new Response(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }),
|
||||
{ headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
function fakeTransport(incoming: Record<string, unknown>[] = [], outputs?: Record<string, unknown>[]): { transport: MoneroFixtureTransport; calls: { method: string; params: unknown; auth: string | null }[] } {
|
||||
const calls: { method: string; params: unknown; auth: string | null }[] = [];
|
||||
const transport: MoneroFixtureTransport = async (url, init) => {
|
||||
assert.equal(url, config.url); assert.equal(init.method, 'POST'); assert.equal(init.redirect, 'error');
|
||||
assert.ok(init.signal);
|
||||
const body = JSON.parse(init.body as string) as { id: string; method: string; params: unknown };
|
||||
calls.push({ method: body.method, params: body.params, auth: new Headers(init.headers).get('authorization') });
|
||||
if (body.method === 'get_height') return response(body.id, { height: 100, status: 'OK' });
|
||||
if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' });
|
||||
if (body.method === 'get_transfers') return response(body.id, { in: incoming, status: 'OK' });
|
||||
if (body.method === 'incoming_transfers') return response(body.id, { transfers: outputs ?? incoming.map((item, index) => ({
|
||||
amount: item.amount, global_index: index, tx_hash: item.txid, subaddr_index: item.subaddr_index,
|
||||
unlocked: !(item.locked as boolean), block_height: item.height, pubkey: item.outputPublicKey ?? item.txid,
|
||||
})), status: 'OK' });
|
||||
throw new Error(`unexpected method ${body.method}`);
|
||||
};
|
||||
return { transport, calls };
|
||||
}
|
||||
async function syntheticSubaddress(fill = 3) {
|
||||
const prefix = Uint8Array.from([42, ...Array(64).fill(fill)]);
|
||||
const data = Buffer.concat([prefix, Buffer.from((await keccak(prefix, 256)).slice(0, 8), 'hex')]);
|
||||
const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
let encoded = '';
|
||||
for (let i = 0; i < data.length; i += 8) {
|
||||
const chunk = data.subarray(i, i + 8); let n = BigInt(`0x${chunk.toString('hex')}`), text = '';
|
||||
while (n) { text = alphabet[Number(n % 58n)] + text; n /= 58n; }
|
||||
encoded += text.padStart(chunk.length === 8 ? 11 : 7, '1');
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
test('read-only Monero observer preserves uint64 amount text and returns observations without crediting balances', async () => {
|
||||
const { transport, calls } = fakeTransport([{ txid: 'a'.repeat(64), address: ADDITIONAL_MARKET_FEE.address,
|
||||
amount: '9007199254740993', height: 91,
|
||||
timestamp: 1_800_000_000, unlock_time: 0, locked: false, double_spend_seen: false,
|
||||
subaddr_index: { major: 0, minor: 7 } }]);
|
||||
const observer = createMoneroWalletObserver(config, transport);
|
||||
assert.equal('createSubaddress' in observer, false);
|
||||
const page = await observer.scanIncoming(90);
|
||||
assert.equal(page.network, 'mainnet'); assert.equal(page.walletHeight, 100);
|
||||
assert.equal(page.primaryAddress, ADDITIONAL_MARKET_FEE.address);
|
||||
assert.equal(page.incoming.length, 1);
|
||||
assert.deepEqual(page.incoming[0], { observationId: `${'a'.repeat(64)}:${'a'.repeat(64)}`, transactionId: 'a'.repeat(64), address: ADDITIONAL_MARKET_FEE.address,
|
||||
amountAtomicXmr: '9007199254740993', height: 91, confirmations: 10, timestamp: 1_800_000_000,
|
||||
unlockTime: '0', accountIndex: 0, addressIndex: 7, outputPublicKey: 'a'.repeat(64), globalOutputIndex: 0,
|
||||
locked: false, doubleSpendSeen: false });
|
||||
assert.deepEqual(calls.map(call => call.method).sort(), ['get_address', 'get_height', 'get_transfers', 'incoming_transfers']);
|
||||
assert.ok(calls.every(call => call.auth === null), 'Wallet RPC credentials must never be sent as Basic/Digest headers');
|
||||
assert.deepEqual(calls.find(call => call.method === 'get_transfers')?.params,
|
||||
{ in: true, filter_by_height: true, min_height: 90, max_height: 100 });
|
||||
assert.deepEqual(calls.find(call => call.method === 'incoming_transfers')?.params,
|
||||
{ transfer_type: 'all', account_index: 0, subaddr_indices: [7] });
|
||||
assert.equal(Object.keys(observer).some(key => /transfer|sweep|send|credit/i.test(key)), false);
|
||||
});
|
||||
|
||||
test('observer distinguishes multiple outputs in one transaction by owned output public key', async () => {
|
||||
const txid = 'a'.repeat(64), firstKey = 'b'.repeat(64), secondKey = 'c'.repeat(64);
|
||||
const { transport } = fakeTransport([{ txid, address: ADDITIONAL_MARKET_FEE.address, amount: '30', height: 91,
|
||||
timestamp: 1_800_000_000, unlock_time: 0, locked: false, double_spend_seen: false,
|
||||
subaddr_index: { major: 0, minor: 7 } }], [
|
||||
{ tx_hash: txid, pubkey: firstKey, amount: '10', global_index: 42, block_height: 91, unlocked: true,
|
||||
subaddr_index: { major: 0, minor: 7 } },
|
||||
{ tx_hash: txid, pubkey: secondKey, amount: '20', global_index: 43, block_height: 91, unlocked: true,
|
||||
subaddr_index: { major: 0, minor: 7 } },
|
||||
]);
|
||||
const page = await createMoneroWalletObserver(config, transport).scanIncoming(90);
|
||||
assert.deepEqual(page.incoming.map(row => [row.observationId, row.amountAtomicXmr, row.globalOutputIndex]), [
|
||||
[`${txid}:${firstKey}`, '10', 42], [`${txid}:${secondKey}`, '20', 43],
|
||||
]);
|
||||
});
|
||||
|
||||
test('observer fails closed on missing, duplicated, or misattributed incoming outputs', async () => {
|
||||
const txid = 'd'.repeat(64), firstKey = 'e'.repeat(64);
|
||||
const transfer = { txid, address: ADDITIONAL_MARKET_FEE.address, amount: '2', height: 91,
|
||||
timestamp: 1_800_000_000, unlock_time: 0, locked: false, double_spend_seen: false,
|
||||
subaddr_index: { major: 0, minor: 7 } };
|
||||
const output = { tx_hash: txid, pubkey: firstKey, amount: '1', global_index: 42, block_height: 91,
|
||||
unlocked: true, subaddr_index: { major: 0, minor: 7 } };
|
||||
|
||||
const incomplete = fakeTransport([transfer], [output]);
|
||||
await assert.rejects(createMoneroWalletObserver(config, incomplete.transport).scanIncoming(90), /transaction amount/);
|
||||
|
||||
const duplicate = fakeTransport([transfer], [output, { ...output, global_index: 43 }]);
|
||||
await assert.rejects(createMoneroWalletObserver(config, duplicate.transport).scanIncoming(90), /Duplicate Monero wallet output public key/);
|
||||
|
||||
const misattributed = fakeTransport([transfer], [{ ...output, subaddr_index: { major: 0, minor: 8 } }]);
|
||||
await assert.rejects(createMoneroWalletObserver(config, misattributed.transport).scanIncoming(90), /outside requested subaddresses/);
|
||||
|
||||
const malformed: MoneroFixtureTransport = async (_url, init) => {
|
||||
const body = JSON.parse(init.body as string) as { id: string; method: string };
|
||||
if (body.method === 'get_height') return response(body.id, { height: 100, status: 'OK' });
|
||||
if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' });
|
||||
if (body.method === 'get_transfers') return response(body.id, { in: [transfer], status: 'OK' });
|
||||
return response(body.id, { transfers: [{ ...output, pubkey: 'not-a-public-key' }], status: 'OK' });
|
||||
};
|
||||
await assert.rejects(createMoneroWalletObserver(config, malformed).scanIncoming(90));
|
||||
});
|
||||
|
||||
test('observer tolerates v0.18.5.4 omission of empty incoming_transfers result fields', async () => {
|
||||
const txid = 'd'.repeat(64);
|
||||
const transport: MoneroFixtureTransport = async (_url, init) => {
|
||||
const body = JSON.parse(init.body as string) as { id: string; method: string };
|
||||
if (body.method === 'get_height') return response(body.id, { height: 100, status: 'OK' });
|
||||
if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' });
|
||||
if (body.method === 'get_transfers') return response(body.id, { in: [{ txid, address: ADDITIONAL_MARKET_FEE.address,
|
||||
amount: '1', height: 91, timestamp: 1, unlock_time: 0, locked: false, double_spend_seen: false,
|
||||
subaddr_index: { major: 0, minor: 7 } }], status: 'OK' });
|
||||
return response(body.id, {}); // Actual empty result shape from the pinned Wallet RPC build.
|
||||
};
|
||||
await assert.rejects(createMoneroWalletObserver(config, transport).scanIncoming(90), /transaction amount/);
|
||||
});
|
||||
|
||||
test('deposit-address RPC client is narrowly allow-listed and creates only one verified subaddress', async () => {
|
||||
const subaddress = await syntheticSubaddress();
|
||||
const calls: { method: string; params: unknown }[] = [];
|
||||
const transport: MoneroFixtureTransport = async (_url, init) => {
|
||||
const body = JSON.parse(init.body as string) as { id: string; method: string; params: unknown };
|
||||
calls.push({ method: body.method, params: body.params });
|
||||
if (body.method === 'validate_address') return response(body.id, { valid: true, integrated: false,
|
||||
nettype: 'mainnet', subaddress: false, openalias_address: '' });
|
||||
if (body.method === 'get_address') {
|
||||
const params = body.params as { address_index?: number[] };
|
||||
return response(body.id, { address: ADDITIONAL_MARKET_FEE.address,
|
||||
...(params.address_index ? { addresses: [{ address: subaddress, label: 'wikideck-deposit-v1:fixture', address_index: 7, used: false }] } : {}),
|
||||
status: 'OK' });
|
||||
}
|
||||
if (body.method === 'create_address') return response(body.id, { address: subaddress, address_index: 7,
|
||||
address_indices: [7], addresses: [subaddress], status: 'OK' });
|
||||
return response(body.id, { height: 100, status: 'OK' });
|
||||
};
|
||||
const rpc = createMoneroDepositAddressRpc(config, transport);
|
||||
assert.equal(await rpc.verifyWallet(), await moneroWalletFingerprint(ADDITIONAL_MARKET_FEE.address));
|
||||
assert.deepEqual(await rpc.getSubaddress(0, 7), { address: subaddress, label: 'wikideck-deposit-v1:fixture' });
|
||||
assert.deepEqual(await rpc.createSubaddress(0, 'wikideck-deposit-v1:fixture'), { address: subaddress, addressIndex: 7 });
|
||||
assert.deepEqual(calls, [
|
||||
{ method: 'get_address', params: { account_index: 0, address_index: [0] } },
|
||||
{ method: 'validate_address', params: { address: ADDITIONAL_MARKET_FEE.address } },
|
||||
{ method: 'get_address', params: { account_index: 0, address_index: [7] } },
|
||||
{ method: 'create_address', params: { account_index: 0, label: 'wikideck-deposit-v1:fixture', count: 1 } },
|
||||
]);
|
||||
await assert.rejects(rpc.getSubaddress(0, 8), /different subaddress index/);
|
||||
await assert.rejects(rpc.createSubaddress(0, 'bad\nlabel'), /parameters/);
|
||||
|
||||
let unexpectedCalls = 0;
|
||||
const onlyAddressTransport: MoneroFixtureTransport = async (_url, init) => {
|
||||
unexpectedCalls++;
|
||||
const body = JSON.parse(init.body as string) as { id: string; method: string };
|
||||
return response(body.id, { height: 100, status: 'OK' });
|
||||
};
|
||||
await assert.rejects(createMoneroDepositAddressRpc(config, onlyAddressTransport).getSubaddress(0, 0), /expected/);
|
||||
assert.equal(unexpectedCalls, 1);
|
||||
|
||||
const spendCapableWallet: MoneroFixtureTransport = async (_url, init) => {
|
||||
const body = JSON.parse(init.body as string) as { id: string; method: string };
|
||||
if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' });
|
||||
if (body.method === 'validate_address') return response(body.id, { valid: true, integrated: false,
|
||||
nettype: 'stagenet', subaddress: false, openalias_address: '' });
|
||||
throw new Error(`unexpected method ${body.method}`);
|
||||
};
|
||||
await assert.rejects(createMoneroDepositAddressRpc(config, spendCapableWallet).verifyWallet());
|
||||
});
|
||||
|
||||
test('official wallet not-yet-created address-index error is recoverable but unrelated RPC errors fail closed', async () => {
|
||||
const notFound: MoneroFixtureTransport = async (_url, init) => {
|
||||
const body = JSON.parse(init.body as string) as { id: string };
|
||||
return rpcErrorResponse(body.id, -15, 'address index is out of bound');
|
||||
};
|
||||
assert.equal(await createMoneroDepositAddressRpc(config, notFound).getSubaddress(0, 1), null);
|
||||
|
||||
const unrelated: MoneroFixtureTransport = async (_url, init) => {
|
||||
const body = JSON.parse(init.body as string) as { id: string };
|
||||
return rpcErrorResponse(body.id, -15, 'account index is out of bound');
|
||||
};
|
||||
await assert.rejects(createMoneroDepositAddressRpc(config, unrelated).getSubaddress(0, 1), /method failed \(-15\)/);
|
||||
});
|
||||
|
||||
test('mTLS gateway enforces bounded per-certificate observer and address-allocator methods', () => {
|
||||
const request = (method: string, params: Record<string, unknown> = {}) =>
|
||||
({ jsonrpc: '2.0', id: 'fixture-id', method, params });
|
||||
const observer = ['observer'] as const, allocator = ['address-allocator'] as const;
|
||||
assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('get_height'), observer));
|
||||
assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('get_address', { account_index: 0, address_index: [0] }), observer));
|
||||
assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('create_address', { account_index: 0, label: 'fixture', count: 1 }), allocator));
|
||||
assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('validate_address', { address: 'x'.repeat(95) }), allocator));
|
||||
assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('get_transfers', {
|
||||
in: true, filter_by_height: true, min_height: 0, max_height: 100,
|
||||
}), observer));
|
||||
assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('incoming_transfers', {
|
||||
transfer_type: 'all', account_index: 0, subaddr_indices: [2, 3],
|
||||
}), observer));
|
||||
assert.throws(() => validateMoneroRpcGatewayRequest(request('incoming_transfers', {
|
||||
transfer_type: 'all', account_index: 0, subaddr_indices: [2], key_image: true,
|
||||
}), observer));
|
||||
assert.throws(() => validateMoneroRpcGatewayRequest(request('create_address', { account_index: 0, label: 'fixture', count: 1 }), observer), /not allowed for this client certificate/);
|
||||
assert.throws(() => validateMoneroRpcGatewayRequest(request('get_transfers', {
|
||||
in: true, filter_by_height: true, min_height: 0, max_height: 100,
|
||||
}), allocator), /not allowed for this client certificate/);
|
||||
for (const method of ['transfer', 'transfer_split', 'sweep_all', 'relay_tx', 'get_tx_key', 'query_key', 'open_wallet', 'get_balance']) {
|
||||
assert.throws(() => validateMoneroRpcGatewayRequest(request(method), ['observer', 'address-allocator']), /not allowed for this client certificate/);
|
||||
}
|
||||
assert.throws(() => validateMoneroRpcGatewayRequest(request('get_address', { account_index: 0, address_index: [0], all: true }), observer));
|
||||
assert.throws(() => validateMoneroRpcGatewayRequest(request('create_address', { account_index: 0, label: 'x', count: 2 }), allocator));
|
||||
assert.throws(() => validateMoneroRpcGatewayRequest(request('get_transfers', {
|
||||
in: true, filter_by_height: true, min_height: 0, max_height: 100_001,
|
||||
}), observer), /range/);
|
||||
assert.throws(() => validateMoneroRpcGatewayRequest(request('get_height'), []));
|
||||
});
|
||||
|
||||
test('production mTLS transport config is mandatory and requires a bare HTTPS RPC endpoint', async () => {
|
||||
await assert.rejects(loadMoneroMtlsTransportConfig({}), /config file is required/);
|
||||
await assert.rejects(loadMoneroRpcGatewayConfig(''), /protected Monero RPC gateway configuration/);
|
||||
await assert.rejects(createMoneroRpcGateway({ listenHost: '203.0.113.10', listenPort: 18085,
|
||||
walletRpcUrl: 'http://127.0.0.1:18083/json_rpc', server: { certificateFile: 'missing', privateKeyFile: 'missing',
|
||||
clientCaFile: 'missing' }, clientPolicies: [{ fingerprint: 'a'.repeat(64), roles: ['observer'] }] }), /private interface IP/);
|
||||
await assert.rejects(createMoneroRpcGateway({ listenHost: '127.0.0.1', listenPort: 18085,
|
||||
walletRpcUrl: 'http://wallet.example/json_rpc', server: { certificateFile: 'missing', privateKeyFile: 'missing',
|
||||
clientCaFile: 'missing' }, clientPolicies: [{ fingerprint: 'a'.repeat(64), roles: ['observer'] }] }), /loopback-only/);
|
||||
});
|
||||
|
||||
test('observer refuses non-loopback plaintext, URL credentials and malformed height ranges', async () => {
|
||||
const { transport } = fakeTransport(); const observer = createMoneroWalletObserver(config, transport);
|
||||
assert.throws(() => createMoneroWalletObserver({ ...config, url: 'http://wallet.example/json_rpc' }, transport), /HTTPS/);
|
||||
assert.throws(() => createMoneroWalletObserver({ ...config, url: 'http://localhost/json_rpc' }, transport), /HTTPS/);
|
||||
assert.throws(() => createMoneroWalletObserver({ ...config, url: 'https://user:pass@wallet.example/json_rpc' }, transport), /bare/);
|
||||
await assert.rejects(observer.scanIncoming(-1), /scan range/);
|
||||
await assert.rejects(observer.scanIncoming(101), /ahead/);
|
||||
});
|
||||
|
||||
test('observer rejects unsafe amounts, duplicate observations, malformed transfers and wrong RPC identity', async () => {
|
||||
const unsafeTransport: MoneroFixtureTransport = async (_url, init) => {
|
||||
const body = JSON.parse(init.body as string) as { id: string; method: string };
|
||||
if (body.method === 'get_height') return response(body.id, { height: 100, status: 'OK' });
|
||||
if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' });
|
||||
if (body.method === 'get_transfers') return new Response(`{"jsonrpc":"2.0","id":"${body.id}","result":{"status":"OK","in":[{"txid":"${'b'.repeat(64)}","address":"${ADDITIONAL_MARKET_FEE.address}","amount":18446744073709551616,"height":91,"timestamp":1,"unlock_time":0,"locked":false,"double_spend_seen":false,"subaddr_index":{"major":0,"minor":1}}]}}`,
|
||||
{ headers: { 'content-type': 'application/json' } });
|
||||
return response(body.id, { status: 'OK', transfers: [{ tx_hash: 'b'.repeat(64), pubkey: 'c'.repeat(64),
|
||||
amount: 18446744073709551616n.toString(), global_index: 1, block_height: 91, unlocked: true,
|
||||
subaddr_index: { major: 0, minor: 1 } }] });
|
||||
};
|
||||
await assert.rejects(createMoneroWalletObserver(config, unsafeTransport).scanIncoming(90), /amount/);
|
||||
|
||||
const duplicate = { txid: 'c'.repeat(64), address: ADDITIONAL_MARKET_FEE.address, amount: '1', height: 91, timestamp: 1, unlock_time: 0,
|
||||
locked: true, double_spend_seen: false, subaddr_index: { major: 0, minor: 1 } };
|
||||
const duplicateFixture = fakeTransport([duplicate, duplicate]);
|
||||
await assert.rejects(createMoneroWalletObserver(config, duplicateFixture.transport).scanIncoming(90), /Duplicate/);
|
||||
|
||||
const wrongId: MoneroFixtureTransport = async (_url, init) => {
|
||||
JSON.parse(init.body as string) as { id: string };
|
||||
return response('00000000-0000-4000-8000-000000000001', { height: 100, status: 'OK' });
|
||||
};
|
||||
await assert.rejects(createMoneroWalletObserver(config, wrongId).scanIncoming(90), /ID mismatch/);
|
||||
});
|
||||
|
||||
test('observer refuses redirects, wrong content type and oversized RPC response without exposing credentials', async () => {
|
||||
const secretSentinel = 'fixture-secret-never-log';
|
||||
const badResponses = [new Response('{}', { status: 302, headers: { 'content-type': 'application/json' } }),
|
||||
new Response('<html/>', { headers: { 'content-type': 'text/html' } }),
|
||||
new Response('x'.repeat(1024 * 1024 + 1), { headers: { 'content-type': 'application/json' } })];
|
||||
for (const bad of badResponses) {
|
||||
let call = 0;
|
||||
const transport: MoneroFixtureTransport = async () => {
|
||||
call++;
|
||||
if (call === 1) return response('unused', { height: 100, status: 'OK' });
|
||||
return bad;
|
||||
};
|
||||
await assert.rejects(createMoneroWalletObserver(config, transport).scanIncoming(90), error =>
|
||||
error instanceof Error && !error.message.includes(secretSentinel));
|
||||
}
|
||||
});
|
||||
|
||||
test('Monero RPC configuration is opt-in and inaccessible without a protected external config file', async () => {
|
||||
await assert.rejects(loadMoneroObserverConfig({}), /disabled/);
|
||||
await assert.rejects(loadMoneroObserverConfig({ WIKIDECK_MONERO_OBSERVER: '1' }), /config file is required/);
|
||||
});
|
||||
+9
-72
@@ -8,7 +8,6 @@ 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], {
|
||||
@@ -21,36 +20,8 @@ test('default testnet v6 manifest is unchanged and unknown network profiles fail
|
||||
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.equal(networkProfile('pilot'), 'pilot');
|
||||
assert.throws(() => networkProfile('production')); assert.throws(() => networkProfile(''));
|
||||
});
|
||||
test('private pilot is a separate deterministic chain with an explicit mock-only startup gate', () => {
|
||||
const output = JSON.parse(script(`
|
||||
import { NETWORK, NETWORK_GENESIS_TIME, NETWORK_LABEL } from './shared/network.ts';
|
||||
import { CHAIN_ID, GENESIS_HASH, GENESIS_TIME } from './server/testnet/consensus.ts';
|
||||
import { launchManifest, assertLaunchAllowed } from './server/launch.ts';
|
||||
const allowed = (env) => { try { assertLaunchAllowed(env); return true; } catch { return false; } };
|
||||
console.log(JSON.stringify({ NETWORK, NETWORK_GENESIS_TIME, NETWORK_LABEL, CHAIN_ID, GENESIS_HASH, GENESIS_TIME,
|
||||
manifest:launchManifest(), missing:allowed({}), approved:allowed({WIKIDECK_PILOT_MODE:'private-mock-only'}),
|
||||
livePayment:allowed({WIKIDECK_PILOT_MODE:'private-mock-only',WIKIDECK_PAYMENT_MODE:'live'}) }));
|
||||
`, { WIKIDECK_NETWORK: 'pilot' }));
|
||||
assert.equal(output.NETWORK, 'wikideck-randomx-pilot-1');
|
||||
assert.equal(output.GENESIS_TIME, 1_792_627_200);
|
||||
assert.equal(output.NETWORK_LABEL, 'PRIVATE PILOT');
|
||||
assert.equal(output.manifest.launchStatus, 'private-pilot-mock-only');
|
||||
assert.equal(output.manifest.paymentMode, 'mock');
|
||||
assert.equal(output.missing, false);
|
||||
assert.equal(output.approved, true);
|
||||
assert.equal(output.livePayment, false);
|
||||
assert.notEqual(output.CHAIN_ID, CHAIN_ID);
|
||||
assert.notEqual(output.GENESIS_HASH, GENESIS_HASH);
|
||||
const missingMode = spawnSync(process.execPath, ['--experimental-strip-types', 'server/index.ts'], {
|
||||
env: { ...process.env, WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: '' },
|
||||
encoding: 'utf8', timeout: 30000,
|
||||
});
|
||||
assert.notEqual(missingMode.status, 0);
|
||||
assert.match(missingMode.stderr, /Private pilot requires WIKIDECK_PILOT_MODE/);
|
||||
});
|
||||
test('mainnet candidate has distinct deterministic genesis, signatures and strict transaction schemas', () => {
|
||||
const output = JSON.parse(script(`
|
||||
import { NETWORK, nftTransferMessage } from './shared/testnet.ts';
|
||||
@@ -70,67 +41,46 @@ test('mainnet startup is blocked before resources open, while manifest inspectio
|
||||
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.profile, 'mainnet'); 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', () => {
|
||||
test('mainnet gates require genesis time, chain-specific approval, HTTPS, and independent peers', () => {
|
||||
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'};
|
||||
PUBLIC_ORIGIN:'https://wikideck.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],
|
||||
[{PUBLIC_ORIGIN:'http://wikideck.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:'processor'},Infinity],[{},Infinity]]) {
|
||||
[{WIKIDECK_ALLOW_PRIVATE_PEERS:'1'},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: 'processor' }));
|
||||
assert.deepEqual(result, [false,false,false,false,false,false,true]);
|
||||
});
|
||||
test('mainnet ledger and market files use isolated folders; testnet backup headers are rejected', async () => {
|
||||
test('mainnet ledger uses its isolated folder; 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();
|
||||
ledger.close();
|
||||
`, { PROFILE_TEST_DIR: dir }));
|
||||
assert.deepEqual(result, { ledger: true, market: true, testnet: false, rejected: true });
|
||||
assert.deepEqual(result, { ledger: true, testnet: false, rejected: true });
|
||||
} finally { await rm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('pilot ledger and market files use their own profile directory', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wikideck-pilot-network-'));
|
||||
try {
|
||||
const result = JSON.parse(script(`
|
||||
import { Ledger } from './server/testnet/ledger.ts';
|
||||
import { Market } from './server/market/store.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,undefined,Date.now,{worker:false});
|
||||
const result={ledger:existsSync(join(process.env.PROFILE_TEST_DIR,'pilot','ledger.sqlite')),
|
||||
market:existsSync(join(process.env.PROFILE_TEST_DIR,'pilot','market.sqlite')),
|
||||
testnet:existsSync(join(process.env.PROFILE_TEST_DIR,'testnet'))};
|
||||
market.close();ledger.close();console.log(JSON.stringify(result));
|
||||
`, { WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: 'private-mock-only', PROFILE_TEST_DIR: dir }));
|
||||
assert.deepEqual(result, { ledger: true, market: true, testnet: false });
|
||||
} finally { await rm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('operator preflight is read-only, fails closed, and does not disclose credentials', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wikideck-preflight-'));
|
||||
@@ -147,22 +97,9 @@ test('operator preflight is read-only, fails closed, and does not disclose crede
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.equal(report.productionReady, false);
|
||||
assert.equal(report.decision, 'NO-GO');
|
||||
assert.equal(report.paymentMode, 'mock');
|
||||
assert.ok(report.checks.some((c: { id: string; status: string }) => c.id === 'consensus-launch-calibration' && c.status === 'blocked'));
|
||||
assert.equal(result.stdout.includes(secret), false);
|
||||
assert.equal(existsSync(dataDir), false);
|
||||
} finally { await rm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('Monero deposit and withdrawal request APIs cannot be enabled on testnet', () => {
|
||||
for (const [name, value] of [['WIKIDECK_MONERO_DEPOSIT_ENROLLMENT', '1'],
|
||||
['WIKIDECK_MONERO_WITHDRAWAL_REQUESTS', '1']]) {
|
||||
const result = spawnSync(process.execPath, ['--experimental-strip-types', 'server/index.ts'], {
|
||||
env: { ...process.env, WIKIDECK_NETWORK: 'testnet', [name]: value },
|
||||
encoding: 'utf8', timeout: 30000,
|
||||
});
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, new RegExp(name.includes('DEPOSIT')
|
||||
? 'Monero deposit enrollment requires the mainnet profile' : 'Monero withdrawal requests require the mainnet profile'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -34,22 +34,15 @@ test('private operations listener exposes bounded aggregates only on separate lo
|
||||
assert.equal((await fetch(`http://127.0.0.1:${a.port}/metrics`)).status,404);
|
||||
assert.equal(ops.address?.()?.address,'127.0.0.1');
|
||||
});
|
||||
test('operations checks identify isolation, stale reconciliation, disk pressure and market reviews',()=>{
|
||||
test('operations checks identify node, peer, native-engine, and disk pressure',()=>{
|
||||
const now=Date.now();
|
||||
const status={schema:'wikideck-operations-v1',observedAt:new Date(now).toISOString(),
|
||||
native:{available:true,queued:0},ledger:{height:10,mutationQueue:0,tipAgeSeconds:60},
|
||||
peers:{configured:2,connected:2},eventLoop:{meanMs:20},disk:{availableBytes:1024**3},diskSampledAt:now,
|
||||
market:{queued:0,lastReconciledAt:now,orderStates:[] as {status:string;count:number}[]}};
|
||||
peers:{configured:2,connected:2},eventLoop:{meanMs:20},disk:{availableBytes:1024**3},diskSampledAt:now};
|
||||
assert.equal(checkOperations(status,now).healthy,true);
|
||||
const bad={...status,native:{available:false,queued:50},peers:{configured:2,connected:0},disk:null,
|
||||
market:{queued:0,lastReconciledAt:now-120000,orderStates:[{status:'review',count:1}]}};
|
||||
ledger:{...status.ledger,mutationQueue:192}};
|
||||
const report=checkOperations(bad,now);
|
||||
for(const warning of ['native-engine-unavailable','queue-pressure','peer-isolation','market-reconciliation-stale','market-operator-review','disk-sample-unavailable-or-stale'])assert.ok(report.warnings.includes(warning));
|
||||
for(const warning of ['native-engine-unavailable','queue-pressure','peer-isolation','disk-sample-unavailable-or-stale'])assert.ok(report.warnings.includes(warning));
|
||||
assert.throws(()=>checkOperations({}));
|
||||
const custody = {backend:'postgres',mutationHold:false,integrityHold:false,
|
||||
audit:{status:'ok',lastAttemptAt:now,lastSuccessAt:now,failureCode:null,stale:false}};
|
||||
assert.equal(checkOperations({...status,custody},now).healthy,true);
|
||||
const held=checkOperations({...status,custody:{...custody,mutationHold:true,integrityHold:true,
|
||||
audit:{...custody.audit,status:'failed',failureCode:'order-reservation-mismatch',lastSuccessAt:now-180000,stale:true}}},now);
|
||||
assert.ok(held.warnings.includes('custody-financial-review'));assert.ok(held.warnings.includes('custody-audit-stale'));
|
||||
});
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
test('private pilot Compose stack is loopback-bound, isolated, and mock-only', async () => {
|
||||
const compose = (await readFile(new URL('../compose.custody-pilot.yaml', import.meta.url), 'utf8')).replaceAll('\r\n', '\n');
|
||||
assert.match(compose, /WIKIDECK_NETWORK: pilot/);
|
||||
assert.match(compose, /WIKIDECK_PILOT_MODE: private-mock-only/);
|
||||
assert.match(compose, /WIKIDECK_PAYMENT_MODE: mock/);
|
||||
assert.match(compose, /WIKIDECK_CUSTODY_STORAGE: postgres/);
|
||||
assert.match(compose, /WIKIDECK_CUSTODY_MODE: sandbox/);
|
||||
assert.match(compose, /WIKIDECK_LOCAL_PG_BOOTSTRAP: sandbox-only/);
|
||||
assert.match(compose, /127\.0\.0\.1:\$\{WIKIDECK_PILOT_PORT:-3423\}:3001/);
|
||||
assert.match(compose, /financial: \{internal: true\}/);
|
||||
assert.match(compose, /pg-runtime:\/secrets\/runtime:ro/);
|
||||
assert.doesNotMatch(compose, /POSTGRES_PASSWORD:\s|5432:5432/i);
|
||||
assert.doesNotMatch(compose, /^\s+(?:monero|wallet-rpc):/im);
|
||||
assert.match(compose, /daemon-check:\n\s+image: wikideck:private-pilot/);
|
||||
assert.match(compose, /profiles: \["monero-check"\]/);
|
||||
assert.match(compose, /scripts\/monero-daemon-check\.ts/);
|
||||
assert.match(compose, /target: \/run\/monero\/daemon\.json\n\s+read_only: true/);
|
||||
assert.match(compose, /pilot-observer:\n\s+image: wikideck:private-pilot\n\s+profiles: \["pilot-observer"\]/);
|
||||
assert.match(compose, /scripts\/pilot-monero-observe-once\.ts/);
|
||||
const observer = compose.split(' pilot-observer:')[1].split('\nnetworks:')[0];
|
||||
assert.doesNotMatch(observer, /ports:|depends_on:|pg-runtime:|pg-admin:|pg-owner:/);
|
||||
assert.match(observer, /pilot-observation-only-no-credit/);
|
||||
assert.match(observer, /target: \/run\/secrets\/pilot-observer-db-url\n\s+read_only: true/);
|
||||
assert.match(observer, /target: \/run\/secrets\/observer-client\.key\n\s+read_only: true/);
|
||||
assert.match(observer, /networks: \[observer-egress\]/);
|
||||
assert.match(observer, /restart: "no"/);
|
||||
const database = compose.split(' database:')[1].split(' migrate:')[0];
|
||||
assert.doesNotMatch(database, /ports:/);
|
||||
const app = compose.split(' wikideck:')[1].split('networks:\n financial:')[0];
|
||||
assert.doesNotMatch(app, /pg-admin:|pg-owner:|pg-tls:/);
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { loadPilotMoneroDepositRequestFile } from '../server/market/pilot-monero-deposit-request-file.ts';
|
||||
|
||||
function run(code: string) {
|
||||
return JSON.parse(execFileSync(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], {
|
||||
env: { ...process.env, WIKIDECK_NETWORK: 'pilot' }, encoding: 'utf8', timeout: 30_000,
|
||||
}).trim());
|
||||
}
|
||||
|
||||
const requestFixture = `
|
||||
import { generateKeyPairSync, sign } from 'node:crypto';
|
||||
import { address } from './server/testnet/consensus.ts';
|
||||
import { CHAIN_ID } from './server/testnet/consensus.ts';
|
||||
import { pilotMoneroDepositRequestMessage } from './shared/pilot-monero-deposit.ts';
|
||||
import { validatePilotMoneroDepositRequest } from './server/market/pilot-monero-deposit-request.ts';
|
||||
const pair=generateKeyPairSync('ec',{namedCurve:'prime256v1'});
|
||||
const publicKey=pair.publicKey.export({type:'spki',format:'der'}).toString('base64');
|
||||
const beneficiary=address(publicKey), now=1_800_000_000_000;
|
||||
const identity={version:1,mode:'pilot-custody',policy:'pilot-custody-v1',marketNetwork:'wikideck-randomx-pilot-1',
|
||||
marketChainId:CHAIN_ID,moneroNetwork:'mainnet',custodianId:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
activationStatus:'identity-only',initializedAt:now-1};
|
||||
const context={identity,walletGenerationId:'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
|
||||
walletFingerprint:'c'.repeat(64),accountIndex:2,now:()=>now};
|
||||
const unsigned={version:1,marketNetwork:'wikideck-randomx-pilot-1',marketChainId:CHAIN_ID,moneroNetwork:'mainnet',
|
||||
custodianId:identity.custodianId,beneficiary,publicKey,requestId:'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
|
||||
walletGenerationId:context.walletGenerationId,walletFingerprint:context.walletFingerprint,accountIndex:2,expiresAt:now+60_000};
|
||||
const signature=sign('sha256',Buffer.from(pilotMoneroDepositRequestMessage(unsigned)),
|
||||
{key:pair.privateKey,dsaEncoding:'ieee-p1363'}).toString('base64');
|
||||
const request={...unsigned,signature};
|
||||
`;
|
||||
|
||||
test('pilot deposit authorization binds both networks and validates without allocating an address', () => {
|
||||
const result = run(`${requestFixture}
|
||||
const intent=validatePilotMoneroDepositRequest(context,request);
|
||||
console.log(JSON.stringify({intent,hasAddress:Object.hasOwn(intent,'address'),hasAddressIndex:Object.hasOwn(intent,'addressIndex')}));`);
|
||||
assert.equal(result.intent.marketNetwork, 'wikideck-randomx-pilot-1');
|
||||
assert.match(result.intent.marketChainId, /^[a-f0-9]{64}$/);
|
||||
assert.equal(result.intent.moneroNetwork, 'mainnet');
|
||||
assert.equal(result.intent.beneficiary.length, 64);
|
||||
assert.match(result.intent.requestDigest, /^[a-f0-9]{64}$/);
|
||||
assert.equal(result.hasAddress, false);
|
||||
assert.equal(result.hasAddressIndex, false);
|
||||
});
|
||||
|
||||
test('pilot deposit authorization rejects domain, identity, wallet, signer, signature, and expiry mismatches', () => {
|
||||
const result = run(`${requestFixture}
|
||||
const rejected=[];
|
||||
const check=(label,ctx,raw)=>{try{validatePilotMoneroDepositRequest(ctx,raw);rejected.push([label,false]);}
|
||||
catch{rejected.push([label,true]);}};
|
||||
check('market-network',context,{...request,marketNetwork:'wikideck-randomx-mainnet-1'});
|
||||
check('monero-network',context,{...request,moneroNetwork:'stagenet'});
|
||||
check('chain-id',context,{...request,marketChainId:'e'.repeat(64)});
|
||||
check('custodian',context,{...request,custodianId:'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'});
|
||||
check('wallet-generation',{...context,walletGenerationId:'ffffffff-ffff-4fff-8fff-ffffffffffff'},request);
|
||||
check('wallet-fingerprint',{...context,walletFingerprint:'d'.repeat(64)},request);
|
||||
check('account-index',{...context,accountIndex:3},request);
|
||||
check('signer',context,{...request,beneficiary:'1'.repeat(64)});
|
||||
check('signature',context,{...request,signature:'A'.repeat(88)});
|
||||
check('expired',{...context,now:()=>request.expiresAt+1},request);
|
||||
check('too-far-future',{...context,now:()=>request.expiresAt+1},{...request,expiresAt:request.expiresAt+10*60_000});
|
||||
check('not-identity-only',{...context,identity:{...context.identity,activationStatus:'active'}},request);
|
||||
console.log(JSON.stringify(rejected));`);
|
||||
assert.deepEqual(result, [['market-network',true],['monero-network',true],['chain-id',true],['custodian',true],
|
||||
['wallet-generation',true],['wallet-fingerprint',true],['account-index',true],['signer',true],['signature',true],
|
||||
['expired',true],['too-far-future',true],['not-identity-only',true]]);
|
||||
});
|
||||
|
||||
test('pilot deposit authorization rejects malformed and unknown fields', () => {
|
||||
const result = run(`${requestFixture}
|
||||
let malformed=false,unknown=false;
|
||||
try{validatePilotMoneroDepositRequest(context,{...request,accountIndex:-1});}catch{malformed=true;}
|
||||
try{validatePilotMoneroDepositRequest(context,{...request,address:'4'.repeat(95)});}catch{unknown=true;}
|
||||
console.log(JSON.stringify({malformed,unknown}));`);
|
||||
assert.deepEqual(result, { malformed: true, unknown: true });
|
||||
});
|
||||
|
||||
test('operator address CLI requires explicit consent before reading wallet or database configuration', () => {
|
||||
const result = spawnSync(process.execPath, ['--experimental-strip-types', 'scripts/pilot-monero-deposit-address.ts', '/missing/request.json'], {
|
||||
cwd: process.cwd(), encoding: 'utf8', timeout: 30_000,
|
||||
env: { ...process.env, WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: 'private-mock-only',
|
||||
WIKIDECK_PILOT_CUSTODY_OPERATIONS: '' },
|
||||
});
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /operator-triggered-addresses-only/);
|
||||
assert.doesNotMatch(result.stderr, /database configuration|Wallet-RPC config/);
|
||||
});
|
||||
|
||||
test('signed pilot request files must be absolute, bounded, owner-only regular files', async t => {
|
||||
if (process.platform === 'win32') {
|
||||
await assert.rejects(loadPilotMoneroDepositRequestFile('C:\\private\\request.json'), /POSIX protected-file/);
|
||||
return;
|
||||
}
|
||||
const directory = await mkdtemp(join(tmpdir(), 'wikideck-pilot-address-request-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const path = join(directory, 'request.json');
|
||||
await writeFile(path, JSON.stringify({ version: 1, signature: 'signed-fixture' }), { mode: 0o600 });
|
||||
await chmod(path, 0o600);
|
||||
assert.deepEqual(await loadPilotMoneroDepositRequestFile(path), { version: 1, signature: 'signed-fixture' });
|
||||
await chmod(path, 0o644);
|
||||
await assert.rejects(loadPilotMoneroDepositRequestFile(path), /owner-only/);
|
||||
await assert.rejects(loadPilotMoneroDepositRequestFile('relative.json'), /absolute/);
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
function run(code: string) {
|
||||
return JSON.parse(execFileSync(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], {
|
||||
env: { ...process.env, WIKIDECK_NETWORK: 'pilot' }, encoding: 'utf8', timeout: 30_000,
|
||||
}).trim());
|
||||
}
|
||||
|
||||
test('pilot observation persistence classifies allocated and suspense outputs and records reorg evidence only', () => {
|
||||
const result = run(`
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { address } from './server/testnet/consensus.ts';
|
||||
import { persistPilotMoneroObservationScan } from './server/market/pilot-monero-observation-store.ts';
|
||||
const pair=generateKeyPairSync('ec',{namedCurve:'prime256v1'});
|
||||
const publicKey=pair.publicKey.export({type:'spki',format:'der'}).toString('base64');
|
||||
const fingerprint='a'.repeat(64), generation='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||
const subaddress='4'.repeat(95), otherAddress='5'.repeat(95);
|
||||
const pool={};
|
||||
console.log(JSON.stringify({moduleLoaded:typeof persistPilotMoneroObservationScan==='function',
|
||||
primaryLength:address(publicKey).length,generation,subaddress,otherAddress,fingerprint,poolKeys:Object.keys(pool)}));
|
||||
`);
|
||||
assert.equal(result.moduleLoaded, true);
|
||||
assert.equal(result.primaryLength, 64);
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const read = (path: string) => readFile(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
test('persistent custody deployment remains separate, sandbox-only and password authenticated without a database host port', async () => {
|
||||
const compose = (await read('compose.custody.yaml')).replaceAll('\r\n', '\n');
|
||||
assert.match(compose, /WIKIDECK_CUSTODY_MODE: sandbox/);
|
||||
assert.match(compose, /WIKIDECK_CUSTODY_STORAGE: postgres/);
|
||||
assert.match(compose, /WIKIDECK_PAYMENT_MODE: mock/);
|
||||
assert.match(compose, /financial: \{internal: true\}/);
|
||||
assert.match(compose, /POSTGRES_PASSWORD_FILE:/);
|
||||
assert.doesNotMatch(compose, /POSTGRES_HOST_AUTH_METHOD|LOCAL_TEST|5432:5432|tmpfs:.*postgresql/);
|
||||
const database = compose.split(' database:')[1].split(' migrate:')[0];
|
||||
assert.doesNotMatch(database, /ports:/);
|
||||
assert.match(database, /pg-data:\/var\/lib\/postgresql\/data/);
|
||||
const application = compose.split(' wikideck:')[1].split('networks:\n financial:')[0];
|
||||
assert.match(application, /pg-runtime:\/secrets\/runtime:ro/);
|
||||
assert.doesNotMatch(application, /pg-admin:|pg-owner:|pg-tls:/);
|
||||
});
|
||||
test('database HBA requires TLS+SCRAM over TCP and provisioning refuses partial credential replacement', async () => {
|
||||
const hba = await read('deploy/postgres/pg_hba.conf'), setup = await read('deploy/postgres/setup.mjs');
|
||||
assert.match(hba, /hostnossl all all all reject/);
|
||||
assert.match(hba, /hostssl wikideck_custody custody_runtime all scram-sha-256/);
|
||||
assert.doesNotMatch(hba, /^host.*trust/m);
|
||||
assert.match(setup, /randomBytes\(32\)/);
|
||||
assert.match(setup, /Partial secret initialization/);
|
||||
assert.match(setup, /subjectAltName=DNS:database/);
|
||||
assert.match(setup, /-checkend/);
|
||||
});
|
||||
|
||||
test('standalone Monero observer service is non-root, filesystem-hardened and has no public listener', async () => {
|
||||
const unit = await read('deploy/systemd/wikideck-monero-observer.service');
|
||||
const guide = await read('deploy/systemd/README.md');
|
||||
const observer = JSON.parse(await read('deploy/systemd/wallet-rpc.example.json')) as { network: string; url: string };
|
||||
const mtls = JSON.parse(await read('deploy/systemd/mtls.example.json')) as { url: string; clientPrivateKeyFile: string };
|
||||
assert.match(unit, /User=wikideck-observer/);
|
||||
assert.match(unit, /ProtectSystem=strict/);
|
||||
assert.match(unit, /ProtectHome=true/);
|
||||
assert.match(unit, /NoNewPrivileges=true/);
|
||||
assert.match(unit, /CapabilityBoundingSet=/);
|
||||
assert.match(unit, /RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX/);
|
||||
assert.match(unit, /WIKIDECK_MONERO_OBSERVER_WORKER=1/);
|
||||
assert.doesNotMatch(unit, /^\s*(?:ListenStream|ListenDatagram|SocketUser)=/m);
|
||||
assert.equal(observer.network, 'mainnet');
|
||||
assert.equal(observer.url, mtls.url);
|
||||
assert.equal(mtls.clientPrivateKeyFile, '/etc/wikideck/monero-observer/client.key');
|
||||
assert.match(guide, /migrate-mainnet-schema/);
|
||||
assert.match(guide, /does \*\*not\*\* issue customer deposit addresses/);
|
||||
assert.doesNotMatch(`${unit}\n${guide}`, /(?:password|api[_-]?key)\s*[:=]\s*[^\s<]+/i);
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, writeFile, chmod, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { postgresConfig } from '../server/market/postgres-config.ts';
|
||||
|
||||
test('PostgreSQL verified TLS remains the default and cannot be downgraded through URL options', async t => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wikideck-transport-'));
|
||||
t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
const file = join(dir, 'connection');
|
||||
// Synthetic configuration fixture, never sent to a database.
|
||||
await writeFile(file, 'postgresql://fixture:fixture-only@database.cluster.example/custody', { mode: 0o600 });
|
||||
const env = { WIKIDECK_CUSTODY_PG_URL_FILE: file };
|
||||
const config = await postgresConfig(env);
|
||||
assert.equal((config.ssl as { rejectUnauthorized: boolean }).rejectUnauthorized, true);
|
||||
assert.deepEqual(config.ssl, (await postgresConfig({ ...env, WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls' })).ssl);
|
||||
for (const mode of ['', 'off', 'disable', 'false', 'mesh', 'TLS']) {
|
||||
await assert.rejects(postgresConfig({ ...env, WIKIDECK_CUSTODY_PG_TRANSPORT: mode }), /configuration/);
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(file, 0o644);
|
||||
await assert.rejects(postgresConfig(env), /configuration/);
|
||||
await chmod(file, 0o600);
|
||||
}
|
||||
await writeFile(file, 'postgresql://fixture:fixture-only@database.cluster.example/custody?sslmode=disable');
|
||||
await assert.rejects(postgresConfig(env), /configuration/);
|
||||
});
|
||||
|
||||
test('PostgreSQL public CA is validated separately from owner-only credential URL files', async t => {
|
||||
if (process.platform === 'win32') return;
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wikideck-ca-'));
|
||||
t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
const urlFile = join(dir, 'connection'), caFile = join(dir, 'ca.crt'), keyFile = join(dir, 'ca.key');
|
||||
await writeFile(urlFile, 'postgresql://fixture:fixture-only@database/custody', { mode: 0o600 });
|
||||
execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-sha256', '-nodes', '-days', '2',
|
||||
'-subj', '/CN=fixture CA', '-keyout', keyFile, '-out', caFile], { stdio: 'ignore' });
|
||||
await chmod(urlFile, 0o600); await chmod(caFile, 0o644); await chmod(keyFile, 0o600);
|
||||
const config = await postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: urlFile, WIKIDECK_CUSTODY_PG_CA_FILE: caFile });
|
||||
assert.equal((config.ssl as { rejectUnauthorized: boolean }).rejectUnauthorized, true);
|
||||
assert.ok((config.ssl as { ca: string }).ca.includes('BEGIN CERTIFICATE'));
|
||||
await chmod(urlFile, 0o644);
|
||||
await assert.rejects(postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: urlFile, WIKIDECK_CUSTODY_PG_CA_FILE: caFile }), /configuration/);
|
||||
await chmod(urlFile, 0o600);
|
||||
await writeFile(caFile, 'not a certificate', { mode: 0o644 }); await chmod(caFile, 0o644);
|
||||
await assert.rejects(postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: urlFile, WIKIDECK_CUSTODY_PG_CA_FILE: caFile }), /configuration/);
|
||||
});
|
||||
|
||||
test('infrastructure-managed transport requires exact opt-in and rejects mixed TLS/test configuration', async t => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wikideck-infrastructure-'));
|
||||
t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
const file = join(dir, 'connection');
|
||||
await writeFile(file, 'postgresql://fixture:fixture-only@database.cluster.example/custody', { mode: 0o600 });
|
||||
const env = { WIKIDECK_CUSTODY_PG_URL_FILE: file, WIKIDECK_CUSTODY_PG_TRANSPORT: 'infrastructure' };
|
||||
await assert.rejects(postgresConfig(env), /configuration/);
|
||||
for (const ack of ['', 'true', 'firewall', 'encrypted-authenticated-path-enforced ']) {
|
||||
await assert.rejects(postgresConfig({ ...env, WIKIDECK_CUSTODY_PG_INFRASTRUCTURE_ACK: ack }), /configuration/);
|
||||
}
|
||||
const approved = { ...env, WIKIDECK_CUSTODY_PG_INFRASTRUCTURE_ACK: 'encrypted-authenticated-path-enforced' };
|
||||
assert.equal((await postgresConfig(approved)).ssl, false);
|
||||
for (const conflict of [{ WIKIDECK_CUSTODY_PG_CA_FILE: file }, { WIKIDECK_CUSTODY_PG_LOCAL_TEST: '1' }]) {
|
||||
await assert.rejects(postgresConfig({ ...approved, ...conflict }), /configuration/);
|
||||
}
|
||||
await assert.rejects(postgresConfig({ ...approved, WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls' }), /configuration/);
|
||||
await writeFile(file, 'postgresql://fixture@database.cluster.example/custody');
|
||||
await assert.rejects(postgresConfig(approved), /configuration/); // Password auth remains mandatory.
|
||||
await writeFile(file, 'postgresql://fixture:fixture-only@database.cluster.example/custody?sslmode=require');
|
||||
await assert.rejects(postgresConfig(approved), /configuration/);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,16 +4,18 @@ import { readFile } from 'node:fs/promises';
|
||||
|
||||
test('project overview focuses on mining and player-to-player card trades',async()=>{
|
||||
const readme=await readFile(new URL('../README.md',import.meta.url),'utf8');
|
||||
assert.match(readme,/mining a block rewards the miner with a collectible card based on a Wikipedia page/);
|
||||
assert.match(readme,/Mine a block with the native RandomX CPU miner to earn a card/);
|
||||
assert.match(readme,/algorithm also used by Monero/);
|
||||
assert.match(readme,/consumer CPUs competitive/);
|
||||
assert.match(readme,/trade cards directly with another player/);
|
||||
assert.match(readme,/not part of the default card-trading experience/);
|
||||
assert.match(readme,/WIKIDECK_MARKET_RUNTIME_ENABLED=1/);
|
||||
assert.match(readme,/docs\/card-only-production-plan\.md/);
|
||||
assert.doesNotMatch(readme,/## Peer ownership and customization/);
|
||||
assert.match(readme,/does not yet contain a project LICENSE file/);
|
||||
for(const name of ['../compose.yaml','../compose.testnet.yaml'])
|
||||
assert.match(await readFile(new URL(name,import.meta.url),'utf8'),/WIKIDECK_MARKET_RUNTIME_ENABLED:.*:-0/);
|
||||
const plan=await readFile(new URL('../docs/card-only-production-plan.md',import.meta.url),'utf8');
|
||||
assert.match(plan,/Markets and real money/);
|
||||
assert.match(plan,/Mainnet or valuable cards/);
|
||||
for(const name of ['../compose.yaml','../compose.testnet.yaml','../compose.mainnet.yaml'])
|
||||
assert.doesNotMatch(await readFile(new URL(name,import.meta.url),'utf8'),/MARKET|CUSTODY|PAYMENT_MODE/);
|
||||
});
|
||||
test('homepage presents mining, collections, and direct card trading without marketplace UI',async()=>{
|
||||
const app=await readFile(new URL('../src/App.tsx',import.meta.url),'utf8');
|
||||
@@ -22,28 +24,7 @@ test('homepage presents mining, collections, and direct card trading without mar
|
||||
assert.match(app,/designed to resist specialized ASICs/);
|
||||
assert.match(app,/ASIC resistance is a design goal, not a guarantee/);
|
||||
assert.match(app,/Trade cards directly with another collector/);
|
||||
assert.doesNotMatch(app,/Marketplace|Custody preview|fixed-price listings/);
|
||||
assert.doesNotMatch(app,/marketplace|custody preview|fixed-price listings/i);
|
||||
assert.match(app,/<details className="mining-details"><summary>Want to run a miner\? Setup & safety details/);
|
||||
assert.match(app,/--consent/); // Documentation-only change: runnable miner invocation keeps its existing required flag.
|
||||
});
|
||||
test('market transparency explains real controls without promising escrow, refunds or live payments',async()=>{
|
||||
const guide=await readFile(new URL('../docs/market-transparency.md',import.meta.url),'utf8');
|
||||
for(const required of [
|
||||
/does not process real cryptocurrency payments/,
|
||||
/Card delivery is real/,
|
||||
/Listing is not escrow/,
|
||||
/That would be custodial/,
|
||||
/30 minutes after auction close/,
|
||||
/48 hours after mock payment/,
|
||||
/0\.096500000000 XMR/,
|
||||
/cannot revoke a signed NFT transfer/,
|
||||
/no built-in arbitration service/,
|
||||
/the hosting operator and anyone with its database\/backups can read orders/,
|
||||
/no live payment integration or supported setting/,
|
||||
])assert.match(guide,required);
|
||||
for(const name of ['../README.md','../docs/marketplace.md']){
|
||||
assert.match(await readFile(new URL(name,import.meta.url),'utf8'),/market-transparency\.md/);
|
||||
}
|
||||
// Resolve every workspace-relative Markdown link in the new guide, catching missing documentation.
|
||||
for(const match of guide.matchAll(/\]\(([^)]+\.md)(?:#[^)]*)?\)/g))await readFile(new URL(`../docs/${match[1]}`,import.meta.url),'utf8');
|
||||
});
|
||||
|
||||
+37
-12
@@ -1,25 +1,26 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp,rm,writeFile,readFile,mkdir,access } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp,rm,writeFile,readFile,mkdir,access,stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { Ledger } from '../server/testnet/ledger.ts';
|
||||
import { Market } from '../server/market/store.ts';
|
||||
import { NETWORK } from '../shared/network.ts';
|
||||
import { CHAIN_ID } from '../server/testnet/consensus.ts';
|
||||
import { createBackup,verifyBackup,restoreBackup } from '../server/recovery.ts';
|
||||
|
||||
test('stopped-node pair backup restores journal identity and replays ledger in isolated destination',async t=>{
|
||||
test('stopped-node backup snapshots only the ledger and replays it in an isolated destination',async t=>{
|
||||
const root=await mkdtemp(join(tmpdir(),'wikideck-recovery-'));t.after(()=>rm(root,{recursive:true,force:true}));
|
||||
const source=join(root,'source');
|
||||
const pow={hash:async()=>{throw new Error('Empty ledger should require no proof work');},close(){}};
|
||||
const ledger=await Ledger.open(source,pow),market=await Market.open(source,ledger);
|
||||
const id=market.id,snapshot=ledger.snapshot();market.close();ledger.close();
|
||||
const ledger=await Ledger.open(source,pow),snapshot=ledger.snapshot();ledger.close();
|
||||
const backup=join(root,'backup');const manifest=await createBackup(source,backup);
|
||||
assert.equal(manifest.files.length,2);assert.deepEqual(await verifyBackup(backup),manifest);
|
||||
assert.equal(manifest.version,2);assert.deepEqual(manifest.files.map(file=>file.name),['ledger.sqlite']);
|
||||
assert.deepEqual(await verifyBackup(backup),manifest);
|
||||
const dest=join(root,'restore');assert.equal((await restoreBackup(backup,dest)).proofsRevalidated,false);
|
||||
const restored=await Ledger.open(dest,pow),journal=await Market.open(dest,restored);
|
||||
assert.deepEqual(restored.snapshot(),snapshot);assert.equal(journal.id,id);
|
||||
journal.close();restored.close();
|
||||
const restored=await Ledger.open(dest,pow);
|
||||
assert.deepEqual(restored.snapshot(),snapshot);restored.close();
|
||||
await assert.rejects(restoreBackup(backup,dest),/EEXIST/);
|
||||
await assert.rejects(createBackup(source,backup),/EEXIST/);
|
||||
await assert.rejects(createBackup(source,join(source,'inside')),/outside/);
|
||||
@@ -28,16 +29,40 @@ test('stopped-node pair backup restores journal identity and replays ledger in i
|
||||
assert.equal(await readFile(join(source,'testnet-node.lock'),'utf8'),'simulated-running');
|
||||
});
|
||||
|
||||
test('new ledger backup refuses to omit legacy journals, and old paired backups remain portable',async t=>{
|
||||
const root=await mkdtemp(join(tmpdir(),'wikideck-recovery-legacy-'));t.after(()=>rm(root,{recursive:true,force:true}));
|
||||
const source=join(root,'source'),ledger=await Ledger.open(source,{hash:async()=>'',close(){}});ledger.close();
|
||||
const legacyFile=join(source,'testnet','market.sqlite');
|
||||
const legacyDb=new DatabaseSync(legacyFile);legacyDb.exec('CREATE TABLE legacy_archive(value TEXT)');legacyDb.close();
|
||||
await assert.rejects(createBackup(source,join(root,'blocked-backup')),/Legacy financial data/);
|
||||
|
||||
const backup=join(root,'legacy-backup');await mkdir(backup);
|
||||
const ledgerCopy=join(backup,'ledger.sqlite');
|
||||
const ledgerDb=new DatabaseSync(join(source,'testnet','ledger.sqlite'),{readOnly:true});
|
||||
try{ledgerDb.prepare('VACUUM INTO ?').run(ledgerCopy);}finally{ledgerDb.close();}
|
||||
const marketCopy=join(backup,'market.sqlite');
|
||||
const marketDb=new DatabaseSync(legacyFile,{readOnly:true});
|
||||
try{marketDb.prepare('VACUUM INTO ?').run(marketCopy);}finally{marketDb.close();}
|
||||
const fileInfo=async(name:string,path:string)=>({name,bytes:(await stat(path)).size,
|
||||
sha256:createHash('sha256').update(await readFile(path)).digest('hex')});
|
||||
const oldManifest={format:'wikideck-stopped-backup',version:1,network:NETWORK,chainId:CHAIN_ID,
|
||||
profile:'testnet',createdAt:new Date().toISOString(),files:[await fileInfo('ledger.sqlite',ledgerCopy),await fileInfo('market.sqlite',marketCopy)]};
|
||||
await writeFile(join(backup,'manifest.json'),JSON.stringify(oldManifest));
|
||||
assert.equal((await verifyBackup(backup)).version,1);
|
||||
const restored=join(root,'legacy-restore');await restoreBackup(backup,restored);
|
||||
assert.equal((await stat(join(restored,'testnet','market.sqlite'))).size,(await stat(marketCopy)).size);
|
||||
});
|
||||
|
||||
test('backup verifier rejects tampering, sidecars, network mismatch and incomplete packages',async t=>{
|
||||
const root=await mkdtemp(join(tmpdir(),'wikideck-recovery-bad-'));t.after(()=>rm(root,{recursive:true,force:true}));
|
||||
const source=join(root,'source'),ledger=await Ledger.open(source,{hash:async()=>'',close(){}}),market=await Market.open(source,ledger);
|
||||
market.close();ledger.close();const backup=join(root,'backup');await createBackup(source,backup);
|
||||
const source=join(root,'source'),ledger=await Ledger.open(source,{hash:async()=>'',close(){}});
|
||||
ledger.close();const backup=join(root,'backup');await createBackup(source,backup);
|
||||
const original=await readFile(join(backup,'manifest.json'),'utf8');
|
||||
await writeFile(join(backup,'ledger.sqlite-wal'),'unexpected');await assert.rejects(verifyBackup(backup),/sidecar/);
|
||||
await rm(join(backup,'ledger.sqlite-wal'));
|
||||
await writeFile(join(backup,'manifest.json'),original.replace('wikideck-randomx-testnet-6','wrong-network'));await assert.rejects(verifyBackup(backup));
|
||||
await writeFile(join(backup,'manifest.json'),original);
|
||||
const db=new DatabaseSync(join(backup,'market.sqlite'));db.exec("UPDATE market_meta SET value='tampered' WHERE key='identity'");db.close();
|
||||
await writeFile(join(backup,'ledger.sqlite'),Buffer.concat([await readFile(join(backup,'ledger.sqlite')),Buffer.from('tampered')]));
|
||||
await assert.rejects(verifyBackup(backup),/hash\/size/);
|
||||
const missing=join(root,'incomplete');await mkdir(missing);await assert.rejects(verifyBackup(missing));
|
||||
await assert.rejects(access(join(source,'testnet-node.lock'))); // maintenance lock released, not silently left behind
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
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 { ADDITIONAL_MARKET_FEE } from '../shared/market.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('candidate host recipient extension is signed and omission preserves historical terms bytes', () => {
|
||||
const legacy = fixture(), t = legacy.terms;
|
||||
assert.equal(saleTermsMessage(t), JSON.stringify([SALE_CANDIDATE, 'terms', t.network, t.chainId, t.policyDigest,
|
||||
t.custodianId, t.orderId, t.cardHash, t.sequence, t.seller, t.buyer, t.host, 'XMR', t.priceAtomicXmr,
|
||||
t.feeBps, ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address, t.lockUntilHeight, t.expiresHeight]));
|
||||
const custom = fixture({ hostFeeAddress: '8'.repeat(95) }); // Syntax-only candidate, no wallet/RPC claim.
|
||||
assert.notEqual(saleId(custom.terms), saleId({ ...custom.terms, hostFeeAddress: undefined }));
|
||||
assert.equal(apply(custom.state, [custom.lock]).sales[saleId(custom.terms)].terms.hostFeeAddress, '8'.repeat(95));
|
||||
assert.throws(() => apply(custom.state, [{ ...custom.lock, terms: { ...custom.terms, hostFeeAddress: ADDITIONAL_MARKET_FEE.address } }]), /both trader/);
|
||||
assert.throws(() => apply(custom.state, [{ ...custom.lock, terms: { ...custom.terms, hostFeeAddress: undefined } }]), /both trader/);
|
||||
});
|
||||
|
||||
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/);
|
||||
assert.throws(() => termsForCustodyOrder({ ...order, hostFeeAddress: 'different-recipient' }, context, options));
|
||||
});
|
||||
|
||||
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); }
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MAX_XMR_AMOUNT, XMR_ATOMIC_UNITS, XMR_RPC_UINT64_MAX, parseXmr, formatXmr,
|
||||
parseAtomicXmr, atomicXmrString, assertXmrRpcAmount, xmrCommission } from '../shared/xmr-amount.ts';
|
||||
import { allocateXmrSale, assessXmrSaleBudget } from '../shared/xmr-pricing.ts';
|
||||
import { atomicXmr, fiatCents } from '../server/market/xmr.ts';
|
||||
import { xmrString, mockQuote } from '../server/market/payments.ts';
|
||||
|
||||
test('exact XMR decimal input preserves legacy bounds and canonicalizes without floats', () => {
|
||||
for (const [input, expected] of [['0', 0n], ['0.000000000001', 1n], ['1.2', 1_200_000_000_000n],
|
||||
['9999999999.999999999999', MAX_XMR_AMOUNT]] as const) {
|
||||
assert.equal(parseXmr(input), expected);
|
||||
assert.equal(atomicXmr(input), expected);
|
||||
assert.equal(parseXmr(formatXmr(expected)), expected);
|
||||
assert.equal(xmrString(expected), formatXmr(expected));
|
||||
}
|
||||
assert.equal(formatXmr(0n), '0.000000000000');
|
||||
assert.equal(formatXmr(1n), '0.000000000001');
|
||||
assert.equal(formatXmr(XMR_ATOMIC_UNITS), '1.000000000000');
|
||||
for (const bad of ['', ' ', ' 1', '1 ', '\n1', '1\n', '+1', '-0', '-1', '01', '.1', '1.',
|
||||
'1e-12', '1_000', '1,000', 'NaN', 'Infinity', '12', '0.0000000000001', '10000000000',
|
||||
'9'.repeat(100_000), 1, 1n, null, {}, ['1']] as unknown[]) {
|
||||
assert.throws(() => parseXmr(bad as string));
|
||||
assert.throws(() => atomicXmr(bad as string));
|
||||
}
|
||||
});
|
||||
|
||||
test('atomic wire strings reject alternate spellings, coercion and unsafe numbers', () => {
|
||||
for (const value of [0n, 1n, BigInt(Number.MAX_SAFE_INTEGER) + 2n, XMR_RPC_UINT64_MAX, MAX_XMR_AMOUNT]) {
|
||||
const wire = atomicXmrString(value);
|
||||
assert.equal(parseAtomicXmr(JSON.parse(JSON.stringify(wire))), value);
|
||||
}
|
||||
for (const value of ['', '00', '01', '+1', '-1', '1.0', '1e12', '1\n', ' 1',
|
||||
(MAX_XMR_AMOUNT + 1n).toString(), '9'.repeat(100_000), 1, null, {}]) {
|
||||
assert.throws(() => parseAtomicXmr(value as string));
|
||||
}
|
||||
for (const bad of [-1n, MAX_XMR_AMOUNT + 1n, 1, null] as unknown[]) {
|
||||
assert.throws(() => formatXmr(bad as bigint));
|
||||
assert.throws(() => atomicXmrString(bad as bigint));
|
||||
}
|
||||
assert.doesNotThrow(() => assertXmrRpcAmount(XMR_RPC_UINT64_MAX));
|
||||
assert.throws(() => assertXmrRpcAmount(XMR_RPC_UINT64_MAX + 1n));
|
||||
});
|
||||
|
||||
test('legacy processor USD parser also requires absolute end-of-input', () => {
|
||||
assert.equal(fiatCents('0'), 0);
|
||||
assert.equal(fiatCents('1.2'), 120);
|
||||
assert.equal(fiatCents('9999999.99'), 999999999);
|
||||
for (const value of ['1\n', '1\r', '1\r\n', '1\u2028', '1\u2029', '1.00\n', '01', '1e2',
|
||||
'10000000', '0.001', '9'.repeat(100_000), 1, null]) {
|
||||
assert.throws(() => fiatCents(value as string));
|
||||
}
|
||||
});
|
||||
|
||||
test('tiny prices are representable and commissions round independently without a minimum charge', () => {
|
||||
assert.deepEqual(allocateXmrSale(1n, 5000), { gross: 1n, hosting: 0n, author: 0n, sellerBeforeNetworkCosts: 1n });
|
||||
assert.equal(allocateXmrSale(99n, 0).author, 0n);
|
||||
assert.equal(allocateXmrSale(100n, 0).author, 1n);
|
||||
assert.deepEqual(allocateXmrSale(199n, 250), { gross: 199n, hosting: 4n, author: 1n, sellerBeforeNetworkCosts: 194n });
|
||||
assert.throws(() => allocateXmrSale(0n, 0));
|
||||
for (const bad of [-1, 5001, 0.5, NaN, Infinity, '100', null] as unknown[]) {
|
||||
assert.throws(() => allocateXmrSale(1n, bad as number));
|
||||
}
|
||||
for (const bad of [-1, 10001, 0.5, NaN]) assert.throws(() => xmrCommission(1n, bad));
|
||||
});
|
||||
|
||||
test('seller network budget preflight distinguishes positive net, exact exhaustion and fee spikes', () => {
|
||||
const gross = parseXmr('1'), available = allocateXmrSale(gross, 250).sellerBeforeNetworkCosts;
|
||||
const good = assessXmrSaleBudget(gross, 250, available - 1n);
|
||||
assert.equal(good.viable, true); assert.equal(good.sellerMinimumNet, 1n); assert.equal(good.fundingShortfall, 0n);
|
||||
for (const extra of [0n, 1n, 1_000_000n]) {
|
||||
const blocked = assessXmrSaleBudget(gross, 250, available + extra);
|
||||
assert.equal(blocked.viable, false); assert.equal(blocked.sellerMinimumNet, 0n);
|
||||
assert.equal(blocked.fundingShortfall, extra + 1n);
|
||||
}
|
||||
assert.equal(assessXmrSaleBudget(1n, 0, 0n).viable, true);
|
||||
assert.equal(assessXmrSaleBudget(1n, 0, 1n).viable, false);
|
||||
assert.throws(() => assessXmrSaleBudget(gross, 0, -1n));
|
||||
assert.throws(() => assessXmrSaleBudget(gross, 0, MAX_XMR_AMOUNT + 1n));
|
||||
});
|
||||
|
||||
test('deterministic generated amounts conserve value, bound rounding and round-trip exactly', () => {
|
||||
let seed = 0x584d52n;
|
||||
for (let i = 0; i < 4000; i++) {
|
||||
seed = (seed * 6364136223846793005n + 1442695040888963407n) % (1n << 96n);
|
||||
const gross = seed % MAX_XMR_AMOUNT + 1n, bps = i % 5001;
|
||||
assert.equal(parseXmr(formatXmr(gross)), gross);
|
||||
assert.equal(parseAtomicXmr(atomicXmrString(gross)), gross);
|
||||
const a = allocateXmrSale(gross, bps);
|
||||
assert.equal(a.hosting + a.author + a.sellerBeforeNetworkCosts, gross);
|
||||
for (const [fee, rate] of [[a.hosting, BigInt(bps)], [a.author, 100n]]) {
|
||||
assert.ok(fee * 10000n <= gross * rate);
|
||||
assert.ok((fee + 1n) * 10000n > gross * rate);
|
||||
}
|
||||
const budget = seed % (a.sellerBeforeNetworkCosts + 2n);
|
||||
const q = assessXmrSaleBudget(gross, bps, budget);
|
||||
assert.equal(q.viable, a.sellerBeforeNetworkCosts > budget);
|
||||
if (q.viable) assert.equal(q.hosting + q.author + budget + q.sellerMinimumNet, gross);
|
||||
}
|
||||
});
|
||||
|
||||
test('mock USD caller retains its original independently rounded amounts', () => {
|
||||
for (const cents of [1, 19, 1500, 100_000_000]) for (const bps of [0, 250, 5000]) {
|
||||
const quote = mockQuote(cents, bps, 19999);
|
||||
const gross = (BigInt(cents) * XMR_ATOMIC_UNITS + 19998n) / 19999n;
|
||||
assert.equal(parseXmr(quote.invoice.amountXmr), gross);
|
||||
assert.equal(parseXmr(quote.feeXmr), gross * BigInt(bps) / 10000n);
|
||||
assert.equal(parseXmr(quote.additionalFee.amountXmr), gross / 100n);
|
||||
assert.equal(parseXmr(quote.sellerNetXmr), gross - gross * BigInt(bps) / 10000n - gross / 100n);
|
||||
assert.equal(quote.invoice.address, null);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user