Signed, beneficiary-bount Monero subaddress enrollment
This commit is contained in:
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user