operator/library-only Wallet-RPC address creation and recovery
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user