Opt-in, mainnet-only deposit-address API
This commit is contained in:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
+64
-3
@@ -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);});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user