Added wallet-identity check before allocation
This commit is contained in:
@@ -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.
|
||||
|
||||
**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.
|
||||
**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 requires `get_wallet_info` to identify a Mainnet, Watch only wallet; it checks `get_wallet_info` against `get_address` and derives a domain-separated fingerprint from the verified primary address. It 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) combines PostgreSQL v3 beneficiary/index reservations with this capability: it reserves first, serializes cooperating allocators per wallet/account, checks that the live wallet fingerprint matches the durable generation, checks the exact reserved index, and recovers a lost `create_address` response by looking up the 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. The configured wallet-generation UUID still requires controlled operator enrollment and backup; the fingerprint/identity checks are implemented but only 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 rejection of wallet identity mismatch and simulated create-success/response-loss/recovery without 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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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.
|
||||
**Monero observation and allocation groundwork:** the disabled-by-default observer remains read-only. The separate address RPC capability now requires a Mainnet, Watch only wallet and compares `get_wallet_info` with the primary `get_address`; it derives the wallet fingerprint from that validated address. The internal allocator checks that fingerprint against the durable generation before reserving/creating, reserves the beneficiary/index first, serializes cooperating allocators, and recovers create-success/lost-response by checking a stable label at the reserved index. It refuses mismatched wallet identity/index instead of silently binding. This is fixture-tested only, not verified against an actual wallet binary or wallet generation. It is not wired to startup/routes/UI/scans/financial posting, and the registry remains inaccessible to custody API and observer roles. Operational assumptions still include a dedicated allocator wallet RPC, controlled generation enrollment/backups and 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, mismatched-wallet rejection and simulated response-loss recovery without duplicate address creation. No live RPC, wallet credential or funds were used. Remaining deposit milestone: rehearse against a disposable local Monero wallet on stagenet; persist and verify wallet-generation enrollment and restart recovery; establish stable per-output identity and canonical inclusion/lock/reorg semantics; then build exact idempotent journal crediting plus audited reversal/hold handling and independent accounting/security review before any real deposits.
|
||||
|
||||
## 1A. MVP release boundary — proposed safe first production-hosted release
|
||||
|
||||
|
||||
@@ -238,6 +238,10 @@ export async function createOrRecoverMoneroDepositSubaddress(
|
||||
return { allocation: toAllocation(current.rows[0]), replayed: true };
|
||||
}
|
||||
const allocation = current.rows[0];
|
||||
const liveWalletFingerprint = await rpc.verifyWallet();
|
||||
if (liveWalletFingerprint !== allocation.wallet_fingerprint) {
|
||||
throw new Error('Configured Monero wallet identity differs from the durable allocation generation');
|
||||
}
|
||||
const expectedLabel = `wikideck-deposit-v1:${allocation.allocation_id}`;
|
||||
const expectedIndex = Number(allocation.address_index);
|
||||
const existing = await rpc.getSubaddress(Number(allocation.account_index), expectedIndex);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createHash, 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';
|
||||
@@ -20,6 +20,8 @@ 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 walletInfoResultSchema = z.object({ address: z.string().length(95), network_type: z.literal('Mainnet'),
|
||||
wallet_type: z.literal('Watch only') }).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),
|
||||
@@ -131,10 +133,10 @@ export async function loadMoneroObserverConfig(env: NodeJS.ProcessEnv = process.
|
||||
|
||||
/** Restricted read-only surface. No generic RPC method, subaddress creation, transfer or sweep API exists. */
|
||||
function createWalletRpc(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport,
|
||||
allowedMethods: readonly ('get_height' | 'get_address' | 'get_transfers' | 'create_address')[]) {
|
||||
allowedMethods: readonly ('get_height' | 'get_address' | 'get_transfers' | 'get_wallet_info' | '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' | 'create_address', params: Record<string, unknown> = {}) {
|
||||
async function rpc(method: 'get_height' | 'get_address' | 'get_transfers' | 'get_wallet_info' | '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();
|
||||
@@ -219,18 +221,35 @@ export function createMoneroWalletObserver(config: z.infer<typeof configSchema>,
|
||||
}
|
||||
|
||||
export type MoneroDepositAddressRpc = {
|
||||
verifyWallet(): Promise<string>;
|
||||
getSubaddress(accountIndex: number, addressIndex: number): Promise<{ address: string; label: string } | null>;
|
||||
createSubaddress(accountIndex: number, label: string): Promise<{ address: string; addressIndex: number }>;
|
||||
};
|
||||
|
||||
/** Stable, non-secret identity of the wallet generation, bound to its verified mainnet primary address. */
|
||||
export async function moneroWalletFingerprint(primaryAddress: string): Promise<string> {
|
||||
await validateXmrAddress(primaryAddress);
|
||||
return createHash('sha256').update(JSON.stringify(['wikideck-monero-wallet-generation-v1', primaryAddress])).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Narrow address-allocation capability; it requires a verified watch-only mainnet wallet and
|
||||
* 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']);
|
||||
const rpc = createWalletRpc(config, transport, ['get_address', 'get_wallet_info', 'create_address']);
|
||||
return {
|
||||
async verifyWallet(): Promise<string> {
|
||||
const [infoRaw, addressRaw] = await Promise.all([rpc('get_wallet_info'), rpc('get_address', { account_index: 0 })]);
|
||||
const info = walletInfoResultSchema.parse(infoRaw);
|
||||
const address = addressResultSchema.parse(addressRaw);
|
||||
await validateXmrAddress(info.address);
|
||||
await validateXmrAddress(address.address);
|
||||
if (info.address !== address.address) throw new Error('Monero wallet primary address identity mismatch');
|
||||
return moneroWalletFingerprint(info.address);
|
||||
},
|
||||
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');
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { keccak } from 'hash-wasm';
|
||||
import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts';
|
||||
import { createMoneroDepositAddressRpc, createMoneroWalletObserver, loadMoneroObserverConfig,
|
||||
type MoneroFixtureTransport } from '../server/market/monero-observer.ts';
|
||||
moneroWalletFingerprint, 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') {
|
||||
@@ -63,16 +63,25 @@ test('deposit-address RPC client is narrowly allow-listed and creates only one v
|
||||
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 === 'get_wallet_info') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address,
|
||||
network_type: 'Mainnet', wallet_type: 'Watch only' });
|
||||
if (body.method === 'get_address') {
|
||||
const params = body.params as { address_index?: number[] };
|
||||
return response(body.id, { address: ADDITIONAL_MARKET_FEE.address,
|
||||
...(params.address_index ? { 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.equal(await rpc.verifyWallet(), await moneroWalletFingerprint(ADDITIONAL_MARKET_FEE.address));
|
||||
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_wallet_info', params: {} },
|
||||
{ method: 'get_address', params: { account_index: 0 } },
|
||||
{ method: 'get_address', params: { account_index: 0, address_index: [7] } },
|
||||
{ method: 'create_address', params: { account_index: 0, label: 'wikideck-deposit-v1:fixture', count: 1 } },
|
||||
]);
|
||||
@@ -87,6 +96,14 @@ test('deposit-address RPC client is narrowly allow-listed and creates only one v
|
||||
};
|
||||
await assert.rejects(createMoneroDepositAddressRpc(config, onlyAddressTransport).getSubaddress(0, 0), /expected/);
|
||||
assert.equal(unexpectedCalls, 1);
|
||||
|
||||
const spendCapableWallet: MoneroFixtureTransport = async (_url, init) => {
|
||||
const body = JSON.parse(init.body as string) as { id: string; method: string };
|
||||
if (body.method === 'get_wallet_info') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address,
|
||||
network_type: 'Mainnet', wallet_type: 'Normal' });
|
||||
return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' });
|
||||
};
|
||||
await assert.rejects(createMoneroDepositAddressRpc(config, spendCapableWallet).verifyWallet(), /expected/);
|
||||
});
|
||||
|
||||
test('observer refuses non-loopback plaintext, URL credentials and malformed height ranges', async () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 { moneroWalletFingerprint } from '../../server/market/monero-observer.ts';
|
||||
import { createOrRecoverMoneroDepositSubaddress, registerMoneroDepositSubaddress,
|
||||
reserveMoneroDepositAllocation } from '../../server/market/monero-deposit-allocation-store.ts';
|
||||
import { importSqliteCustody } from '../../server/market/custody-import.ts';
|
||||
@@ -289,13 +290,16 @@ test('deposit allocation reserves unique beneficiary-bound indexes before RPC an
|
||||
[first.allocation.allocationId, other.actor]), /identity is immutable/);
|
||||
|
||||
const recoveryGeneration = randomUUID();
|
||||
const recoveryFingerprint = await moneroWalletFingerprint(ADDITIONAL_MARKET_FEE.address);
|
||||
const recoveryReservation = await reserveMoneroDepositAllocation(f.admin, { ...input(randomUUID(), buyer.actor),
|
||||
walletGenerationId: recoveryGeneration, walletFingerprint: 'a'.repeat(64) });
|
||||
walletGenerationId: recoveryGeneration, walletFingerprint: recoveryFingerprint });
|
||||
const walletAddresses = new Map<string, { address: string; label: string }>();
|
||||
const recoveryAddress = await syntheticSubaddress(6);
|
||||
let createCalls = 0;
|
||||
let createCalls = 0, addressReads = 0, liveFingerprint = 'b'.repeat(64);
|
||||
const fakeRpc = {
|
||||
async verifyWallet() { return liveFingerprint; },
|
||||
async getSubaddress(accountIndex: number, addressIndex: number) {
|
||||
addressReads++;
|
||||
return walletAddresses.get(`${accountIndex}:${addressIndex}`) ?? null;
|
||||
},
|
||||
async createSubaddress(accountIndex: number, label: string) {
|
||||
@@ -306,6 +310,10 @@ test('deposit allocation reserves unique beneficiary-bound indexes before RPC an
|
||||
return { address: row.address, addressIndex: recoveryReservation.allocation.addressIndex };
|
||||
},
|
||||
};
|
||||
await assert.rejects(createOrRecoverMoneroDepositSubaddress(f.admin, fakeRpc,
|
||||
recoveryReservation.allocation.allocationId, recoveryGeneration, reservedAt + 4), /identity differs/);
|
||||
assert.equal(addressReads, 0); assert.equal(createCalls, 0, 'wallet mismatch must fail before address operations');
|
||||
liveFingerprint = recoveryFingerprint;
|
||||
await assert.rejects(createOrRecoverMoneroDepositSubaddress(f.admin, fakeRpc,
|
||||
recoveryReservation.allocation.allocationId, recoveryGeneration, reservedAt + 5), /response lost/);
|
||||
const recovered = await createOrRecoverMoneroDepositSubaddress(f.admin, fakeRpc,
|
||||
|
||||
Reference in New Issue
Block a user