Ledger-side credit and withdrawal-intent paths
This commit is contained in:
@@ -11,12 +11,17 @@ import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { PostgresCustody, auditPostgres } from '../../server/market/postgres-custody.ts';
|
||||
import { CustodyIntegrityError } from '../../server/market/custody-integrity.ts';
|
||||
import { CustodyIntegrityError, auditBusinessLiabilities } 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, grantMoneroDepositAllocatorRuntime, CUSTODY_MIGRATION_V2,
|
||||
grantMoneroDepositCreditRuntime, grantMoneroDepositAllocatorRuntime, grantMoneroWithdrawalRuntime, CUSTODY_MIGRATION_V2,
|
||||
CUSTODY_SCHEMA_V2_DIGEST } from '../../server/market/postgres-schema.ts';
|
||||
import { parseMainnetCustodianId } from '../../server/market/mainnet-custody-identity.ts';
|
||||
import { readMainnetCustodyBalance, reserveMainnetWithdrawal, cancelMainnetWithdrawal } from '../../server/market/mainnet-withdrawal.ts';
|
||||
import { mainnetCustodyBalanceMessage, mainnetWithdrawalCancellationMessage, mainnetWithdrawalMessage,
|
||||
type MainnetCustodyBalanceRequestUnsigned, type MainnetWithdrawalRequestUnsigned,
|
||||
type MainnetWithdrawalCancellationUnsigned } from '../../shared/monero-withdrawal.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';
|
||||
@@ -200,7 +205,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 v8 without dropping existing metadata', async t => {
|
||||
test('custody schema v1 upgrades additively through v9 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();
|
||||
@@ -214,7 +219,7 @@ test('custody schema v1 upgrades additively through v8 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, 8]);
|
||||
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
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']) {
|
||||
@@ -228,7 +233,7 @@ test('custody schema v1 upgrades additively through v8 without dropping existing
|
||||
} finally { await admin.end(); }
|
||||
});
|
||||
|
||||
test('custody schema v2 upgrades explicitly through v8 deposit lifecycle tables', async t => {
|
||||
test('custody schema v2 upgrades explicitly through v9 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();
|
||||
@@ -241,13 +246,173 @@ test('custody schema v2 upgrades explicitly through v8 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, 8]);
|
||||
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
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);
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); 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/);
|
||||
const admin = new Pool(config), database = `mainnet_identity_${randomUUID().replaceAll('-', '')}`;
|
||||
await admin.query(`CREATE DATABASE "${database}"`);
|
||||
const target = new Pool({ ...config, database });
|
||||
const run = promisify(execFile), custodianId = randomUUID();
|
||||
try {
|
||||
await migrateCustody(target);
|
||||
const code = `import { Pool } from 'pg';
|
||||
import { initializeMainnetCustodyIdentity } from './server/market/mainnet-custody-identity.ts';
|
||||
const pool = new Pool({host:'127.0.0.1',database:process.env.TEST_DATABASE,user:'postgres',ssl:false});
|
||||
try { console.log(JSON.stringify(await initializeMainnetCustodyIdentity(pool, process.env.TEST_CUSTODIAN_ID))); }
|
||||
finally { await pool.end(); }`;
|
||||
const env = { ...process.env, WIKIDECK_NETWORK: 'mainnet', TEST_DATABASE: database, TEST_CUSTODIAN_ID: custodianId };
|
||||
const first = JSON.parse((await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], { env })).stdout.trim());
|
||||
const replay = JSON.parse((await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], { env })).stdout.trim());
|
||||
assert.equal(first.mode, 'mainnet-custody'); assert.equal(first.network, 'wikideck-randomx-mainnet-1');
|
||||
assert.equal(first.id, custodianId); assert.deepEqual(replay, first);
|
||||
await assert.rejects(run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], {
|
||||
env: { ...env, TEST_CUSTODIAN_ID: randomUUID() },
|
||||
}));
|
||||
assert.equal((await target.query('SELECT count(*)::int AS n FROM wk_custody.journals')).rows[0].n, 0);
|
||||
} finally { await target.end(); await admin.query(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`); await admin.end(); }
|
||||
});
|
||||
|
||||
test('customer-signed mainnet withdrawal reserves available balance and can cancel before signer handoff', async t => {
|
||||
const admin = new Pool(config), database = `mainnet_withdraw_${randomUUID().replaceAll('-', '')}`;
|
||||
await admin.query(`CREATE DATABASE "${database}"`);
|
||||
const owner = new Pool({ ...config, database }), custodianId = randomUUID(), run = promisify(execFile);
|
||||
const now = Date.now(), withdrawalRole = 'monero_withdrawal_test', observerRole = 'monero_observer',
|
||||
creditRole = 'monero_deposit_credit';
|
||||
await admin.query(`DO $$ BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='${withdrawalRole}') THEN CREATE ROLE ${withdrawalRole} LOGIN; END IF;
|
||||
IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='${observerRole}') THEN CREATE ROLE ${observerRole} LOGIN; END IF;
|
||||
IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='${creditRole}') THEN CREATE ROLE ${creditRole} LOGIN; END IF;
|
||||
END $$`);
|
||||
const runtime = new Pool({ ...config, database, user: withdrawalRole });
|
||||
const observer = new Pool({ ...config, database, user: observerRole });
|
||||
const creditRuntime = new Pool({ ...config, database, user: creditRole });
|
||||
try {
|
||||
await migrateCustody(owner);
|
||||
const bootstrap = `import { Pool } from 'pg'; import { initializeMainnetCustodyIdentity } from './server/market/mainnet-custody-identity.ts';
|
||||
const p=new Pool({host:'127.0.0.1',database:process.env.DB,user:'postgres',ssl:false});
|
||||
try { await initializeMainnetCustodyIdentity(p,process.env.CUSTODIAN); } finally { await p.end(); }`;
|
||||
await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', bootstrap], {
|
||||
env: { ...process.env, WIKIDECK_NETWORK: 'mainnet', DB: database, CUSTODIAN: custodianId },
|
||||
});
|
||||
const mainnetChain = JSON.parse((await owner.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'")).rows[0].value).chainId as string;
|
||||
await grantMoneroObserverRuntime(owner, observerRole);
|
||||
await grantMoneroDepositCreditRuntime(owner, creditRole);
|
||||
await grantMoneroWithdrawalRuntime(owner, withdrawalRole);
|
||||
assert.equal((await owner.query("SELECT has_table_privilege($1,'wk_custody.accounts','SELECT') AS allowed", [withdrawalRole])).rows[0].allowed, true);
|
||||
assert.equal((await owner.query("SELECT has_table_privilege($1,'wk_custody.withdrawals','SELECT') AS allowed", [withdrawalRole])).rows[0].allowed, false);
|
||||
assert.equal((await owner.query("SELECT has_table_privilege($1,'wk_custody.monero_transfer_observations','SELECT') AS allowed", [withdrawalRole])).rows[0].allowed, false);
|
||||
const walletFingerprint = 'd'.repeat(64), generation = randomUUID(), allocationRequest = randomUUID();
|
||||
const depositAddress = await syntheticSubaddress(11), outputTx = 'e'.repeat(64), outputKey = 'f'.repeat(64);
|
||||
const allocation = await reserveMoneroDepositAllocation(owner, { requestId: allocationRequest, custodianId,
|
||||
marketNetwork: 'wikideck-randomx-mainnet-1', marketChainId: mainnetChain, beneficiary: buyer.actor,
|
||||
walletGenerationId: generation, walletFingerprint, accountIndex: 0, reservedAt: now - 1 });
|
||||
await registerMoneroDepositSubaddress(owner, { allocationId: allocation.allocation.allocationId,
|
||||
walletGenerationId: generation, moneroAddress: depositAddress, registeredAt: now });
|
||||
const observationId = `${outputTx}:${outputKey}`;
|
||||
const observation = { observationId, transactionId: outputTx, amountAtomicXmr: '1000', height: 190,
|
||||
address: depositAddress, confirmations: 11, timestamp: now, unlockTime: '0', accountIndex: 0,
|
||||
addressIndex: allocation.allocation.addressIndex, outputPublicKey: outputKey, globalOutputIndex: 90,
|
||||
locked: false, doubleSpendSeen: false };
|
||||
const page = { network: 'mainnet' as const, walletHeight: 200, fromHeight: 0, throughHeight: 200,
|
||||
primaryAddress: ADDITIONAL_MARKET_FEE.address, incoming: [observation] };
|
||||
await persistMoneroObservationScan(observer, { scanId: randomUUID(), walletFingerprint,
|
||||
expectedScanHeight: 0, scannedAt: now, page });
|
||||
const policy: DepositCreditPolicy = { approvedReviewerOwners: [host.actor], minimumConfirmations: 10,
|
||||
maximumObservationAgeMs: 120_000 };
|
||||
const creditSubject = await prepareDepositCreditReview(creditRuntime, walletFingerprint, observationId, policy, now);
|
||||
const creditNonce = randomUUID(), creditExpiry = now + 60_000;
|
||||
const creditApproval = { subject: creditSubject, reviewerOwner: host.actor, reviewerPublicKey: host.publicKey,
|
||||
nonce: creditNonce, expiresAt: creditExpiry,
|
||||
signature: host.sign(depositCreditApprovalMessage(creditSubject, host.actor, creditNonce, creditExpiry)) };
|
||||
const pendingCredit = await postReviewedDepositPendingCredit(creditRuntime, creditApproval, policy, now);
|
||||
const releaseSubject = await prepareDepositCreditReleaseReview(creditRuntime, pendingCredit.creditId, policy, now);
|
||||
const releaseNonce = randomUUID(), releaseExpiry = now + 60_000;
|
||||
const releaseApproval = { subject: releaseSubject, reviewerOwner: host.actor, reviewerPublicKey: host.publicKey,
|
||||
nonce: releaseNonce, expiresAt: releaseExpiry,
|
||||
signature: host.sign(depositCreditReleaseApprovalMessage(releaseSubject, host.actor, releaseNonce, releaseExpiry)) };
|
||||
await releaseReviewedDepositCredit(creditRuntime, releaseApproval, policy, now);
|
||||
assert.equal((await owner.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1',
|
||||
[`customer:${buyer.actor}:available`])).rows[0].balance, '1000', 'mainnet reviewed deposit release funds customer available balance');
|
||||
const balanceUnsigned: MainnetCustodyBalanceRequestUnsigned = { version: 1,
|
||||
network: 'wikideck-randomx-mainnet-1', chainId: mainnetChain, custodianId,
|
||||
beneficiary: buyer.actor, publicKey: buyer.publicKey, nonce: randomUUID(), expiresAt: now + 60_000 };
|
||||
const balanceRequest = { ...balanceUnsigned, signature: buyer.sign(mainnetCustodyBalanceMessage(balanceUnsigned)) };
|
||||
assert.deepEqual(await readMainnetCustodyBalance(runtime, balanceRequest, now), {
|
||||
availableAtomicXmr: '1000', depositPendingAtomicXmr: '0', withdrawalReservedAtomicXmr: '0',
|
||||
});
|
||||
await assert.rejects(readMainnetCustodyBalance(runtime, { ...balanceRequest, beneficiary: seller.actor }, now), /signature/);
|
||||
|
||||
// Model an accidental/operator-created liability balance above reviewed on-chain credits.
|
||||
const fixtureClient = await owner.connect();
|
||||
try {
|
||||
await fixtureClient.query('BEGIN');
|
||||
await fixtureClient.query("INSERT INTO wk_custody.accounts(id,normal) VALUES('treasury:fixture-extra', 'debit') ON CONFLICT DO NOTHING");
|
||||
await fixtureClient.query("INSERT INTO wk_custody.journals(id,kind,created) VALUES('fixture-unbacked-extra','fixture-unbacked-extra',$1)", [now]);
|
||||
await fixtureClient.query("INSERT INTO wk_custody.entries VALUES('fixture-unbacked-extra',0,'treasury:fixture-extra',500,0),('fixture-unbacked-extra',1,$1,0,500)",
|
||||
[`customer:${buyer.actor}:available`]);
|
||||
await fixtureClient.query('COMMIT');
|
||||
} catch (error) { await fixtureClient.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { fixtureClient.release(); }
|
||||
|
||||
const requestId = randomUUID();
|
||||
const unsigned: MainnetWithdrawalRequestUnsigned = { version: 1, network: 'wikideck-randomx-mainnet-1',
|
||||
chainId: mainnetChain, custodianId, beneficiary: buyer.actor, publicKey: buyer.publicKey, requestId,
|
||||
destination: ADDITIONAL_MARKET_FEE.address, amountAtomicXmr: '700', maxFeeAtomicXmr: '50', expiresAt: now + 60_000 };
|
||||
const request = { ...unsigned, signature: buyer.sign(mainnetWithdrawalMessage(unsigned)) };
|
||||
const invalidDestinationUnsigned = { ...unsigned, requestId: randomUUID(), destination: '1'.repeat(95) };
|
||||
await assert.rejects(reserveMainnetWithdrawal(runtime, { ...invalidDestinationUnsigned,
|
||||
signature: buyer.sign(mainnetWithdrawalMessage(invalidDestinationUnsigned)) }, now), /address/);
|
||||
const overBackingUnsigned = { ...unsigned, requestId: randomUUID(), amountAtomicXmr: '1100', maxFeeAtomicXmr: '0' };
|
||||
await assert.rejects(reserveMainnetWithdrawal(runtime, { ...overBackingUnsigned,
|
||||
signature: buyer.sign(mainnetWithdrawalMessage(overBackingUnsigned)) }, now), /exceed reviewed released deposit backing/);
|
||||
const first = await reserveMainnetWithdrawal(runtime, request, now);
|
||||
assert.equal(first.status, 'reserved'); assert.equal(first.amountAtomicXmr, '700');
|
||||
const replay = await reserveMainnetWithdrawal(runtime, request, now);
|
||||
assert.equal(replay.requestId, requestId); assert.equal(replay.status, 'reserved');
|
||||
assert.equal((await reserveMainnetWithdrawal(runtime, request, now + 120_000)).status, 'reserved',
|
||||
'replay of a committed request remains idempotent after its authorization expires');
|
||||
assert.equal((await owner.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1',
|
||||
[`customer:${buyer.actor}:available`])).rows[0].balance, '750');
|
||||
assert.equal((await owner.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1',
|
||||
[`customer:${buyer.actor}:monero-withdrawal:${requestId}`])).rows[0].balance, '750');
|
||||
const afterReserveUnsigned = { ...balanceUnsigned, nonce: randomUUID() };
|
||||
const afterReserve = { ...afterReserveUnsigned, signature: buyer.sign(mainnetCustodyBalanceMessage(afterReserveUnsigned)) };
|
||||
assert.deepEqual(await readMainnetCustodyBalance(runtime, afterReserve, now), {
|
||||
availableAtomicXmr: '750', depositPendingAtomicXmr: '0', withdrawalReservedAtomicXmr: '750',
|
||||
});
|
||||
await assert.rejects(runtime.query('SELECT * FROM wk_custody.journals'), /permission denied/);
|
||||
await assert.rejects(reserveMainnetWithdrawal(runtime, { ...request, beneficiary: seller.actor }, now), /signature/);
|
||||
const cancelUnsigned: MainnetWithdrawalCancellationUnsigned = { version: 1, network: 'wikideck-randomx-mainnet-1',
|
||||
chainId: mainnetChain, custodianId, beneficiary: buyer.actor, publicKey: buyer.publicKey,
|
||||
cancellationId: randomUUID(), withdrawalRequestId: requestId, expiresAt: now + 60_000 };
|
||||
const cancel = { ...cancelUnsigned, signature: buyer.sign(mainnetWithdrawalCancellationMessage(cancelUnsigned)) };
|
||||
const cancelled = await cancelMainnetWithdrawal(runtime, cancel, now);
|
||||
assert.equal(cancelled.status, 'cancelled');
|
||||
assert.equal((await cancelMainnetWithdrawal(runtime, cancel, now + 120_000)).status, 'cancelled',
|
||||
'replay of an acknowledged cancellation remains idempotent after expiry');
|
||||
assert.equal((await owner.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1',
|
||||
[`customer:${buyer.actor}:available`])).rows[0].balance, '1500');
|
||||
assert.equal((await owner.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1',
|
||||
[`customer:${buyer.actor}:monero-withdrawal:${requestId}`])).rows[0].balance, '0');
|
||||
const audit = await owner.connect();
|
||||
try { await audit.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'); await auditBusinessLiabilities(audit); await audit.query('COMMIT'); }
|
||||
catch (error) { await audit.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { audit.release(); }
|
||||
const alternateCancelUnsigned = { ...cancelUnsigned, cancellationId: randomUUID() };
|
||||
const alternateCancel = { ...alternateCancelUnsigned,
|
||||
signature: buyer.sign(mainnetWithdrawalCancellationMessage(alternateCancelUnsigned)) };
|
||||
await assert.rejects(cancelMainnetWithdrawal(runtime, alternateCancel, now), /different signed terms/);
|
||||
} finally { await creditRuntime.end(); await observer.end(); await runtime.end(); await owner.end(); await admin.query(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`); await admin.end(); }
|
||||
});
|
||||
|
||||
test('deposit allocation reserves unique beneficiary-bound indexes before RPC and only registers an immutable subaddress', async t => {
|
||||
const f = await fixture(t);
|
||||
await f.admin.query("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='monero_allocation_observer') THEN CREATE ROLE monero_allocation_observer LOGIN; END IF; END $$");
|
||||
@@ -531,6 +696,7 @@ test('reviewed Monero outputs post once to pending liability and reorgs create h
|
||||
END $$`);
|
||||
await grantMoneroObserverRuntime(f.admin, 'monero_observer');
|
||||
await grantMoneroDepositCreditRuntime(f.admin, 'monero_deposit_credit');
|
||||
await assert.rejects(grantMoneroDepositCreditRuntime(f.admin, 'monero_deposit_allocator'), /separate role/);
|
||||
const creditPrivileges = await f.admin.query(`SELECT has_table_privilege('monero_deposit_credit',
|
||||
'wk_custody.monero_deposit_credit_events','SELECT') AS can_read_credit,
|
||||
has_table_privilege('monero_deposit_credit','wk_custody.monero_observation_events','SELECT') AS can_read_events`);
|
||||
|
||||
Reference in New Issue
Block a user