Remove markets
This commit is contained in:
@@ -1,28 +0,0 @@
|
||||
FROM node:24-trixie-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update -qq \
|
||||
&& apt-get upgrade -y -qq \
|
||||
&& apt-get install -y -qq --no-install-recommends ca-certificates curl gnupg openssl bzip2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN set -eu; \
|
||||
mkdir -m 700 -p /tmp/monero-verify /opt/monero; \
|
||||
cd /tmp/monero-verify; \
|
||||
curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
|
||||
'https://downloads.getmonero.org/cli/monero-linux-x64-v0.18.5.4.tar.bz2' -o monero-linux-x64-v0.18.5.4.tar.bz2; \
|
||||
curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
|
||||
'https://www.getmonero.org/downloads/hashes.txt' -o hashes.txt; \
|
||||
curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
|
||||
'https://raw.githubusercontent.com/monero-project/monero/master/utils/gpg_keys/binaryfate.asc' -o binaryfate.asc; \
|
||||
actual_fingerprint="$(gpg --batch --show-keys --with-colons binaryfate.asc | awk -F: '$1 == "fpr" {print $10; exit}')"; \
|
||||
test "$actual_fingerprint" = '81AC591FE9C4B65C5806AFC3F0AF4D462A0BDF92'; \
|
||||
export GNUPGHOME=/tmp/monero-verify/gnupg; mkdir -m 700 -p "$GNUPGHOME"; \
|
||||
gpg --batch --import binaryfate.asc >/dev/null 2>&1; \
|
||||
gpg --batch --output hashes.verified.txt --decrypt hashes.txt >/dev/null 2>&1; \
|
||||
grep -Fx 'ba2928962c101b609434bf8c1b43ad59ebe96c5525977284ca599d8bf0f02d10 monero-linux-x64-v0.18.5.4.tar.bz2' hashes.verified.txt >/dev/null; \
|
||||
printf '%s %s\n' 'ba2928962c101b609434bf8c1b43ad59ebe96c5525977284ca599d8bf0f02d10' 'monero-linux-x64-v0.18.5.4.tar.bz2' | sha256sum --check --status; \
|
||||
tar -xjf monero-linux-x64-v0.18.5.4.tar.bz2 -C /opt/monero --strip-components=1; \
|
||||
rm -rf /tmp/monero-verify
|
||||
CMD ["node", "--experimental-strip-types", "scripts/monero-mtls-stagenet-rehearsal.mjs", "/opt/monero/monero-wallet-rpc"]
|
||||
@@ -1,171 +0,0 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { MARKET_RECONCILIATION_BATCH_SIZE, Market } from '../server/market/store.ts';
|
||||
import { CHAIN_ID, GENESIS_HASH, transactionId } from '../server/testnet/consensus.ts';
|
||||
import { NETWORK } from '../shared/testnet.ts';
|
||||
|
||||
// Synthetic market journal only. It is isolated in a temporary SQLite database and never
|
||||
// connects to a Ledger, peer, payment adapter, wallet, or production data directory.
|
||||
const text = process.argv[2] ?? '10000';
|
||||
if (!/^[1-9]\d{0,5}$/.test(text) || Number(text) > 100000) throw new Error('Fixture size must be 1..100000 orders');
|
||||
const count = Number(text), scenario = process.argv[3] ?? 'terminal';
|
||||
if (!['terminal', 'confirmed-delivery'].includes(scenario)) throw new Error('Scenario must be terminal or confirmed-delivery');
|
||||
const executionMode = process.argv[4] ?? 'sync';
|
||||
if (!['sync', 'worker'].includes(executionMode)) throw new Error('Execution mode must be sync or worker');
|
||||
const workerEnabled = executionMode === 'worker';
|
||||
const httpLoadEnabled = process.argv[5] === 'http';
|
||||
const directory = await mkdtemp(join(tmpdir(), 'wikideck-market-reconciliation-'));
|
||||
const streamId = randomUUID(), tip = 'a'.repeat(64);
|
||||
const ledgerFile = join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet', 'ledger.sqlite');
|
||||
const seller = '1'.repeat(64), buyer = '2'.repeat(64);
|
||||
let market: Market | undefined;
|
||||
let ledgerDb: DatabaseSync | undefined;
|
||||
let loadServer: ReturnType<typeof createServer> | undefined;
|
||||
try {
|
||||
// Indexed-only adapter: the benchmark must not fall back to materializing chain snapshots.
|
||||
await mkdir(join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet'), { recursive: true });
|
||||
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 insertTip = ledgerDb.prepare('INSERT INTO canonical_blocks VALUES(?,?)');
|
||||
const insertNft = ledgerDb.prepare('INSERT INTO canonical_nfts VALUES(?,?,?,?,?)');
|
||||
const priorHash = 'b'.repeat(64);
|
||||
for (const [height, hash] of [[1, priorHash], [2, tip]] as const) {
|
||||
insertBlock.run(hash, JSON.stringify({ height, hash, tokenId: '', owner: seller, transactions: [] }));
|
||||
insertTip.run(height, hash);
|
||||
}
|
||||
const chain = {
|
||||
snapshot: () => { throw new Error('Benchmark must not request a full chain snapshot'); },
|
||||
summary: () => ({ tip, revision: 0, height: 2 }),
|
||||
nft: (token: string) => {
|
||||
const row = ledgerDb!.prepare(`SELECT n.owner,n.sequence,n.minted_height,b.block_json FROM canonical_nfts n
|
||||
JOIN blocks b ON b.hash=n.block_hash WHERE n.token=?`).get(token);
|
||||
if (!row) return undefined;
|
||||
return { block: JSON.parse(row.block_json), owner: row.owner, sequence: row.sequence,
|
||||
confirmations: 2 - row.minted_height + 1 };
|
||||
},
|
||||
canonicalTransaction: (id: string) => ledgerDb!.prepare('SELECT height,block_hash AS blockHash FROM canonical_transactions WHERE id=?').get(id),
|
||||
pendingTransactions: () => ledgerDb!.prepare('SELECT transaction_json FROM pending_transfers ORDER BY position').all()
|
||||
.map(row => JSON.parse(row.transaction_json)),
|
||||
canonicalHead: () => 0,
|
||||
canonicalEvents: (after: number) => ({ streamId, previousAnchor: GENESIS_HASH, anchor: GENESIS_HASH,
|
||||
events: [], cursor: after, head: 0, caughtUp: true }),
|
||||
...(workerEnabled ? { reconciliationDatabasePath: () => ledgerFile } : {}),
|
||||
submitTransfer: async () => { throw new Error('Benchmark never submits transfers'); },
|
||||
};
|
||||
market = await Market.open(directory, chain, { feeBps: 250, confirmations: 2 }, Date.now, { worker: workerEnabled });
|
||||
const db = new DatabaseSync(join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet', 'market.sqlite'));
|
||||
try {
|
||||
const insertListing = db.prepare('INSERT INTO listings(id,card,status,data) VALUES(?,?,?,?)');
|
||||
const insertOrder = db.prepare('INSERT INTO orders(id,data) VALUES(?,?)');
|
||||
const insertNft = ledgerDb?.prepare('INSERT INTO canonical_nfts VALUES(?,?,?,?,?)');
|
||||
const insertTransaction = ledgerDb?.prepare('INSERT INTO canonical_transactions VALUES(?,?,?)');
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = i.toString().padStart(12, '0');
|
||||
const listingId = `listing-${id}`, orderId = `order-${id}`, cardHash = i.toString(16).padStart(64, '0');
|
||||
const terminal = scenario === 'terminal';
|
||||
const listingStatus = terminal ? 'cancelled' : 'reserved';
|
||||
const listing = { id: listingId, cardHash, seller, sequence: 0, kind: 'fixed', status: listingStatus };
|
||||
const order = { id: orderId, listingId, seller, buyer, cardHash, sequence: 0,
|
||||
status: terminal ? 'refunded' : 'paid', payout: null, deliveryConfirmations: 0,
|
||||
requiredConfirmations: 2, createdAt: 1, payBy: 2, deliverBy: 3 };
|
||||
if (!terminal) {
|
||||
const delivery = { network: NETWORK, chainId: CHAIN_ID, cardHash, from: seller, to: buyer,
|
||||
sequence: 0, publicKey: '', signature: '' };
|
||||
const id = transactionId(delivery);
|
||||
insertTransaction?.run(id, 1, 'b'.repeat(64));
|
||||
}
|
||||
insertNft?.run(cardHash, terminal ? seller : buyer, terminal ? 0 : 1, 1, 'b'.repeat(64));
|
||||
insertListing.run(listingId, cardHash, listingStatus, JSON.stringify(listing));
|
||||
insertOrder.run(orderId, JSON.stringify(order));
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
||||
} finally { db.close(); }
|
||||
let httpLatencies: number[] = [], httpRequests = 0;
|
||||
let httpLoadPromise: Promise<void> | undefined;
|
||||
if (httpLoadEnabled) {
|
||||
loadServer = createServer((request, response) => {
|
||||
try {
|
||||
if (request.url === '/health') {
|
||||
response.writeHead(200, { 'content-type': 'application/json' }); response.end('{"ok":true}'); return;
|
||||
}
|
||||
if (request.url === '/market') {
|
||||
const result = market!.browse();
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ listingCount: result.listings.length, backlog: market!.hasReconciliationBacklog() }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(404); response.end();
|
||||
} catch {
|
||||
response.writeHead(503, { 'retry-after': '1' }); response.end('{"error":"reconciling"}');
|
||||
}
|
||||
});
|
||||
await new Promise<void>(resolve => loadServer!.listen(0, '127.0.0.1', resolve));
|
||||
const address = loadServer.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Benchmark HTTP listener did not bind TCP');
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
httpLoadPromise = Promise.all(Array.from({ length: 160 }, (_, i) => (async () => {
|
||||
const before = performance.now();
|
||||
const response = await fetch(`${baseUrl}${i % 4 === 0 ? '/market' : '/health'}`);
|
||||
if (response.status !== 200) throw new Error(`Loopback benchmark endpoint returned ${response.status}`);
|
||||
await response.arrayBuffer();
|
||||
httpLatencies.push(performance.now() - before);
|
||||
})())).then(() => { httpLatencies.sort((a, b) => a - b); httpRequests = httpLatencies.length; });
|
||||
}
|
||||
const latencies: number[] = [];
|
||||
const deadline = Date.now() + Math.max(30_000, count * 20);
|
||||
const eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
|
||||
eventLoopDelay.enable();
|
||||
const start = performance.now();
|
||||
let started = false;
|
||||
try {
|
||||
while (Date.now() < deadline && (!started || market.hasReconciliationBacklog() || market.isReconciliationWorkerBusy() ||
|
||||
(!!httpLoadPromise && httpRequests === 0))) {
|
||||
if (!workerEnabled || !market.isReconciliationWorkerBusy()) {
|
||||
const before = performance.now();
|
||||
market.browse();
|
||||
latencies.push(performance.now() - before);
|
||||
started = true;
|
||||
}
|
||||
await yieldToEventLoop(); // Permit the delay monitor to observe synchronous batch stalls.
|
||||
if (workerEnabled && market.isReconciliationWorkerBusy()) await new Promise(resolve => setTimeout(resolve, 1));
|
||||
}
|
||||
} finally { eventLoopDelay.disable(); }
|
||||
const elapsedMs = performance.now() - start;
|
||||
if (!started || market.hasReconciliationBacklog() || market.isReconciliationWorkerBusy()) throw new Error('Reconciliation did not finish before benchmark deadline');
|
||||
await httpLoadPromise;
|
||||
await market.close(); market = undefined;
|
||||
ledgerDb.close(); ledgerDb = undefined;
|
||||
const bytes = (await stat(join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet', 'market.sqlite'))).size;
|
||||
latencies.sort((a, b) => a - b);
|
||||
const percentile = (p: number) => latencies[Math.min(latencies.length - 1, Math.ceil(latencies.length * p) - 1)];
|
||||
const httpPercentile = (p: number) => httpLatencies.length ? httpLatencies[Math.min(httpLatencies.length - 1, Math.ceil(httpLatencies.length * p) - 1)] : null;
|
||||
console.log(JSON.stringify({ fixture: `synthetic-${scenario}-orders-and-listings`, workerEnabled, httpLoadEnabled, orders: count, listings: count,
|
||||
batchSizePerTable: MARKET_RECONCILIATION_BATCH_SIZE, reconciliationPasses: latencies.length, elapsedMs,
|
||||
rowsPerSecond: Math.round((count * 2 * 1000) / elapsedMs), passLatencyP50Ms: percentile(0.50),
|
||||
passLatencyP95Ms: percentile(0.95), passLatencyMaxMs: latencies.at(-1), sqliteBytes: bytes,
|
||||
eventLoopDelayResolutionMs: 10, eventLoopDelayP95Ms: eventLoopDelay.percentile(95) / 1e6,
|
||||
eventLoopDelayMaxMs: eventLoopDelay.max / 1e6,
|
||||
httpRequests, httpLatencyP50Ms: httpPercentile(0.5), httpLatencyP95Ms: httpPercentile(0.95),
|
||||
httpLatencyMaxMs: httpLatencies.at(-1) ?? null,
|
||||
warning: 'Synthetic fixture on this development host only; SQLite Ledger-shaped facts are not mined or validated chain evidence. Excludes concurrent HTTP load, PostgreSQL, and production SLO certification.' }, null, 2));
|
||||
} finally {
|
||||
if (loadServer) await new Promise<void>(resolve => loadServer!.close(() => resolve()));
|
||||
if (market) await market.close();
|
||||
ledgerDb?.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
import { Pool } from 'pg';
|
||||
import { open, unlink, mkdir } from 'node:fs/promises';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { postgresConfig } from '../server/market/postgres-config.ts';
|
||||
import { migrateCustody, grantCustodyRuntime, grantMoneroObserverRuntime,
|
||||
grantMoneroDepositCreditRuntime, grantMoneroDepositAllocatorRuntime, grantMoneroWithdrawalRuntime,
|
||||
grantPilotDepositReservationRuntime, grantPilotDepositAddressRuntime,
|
||||
grantPilotMoneroObserverRuntime, grantPilotMoneroDepositReviewRuntime } from '../server/market/postgres-schema.ts';
|
||||
import { initializeMainnetCustodyIdentity, parseMainnetCustodianId } from '../server/market/mainnet-custody-identity.ts';
|
||||
import { importSqliteCustody } from '../server/market/custody-import.ts';
|
||||
import { PostgresCustody } from '../server/market/postgres-custody.ts';
|
||||
import { IS_MAINNET, IS_PILOT } from '../shared/network.ts';
|
||||
import { initializePilotCustodyIdentity, parsePilotCustodianId } from '../server/market/pilot-custody-identity.ts';
|
||||
import { resolveCustodyIncident } from '../server/market/custody-incident.ts';
|
||||
import { loadMoneroDepositReviewApproval, loadMoneroDepositReviewPolicy } from '../server/market/monero-deposit-review-config.ts';
|
||||
import { assertCustodyDbActionAllowedOnNetwork, assertMainnetSchemaMigrationAcknowledged,
|
||||
assertMainnetCustodyOperationAcknowledged, assertPilotCustodyActionAllowed, assertPilotSchemaMigrationAcknowledged,
|
||||
assertPilotCustodyIdentityAcknowledged, assertPilotReservationGrantAcknowledged,
|
||||
assertPilotAddressGrantAcknowledged,
|
||||
assertPilotObserverGrantAcknowledged,
|
||||
assertPilotDepositReviewAcknowledged,
|
||||
parseCustodyDbArguments } from '../server/market/custody-db-args.ts';
|
||||
import { prepareDepositCreditReview, postReviewedDepositPendingCredit, prepareDepositCreditReleaseReview,
|
||||
releaseReviewedDepositCredit, reconcileDepositCreditReorgHolds, prepareDepositReorgResolution,
|
||||
resolveReleasedDepositReorgHold, type DepositCreditApproval, type DepositCreditReleaseApproval,
|
||||
type DepositReorgResolutionApproval } from '../server/market/monero-deposit-credit.ts';
|
||||
import { preparePilotMoneroDepositReview } from '../server/market/pilot-monero-deposit-review.ts';
|
||||
|
||||
// Explicit operator task, never invoked by node startup. No passwords or database URLs printed.
|
||||
const commandArgs = process.argv.slice(2);
|
||||
const depositActions = ['deposit-prepare-credit', 'deposit-stage-credit', 'deposit-prepare-release', 'deposit-release',
|
||||
'deposit-reconcile-holds', 'deposit-prepare-resolve-hold', 'deposit-resolve-hold'];
|
||||
const pilotReviewActions = ['pilot-deposit-prepare-review'];
|
||||
const custodyActions = ['migrate', 'migrate-mainnet-schema', 'initialize-mainnet-custody-identity', 'migrate-pilot-schema',
|
||||
'initialize-pilot-custody-identity', 'grant-runtime', 'grant-monero-observer-runtime',
|
||||
'grant-pilot-deposit-reservation-runtime',
|
||||
'grant-pilot-deposit-address-runtime',
|
||||
'grant-pilot-monero-observer-runtime',
|
||||
'grant-pilot-monero-review-runtime',
|
||||
'grant-monero-withdrawal-runtime',
|
||||
'grant-monero-deposit-allocator-runtime', 'grant-monero-credit-runtime',
|
||||
'import-sqlite', 'audit', 'resolve-integrity'];
|
||||
const { action, argument, extraArgument } = parseCustodyDbArguments(commandArgs);
|
||||
assertCustodyDbActionAllowedOnNetwork(action, IS_MAINNET);
|
||||
assertPilotCustodyActionAllowed(action, IS_PILOT);
|
||||
assertMainnetSchemaMigrationAcknowledged(action, process.env.WIKIDECK_MAINNET_SCHEMA_MIGRATION);
|
||||
assertMainnetCustodyOperationAcknowledged(action, process.env.WIKIDECK_MAINNET_CUSTODY_OPERATIONS);
|
||||
assertPilotSchemaMigrationAcknowledged(action, process.env.WIKIDECK_PILOT_SCHEMA_MIGRATION);
|
||||
assertPilotCustodyIdentityAcknowledged(action, process.env.WIKIDECK_PILOT_CUSTODY_IDENTITY,
|
||||
process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS);
|
||||
assertPilotReservationGrantAcknowledged(action, process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS);
|
||||
assertPilotAddressGrantAcknowledged(action, process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS);
|
||||
assertPilotObserverGrantAcknowledged(action, process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS);
|
||||
assertPilotDepositReviewAcknowledged(action, process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS);
|
||||
if (action === 'initialize-mainnet-custody-identity' && process.env.WIKIDECK_MAINNET_CUSTODY_IDENTITY !== 'initialize-mainnet-custody-v1') {
|
||||
throw new Error('Mainnet custody identity initialization requires WIKIDECK_MAINNET_CUSTODY_IDENTITY=initialize-mainnet-custody-v1');
|
||||
}
|
||||
if (!custodyActions.includes(action) && !depositActions.includes(action) && !pilotReviewActions.includes(action)) {
|
||||
throw new Error('Unsupported custody operation');
|
||||
}
|
||||
|
||||
function boundedPilotReviewSetting(name: string, fallback: number, minimum: number, maximum: number): number {
|
||||
const value = process.env[name];
|
||||
if (value === undefined) return fallback;
|
||||
if (!/^(0|[1-9]\d{0,8})$/.test(value)) throw new Error(`Invalid ${name}`);
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) throw new Error(`Invalid ${name}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
try {
|
||||
const reviewRole = pilotReviewActions.includes(action) ? process.env.WIKIDECK_PILOT_REVIEW_DB_ROLE : undefined;
|
||||
let config;
|
||||
if (pilotReviewActions.includes(action)) {
|
||||
const reviewUrlFile = process.env.WIKIDECK_PILOT_REVIEW_PG_URL_FILE;
|
||||
const reviewCaFile = process.env.WIKIDECK_PILOT_REVIEW_PG_CA_FILE;
|
||||
if (!IS_PILOT || process.env.WIKIDECK_PILOT_MODE !== 'private-mock-only' || !reviewRole ||
|
||||
!/^[a-z][a-z0-9_]{0,62}$/.test(reviewRole) || !reviewUrlFile || !reviewCaFile) {
|
||||
throw new Error('Pilot review requires the private mock-only profile, exact review-role name, and dedicated PostgreSQL TLS secret files');
|
||||
}
|
||||
config = await postgresConfig({ ...process.env,
|
||||
WIKIDECK_CUSTODY_PG_URL_FILE: reviewUrlFile,
|
||||
WIKIDECK_CUSTODY_PG_CA_FILE: reviewCaFile,
|
||||
WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls',
|
||||
WIKIDECK_CUSTODY_PG_LOCAL_TEST: undefined,
|
||||
WIKIDECK_CUSTODY_PG_INFRASTRUCTURE_ACK: undefined,
|
||||
});
|
||||
if (config.user !== reviewRole) throw new Error('Pilot review PostgreSQL secret does not use the configured read-only review role');
|
||||
} else config = await postgresConfig();
|
||||
if (action === 'audit') {
|
||||
const market = await PostgresCustody.open(config, { feeBps: 250, card: () => undefined });
|
||||
try { console.log(JSON.stringify(await market.audit())); } finally { await market.close(); }
|
||||
} else {
|
||||
const pool = new Pool(config);
|
||||
try {
|
||||
if (action === 'migrate' || action === 'migrate-mainnet-schema' || action === 'migrate-pilot-schema') {
|
||||
await migrateCustody(pool);
|
||||
console.log(action === 'migrate-mainnet-schema'
|
||||
? 'Mainnet custody schema verified/migrated; no runtime grants, deposits, or payments enabled.'
|
||||
: action === 'migrate-pilot-schema'
|
||||
? 'Pilot custody schema verified/migrated; no pilot identity, runtime grants, deposits, or payments enabled.'
|
||||
: 'Custody PostgreSQL schema verified/migrated. No funds activated.');
|
||||
}
|
||||
if (action === 'initialize-mainnet-custody-identity') {
|
||||
if (!IS_MAINNET || extraArgument) throw new Error('Expected mainnet initialize-mainnet-custody-identity CUSTODIAN_UUID');
|
||||
const identity = await initializeMainnetCustodyIdentity(pool, parseMainnetCustodianId(argument ?? ''));
|
||||
console.log(JSON.stringify({ initialized: true, network: identity.network, chainId: identity.chainId,
|
||||
custodianId: identity.id, crediting: 'reviewed-pending-only', withdrawals: 'disabled' }));
|
||||
}
|
||||
if (action === 'initialize-pilot-custody-identity') {
|
||||
if (!IS_PILOT || process.env.WIKIDECK_PILOT_MODE !== 'private-mock-only' || extraArgument) {
|
||||
throw new Error('Expected pilot profile, private-mock-only mode, and one custodian UUID');
|
||||
}
|
||||
const identity = await initializePilotCustodyIdentity(pool, parsePilotCustodianId(argument ?? ''));
|
||||
console.log(JSON.stringify({ initialized: true, marketNetwork: identity.marketNetwork,
|
||||
marketChainId: identity.marketChainId, moneroNetwork: identity.moneroNetwork,
|
||||
custodianId: identity.custodianId, activation: identity.activationStatus,
|
||||
deposits: 'disabled', withdrawals: 'disabled' }));
|
||||
}
|
||||
if (action === 'grant-pilot-deposit-reservation-runtime') {
|
||||
if (!IS_PILOT || process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS !== 'reservation-only-no-addresses' || extraArgument) {
|
||||
throw new Error('Pilot reservation grant requires the pilot profile, reservation-only acknowledgement, and one role name');
|
||||
}
|
||||
const identity = await pool.query('SELECT 1 FROM wk_custody.pilot_custody_identity WHERE singleton=1');
|
||||
if (!identity.rowCount) throw new Error('Initialize the separate pilot custody identity before granting reservation access');
|
||||
await grantPilotDepositReservationRuntime(pool, argument ?? '');
|
||||
console.log('Pilot durable index-reservation role granted; no Wallet-RPC address creation, credit, or withdrawal access.');
|
||||
}
|
||||
if (action === 'grant-pilot-deposit-address-runtime') {
|
||||
if (!IS_PILOT || process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS !== 'operator-triggered-addresses-only' || extraArgument) {
|
||||
throw new Error('Pilot address grant requires pilot profile, operator-triggered acknowledgement, and one role name');
|
||||
}
|
||||
const identity = await pool.query('SELECT 1 FROM wk_custody.pilot_custody_identity WHERE singleton=1');
|
||||
if (!identity.rowCount) throw new Error('Initialize pilot custody identity before granting address allocation access');
|
||||
await grantPilotDepositAddressRuntime(pool, argument ?? '');
|
||||
console.log('Pilot Wallet-RPC address allocator role granted for explicit operator action only; no HTTP enrollment or financial posting.');
|
||||
}
|
||||
if (action === 'grant-pilot-monero-observer-runtime') {
|
||||
if (!IS_PILOT || process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS !== 'pilot-observation-only-no-credit' || extraArgument) {
|
||||
throw new Error('Pilot observer grant requires pilot profile, observation-only acknowledgement, and one role name');
|
||||
}
|
||||
const identity = await pool.query('SELECT 1 FROM wk_custody.pilot_custody_identity WHERE singleton=1');
|
||||
if (!identity.rowCount) throw new Error('Initialize pilot custody identity before granting observation access');
|
||||
await grantPilotMoneroObserverRuntime(pool, argument ?? '');
|
||||
console.log('Pilot Monero observation-only role granted; no address allocation, credit, withdrawal, or public enrollment privileges.');
|
||||
}
|
||||
if (action === 'grant-pilot-monero-review-runtime') {
|
||||
if (!IS_PILOT || process.env.WIKIDECK_PILOT_MODE !== 'private-mock-only' ||
|
||||
process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS !== 'read-only-pilot-review-role-only' || extraArgument) {
|
||||
throw new Error('Pilot review role grant requires the private pilot profile, read-only-role acknowledgement, and one role name');
|
||||
}
|
||||
const identity = await pool.query('SELECT 1 FROM wk_custody.pilot_custody_identity WHERE singleton=1');
|
||||
if (!identity.rowCount) throw new Error('Initialize pilot custody identity before granting review access');
|
||||
await grantPilotMoneroDepositReviewRuntime(pool, argument ?? '');
|
||||
console.log('Pilot deposit review role granted read-only pilot evidence access; financial tables and all writes remain denied.');
|
||||
}
|
||||
if (action === 'grant-runtime') { await grantCustodyRuntime(pool, argument ?? ''); console.log('Runtime grants applied; no credentials printed.'); }
|
||||
if (action === 'grant-monero-observer-runtime') {
|
||||
await grantMoneroObserverRuntime(pool, argument ?? '');
|
||||
console.log('Separate Monero observation-only grants applied; journal access and deposit posting remain denied.');
|
||||
}
|
||||
if (action === 'grant-monero-deposit-allocator-runtime') {
|
||||
await grantMoneroDepositAllocatorRuntime(pool, argument ?? '');
|
||||
console.log('Separate Monero deposit-allocation grants applied; balance, journal, observation, and credit tables remain denied.');
|
||||
}
|
||||
if (action === 'grant-monero-withdrawal-runtime') {
|
||||
await grantMoneroWithdrawalRuntime(pool, argument ?? '');
|
||||
console.log('Separate customer-signed withdrawal-reservation grants applied; transaction signing and relay are not granted.');
|
||||
}
|
||||
if (action === 'grant-monero-credit-runtime') {
|
||||
await grantMoneroDepositCreditRuntime(pool, argument ?? '');
|
||||
console.log('Separate reviewed pending-credit coordinator grants applied; no credentials printed and no live deposits enabled.');
|
||||
}
|
||||
if (action === 'resolve-integrity') console.log(JSON.stringify(await resolveCustodyIncident(pool, argument ?? '')));
|
||||
if (depositActions.includes(action)) {
|
||||
const policy = await loadMoneroDepositReviewPolicy();
|
||||
if (action === 'deposit-prepare-credit') {
|
||||
if (!argument || !extraArgument) throw new Error('Expected deposit-prepare-credit WALLET_FINGERPRINT OBSERVATION_ID');
|
||||
console.log(JSON.stringify(await prepareDepositCreditReview(pool, argument, extraArgument, policy)));
|
||||
}
|
||||
if (action === 'deposit-stage-credit') {
|
||||
if (!argument || extraArgument) throw new Error('Expected deposit-stage-credit APPROVAL_JSON_FILE');
|
||||
const approval = await loadMoneroDepositReviewApproval(argument);
|
||||
// The coordinator validates the exact approval schema/signature before any write.
|
||||
console.log(JSON.stringify(await postReviewedDepositPendingCredit(pool, approval as DepositCreditApproval, policy)));
|
||||
}
|
||||
if (action === 'deposit-prepare-release') {
|
||||
if (!argument || extraArgument) throw new Error('Expected deposit-prepare-release CREDIT_UUID');
|
||||
console.log(JSON.stringify(await prepareDepositCreditReleaseReview(pool, argument, policy)));
|
||||
}
|
||||
if (action === 'deposit-release') {
|
||||
if (!argument || extraArgument) throw new Error('Expected deposit-release APPROVAL_JSON_FILE');
|
||||
const approval = await loadMoneroDepositReviewApproval(argument);
|
||||
// The coordinator validates the exact approval schema/signature before any write.
|
||||
console.log(JSON.stringify(await releaseReviewedDepositCredit(pool, approval as DepositCreditReleaseApproval, policy)));
|
||||
}
|
||||
if (action === 'deposit-reconcile-holds') {
|
||||
if (!argument || extraArgument) throw new Error('Expected deposit-reconcile-holds WALLET_FINGERPRINT');
|
||||
console.log(JSON.stringify({ holdsCreated: await reconcileDepositCreditReorgHolds(pool, argument) }));
|
||||
}
|
||||
if (action === 'deposit-prepare-resolve-hold') {
|
||||
if (!argument || !extraArgument) throw new Error('Expected deposit-prepare-resolve-hold CREDIT_UUID HOLD_UUID');
|
||||
console.log(JSON.stringify(await prepareDepositReorgResolution(pool, argument, extraArgument, policy)));
|
||||
}
|
||||
if (action === 'deposit-resolve-hold') {
|
||||
if (!argument || extraArgument) throw new Error('Expected deposit-resolve-hold APPROVAL_JSON_FILE');
|
||||
const approval = await loadMoneroDepositReviewApproval(argument);
|
||||
// The coordinator validates the exact approval schema/signature before any write.
|
||||
console.log(JSON.stringify(await resolveReleasedDepositReorgHold(pool, approval as DepositReorgResolutionApproval, policy)));
|
||||
}
|
||||
}
|
||||
if (pilotReviewActions.includes(action)) {
|
||||
if (!IS_PILOT || process.env.WIKIDECK_PILOT_MODE !== 'private-mock-only' || !argument || !extraArgument ||
|
||||
process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS !== 'pilot-deposit-review-preparation-only') {
|
||||
throw new Error('Pilot review requires the private pilot profile, preparation-only acknowledgement, wallet generation, and observation ID');
|
||||
}
|
||||
const currentUser = await pool.query('SELECT current_user AS role');
|
||||
if (currentUser.rows[0]?.role !== reviewRole) throw new Error('Pilot deposit review must use the dedicated read-only review role');
|
||||
const subject = await preparePilotMoneroDepositReview(pool, {
|
||||
walletGenerationId: argument,
|
||||
observationId: extraArgument,
|
||||
}, {
|
||||
minimumConfirmations: boundedPilotReviewSetting('WIKIDECK_PILOT_DEPOSIT_REVIEW_MIN_CONFIRMATIONS', 10, 10, 100_000),
|
||||
maximumObservationAgeMs: boundedPilotReviewSetting('WIKIDECK_PILOT_DEPOSIT_REVIEW_MAX_OBSERVATION_AGE_MS', 300_000, 10_000, 15 * 60_000),
|
||||
});
|
||||
console.log(JSON.stringify(subject));
|
||||
}
|
||||
if (action === 'import-sqlite') {
|
||||
if (!argument) throw new Error('Stopped source node DATA_DIR required');
|
||||
const directory = resolve(argument);
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const lockPath = join(directory, 'testnet-node.lock'), lock = await open(lockPath, 'wx', 0o600);
|
||||
try {
|
||||
await lock.writeFile(`custody-import:${process.pid}`);
|
||||
console.log(JSON.stringify(await importSqliteCustody(join(directory, 'testnet', 'custody-sandbox.sqlite'), pool)));
|
||||
} finally { await lock.close(); await unlink(lockPath); }
|
||||
}
|
||||
} finally { await pool.end(); }
|
||||
}
|
||||
} catch {
|
||||
console.error('Custody database operation failed. Check configuration, schema, stopped-node lock and migration state privately. No automatic fallback/reset was performed.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Pool } from 'pg';
|
||||
import { generateKeyPairSync, randomUUID, sign } from 'node:crypto';
|
||||
import { postgresConfig } from '../server/market/postgres-config.ts';
|
||||
import { auditPostgres } from '../server/market/postgres-custody.ts';
|
||||
import { custodyMessage, CUSTODY_POLICY, type CustodyCommand, type CustodyPage } from '../shared/custody.ts';
|
||||
import { address } from '../server/testnet/consensus.ts';
|
||||
|
||||
// Run inside the new sandbox application container. Optional explicit fictitious credit probe only.
|
||||
const credit = process.argv.slice(2);
|
||||
if (credit.length && (credit.length !== 1 || credit[0] !== '--credit')) throw new Error('Expected no arguments or --credit');
|
||||
const configuration = await postgresConfig();
|
||||
const pool = new Pool(configuration);
|
||||
try {
|
||||
// Verify server policy too: correct credentials must NOT authenticate over plaintext TCP.
|
||||
const plaintext = new Pool({ ...configuration, ssl: false, connectionTimeoutMillis: 3000 });
|
||||
try {
|
||||
let rejected = false;
|
||||
try { await plaintext.query('SELECT 1'); } catch { rejected = true; }
|
||||
if (!rejected) throw new Error('Database unexpectedly permits plaintext authentication');
|
||||
} finally { await plaintext.end(); }
|
||||
const c = await pool.connect();
|
||||
try {
|
||||
const tls = await c.query('SELECT ssl,version FROM pg_stat_ssl WHERE pid=pg_backend_pid()');
|
||||
if (tls.rows[0]?.ssl !== true) throw new Error('Database connection is not encrypted');
|
||||
const role = await c.query('SELECT current_user AS role');
|
||||
if (role.rows[0].role !== 'custody_runtime') throw new Error('Expected restricted sandbox runtime role');
|
||||
const page = await fetch('http://127.0.0.1:3001/api/custody', { signal: AbortSignal.timeout(10000) });
|
||||
if (!page.ok) throw new Error('Custody API unavailable');
|
||||
const data = await page.json() as CustodyPage;
|
||||
if (data.info.mode !== 'sandbox' || data.info.liveDeposits || data.info.liveWithdrawals || data.info.nftSettlement) throw new Error('Unexpected custody capabilities');
|
||||
if (credit.length) {
|
||||
const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
||||
const terms: Omit<CustodyCommand, 'signature'> = { version: 1, mode: 'sandbox', policy: CUSTODY_POLICY,
|
||||
network: data.info.network, chainId: data.info.chainId, custodianId: data.info.custodianId,
|
||||
actor: address(publicKey), publicKey, nonce: randomUUID(), expiresAt: Date.now() + 60000, action: { type: 'mock-credit' } };
|
||||
const signature = sign('sha256', Buffer.from(custodyMessage(terms)), { key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64');
|
||||
const result = await fetch('http://127.0.0.1:3001/api/custody/command', { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...terms, signature }), signal: AbortSignal.timeout(10000) });
|
||||
if (!result.ok || (await result.json()).availableAtomicXmr !== '1000000000000') throw new Error('Fictitious credit probe failed');
|
||||
// Test key never printed/persisted. The resulting fictitious liability remains in the sandbox journal.
|
||||
}
|
||||
await c.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
const audit = await auditPostgres(c); await c.query('COMMIT');
|
||||
console.log(JSON.stringify({ custodianId: data.info.custodianId, tls: tls.rows[0].version,
|
||||
role: role.rows[0].role, sandbox: true, ...audit }));
|
||||
} finally { c.release(); }
|
||||
} catch {
|
||||
console.error('Sandbox deployment check failed; no connection secrets logged.'); process.exitCode = 1;
|
||||
} finally { await pool.end(); }
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Pool } from 'pg';
|
||||
import { postgresConfig } from '../server/market/postgres-config.ts';
|
||||
import { migrateCustody, grantCustodyRuntime } from '../server/market/postgres-schema.ts';
|
||||
import { IS_MAINNET } from '../shared/network.ts';
|
||||
|
||||
// Explicit one-shot service in the local persistent sandbox stack, NOT HTTP-startup migrations.
|
||||
if (IS_MAINNET || process.env.WIKIDECK_LOCAL_PG_BOOTSTRAP !== 'sandbox-only') throw new Error('Local sandbox bootstrap only');
|
||||
const ca = '/secrets/admin/ca.crt';
|
||||
const config = (role: string) => postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: `/secrets/${role}/url`, WIKIDECK_CUSTODY_PG_CA_FILE: ca });
|
||||
try {
|
||||
const admin = new Pool(await config('admin'));
|
||||
try {
|
||||
const ownerConfig = await config('owner'), runtimeConfig = await config('runtime');
|
||||
const backupConfig = await postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: '/secrets/backup/url',
|
||||
WIKIDECK_CUSTODY_PG_CA_FILE: '/secrets/backup/ca.crt' });
|
||||
for (const [role, password] of [['custody_owner', ownerConfig.password], ['custody_runtime', runtimeConfig.password],
|
||||
['custody_backup', backupConfig.password]] as const) {
|
||||
// Static role allowlist; password is a generated hex token. Values are never printed, even on error.
|
||||
if (typeof password !== 'string' || !/^[a-f0-9]{64}$/.test(password)) throw new Error('Invalid generated credential');
|
||||
const exists = await admin.query('SELECT 1 FROM pg_roles WHERE rolname=$1', [role]);
|
||||
if (!exists.rowCount) await admin.query(`CREATE ROLE ${role} LOGIN PASSWORD '${password}' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`);
|
||||
}
|
||||
await admin.query('REVOKE CREATE ON SCHEMA public FROM PUBLIC; REVOKE ALL ON DATABASE wikideck_custody FROM PUBLIC');
|
||||
await admin.query(`GRANT CONNECT,CREATE ON DATABASE wikideck_custody TO custody_owner;
|
||||
GRANT CONNECT ON DATABASE wikideck_custody TO custody_runtime,custody_backup`);
|
||||
} finally { await admin.end(); }
|
||||
const owner = new Pool(await config('owner'));
|
||||
try {
|
||||
await migrateCustody(owner); await grantCustodyRuntime(owner, 'custody_runtime');
|
||||
await owner.query(`GRANT USAGE ON SCHEMA wk_custody TO custody_backup;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA wk_custody TO custody_backup;
|
||||
GRANT SELECT ON ALL SEQUENCES IN SCHEMA wk_custody TO custody_backup`);
|
||||
}
|
||||
finally { await owner.end(); }
|
||||
console.log('Local PostgreSQL schema and restricted runtime grants ready. Sandbox payments only.');
|
||||
} catch {
|
||||
console.error('Local PostgreSQL bootstrap failed; inspect roles, secret volumes, TLS and schema privately. No reset or fallback performed.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { createCoordinatedCustodyBackup, verifyCoordinatedCustodyBackup } from '../server/coordinated-backup.ts';
|
||||
|
||||
const [action, ...args] = process.argv.slice(2);
|
||||
if (action !== 'create' && action !== 'verify') throw new Error('Expected create or verify');
|
||||
const allowed = new Set(['--source', '--destination', '--consent', '--pg-restore-binary']);
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
if (!allowed.has(args[index]) || (args[index] !== '--consent' && (!args[index + 1] || args[index + 1].startsWith('--')))) {
|
||||
throw new Error('Invalid coordinated backup arguments');
|
||||
}
|
||||
if (args[index] !== '--consent') index++;
|
||||
}
|
||||
const value = (name: string) => { const index = args.indexOf(name); return index < 0 ? undefined : args[index + 1]; };
|
||||
const source = value('--source');
|
||||
if (!source) throw new Error('Explicit --source is required');
|
||||
let result: unknown;
|
||||
try {
|
||||
if (action === 'create') {
|
||||
const destination = value('--destination');
|
||||
if (!destination || !args.includes('--consent')) throw new Error('Create requires --destination and explicit --consent');
|
||||
result = await createCoordinatedCustodyBackup(source, destination);
|
||||
} else {
|
||||
if (value('--destination') || args.includes('--consent')) throw new Error('Verify accepts --source and optional --pg-restore-binary');
|
||||
result = await verifyCoordinatedCustodyBackup(source, value('--pg-restore-binary'));
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch {
|
||||
console.error('Coordinated backup failed; check stopped-node state, separate backup credentials, PostgreSQL tools, destination permissions and partial output privately. No source database was reset.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { loadMoneroDaemonProbeConfig, probeMoneroDaemon } from '../server/market/monero-daemon-probe.ts';
|
||||
|
||||
try {
|
||||
const config = await loadMoneroDaemonProbeConfig();
|
||||
const result = await probeMoneroDaemon(config);
|
||||
console.log(JSON.stringify({ component: 'monero-daemon-probe', ready: true, ...result }));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({ component: 'monero-daemon-probe', ready: false,
|
||||
reason: error instanceof Error ? error.message : 'Probe failed' }));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash, randomBytes, randomUUID, X509Certificate } from 'node:crypto';
|
||||
import { execFile as execFileCallback, spawn } from 'node:child_process';
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { createMoneroRpcGateway } from '../server/market/monero-rpc-gateway.ts';
|
||||
import { loadMoneroMtlsTransport } from '../server/market/monero-mtls-transport.ts';
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
const walletRpcBinary = process.argv[2];
|
||||
if (!walletRpcBinary || !/monero-wallet-rpc(?:\.exe)?$/i.test(walletRpcBinary)) {
|
||||
throw new Error('Pass the verified monero-wallet-rpc binary path');
|
||||
}
|
||||
const binary = resolve(walletRpcBinary), temp = await mkdtemp(join(tmpdir(), 'wikideck-mtls-stagenet-'));
|
||||
const walletPort = 18083, gatewayPort = 18085;
|
||||
const rpcBase = `http://127.0.0.1:${walletPort}/json_rpc`;
|
||||
const gatewayUrl = `https://127.0.0.1:${gatewayPort}/json_rpc`;
|
||||
const walletDir = join(temp, 'wallets'), walletName = 'mtls-watch-only';
|
||||
const walletPassword = randomBytes(32).toString('hex');
|
||||
let walletRpc, gateway, passed = false;
|
||||
const certs = Object.fromEntries(['ca.key','ca.crt','server.key','server.csr','server.crt','server.ext',
|
||||
'client.key','client.csr','client.crt','client.ext','rogue.key','rogue.csr','rogue.crt','rogue.ext']
|
||||
.map(name => [name, join(temp, name)]));
|
||||
|
||||
async function runOpenSsl(args) {
|
||||
await execFile('openssl', args, { cwd: temp, windowsHide: true, timeout: 15_000 });
|
||||
}
|
||||
async function makeCerts() {
|
||||
await runOpenSsl(['req','-x509','-newkey','rsa:2048','-nodes','-sha256','-days','1','-keyout',certs['ca.key'],
|
||||
'-out',certs['ca.crt'],'-subj','/CN=Wikideck Disposable Test CA']);
|
||||
await runOpenSsl(['req','-newkey','rsa:2048','-nodes','-sha256','-keyout',certs['server.key'],
|
||||
'-out',certs['server.csr'],'-subj','/CN=127.0.0.1']);
|
||||
await writeFile(certs['server.ext'], 'subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\nkeyUsage=digitalSignature,keyEncipherment\n');
|
||||
await runOpenSsl(['x509','-req','-sha256','-days','1','-in',certs['server.csr'],'-CA',certs['ca.crt'],
|
||||
'-CAkey',certs['ca.key'],'-CAcreateserial','-out',certs['server.crt'],'-extfile',certs['server.ext']]);
|
||||
for (const name of ['client','rogue']) {
|
||||
await runOpenSsl(['req','-newkey','rsa:2048','-nodes','-sha256','-keyout',certs[`${name}.key`],
|
||||
'-out',certs[`${name}.csr`],'-subj',`/CN=wikideck-${name}`]);
|
||||
await writeFile(certs[`${name}.ext`], 'extendedKeyUsage=clientAuth\nkeyUsage=digitalSignature,keyEncipherment\n');
|
||||
await runOpenSsl(['x509','-req','-sha256','-days','1','-in',certs[`${name}.csr`],'-CA',certs['ca.crt'],
|
||||
'-CAkey',certs['ca.key'],'-CAcreateserial','-out',certs[`${name}.crt`],'-extfile',certs[`${name}.ext`]]);
|
||||
}
|
||||
for (const file of Object.keys(certs)) await chmod(certs[file], 0o600);
|
||||
return new X509Certificate(await readFile(certs['client.crt'])).fingerprint256.replaceAll(':','').toLowerCase();
|
||||
}
|
||||
|
||||
async function rawRpc(method, params = {}) {
|
||||
console.log(`wallet-rpc: ${method}`);
|
||||
const id = randomUUID();
|
||||
const response = await fetch(rpcBase, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30_000),
|
||||
headers: { 'content-type':'application/json', accept:'application/json' },
|
||||
body: JSON.stringify({ jsonrpc:'2.0', id, method, params }) });
|
||||
assert.equal(response.status, 200);
|
||||
const data = await response.json(); assert.equal(data.id,id);
|
||||
if (data.error) { const error = new Error('wallet RPC method returned an error'); error.rpcCode = data.error.code; throw error; }
|
||||
console.log(`wallet-rpc: ${method} complete`);
|
||||
return data.result;
|
||||
}
|
||||
function startWallet(walletFile) {
|
||||
const args = ['--stagenet','--offline','--rpc-bind-ip=127.0.0.1',`--rpc-bind-port=${walletPort}`,
|
||||
'--disable-rpc-login',`--wallet-dir=${walletDir}`,'--non-interactive','--no-initial-sync',
|
||||
'--max-concurrency=1','--log-level=0',`--log-file=${join(temp,'wallet-rpc.log')}`];
|
||||
if (walletFile) { args.splice(args.indexOf(`--wallet-dir=${walletDir}`),1); args.push(`--wallet-file=${walletFile}`,`--password=${walletPassword}`); }
|
||||
walletRpc = spawn(binary,args,{shell:false,windowsHide:true,stdio:'ignore',cwd:temp}); walletRpc.on('error',()=>{});
|
||||
}
|
||||
async function waitWallet() {
|
||||
const deadline=Date.now()+60_000;
|
||||
while(Date.now()<deadline) {
|
||||
if(walletRpc.exitCode!==null) throw new Error(`Wallet RPC exited during startup (${walletRpc.exitCode})`);
|
||||
try { await rawRpc('get_version'); return; } catch { await new Promise(r=>setTimeout(r,100)); }
|
||||
}
|
||||
throw new Error('Wallet RPC readiness timeout');
|
||||
}
|
||||
async function stopWallet() {
|
||||
if(!walletRpc||walletRpc.exitCode!==null) return;
|
||||
await rawRpc('store').catch(()=>{}); await rawRpc('stop_wallet').catch(()=>{});
|
||||
await Promise.race([new Promise(r=>walletRpc.once('exit',r)),new Promise(r=>setTimeout(r,5000))]);
|
||||
if(walletRpc.exitCode===null) walletRpc.kill('SIGKILL');
|
||||
}
|
||||
async function gatewayCall(transport,method,params={}) {
|
||||
const id=randomUUID();
|
||||
const response=await transport(gatewayUrl,{method:'POST',redirect:'error',headers:{'content-type':'application/json',accept:'application/json'},
|
||||
body:JSON.stringify({jsonrpc:'2.0',id,method,params})});
|
||||
const data=await response.json();
|
||||
return {status:response.status,data};
|
||||
}
|
||||
async function rawGatewayStatus(method, params, cert, key, ca) {
|
||||
const body=Buffer.from(JSON.stringify({jsonrpc:'2.0',id:randomUUID(),method,params}));
|
||||
const [certPem,keyPem,caPem]=await Promise.all([readFile(cert),readFile(key),readFile(ca)]);
|
||||
return new Promise((resolveStatus,reject)=>{
|
||||
const request=httpsRequest({hostname:'127.0.0.1',port:gatewayPort,path:'/json_rpc',method:'POST',cert:certPem,key:keyPem,ca:caPem,
|
||||
rejectUnauthorized:true,minVersion:'TLSv1.3',headers:{'content-type':'application/json','content-length':body.length}},response=>{
|
||||
response.resume(); response.once('end',()=>resolveStatus(response.statusCode));
|
||||
});
|
||||
request.once('error',reject); request.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('stage: generate ephemeral TLS certificates');
|
||||
const fingerprint = await makeCerts();
|
||||
console.log('stage: start offline Stagenet Wallet RPC');
|
||||
startWallet(); await waitWallet();
|
||||
console.log('stage: create watch-only disposable wallet');
|
||||
await rawRpc('create_wallet',{filename:'source',password:walletPassword,language:'English'});
|
||||
const primary=(await rawRpc('get_address',{account_index:0})).address;
|
||||
const viewKey=(await rawRpc('query_key',{key_type:'view_key'})).key;
|
||||
const watch=await rawRpc('generate_from_keys',{filename:walletName,address:primary,viewkey:viewKey,
|
||||
password:walletPassword,restore_height:0,autosave_current:true});
|
||||
assert.equal(watch.address,primary);
|
||||
await assert.rejects(rawRpc('query_key',{key_type:'spend_key'}));
|
||||
await stopWallet(); startWallet(join(walletDir,walletName)); await waitWallet();
|
||||
console.log('stage: wallet is watch-only after restart');
|
||||
|
||||
gateway=await createMoneroRpcGateway({listenHost:'127.0.0.1',listenPort:gatewayPort,walletRpcUrl:rpcBase,
|
||||
server:{certificateFile:certs['server.crt'],privateKeyFile:certs['server.key'],clientCaFile:certs['ca.crt']},
|
||||
clientPolicies:[{fingerprint,roles:['observer','address-allocator']}]});
|
||||
console.log('stage: mTLS gateway listening');
|
||||
const validTransport=await loadMoneroMtlsTransport({url:gatewayUrl,clientCertificateFile:certs['client.crt'],
|
||||
clientPrivateKeyFile:certs['client.key'],serverCaFile:certs['ca.crt']});
|
||||
const info=await gatewayCall(validTransport,'get_address',{account_index:0,address_index:[0]});
|
||||
console.log('stage: authorized mTLS address read');
|
||||
assert.equal(info.status,200); assert.equal(info.data.result.address,primary);
|
||||
const sub=await gatewayCall(validTransport,'create_address',{account_index:0,label:`wikideck-deposit-v1:${randomUUID()}`,count:1});
|
||||
console.log('stage: authorized mTLS subaddress creation');
|
||||
assert.equal(sub.status,200); assert.equal(sub.data.result.address_index,1);
|
||||
const outputs=await gatewayCall(validTransport,'incoming_transfers',{transfer_type:'all',account_index:0,subaddr_indices:[1]});
|
||||
assert.equal(outputs.status,200);
|
||||
assert.equal(outputs.data.error,undefined,'incoming_transfers must be a supported JSON-RPC method');
|
||||
assert.deepEqual(outputs.data.result?.transfers ?? [],[],
|
||||
'a fresh watch-only Stagenet address must return an empty owned-output list');
|
||||
console.log('stage: incoming_transfers allowed; pinned RPC omits default empty result fields');
|
||||
const forbiddenStatus=await rawGatewayStatus('transfer',{destinations:[]},certs['client.crt'],certs['client.key'],certs['ca.crt']);
|
||||
assert.equal(forbiddenStatus,400,'gateway must reject transfer before Wallet RPC');
|
||||
console.log('stage: forbidden transfer blocked');
|
||||
|
||||
const rogueTransport=await loadMoneroMtlsTransport({url:gatewayUrl,clientCertificateFile:certs['rogue.crt'],
|
||||
clientPrivateKeyFile:certs['rogue.key'],serverCaFile:certs['ca.crt']});
|
||||
await assert.rejects(gatewayCall(rogueTransport,'get_address',{account_index:0,address_index:[0]}));
|
||||
console.log('stage: untrusted client certificate rejected');
|
||||
await assert.rejects(fetch(gatewayUrl,{method:'POST',headers:{'content-type':'application/json'},body:'{}',
|
||||
signal:AbortSignal.timeout(3000)}),'gateway must reject clients without a certificate');
|
||||
console.log('stage: missing client certificate rejected');
|
||||
passed=true;
|
||||
console.log(JSON.stringify({result:'passed',moneroWalletRpc:'0.18.5.4',network:'Stagenet',containerNetwork:'none',
|
||||
rpcAuthentication:'mTLS',tlsMinimum:'1.3',approvedClientAccepted:true,untrustedClientRejected:true,
|
||||
noCertificateRejected:true,forbiddenTransferRejected:true,allowedAddressCreation:true,allowedOutputRead:true,watchOnly:true,
|
||||
realFundsUsed:false,secretsOrAddressesLogged:false}));
|
||||
} finally {
|
||||
if(gateway) await new Promise(resolveClose=>gateway.close(()=>resolveClose()));
|
||||
await stopWallet(); await rm(temp,{recursive:true,force:true}); if(!passed) process.exitCode=1;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Pool } from 'pg';
|
||||
import { createMoneroWalletObserver, loadMoneroObserverConfig, moneroWalletFingerprint } from '../server/market/monero-observer.ts';
|
||||
import { loadMoneroMtlsTransport, loadMoneroMtlsTransportConfig } from '../server/market/monero-mtls-transport.ts';
|
||||
import { postgresConfig } from '../server/market/postgres-config.ts';
|
||||
import { runMoneroObserverWorker } from '../server/market/monero-observer-worker.ts';
|
||||
|
||||
if (process.platform === 'win32') throw new Error('Monero observer worker requires a POSIX deployment with verifiable protected-file permissions');
|
||||
if (process.env.WIKIDECK_MONERO_OBSERVER_WORKER !== '1') throw new Error('Set WIKIDECK_MONERO_OBSERVER_WORKER=1 to start the private observer worker');
|
||||
|
||||
const observerConfig = await loadMoneroObserverConfig();
|
||||
const transportConfig = await loadMoneroMtlsTransportConfig();
|
||||
if (new URL(observerConfig.url).href !== new URL(transportConfig.url).href) {
|
||||
throw new Error('Observer and mTLS transport endpoints do not match');
|
||||
}
|
||||
const transport = await loadMoneroMtlsTransport(transportConfig);
|
||||
const scanner = createMoneroWalletObserver(observerConfig, transport);
|
||||
const primaryAddress = await scanner.verifyMainnetWallet();
|
||||
const walletFingerprint = await moneroWalletFingerprint(primaryAddress);
|
||||
const pool = new Pool(await postgresConfig());
|
||||
pool.on('error', () => console.error(JSON.stringify({ component: 'monero-observer', event: 'database-unavailable' })));
|
||||
const abort = new AbortController();
|
||||
const stop = () => abort.abort();
|
||||
process.once('SIGINT', stop);
|
||||
process.once('SIGTERM', stop);
|
||||
|
||||
try {
|
||||
await runMoneroObserverWorker(pool, walletFingerprint, scanner, {
|
||||
pollIntervalMs: parsePollInterval(process.env.WIKIDECK_MONERO_OBSERVER_POLL_MS),
|
||||
signal: abort.signal,
|
||||
onBatch(result) {
|
||||
console.log(JSON.stringify({ component: 'monero-observer', event: 'scan-complete',
|
||||
scanHeight: result.scanHeight, walletHeight: result.walletHeight, caughtUp: result.caughtUp,
|
||||
pages: result.pages.length, observed: result.pages.reduce((sum, page) => sum + page.observed, 0),
|
||||
changed: result.pages.reduce((sum, page) => sum + page.changed, 0),
|
||||
reappeared: result.pages.reduce((sum, page) => sum + page.reappeared, 0),
|
||||
notSeen: result.pages.reduce((sum, page) => sum + page.notSeen, 0) }));
|
||||
},
|
||||
onFailure() { console.error(JSON.stringify({ component: 'monero-observer', event: 'scan-failed', action: 'retrying' })); },
|
||||
});
|
||||
} finally {
|
||||
process.off('SIGINT', stop);
|
||||
process.off('SIGTERM', stop);
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
function parsePollInterval(value: string | undefined): number {
|
||||
if (value === undefined) return 30_000;
|
||||
if (!/^[1-9]\d{0,5}$/.test(value)) throw new Error('Invalid Monero observer polling interval');
|
||||
const interval = Number(value);
|
||||
if (!Number.isSafeInteger(interval) || interval < 1_000 || interval > 15 * 60_000) {
|
||||
throw new Error('Invalid Monero observer polling interval');
|
||||
}
|
||||
return interval;
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
const walletRpcBinary = process.argv[2];
|
||||
if (!walletRpcBinary || !/monero-wallet-rpc(?:\.exe)?$/i.test(walletRpcBinary)) {
|
||||
throw new Error('Pass the path to the verified monero-wallet-rpc binary');
|
||||
}
|
||||
const rpcBinary = resolve(walletRpcBinary);
|
||||
const port = 18083;
|
||||
const endpoint = `http://127.0.0.1:${port}/json_rpc`;
|
||||
const walletPassword = randomBytes(32).toString('hex');
|
||||
const temp = await mkdtemp(join(tmpdir(), 'wikideck-stagenet-rpc-'));
|
||||
const walletDirectory = join(temp, 'wallets');
|
||||
const watchOnlyName = 'wikideck-stagenet-watch-only';
|
||||
let child;
|
||||
|
||||
function startWalletRpc(walletFile) {
|
||||
const args = [
|
||||
'--stagenet', '--offline', '--rpc-bind-ip=127.0.0.1', `--rpc-bind-port=${port}`,
|
||||
'--disable-rpc-login', `--wallet-dir=${walletDirectory}`,
|
||||
'--non-interactive', '--no-initial-sync', '--max-concurrency=1', '--log-level=0',
|
||||
'--log-file=' + join(temp, 'wallet-rpc.log'),
|
||||
];
|
||||
if (walletFile) {
|
||||
args.splice(args.indexOf(`--wallet-dir=${walletDirectory}`), 1);
|
||||
args.push(`--wallet-file=${walletFile}`, `--password=${walletPassword}`);
|
||||
}
|
||||
child = spawn(rpcBinary, args, { shell: false, windowsHide: true, stdio: 'ignore', cwd: temp });
|
||||
child.on('error', () => {});
|
||||
}
|
||||
|
||||
async function rpc(method, params = {}) {
|
||||
const id = randomUUID();
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
|
||||
});
|
||||
assert.equal(response.status, 200, `RPC ${method} status`);
|
||||
const body = await response.json();
|
||||
assert.equal(body.jsonrpc, '2.0');
|
||||
assert.equal(body.id, id);
|
||||
if (body.error) {
|
||||
const error = new Error(`RPC ${method} returned an error`);
|
||||
error.rpcError = body.error;
|
||||
throw error;
|
||||
}
|
||||
return body.result;
|
||||
}
|
||||
|
||||
async function waitUntilReady() {
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) throw new Error(`wallet-RPC exited during startup (${child.exitCode})`);
|
||||
try {
|
||||
return await rpc('get_version');
|
||||
} catch {
|
||||
await new Promise(resolveWait => setTimeout(resolveWait, 100));
|
||||
}
|
||||
}
|
||||
throw new Error('wallet-RPC did not become ready on loopback');
|
||||
}
|
||||
|
||||
async function stopWalletRpc() {
|
||||
if (!child || child.exitCode !== null) return;
|
||||
await rpc('store').catch(() => {});
|
||||
await rpc('stop_wallet').catch(() => {});
|
||||
await Promise.race([
|
||||
new Promise(resolveExit => child.once('exit', resolveExit)),
|
||||
new Promise(resolveTimeout => setTimeout(resolveTimeout, 5000)),
|
||||
]);
|
||||
if (child.exitCode === null) child.kill('SIGKILL');
|
||||
}
|
||||
|
||||
let created = false;
|
||||
try {
|
||||
console.log('stage: start offline stagenet wallet RPC');
|
||||
startWalletRpc();
|
||||
const version = await waitUntilReady();
|
||||
console.log('stage: RPC ready');
|
||||
assert.equal(version.release, true);
|
||||
|
||||
await rpc('create_wallet', { filename: 'temporary-source', password: walletPassword, language: 'English' });
|
||||
console.log('stage: disposable source wallet created');
|
||||
const originalAddress = (await rpc('get_address', { account_index: 0 })).address;
|
||||
const originalValidation = await rpc('validate_address', { address: originalAddress });
|
||||
assert.equal(originalValidation.valid, true);
|
||||
assert.equal(originalValidation.nettype, 'stagenet');
|
||||
const viewKey = (await rpc('query_key', { key_type: 'view_key' })).key;
|
||||
assert.match(viewKey, /^[a-f0-9]{64}$/);
|
||||
|
||||
const generated = await rpc('generate_from_keys', {
|
||||
filename: watchOnlyName,
|
||||
address: originalAddress,
|
||||
viewkey: viewKey,
|
||||
password: walletPassword,
|
||||
restore_height: 0,
|
||||
autosave_current: true,
|
||||
});
|
||||
console.log('stage: watch-only wallet generated');
|
||||
assert.match(generated.address, /^.{95}$/);
|
||||
assert.equal((await rpc('get_address', { account_index: 0 })).address, originalAddress);
|
||||
const watchValidation = await rpc('validate_address', { address: originalAddress });
|
||||
assert.equal(watchValidation.valid, true);
|
||||
assert.equal(watchValidation.nettype, 'stagenet');
|
||||
await assert.rejects(rpc('query_key', { key_type: 'spend_key' }));
|
||||
|
||||
const before = await rpc('get_address', { account_index: 0 });
|
||||
const reservedIndex = Math.max(...before.addresses.map(address => address.address_index)) + 1;
|
||||
let absent;
|
||||
try {
|
||||
absent = await rpc('get_address', { account_index: 0, address_index: [reservedIndex] });
|
||||
} catch (error) {
|
||||
assert.equal(error.rpcError?.code, -15);
|
||||
assert.match(error.rpcError?.message ?? '', /address index is out of bound/i);
|
||||
absent = { addresses: [] };
|
||||
}
|
||||
assert.equal(absent.addresses.length, 0, 'reserved index should be absent before create_address');
|
||||
|
||||
const label = `wikideck-deposit-v1:${randomUUID()}`;
|
||||
const lostResponse = await rpc('create_address', { account_index: 0, label, count: 1 });
|
||||
console.log('stage: one subaddress created; simulating lost application response');
|
||||
assert.equal(lostResponse.address_index, reservedIndex);
|
||||
assert.equal(lostResponse.addresses.length, 1);
|
||||
// Simulate the application losing the response: discard its address, then recover by reserved index.
|
||||
const recovered = await rpc('get_address', { account_index: 0, address_index: [reservedIndex] });
|
||||
assert.equal(recovered.addresses.length, 1);
|
||||
assert.equal(recovered.addresses[0].address, lostResponse.address);
|
||||
assert.equal(recovered.addresses[0].label, label);
|
||||
|
||||
await stopWalletRpc();
|
||||
console.log('stage: wallet RPC stopped after store');
|
||||
startWalletRpc(join(walletDirectory, watchOnlyName));
|
||||
await waitUntilReady();
|
||||
console.log('stage: wallet RPC restarted with watch-only wallet');
|
||||
assert.equal((await rpc('get_address', { account_index: 0 })).address, originalAddress);
|
||||
assert.equal((await rpc('validate_address', { address: originalAddress })).nettype, 'stagenet');
|
||||
await assert.rejects(rpc('query_key', { key_type: 'spend_key' }));
|
||||
const afterRestart = await rpc('get_address', { account_index: 0, address_index: [reservedIndex] });
|
||||
assert.equal(afterRestart.addresses.length, 1);
|
||||
assert.equal(afterRestart.addresses[0].address, recovered.addresses[0].address);
|
||||
assert.equal(afterRestart.addresses[0].label, label);
|
||||
created = true;
|
||||
console.log(JSON.stringify({
|
||||
result: 'passed',
|
||||
moneroWalletRpc: '0.18.5.4',
|
||||
network: 'Stagenet',
|
||||
offline: true,
|
||||
watchOnly: true,
|
||||
rpcBind: '127.0.0.1',
|
||||
subaddressIndex: reservedIndex,
|
||||
lostResponseRecovery: true,
|
||||
restartPersistence: true,
|
||||
realFundsUsed: false,
|
||||
addressOrKeysLogged: false,
|
||||
}));
|
||||
} finally {
|
||||
await stopWalletRpc();
|
||||
await rm(temp, { recursive: true, force: true });
|
||||
if (!created) process.exitCode = 1;
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Pool } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { IS_PILOT } from '../shared/network.ts';
|
||||
import { createMoneroDepositAddressRpc, loadMoneroObserverConfig } from '../server/market/monero-observer.ts';
|
||||
import { loadMoneroMtlsTransport, loadMoneroMtlsTransportConfig } from '../server/market/monero-mtls-transport.ts';
|
||||
import { postgresConfig } from '../server/market/postgres-config.ts';
|
||||
import { createOrRecoverPilotMoneroDepositAddress } from '../server/market/pilot-monero-deposit-allocation.ts';
|
||||
import { parsePilotCustodianId } from '../server/market/pilot-custody-identity.ts';
|
||||
import { loadPilotMoneroDepositRequestFile } from '../server/market/pilot-monero-deposit-request-file.ts';
|
||||
|
||||
if (!IS_PILOT || process.env.WIKIDECK_PILOT_MODE !== 'private-mock-only') {
|
||||
throw new Error('Pilot address command requires the private mock-only pilot profile');
|
||||
}
|
||||
if (process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS !== 'operator-triggered-addresses-only') {
|
||||
throw new Error('Set WIKIDECK_PILOT_CUSTODY_OPERATIONS=operator-triggered-addresses-only for this explicit operator action');
|
||||
}
|
||||
if (process.argv.length !== 3) throw new Error('Usage: pilot-monero-deposit-address.ts SIGNED_REQUEST_JSON_FILE');
|
||||
if (process.platform === 'win32') throw new Error('Pilot address command requires POSIX protected-file permission checks');
|
||||
|
||||
const request = await loadPilotMoneroDepositRequestFile(process.argv[2]);
|
||||
const custodianId = parsePilotCustodianId(process.env.WIKIDECK_PILOT_CUSTODIAN_ID ?? '');
|
||||
const requestCustodian = z.object({ custodianId: z.string().uuid() }).passthrough().parse(request).custodianId.toLowerCase();
|
||||
if (requestCustodian !== custodianId) throw new Error('Signed request does not match the configured pilot custodian');
|
||||
const walletGenerationId = process.env.WIKIDECK_MONERO_WALLET_GENERATION_ID ?? '';
|
||||
const walletFingerprint = process.env.WIKIDECK_MONERO_WALLET_FINGERPRINT ?? '';
|
||||
const accountText = process.env.WIKIDECK_MONERO_DEPOSIT_ACCOUNT_INDEX;
|
||||
if (!/^[a-f0-9-]{36}$/i.test(walletGenerationId) || !/^[a-f0-9]{64}$/.test(walletFingerprint) ||
|
||||
!accountText || !/^(0|[1-9]\d{0,9})$/.test(accountText) || Number(accountText) > 0xffff_ffff) {
|
||||
throw new Error('Invalid protected pilot watch-only wallet generation, fingerprint, or account configuration');
|
||||
}
|
||||
|
||||
const observerConfig = await loadMoneroObserverConfig({ ...process.env, WIKIDECK_MONERO_OBSERVER: '1' });
|
||||
const mtlsConfig = await loadMoneroMtlsTransportConfig();
|
||||
if (observerConfig.network !== 'mainnet' || new URL(observerConfig.url).href !== new URL(mtlsConfig.url).href) {
|
||||
throw new Error('Pilot Wallet-RPC config must match the protected Mainnet mTLS gateway endpoint');
|
||||
}
|
||||
const transport = await loadMoneroMtlsTransport(mtlsConfig);
|
||||
const rpc = createMoneroDepositAddressRpc(observerConfig, transport);
|
||||
const actualFingerprint = await rpc.verifyWallet();
|
||||
if (actualFingerprint !== walletFingerprint) throw new Error('Configured pilot wallet fingerprint does not match the protected Wallet-RPC wallet');
|
||||
|
||||
const pool = new Pool(await postgresConfig({
|
||||
...process.env,
|
||||
WIKIDECK_CUSTODY_PG_URL_FILE: process.env.WIKIDECK_PILOT_ADDRESS_PG_URL_FILE,
|
||||
WIKIDECK_CUSTODY_PG_CA_FILE: process.env.WIKIDECK_PILOT_ADDRESS_PG_CA_FILE,
|
||||
WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls',
|
||||
}));
|
||||
pool.on('error', () => console.error(JSON.stringify({ component: 'pilot-address', event: 'database-unavailable' })));
|
||||
try {
|
||||
const result = await createOrRecoverPilotMoneroDepositAddress(pool, rpc, {
|
||||
walletGenerationId,
|
||||
walletFingerprint: actualFingerprint,
|
||||
accountIndex: Number(accountText),
|
||||
}, request);
|
||||
// This is an operator-invoked address response only; no balance credit or deposit acceptance occurs here.
|
||||
console.log(JSON.stringify({ network: 'mainnet', marketNetwork: result.reservation.marketNetwork,
|
||||
marketChainId: result.reservation.marketChainId, custodianId: result.reservation.custodianId,
|
||||
allocationId: result.reservation.allocationId, beneficiary: result.reservation.beneficiary,
|
||||
addressIndex: result.reservation.addressIndex, address: result.address, replayed: result.replayed,
|
||||
crediting: 'disabled' }));
|
||||
} catch {
|
||||
console.error('Pilot address allocation failed or requires manual reconciliation; no funding/credit action was performed.');
|
||||
process.exitCode = 1;
|
||||
} finally { await pool.end(); }
|
||||
@@ -1,68 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Pool } from 'pg';
|
||||
import { IS_PILOT } from '../shared/network.ts';
|
||||
import { createMoneroWalletObserver, loadMoneroObserverConfig, moneroWalletFingerprint } from '../server/market/monero-observer.ts';
|
||||
import { loadMoneroMtlsTransport, loadMoneroMtlsTransportConfig } from '../server/market/monero-mtls-transport.ts';
|
||||
import { postgresConfig } from '../server/market/postgres-config.ts';
|
||||
import { parsePilotCustodianId, verifyPilotCustodyIdentity } from '../server/market/pilot-custody-identity.ts';
|
||||
import { readPilotMoneroObservationCursor, persistPilotMoneroObservationScan } from '../server/market/pilot-monero-observation-store.ts';
|
||||
|
||||
if (process.platform === 'win32') throw new Error('Pilot observer requires POSIX deployment with protected-file permission checks');
|
||||
if (!IS_PILOT || process.env.WIKIDECK_PILOT_MODE !== 'private-mock-only') {
|
||||
throw new Error('Pilot observer requires the private mock-only pilot profile');
|
||||
}
|
||||
if (process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS !== 'pilot-observation-only-no-credit') {
|
||||
throw new Error('Set WIKIDECK_PILOT_CUSTODY_OPERATIONS=pilot-observation-only-no-credit for this explicit observer action');
|
||||
}
|
||||
|
||||
const custodianId = parsePilotCustodianId(process.env.WIKIDECK_PILOT_CUSTODIAN_ID ?? '');
|
||||
const walletGenerationId = process.env.WIKIDECK_MONERO_WALLET_GENERATION_ID ?? '';
|
||||
const expectedFingerprint = process.env.WIKIDECK_MONERO_WALLET_FINGERPRINT ?? '';
|
||||
if (!/^[a-f0-9-]{36}$/i.test(walletGenerationId) || !/^[a-f0-9]{64}$/.test(expectedFingerprint)) {
|
||||
throw new Error('Invalid protected pilot watch-only wallet generation or fingerprint');
|
||||
}
|
||||
function positiveBounded(name: string, fallback: number, maximum: number): number {
|
||||
const value = process.env[name];
|
||||
if (value === undefined) return fallback;
|
||||
if (!/^[1-9]\d{0,5}$/.test(value)) throw new Error(`Invalid ${name}`);
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed > maximum) throw new Error(`Invalid ${name}`);
|
||||
return parsed;
|
||||
}
|
||||
const maxBlocks = positiveBounded('WIKIDECK_PILOT_OBSERVER_MAX_BLOCKS', 5000, 100_000);
|
||||
const overlap = positiveBounded('WIKIDECK_PILOT_OBSERVER_OVERLAP', 100, 10_000);
|
||||
|
||||
const observerConfig = await loadMoneroObserverConfig({ ...process.env, WIKIDECK_MONERO_OBSERVER: '1' });
|
||||
const transportConfig = await loadMoneroMtlsTransportConfig();
|
||||
if (observerConfig.network !== 'mainnet' || new URL(observerConfig.url).href !== new URL(transportConfig.url).href) {
|
||||
throw new Error('Pilot observer requires matching Mainnet Wallet-RPC and mTLS gateway endpoints');
|
||||
}
|
||||
const scanner = createMoneroWalletObserver(observerConfig, await loadMoneroMtlsTransport(transportConfig));
|
||||
const primaryAddress = await scanner.verifyMainnetWallet();
|
||||
const walletFingerprint = await moneroWalletFingerprint(primaryAddress);
|
||||
if (walletFingerprint !== expectedFingerprint) throw new Error('Configured pilot wallet fingerprint does not match protected Wallet-RPC');
|
||||
|
||||
const dbUrlFile = process.env.WIKIDECK_PILOT_OBSERVER_PG_URL_FILE;
|
||||
const dbCaFile = process.env.WIKIDECK_PILOT_OBSERVER_PG_CA_FILE;
|
||||
if (!dbUrlFile || !dbCaFile) throw new Error('Pilot observer PostgreSQL secret URL and CA files are required');
|
||||
const pool = new Pool(await postgresConfig({ ...process.env,
|
||||
WIKIDECK_CUSTODY_PG_URL_FILE: dbUrlFile,
|
||||
WIKIDECK_CUSTODY_PG_CA_FILE: dbCaFile,
|
||||
WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls' }));
|
||||
pool.on('error', () => console.error(JSON.stringify({ component: 'pilot-monero-observer', event: 'database-unavailable' })));
|
||||
try {
|
||||
const identity = await verifyPilotCustodyIdentity(pool, custodianId);
|
||||
const scanHeight = await readPilotMoneroObservationCursor(pool, walletGenerationId, walletFingerprint);
|
||||
const fromHeight = Math.max(0, scanHeight - overlap);
|
||||
const page = await scanner.scanIncoming(fromHeight, maxBlocks);
|
||||
const result = await persistPilotMoneroObservationScan(pool, { scanId: randomUUID(), walletGenerationId,
|
||||
walletFingerprint, expectedScanHeight: scanHeight, scannedAt: Date.now(), page });
|
||||
console.log(JSON.stringify({ component: 'pilot-monero-observer', network: identity.moneroNetwork,
|
||||
marketNetwork: identity.marketNetwork, walletHeight: page.walletHeight, scanHeight: result.scanHeight,
|
||||
caughtUp: result.scanHeight >= page.walletHeight, observed: result.observed, assigned: result.assigned,
|
||||
unallocated: result.unallocated, changed: result.changed, reappeared: result.reappeared,
|
||||
notSeen: result.notSeen, replayed: result.replayed, credits: 'disabled' }));
|
||||
} catch {
|
||||
console.error('Pilot observation failed or requires review; no balance credit was performed.');
|
||||
process.exitCode = 1;
|
||||
} finally { await pool.end(); }
|
||||
+4
-1
@@ -1,3 +1,5 @@
|
||||
export {};
|
||||
|
||||
// Opt-in loopback-only, read-only smoke load. Not a production capacity certification.
|
||||
const args=process.argv.slice(2);
|
||||
const argument=(key:string,fallback:string)=>{const i=args.indexOf(key);return i<0?fallback:args[i+1];};
|
||||
@@ -12,7 +14,8 @@ const started=performance.now();
|
||||
await Promise.all(Array.from({length:concurrency},async()=>{
|
||||
while(next<count){const i=next++,start=performance.now();
|
||||
try{
|
||||
const response=await fetch(new URL(i%3===0?'/api/market':'/api/summary',endpoint),{redirect:'error',signal:AbortSignal.timeout(5000)});
|
||||
const path=i%3===0?'/api/peers':'/api/summary';
|
||||
const response=await fetch(new URL(path,endpoint),{redirect:'error',signal:AbortSignal.timeout(5000)});
|
||||
const reader=response.body?.getReader();let bytes=0;
|
||||
if(reader)try{for(;;){const part=await reader.read();if(part.done)break;bytes+=part.value.byteLength;if(bytes>2*1024*1024)throw new Error('Response too large');}}finally{await reader.cancel();reader.releaseLock();}
|
||||
totalBytes+=bytes;statuses[response.status]=(statuses[response.status]??0)+1;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const containerArgs = ['create', '--network', 'none', '--entrypoint', 'node', 'wikideck-monero-mtls-test',
|
||||
'--experimental-strip-types', 'scripts/monero-mtls-stagenet-rehearsal.mjs', '/opt/monero/monero-wallet-rpc'];
|
||||
const containerId = execFileSync('docker', containerArgs, { encoding: 'utf8' }).trim();
|
||||
if (!/^[a-f0-9]{64}$/.test(containerId)) throw new Error('Docker returned an invalid rehearsal container identity');
|
||||
|
||||
let exitCode = 1;
|
||||
try {
|
||||
execFileSync('docker', ['cp', 'Z:\\Repositories\\wikideck\\server', `${containerId}:/app/`], { stdio: 'inherit' });
|
||||
execFileSync('docker', ['cp', 'Z:\\Repositories\\wikideck\\shared', `${containerId}:/app/`], { stdio: 'inherit' });
|
||||
execFileSync('docker', ['cp', 'Z:\\Repositories\\wikideck\\scripts\\monero-mtls-stagenet-rehearsal.mjs',
|
||||
`${containerId}:/app/scripts/monero-mtls-stagenet-rehearsal.mjs`], { stdio: 'inherit' });
|
||||
try {
|
||||
execFileSync('docker', ['start', '--attach', containerId], { stdio: 'inherit' });
|
||||
exitCode = 0;
|
||||
} catch (error) {
|
||||
exitCode = typeof error.status === 'number' ? error.status : 1;
|
||||
}
|
||||
} finally {
|
||||
execFileSync('docker', ['rm', '--force', containerId], { stdio: 'ignore' });
|
||||
}
|
||||
process.exitCode = exitCode;
|
||||
@@ -1,20 +0,0 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$containerId = & docker create --network none --entrypoint node wikideck-monero-mtls-test `
|
||||
--experimental-strip-types scripts/monero-mtls-stagenet-rehearsal.mjs /opt/monero/monero-wallet-rpc
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
$exitCode = 1
|
||||
try {
|
||||
& docker cp 'Z:\Repositories\wikideck\server' "${containerId}:/app/"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Copying current server source into the isolated rehearsal container failed.' }
|
||||
& docker cp 'Z:\Repositories\wikideck\shared' "${containerId}:/app/"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Copying current shared source into the isolated rehearsal container failed.' }
|
||||
& docker cp 'Z:\Repositories\wikideck\scripts\monero-mtls-stagenet-rehearsal.mjs' `
|
||||
"${containerId}:/app/scripts/monero-mtls-stagenet-rehearsal.mjs"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Copying the current rehearsal script failed.' }
|
||||
& docker start --attach $containerId
|
||||
$exitCode = $LASTEXITCODE
|
||||
} finally {
|
||||
& docker rm --force $containerId | Out-Null
|
||||
}
|
||||
exit $exitCode
|
||||
Reference in New Issue
Block a user