diff --git a/docs/marketplace.md b/docs/marketplace.md index 94253fd..5a44f04 100755 --- a/docs/marketplace.md +++ b/docs/marketplace.md @@ -88,6 +88,6 @@ For **review**, preserve backups and inspect canonical blocks, pending transfers ## Future live XMR custody -The selected direction is a pooled custodial ledger, not per-sale processor invoices. Real deposits would be assigned to durable Monero subaddresses and reconciled against wallet-owned outputs; purchases would transfer liabilities internally. Withdrawals would require a separate, policy-constrained treasury signer and durable reconciliation. **None of this is connected to the public marketplace or enabled:** there is no live deposit-address route, customer credit worker, withdrawal signer, or paid-sale settlement path. +The selected direction is a pooled custodial ledger, not per-sale processor invoices. Real deposits would be assigned to durable Monero subaddresses and reconciled against wallet-owned outputs; purchases would transfer liabilities internally. Withdrawals would require a separate, policy-constrained treasury signer and durable reconciliation. A mainnet-only address-enrollment API now exists behind `WIKIDECK_MONERO_DEPOSIT_ENROLLMENT=1`, but it is off by default and has no customer UI; its response explicitly says the address is not credited. There is still no customer credit worker, withdrawal signer, or paid-sale settlement path. **Do not enable the enrollment API or publish its addresses until customers can be credited, withdraw, and recover funds end-to-end.** Do not send real XMR to this sandbox. Live activation requires an end-to-end reviewed implementation, funded-liability and custody policies, production wallet/daemon isolation, withdrawal/reorg recovery, backups and operations evidence. No payment-mode setting is an activation path. diff --git a/docs/production-plan.md b/docs/production-plan.md index 690e823..1724eba 100755 --- a/docs/production-plan.md +++ b/docs/production-plan.md @@ -38,6 +38,8 @@ Status: active engineering plan; **not launch or payment authorization**. Update **Signed deposit-enrollment slice (2026-10-22):** added a customer-wallet-signed request contract and server-side enrollment service that verifies mainnet profile, chain, custodian, beneficiary ownership, wallet-generation fingerprint, account index and a five-minute expiry before reserving a never-reused subaddress index and recovering/registering exactly one address. Added a separate allocator DB role, and schema v8 now forbids inserting pre-registered addresses directly; role permissions permit only allocation storage and the guarded reserved→registered fields. Replay returns the existing allocation; wallet mismatch/invalid signature fail before address RPC. PostgreSQL tests cover signed enrollment, replay, beneficiary uniqueness, role isolation, no journal writes and database-trigger bypass attempts. This is not exposed through HTTP/UI and no real wallet was contacted; it does not credit funds or establish a withdrawal path. Validation: build passed, offline suite **175/175**, PostgreSQL suite **38/38**. +**Mainnet enrollment API wiring (2026-10-22):** added `GET /api/monero/deposit-config` and signed `POST /api/monero/deposit-address`, only mounted when `WIKIDECK_MONERO_DEPOSIT_ENROLLMENT=1` on the gated mainnet profile. Startup pins the operator-provided custodian ID, wallet-generation UUID, expected wallet fingerprint, account index, matching mTLS endpoint and separate TLS allocator PostgreSQL role. Enrollment requests are rate-limited and signed by the beneficiary; responses explicitly say `address-enrolled-not-credited`, with crediting and withdrawals reported disabled. The flag defaults off; this API must not be enabled for customers until deposit crediting, withdrawals and recovery are end-to-end functional. No real wallet or database was contacted. The API has no customer UI yet. Validation: test-image build passed and offline suite **176/176**; the preceding complete PostgreSQL suite passed **38/38** after schema-v8 allocator protection. + ## 1A. MVP release boundary — proposed safe first production-hosted release The apartment/single-operator constraint changes the rollout and availability claims, not the safety evidence required to hold customer funds or valuable assets. Recommended MVP: **a public, clearly labeled non-monetary beta on the existing testnet**, with real XMR completely disabled, no custodial balances/deposits/withdrawals, and marketplace checkout/delivery disabled for valuable assets. Keep mining, browsing and wallet use only if they are explicitly described as experimental and users accept that data/ownership may be lost. This would be a production-hosted beta, **not mainnet production**, a custody MVP, or an independently secured chain. diff --git a/server/market/monero-deposit-enrollment.ts b/server/market/monero-deposit-enrollment.ts index d496c11..a7e4e58 100755 --- a/server/market/monero-deposit-enrollment.ts +++ b/server/market/monero-deposit-enrollment.ts @@ -6,7 +6,7 @@ import type { MoneroDepositAddressRpc } from './monero-observer.ts'; const MAX_REQUEST_LIFETIME_MS = 5 * 60_000; -type MainnetEnrollmentContext = { +export type MainnetEnrollmentContext = { custodianId: string; chainId: string; walletGenerationId: string; diff --git a/server/testnet/http.ts b/server/testnet/http.ts index 92d3c53..6165700 100644 --- a/server/testnet/http.ts +++ b/server/testnet/http.ts @@ -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 | 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);}); diff --git a/tests/network.test.ts b/tests/network.test.ts index 736aba0..cd8dd02 100755 --- a/tests/network.test.ts +++ b/tests/network.test.ts @@ -107,3 +107,12 @@ test('operator preflight is read-only, fails closed, and does not disclose crede assert.equal(existsSync(dataDir), false); } finally { await rm(dir, { recursive: true, force: true }); } }); + +test('Monero deposit enrollment API cannot be enabled on testnet', () => { + const result = spawnSync(process.execPath, ['--experimental-strip-types', 'server/index.ts'], { + env: { ...process.env, WIKIDECK_NETWORK: 'testnet', WIKIDECK_MONERO_DEPOSIT_ENROLLMENT: '1' }, + encoding: 'utf8', timeout: 30000, + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /Monero deposit enrollment requires the mainnet profile/); +});