Ledger-side credit and withdrawal-intent paths

This commit is contained in:
2026-10-22 13:28:55 -04:00
parent 4fea176877
commit 52559f65d7
17 changed files with 842 additions and 40 deletions
+172 -6
View File
@@ -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`);