Signed, beneficiary-bount Monero subaddress enrollment

This commit is contained in:
2026-10-22 11:33:38 -04:00
parent 5d607c1521
commit b865807272
9 changed files with 266 additions and 28 deletions
+6 -2
View File
@@ -13,10 +13,14 @@ test('custody database CLI parses two-argument deposit operations without shifti
assert.deepEqual(parseCustodyDbArguments(['grant-monero-observer-runtime', 'monero_observer']), {
action: 'grant-monero-observer-runtime', argument: 'monero_observer', extraArgument: undefined,
});
assert.deepEqual(parseCustodyDbArguments(['grant-monero-deposit-allocator-runtime', 'monero_allocator']), {
action: 'grant-monero-deposit-allocator-runtime', argument: 'monero_allocator', extraArgument: undefined,
});
});
test('mainnet custody CLI permits only the observation-role grant', () => {
test('mainnet custody CLI permits only scoped Monero infrastructure grants and schema migration', () => {
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('grant-monero-observer-runtime', true));
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('grant-monero-deposit-allocator-runtime', true));
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('migrate-mainnet-schema', true));
assert.throws(() => assertCustodyDbActionAllowedOnNetwork('migrate-mainnet-schema', false), /requires the mainnet profile/);
assert.throws(() => assertMainnetSchemaMigrationAcknowledged('migrate-mainnet-schema', undefined), /acknowledgement/);
@@ -24,7 +28,7 @@ test('mainnet custody CLI permits only the observation-role grant', () => {
assert.doesNotThrow(() => assertMainnetSchemaMigrationAcknowledged('migrate-mainnet-schema', 'explicit-schema-migration-only'));
assert.doesNotThrow(() => assertMainnetSchemaMigrationAcknowledged('grant-monero-observer-runtime', undefined));
for (const action of ['migrate', 'grant-runtime', 'grant-monero-credit-runtime', 'audit', 'deposit-release'] as const) {
assert.throws(() => assertCustodyDbActionAllowedOnNetwork(action, true), /only the separately scoped/i);
assert.throws(() => assertCustodyDbActionAllowedOnNetwork(action, true), /only explicitly scoped/i);
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork(action, false));
}
});
+83 -17
View File
@@ -15,7 +15,8 @@ import { CustodyIntegrityError } from '../../server/market/custody-integrity.ts'
import { resolveCustodyIncident } from '../../server/market/custody-incident.ts';
import { CustodyMarket } from '../../server/market/custody.ts';
import { migrateCustody, grantCustodyRuntime, grantMoneroObserverRuntime, CUSTODY_SCHEMA_V1, CUSTODY_SCHEMA_V1_DIGEST,
grantMoneroDepositCreditRuntime, CUSTODY_MIGRATION_V2, CUSTODY_SCHEMA_V2_DIGEST } from '../../server/market/postgres-schema.ts';
grantMoneroDepositCreditRuntime, grantMoneroDepositAllocatorRuntime, 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 { runMoneroObserverBatch, runMoneroObserverWorker } from '../../server/market/monero-observer-worker.ts';
@@ -25,6 +26,7 @@ import { depositCreditApprovalMessage, depositCreditReleaseApprovalMessage, depo
type DepositCreditPolicy } from '../../server/market/monero-deposit-credit.ts';
import { createOrRecoverMoneroDepositSubaddress, registerMoneroDepositSubaddress,
reserveMoneroDepositAllocation } from '../../server/market/monero-deposit-allocation-store.ts';
import { enrollMoneroDepositAddress } from '../../server/market/monero-deposit-enrollment.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';
@@ -32,6 +34,7 @@ import { createBackup, restoreBackup } from '../../server/recovery.ts';
import { createCoordinatedCustodyBackup, verifyCoordinatedCustodyBackup } from '../../server/coordinated-backup.ts';
import { CUSTODY_POLICY, custodyMessage, type CustodyAction, type CustodyCommand, type CustodyActivity,
type CustodyListing, type CustodyOrder, type CustodyWithdrawal } from '../../shared/custody.ts';
import { moneroDepositAddressMessage, type MoneroDepositAddressRequestUnsigned } from '../../shared/monero-deposit.ts';
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
import { NETWORK } from '../../shared/network.ts';
import { CHAIN_ID, GENESIS_HASH, address, sha256 } from '../../server/testnet/consensus.ts';
@@ -197,7 +200,7 @@ test('explicit PostgreSQL migrations are idempotent and runtime role cannot alte
assert.equal((await f.market.audit()).assetsAtomicXmr, '1000000000000');
});
test('custody schema v1 upgrades additively through v7 without dropping existing metadata', async t => {
test('custody schema v1 upgrades additively through v8 without dropping existing metadata', async t => {
const admin = new Pool(config);
await admin.query('DROP SCHEMA IF EXISTS wk_custody CASCADE');
const client = await admin.connect();
@@ -211,7 +214,7 @@ test('custody schema v1 upgrades additively through v7 without dropping existing
try {
await migrateCustody(admin); await migrateCustody(admin);
const versions = await admin.query('SELECT version FROM wk_custody.schema_versions ORDER BY version');
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7]);
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7, 8]);
assert.equal((await admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='v1-preserved-marker'")).rows[0].value,
'existing-test-data');
for (const table of ['monero_observer_state', 'monero_transfer_observations', 'monero_observation_events', 'monero_observation_scans']) {
@@ -225,7 +228,7 @@ test('custody schema v1 upgrades additively through v7 without dropping existing
} finally { await admin.end(); }
});
test('custody schema v2 upgrades explicitly through v7 deposit lifecycle tables', async t => {
test('custody schema v2 upgrades explicitly through v8 deposit lifecycle tables', async t => {
const admin = new Pool(config);
await admin.query('DROP SCHEMA IF EXISTS wk_custody CASCADE');
const client = await admin.connect();
@@ -238,7 +241,7 @@ test('custody schema v2 upgrades explicitly through v7 deposit lifecycle tables'
await client.query('COMMIT');
await migrateCustody(admin); await migrateCustody(admin);
const versions = await admin.query('SELECT version FROM wk_custody.schema_versions ORDER BY version');
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7]);
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7, 8]);
assert.equal((await admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='v2-marker'")).rows[0].value, 'preserve');
assert.ok((await admin.query("SELECT to_regclass('wk_custody.monero_deposit_release_events') AS name")).rows[0].name);
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
@@ -337,6 +340,67 @@ test('deposit allocation reserves unique beneficiary-bound indexes before RPC an
} finally { await secondAllocatorProcess.end(); await custodyRuntime.end(); await observer.end(); }
});
test('signed mainnet deposit enrollment reserves and recovers one beneficiary address with the allocator role', async t => {
const f = await fixture(t);
await f.admin.query("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='monero_deposit_allocator_test') THEN CREATE ROLE monero_deposit_allocator_test LOGIN; END IF; END $$");
await grantMoneroDepositAllocatorRuntime(f.admin, 'monero_deposit_allocator_test');
await assert.rejects(grantMoneroDepositAllocatorRuntime(f.admin, 'custody_runtime'), /separate role/);
const allocator = new Pool({ ...config, user: 'monero_deposit_allocator_test' });
const walletGenerationId = randomUUID(), walletFingerprint = 'a'.repeat(64), chainId = 'b'.repeat(64);
const now = Date.now(), depositAddress = await syntheticSubaddress(8), labels = new Map<string, string>();
let createCalls = 0, verifyCalls = 0;
const rpc = {
async verifyWallet() { verifyCalls++; return walletFingerprint; },
async getSubaddress(accountIndex: number, addressIndex: number) {
const label = labels.get(`${accountIndex}:${addressIndex}`);
return label ? { address: depositAddress, label } : null;
},
async createSubaddress(accountIndex: number, label: string) {
createCalls++;
labels.set(`${accountIndex}:${createCalls - 1}`, label);
return { address: depositAddress, addressIndex: createCalls - 1 };
},
};
const context = { custodianId: f.market.id, chainId, walletGenerationId, walletFingerprint, accountIndex: 0,
now: () => now };
const signedRequest = (requestId: string) => {
const unsigned: MoneroDepositAddressRequestUnsigned = { version: 1, network: 'wikideck-randomx-mainnet-1',
chainId, custodianId: f.market.id, actor: buyer.actor, publicKey: buyer.publicKey, requestId,
walletGenerationId, walletFingerprint, accountIndex: 0, expiresAt: now + 60_000 };
return { ...unsigned, signature: buyer.sign(moneroDepositAddressMessage(unsigned)) };
};
try {
const request = signedRequest(randomUUID());
const created = await enrollMoneroDepositAddress(allocator, rpc, context, request);
assert.equal(created.network, 'mainnet'); assert.equal(created.address, depositAddress);
assert.equal(created.addressIndex, 0); assert.equal(created.replayed, false);
const replay = await enrollMoneroDepositAddress(allocator, rpc, context, request);
assert.equal(replay.allocationId, created.allocationId); assert.equal(replay.address, created.address);
assert.equal(replay.replayed, true); assert.equal(createCalls, 1);
assert.equal(verifyCalls, 1);
const newNonce = await enrollMoneroDepositAddress(allocator, rpc, context, signedRequest(randomUUID()));
assert.equal(newNonce.allocationId, created.allocationId, 'one beneficiary keeps one address per wallet generation');
assert.equal(newNonce.replayed, true); assert.equal(createCalls, 1);
await assert.rejects(enrollMoneroDepositAddress(allocator, rpc, context, { ...request, actor: other.actor }), /signature|beneficiary/);
const wrongChain = { ...signedRequest(randomUUID()), chainId: 'c'.repeat(64) };
await assert.rejects(enrollMoneroDepositAddress(allocator, rpc, context, wrongChain), /does not match/);
assert.equal(verifyCalls, 1, 'invalid signed terms must be rejected before Wallet RPC');
await assert.rejects(allocator.query('SELECT * FROM wk_custody.journals'), /permission denied/,
'the allocation role cannot read financial journals');
await assert.rejects(allocator.query('DELETE FROM wk_custody.monero_deposit_allocations WHERE allocation_id=$1',
[created.allocationId]), /permission denied/);
await assert.rejects(allocator.query(`INSERT INTO wk_custody.monero_deposit_allocations(
allocation_id,request_id,custodian_id,market_network,market_chain_id,beneficiary,wallet_generation_id,
wallet_fingerprint,monero_network,account_index,address_index,status,monero_address,request_digest,reserved_at,registered_at)
VALUES($1,$2,$3,'wikideck-randomx-mainnet-1',$4,$5,$6,$7,'mainnet',0,100,'registered',$8,$9,$10,$10)` ,
[randomUUID(), randomUUID(), f.market.id, chainId, other.actor, walletGenerationId, walletFingerprint,
await syntheticSubaddress(9), 'd'.repeat(64), now]), /inserted as address-free reservations/i,
'allocator credentials must not bypass the reserve-then-register state transition');
assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.journals')).rows[0].n, 0,
'deposit address enrollment must not credit balances or post journals');
} finally { await allocator.end(); }
});
test('Monero scan persistence is idempotent, reorg-visible and never posts customer credit', async t => {
const f = await fixture(t);
await f.admin.query("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='monero_observer') THEN CREATE ROLE monero_observer LOGIN; END IF; END $$");
@@ -478,21 +542,23 @@ test('reviewed Monero outputs post once to pending liability and reorgs create h
const custodyRuntime = new Pool(f.runtime);
const wallet = '8'.repeat(64), txid = '9'.repeat(64), outputPublicKey = 'a'.repeat(64);
const observationId = `${txid}:${outputPublicKey}`, now = Date.now(), scanId = randomUUID();
const generationId = randomUUID(), allocationId = randomUUID(), requestId = randomUUID();
const observation = { observationId, transactionId: txid, amountAtomicXmr: '9007199254740993', height: 90,
address: ADDITIONAL_MARKET_FEE.address, confirmations: 11, timestamp: now, unlockTime: '0',
accountIndex: 0, addressIndex: 4, outputPublicKey, globalOutputIndex: 123, locked: false, doubleSpendSeen: false };
const page = { network: 'mainnet' as const, walletHeight: 100, fromHeight: 0, throughHeight: 100,
primaryAddress: ADDITIONAL_MARKET_FEE.address, incoming: [observation] };
const generationId = randomUUID(), requestId = randomUUID();
const depositAddress = await syntheticSubaddress(10);
const policy: DepositCreditPolicy = { approvedReviewerOwners: [host.actor], minimumConfirmations: 10,
maximumObservationAgeMs: 120_000 };
try {
await f.admin.query(`INSERT INTO wk_custody.monero_deposit_allocations(
allocation_id,request_id,custodian_id,market_network,market_chain_id,beneficiary,wallet_generation_id,
wallet_fingerprint,monero_network,account_index,address_index,status,monero_address,request_digest,reserved_at,registered_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,'mainnet',0,4,'registered',$9,$10,$11,$12)`,
[allocationId, requestId, f.market.id, NETWORK, CHAIN_ID, buyer.actor, generationId, wallet,
ADDITIONAL_MARKET_FEE.address, 'b'.repeat(64), now - 1, now]);
const allocation = await reserveMoneroDepositAllocation(f.admin, { requestId, custodianId: f.market.id,
marketNetwork: NETWORK, marketChainId: CHAIN_ID, beneficiary: buyer.actor, walletGenerationId: generationId,
walletFingerprint: wallet, accountIndex: 0, reservedAt: now - 1 });
await registerMoneroDepositSubaddress(f.admin, { allocationId: allocation.allocation.allocationId,
walletGenerationId: generationId, moneroAddress: depositAddress, registeredAt: now });
const allocationId = allocation.allocation.allocationId;
const observation = { observationId, transactionId: txid, amountAtomicXmr: '9007199254740993', height: 90,
address: depositAddress, confirmations: 11, timestamp: now, unlockTime: '0',
accountIndex: 0, addressIndex: allocation.allocation.addressIndex, outputPublicKey, globalOutputIndex: 123,
locked: false, doubleSpendSeen: false };
const page = { network: 'mainnet' as const, walletHeight: 100, fromHeight: 0, throughHeight: 100,
primaryAddress: ADDITIONAL_MARKET_FEE.address, incoming: [observation] };
await persistMoneroObservationScan(observer, { scanId, walletFingerprint: wallet, expectedScanHeight: 0,
scannedAt: now, page });
await assert.rejects(custodyRuntime.query('SELECT * FROM wk_custody.monero_transfer_observations'));