Wallet subaddress creation with retry recovery
This commit is contained in:
@@ -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/);
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
Reference in New Issue
Block a user