|
|
|
@@ -1,4 +1,5 @@
|
|
|
|
|
import express from 'express';
|
|
|
|
|
import { Pool } from 'pg';
|
|
|
|
|
import helmet from 'helmet';
|
|
|
|
|
import rateLimit from 'express-rate-limit';
|
|
|
|
|
import { resolve } from 'node:path';
|
|
|
|
@@ -18,10 +19,15 @@ import { Market, marketConfig } from '../market/store.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 { 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 { 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, PROFILE } from '../../shared/network.ts';
|
|
|
|
|
import { assertLaunchAllowed, launchManifest } from '../launch.ts';
|
|
|
|
@@ -37,6 +43,13 @@ 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');
|
|
|
|
|
}
|
|
|
|
|
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');
|
|
|
|
@@ -67,6 +80,9 @@ try { market = await Market.open(dataDir, ledger, paymentConfig, Date.now, { wor
|
|
|
|
|
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;
|
|
|
|
|
try {
|
|
|
|
|
if (custodyConfig === 'sandbox') custody = await openCustodyStorage(dataDir, { ...paymentConfig,
|
|
|
|
|
card: (hash, owner, sequence) => {
|
|
|
|
@@ -80,7 +96,34 @@ try {
|
|
|
|
|
custodyObserver = new CanonicalObserver(custody, ledger);
|
|
|
|
|
if ((await custodyObserver.start()).state !== 'running') throw new Error('Canonical observer rehearsal failed to start; inspect custody diagnostics');
|
|
|
|
|
}
|
|
|
|
|
} catch(e) {await custodyObserver?.close();await custody?.close();await market.close();peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
|
|
|
|
|
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) };
|
|
|
|
|
}
|
|
|
|
|
} catch(e) {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);
|
|
|
|
@@ -133,6 +176,24 @@ app.post('/api/custody/command', rateLimit({windowMs:60000,limit:30,standardHead
|
|
|
|
|
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.' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
@@ -279,6 +340,6 @@ for(const signal of ['SIGTERM','SIGINT']as const)process.once(signal,()=>{
|
|
|
|
|
if(catchupImmediate)clearImmediate(catchupImmediate);
|
|
|
|
|
const observerDrain=custodyObserver?.close()??Promise.resolve();
|
|
|
|
|
const timeout=setTimeout(()=>process.exit(1),25000);timeout.unref();
|
|
|
|
|
server.close(async()=>{await observerDrain;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()=>{await observerDrain;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.on('error',async()=>{stopping=true;clearInterval(marketTimer);clearInterval(marketCatchupTimer);if(catchupImmediate)clearImmediate(catchupImmediate);operations.close();peers.close();await custodyObserver?.close();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;clearInterval(marketTimer);clearInterval(marketCatchupTimer);if(catchupImmediate)clearImmediate(catchupImmediate);operations.close();peers.close();await custodyObserver?.close();await moneroDepositPool?.end();await custody?.close();await market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
|
|
|
|
|