V2.1 #21

Merged
eric merged 76 commits from v2.1 into main 2026-11-01 22:07:57 +00:00
6 changed files with 214 additions and 7 deletions
Showing only changes of commit c8cf8245fc - Show all commits
+1 -1
View File
@@ -19,7 +19,7 @@ The operator has authorized proceeding with implementation instead of the previo
**Financial storage update:** [ADR 003](postgres-financial-storage.md) selects PostgreSQL and records the integrated asynchronous custody backend, restricted runtime/schema roles, deferred accounting constraints, explicit SQLite freeze/import, and full dump/restore rehearsal. SQLite remains an opt-in-compatible local sandbox default, not an automatic fallback. Real custody and production operating acceptance remain blocked.
**Read-only Monero observation and allocation-registry groundwork:** [server/market/monero-observer.ts](../server/market/monero-observer.ts) defines a narrow mainnet wallet-RPC client for mainnet primary-address verification and bounded incoming-transfer scans. It requires explicit opt-in and a protected external config file, uses authenticated requests with strict HTTPS/literal-loopback-only HTTP, no redirects, bounded response/deadline, JSON-RPC request-ID validation and lossless JSON integer handling. Each observed transfer includes its validated destination address. It exposes no subaddress creation, credit posting, transfer construction, relay, sweep or withdrawal operation and is not wired into server startup/routes. [server/market/monero-observation-store.ts](../server/market/monero-observation-store.ts) persists scan manifests, exact incoming-transfer observations, cursor progress and append-only seen/changed/not-seen/reappeared events. **PostgreSQL schema v3** adds [server/market/monero-deposit-allocation-store.ts](../server/market/monero-deposit-allocation-store.ts), which can durably reserve a unique subaddress index for a beneficiary before a future wallet side effect and register a validated mainnet subaddress once. Reservations and beneficiary/index bindings cannot be deleted or reassigned; a registered address cannot be replaced. The registry is currently unreachable to both the custody API and observer roles, and the store is not wired to startup, routes, UI or wallet RPC. A wallet-generation UUID/fingerprint supplied to this groundwork is not proof of wallet ownership. It does not solve wallet-RPC/database crash reconciliation or establish the pinned RPC's per-output identity model. Confirmation growth updates an observation projection without emitting an event every poll. A missing output is only a review signal; no automatic journal posting, debit, reversal or customer credit exists. Schema v1→v3 is an explicit additive migration, not an HTTP-runtime migration. The final disposable PostgreSQL suite passed **35/35**, covering v1/v2 upgrades, concurrent reservations across independent pools, idempotency, immutable registration, role isolation and zero journal postings. The offline suite passed **183/183**, and the test-target build succeeded. No wallet endpoint, credentials or funds were accessed. This is not a working deposit-address allocator, live deposit processor, or approved mainnet switch. The application only calls read methods, but operators must still use a restricted view-only wallet RPC instance/account; the code cannot prove credential scope.
**Monero observation and deposit-address allocation groundwork:** [server/market/monero-observer.ts](../server/market/monero-observer.ts) keeps the existing observer client strictly read-only: mainnet wallet validation and bounded incoming scans, authenticated requests, strict HTTPS/literal-loopback-only HTTP, no redirects, bounded response/deadline, JSON-RPC ID validation and lossless integer handling. A separate, narrow `createMoneroDepositAddressRpc()` capability can inspect one indexed subaddress or request exactly one labeled subaddress; it exposes no transfer, relay, sweep or withdrawal operation. [server/market/monero-deposit-allocation-store.ts](../server/market/monero-deposit-allocation-store.ts) now combines the PostgreSQL v3 durable beneficiary/index reservation with this capability: it reserves first, serializes cooperating allocators per wallet/account, checks the exact reserved index, and recovers a lost `create_address` response by looking up a deterministic allocation label before retrying creation. Address registration is immutable and performed only after the wallet returns the expected index/address/label. If another wallet client consumes indexes outside this allocator, the operation fails for reconciliation rather than reassigning an address; the wallet RPC must therefore be dedicated to the allocator. A wallet-generation UUID/fingerprint supplied to this groundwork is not proof of wallet ownership, and the RPC/database crash window has only been fixture-tested. Neither client nor orchestration is wired to startup, routes, UI, deposit scans or financial posting. The registry remains inaccessible to both custody API and observer roles. Observation storage continues to record reorg/reappearance evidence only; no credit, debit, reversal or journal can be posted. Schema v1→v3 is explicit and additive. The test-target build succeeded; **184/184** offline tests and **35/35** disposable PostgreSQL tests passed, including a simulated create-success/response-loss/recovery cycle with no duplicate address creation or journal posting. No live wallet RPC, wallet credential or funds were used. This is not a public deposit allocator or live deposit processor; mainnet payments remain disabled.
Validation: TypeScript/Vite build and 144 offline tests passed in the serial native-RandomX test run; the initial parallel run encountered a peer-gossip timeout. Browser smoke verified a disposable wallet's signed test credit, withdrawal hold and cancellation restoring the exact balance, plus a 390px viewport without horizontal overflow. No real funds were used. Tests and remaining acceptance gates are below; this is not launch readiness.
+1 -1
View File
@@ -20,7 +20,7 @@ Status: active engineering plan; **not launch or payment authorization**. Update
**Current design deliverable:** [custodial market design](custodial-market-design.md). Read the workstreams below with this update: pooled customer liabilities replace per-order incoming invoices and external sale payouts in the proposed architecture. Earlier trustless-first research gates are historical, not a prerequisite to investigating custody. Exact ledger, withdrawal, fee, custody/legal and host-attested NFT consensus rules still require approval and evidence; the existing mock domain cannot authorize real balance debits.
**Read-only XMR groundwork:** a disabled-by-default Monero wallet-RPC observer client is implemented for mainnet address validation and bounded incoming scans. Explicit PostgreSQL v1→v3 migrations add durable scan cursors, exact observation records, append-only reorg/reappearance markers, and a private beneficiary-bound subaddress-index registry. The registry can reserve a never-reused index before a future wallet RPC side effect, then register a validated mainnet subaddress once. It is not wired to startup/routes/UI/RPC; existing custody and observer roles cannot read or write it. The registry does not prove wallet-generation identity, solve RPC/database crash recovery, or determine unique output identity. Observation records include validated destination addresses. Missing outputs create review evidence only; neither observer nor registry can post credits, reverse balances or spend. Verification passed: Docker test-target build; **183/183** offline tests; **35/35** disposable PostgreSQL integration tests, including v1/v2 upgrades, cross-pool concurrent index reservation, replay, immutable registration, least-privilege isolation and zero journal postings. No live RPC, wallet credential or funds were used. Remaining deposit milestone: reconcile reserved allocations against the pinned wallet RPC and backed-up wallet generation; establish stable per-output identity and canonical inclusion/lock/reorg semantics; then design exact idempotent journal crediting plus audited reversal/hold handling, controlled stagenet rehearsal, and independent accounting/security review before any real deposits.
**Monero observation and allocation groundwork:** the disabled-by-default observer remains read-only. A separate narrow RPC capability can read one indexed subaddress or create exactly one labeled subaddress; the internal allocator durably reserves the beneficiary/index first, serializes cooperating allocations, and recovers a create-success/lost-response case by checking the stable label at the reserved index. It refuses mismatched indexes instead of silently binding them. It is not wired to startup/routes/UI/scans/financial posting, and the registry remains inaccessible to custody API and observer roles. Its wallet-generation identity is unverified; recovery was tested with fixtures, not a real RPC/wallet; its assumptions require an allocator-exclusive wallet RPC and operational wallet/database reconciliation. The registry cannot establish unique output identity. Missing scans remain review evidence, not automatic balance changes. Verification passed: Docker test-target build; **184/184** offline tests; **35/35** disposable PostgreSQL tests, including v1/v2 migrations, independent-pool allocator concurrency, replay, immutable registration, least-privilege isolation, zero journal postings and simulated response-loss recovery without duplicate address creation. No live RPC, wallet credential or funds were used. Remaining deposit milestone: provision and verify an isolated wallet RPC instance and generation identity; reconcile reserved allocations and backed-up wallet state; establish stable per-output identity and canonical inclusion/lock/reorg semantics; then build exact idempotent journal crediting plus audited reversal/hold handling, stagenet rehearsal, and independent accounting/security review before any real deposits.
## 1A. MVP release boundary — proposed safe first production-hosted release
@@ -1,6 +1,7 @@
import { createHash, randomUUID } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
import { z } from 'zod';
import type { MoneroDepositAddressRpc } from './monero-observer.ts';
import { validateXmrSubaddress } from './xmr.ts';
const uuid = z.string().uuid();
@@ -194,3 +195,73 @@ export async function registerMoneroDepositSubaddress(
client.release();
}
}
/**
* Create or recover the wallet subaddress for a previously committed reservation. A stable
* wallet label makes a lost create_address response recoverable via get_address at the exact
* reserved index. The wallet RPC must be dedicated to this allocator: external address
* creation can consume indexes and is treated as a hard mismatch, never silently reassigned.
*/
export async function createOrRecoverMoneroDepositSubaddress(
pool: Pool,
rpc: MoneroDepositAddressRpc,
allocationId: string,
walletGenerationId: string,
registeredAt: number,
): Promise<{ allocation: MoneroDepositAllocation; replayed: boolean }> {
const parsedAllocationId = uuid.parse(allocationId);
const parsedGenerationId = uuid.parse(walletGenerationId);
if (!Number.isSafeInteger(registeredAt) || registeredAt < 0) throw new Error('Invalid deposit address registration time');
const client = await pool.connect();
let address: string | undefined;
let label: string | undefined;
let lockKey: string | undefined;
let locked = false;
try {
const initial = await client.query<AllocationRow>(`SELECT * FROM wk_custody.monero_deposit_allocations
WHERE allocation_id=$1 AND wallet_generation_id=$2`, [parsedAllocationId, parsedGenerationId]);
if (!initial.rowCount) throw new Error('Deposit allocation reservation was not found for this wallet generation');
if (initial.rows[0].status === 'registered') {
return { allocation: toAllocation(initial.rows[0]), replayed: true };
}
const row = initial.rows[0];
lockKey = `wikideck-monero-deposit-address-v1:${row.wallet_generation_id}:${row.account_index}`;
await client.query('SELECT pg_advisory_lock(hashtextextended($1,0))', [lockKey]);
locked = true;
// Re-read after obtaining the per-wallet lock in case another process finished first.
const current = await client.query<AllocationRow>(`SELECT * FROM wk_custody.monero_deposit_allocations
WHERE allocation_id=$1 AND wallet_generation_id=$2`, [parsedAllocationId, parsedGenerationId]);
if (!current.rowCount) throw new Error('Deposit allocation reservation disappeared');
if (current.rows[0].status === 'registered') {
return { allocation: toAllocation(current.rows[0]), replayed: true };
}
const allocation = current.rows[0];
const expectedLabel = `wikideck-deposit-v1:${allocation.allocation_id}`;
const expectedIndex = Number(allocation.address_index);
const existing = await rpc.getSubaddress(Number(allocation.account_index), expectedIndex);
if (existing) {
if (existing.label !== expectedLabel) throw new Error('Reserved Monero subaddress index is occupied by a different wallet label');
address = await validateXmrSubaddress(existing.address);
label = existing.label;
} else {
const created = await rpc.createSubaddress(Number(allocation.account_index), expectedLabel);
if (created.addressIndex !== expectedIndex) {
throw new Error('Monero wallet created an unexpected subaddress index; allocation requires operator reconciliation');
}
const verified = await rpc.getSubaddress(Number(allocation.account_index), expectedIndex);
if (!verified || verified.label !== expectedLabel || verified.address !== created.address) {
throw new Error('Monero wallet subaddress could not be verified at its reserved index');
}
address = await validateXmrSubaddress(verified.address);
label = verified.label;
}
} finally {
if (locked && lockKey) await client.query('SELECT pg_advisory_unlock(hashtextextended($1,0))', [lockKey]).catch(() => {});
client.release();
}
if (!address || !label) throw new Error('Monero deposit address allocation was not verified');
return registerMoneroDepositSubaddress(pool, { allocationId: parsedAllocationId,
walletGenerationId: parsedGenerationId, moneroAddress: address, registeredAt });
}
+64 -3
View File
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
import { lstat, readFile } from 'node:fs/promises';
import { z } from 'zod';
import { parseAtomicXmr, XMR_RPC_UINT64_MAX } from '../../shared/xmr-amount.ts';
import { validateXmrAddress } from './xmr.ts';
import { validateXmrAddress, validateXmrSubaddress } from './xmr.ts';
const MAX_CONFIG_BYTES = 4096;
const MAX_RESPONSE_BYTES = 1024 * 1024;
@@ -20,6 +20,13 @@ const rpcEnvelopeSchema = z.object({
const integerString = z.string().regex(/^(0|[1-9]\d{0,19})$/);
const heightResultSchema = z.object({ height: integerString, status: z.literal('OK') }).passthrough();
const addressResultSchema = z.object({ address: z.string().length(95), addresses: z.array(z.unknown()).optional(), status: z.literal('OK') }).passthrough();
const subaddressEntrySchema = z.object({ address: z.string().length(95), label: z.string().max(128),
address_index: integerString, used: z.boolean() }).passthrough();
const subaddressReadResultSchema = z.object({ address: z.string().length(95),
addresses: z.array(subaddressEntrySchema).max(1), status: z.literal('OK') }).passthrough();
const subaddressCreateResultSchema = z.object({ address: z.string().length(95), address_index: integerString,
address_indices: z.array(integerString).max(1).optional(), addresses: z.array(z.string().length(95)).max(1).optional(),
status: z.literal('OK') }).passthrough();
const incomingTransferSchema = z.object({
txid: z.string().regex(/^[a-f0-9]{64}$/),
address: z.string().length(95),
@@ -123,10 +130,12 @@ export async function loadMoneroObserverConfig(env: NodeJS.ProcessEnv = process.
}
/** Restricted read-only surface. No generic RPC method, subaddress creation, transfer or sweep API exists. */
export function createMoneroWalletObserver(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport = fetch) {
function createWalletRpc(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport,
allowedMethods: readonly ('get_height' | 'get_address' | 'get_transfers' | 'create_address')[]) {
const endpoint = validateEndpoint(config.url);
const authorization = `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`;
async function rpc(method: 'get_height' | 'get_address' | 'get_transfers', params: Record<string, unknown> = {}) {
async function rpc(method: 'get_height' | 'get_address' | 'get_transfers' | '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();
const abort = new Promise<never>((_, reject) => controller.signal.addEventListener('abort',
@@ -165,6 +174,12 @@ export function createMoneroWalletObserver(config: z.infer<typeof configSchema>,
try { return await Promise.race([operation, abort]); }
finally { clearTimeout(timeout); }
}
return rpc;
}
/** Read-only observer capability. It deliberately cannot create addresses or move funds. */
export function createMoneroWalletObserver(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport = fetch) {
const rpc = createWalletRpc(config, transport, ['get_height', 'get_address', 'get_transfers']);
return {
async verifyMainnetWallet(): Promise<string> {
const result = addressResultSchema.parse(await rpc('get_address', { account_index: 0 }));
@@ -202,3 +217,49 @@ export function createMoneroWalletObserver(config: z.infer<typeof configSchema>,
},
};
}
export type MoneroDepositAddressRpc = {
getSubaddress(accountIndex: number, addressIndex: number): Promise<{ address: string; label: string } | null>;
createSubaddress(accountIndex: number, label: string): Promise<{ address: string; addressIndex: number }>;
};
/**
* Narrow address-allocation capability; it can only inspect or create one subaddress at a
* time. Keep this credential/service separate from API and withdrawal capabilities. This
* client is intentionally not attached to startup, routes, UI, or automatic crediting.
*/
export function createMoneroDepositAddressRpc(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport = fetch) {
const rpc = createWalletRpc(config, transport, ['get_address', 'create_address']);
return {
async getSubaddress(accountIndex: number, addressIndex: number): Promise<{ address: string; label: string } | null> {
if (![accountIndex, addressIndex].every(value => Number.isSafeInteger(value) && value >= 0 && value <= 0xffff_ffff)) {
throw new Error('Invalid Monero subaddress index');
}
const raw = await rpc('get_address', { account_index: accountIndex, address_index: [addressIndex] });
const result = subaddressReadResultSchema.parse(raw);
const entry = result.addresses[0];
if (!entry) return null;
if (integer(entry.address_index, 0xffff_ffffn, 'address index') !== addressIndex) {
throw new Error('Monero wallet returned a different subaddress index');
}
await validateXmrAddress(entry.address);
return { address: entry.address, label: entry.label };
},
async createSubaddress(accountIndex: number, label: string): Promise<{ address: string; addressIndex: number }> {
if (!Number.isSafeInteger(accountIndex) || accountIndex < 0 || accountIndex > 0xffff_ffff ||
typeof label !== 'string' || label.length < 1 || label.length > 128 || /[\r\n\x00]/.test(label)) {
throw new Error('Invalid Monero subaddress creation parameters');
}
const result = subaddressCreateResultSchema.parse(await rpc('create_address', {
account_index: accountIndex, label, count: 1,
}));
const addressIndex = integer(result.address_index, 0xffff_ffffn, 'address index');
if ((result.address_indices && (result.address_indices.length !== 1 || integer(result.address_indices[0], 0xffff_ffffn, 'address index') !== addressIndex)) ||
(result.addresses && (result.addresses.length !== 1 || result.addresses[0] !== result.address))) {
throw new Error('Monero wallet returned inconsistent subaddress creation data');
}
await validateXmrSubaddress(result.address);
return { address: result.address, addressIndex };
},
};
}
+48 -1
View File
@@ -1,7 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { keccak } from 'hash-wasm';
import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts';
import { createMoneroWalletObserver, loadMoneroObserverConfig, type MoneroFixtureTransport } from '../server/market/monero-observer.ts';
import { createMoneroDepositAddressRpc, createMoneroWalletObserver, loadMoneroObserverConfig,
type MoneroFixtureTransport } from '../server/market/monero-observer.ts';
const config = { network: 'mainnet' as const, url: 'http://127.0.0.1:18083/json_rpc', username: 'fixture', password: 'not-a-live-secret' };
function response(id: string, result: Record<string, unknown>, status = 200, type = 'application/json') {
@@ -21,6 +23,18 @@ function fakeTransport(incoming: Record<string, unknown>[] = []): { transport: M
};
return { transport, calls };
}
async function syntheticSubaddress(fill = 3) {
const prefix = Uint8Array.from([42, ...Array(64).fill(fill)]);
const data = Buffer.concat([prefix, Buffer.from((await keccak(prefix, 256)).slice(0, 8), 'hex')]);
const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
let encoded = '';
for (let i = 0; i < data.length; i += 8) {
const chunk = data.subarray(i, i + 8); let n = BigInt(`0x${chunk.toString('hex')}`), text = '';
while (n) { text = alphabet[Number(n % 58n)] + text; n /= 58n; }
encoded += text.padStart(chunk.length === 8 ? 11 : 7, '1');
}
return encoded;
}
test('read-only Monero observer preserves uint64 amount text and returns observations without crediting balances', async () => {
const { transport, calls } = fakeTransport([{ txid: 'a'.repeat(64), address: ADDITIONAL_MARKET_FEE.address,
@@ -28,6 +42,7 @@ test('read-only Monero observer preserves uint64 amount text and returns observa
timestamp: 1_800_000_000, unlock_time: 0, locked: false, double_spend_seen: false,
subaddr_index: { major: 0, minor: 7 } }]);
const observer = createMoneroWalletObserver(config, transport);
assert.equal('createSubaddress' in observer, false);
const page = await observer.scanIncoming(90);
assert.equal(page.network, 'mainnet'); assert.equal(page.walletHeight, 100);
assert.equal(page.primaryAddress, ADDITIONAL_MARKET_FEE.address);
@@ -42,6 +57,38 @@ test('read-only Monero observer preserves uint64 amount text and returns observa
assert.equal(Object.keys(observer).some(key => /transfer|sweep|send|credit/i.test(key)), false);
});
test('deposit-address RPC client is narrowly allow-listed and creates only one verified subaddress', async () => {
const subaddress = await syntheticSubaddress();
const calls: { method: string; params: unknown }[] = [];
const transport: MoneroFixtureTransport = async (_url, init) => {
const body = JSON.parse(init.body as string) as { id: string; method: string; params: unknown };
calls.push({ method: body.method, params: body.params });
if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address,
addresses: [{ address: subaddress, label: 'wikideck-deposit-v1:fixture', address_index: 7, used: false }], status: 'OK' });
if (body.method === 'create_address') return response(body.id, { address: subaddress, address_index: 7,
address_indices: [7], addresses: [subaddress], status: 'OK' });
return response(body.id, { height: 100, status: 'OK' });
};
const rpc = createMoneroDepositAddressRpc(config, transport);
assert.deepEqual(await rpc.getSubaddress(0, 7), { address: subaddress, label: 'wikideck-deposit-v1:fixture' });
assert.deepEqual(await rpc.createSubaddress(0, 'wikideck-deposit-v1:fixture'), { address: subaddress, addressIndex: 7 });
assert.deepEqual(calls, [
{ method: 'get_address', params: { account_index: 0, address_index: [7] } },
{ method: 'create_address', params: { account_index: 0, label: 'wikideck-deposit-v1:fixture', count: 1 } },
]);
await assert.rejects(rpc.getSubaddress(0, 8), /different subaddress index/);
await assert.rejects(rpc.createSubaddress(0, 'bad\nlabel'), /parameters/);
let unexpectedCalls = 0;
const onlyAddressTransport: MoneroFixtureTransport = async (_url, init) => {
unexpectedCalls++;
const body = JSON.parse(init.body as string) as { id: string; method: string };
return response(body.id, { height: 100, status: 'OK' });
};
await assert.rejects(createMoneroDepositAddressRpc(config, onlyAddressTransport).getSubaddress(0, 0), /expected/);
assert.equal(unexpectedCalls, 1);
});
test('observer refuses non-loopback plaintext, URL credentials and malformed height ranges', async () => {
assert.throws(() => createMoneroWalletObserver({ ...config, url: 'http://wallet.example/json_rpc' }), /HTTPS/);
assert.throws(() => createMoneroWalletObserver({ ...config, url: 'http://localhost/json_rpc' }), /HTTPS/);
+29 -1
View File
@@ -17,7 +17,8 @@ import { CustodyMarket } from '../../server/market/custody.ts';
import { migrateCustody, grantCustodyRuntime, grantMoneroObserverRuntime, CUSTODY_SCHEMA_V1, CUSTODY_SCHEMA_V1_DIGEST,
CUSTODY_MIGRATION_V2, CUSTODY_SCHEMA_V2_DIGEST } from '../../server/market/postgres-schema.ts';
import { persistMoneroObservationScan } from '../../server/market/monero-observation-store.ts';
import { registerMoneroDepositSubaddress, reserveMoneroDepositAllocation } from '../../server/market/monero-deposit-allocation-store.ts';
import { createOrRecoverMoneroDepositSubaddress, registerMoneroDepositSubaddress,
reserveMoneroDepositAllocation } from '../../server/market/monero-deposit-allocation-store.ts';
import { importSqliteCustody } from '../../server/market/custody-import.ts';
import { postgresConfig, custodyStorage } from '../../server/market/postgres-config.ts';
import { openCustodyStorage } from '../../server/market/custody-storage.ts';
@@ -286,6 +287,33 @@ test('deposit allocation reserves unique beneficiary-bound indexes before RPC an
[first.allocation.allocationId]), /cannot be deleted/);
await assert.rejects(f.admin.query('UPDATE wk_custody.monero_deposit_allocations SET beneficiary=$2 WHERE allocation_id=$1',
[first.allocation.allocationId, other.actor]), /identity is immutable/);
const recoveryGeneration = randomUUID();
const recoveryReservation = await reserveMoneroDepositAllocation(f.admin, { ...input(randomUUID(), buyer.actor),
walletGenerationId: recoveryGeneration, walletFingerprint: 'a'.repeat(64) });
const walletAddresses = new Map<string, { address: string; label: string }>();
const recoveryAddress = await syntheticSubaddress(6);
let createCalls = 0;
const fakeRpc = {
async getSubaddress(accountIndex: number, addressIndex: number) {
return walletAddresses.get(`${accountIndex}:${addressIndex}`) ?? null;
},
async createSubaddress(accountIndex: number, label: string) {
createCalls++;
const row = { address: recoveryAddress, label };
walletAddresses.set(`${accountIndex}:${recoveryReservation.allocation.addressIndex}`, row);
if (createCalls === 1) throw new Error('fixture: RPC response lost after wallet persisted address');
return { address: row.address, addressIndex: recoveryReservation.allocation.addressIndex };
},
};
await assert.rejects(createOrRecoverMoneroDepositSubaddress(f.admin, fakeRpc,
recoveryReservation.allocation.allocationId, recoveryGeneration, reservedAt + 5), /response lost/);
const recovered = await createOrRecoverMoneroDepositSubaddress(f.admin, fakeRpc,
recoveryReservation.allocation.allocationId, recoveryGeneration, reservedAt + 6);
assert.equal(recovered.allocation.status, 'registered');
assert.equal(recovered.allocation.moneroAddress, recoveryAddress);
assert.equal(recovered.replayed, false);
assert.equal(createCalls, 1, 'retry must recover by deterministic wallet label rather than create a duplicate');
assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.journals')).rows[0].n, 0,
'address allocation must never create financial postings');
} finally { await secondAllocatorProcess.end(); await custodyRuntime.end(); await observer.end(); }