Remove markets
This commit is contained in:
+8
-245
@@ -1,13 +1,11 @@
|
||||
import express from 'express';
|
||||
import { Pool } from 'pg';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdir, open, unlink } from 'node:fs/promises';
|
||||
import { z } from 'zod';
|
||||
import { NETWORK, CONSENSUS } from '../../shared/testnet.ts';
|
||||
import { transactionCardIds } from '../../shared/testnet.ts';
|
||||
import { NETWORK } from '../../shared/testnet.ts';
|
||||
import { CHAIN_ID, blockSchema, tradeSchema, transferSchema, transactionSchema } from './consensus.ts';
|
||||
import { RandomX } from './randomx.ts';
|
||||
import { Ledger } from './ledger.ts';
|
||||
@@ -15,66 +13,13 @@ import { PeerNetwork } from './peers.ts';
|
||||
import { wikipediaEnrichment } from '../enrichment.ts';
|
||||
import { snapshotStats } from '../../shared/enrichment.ts';
|
||||
import { wikipediaArticles } from '../wiki.ts';
|
||||
import { Market, marketConfig } from '../market/store.ts';
|
||||
import { paymentMode } from '../market/payments.ts';
|
||||
import { marketCommandSchema } from '../../shared/market.ts';
|
||||
import { marketCommandV3Schema } from '../../shared/market-v3.ts';
|
||||
import { custodyCommandSchema } from '../../shared/custody.ts';
|
||||
import { moneroDepositAddressRequestSchema } from '../../shared/monero-deposit.ts';
|
||||
import { mainnetCustodyBalanceRequestSchema, mainnetWithdrawalRequestSchema,
|
||||
mainnetWithdrawalCancellationSchema } from '../../shared/monero-withdrawal.ts';
|
||||
import { CustodyMarket, CustodyError, custodyMode } from '../market/custody.ts';
|
||||
import { openCustodyStorage } from '../market/custody-storage.ts';
|
||||
import { PostgresCustody } from '../market/postgres-custody.ts';
|
||||
import { CanonicalObserver } from '../market/canonical-observer.ts';
|
||||
import { enrollMoneroDepositAddress, type MainnetEnrollmentContext } from '../market/monero-deposit-enrollment.ts';
|
||||
import { cancelMainnetWithdrawal, readMainnetCustodyBalance, reserveMainnetWithdrawal } from '../market/mainnet-withdrawal.ts';
|
||||
import { verifyMainnetCustodyIdentity } from '../market/mainnet-custody-identity.ts';
|
||||
import { createMoneroDepositAddressRpc, loadMoneroObserverConfig } from '../market/monero-observer.ts';
|
||||
import { loadMoneroMtlsTransport, loadMoneroMtlsTransportConfig } from '../market/monero-mtls-transport.ts';
|
||||
import { postgresConfig } from '../market/postgres-config.ts';
|
||||
import { validateMarketPolicy } from '../market/policy.ts';
|
||||
import { IS_MAINNET, IS_PILOT, PROFILE } from '../../shared/network.ts';
|
||||
import { IS_MAINNET, PROFILE } from '../../shared/network.ts';
|
||||
import { assertLaunchAllowed, launchManifest } from '../launch.ts';
|
||||
import { resourceConfig, RpcBudget, RpcError } from '../resources.ts';
|
||||
import { trustedProxies, operationsPort, startOperations } from '../operations.ts';
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
assertLaunchAllowed();
|
||||
const marketRuntimeSetting = process.env.WIKIDECK_MARKET_RUNTIME_ENABLED ?? '0';
|
||||
if (marketRuntimeSetting !== '0' && marketRuntimeSetting !== '1') {
|
||||
throw new Error('WIKIDECK_MARKET_RUNTIME_ENABLED must be exactly 0 or 1');
|
||||
}
|
||||
const marketRuntimeEnabled = marketRuntimeSetting === '1';
|
||||
const custodyConfig = custodyMode(); // Explicit opt-in sandbox only. Unknown/live modes fail before opening resources.
|
||||
paymentMode(); // Keep real-payment modes fail-closed even when the optional market runtime is off.
|
||||
let paymentConfig: ReturnType<typeof marketConfig> | undefined;
|
||||
if (marketRuntimeEnabled || custodyConfig === 'sandbox') {
|
||||
paymentConfig = marketConfig();
|
||||
await validateMarketPolicy(paymentConfig); // Reject invalid recipient checksum/network before opening node resources.
|
||||
}
|
||||
const canonicalObserverMode = process.env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL;
|
||||
if (canonicalObserverMode !== undefined && canonicalObserverMode !== '0' && canonicalObserverMode !== '1') {
|
||||
throw new Error('WIKIDECK_CUSTODY_CANONICAL_REHEARSAL must be exactly 0 or 1');
|
||||
}
|
||||
const depositEnrollmentMode = process.env.WIKIDECK_MONERO_DEPOSIT_ENROLLMENT ?? '0';
|
||||
if (depositEnrollmentMode !== '0' && depositEnrollmentMode !== '1') {
|
||||
throw new Error('WIKIDECK_MONERO_DEPOSIT_ENROLLMENT must be exactly 0 or 1');
|
||||
}
|
||||
if (depositEnrollmentMode === '1' && !IS_MAINNET) {
|
||||
throw new Error('Monero deposit enrollment requires the mainnet profile');
|
||||
}
|
||||
const withdrawalRequestMode = process.env.WIKIDECK_MONERO_WITHDRAWAL_REQUESTS ?? '0';
|
||||
if (withdrawalRequestMode !== '0' && withdrawalRequestMode !== '1') {
|
||||
throw new Error('WIKIDECK_MONERO_WITHDRAWAL_REQUESTS must be exactly 0 or 1');
|
||||
}
|
||||
if (withdrawalRequestMode === '1' && !IS_MAINNET) {
|
||||
throw new Error('Monero withdrawal requests require the mainnet profile');
|
||||
}
|
||||
if (canonicalObserverMode === '1' && (process.env.NODE_ENV === 'production' || custodyConfig !== 'sandbox' ||
|
||||
process.env.WIKIDECK_CUSTODY_STORAGE !== 'postgres' || process.env.WIKIDECK_PG_DISPOSABLE_TEST !== '1')) {
|
||||
throw new Error('Canonical observer rehearsal requires sandbox custody, PostgreSQL, and disposable test isolation');
|
||||
}
|
||||
const limits = resourceConfig();
|
||||
const proxyTrust=trustedProxies();
|
||||
const opsPort=operationsPort();
|
||||
@@ -96,68 +41,6 @@ let peers: PeerNetwork;
|
||||
try { peers = new PeerNetwork({ ledger, urls:(process.env.WIKIDECK_PEERS ?? '').split(',').map(s=>s.trim()).filter(Boolean),
|
||||
allowPrivate:process.env.WIKIDECK_ALLOW_PRIVATE_PEERS === '1' }); }
|
||||
catch(e) {ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
|
||||
let market: Market | undefined;
|
||||
try { if (marketRuntimeEnabled) market = await Market.open(dataDir, ledger, paymentConfig!, Date.now, { worker: true }); }
|
||||
catch(e) {peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
|
||||
let custody: CustodyMarket | PostgresCustody | undefined;
|
||||
let custodyObserver: CanonicalObserver | undefined;
|
||||
let moneroDepositPool: Pool | undefined;
|
||||
let moneroDepositRpc: ReturnType<typeof createMoneroDepositAddressRpc> | undefined;
|
||||
let moneroDepositContext: MainnetEnrollmentContext | undefined;
|
||||
let moneroWithdrawalPool: Pool | undefined;
|
||||
let moneroWithdrawalCustodianId: string | undefined;
|
||||
try {
|
||||
if (custodyConfig === 'sandbox') custody = await openCustodyStorage(dataDir, { ...paymentConfig!,
|
||||
card: (hash, owner, sequence) => {
|
||||
const nft = ledger.nft(hash);
|
||||
if (!nft || nft.owner !== owner || nft.confirmations < CONSENSUS.coinbaseMaturity || nft.sequence !== sequence ||
|
||||
ledger.pendingTransactions().some(tx => transactionCardIds(tx).includes(hash))) return undefined;
|
||||
return { title: nft.block.article.title };
|
||||
} });
|
||||
if (canonicalObserverMode === '1') {
|
||||
if (!(custody instanceof PostgresCustody)) throw new Error('Canonical observer rehearsal requires PostgreSQL custody');
|
||||
custodyObserver = new CanonicalObserver(custody, ledger);
|
||||
if ((await custodyObserver.start()).state !== 'running') throw new Error('Canonical observer rehearsal failed to start; inspect custody diagnostics');
|
||||
}
|
||||
if (depositEnrollmentMode === '1') {
|
||||
const custodianId = z.string().uuid().parse(process.env.WIKIDECK_MONERO_CUSTODIAN_ID);
|
||||
const walletGenerationId = z.string().uuid().parse(process.env.WIKIDECK_MONERO_WALLET_GENERATION_ID);
|
||||
const expectedFingerprint = z.string().regex(/^[a-f0-9]{64}$/).parse(process.env.WIKIDECK_MONERO_WALLET_FINGERPRINT);
|
||||
const accountText = process.env.WIKIDECK_MONERO_DEPOSIT_ACCOUNT_INDEX;
|
||||
if (!accountText || !/^(0|[1-9]\d{0,9})$/.test(accountText) || Number(accountText) > 0xffff_ffff) {
|
||||
throw new Error('Invalid configured Monero deposit account index');
|
||||
}
|
||||
const observerConfig = await loadMoneroObserverConfig({ ...process.env, WIKIDECK_MONERO_OBSERVER: '1' });
|
||||
const mtlsConfig = await loadMoneroMtlsTransportConfig();
|
||||
if (new URL(observerConfig.url).href !== new URL(mtlsConfig.url).href) {
|
||||
throw new Error('Monero observer and mTLS gateway endpoints do not match');
|
||||
}
|
||||
const transport = await loadMoneroMtlsTransport(mtlsConfig);
|
||||
moneroDepositRpc = createMoneroDepositAddressRpc(observerConfig, transport);
|
||||
const actualFingerprint = await moneroDepositRpc.verifyWallet();
|
||||
if (actualFingerprint !== expectedFingerprint) throw new Error('Configured Monero wallet fingerprint does not match the protected wallet');
|
||||
const allocatorUrlFile = process.env.WIKIDECK_MONERO_ALLOCATOR_PG_URL_FILE;
|
||||
const allocatorCaFile = process.env.WIKIDECK_MONERO_ALLOCATOR_PG_CA_FILE;
|
||||
if (!allocatorUrlFile || !allocatorCaFile) throw new Error('Monero deposit allocator PostgreSQL URL and CA files are required');
|
||||
moneroDepositPool = new Pool(await postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: allocatorUrlFile,
|
||||
WIKIDECK_CUSTODY_PG_CA_FILE: allocatorCaFile, WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls' }));
|
||||
moneroDepositPool.on('error', () => console.error('Monero deposit allocator database unavailable.'));
|
||||
await moneroDepositPool.query('SELECT 1 FROM wk_custody.monero_deposit_allocations LIMIT 0');
|
||||
moneroDepositContext = { custodianId, chainId: CHAIN_ID, walletGenerationId, walletFingerprint: actualFingerprint,
|
||||
accountIndex: Number(accountText) };
|
||||
}
|
||||
if (withdrawalRequestMode === '1') {
|
||||
moneroWithdrawalCustodianId = z.string().uuid().parse(process.env.WIKIDECK_MONERO_WITHDRAWAL_CUSTODIAN_ID);
|
||||
const urlFile = process.env.WIKIDECK_MONERO_WITHDRAWAL_PG_URL_FILE;
|
||||
const caFile = process.env.WIKIDECK_MONERO_WITHDRAWAL_PG_CA_FILE;
|
||||
if (!urlFile || !caFile) throw new Error('Monero withdrawal request PostgreSQL URL and CA files are required');
|
||||
moneroWithdrawalPool = new Pool(await postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: urlFile,
|
||||
WIKIDECK_CUSTODY_PG_CA_FILE: caFile, WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls' }));
|
||||
moneroWithdrawalPool.on('error', () => console.error('Monero withdrawal request database unavailable.'));
|
||||
await verifyMainnetCustodyIdentity(moneroWithdrawalPool, moneroWithdrawalCustodianId);
|
||||
await moneroWithdrawalPool.query('SELECT 1 FROM wk_custody.monero_withdrawal_intents LIMIT 0');
|
||||
}
|
||||
} catch(e) {await moneroWithdrawalPool?.end();await moneroDepositPool?.end();await custodyObserver?.close();await custody?.close();await market?.close();peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
if(proxyTrust.length)app.set('trust proxy',proxyTrust);
|
||||
@@ -191,93 +74,6 @@ app.get('/api/canonical',(req,res)=>{
|
||||
app.get('/api/network',(_req,res)=>res.json(launchManifest()));
|
||||
app.get('/api/chain',(_req,res)=>res.json(ledger.legacySnapshot(limits.legacyBlocks)));
|
||||
app.get('/api/peers',(_req,res)=>res.json({peers:peers.status()}));
|
||||
if (market) {
|
||||
app.get('/api/market', (req,res)=>res.json(market!.browse(Number(req.query.offset ?? 0))));
|
||||
app.post('/api/market/command',rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}),async(req,res)=>{
|
||||
const current = marketCommandV3Schema.safeParse(req.body);
|
||||
const command = current.success ? current.data : marketCommandSchema.parse(req.body);
|
||||
try {
|
||||
const result = await market!.execute(command);
|
||||
res.json(result);
|
||||
if(command.action.type==='deliver')void peers.broadcastTransfer(command.action.transfer).catch(()=>{});
|
||||
} catch(error) {
|
||||
if(error instanceof RpcError)throw error;
|
||||
// Marketplace storage/payment internals must never leak through the generic node error handler.
|
||||
if(error instanceof Error && !/SQLITE|constraint|database/i.test(error.message))res.status(400).json({error:error.message.slice(0,200)});
|
||||
else res.status(409).json({error:'Marketplace state conflict. Refresh and retry.'});
|
||||
}
|
||||
});
|
||||
}
|
||||
app.get('/api/custody', async (req,res)=> {
|
||||
if (!custody) { res.json({ enabled: false }); return; }
|
||||
try { res.json(await custody.browse(Number(req.query.offset ?? 0))); }
|
||||
catch (error) {
|
||||
if (error instanceof CustodyError) res.status(400).json({ error: error.message });
|
||||
else res.status(503).json({ error: 'Custody sandbox unavailable; operator review required.' });
|
||||
}
|
||||
});
|
||||
app.post('/api/custody/command', rateLimit({windowMs:60000,limit:30,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res)=> {
|
||||
if (!custody) { res.status(503).json({ error: 'Custody sandbox is disabled on this host.' }); return; }
|
||||
const parsed = custodyCommandSchema.safeParse(req.body);
|
||||
if (!parsed.success) { res.status(400).json({ error: 'Invalid custody command schema.' }); return; }
|
||||
try { res.json(await custody.execute(parsed.data)); }
|
||||
catch (error) {
|
||||
if (error instanceof CustodyError) res.status(400).json({ error: error.message });
|
||||
else res.status(503).json({ error: 'Custody state conflict; refresh activity before retrying. Operator review may be required.' });
|
||||
}
|
||||
});
|
||||
if (depositEnrollmentMode === '1' && moneroDepositContext && moneroDepositRpc && moneroDepositPool) {
|
||||
app.get('/api/monero/deposit-config', (_req,res) => {
|
||||
res.json({ network: 'wikideck-randomx-mainnet-1', chainId: moneroDepositContext!.chainId,
|
||||
custodianId: moneroDepositContext!.custodianId, walletGenerationId: moneroDepositContext!.walletGenerationId,
|
||||
walletFingerprint: moneroDepositContext!.walletFingerprint, accountIndex: moneroDepositContext!.accountIndex,
|
||||
addressEnrollmentEnabled: true, depositCreditEnabled: false, withdrawalsEnabled: false });
|
||||
});
|
||||
app.post('/api/monero/deposit-address', rateLimit({windowMs:60000,limit:5,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res) => {
|
||||
const parsed = moneroDepositAddressRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) { res.status(400).json({ error: 'Invalid signed Monero deposit-address request.' }); return; }
|
||||
try {
|
||||
const result = await enrollMoneroDepositAddress(moneroDepositPool!, moneroDepositRpc!, moneroDepositContext!, parsed.data);
|
||||
res.json({ ...result, status: 'address-enrolled-not-credited', depositCreditEnabled: false, withdrawalsEnabled: false });
|
||||
} catch {
|
||||
res.status(409).json({ error: 'Deposit address enrollment failed; verify signed terms and retry with the same request ID.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
if (withdrawalRequestMode === '1' && moneroWithdrawalPool && moneroWithdrawalCustodianId) {
|
||||
app.get('/api/monero/withdrawal-config', (_req,res) => {
|
||||
res.json({ network: 'wikideck-randomx-mainnet-1', chainId: CHAIN_ID,
|
||||
custodianId: moneroWithdrawalCustodianId, withdrawalRequestsEnabled: true,
|
||||
transactionSigningEnabled: false, transactionRelayEnabled: false });
|
||||
});
|
||||
app.post('/api/monero/custody-balance', rateLimit({windowMs:60000,limit:20,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res) => {
|
||||
const parsed = mainnetCustodyBalanceRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) { res.status(400).json({ error: 'Invalid signed mainnet custody balance request.' }); return; }
|
||||
try { res.json(await readMainnetCustodyBalance(moneroWithdrawalPool!, parsed.data)); }
|
||||
catch { res.status(403).json({ error: 'Mainnet custody balance is unavailable for this signed request.' }); }
|
||||
});
|
||||
app.post('/api/monero/withdrawals', rateLimit({windowMs:60000,limit:5,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res) => {
|
||||
const parsed = mainnetWithdrawalRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) { res.status(400).json({ error: 'Invalid signed mainnet withdrawal request.' }); return; }
|
||||
try {
|
||||
const result = await reserveMainnetWithdrawal(moneroWithdrawalPool!, parsed.data);
|
||||
res.status(202).json({ ...result, status: result.status === 'reserved' ? 'reserved-not-sent' : result.status,
|
||||
transactionSigningEnabled: false, transactionRelayEnabled: false });
|
||||
} catch {
|
||||
res.status(409).json({ error: 'Withdrawal request could not be reserved; verify signed terms and available balance.' });
|
||||
}
|
||||
});
|
||||
app.post('/api/monero/withdrawals/cancel', rateLimit({windowMs:60000,limit:5,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res) => {
|
||||
const parsed = mainnetWithdrawalCancellationSchema.safeParse(req.body);
|
||||
if (!parsed.success) { res.status(400).json({ error: 'Invalid signed withdrawal cancellation.' }); return; }
|
||||
try {
|
||||
const result = await cancelMainnetWithdrawal(moneroWithdrawalPool!, parsed.data);
|
||||
res.json({ ...result, transactionSigningEnabled: false, transactionRelayEnabled: false });
|
||||
} catch {
|
||||
res.status(409).json({ error: 'Withdrawal cancellation could not be applied; it may already be cancelled or under operator processing.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
app.get('/api/decks/:address', rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}), (req,res)=>{
|
||||
if(typeof req.params.address!=='string'||!/^[a-f0-9]{64}$/.test(req.params.address)){
|
||||
res.status(400).json({error:'Invalid wallet address'});return;
|
||||
@@ -365,55 +161,22 @@ app.use((err:Error & {status?:number},_req:express.Request,res:express.Response,
|
||||
});
|
||||
const port=Number(process.env.PORT??3001),host=process.env.HOST??'127.0.0.1';
|
||||
const server=app.listen(port,host,()=>{
|
||||
const a=server.address();console.log(`Wikideck RandomX ${IS_MAINNET?'mainnet candidate':IS_PILOT?'private pilot':'testnet'}: http://${host}:${typeof a==='object'&&a?a.port:port} · chain ${CHAIN_ID} · ${ledger.snapshot().blocks.length} blocks`);
|
||||
const a=server.address();console.log(`Wikideck RandomX ${IS_MAINNET?'mainnet candidate':'testnet'}: http://${host}:${typeof a==='object'&&a?a.port:port} · chain ${CHAIN_ID} · ${ledger.snapshot().blocks.length} blocks`);
|
||||
peers.start();
|
||||
});
|
||||
const operations=startOperations(opsPort,()=>({network:NETWORK,paymentMode:'mock',
|
||||
const operations=startOperations(opsPort,()=>({network:NETWORK,
|
||||
ledger:ledger.diagnostics(),native:engine.diagnostics(),
|
||||
peers:{configured:peers.status().length,connected:peers.status().filter(p=>p.connected).length},
|
||||
rpc:{api:apiBudget.status(),p2p:peerBudget.status()},market:market?.diagnostics() ?? { enabled:false },
|
||||
canonicalObserver:custodyObserver?.status(),
|
||||
custody: custody && 'diagnostics' in custody ? custody.diagnostics() : { backend: custody ? 'sqlite-sandbox' : 'off' }}),resolve(dataDir,PROFILE));
|
||||
rpc:{api:apiBudget.status(),p2p:peerBudget.status()}}),resolve(dataDir,PROFILE));
|
||||
server.maxConnections=256;
|
||||
server.headersTimeout=10000;
|
||||
server.requestTimeout=15000;
|
||||
server.keepAliveTimeout=5000;
|
||||
server.setTimeout(30000,socket=>socket.destroy());
|
||||
// Advance simulated payout/expiry state even when no browser is open. No real funds are sent.
|
||||
let stopping=false;
|
||||
let catchupPaused=false;
|
||||
let catchupImmediate:NodeJS.Immediate|undefined;
|
||||
let marketTimer:NodeJS.Timeout|undefined;
|
||||
let marketCatchupTimer:NodeJS.Timeout|undefined;
|
||||
// Drain durable catch-up without page-load dependence. Each pass is a smaller synchronous
|
||||
// SQLite slice, then setImmediate yields so HTTP and peer work can run between passes.
|
||||
const advanceMarketCatchup=()=>{
|
||||
catchupImmediate=undefined;
|
||||
if(!market||stopping||catchupPaused||market.isReconciliationWorkerBusy()||!market.hasReconciliationBacklog())return;
|
||||
if(market.isReconciliationWorkerFailed()){
|
||||
catchupPaused=true;console.error('Marketplace reconciliation worker failed; operator review required.');return;
|
||||
}
|
||||
try {market.browse();}
|
||||
catch {catchupPaused=true;console.error('Marketplace canonical catch-up paused; operator review required.');return;}
|
||||
if(market.hasReconciliationBacklog()&&!market.isReconciliationWorkerBusy()){
|
||||
catchupImmediate=setImmediate(advanceMarketCatchup);catchupImmediate.unref();
|
||||
}
|
||||
};
|
||||
if (market) {
|
||||
marketTimer=setInterval(()=>{try {market!.browse();}catch{console.error('Marketplace reconciliation failed; operator review required.');}},15000);
|
||||
marketTimer.unref();
|
||||
marketCatchupTimer=setInterval(()=>{
|
||||
if(!stopping&&!catchupPaused&&!catchupImmediate&&market!.hasReconciliationBacklog()){
|
||||
catchupImmediate=setImmediate(advanceMarketCatchup);catchupImmediate.unref();
|
||||
}
|
||||
},250);
|
||||
marketCatchupTimer.unref();
|
||||
}
|
||||
for(const signal of ['SIGTERM','SIGINT']as const)process.once(signal,()=>{
|
||||
if(stopping)return;stopping=true;peers.close();operations.close();if(marketTimer)clearInterval(marketTimer);if(marketCatchupTimer)clearInterval(marketCatchupTimer);
|
||||
if(catchupImmediate)clearImmediate(catchupImmediate);
|
||||
const observerDrain=custodyObserver?.close()??Promise.resolve();
|
||||
if(stopping)return;stopping=true;peers.close();operations.close();
|
||||
const timeout=setTimeout(()=>process.exit(1),25000);timeout.unref();
|
||||
server.close(async()=>{await observerDrain;await moneroWithdrawalPool?.end();await moneroDepositPool?.end();await custody?.close();await market?.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
|
||||
server.close(async()=>{ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
|
||||
});
|
||||
server.on('error',async()=>{stopping=true;if(marketTimer)clearInterval(marketTimer);if(marketCatchupTimer)clearInterval(marketCatchupTimer);if(catchupImmediate)clearImmediate(catchupImmediate);operations.close();peers.close();await custodyObserver?.close();await moneroWithdrawalPool?.end();await moneroDepositPool?.end();await custody?.close();await market?.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
|
||||
server.on('error',async()=>{stopping=true;operations.close();peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
|
||||
|
||||
@@ -257,8 +257,6 @@ export class Ledger {
|
||||
canonicalHead(): number { return this.index.head(); }
|
||||
nft(token: string) { return this.index.nft(token, this.height()); }
|
||||
canonicalTransaction(id: string) { return this.index.transaction(id); }
|
||||
/** Local read-only observer path for the market worker; never exposed through an HTTP response. */
|
||||
reconciliationDatabasePath(): string { return this.file; }
|
||||
legacySnapshot(maxBlocks: number): TestnetSnapshot {
|
||||
// Reject before any full-chain copy/serialization. Never truncate ownership history silently.
|
||||
if (this.height() > maxBlocks) throw new RpcError(413, 'Full snapshot disabled at this height; use /api/summary and /api/canonical pages.');
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { NETWORK } from '../../shared/network.ts';
|
||||
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
|
||||
import { allocateXmrSale } from '../../shared/xmr-pricing.ts';
|
||||
import { parseAtomicXmr } from '../../shared/xmr-amount.ts';
|
||||
import { nftTransferMessage, nftTradeMessage, type NftState, type NftTransfer, type NftTrade } from '../../shared/testnet.ts';
|
||||
import { SALE_CANDIDATE, saleTransactionSchema, saleTermsSchema, saleTermsMessage,
|
||||
saleCompletionMessage, saleCancellationMessage, type SaleTerms, type SaleTransaction } from '../../shared/sale-candidate.ts';
|
||||
import { CHAIN_ID, sha256, verifySignature, transferSchema, tradeSchema } from './consensus.ts';
|
||||
import type { CustodyOrder } from '../../shared/custody.ts';
|
||||
|
||||
const integer = z.number().int().min(1).max(1000000);
|
||||
const policySchema = z.object({ coinbaseMaturity: integer, lockDepth: integer, outcomeDepth: integer,
|
||||
maxLockBlocks: integer, maxTransactions: z.number().int().min(1).max(64) }).strict();
|
||||
export type SalePolicy = z.infer<typeof policySchema>;
|
||||
export type SaleContext = { chainId: string; policyDigest: string; policy: SalePolicy };
|
||||
export type SaleRecord = { terms: SaleTerms; lockHeight: number; status: 'locked' | 'completed' | 'cancelled' | 'expired';
|
||||
outcomeHeight?: number; reservationDigest?: string };
|
||||
export type SaleState = { height: number; nfts: NftState; sales: Record<string, SaleRecord>;
|
||||
locks: Record<string, string>; orders: Record<string, string> };
|
||||
export type CandidateTransaction = SaleTransaction | { type: 'native-transfer'; transfer: NftTransfer } |
|
||||
{ type: 'native-trade'; trade: NftTrade };
|
||||
const genericChain = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const candidateNativeTransfer = transferSchema.extend({ chainId: genericChain });
|
||||
// Reuse the existing two-party signature/uniqueness checks, but NEVER the active chain ID.
|
||||
const candidateNativeTrade = tradeSchema.safeExtend({ chainId: genericChain });
|
||||
const candidateTransactionSchema = z.union([saleTransactionSchema,
|
||||
z.object({ type: z.literal('native-transfer'), transfer: candidateNativeTransfer }).strict(),
|
||||
z.object({ type: z.literal('native-trade'), trade: candidateNativeTrade }).strict()]);
|
||||
function check(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); }
|
||||
const orderKey = (t: SaleTerms) => `${t.custodianId}:${t.orderId}`;
|
||||
|
||||
/** Explicit policy, no invented production thresholds. Digest creates an OFFLINE domain, not a new genesis. */
|
||||
export function saleContext(raw: unknown): SaleContext {
|
||||
const policy = policySchema.parse(raw);
|
||||
check(policy.maxLockBlocks > policy.lockDepth, 'Lock window must exceed lock depth');
|
||||
const policyDigest = sha256(JSON.stringify([SALE_CANDIDATE, policy.coinbaseMaturity, policy.lockDepth,
|
||||
policy.outcomeDepth, policy.maxLockBlocks, policy.maxTransactions, ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address]));
|
||||
return { policy, policyDigest, chainId: sha256(JSON.stringify([SALE_CANDIDATE, NETWORK, CHAIN_ID, policyDigest])) };
|
||||
}
|
||||
export function saleId(terms: SaleTerms): string { return sha256(saleTermsMessage(saleTermsSchema.parse(terms))); }
|
||||
|
||||
/** Pure bridge from an actual reservation record to candidate terms; signs/submits/debits NOTHING. */
|
||||
export function termsForCustodyOrder(order: CustodyOrder, context: SaleContext, options: {
|
||||
custodianId: string; host: string; feeBps: number; lockUntilHeight: number; expiresHeight: number;
|
||||
}): SaleTerms {
|
||||
check(order.status === 'awaiting-lock', 'Only awaiting-lock custody orders can draft candidate terms');
|
||||
const allocation = allocateXmrSale(parseAtomicXmr(order.grossAtomicXmr), options.feeBps);
|
||||
check(order.sellerAtomicXmr === allocation.sellerBeforeNetworkCosts.toString() && order.hostAtomicXmr === allocation.hosting.toString() &&
|
||||
order.authorAtomicXmr === allocation.author.toString() && order.authorAddress === ADDITIONAL_MARKET_FEE.address,
|
||||
'Custody order allocations do not match candidate terms');
|
||||
return saleTermsSchema.parse({ protocol: SALE_CANDIDATE, network: NETWORK, chainId: context.chainId,
|
||||
policyDigest: context.policyDigest, custodianId: options.custodianId, orderId: order.id, cardHash: order.cardHash,
|
||||
sequence: order.sequence, seller: order.seller, buyer: order.buyer, host: options.host,
|
||||
priceAtomicXmr: order.grossAtomicXmr, feeBps: options.feeBps, hostFeeAddress: order.hostFeeAddress,
|
||||
lockUntilHeight: options.lockUntilHeight, expiresHeight: options.expiresHeight });
|
||||
}
|
||||
export function initialSaleState(nfts: NftState, height = 0): SaleState {
|
||||
check(Number.isSafeInteger(height) && height >= 0, 'Invalid candidate height');
|
||||
return { height, nfts: Object.assign(Object.create(null), structuredClone(nfts)),
|
||||
sales: Object.create(null), locks: Object.create(null), orders: Object.create(null) };
|
||||
}
|
||||
function advance(sequence: number): number {
|
||||
check(Number.isSafeInteger(sequence) && sequence < Number.MAX_SAFE_INTEGER, 'NFT sequence exhausted'); return sequence + 1;
|
||||
}
|
||||
|
||||
/** Offline deterministic block transition; all changes roll back by discarding the clone on any error.
|
||||
* No HTTP caller, Ledger import, wallet RPC, time, mutable configuration or custody DB access here.
|
||||
*/
|
||||
export function applySaleBlock(parent: SaleState, raw: unknown[], context: SaleContext): SaleState {
|
||||
const canonicalContext = saleContext(context.policy);
|
||||
check(context.chainId === canonicalContext.chainId && context.policyDigest === canonicalContext.policyDigest && context.chainId !== CHAIN_ID,
|
||||
'Invalid candidate context');
|
||||
check(Number.isSafeInteger(parent.height) && parent.height < Number.MAX_SAFE_INTEGER, 'Candidate height exhausted');
|
||||
check(Array.isArray(raw) && raw.length <= context.policy.maxTransactions, 'Candidate transaction budget exceeded');
|
||||
const txs = raw.map(tx => candidateTransactionSchema.parse(tx));
|
||||
const state = structuredClone(parent); state.height++;
|
||||
const seen = new Set<string>();
|
||||
for (const tx of txs) {
|
||||
const identity = candidateIdentity(tx);
|
||||
check(!seen.has(identity), 'Duplicate candidate transaction'); seen.add(identity);
|
||||
if (tx.type === 'native-transfer' || tx.type === 'native-trade') {
|
||||
applyNative(state, tx, context); continue;
|
||||
}
|
||||
if (tx.type === 'sale-lock') {
|
||||
const t = tx.terms, id = saleId(t), nft = state.nfts[t.cardHash];
|
||||
check(t.chainId === context.chainId && t.policyDigest === context.policyDigest, 'Sale context mismatch');
|
||||
check(t.seller !== t.buyer && t.host !== t.seller && t.host !== t.buyer, 'Host and trading identities must be separate');
|
||||
check(nft && nft.owner === t.seller && nft.sequence === t.sequence && !state.locks[t.cardHash], 'Card missing, locked, or stale');
|
||||
check(nft.sequence <= Number.MAX_SAFE_INTEGER - 2, 'NFT sequence exhausted for lock and terminal outcome');
|
||||
check(state.height - nft.mintedHeight >= context.policy.coinbaseMaturity, 'Immature sale card');
|
||||
check(state.height <= t.lockUntilHeight && t.lockUntilHeight + context.policy.lockDepth < t.expiresHeight &&
|
||||
Number.isSafeInteger(t.lockUntilHeight + context.policy.lockDepth) &&
|
||||
t.expiresHeight - state.height <= context.policy.maxLockBlocks, 'Invalid lock height window');
|
||||
check(!state.sales[id] && !state.orders[orderKey(t)], 'Sale/order authorization already used');
|
||||
const message = saleTermsMessage(t);
|
||||
check(verifySignature(tx.sellerAuthorization.publicKey, t.seller, message, tx.sellerAuthorization.signature) &&
|
||||
verifySignature(tx.buyerAuthorization.publicKey, t.buyer, message, tx.buyerAuthorization.signature), 'Sale lock requires both trader signatures');
|
||||
// Sequence changes at lock AND terminal outcome invalidate previously signed ordinary transfers.
|
||||
state.nfts[t.cardHash] = { ...nft, sequence: advance(nft.sequence) };
|
||||
state.sales[id] = { terms: t, lockHeight: state.height, status: 'locked' };
|
||||
state.locks[t.cardHash] = id; state.orders[orderKey(t)] = id;
|
||||
continue;
|
||||
}
|
||||
check(tx.chainId === context.chainId, 'Sale context mismatch');
|
||||
const sale = state.sales[tx.saleId];
|
||||
check(sale?.status === 'locked' && state.locks[sale.terms.cardHash] === tx.saleId, 'No active matching sale lock');
|
||||
const t = sale.terms, nft = state.nfts[t.cardHash];
|
||||
check(nft.owner === t.seller && nft.sequence === t.sequence + 1, 'Sale ownership/sequence mismatch');
|
||||
if (tx.type === 'sale-complete') {
|
||||
check(state.height < t.expiresHeight && state.height - sale.lockHeight >= context.policy.lockDepth,
|
||||
'Sale completion outside mature lock window');
|
||||
check(verifySignature(tx.hostAuthorization.publicKey, t.host, saleCompletionMessage(tx), tx.hostAuthorization.signature),
|
||||
'Invalid funding host authorization');
|
||||
sale.status = 'completed'; sale.reservationDigest = tx.reservationDigest;
|
||||
state.nfts[t.cardHash] = { ...nft, owner: t.buyer, sequence: advance(nft.sequence) };
|
||||
} else if (tx.type === 'sale-cancel') {
|
||||
check(state.height < t.expiresHeight, 'Use permissionless expiry at or after expiry height');
|
||||
const message = saleCancellationMessage(tx);
|
||||
check(verifySignature(tx.hostAuthorization.publicKey, t.host, message, tx.hostAuthorization.signature) &&
|
||||
verifySignature(tx.buyerAuthorization.publicKey, t.buyer, message, tx.buyerAuthorization.signature), 'Cancellation requires host and buyer authorization');
|
||||
sale.status = 'cancelled'; state.nfts[t.cardHash] = { ...nft, sequence: advance(nft.sequence) };
|
||||
} else {
|
||||
check(state.height >= t.expiresHeight, 'Sale has not expired');
|
||||
sale.status = 'expired'; state.nfts[t.cardHash] = { ...nft, sequence: advance(nft.sequence) };
|
||||
}
|
||||
sale.outcomeHeight = state.height; delete state.locks[t.cardHash];
|
||||
}
|
||||
return state;
|
||||
}
|
||||
/** Candidate transaction IDs exclude malleable signature bytes, matching the active chain convention.
|
||||
* These IDs are an unactivated candidate format and are NOT accepted by the existing Ledger schema.
|
||||
*/
|
||||
export function saleCandidateTransactionId(tx: SaleTransaction): string {
|
||||
if (tx.type === 'sale-lock') return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'lock', saleTermsMessage(tx.terms)]));
|
||||
if (tx.type === 'sale-complete') return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'complete', saleCompletionMessage(tx)]));
|
||||
if (tx.type === 'sale-cancel') return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'cancel', saleCancellationMessage(tx)]));
|
||||
return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'expire', tx.chainId, tx.saleId]));
|
||||
}
|
||||
function candidateIdentity(tx: CandidateTransaction): string {
|
||||
if (tx.type === 'sale-lock' || tx.type === 'sale-complete' || tx.type === 'sale-cancel' || tx.type === 'sale-expire') {
|
||||
return saleCandidateTransactionId(tx);
|
||||
}
|
||||
return sha256(tx.type === 'native-transfer' ? nftTransferMessage(tx.transfer) : nftTradeMessage(tx.trade));
|
||||
}
|
||||
function applyNative(state: SaleState, tx: Extract<CandidateTransaction, { type: 'native-transfer' | 'native-trade' }>, context: SaleContext) {
|
||||
const native = tx.type === 'native-transfer' ? tx.transfer : tx.trade;
|
||||
check(native.chainId === context.chainId, 'Native candidate context mismatch');
|
||||
const legs = tx.type === 'native-transfer' ? [{ cardHash: tx.transfer.cardHash, sequence: tx.transfer.sequence,
|
||||
from: tx.transfer.from, to: tx.transfer.to }] : [
|
||||
...tx.trade.offered.map(l => ({ ...l, from: tx.trade.proposer, to: tx.trade.counterparty })),
|
||||
...tx.trade.requested.map(l => ({ ...l, from: tx.trade.counterparty, to: tx.trade.proposer }))];
|
||||
for (const l of legs) {
|
||||
const nft = state.nfts[l.cardHash];
|
||||
check(nft && !state.locks[l.cardHash] && nft.owner === l.from && nft.sequence === l.sequence && l.from !== l.to,
|
||||
'Native candidate card locked, missing or stale');
|
||||
check(state.height - nft.mintedHeight >= context.policy.coinbaseMaturity, 'Immature native card');
|
||||
}
|
||||
if (tx.type === 'native-transfer') check(verifySignature(tx.transfer.publicKey, tx.transfer.from,
|
||||
nftTransferMessage(tx.transfer), tx.transfer.signature), 'Invalid native transfer signature');
|
||||
// Trade signatures/unique cards have already been checked by candidateNativeTrade.
|
||||
for (const l of legs) state.nfts[l.cardHash] = { ...state.nfts[l.cardHash], owner: l.to, sequence: advance(l.sequence) };
|
||||
}
|
||||
|
||||
export type SaleDecision = { action: 'hold' | 'settle' | 'refund' | 'review' | 'none'; reason: string };
|
||||
/** Pure financial observation policy: recommendation ONLY, not authority to post money or a canonical-chain proof. */
|
||||
export function saleDecision(state: SaleState, terms: SaleTerms, context: SaleContext,
|
||||
previous: 'reserved' | 'settled' | 'refunded' | 'review' = 'reserved'): SaleDecision {
|
||||
check(terms.chainId === context.chainId && terms.policyDigest === context.policyDigest, 'Observation context mismatch');
|
||||
const sale = state.sales[saleId(terms)];
|
||||
const mature = sale?.outcomeHeight !== undefined && state.height - sale.outcomeHeight + 1 >= context.policy.outcomeDepth;
|
||||
if (previous === 'review') return { action: 'review', reason: 'Existing incident requires evidence-backed resolution' };
|
||||
if (previous === 'settled' || previous === 'refunded') {
|
||||
const expected = previous === 'settled' ? sale?.status === 'completed' : sale?.status === 'cancelled' || sale?.status === 'expired' ||
|
||||
(!sale && state.height - terms.lockUntilHeight >= context.policy.outcomeDepth);
|
||||
return expected && (sale ? mature : true) ? { action: 'none', reason: 'Already posted; never duplicate' }
|
||||
: { action: 'review', reason: 'Previously posted outcome lost canonical maturity' };
|
||||
}
|
||||
if (!sale) return state.height - terms.lockUntilHeight >= context.policy.outcomeDepth
|
||||
? { action: 'refund', reason: 'Lock admission deadline is mature without canonical lock' }
|
||||
: { action: 'hold', reason: 'A withheld lock authorization can still be included' };
|
||||
if (!mature) return { action: 'hold', reason: 'Lock or terminal outcome is not mature' };
|
||||
return sale.status === 'completed' ? { action: 'settle', reason: 'Canonical completion reached configured depth' }
|
||||
: { action: 'refund', reason: 'Canonical cancellation/expiry reached configured depth' };
|
||||
}
|
||||
/** Candidate commitment includes locks AND permanent order-use tombstones, not only current NFT ownership. */
|
||||
export function saleStateRoot(state: SaleState, context: SaleContext): string {
|
||||
return sha256(JSON.stringify([SALE_CANDIDATE, 'state', context.chainId, state.height,
|
||||
Object.keys(state.nfts).sort().map(id => { const n = state.nfts[id]; return [id, n.owner, n.sequence, n.mintedHeight, n.pageId]; }),
|
||||
Object.keys(state.sales).sort().map(id => { const s = state.sales[id]; return [id, saleTermsMessage(s.terms), s.lockHeight,
|
||||
s.status, s.outcomeHeight ?? null, s.reservationDigest ?? null]; }),
|
||||
Object.keys(state.locks).sort().map(id => [id, state.locks[id]]),
|
||||
Object.keys(state.orders).sort().map(id => [id, state.orders[id]])]));
|
||||
}
|
||||
Reference in New Issue
Block a user