Remove markets

This commit is contained in:
2026-10-28 18:33:52 -04:00
parent eea2deb1fb
commit b3a1179703
141 changed files with 554 additions and 17365 deletions
-28
View File
@@ -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"]
-171
View File
@@ -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 });
}
-243
View File
@@ -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;
}
-50
View File
@@ -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(); }
-39
View File
@@ -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;
}
-29
View File
@@ -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;
}
-11
View File
@@ -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;
}
-155
View File
@@ -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;
}
-54
View File
@@ -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;
}
-170
View File
@@ -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;
}
-64
View File
@@ -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(); }
-68
View File
@@ -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
View File
@@ -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