Files
wikideck/server/market/monero-deposit-enrollment.ts
T

75 lines
3.8 KiB
TypeScript
Executable File

import type { Pool } from 'pg';
import { address, verifySignature } from '../testnet/consensus.ts';
import { moneroDepositAddressRequestSchema, moneroDepositAddressMessage } from '../../shared/monero-deposit.ts';
import { createOrRecoverMoneroDepositSubaddress, reserveMoneroDepositAllocation } from './monero-deposit-allocation-store.ts';
import type { MoneroDepositAddressRpc } from './monero-observer.ts';
const MAX_REQUEST_LIFETIME_MS = 5 * 60_000;
export type MainnetEnrollmentContext = {
custodianId: string;
chainId: string;
walletGenerationId: string;
walletFingerprint: string;
accountIndex: number;
now?: () => number;
};
export type MoneroDepositAddressResult = {
network: 'mainnet';
allocationId: string;
walletGenerationId: string;
addressIndex: number;
address: string;
replayed: boolean;
};
/**
* Verify an expiring customer-wallet signature and durably allocate that customer's one mainnet
* subaddress. This only enrolls an address; it never credits a balance, starts a sale, or moves XMR.
* Do not expose a public route until the matching credit, withdrawal, and recovery paths are ready.
*/
export async function enrollMoneroDepositAddress(pool: Pool, rpc: MoneroDepositAddressRpc,
context: MainnetEnrollmentContext, rawRequest: unknown): Promise<MoneroDepositAddressResult> {
const request = moneroDepositAddressRequestSchema.parse(rawRequest);
if (request.network !== 'wikideck-randomx-mainnet-1' || request.custodianId !== context.custodianId ||
request.chainId !== context.chainId || request.walletGenerationId !== context.walletGenerationId ||
request.walletFingerprint !== context.walletFingerprint || request.accountIndex !== context.accountIndex) {
throw new Error('Deposit address request does not match this mainnet custodian and wallet generation');
}
if (address(request.publicKey) !== request.actor) throw new Error('Deposit address signer does not own the requested beneficiary');
const { signature, ...unsigned } = request;
const message = moneroDepositAddressMessage(unsigned);
if (!verifySignature(request.publicKey, request.actor, message, signature)) {
throw new Error('Deposit address request signature is invalid');
}
const now = context.now?.() ?? Date.now();
if (!Number.isSafeInteger(now) || now < 0 || request.expiresAt < now ||
request.expiresAt > now + MAX_REQUEST_LIFETIME_MS) {
throw new Error('Deposit address request is expired or outside the allowed window');
}
const { allocation } = await reserveMoneroDepositAllocation(pool, {
requestId: request.requestId,
custodianId: context.custodianId,
marketNetwork: 'wikideck-randomx-mainnet-1',
marketChainId: context.chainId,
beneficiary: request.actor,
walletGenerationId: context.walletGenerationId,
walletFingerprint: context.walletFingerprint,
accountIndex: context.accountIndex,
reservedAt: now,
});
const registered = await createOrRecoverMoneroDepositSubaddress(pool, rpc, allocation.allocationId,
context.walletGenerationId, now);
if (registered.allocation.beneficiary !== request.actor || registered.allocation.custodianId !== context.custodianId ||
registered.allocation.marketChainId !== context.chainId || registered.allocation.moneroNetwork !== 'mainnet' ||
registered.allocation.status !== 'registered' || !registered.allocation.moneroAddress) {
throw new Error('Registered Monero deposit address does not match the signed beneficiary allocation');
}
return { network: 'mainnet', allocationId: registered.allocation.allocationId,
walletGenerationId: registered.allocation.walletGenerationId,
addressIndex: registered.allocation.addressIndex, address: registered.allocation.moneroAddress,
replayed: registered.replayed };
}