First deposit integration slice
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user