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
+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 };
},
};