First deposit integration slice

This commit is contained in:
2026-10-21 20:07:27 -04:00
parent e3b2261da3
commit af646dcc32
10 changed files with 223 additions and 46 deletions
+11 -7
View File
@@ -25,6 +25,7 @@ const observationSchema = z.object({
height: z.number().int().positive().safe(), confirmations: z.number().int().positive().safe(),
timestamp: z.number().int().nonnegative().safe(), unlockTime: z.string().regex(/^(0|[1-9]\d{0,19})$/),
accountIndex: z.number().int().nonnegative().safe(), addressIndex: z.number().int().nonnegative().safe(),
outputPublicKey: z.string().regex(/^[a-f0-9]{64}$/), globalOutputIndex: z.number().int().nonnegative().safe(),
locked: z.boolean(), doubleSpendSeen: z.boolean(),
}).strict();
const MAX_SCAN_RANGE = 100_000;
@@ -53,7 +54,8 @@ function digest(value: unknown): string {
function rowDigest(observation: MoneroIncomingObservation): string {
return digest([observation.observationId, observation.transactionId, observation.amountAtomicXmr,
observation.height, observation.timestamp, observation.unlockTime,
observation.accountIndex, observation.addressIndex, observation.locked, observation.doubleSpendSeen]);
observation.accountIndex, observation.addressIndex, observation.outputPublicKey, observation.globalOutputIndex,
observation.locked, observation.doubleSpendSeen]);
}
/**
@@ -74,7 +76,7 @@ export async function persistMoneroObservationScan(pool: Pool, input: PersistObs
const ids = new Set<string>();
for (const observation of observations) {
await validateXmrAddress(observation.address);
if (observation.observationId !== `${observation.transactionId}:${observation.accountIndex}:${observation.addressIndex}` ||
if (observation.observationId !== `${observation.transactionId}:${observation.outputPublicKey}` ||
BigInt(observation.amountAtomicXmr) > XMR_RPC_UINT64_MAX || observation.height < page.fromHeight ||
observation.height > page.throughHeight || observation.confirmations !== page.walletHeight - observation.height + 1 ||
(observation.locked && observation.confirmations <= 0)) throw new Error('Monero observation is inconsistent with its scan page');
@@ -119,20 +121,22 @@ export async function persistMoneroObservationScan(pool: Pool, input: PersistObs
const eventType = !current.rowCount ? 'seen' : !current.rows[0].currently_present ? 'reappeared'
: current.rows[0].payload_digest !== payloadDigest ? 'changed' : null;
await client.query(`INSERT INTO wk_custody.monero_transfer_observations(
wallet_fingerprint,observation_id,txid,monero_address,account_index,address_index,amount_atomic_xmr,block_height,
wallet_fingerprint,observation_id,txid,monero_address,account_index,address_index,output_public_key,global_output_index,
amount_atomic_xmr,block_height,
wallet_height,confirmations,timestamp,unlock_time,locked,double_spend_seen,payload_digest,
currently_present,last_seen_scan_id,first_observed_at,last_observed_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true,$16,$17,$17)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,true,$18,$19,$19)
ON CONFLICT(wallet_fingerprint,observation_id) DO UPDATE SET
monero_address=excluded.monero_address,
output_public_key=excluded.output_public_key,global_output_index=excluded.global_output_index,
amount_atomic_xmr=excluded.amount_atomic_xmr,block_height=excluded.block_height,wallet_height=excluded.wallet_height,
confirmations=excluded.confirmations,timestamp=excluded.timestamp,unlock_time=excluded.unlock_time,
locked=excluded.locked,double_spend_seen=excluded.double_spend_seen,payload_digest=excluded.payload_digest,
currently_present=true,last_seen_scan_id=excluded.last_seen_scan_id,last_observed_at=excluded.last_observed_at`,
[parsed.walletFingerprint, observation.observationId, observation.transactionId, observation.address,
observation.accountIndex, observation.addressIndex, observation.amountAtomicXmr, observation.height, page.walletHeight,
observation.confirmations, observation.timestamp, observation.unlockTime, observation.locked,
observation.doubleSpendSeen, payloadDigest, parsed.scanId,
observation.accountIndex, observation.addressIndex, observation.outputPublicKey, observation.globalOutputIndex,
observation.amountAtomicXmr, observation.height, page.walletHeight, observation.confirmations,
observation.timestamp, observation.unlockTime, observation.locked, observation.doubleSpendSeen, payloadDigest, parsed.scanId,
current.rowCount ? Number(current.rows[0].first_observed_at) : parsed.scannedAt]);
if (eventType) {
await client.query(`INSERT INTO wk_custody.monero_observation_events
+79 -17
View File
@@ -44,6 +44,19 @@ const transfersResultSchema = z.object({
in: z.array(incomingTransferSchema).max(MAX_TRANSFERS).default([]),
status: z.literal('OK'),
}).passthrough();
const incomingOutputSchema = z.object({
amount: integerString,
global_index: integerString,
tx_hash: z.string().regex(/^[a-f0-9]{64}$/),
subaddr_index: z.object({ major: integerString, minor: integerString }).strict(),
unlocked: z.boolean(),
block_height: integerString,
pubkey: z.string().regex(/^[a-f0-9]{64}$/),
}).passthrough();
const incomingOutputsResultSchema = z.object({
transfers: z.array(incomingOutputSchema).max(MAX_TRANSFERS),
status: z.literal('OK'),
}).passthrough();
export type MoneroIncomingObservation = {
observationId: string;
@@ -56,6 +69,8 @@ export type MoneroIncomingObservation = {
unlockTime: string;
accountIndex: number;
addressIndex: number;
outputPublicKey: string;
globalOutputIndex: number;
locked: boolean;
doubleSpendSeen: boolean;
};
@@ -144,10 +159,10 @@ export async function loadMoneroObserverConfig(env: NodeJS.ProcessEnv = process.
/** Restricted read-only surface. No generic RPC method, subaddress creation, transfer or sweep API exists. */
/** Restricted RPC surface. Authentication is delegated to an explicitly injected secure transport. */
function createWalletRpc(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport,
allowedMethods: readonly ('get_height' | 'get_address' | 'get_transfers' | 'validate_address' | 'create_address')[]) {
allowedMethods: readonly ('get_height' | 'get_address' | 'get_transfers' | 'incoming_transfers' | 'validate_address' | 'create_address')[]) {
const endpoint = validateEndpoint(config.url);
// Removed Basic auth header
async function rpc(method: 'get_height' | 'get_address' | 'get_transfers' | 'validate_address' | 'create_address', params: Record<string, unknown> = {}) {
async function rpc(method: 'get_height' | 'get_address' | 'get_transfers' | 'incoming_transfers' | 'validate_address' | 'create_address', params: Record<string, unknown> = {}) {
if (!allowedMethods.includes(method)) throw new Error('Monero wallet-RPC method is not allowed for this client');
const id = randomUUID(), controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS); timeout.unref();
@@ -193,7 +208,7 @@ function createWalletRpc(config: z.infer<typeof configSchema>, transport: Monero
/** Read-only observer capability. It deliberately cannot create addresses or move funds. */
export function createMoneroWalletObserver(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport) {
const rpc = createWalletRpc(config, transport, ['get_height', 'get_address', 'get_transfers']);
const rpc = createWalletRpc(config, transport, ['get_height', 'get_address', 'get_transfers', 'incoming_transfers']);
return {
async verifyMainnetWallet(): Promise<string> {
const result = addressResultSchema.parse(await rpc('get_address', { account_index: 0 }));
@@ -210,23 +225,70 @@ export function createMoneroWalletObserver(config: z.infer<typeof configSchema>,
in: true, filter_by_height: true, min_height: fromHeight, max_height: walletHeight,
}));
if (transferRaw.in.length >= MAX_TRANSFERS) throw new Error('Monero transfer page reached its safety limit; refusing to advance scan cursor');
const incoming = await Promise.all(transferRaw.in.map(async item => {
const amount = BigInt(item.amount);
if (amount <= 0n || amount > XMR_RPC_UINT64_MAX) throw new Error('Invalid incoming Monero amount');
const address = item.address;
await validateXmrAddress(address);
const height = integer(item.height, BigInt(Number.MAX_SAFE_INTEGER), 'transfer height');
if (height < fromHeight || height > walletHeight) throw new Error('Wallet returned transfer outside requested height range');
const transferMetadata = new Map<string, z.infer<typeof incomingTransferSchema>>();
const keyForIndex = (txid: string, major: number, minor: number) => `${txid}:${major}:${minor}`;
for (const item of transferRaw.in) {
const key = keyForIndex(item.txid, integer(item.subaddr_index.major, 0xffffffffn, 'account index'),
integer(item.subaddr_index.minor, 0xffffffffn, 'address index'));
if (transferMetadata.has(key)) throw new Error('Duplicate incoming Monero transaction metadata');
transferMetadata.set(key, item);
}
// get_transfers groups amounts by transaction/subaddress. incoming_transfers exposes each
// owned output and its public key, which is the stable per-output identity needed for credit idempotency.
const requestedIndices = new Map<number, Set<number>>();
for (const item of transferRaw.in) {
const major = integer(item.subaddr_index.major, 0xffffffffn, 'account index');
const minor = integer(item.subaddr_index.minor, 0xffffffffn, 'address index');
return { observationId: `${item.txid}:${major}:${minor}`, transactionId: item.txid, address,
amountAtomicXmr: amount.toString(), height, confirmations: walletHeight - height + 1,
timestamp: integer(item.timestamp, BigInt(Number.MAX_SAFE_INTEGER), 'timestamp'),
unlockTime: item.unlock_time, accountIndex: major, addressIndex: minor,
locked: item.locked, doubleSpendSeen: item.double_spend_seen };
const indices = requestedIndices.get(major) ?? new Set<number>();
indices.add(minor); requestedIndices.set(major, indices);
}
const incoming: MoneroIncomingObservation[] = [];
const seenOutputKeys = new Set<string>();
for (const [major, indices] of requestedIndices) {
const minors = [...indices];
for (let offset = 0; offset < minors.length; offset += 64) {
const selected = minors.slice(offset, offset + 64);
const outputRaw = incomingOutputsResultSchema.parse(await rpc('incoming_transfers', {
transfer_type: 'all', account_index: major, subaddr_indices: selected,
}));
const unique = new Set(incoming.map(row => row.observationId));
if (unique.size !== incoming.length) throw new Error('Duplicate Monero wallet output observation');
if (outputRaw.transfers.length >= MAX_TRANSFERS) {
throw new Error('Monero output page reached its safety limit; refusing to advance scan cursor');
}
for (const output of outputRaw.transfers) {
const outputMajor = integer(output.subaddr_index.major, 0xffffffffn, 'account index');
const outputMinor = integer(output.subaddr_index.minor, 0xffffffffn, 'address index');
if (outputMajor !== major || !indices.has(outputMinor)) throw new Error('Wallet returned an output outside requested subaddresses');
const metadata = transferMetadata.get(keyForIndex(output.tx_hash, outputMajor, outputMinor));
if (!metadata) continue;
const height = integer(output.block_height, BigInt(Number.MAX_SAFE_INTEGER), 'output height');
const expectedHeight = integer(metadata.height, BigInt(Number.MAX_SAFE_INTEGER), 'transfer height');
if (height !== expectedHeight || height < fromHeight || height > walletHeight) continue;
const amount = BigInt(output.amount);
if (amount <= 0n || amount > XMR_RPC_UINT64_MAX) throw new Error('Invalid incoming Monero output amount');
const globalOutputIndex = integer(output.global_index, BigInt(Number.MAX_SAFE_INTEGER), 'global output index');
const outputPublicKey = output.pubkey;
if (seenOutputKeys.has(outputPublicKey)) throw new Error('Duplicate Monero wallet output public key');
if (incoming.length >= MAX_TRANSFERS) throw new Error('Monero output page reached its safety limit; refusing to advance scan cursor');
seenOutputKeys.add(outputPublicKey);
incoming.push({ observationId: `${output.tx_hash}:${outputPublicKey}`, transactionId: output.tx_hash,
address: metadata.address, amountAtomicXmr: amount.toString(), height,
confirmations: walletHeight - height + 1,
timestamp: integer(metadata.timestamp, BigInt(Number.MAX_SAFE_INTEGER), 'timestamp'),
unlockTime: metadata.unlock_time, accountIndex: outputMajor, addressIndex: outputMinor,
outputPublicKey, globalOutputIndex, locked: !output.unlocked, doubleSpendSeen: metadata.double_spend_seen });
}
}
}
const totals = new Map<string, bigint>();
for (const output of incoming) {
const key = keyForIndex(output.transactionId, output.accountIndex, output.addressIndex);
totals.set(key, (totals.get(key) ?? 0n) + BigInt(output.amountAtomicXmr));
}
for (const [key, metadata] of transferMetadata) {
if (totals.get(key) !== BigInt(metadata.amount)) throw new Error('Wallet transaction amount does not match its owned output set');
await validateXmrAddress(metadata.address);
}
return { network: 'mainnet', walletHeight, fromHeight, throughHeight: walletHeight, primaryAddress, incoming };
},
};
+4
View File
@@ -87,6 +87,10 @@ export function validateMoneroRpcGatewayRequest(value: unknown): string {
}
break;
}
case 'incoming_transfers':
z.object({ transfer_type: z.literal('all'), account_index: uint32,
subaddr_indices: z.array(uint32).min(1).max(64) }).strict().parse(params);
break;
default:
throw new Error('Wallet RPC method is not allow-listed');
}
+31 -3
View File
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
export const CUSTODY_PG_VERSION = 3;
export const CUSTODY_PG_VERSION = 4;
export const CUSTODY_WRITE_LOCK = '7140824361';
export const CUSTODY_SCHEMA_V1 = `
CREATE SCHEMA IF NOT EXISTS wk_custody;
@@ -175,9 +175,32 @@ REVOKE ALL ON FUNCTION wk_custody.monero_deposit_allocations_immutable() FROM PU
`;
export const CUSTODY_SCHEMA_V3_DIGEST = createHash('sha256')
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}`).digest('hex');
export const CUSTODY_MIGRATION_V4 = `
DO $$
DECLARE old_constraint text;
BEGIN
SELECT conname INTO old_constraint FROM pg_constraint
WHERE conrelid='wk_custody.monero_transfer_observations'::regclass AND contype='u'
AND pg_get_constraintdef(oid)='UNIQUE (wallet_fingerprint, txid, account_index, address_index)';
IF old_constraint IS NOT NULL THEN
EXECUTE format('ALTER TABLE wk_custody.monero_transfer_observations DROP CONSTRAINT %I',old_constraint);
END IF;
END $$;
ALTER TABLE wk_custody.monero_transfer_observations
ADD COLUMN output_public_key text CHECK(output_public_key IS NULL OR output_public_key ~ '^[a-f0-9]{64}$'),
ADD COLUMN global_output_index bigint CHECK(global_output_index IS NULL OR global_output_index>=0),
ADD CONSTRAINT monero_observation_output_identity CHECK(
(output_public_key IS NULL AND global_output_index IS NULL) OR
(output_public_key IS NOT NULL AND global_output_index IS NOT NULL AND observation_id=(txid || ':' || output_public_key)));
CREATE UNIQUE INDEX monero_observations_output_key
ON wk_custody.monero_transfer_observations(wallet_fingerprint,output_public_key)
WHERE output_public_key IS NOT NULL;
`;
export const CUSTODY_SCHEMA_V4_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}`).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_V2_DIGEST;
export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V4_DIGEST;
/** Explicit operator migration; the HTTP runtime never creates or upgrades schema. */
export async function migrateCustody(pool: Pool): Promise<void> {
@@ -200,6 +223,11 @@ export async function migrateCustody(pool: Pool): Promise<void> {
if (current === 2) {
await c.query(CUSTODY_MIGRATION_V3);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(3,$1)', [CUSTODY_SCHEMA_V3_DIGEST]);
current = 3;
}
if (current === 3) {
await c.query(CUSTODY_MIGRATION_V4);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(4,$1)', [CUSTODY_SCHEMA_V4_DIGEST]);
}
if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application');
await verifyCustodySchema(c);
@@ -213,7 +241,7 @@ 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];
const expected = [CUSTODY_SCHEMA_V1_DIGEST, CUSTODY_SCHEMA_V2_DIGEST, CUSTODY_SCHEMA_V3_DIGEST, CUSTODY_SCHEMA_V4_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');