Signed, beneficiary-bount Monero subaddress enrollment

This commit is contained in:
2026-10-22 11:33:38 -04:00
parent 5d607c1521
commit b865807272
9 changed files with 266 additions and 28 deletions
+2
View File
@@ -154,3 +154,5 @@ Source review: 2026-10-20; documentation is capability evidence, not an audited
**Actual mTLS rehearsal result:** against the authenticated official v0.18.5.4 binary in Docker `--network none` on offline Stagenet, the approved ephemeral client certificate succeeded, a CA-trusted but non-allowlisted certificate and a client without a certificate were rejected, and a forbidden transfer method was rejected before Wallet RPC. This is an actual protocol handshake and allowlist test in one isolated container, not production network isolation or certificate lifecycle proof. Gateway/app wiring, deployment firewall policies, cert rotation, deposit observation/crediting and any payment capability remain unimplemented/disabled.
**Actual RPC integration blocker:** The v0.18.5.4 process rehearsal confirmed a login-protected Wallet RPC returns HTTP 401 with a Digest challenge; Basic auth also returns 401. The app does not implement legacy MD5 Digest. New groundwork replaces that idea with a local TLS 1.3 mTLS gateway and a client transport that emits no Authorization header. The gateway binds only to an explicitly configured private IP (never a wildcard/public interface), authenticates an allowlisted client certificate, permits only schema-bounded address/observation methods, and forwards to a fixed loopback Wallet RPC upstream. Wallet RPC would run without its own HTTP login only inside that protected boundary. This gateway is not deployed or wired to the application. Its isolated end-to-end certificate handshake has now passed against the official Wallet RPC; intended-host/network isolation, certificate provisioning/rotation, and production configuration remain unverified. Do not enable Wallet RPC outside the tested disposable boundary until those deployment controls pass review.
**Signed deposit-address enrollment slice (2026-10-22):** a server-side service now validates a short-lived customer-wallet signature over mainnet network/chain, custodian, beneficiary, wallet-generation fingerprint, account index and request ID, then uses the existing durable reserve-before-RPC allocator and deterministic-label recovery to return that customer's registered subaddress. A dedicated allocator database role is separated from custody-runtime, observer and credit roles. Schema v8 adds a database trigger preventing that role from inserting an already-registered/payable address; registration must follow a reserved row. PostgreSQL integration tests exercise replay, beneficiary uniqueness, wallet identity, role boundaries, no financial journals and attempted trigger bypass. This service is **not yet wired to an HTTP route or UI**, all wallet tests use fake RPC, and no deposits, credits or withdrawals are enabled. Do not display addresses to users until the complete credit/withdrawal/recovery flow has been validated.
+4 -2
View File
@@ -36,6 +36,8 @@ Status: active engineering plan; **not launch or payment authorization**. Update
**Single-host worker deployment increment (2026-10-22):** added a hardened systemd unit and protected configuration templates for the stated apartment-host model. Added a distinct mainnet-only, exact-acknowledgement PostgreSQL schema migration action; it runs the existing transactional, digest-checked migrations and grants no runtime permissions. PostgreSQL URL/CA files now require POSIX owner-only permissions. The worker remains non-root, private-network-only by host firewall contract, and separate from the API. This advances deployment preparation, not live service: no external host/DB/gateway was contacted, no credentials were provisioned, and no service was started. The private Monero daemon/Wallet-RPC/gateway, external monitoring and recovery, production database provisioning and independent review remain unprovided. Validation: image build passed, offline suite **196/196**, PostgreSQL suite **37/37**. See [the systemd deployment contract](../deploy/systemd/README.md).
**Signed deposit-enrollment slice (2026-10-22):** added a customer-wallet-signed request contract and server-side enrollment service that verifies mainnet profile, chain, custodian, beneficiary ownership, wallet-generation fingerprint, account index and a five-minute expiry before reserving a never-reused subaddress index and recovering/registering exactly one address. Added a separate allocator DB role, and schema v8 now forbids inserting pre-registered addresses directly; role permissions permit only allocation storage and the guarded reserved→registered fields. Replay returns the existing allocation; wallet mismatch/invalid signature fail before address RPC. PostgreSQL tests cover signed enrollment, replay, beneficiary uniqueness, role isolation, no journal writes and database-trigger bypass attempts. This is not exposed through HTTP/UI and no real wallet was contacted; it does not credit funds or establish a withdrawal path. Validation: build passed, offline suite **175/175**, PostgreSQL suite **38/38**.
## 1A. MVP release boundary — proposed safe first production-hosted release
The apartment/single-operator constraint changes the rollout and availability claims, not the safety evidence required to hold customer funds or valuable assets. Recommended MVP: **a public, clearly labeled non-monetary beta on the existing testnet**, with real XMR completely disabled, no custodial balances/deposits/withdrawals, and marketplace checkout/delivery disabled for valuable assets. Keep mining, browsing and wallet use only if they are explicitly described as experimental and users accept that data/ownership may be lost. This would be a production-hosted beta, **not mainnet production**, a custody MVP, or an independently secured chain.
@@ -143,14 +145,14 @@ Depends on the pooled-custody decision in ADR 002 and the separately reviewed NF
- Recovery and operations: include customer-to-subaddress mapping, wallet generation/high-water marks, journal, pending outputs, signed approvals and prepared transactions in encrypted backups. Restore without spending, reconcile with Monero state, then explicitly re-enable the treasury worker.
- Set measured liability/exposure caps and independent alert/stop controls. Stopping new deposits or orders must not abandon existing liabilities. Separate observation, ledger, operator review and spend-signing privileges.
Acceptance: full end-to-end staging against the verified supported processor/network, unauthorized-action tests, injected partial sends/outages/timeout/crash/reorg tests, actual-fund reconciliation procedure and reviewed secret-access controls. Any processor access, credentials or fund movement requires a separate explicit authorized test scope, budgets and network selection. This plan authorizes none.
Acceptance: full end-to-end staging against an isolated Monero daemon and watch-only wallet, unauthorized-action tests, injected outages/timeouts/crashes/reorgs, complete deposit-to-ledger and withdrawal-to-chain reconciliation, and reviewed secret/signing controls. Any real-fund movement requires a separate explicit scope, budget and network authorization. This plan authorizes none.
### P5 — buyer/seller policy, disputes and abuse
Policy is a product deliverable, not a footer disclaimer; implementation and legal review must match it.
- Publish host responsibility, contact/support channel, response targets, eligibility/evidence and who decides a dispute. Reserves and caps must be funded and enforceable operationally before they are advertised. No unqualified loss insurance or guaranteed reimbursement without a real funded obligation.
- Define ownership/delivery acceptance, payout hold, cancel/refund/release states and which party bears fees/reorg/processor/key-loss errors. Prevent double compensation. An expired invoice does not erase a legitimate incoming payment.
- Define ownership/delivery acceptance, payout hold, cancel/refund/release states and which party bears fees, reorg, key-loss, and late/partial/duplicate deposit outcomes. Prevent double compensation. An expired payment request does not erase a legitimate incoming payment.
- Host and author fees remain separately visible. Decide revenue recognition on settled sales versus refunds and who bears sending costs if seller payout succeeded but author payout failed. The author's fee does not imply author arbitration or host underwriting.
- US operator engages qualified counsel for applicable custody/money-transmission, sanctions, consumer, privacy and tax obligations before accepting real funds. Applicability is not decided by the software author/assistant or by calling the product “decentralized.” Do not assume identity collection is required or unnecessary without that review.
- Anti-abuse for fixed-price launch: short bounded reservations, address/account/request limits, deposit or admission policy only if approved, fee-aware dust handling, suspicious-activity and order-churn controls. Consider Sybil attacks; one wallet is not one person.
+7 -2
View File
@@ -3,7 +3,7 @@ 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 } from '../server/market/postgres-schema.ts';
grantMoneroDepositCreditRuntime, grantMoneroDepositAllocatorRuntime } from '../server/market/postgres-schema.ts';
import { importSqliteCustody } from '../server/market/custody-import.ts';
import { PostgresCustody } from '../server/market/postgres-custody.ts';
import { IS_MAINNET } from '../shared/network.ts';
@@ -20,7 +20,8 @@ import { prepareDepositCreditReview, postReviewedDepositPendingCredit, prepareDe
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 custodyActions = ['migrate', 'migrate-mainnet-schema', 'grant-runtime', 'grant-monero-observer-runtime', 'grant-monero-credit-runtime',
const custodyActions = ['migrate', 'migrate-mainnet-schema', 'grant-runtime', 'grant-monero-observer-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);
@@ -45,6 +46,10 @@ try {
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-credit-runtime') {
await grantMoneroDepositCreditRuntime(pool, argument ?? '');
console.log('Separate reviewed pending-credit coordinator grants applied; no credentials printed and no live deposits enabled.');
+4 -2
View File
@@ -3,6 +3,7 @@ export const CUSTODY_DB_ACTION_ARGUMENT_COUNTS = {
'migrate-mainnet-schema': 0,
'grant-runtime': 1,
'grant-monero-observer-runtime': 1,
'grant-monero-deposit-allocator-runtime': 1,
'grant-monero-credit-runtime': 1,
'import-sqlite': 1,
audit: 0,
@@ -23,8 +24,9 @@ export function assertCustodyDbActionAllowedOnNetwork(action: CustodyDbAction, i
if (action === 'migrate-mainnet-schema' && !isMainnet) {
throw new Error('Mainnet schema migration action requires the mainnet profile');
}
if (isMainnet && action !== 'grant-monero-observer-runtime' && action !== 'migrate-mainnet-schema') {
throw new Error('Only the separately scoped Monero observation-role grant is allowed on mainnet');
if (isMainnet && action !== 'grant-monero-observer-runtime' && action !== 'grant-monero-deposit-allocator-runtime' &&
action !== 'migrate-mainnet-schema') {
throw new Error('Only explicitly scoped Monero infrastructure-role grants and schema migration are allowed on mainnet');
}
}
+74
View File
@@ -0,0 +1,74 @@
import type { Pool } from 'pg';
import { address, verifySignature } from '../testnet/consensus.ts';
import { moneroDepositAddressRequestSchema, moneroDepositAddressMessage } from '../../shared/monero-deposit.ts';
import { createOrRecoverMoneroDepositSubaddress, reserveMoneroDepositAllocation } from './monero-deposit-allocation-store.ts';
import type { MoneroDepositAddressRpc } from './monero-observer.ts';
const MAX_REQUEST_LIFETIME_MS = 5 * 60_000;
type MainnetEnrollmentContext = {
custodianId: string;
chainId: string;
walletGenerationId: string;
walletFingerprint: string;
accountIndex: number;
now?: () => number;
};
export type MoneroDepositAddressResult = {
network: 'mainnet';
allocationId: string;
walletGenerationId: string;
addressIndex: number;
address: string;
replayed: boolean;
};
/**
* Verify an expiring customer-wallet signature and durably allocate that customer's one mainnet
* subaddress. This only enrolls an address; it never credits a balance, starts a sale, or moves XMR.
* Do not expose a public route until the matching credit, withdrawal, and recovery paths are ready.
*/
export async function enrollMoneroDepositAddress(pool: Pool, rpc: MoneroDepositAddressRpc,
context: MainnetEnrollmentContext, rawRequest: unknown): Promise<MoneroDepositAddressResult> {
const request = moneroDepositAddressRequestSchema.parse(rawRequest);
if (request.network !== 'wikideck-randomx-mainnet-1' || request.custodianId !== context.custodianId ||
request.chainId !== context.chainId || request.walletGenerationId !== context.walletGenerationId ||
request.walletFingerprint !== context.walletFingerprint || request.accountIndex !== context.accountIndex) {
throw new Error('Deposit address request does not match this mainnet custodian and wallet generation');
}
if (address(request.publicKey) !== request.actor) throw new Error('Deposit address signer does not own the requested beneficiary');
const { signature, ...unsigned } = request;
const message = moneroDepositAddressMessage(unsigned);
if (!verifySignature(request.publicKey, request.actor, message, signature)) {
throw new Error('Deposit address request signature is invalid');
}
const now = context.now?.() ?? Date.now();
if (!Number.isSafeInteger(now) || now < 0 || request.expiresAt < now ||
request.expiresAt > now + MAX_REQUEST_LIFETIME_MS) {
throw new Error('Deposit address request is expired or outside the allowed window');
}
const { allocation } = await reserveMoneroDepositAllocation(pool, {
requestId: request.requestId,
custodianId: context.custodianId,
marketNetwork: 'wikideck-randomx-mainnet-1',
marketChainId: context.chainId,
beneficiary: request.actor,
walletGenerationId: context.walletGenerationId,
walletFingerprint: context.walletFingerprint,
accountIndex: context.accountIndex,
reservedAt: now,
});
const registered = await createOrRecoverMoneroDepositSubaddress(pool, rpc, allocation.allocationId,
context.walletGenerationId, now);
if (registered.allocation.beneficiary !== request.actor || registered.allocation.custodianId !== context.custodianId ||
registered.allocation.marketChainId !== context.chainId || registered.allocation.moneroNetwork !== 'mainnet' ||
registered.allocation.status !== 'registered' || !registered.allocation.moneroAddress) {
throw new Error('Registered Monero deposit address does not match the signed beneficiary allocation');
}
return { network: 'mainnet', allocationId: registered.allocation.allocationId,
walletGenerationId: registered.allocation.walletGenerationId,
addressIndex: registered.allocation.addressIndex, address: registered.allocation.moneroAddress,
replayed: registered.replayed };
}
+53 -3
View File
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
export const CUSTODY_PG_VERSION = 7;
export const CUSTODY_PG_VERSION = 8;
export const CUSTODY_WRITE_LOCK = '7140824361';
export const MONERO_OBSERVER_WORKER_LOCK = '7140824362';
export const CUSTODY_SCHEMA_V1 = `
@@ -307,9 +307,25 @@ ALTER TABLE wk_custody.monero_deposit_reorg_resolutions
`;
export const CUSTODY_SCHEMA_V7_DIGEST = createHash('sha256')
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}\n-- custody migration v5 --\n${CUSTODY_MIGRATION_V5}\n-- custody migration v6 --\n${CUSTODY_MIGRATION_V6}\n-- custody migration v7 --\n${CUSTODY_MIGRATION_V7}`).digest('hex');
export const CUSTODY_MIGRATION_V8 = `
CREATE FUNCTION wk_custody.monero_deposit_allocation_insert_guard() RETURNS trigger LANGUAGE plpgsql
SET search_path=wk_custody,pg_catalog AS $$
BEGIN
IF NEW.status<>'reserved' OR NEW.monero_address IS NOT NULL OR NEW.registered_at IS NOT NULL THEN
RAISE EXCEPTION 'Monero deposit allocations must be inserted as address-free reservations';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER monero_deposit_allocations_insert_reserved
BEFORE INSERT ON wk_custody.monero_deposit_allocations
FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_deposit_allocation_insert_guard();
REVOKE ALL ON FUNCTION wk_custody.monero_deposit_allocation_insert_guard() FROM PUBLIC;
`;
export const CUSTODY_SCHEMA_V8_DIGEST = createHash('sha256')
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}\n-- custody migration v5 --\n${CUSTODY_MIGRATION_V5}\n-- custody migration v6 --\n${CUSTODY_MIGRATION_V6}\n-- custody migration v7 --\n${CUSTODY_MIGRATION_V7}\n-- custody migration v8 --\n${CUSTODY_MIGRATION_V8}`).digest('hex');
/** Retained alias for callers that need the initial schema definition in disposable fixtures. */
export const CUSTODY_SCHEMA = CUSTODY_SCHEMA_V1;
export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V7_DIGEST;
export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V8_DIGEST;
/** Explicit operator migration; the HTTP runtime never creates or upgrades schema. */
export async function migrateCustody(pool: Pool): Promise<void> {
@@ -352,6 +368,11 @@ export async function migrateCustody(pool: Pool): Promise<void> {
if (current === 6) {
await c.query(CUSTODY_MIGRATION_V7);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(7,$1)', [CUSTODY_SCHEMA_V7_DIGEST]);
current = 7;
}
if (current === 7) {
await c.query(CUSTODY_MIGRATION_V8);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(8,$1)', [CUSTODY_SCHEMA_V8_DIGEST]);
}
if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application');
await verifyCustodySchema(c);
@@ -366,7 +387,8 @@ export async function verifyCustodySchema(c: PoolClient): Promise<void> {
async function verifyCustodySchemaHistory(c: PoolClient): Promise<number> {
const rows = await c.query('SELECT version,digest FROM wk_custody.schema_versions ORDER BY version');
const expected = [CUSTODY_SCHEMA_V1_DIGEST, CUSTODY_SCHEMA_V2_DIGEST, CUSTODY_SCHEMA_V3_DIGEST,
CUSTODY_SCHEMA_V4_DIGEST, CUSTODY_SCHEMA_V5_DIGEST, CUSTODY_SCHEMA_V6_DIGEST, CUSTODY_SCHEMA_V7_DIGEST];
CUSTODY_SCHEMA_V4_DIGEST, CUSTODY_SCHEMA_V5_DIGEST, CUSTODY_SCHEMA_V6_DIGEST, CUSTODY_SCHEMA_V7_DIGEST,
CUSTODY_SCHEMA_V8_DIGEST];
if (!rows.rowCount || rows.rowCount > expected.length || rows.rows.some((row, index) =>
Number(row.version) !== index + 1 || row.digest !== expected[index])) {
throw new Error('Custody PostgreSQL schema version/digest history mismatch; explicit migration required');
@@ -464,3 +486,31 @@ export async function grantMoneroDepositCreditRuntime(pool: Pool, role: string,
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
finally { c.release(); }
}
/** Separate API role for immutable customer-bound subaddress allocation; it cannot read balances or post journals. */
export async function grantMoneroDepositAllocatorRuntime(pool: Pool, role: string,
custodyRuntimeRole = 'custody_runtime', observerRole = 'monero_observer',
creditRole = 'monero_deposit_credit'): Promise<void> {
if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid deposit-allocator role identifier');
if ([custodyRuntimeRole, observerRole, creditRole].includes(role)) {
throw new Error('Deposit allocator requires a separate role from custody, observer, and credit roles');
}
const name = `"${role}"`;
const c = await pool.connect();
try {
await c.query('BEGIN'); await verifyCustodySchema(c);
const r = await c.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolreplication,rolbypassrls FROM pg_roles WHERE rolname=$1', [role]);
if (!r.rowCount || Object.values(r.rows[0]).some(Boolean)) throw new Error('Deposit allocator role must exist without administrative privileges');
const memberships = await c.query('SELECT 1 FROM pg_auth_members WHERE member=(SELECT oid FROM pg_roles WHERE rolname=$1) LIMIT 1', [role]);
if (memberships.rowCount) throw new Error('Deposit allocator role must not inherit privileges through role membership');
const owner = await c.query("SELECT pg_get_userbyid(nspowner) AS owner FROM pg_namespace WHERE nspname='wk_custody'");
if (owner.rows[0].owner === role) throw new Error('Deposit allocator role must not own the custody schema');
await c.query(`GRANT USAGE ON SCHEMA wk_custody TO ${name};
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA wk_custody FROM ${name};
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA wk_custody FROM ${name};
GRANT SELECT,INSERT ON wk_custody.monero_deposit_allocations TO ${name};
GRANT UPDATE(status,monero_address,registered_at) ON wk_custody.monero_deposit_allocations TO ${name};`);
await c.query('COMMIT');
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
finally { c.release(); }
}
+33
View File
@@ -0,0 +1,33 @@
import { z } from 'zod';
const hex64 = z.string().regex(/^[a-f0-9]{64}$/);
const uuid = z.string().uuid();
const network = z.enum(['wikideck-randomx-mainnet-1', 'wikideck-randomx-testnet-6']);
/** Wallet-signed request to allocate the caller's own immutable Monero subaddress. */
export const moneroDepositAddressRequestSchema = z.object({
version: z.literal(1),
network,
chainId: hex64,
custodianId: uuid,
actor: hex64,
publicKey: z.string().min(1).max(256),
requestId: uuid,
walletGenerationId: uuid,
walletFingerprint: hex64,
accountIndex: z.number().int().min(0).max(0xffff_ffff).safe(),
expiresAt: z.number().int().nonnegative().safe(),
signature: z.string().min(1).max(256),
}).strict();
export type MoneroDepositAddressRequest = z.infer<typeof moneroDepositAddressRequestSchema>;
export type MoneroDepositAddressRequestUnsigned = Omit<MoneroDepositAddressRequest, 'signature'>;
/** Domain-separated, canonical terms for requesting one beneficiary-bound deposit address. */
export function moneroDepositAddressMessage(request: MoneroDepositAddressRequestUnsigned): string {
return JSON.stringify([
'wikideck-monero-deposit-address-v1', request.version, request.network, request.chainId,
request.custodianId, request.actor, request.publicKey, request.requestId,
request.walletGenerationId, request.walletFingerprint, request.accountIndex, request.expiresAt,
]);
}
+6 -2
View File
@@ -13,10 +13,14 @@ test('custody database CLI parses two-argument deposit operations without shifti
assert.deepEqual(parseCustodyDbArguments(['grant-monero-observer-runtime', 'monero_observer']), {
action: 'grant-monero-observer-runtime', argument: 'monero_observer', extraArgument: undefined,
});
assert.deepEqual(parseCustodyDbArguments(['grant-monero-deposit-allocator-runtime', 'monero_allocator']), {
action: 'grant-monero-deposit-allocator-runtime', argument: 'monero_allocator', extraArgument: undefined,
});
});
test('mainnet custody CLI permits only the observation-role grant', () => {
test('mainnet custody CLI permits only scoped Monero infrastructure grants and schema migration', () => {
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('grant-monero-observer-runtime', true));
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('grant-monero-deposit-allocator-runtime', true));
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('migrate-mainnet-schema', true));
assert.throws(() => assertCustodyDbActionAllowedOnNetwork('migrate-mainnet-schema', false), /requires the mainnet profile/);
assert.throws(() => assertMainnetSchemaMigrationAcknowledged('migrate-mainnet-schema', undefined), /acknowledgement/);
@@ -24,7 +28,7 @@ test('mainnet custody CLI permits only the observation-role grant', () => {
assert.doesNotThrow(() => assertMainnetSchemaMigrationAcknowledged('migrate-mainnet-schema', 'explicit-schema-migration-only'));
assert.doesNotThrow(() => assertMainnetSchemaMigrationAcknowledged('grant-monero-observer-runtime', undefined));
for (const action of ['migrate', 'grant-runtime', 'grant-monero-credit-runtime', 'audit', 'deposit-release'] as const) {
assert.throws(() => assertCustodyDbActionAllowedOnNetwork(action, true), /only the separately scoped/i);
assert.throws(() => assertCustodyDbActionAllowedOnNetwork(action, true), /only explicitly scoped/i);
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork(action, false));
}
});
+83 -17
View File
@@ -15,7 +15,8 @@ import { CustodyIntegrityError } from '../../server/market/custody-integrity.ts'
import { resolveCustodyIncident } from '../../server/market/custody-incident.ts';
import { CustodyMarket } from '../../server/market/custody.ts';
import { migrateCustody, grantCustodyRuntime, grantMoneroObserverRuntime, CUSTODY_SCHEMA_V1, CUSTODY_SCHEMA_V1_DIGEST,
grantMoneroDepositCreditRuntime, CUSTODY_MIGRATION_V2, CUSTODY_SCHEMA_V2_DIGEST } from '../../server/market/postgres-schema.ts';
grantMoneroDepositCreditRuntime, grantMoneroDepositAllocatorRuntime, CUSTODY_MIGRATION_V2,
CUSTODY_SCHEMA_V2_DIGEST } from '../../server/market/postgres-schema.ts';
import { persistMoneroObservationScan } from '../../server/market/monero-observation-store.ts';
import { moneroWalletFingerprint } from '../../server/market/monero-observer.ts';
import { runMoneroObserverBatch, runMoneroObserverWorker } from '../../server/market/monero-observer-worker.ts';
@@ -25,6 +26,7 @@ import { depositCreditApprovalMessage, depositCreditReleaseApprovalMessage, depo
type DepositCreditPolicy } from '../../server/market/monero-deposit-credit.ts';
import { createOrRecoverMoneroDepositSubaddress, registerMoneroDepositSubaddress,
reserveMoneroDepositAllocation } from '../../server/market/monero-deposit-allocation-store.ts';
import { enrollMoneroDepositAddress } from '../../server/market/monero-deposit-enrollment.ts';
import { importSqliteCustody } from '../../server/market/custody-import.ts';
import { postgresConfig, custodyStorage } from '../../server/market/postgres-config.ts';
import { openCustodyStorage } from '../../server/market/custody-storage.ts';
@@ -32,6 +34,7 @@ import { createBackup, restoreBackup } from '../../server/recovery.ts';
import { createCoordinatedCustodyBackup, verifyCoordinatedCustodyBackup } from '../../server/coordinated-backup.ts';
import { CUSTODY_POLICY, custodyMessage, type CustodyAction, type CustodyCommand, type CustodyActivity,
type CustodyListing, type CustodyOrder, type CustodyWithdrawal } from '../../shared/custody.ts';
import { moneroDepositAddressMessage, type MoneroDepositAddressRequestUnsigned } from '../../shared/monero-deposit.ts';
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
import { NETWORK } from '../../shared/network.ts';
import { CHAIN_ID, GENESIS_HASH, address, sha256 } from '../../server/testnet/consensus.ts';
@@ -197,7 +200,7 @@ test('explicit PostgreSQL migrations are idempotent and runtime role cannot alte
assert.equal((await f.market.audit()).assetsAtomicXmr, '1000000000000');
});
test('custody schema v1 upgrades additively through v7 without dropping existing metadata', async t => {
test('custody schema v1 upgrades additively through v8 without dropping existing metadata', async t => {
const admin = new Pool(config);
await admin.query('DROP SCHEMA IF EXISTS wk_custody CASCADE');
const client = await admin.connect();
@@ -211,7 +214,7 @@ test('custody schema v1 upgrades additively through v7 without dropping existing
try {
await migrateCustody(admin); await migrateCustody(admin);
const versions = await admin.query('SELECT version FROM wk_custody.schema_versions ORDER BY version');
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7]);
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7, 8]);
assert.equal((await admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='v1-preserved-marker'")).rows[0].value,
'existing-test-data');
for (const table of ['monero_observer_state', 'monero_transfer_observations', 'monero_observation_events', 'monero_observation_scans']) {
@@ -225,7 +228,7 @@ test('custody schema v1 upgrades additively through v7 without dropping existing
} finally { await admin.end(); }
});
test('custody schema v2 upgrades explicitly through v7 deposit lifecycle tables', async t => {
test('custody schema v2 upgrades explicitly through v8 deposit lifecycle tables', async t => {
const admin = new Pool(config);
await admin.query('DROP SCHEMA IF EXISTS wk_custody CASCADE');
const client = await admin.connect();
@@ -238,7 +241,7 @@ test('custody schema v2 upgrades explicitly through v7 deposit lifecycle tables'
await client.query('COMMIT');
await migrateCustody(admin); await migrateCustody(admin);
const versions = await admin.query('SELECT version FROM wk_custody.schema_versions ORDER BY version');
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7]);
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7, 8]);
assert.equal((await admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='v2-marker'")).rows[0].value, 'preserve');
assert.ok((await admin.query("SELECT to_regclass('wk_custody.monero_deposit_release_events') AS name")).rows[0].name);
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
@@ -337,6 +340,67 @@ test('deposit allocation reserves unique beneficiary-bound indexes before RPC an
} finally { await secondAllocatorProcess.end(); await custodyRuntime.end(); await observer.end(); }
});
test('signed mainnet deposit enrollment reserves and recovers one beneficiary address with the allocator role', async t => {
const f = await fixture(t);
await f.admin.query("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='monero_deposit_allocator_test') THEN CREATE ROLE monero_deposit_allocator_test LOGIN; END IF; END $$");
await grantMoneroDepositAllocatorRuntime(f.admin, 'monero_deposit_allocator_test');
await assert.rejects(grantMoneroDepositAllocatorRuntime(f.admin, 'custody_runtime'), /separate role/);
const allocator = new Pool({ ...config, user: 'monero_deposit_allocator_test' });
const walletGenerationId = randomUUID(), walletFingerprint = 'a'.repeat(64), chainId = 'b'.repeat(64);
const now = Date.now(), depositAddress = await syntheticSubaddress(8), labels = new Map<string, string>();
let createCalls = 0, verifyCalls = 0;
const rpc = {
async verifyWallet() { verifyCalls++; return walletFingerprint; },
async getSubaddress(accountIndex: number, addressIndex: number) {
const label = labels.get(`${accountIndex}:${addressIndex}`);
return label ? { address: depositAddress, label } : null;
},
async createSubaddress(accountIndex: number, label: string) {
createCalls++;
labels.set(`${accountIndex}:${createCalls - 1}`, label);
return { address: depositAddress, addressIndex: createCalls - 1 };
},
};
const context = { custodianId: f.market.id, chainId, walletGenerationId, walletFingerprint, accountIndex: 0,
now: () => now };
const signedRequest = (requestId: string) => {
const unsigned: MoneroDepositAddressRequestUnsigned = { version: 1, network: 'wikideck-randomx-mainnet-1',
chainId, custodianId: f.market.id, actor: buyer.actor, publicKey: buyer.publicKey, requestId,
walletGenerationId, walletFingerprint, accountIndex: 0, expiresAt: now + 60_000 };
return { ...unsigned, signature: buyer.sign(moneroDepositAddressMessage(unsigned)) };
};
try {
const request = signedRequest(randomUUID());
const created = await enrollMoneroDepositAddress(allocator, rpc, context, request);
assert.equal(created.network, 'mainnet'); assert.equal(created.address, depositAddress);
assert.equal(created.addressIndex, 0); assert.equal(created.replayed, false);
const replay = await enrollMoneroDepositAddress(allocator, rpc, context, request);
assert.equal(replay.allocationId, created.allocationId); assert.equal(replay.address, created.address);
assert.equal(replay.replayed, true); assert.equal(createCalls, 1);
assert.equal(verifyCalls, 1);
const newNonce = await enrollMoneroDepositAddress(allocator, rpc, context, signedRequest(randomUUID()));
assert.equal(newNonce.allocationId, created.allocationId, 'one beneficiary keeps one address per wallet generation');
assert.equal(newNonce.replayed, true); assert.equal(createCalls, 1);
await assert.rejects(enrollMoneroDepositAddress(allocator, rpc, context, { ...request, actor: other.actor }), /signature|beneficiary/);
const wrongChain = { ...signedRequest(randomUUID()), chainId: 'c'.repeat(64) };
await assert.rejects(enrollMoneroDepositAddress(allocator, rpc, context, wrongChain), /does not match/);
assert.equal(verifyCalls, 1, 'invalid signed terms must be rejected before Wallet RPC');
await assert.rejects(allocator.query('SELECT * FROM wk_custody.journals'), /permission denied/,
'the allocation role cannot read financial journals');
await assert.rejects(allocator.query('DELETE FROM wk_custody.monero_deposit_allocations WHERE allocation_id=$1',
[created.allocationId]), /permission denied/);
await assert.rejects(allocator.query(`INSERT INTO wk_custody.monero_deposit_allocations(
allocation_id,request_id,custodian_id,market_network,market_chain_id,beneficiary,wallet_generation_id,
wallet_fingerprint,monero_network,account_index,address_index,status,monero_address,request_digest,reserved_at,registered_at)
VALUES($1,$2,$3,'wikideck-randomx-mainnet-1',$4,$5,$6,$7,'mainnet',0,100,'registered',$8,$9,$10,$10)` ,
[randomUUID(), randomUUID(), f.market.id, chainId, other.actor, walletGenerationId, walletFingerprint,
await syntheticSubaddress(9), 'd'.repeat(64), now]), /inserted as address-free reservations/i,
'allocator credentials must not bypass the reserve-then-register state transition');
assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.journals')).rows[0].n, 0,
'deposit address enrollment must not credit balances or post journals');
} finally { await allocator.end(); }
});
test('Monero scan persistence is idempotent, reorg-visible and never posts customer credit', async t => {
const f = await fixture(t);
await f.admin.query("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='monero_observer') THEN CREATE ROLE monero_observer LOGIN; END IF; END $$");
@@ -478,21 +542,23 @@ test('reviewed Monero outputs post once to pending liability and reorgs create h
const custodyRuntime = new Pool(f.runtime);
const wallet = '8'.repeat(64), txid = '9'.repeat(64), outputPublicKey = 'a'.repeat(64);
const observationId = `${txid}:${outputPublicKey}`, now = Date.now(), scanId = randomUUID();
const generationId = randomUUID(), allocationId = randomUUID(), requestId = randomUUID();
const observation = { observationId, transactionId: txid, amountAtomicXmr: '9007199254740993', height: 90,
address: ADDITIONAL_MARKET_FEE.address, confirmations: 11, timestamp: now, unlockTime: '0',
accountIndex: 0, addressIndex: 4, outputPublicKey, globalOutputIndex: 123, locked: false, doubleSpendSeen: false };
const page = { network: 'mainnet' as const, walletHeight: 100, fromHeight: 0, throughHeight: 100,
primaryAddress: ADDITIONAL_MARKET_FEE.address, incoming: [observation] };
const generationId = randomUUID(), requestId = randomUUID();
const depositAddress = await syntheticSubaddress(10);
const policy: DepositCreditPolicy = { approvedReviewerOwners: [host.actor], minimumConfirmations: 10,
maximumObservationAgeMs: 120_000 };
try {
await f.admin.query(`INSERT INTO wk_custody.monero_deposit_allocations(
allocation_id,request_id,custodian_id,market_network,market_chain_id,beneficiary,wallet_generation_id,
wallet_fingerprint,monero_network,account_index,address_index,status,monero_address,request_digest,reserved_at,registered_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,'mainnet',0,4,'registered',$9,$10,$11,$12)`,
[allocationId, requestId, f.market.id, NETWORK, CHAIN_ID, buyer.actor, generationId, wallet,
ADDITIONAL_MARKET_FEE.address, 'b'.repeat(64), now - 1, now]);
const allocation = await reserveMoneroDepositAllocation(f.admin, { requestId, custodianId: f.market.id,
marketNetwork: NETWORK, marketChainId: CHAIN_ID, beneficiary: buyer.actor, walletGenerationId: generationId,
walletFingerprint: wallet, accountIndex: 0, reservedAt: now - 1 });
await registerMoneroDepositSubaddress(f.admin, { allocationId: allocation.allocation.allocationId,
walletGenerationId: generationId, moneroAddress: depositAddress, registeredAt: now });
const allocationId = allocation.allocation.allocationId;
const observation = { observationId, transactionId: txid, amountAtomicXmr: '9007199254740993', height: 90,
address: depositAddress, confirmations: 11, timestamp: now, unlockTime: '0',
accountIndex: 0, addressIndex: allocation.allocation.addressIndex, outputPublicKey, globalOutputIndex: 123,
locked: false, doubleSpendSeen: false };
const page = { network: 'mainnet' as const, walletHeight: 100, fromHeight: 0, throughHeight: 100,
primaryAddress: ADDITIONAL_MARKET_FEE.address, incoming: [observation] };
await persistMoneroObservationScan(observer, { scanId, walletFingerprint: wallet, expectedScanHeight: 0,
scannedAt: now, page });
await assert.rejects(custodyRuntime.query('SELECT * FROM wk_custody.monero_transfer_observations'));