operator/library-only Wallet-RPC address creation and recovery

This commit is contained in:
2026-10-22 18:55:05 -04:00
parent 2ecb42c019
commit 83d3d104c1
8 changed files with 386 additions and 16 deletions
+6 -3
View File
@@ -18,9 +18,9 @@ The independent private chain uses a fixed pilot genesis time and its own determ
This stack does **not** connect to the operator's Monero daemon, create real Monero deposit addresses, scan a wallet, approve real credits, construct or relay withdrawals, or settle NFT sales against active consensus. Having a full node supplies only the blockchain daemon; it is not a Wallet-RPC service, watch-only deposit wallet, or spending wallet. Deposit enrollment and withdrawal request flags remain rejected outside the mainnet profile, and mainnet remains separately gated. Do not expose the service publicly or solicit funds.
### Pilot custody identity and durable deposit reservations (schema v11)
### Pilot custody identity and deposit reservations (schema v13)
Schema v10 added the immutable identity binding with separate fields for Wikideck market network/chain (`wikideck-randomx-pilot-1` and pilot chain ID) and Monero network (`mainnet`). Schema v11 adds a separate pilot reservation table. A signed beneficiary/wallet request can now reserve a never-reused subaddress index transactionally, with request replay, same-beneficiary recovery, concurrent index allocation, and conflict protection. Mainnet allocation/withdrawal constraints and v1-v10 migration definitions remain unchanged.
Schema v10 added the immutable identity binding with separate fields for Wikideck market network/chain (`wikideck-randomx-pilot-1` and pilot chain ID) and Monero network (`mainnet`). Schema v11 adds the separate pilot reservation table; v12 adds one-time address registration; v13 persists a create-intent state before the Wallet-RPC side effect. A signed beneficiary/wallet request reserves a never-reused subaddress index transactionally, with request replay, same-beneficiary recovery, concurrent index allocation, and conflict protection. Mainnet allocation/withdrawal constraints and v1-v12 migration definitions remain unchanged.
The reservation table has a composite foreign key to the immutable identity row, so direct SQL inserts cannot bind a reservation to a different custodian, Wikideck chain, or Monero network. The focused disposable PostgreSQL lifecycle test verifies index sequencing, replay, concurrent reservations, beneficiary conflict, and denied financial/address access for the restricted role. This implementation still does not create any address.
@@ -31,12 +31,15 @@ After an operator creates/provisions a distinct empty database and its schema-ow
1. With the `pilot` profile selected, run `custody:db migrate-pilot-schema` with `WIKIDECK_PILOT_SCHEMA_MIGRATION=explicit-pilot-schema-migration-only`.
2. Run `custody:db initialize-pilot-custody-identity <CUSTODIAN_UUID>` with `WIKIDECK_PILOT_MODE=private-mock-only`, `WIKIDECK_PILOT_CUSTODY_IDENTITY=initialize-pilot-custody-v1`, and `WIKIDECK_PILOT_CUSTODY_OPERATIONS=identity-only-no-funds`.
3. If using the reservation library against that database, create a separate least-privilege role out of band and run `custody:db grant-pilot-deposit-reservation-runtime <ROLE>` with `WIKIDECK_PILOT_CUSTODY_OPERATIONS=reservation-only-no-addresses`.
4. Before any operator-triggered address exercise, create a separate Wallet-RPC role out of band and run `custody:db grant-pilot-deposit-address-runtime <ROLE>` with `WIKIDECK_PILOT_CUSTODY_OPERATIONS=operator-triggered-addresses-only`.
The schema migration does not grant runtime roles. The reservation role can read schema-version digests and the immutable pilot identity, execute a fixed isolation predicate, and read/insert immutable reservation rows. It cannot read custody metadata, balances, journals, mock custody, observer data, withdrawal intents, or alter reservation history. The isolation predicate uses a fixed-search-path schema-owner function; the role does not receive broad SELECT access just to check that the dedicated database remains unpopulated.
The CLI refuses other custody actions on the pilot profile; mainnet keeps its existing separate controls.
The library path verifies the signed pilot request, checks both network identities and configured wallet-generation/fingerprint/account, then reserves an index. It intentionally stops there: no Wallet-RPC call, address creation/registration, address response, observer, credit, withdrawal, or HTTP enrollment route exists. A durable reserved index is never reclaimed; wallet-side index advancement or lost RPC responses must be reconciled before address-generation support is added. Existing mainnet enrollment remains unchanged. Do not treat a reserved index or valid signature as approval to accept funds.
The operator/library-only address path verifies the signed pilot request, checks both network identities and configured wallet-generation/fingerprint/account, and uses the narrow Wallet-RPC client to inspect/create at the exact reserved index. It requires the stable reservation label and independently reads back/validates the returned mainnet subaddress before recording it. A create-intent timestamp is committed before the RPC side effect. If the process loses the response but the address exists at that exact index/label, a retry recovers it without creating another address. If no matching address exists after a create intent, retries stop with an uncertain outcome for manual reconciliation; they do **not** issue another create call. An unexpected consumed index is likewise held for operator investigation.
There is still no HTTP enrollment route, observer startup, customer credit, withdrawal, or sale activation. The function returns an address only to its operator/library caller. Keep Wallet-RPC off the public Internet and the credentials private. Existing mainnet enrollment remains unchanged. Do not treat an allocated address as approval to accept funds.
### Reusing the operator-hosted daemon
+3 -2
View File
@@ -9,7 +9,8 @@ Updated: 2026-10-22. Implementation plan for a private, operator-supervised func
- **Next integration slice implemented:** added an operator-triggered, read-only `monerod get_info` probe, opt-in Compose check service, and mocked regression coverage. The Linux test image builds and focused probe/deployment tests pass (6/6). The probe validates expected Mainnet/Stagenet identity and synchronization, but has not been run against the operator's node because its private endpoint/configuration has not been provided. It does not enable wallet access, addresses, deposits, or credits. See [the probe setup and security boundary](real-xmr-pilot-deployment.md).
- **Pilot identity/schema slice implemented:** schema v10 additively records a separate identity binding `wikideck-randomx-pilot-1` + current pilot chain ID to Monero `mainnet`, with an immutable `identity-only` activation status. Schema v11 adds separate immutable pilot reservation rows. Explicit pilot-only migration, identity, and reservation-role CLI actions require exact acknowledgements, reject non-pilot profiles, and refuse reused financial databases.
- **Durable pilot allocation implemented:** a signed pilot request now reserves the next never-reused index transactionally, serializes concurrent allocators, replays idempotently, and rejects request/beneficiary conflicts. PostgreSQL enforces the reservation's custodian, market-chain, and Monero-network binding with a composite foreign key to the immutable pilot identity. A dedicated role can read schema digests and pilot identity, execute the isolation check, and read/insert reservations only; it cannot read balances, register addresses, or access observation, credit, or withdrawal tables. This code does not call Wallet-RPC, return a Monero address, or connect to HTTP.
- **Validation:** focused PostgreSQL allocator lifecycle passed, covering first reservation, request replay, same-beneficiary recovery, concurrent index allocation, conflict rejection, and least-privilege denials. The schema v1-to-v11 migration test passed and the Linux test image builds. The serial offline suite passed 189/189 after the allocator implementation; no operator database or real node was accessed.
- **Wallet-RPC address create/recover implemented:** the operator/library-only function verifies the configured wallet fingerprint, checks the reserved index and stable allocation label, persists a create intent before `create_address`, then reads back and validates the exact Mainnet subaddress before registering it once. Lost responses recover by the stable label without repeating creation; ambiguous missing responses remain in a durable `creating` state and stop for operator reconciliation. No HTTP enrollment, observer, credit, withdrawal, or sale path is enabled.
- **Validation:** serial offline suite passed 189/189; disposable PostgreSQL suite passed 44/44, including schema v13 migration and the pilot reservation/address lifecycle tests. The focused address test verifies lost-response recovery and that an unresolved create is not sent twice. No operator database or real Monero node was accessed.
- **Step 1 is not complete.** The current pilot stack does not connect to that external daemon and still has no Wallet-RPC gateway, observer, real-fund deposit or withdrawal path. It is useful for isolated UI/mock-flow work only and must not receive funds.
- **Validation history:** earlier pilot-only checks passed 10/10. An earlier full-suite attempt had a transient RandomX peer-gossip failure; the most recent serial offline suite subsequently exited 0.
- **Steps 2–5:** not started. Existing deposit/withdrawal APIs still require the separately gated mainnet profile; the new pilot record is identity metadata only. Sale-lock transactions remain candidate-only and are not accepted by active consensus.
@@ -121,4 +122,4 @@ Keep the functional essentials: signed authorization, correct network binding, p
## Next coding task
**Next: implement Wallet-RPC create/recover for durable pilot reservations through the existing private mTLS wallet gateway, with lost-response recovery and out-of-band index-consumption tests.** Do not expose enrollment over HTTP or enable credits. Keep addresses private and operator-triggered until actual withdrawals, recovery, and financial review are ready. Run the daemon health check when protected endpoint config is available; no duplicate daemon is planned. Keep the deployment mock-only until network binding, reviewed credit, actual withdrawal execution, recovery, and sale settlement are connected. Continue treasury execution and active sale-lock settlement as parallel tracks once their shared identity boundary is defined. Completion means a buyer purchasing a card and the seller receiving withdrawable XMR—not another scaffold or release checklist.
**Next: add an operator-only CLI entrypoint for the pilot allocation/create-or-recover path, then test it end-to-end with the private mTLS Wallet-RPC gateway.** Keep HTTP enrollment disabled. Review actual protected wallet configuration, wallet type, and external-daemon network binding before creating any addresses. Deposits remain unusable until observation, reviewed credit, withdrawal execution, and recovery are all connected.
+13 -1
View File
@@ -4,7 +4,7 @@ import { resolve, join } from 'node:path';
import { postgresConfig } from '../server/market/postgres-config.ts';
import { migrateCustody, grantCustodyRuntime, grantMoneroObserverRuntime,
grantMoneroDepositCreditRuntime, grantMoneroDepositAllocatorRuntime, grantMoneroWithdrawalRuntime,
grantPilotDepositReservationRuntime } from '../server/market/postgres-schema.ts';
grantPilotDepositReservationRuntime, grantPilotDepositAddressRuntime } 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';
@@ -15,6 +15,7 @@ import { loadMoneroDepositReviewApproval, loadMoneroDepositReviewPolicy } from '
import { assertCustodyDbActionAllowedOnNetwork, assertMainnetSchemaMigrationAcknowledged,
assertMainnetCustodyOperationAcknowledged, assertPilotCustodyActionAllowed, assertPilotSchemaMigrationAcknowledged,
assertPilotCustodyIdentityAcknowledged, assertPilotReservationGrantAcknowledged,
assertPilotAddressGrantAcknowledged,
parseCustodyDbArguments } from '../server/market/custody-db-args.ts';
import { prepareDepositCreditReview, postReviewedDepositPendingCredit, prepareDepositCreditReleaseReview,
releaseReviewedDepositCredit, reconcileDepositCreditReorgHolds, prepareDepositReorgResolution,
@@ -28,6 +29,7 @@ const depositActions = ['deposit-prepare-credit', 'deposit-stage-credit', 'depos
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-monero-withdrawal-runtime',
'grant-monero-deposit-allocator-runtime', 'grant-monero-credit-runtime',
'import-sqlite', 'audit', 'resolve-integrity'];
@@ -40,6 +42,7 @@ assertPilotSchemaMigrationAcknowledged(action, process.env.WIKIDECK_PILOT_SCHEMA
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);
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');
}
@@ -85,6 +88,15 @@ try {
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-runtime') { await grantCustodyRuntime(pool, argument ?? ''); console.log('Runtime grants applied; no credentials printed.'); }
if (action === 'grant-monero-observer-runtime') {
await grantMoneroObserverRuntime(pool, argument ?? '');
+9 -1
View File
@@ -5,6 +5,7 @@ export const CUSTODY_DB_ACTION_ARGUMENT_COUNTS = {
'migrate-pilot-schema': 0,
'initialize-pilot-custody-identity': 1,
'grant-pilot-deposit-reservation-runtime': 1,
'grant-pilot-deposit-address-runtime': 1,
'grant-runtime': 1,
'grant-monero-observer-runtime': 1,
'grant-monero-deposit-allocator-runtime': 1,
@@ -54,7 +55,8 @@ export function assertMainnetCustodyOperationAcknowledged(action: CustodyDbActio
}
export function assertPilotCustodyActionAllowed(action: CustodyDbAction, isPilot: boolean): void {
const pilotActions = ['migrate-pilot-schema', 'initialize-pilot-custody-identity', 'grant-pilot-deposit-reservation-runtime'];
const pilotActions = ['migrate-pilot-schema', 'initialize-pilot-custody-identity',
'grant-pilot-deposit-reservation-runtime', 'grant-pilot-deposit-address-runtime'];
if ((isPilot && !pilotActions.includes(action)) || (!isPilot && pilotActions.includes(action))) {
throw new Error(isPilot
? 'Pilot profile permits only pilot schema migration and identity-only initialization through this CLI'
@@ -68,6 +70,12 @@ export function assertPilotReservationGrantAcknowledged(action: CustodyDbAction,
}
}
export function assertPilotAddressGrantAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void {
if (action === 'grant-pilot-deposit-address-runtime' && acknowledgement !== 'operator-triggered-addresses-only') {
throw new Error('Pilot address role grant requires explicit operator-triggered/address-only acknowledgement');
}
}
export function assertPilotSchemaMigrationAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void {
if (action === 'migrate-pilot-schema' && acknowledgement !== 'explicit-pilot-schema-migration-only') {
throw new Error('Pilot schema migration requires explicit pilot schema-migration acknowledgement');
@@ -1,6 +1,8 @@
import { createHash, randomUUID } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
import { z } from 'zod';
import type { MoneroDepositAddressRpc } from './monero-observer.ts';
import { validateXmrSubaddress } from './xmr.ts';
import { IS_PILOT, NETWORK } from '../../shared/network.ts';
import { CHAIN_ID } from '../testnet/consensus.ts';
import type { PilotCustodyIdentity } from './pilot-custody-identity.ts';
@@ -42,6 +44,13 @@ export type PilotDepositAllocationIntent = {
accountIndex: number; requestDigest: string;
};
type ReservationAddressRow = ReservationRow & {
status: 'reserved' | 'creating' | 'registered';
create_started_at: string | null;
monero_address: string | null;
registered_at: string | null;
};
function toReservation(row: ReservationRow): PilotDepositReservation {
return { allocationId: row.allocation_id, requestId: row.request_id, custodianId: row.custodian_id,
marketNetwork: row.market_network, marketChainId: row.market_chain_id, moneroNetwork: row.monero_network,
@@ -166,3 +175,119 @@ export async function reservePilotMoneroDepositRequest(pool: Pool,
const intent = validatePilotMoneroDepositRequest({ ...context, identity }, rawRequest);
return reservePilotMoneroDepositIndex(pool, identity, intent, reservedAt);
}
async function registerPilotAddress(pool: Pool, reservation: PilotDepositReservation, moneroAddress: string,
registeredAt: number): Promise<{ reservation: PilotDepositReservation; address: string; replayed: boolean }> {
const checkedAddress = await validateXmrSubaddress(moneroAddress);
if (!Number.isSafeInteger(registeredAt) || registeredAt < reservation.reservedAt) {
throw new Error('Pilot address registration predates its durable index reservation');
}
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on");
const result = await client.query<ReservationAddressRow>(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations
WHERE allocation_id=$1 AND wallet_generation_id=$2`, [reservation.allocationId, reservation.walletGenerationId]);
if (!result.rowCount) throw new Error('Pilot deposit reservation was not found for this wallet generation');
const row = result.rows[0];
if (row.status === 'registered') {
if (row.monero_address !== checkedAddress) throw new Error('Registered pilot deposit address cannot be replaced');
await client.query('COMMIT');
return { reservation: toReservation(row), address: checkedAddress, replayed: true };
}
if (row.status !== 'creating' || row.create_started_at === null || row.monero_address !== null ||
row.registered_at !== null || registeredAt < Number(row.create_started_at)) {
throw new Error('Pilot deposit reservation registration state is inconsistent');
}
const updated = await client.query<ReservationAddressRow>(`UPDATE wk_custody.pilot_monero_deposit_reservations
SET status='registered',monero_address=$2,registered_at=$3
WHERE allocation_id=$1 AND status='creating' AND monero_address IS NULL RETURNING *`, [reservation.allocationId, checkedAddress, registeredAt]);
if (!updated.rowCount) {
const already = await client.query<ReservationAddressRow>(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations
WHERE allocation_id=$1 AND status='registered'`, [reservation.allocationId]);
if (!already.rowCount || already.rows[0].monero_address !== checkedAddress) {
throw new Error('Pilot deposit reservation changed during address registration');
}
await client.query('COMMIT');
return { reservation: toReservation(already.rows[0]), address: checkedAddress, replayed: true };
}
await client.query('COMMIT');
return { reservation: toReservation(updated.rows[0]), address: checkedAddress, replayed: false };
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
finally { client.release(); }
}
/**
* Operator/library-only address creation for a durable reservation. A lost create_address
* response is recovered by the stable label at the exact reserved index on the next call.
* No HTTP route invokes this function and the result is not a credit or payment authorization.
*/
export async function createOrRecoverPilotMoneroDepositAddress(pool: Pool, rpc: MoneroDepositAddressRpc,
context: Omit<PilotDepositRequestContext, 'identity'>, rawRequest: unknown,
now = Date.now()): Promise<{ reservation: PilotDepositReservation; address: string; replayed: boolean }> {
const reserved = await reservePilotMoneroDepositRequest(pool, context, rawRequest, now);
const initial = await pool.query<ReservationAddressRow>(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations
WHERE allocation_id=$1 AND wallet_generation_id=$2`, [reserved.reservation.allocationId, reserved.reservation.walletGenerationId]);
if (!initial.rowCount) throw new Error('Pilot deposit reservation was not found for its wallet generation');
if (initial.rows[0].status === 'registered') {
const address = await validateXmrSubaddress(initial.rows[0].monero_address ?? '');
return { reservation: toReservation(initial.rows[0]), address, replayed: true };
}
const row = initial.rows[0];
const lockKey = `wikideck-pilot-deposit-address-v1:${row.wallet_generation_id}:${row.account_index}`;
const lockClient = await pool.connect();
let locked = false;
try {
await lockClient.query('SELECT pg_advisory_lock(hashtextextended($1,0))', [lockKey]);
locked = true;
// Another worker may have completed while this call waited for the per-wallet fence.
const current = await pool.query<ReservationAddressRow>(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations
WHERE allocation_id=$1 AND wallet_generation_id=$2`, [row.allocation_id, row.wallet_generation_id]);
if (!current.rowCount) throw new Error('Pilot deposit reservation disappeared');
if (current.rows[0].status === 'registered') {
const address = await validateXmrSubaddress(current.rows[0].monero_address ?? '');
return { reservation: toReservation(current.rows[0]), address, replayed: true };
}
const allocation = current.rows[0];
if (await rpc.verifyWallet() !== allocation.wallet_fingerprint) {
throw new Error('Configured Monero wallet identity differs from the durable pilot reservation');
}
const expectedIndex = Number(allocation.address_index);
const expectedLabel = `wikideck-pilot-deposit-v1:${allocation.allocation_id}`;
const createWasAlreadyStarted = allocation.status === 'creating';
let existing = await rpc.getSubaddress(Number(allocation.account_index), expectedIndex);
if (!createWasAlreadyStarted) {
// Persist the side-effect intent first. If the process or transport fails afterwards,
// retry is reconciliation-only: it may recover an existing labeled index but never create twice.
const createStartedAt = Math.max(now, Number(allocation.reserved_at));
const started = await pool.query(`UPDATE wk_custody.pilot_monero_deposit_reservations
SET status='creating',create_started_at=$2 WHERE allocation_id=$1 AND status='reserved'
RETURNING allocation_id`, [allocation.allocation_id, createStartedAt]);
if (started.rowCount !== 1) throw new Error('Pilot address create intent could not be committed before Wallet-RPC');
}
if (!existing) {
if (createWasAlreadyStarted) {
throw new Error('Pilot Wallet-RPC create outcome is uncertain; reconcile the reserved index and wallet before retrying');
}
const created = await rpc.createSubaddress(Number(allocation.account_index), expectedLabel);
if (created.addressIndex !== expectedIndex) {
throw new Error('Monero wallet created an unexpected pilot subaddress index; reservation requires operator reconciliation');
}
// Never trust the create response alone; query the exact reserved index and stable label.
existing = await rpc.getSubaddress(Number(allocation.account_index), expectedIndex);
if (!existing || existing.address !== created.address) {
throw new Error('Monero wallet subaddress could not be recovered at the reserved pilot index');
}
}
if (existing.label !== expectedLabel) {
throw new Error('Reserved pilot Monero subaddress index is occupied by a different wallet label');
}
const address = await validateXmrSubaddress(existing.address);
const reservation = toReservation(allocation);
return await registerPilotAddress(pool, reservation, address, now);
} finally {
if (locked) await lockClient.query('SELECT pg_advisory_unlock(hashtextextended($1,0))', [lockKey]).catch(() => {});
lockClient.release();
}
}
+123 -2
View File
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
export const CUSTODY_PG_VERSION = 11;
export const CUSTODY_PG_VERSION = 13;
export const CUSTODY_WRITE_LOCK = '7140824361';
export const MONERO_OBSERVER_WORKER_LOCK = '7140824362';
export const CUSTODY_SCHEMA_V1 = `
@@ -455,6 +455,86 @@ REVOKE ALL ON FUNCTION wk_custody.pilot_reservation_database_isolated() FROM PUB
`;
export const CUSTODY_SCHEMA_V11_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}\n-- custody migration v9 --\n${CUSTODY_MIGRATION_V9}\n-- custody migration v10 --\n${CUSTODY_MIGRATION_V10}\n-- custody migration v11 --\n${CUSTODY_MIGRATION_V11}`).digest('hex');
export const CUSTODY_MIGRATION_V12 = `
DROP TRIGGER pilot_monero_deposit_reservations_immutable ON wk_custody.pilot_monero_deposit_reservations;
DROP FUNCTION wk_custody.pilot_monero_deposit_reservations_immutable();
ALTER TABLE wk_custody.pilot_monero_deposit_reservations
ADD COLUMN status text NOT NULL DEFAULT 'reserved' CHECK(status IN ('reserved','registered')),
ADD COLUMN monero_address text CHECK(monero_address IS NULL OR length(monero_address)=95),
ADD COLUMN registered_at bigint CHECK(registered_at IS NULL OR registered_at>=reserved_at),
ADD CONSTRAINT pilot_deposit_reservation_registration_state CHECK(
(status='reserved' AND monero_address IS NULL AND registered_at IS NULL) OR
(status='registered' AND monero_address IS NOT NULL AND registered_at IS NOT NULL));
CREATE UNIQUE INDEX pilot_monero_deposit_address_unique
ON wk_custody.pilot_monero_deposit_reservations(monero_address) WHERE monero_address IS NOT NULL;
CREATE FUNCTION wk_custody.pilot_monero_deposit_registration_guard() RETURNS trigger LANGUAGE plpgsql
SET search_path=wk_custody,pg_catalog AS $$
BEGIN
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'Pilot Monero deposit reservations cannot be deleted'; END IF;
IF OLD.status<>'reserved' OR NEW.status<>'registered' OR NEW.monero_address IS NULL OR NEW.registered_at IS NULL OR
NEW.registered_at<OLD.reserved_at OR NEW.allocation_id IS DISTINCT FROM OLD.allocation_id OR
NEW.request_id IS DISTINCT FROM OLD.request_id OR NEW.custodian_id IS DISTINCT FROM OLD.custodian_id OR
NEW.market_network IS DISTINCT FROM OLD.market_network OR NEW.market_chain_id IS DISTINCT FROM OLD.market_chain_id OR
NEW.monero_network IS DISTINCT FROM OLD.monero_network OR NEW.beneficiary IS DISTINCT FROM OLD.beneficiary OR
NEW.wallet_generation_id IS DISTINCT FROM OLD.wallet_generation_id OR NEW.wallet_fingerprint IS DISTINCT FROM OLD.wallet_fingerprint OR
NEW.account_index IS DISTINCT FROM OLD.account_index OR NEW.address_index IS DISTINCT FROM OLD.address_index OR
NEW.request_digest IS DISTINCT FROM OLD.request_digest OR NEW.allocation_digest IS DISTINCT FROM OLD.allocation_digest OR
NEW.reserved_at IS DISTINCT FROM OLD.reserved_at THEN
RAISE EXCEPTION 'Pilot Monero reservation permits only one-time address registration';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER pilot_monero_deposit_registration_guard
BEFORE UPDATE OR DELETE ON wk_custody.pilot_monero_deposit_reservations
FOR EACH ROW EXECUTE FUNCTION wk_custody.pilot_monero_deposit_registration_guard();
REVOKE ALL ON FUNCTION wk_custody.pilot_monero_deposit_registration_guard() FROM PUBLIC;
`;
export const CUSTODY_SCHEMA_V12_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}\n-- custody migration v9 --\n${CUSTODY_MIGRATION_V9}\n-- custody migration v10 --\n${CUSTODY_MIGRATION_V10}\n-- custody migration v11 --\n${CUSTODY_MIGRATION_V11}\n-- custody migration v12 --\n${CUSTODY_MIGRATION_V12}`).digest('hex');
export const CUSTODY_MIGRATION_V13 = `
DROP TRIGGER pilot_monero_deposit_registration_guard ON wk_custody.pilot_monero_deposit_reservations;
DROP FUNCTION wk_custody.pilot_monero_deposit_registration_guard();
ALTER TABLE wk_custody.pilot_monero_deposit_reservations
ADD COLUMN create_started_at bigint CHECK(create_started_at IS NULL OR create_started_at>=reserved_at);
UPDATE wk_custody.pilot_monero_deposit_reservations
SET create_started_at=registered_at WHERE status='registered';
ALTER TABLE wk_custody.pilot_monero_deposit_reservations
DROP CONSTRAINT pilot_monero_deposit_reservations_status_check,
ADD CONSTRAINT pilot_monero_deposit_reservations_status_check CHECK(status IN ('reserved','creating','registered')),
DROP CONSTRAINT pilot_deposit_reservation_registration_state,
ADD CONSTRAINT pilot_deposit_reservation_registration_state CHECK(
(status='reserved' AND create_started_at IS NULL AND monero_address IS NULL AND registered_at IS NULL) OR
(status='creating' AND create_started_at IS NOT NULL AND monero_address IS NULL AND registered_at IS NULL) OR
(status='registered' AND create_started_at IS NOT NULL AND monero_address IS NOT NULL AND
registered_at IS NOT NULL AND registered_at>=create_started_at));
CREATE FUNCTION wk_custody.pilot_monero_deposit_registration_guard() RETURNS trigger LANGUAGE plpgsql
SET search_path=wk_custody,pg_catalog AS $$
BEGIN
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'Pilot Monero deposit reservations cannot be deleted'; END IF;
IF NEW.allocation_id IS DISTINCT FROM OLD.allocation_id OR NEW.request_id IS DISTINCT FROM OLD.request_id OR
NEW.custodian_id IS DISTINCT FROM OLD.custodian_id OR NEW.market_network IS DISTINCT FROM OLD.market_network OR
NEW.market_chain_id IS DISTINCT FROM OLD.market_chain_id OR NEW.monero_network IS DISTINCT FROM OLD.monero_network OR
NEW.beneficiary IS DISTINCT FROM OLD.beneficiary OR NEW.wallet_generation_id IS DISTINCT FROM OLD.wallet_generation_id OR
NEW.wallet_fingerprint IS DISTINCT FROM OLD.wallet_fingerprint OR NEW.account_index IS DISTINCT FROM OLD.account_index OR
NEW.address_index IS DISTINCT FROM OLD.address_index OR NEW.request_digest IS DISTINCT FROM OLD.request_digest OR
NEW.allocation_digest IS DISTINCT FROM OLD.allocation_digest OR NEW.reserved_at IS DISTINCT FROM OLD.reserved_at THEN
RAISE EXCEPTION 'Pilot Monero reservation terms are immutable';
END IF;
IF OLD.status='reserved' AND NEW.status='creating' AND OLD.create_started_at IS NULL AND
NEW.create_started_at IS NOT NULL AND NEW.monero_address IS NULL AND NEW.registered_at IS NULL THEN
RETURN NEW;
END IF;
IF OLD.status='creating' AND NEW.status='registered' AND OLD.create_started_at IS NOT NULL AND
NEW.create_started_at IS NOT DISTINCT FROM OLD.create_started_at AND NEW.monero_address IS NOT NULL AND
OLD.monero_address IS NULL AND NEW.registered_at IS NOT NULL AND NEW.registered_at>=OLD.create_started_at THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'Pilot Monero reservation permits only one-time create intent and address registration';
END $$;
REVOKE ALL ON FUNCTION wk_custody.pilot_monero_deposit_registration_guard() FROM PUBLIC;
`;
export const CUSTODY_SCHEMA_V13_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}\n-- custody migration v9 --\n${CUSTODY_MIGRATION_V9}\n-- custody migration v10 --\n${CUSTODY_MIGRATION_V10}\n-- custody migration v11 --\n${CUSTODY_MIGRATION_V11}\n-- custody migration v12 --\n${CUSTODY_MIGRATION_V12}\n-- custody migration v13 --\n${CUSTODY_MIGRATION_V13}`).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_V10_DIGEST;
@@ -520,6 +600,16 @@ export async function migrateCustody(pool: Pool): Promise<void> {
if (current === 10) {
await c.query(CUSTODY_MIGRATION_V11);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(11,$1)', [CUSTODY_SCHEMA_V11_DIGEST]);
current = 11;
}
if (current === 11) {
await c.query(CUSTODY_MIGRATION_V12);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(12,$1)', [CUSTODY_SCHEMA_V12_DIGEST]);
current = 12;
}
if (current === 12) {
await c.query(CUSTODY_MIGRATION_V13);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(13,$1)', [CUSTODY_SCHEMA_V13_DIGEST]);
}
if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application');
await verifyCustodySchema(c);
@@ -535,7 +625,8 @@ 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_V8_DIGEST, CUSTODY_SCHEMA_V9_DIGEST, CUSTODY_SCHEMA_V10_DIGEST, CUSTODY_SCHEMA_V11_DIGEST];
CUSTODY_SCHEMA_V8_DIGEST, CUSTODY_SCHEMA_V9_DIGEST, CUSTODY_SCHEMA_V10_DIGEST, CUSTODY_SCHEMA_V11_DIGEST,
CUSTODY_SCHEMA_V12_DIGEST, CUSTODY_SCHEMA_V13_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');
@@ -693,6 +784,36 @@ export async function grantPilotDepositReservationRuntime(pool: Pool, role: stri
finally { c.release(); }
}
/** Separate pilot address allocator: only the reserved-to-registered column transition is writable. */
export async function grantPilotDepositAddressRuntime(pool: Pool, role: string,
reservationRole = 'pilot_deposit_reservation', 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 pilot deposit address role identifier');
if ([reservationRole, custodyRuntimeRole, observerRole, creditRole].includes(role)) {
throw new Error('Pilot address role must be separate from reservation and other custody 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('Pilot address 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('Pilot address 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('Pilot address 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 ON wk_custody.schema_versions,wk_custody.pilot_custody_identity TO ${name};
GRANT SELECT,INSERT ON wk_custody.pilot_monero_deposit_reservations TO ${name};
GRANT UPDATE(status,create_started_at,monero_address,registered_at) ON wk_custody.pilot_monero_deposit_reservations TO ${name};
GRANT EXECUTE ON FUNCTION wk_custody.pilot_reservation_database_isolated() TO ${name};`);
await c.query('COMMIT');
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
finally { c.release(); }
}
/** Mainnet customer withdrawal-intent role. It can reserve/cancel liabilities but has no signing or relay authority. */
export async function grantMoneroWithdrawalRuntime(pool: Pool, role: string,
custodyRuntimeRole = 'custody_runtime', observerRole = 'monero_observer',
+4 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { assertCustodyDbActionAllowedOnNetwork, assertMainnetSchemaMigrationAcknowledged,
assertMainnetCustodyOperationAcknowledged, assertPilotCustodyActionAllowed,
assertPilotSchemaMigrationAcknowledged, assertPilotCustodyIdentityAcknowledged,
assertPilotReservationGrantAcknowledged,
assertPilotReservationGrantAcknowledged, assertPilotAddressGrantAcknowledged,
parseCustodyDbArguments } from '../server/market/custody-db-args.ts';
test('custody database CLI parses two-argument deposit operations without shifting arguments', () => {
@@ -77,6 +77,9 @@ test('pilot custody CLI permits only additive schema migration and identity-only
assert.throws(() => assertPilotReservationGrantAcknowledged('grant-pilot-deposit-reservation-runtime', undefined), /acknowledgement/);
assert.throws(() => assertPilotReservationGrantAcknowledged('grant-pilot-deposit-reservation-runtime', 'reviewed-credit-only'), /acknowledgement/);
assert.doesNotThrow(() => assertPilotReservationGrantAcknowledged('grant-pilot-deposit-reservation-runtime', 'reservation-only-no-addresses'));
assert.throws(() => assertPilotAddressGrantAcknowledged('grant-pilot-deposit-address-runtime', undefined), /acknowledgement/);
assert.throws(() => assertPilotAddressGrantAcknowledged('grant-pilot-deposit-address-runtime', 'reservation-only-no-addresses'), /acknowledgement/);
assert.doesNotThrow(() => assertPilotAddressGrantAcknowledged('grant-pilot-deposit-address-runtime', 'operator-triggered-addresses-only'));
});
test('custody database CLI rejects unknown, missing, empty, and excess arguments', () => {
+103 -6
View File
@@ -16,11 +16,11 @@ 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, grantMoneroDepositAllocatorRuntime, grantMoneroWithdrawalRuntime,
grantPilotDepositReservationRuntime, CUSTODY_MIGRATION_V2,
grantPilotDepositReservationRuntime, grantPilotDepositAddressRuntime, CUSTODY_MIGRATION_V2,
CUSTODY_SCHEMA_V2_DIGEST } from '../../server/market/postgres-schema.ts';
import { parseMainnetCustodianId } from '../../server/market/mainnet-custody-identity.ts';
import { initializePilotCustodyIdentity, parsePilotCustodianId } from '../../server/market/pilot-custody-identity.ts';
import { reservePilotMoneroDepositRequest } from '../../server/market/pilot-monero-deposit-allocation.ts';
import { createOrRecoverPilotMoneroDepositAddress, reservePilotMoneroDepositRequest } from '../../server/market/pilot-monero-deposit-allocation.ts';
import { readMainnetCustodyBalance, reserveMainnetWithdrawal, cancelMainnetWithdrawal } from '../../server/market/mainnet-withdrawal.ts';
import { mainnetCustodyBalanceMessage, mainnetWithdrawalCancellationMessage, mainnetWithdrawalMessage,
type MainnetCustodyBalanceRequestUnsigned, type MainnetWithdrawalRequestUnsigned,
@@ -208,7 +208,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 v11 without dropping existing metadata', async t => {
test('custody schema v1 upgrades additively through v13 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();
@@ -222,7 +222,7 @@ test('custody schema v1 upgrades additively through v11 without dropping existin
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, 8, 9, 10, 11]);
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]);
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']) {
@@ -238,7 +238,7 @@ test('custody schema v1 upgrades additively through v11 without dropping existin
} finally { await admin.end(); }
});
test('custody schema v2 upgrades explicitly through v11 deposit and withdrawal lifecycle tables', async t => {
test('custody schema v2 upgrades explicitly through v13 deposit and withdrawal lifecycle tables', async t => {
const admin = new Pool(config);
await admin.query('DROP SCHEMA IF EXISTS wk_custody CASCADE');
const client = await admin.connect();
@@ -251,7 +251,7 @@ test('custody schema v2 upgrades explicitly through v11 deposit and withdrawal l
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, 8, 9, 10, 11]);
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]);
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);
assert.ok((await admin.query("SELECT to_regclass('wk_custody.monero_withdrawal_intents') AS name")).rows[0].name);
@@ -410,6 +410,103 @@ test('pilot signed deposit requests reserve unique durable indexes, replay safel
}
});
test('pilot Wallet-RPC address creation recovers a lost response and registers only the reserved index once', async t => {
const admin = new Pool(config), database = `pilot_address_create_${randomUUID().replaceAll('-', '')}`;
const role = `pilot_address_${randomUUID().replaceAll('-', '')}`;
await admin.query(`CREATE DATABASE "${database}"`);
const owner = new Pool({ ...config, database }), run = promisify(execFile), custodianId = randomUUID();
const now = 1_800_000_000_000;
const address = await syntheticSubaddress(17);
const bootstrap = `import { Pool } from 'pg'; import { initializePilotCustodyIdentity } from './server/market/pilot-custody-identity.ts';
const p=new Pool({host:'127.0.0.1',database:process.env.DB,user:'postgres',ssl:false});
try { await initializePilotCustodyIdentity(p,process.env.CUSTODIAN,Number(process.env.NOW)-1); } finally { await p.end(); }`;
const script = `import { Pool } from 'pg';
import { generateKeyPairSync, sign, randomUUID } from 'node:crypto';
import { address as ownerAddress, CHAIN_ID } from './server/testnet/consensus.ts';
import { pilotMoneroDepositRequestMessage } from './shared/pilot-monero-deposit.ts';
import { createOrRecoverPilotMoneroDepositAddress } from './server/market/pilot-monero-deposit-allocation.ts';
const pool=new Pool({host:'127.0.0.1',database:process.env.DB,user:process.env.DB_ROLE,ssl:false,max:4});
const now=Number(process.env.NOW), generation='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', fingerprint='c'.repeat(64);
function newPair(){return generateKeyPairSync('ec',{namedCurve:'prime256v1'});}
function makeRequest(pair,accountIndex=0){
const publicKey=pair.publicKey.export({type:'spki',format:'der'}).toString('base64');
const unsigned={version:1,marketNetwork:'wikideck-randomx-pilot-1',marketChainId:CHAIN_ID,moneroNetwork:'mainnet',
custodianId:process.env.CUSTODIAN,beneficiary:ownerAddress(publicKey),publicKey,requestId:randomUUID(),
walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex,expiresAt:now+60000};
return {...unsigned,signature:sign('sha256',Buffer.from(pilotMoneroDepositRequestMessage(unsigned)),
{key:pair.privateKey,dsaEncoding:'ieee-p1363'}).toString('base64')};
}
const request=makeRequest(newPair());
const addresses=new Map(), counters={create:0,verify:0}, failFirstCreate={value:true};
const rpc={verifyWallet:async()=>{counters.verify++;return fingerprint;},
getSubaddress:async(account,index)=>addresses.get(account+':'+index)??null,
createSubaddress:async(account,label)=>{counters.create++;if(account===1)throw Error('ambiguous create with no visible address');
const entry={address:process.env.MONERO_ADDRESS,label};
addresses.set(account+':0',entry);if(failFirstCreate.value){failFirstCreate.value=false;throw Error('simulated lost response');}
return {address:entry.address,addressIndex:0};}};
try {
let firstRejected=false;try{await createOrRecoverPilotMoneroDepositAddress(pool,rpc,
{walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:0,now:()=>now},request,now);}catch{firstRejected=true;}
const afterFailure=await pool.query('SELECT status,create_started_at,monero_address FROM wk_custody.pilot_monero_deposit_reservations WHERE request_id=$1',[request.requestId]);
const recovered=await createOrRecoverPilotMoneroDepositAddress(pool,rpc,
{walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:0,now:()=>now},request,now);
const replay=await createOrRecoverPilotMoneroDepositAddress(pool,rpc,
{walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:0,now:()=>now},request,now);
const uncertainRequest=makeRequest(newPair(),1);
let uncertainFirst=false,uncertainRetry=false;
try{await createOrRecoverPilotMoneroDepositAddress(pool,rpc,
{walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:1,now:()=>now},uncertainRequest,now);}catch{uncertainFirst=true;}
try{await createOrRecoverPilotMoneroDepositAddress(pool,rpc,
{walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:1,now:()=>now},uncertainRequest,now);}catch(error){uncertainRetry=/outcome is uncertain/.test(error.message);}
const uncertainState=await pool.query('SELECT status FROM wk_custody.pilot_monero_deposit_reservations WHERE request_id=$1',[uncertainRequest.requestId]);
const result={firstRejected,afterFailure:afterFailure.rows[0],recovered,replay,uncertainFirst,uncertainRetry,
uncertainStatus:uncertainState.rows[0]?.status,counters};
console.log(JSON.stringify(result));
} finally { await pool.end(); }`;
const env = { ...process.env, WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: 'private-mock-only',
DB: database, CUSTODIAN: custodianId, NOW: String(now), MONERO_ADDRESS: address };
try {
await migrateCustody(owner);
await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', bootstrap], {
env: { ...process.env, WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: 'private-mock-only',
DB: database, CUSTODIAN: custodianId, NOW: String(now) },
});
await admin.query(`CREATE ROLE "${role}" LOGIN`);
await grantPilotDepositAddressRuntime(owner, role);
assert.equal((await owner.query("SELECT has_table_privilege($1,'wk_custody.pilot_monero_deposit_reservations','UPDATE') AS allowed", [role])).rows[0].allowed, false);
assert.equal((await owner.query("SELECT has_column_privilege($1,'wk_custody.pilot_monero_deposit_reservations','status','UPDATE') AS allowed", [role])).rows[0].allowed, true);
assert.equal((await owner.query("SELECT has_column_privilege($1,'wk_custody.pilot_monero_deposit_reservations','monero_address','UPDATE') AS allowed", [role])).rows[0].allowed, true);
assert.equal((await owner.query("SELECT has_column_privilege($1,'wk_custody.pilot_monero_deposit_reservations','address_index','UPDATE') AS allowed", [role])).rows[0].allowed, false);
assert.equal((await owner.query("SELECT has_table_privilege($1,'wk_custody.accounts','SELECT') AS allowed", [role])).rows[0].allowed, false);
const result = JSON.parse((await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', script], {
env: { ...env, DB_ROLE: role },
})).stdout.trim());
assert.equal(result.firstRejected, true);
assert.equal(result.afterFailure.status, 'creating');
assert.notEqual(result.afterFailure.create_started_at, null);
assert.equal(result.afterFailure.monero_address, null);
assert.equal(result.recovered.address, address);
assert.equal(result.recovered.replayed, false);
assert.equal(result.replay.address, address);
assert.equal(result.replay.replayed, true);
assert.equal(result.uncertainFirst, true);
assert.equal(result.uncertainRetry, true);
assert.equal(result.uncertainStatus, 'creating');
assert.equal(result.counters.create, 2, 'ambiguous create must never be retried automatically');
assert.equal((await owner.query("SELECT count(*)::int AS n FROM wk_custody.pilot_monero_deposit_reservations WHERE status='registered' AND monero_address=$1", [address])).rows[0].n, 1);
const runtime = new Pool({ ...config, database, user: role });
try {
await assert.rejects(runtime.query("UPDATE wk_custody.pilot_monero_deposit_reservations SET monero_address=$1", ['5'.repeat(95)]));
await assert.rejects(runtime.query('DELETE FROM wk_custody.pilot_monero_deposit_reservations'));
} finally { await runtime.end(); }
} finally {
await owner.end();
await admin.query(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`);
await admin.query(`DROP ROLE IF EXISTS "${role}"`);
await admin.end();
}
});
test('mainnet custody identity bootstrap is one-time, chain-bound, and does not open withdrawals', async t => {
assert.equal(parseMainnetCustodianId('AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA'), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa');
assert.throws(() => parseMainnetCustodianId('not-a-uuid'), /Invalid mainnet custodian UUID/);