operator/library-only Wallet-RPC address creation and recovery
This commit is contained in:
@@ -16,11 +16,11 @@ 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, grantMoneroDepositAllocatorRuntime, grantMoneroWithdrawalRuntime,
|
||||
grantPilotDepositReservationRuntime, CUSTODY_MIGRATION_V2,
|
||||
grantPilotDepositReservationRuntime, grantPilotDepositAddressRuntime, CUSTODY_MIGRATION_V2,
|
||||
CUSTODY_SCHEMA_V2_DIGEST } from '../../server/market/postgres-schema.ts';
|
||||
import { parseMainnetCustodianId } from '../../server/market/mainnet-custody-identity.ts';
|
||||
import { initializePilotCustodyIdentity, parsePilotCustodianId } from '../../server/market/pilot-custody-identity.ts';
|
||||
import { reservePilotMoneroDepositRequest } from '../../server/market/pilot-monero-deposit-allocation.ts';
|
||||
import { createOrRecoverPilotMoneroDepositAddress, reservePilotMoneroDepositRequest } from '../../server/market/pilot-monero-deposit-allocation.ts';
|
||||
import { readMainnetCustodyBalance, reserveMainnetWithdrawal, cancelMainnetWithdrawal } from '../../server/market/mainnet-withdrawal.ts';
|
||||
import { mainnetCustodyBalanceMessage, mainnetWithdrawalCancellationMessage, mainnetWithdrawalMessage,
|
||||
type MainnetCustodyBalanceRequestUnsigned, type MainnetWithdrawalRequestUnsigned,
|
||||
@@ -208,7 +208,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 v11 without dropping existing metadata', async t => {
|
||||
test('custody schema v1 upgrades additively through v13 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();
|
||||
@@ -222,7 +222,7 @@ test('custody schema v1 upgrades additively through v11 without dropping existin
|
||||
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, 8, 9, 10, 11]);
|
||||
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]);
|
||||
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']) {
|
||||
@@ -238,7 +238,7 @@ test('custody schema v1 upgrades additively through v11 without dropping existin
|
||||
} finally { await admin.end(); }
|
||||
});
|
||||
|
||||
test('custody schema v2 upgrades explicitly through v11 deposit and withdrawal lifecycle tables', async t => {
|
||||
test('custody schema v2 upgrades explicitly through v13 deposit and withdrawal lifecycle tables', async t => {
|
||||
const admin = new Pool(config);
|
||||
await admin.query('DROP SCHEMA IF EXISTS wk_custody CASCADE');
|
||||
const client = await admin.connect();
|
||||
@@ -251,7 +251,7 @@ test('custody schema v2 upgrades explicitly through v11 deposit and withdrawal l
|
||||
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, 8, 9, 10, 11]);
|
||||
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]);
|
||||
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);
|
||||
assert.ok((await admin.query("SELECT to_regclass('wk_custody.monero_withdrawal_intents') AS name")).rows[0].name);
|
||||
@@ -410,6 +410,103 @@ test('pilot signed deposit requests reserve unique durable indexes, replay safel
|
||||
}
|
||||
});
|
||||
|
||||
test('pilot Wallet-RPC address creation recovers a lost response and registers only the reserved index once', async t => {
|
||||
const admin = new Pool(config), database = `pilot_address_create_${randomUUID().replaceAll('-', '')}`;
|
||||
const role = `pilot_address_${randomUUID().replaceAll('-', '')}`;
|
||||
await admin.query(`CREATE DATABASE "${database}"`);
|
||||
const owner = new Pool({ ...config, database }), run = promisify(execFile), custodianId = randomUUID();
|
||||
const now = 1_800_000_000_000;
|
||||
const address = await syntheticSubaddress(17);
|
||||
const bootstrap = `import { Pool } from 'pg'; import { initializePilotCustodyIdentity } from './server/market/pilot-custody-identity.ts';
|
||||
const p=new Pool({host:'127.0.0.1',database:process.env.DB,user:'postgres',ssl:false});
|
||||
try { await initializePilotCustodyIdentity(p,process.env.CUSTODIAN,Number(process.env.NOW)-1); } finally { await p.end(); }`;
|
||||
const script = `import { Pool } from 'pg';
|
||||
import { generateKeyPairSync, sign, randomUUID } from 'node:crypto';
|
||||
import { address as ownerAddress, CHAIN_ID } from './server/testnet/consensus.ts';
|
||||
import { pilotMoneroDepositRequestMessage } from './shared/pilot-monero-deposit.ts';
|
||||
import { createOrRecoverPilotMoneroDepositAddress } from './server/market/pilot-monero-deposit-allocation.ts';
|
||||
const pool=new Pool({host:'127.0.0.1',database:process.env.DB,user:process.env.DB_ROLE,ssl:false,max:4});
|
||||
const now=Number(process.env.NOW), generation='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', fingerprint='c'.repeat(64);
|
||||
function newPair(){return generateKeyPairSync('ec',{namedCurve:'prime256v1'});}
|
||||
function makeRequest(pair,accountIndex=0){
|
||||
const publicKey=pair.publicKey.export({type:'spki',format:'der'}).toString('base64');
|
||||
const unsigned={version:1,marketNetwork:'wikideck-randomx-pilot-1',marketChainId:CHAIN_ID,moneroNetwork:'mainnet',
|
||||
custodianId:process.env.CUSTODIAN,beneficiary:ownerAddress(publicKey),publicKey,requestId:randomUUID(),
|
||||
walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex,expiresAt:now+60000};
|
||||
return {...unsigned,signature:sign('sha256',Buffer.from(pilotMoneroDepositRequestMessage(unsigned)),
|
||||
{key:pair.privateKey,dsaEncoding:'ieee-p1363'}).toString('base64')};
|
||||
}
|
||||
const request=makeRequest(newPair());
|
||||
const addresses=new Map(), counters={create:0,verify:0}, failFirstCreate={value:true};
|
||||
const rpc={verifyWallet:async()=>{counters.verify++;return fingerprint;},
|
||||
getSubaddress:async(account,index)=>addresses.get(account+':'+index)??null,
|
||||
createSubaddress:async(account,label)=>{counters.create++;if(account===1)throw Error('ambiguous create with no visible address');
|
||||
const entry={address:process.env.MONERO_ADDRESS,label};
|
||||
addresses.set(account+':0',entry);if(failFirstCreate.value){failFirstCreate.value=false;throw Error('simulated lost response');}
|
||||
return {address:entry.address,addressIndex:0};}};
|
||||
try {
|
||||
let firstRejected=false;try{await createOrRecoverPilotMoneroDepositAddress(pool,rpc,
|
||||
{walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:0,now:()=>now},request,now);}catch{firstRejected=true;}
|
||||
const afterFailure=await pool.query('SELECT status,create_started_at,monero_address FROM wk_custody.pilot_monero_deposit_reservations WHERE request_id=$1',[request.requestId]);
|
||||
const recovered=await createOrRecoverPilotMoneroDepositAddress(pool,rpc,
|
||||
{walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:0,now:()=>now},request,now);
|
||||
const replay=await createOrRecoverPilotMoneroDepositAddress(pool,rpc,
|
||||
{walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:0,now:()=>now},request,now);
|
||||
const uncertainRequest=makeRequest(newPair(),1);
|
||||
let uncertainFirst=false,uncertainRetry=false;
|
||||
try{await createOrRecoverPilotMoneroDepositAddress(pool,rpc,
|
||||
{walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:1,now:()=>now},uncertainRequest,now);}catch{uncertainFirst=true;}
|
||||
try{await createOrRecoverPilotMoneroDepositAddress(pool,rpc,
|
||||
{walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:1,now:()=>now},uncertainRequest,now);}catch(error){uncertainRetry=/outcome is uncertain/.test(error.message);}
|
||||
const uncertainState=await pool.query('SELECT status FROM wk_custody.pilot_monero_deposit_reservations WHERE request_id=$1',[uncertainRequest.requestId]);
|
||||
const result={firstRejected,afterFailure:afterFailure.rows[0],recovered,replay,uncertainFirst,uncertainRetry,
|
||||
uncertainStatus:uncertainState.rows[0]?.status,counters};
|
||||
console.log(JSON.stringify(result));
|
||||
} finally { await pool.end(); }`;
|
||||
const env = { ...process.env, WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: 'private-mock-only',
|
||||
DB: database, CUSTODIAN: custodianId, NOW: String(now), MONERO_ADDRESS: address };
|
||||
try {
|
||||
await migrateCustody(owner);
|
||||
await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', bootstrap], {
|
||||
env: { ...process.env, WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: 'private-mock-only',
|
||||
DB: database, CUSTODIAN: custodianId, NOW: String(now) },
|
||||
});
|
||||
await admin.query(`CREATE ROLE "${role}" LOGIN`);
|
||||
await grantPilotDepositAddressRuntime(owner, role);
|
||||
assert.equal((await owner.query("SELECT has_table_privilege($1,'wk_custody.pilot_monero_deposit_reservations','UPDATE') AS allowed", [role])).rows[0].allowed, false);
|
||||
assert.equal((await owner.query("SELECT has_column_privilege($1,'wk_custody.pilot_monero_deposit_reservations','status','UPDATE') AS allowed", [role])).rows[0].allowed, true);
|
||||
assert.equal((await owner.query("SELECT has_column_privilege($1,'wk_custody.pilot_monero_deposit_reservations','monero_address','UPDATE') AS allowed", [role])).rows[0].allowed, true);
|
||||
assert.equal((await owner.query("SELECT has_column_privilege($1,'wk_custody.pilot_monero_deposit_reservations','address_index','UPDATE') AS allowed", [role])).rows[0].allowed, false);
|
||||
assert.equal((await owner.query("SELECT has_table_privilege($1,'wk_custody.accounts','SELECT') AS allowed", [role])).rows[0].allowed, false);
|
||||
const result = JSON.parse((await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', script], {
|
||||
env: { ...env, DB_ROLE: role },
|
||||
})).stdout.trim());
|
||||
assert.equal(result.firstRejected, true);
|
||||
assert.equal(result.afterFailure.status, 'creating');
|
||||
assert.notEqual(result.afterFailure.create_started_at, null);
|
||||
assert.equal(result.afterFailure.monero_address, null);
|
||||
assert.equal(result.recovered.address, address);
|
||||
assert.equal(result.recovered.replayed, false);
|
||||
assert.equal(result.replay.address, address);
|
||||
assert.equal(result.replay.replayed, true);
|
||||
assert.equal(result.uncertainFirst, true);
|
||||
assert.equal(result.uncertainRetry, true);
|
||||
assert.equal(result.uncertainStatus, 'creating');
|
||||
assert.equal(result.counters.create, 2, 'ambiguous create must never be retried automatically');
|
||||
assert.equal((await owner.query("SELECT count(*)::int AS n FROM wk_custody.pilot_monero_deposit_reservations WHERE status='registered' AND monero_address=$1", [address])).rows[0].n, 1);
|
||||
const runtime = new Pool({ ...config, database, user: role });
|
||||
try {
|
||||
await assert.rejects(runtime.query("UPDATE wk_custody.pilot_monero_deposit_reservations SET monero_address=$1", ['5'.repeat(95)]));
|
||||
await assert.rejects(runtime.query('DELETE FROM wk_custody.pilot_monero_deposit_reservations'));
|
||||
} finally { await runtime.end(); }
|
||||
} finally {
|
||||
await owner.end();
|
||||
await admin.query(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`);
|
||||
await admin.query(`DROP ROLE IF EXISTS "${role}"`);
|
||||
await admin.end();
|
||||
}
|
||||
});
|
||||
|
||||
test('mainnet custody identity bootstrap is one-time, chain-bound, and does not open withdrawals', async t => {
|
||||
assert.equal(parseMainnetCustodianId('AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA'), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa');
|
||||
assert.throws(() => parseMainnetCustodianId('not-a-uuid'), /Invalid mainnet custodian UUID/);
|
||||
|
||||
Reference in New Issue
Block a user