75 lines
3.8 KiB
TypeScript
Executable File
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;
|
|
|
|
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 };
|
|
}
|