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