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
+1 -1
View File
@@ -1,6 +1,6 @@
# Gated mainnet candidate
This repository now has a **separate mainnet candidate profile**, not an audited or launched production network. Shipping code does not establish independent miners, economic security, an operational public network, or a safe real-money market. **Payments remain mocked on every profile.**
This repository now has a **separate mainnet candidate profile**, not an audited or launched production network. Shipping code does not establish independent miners, economic security, an operational public network, or a safe real-money market. **Marketplace payments remain mocked on every profile.** There is opt-in mainnet code for operator-reviewed pending deposit credits and signed withdrawal reservations, but it does not expose customer deposits by default or construct/relay Monero transactions; it is not a live payment service.
**Current production decision: NO-GO.** See the [2026-10-20 security review](security-review.md) and [operator evidence register and launch procedure](operator-launch.md). The read-only `launch:preflight` package script (or node entry-point `--launch-preflight`) reports local gates and unresolved blockers without starting a node or setting approvals. It deliberately exits nonzero while production readiness is blocked.
+1 -1
View File
@@ -103,7 +103,7 @@ There is currently **no built-in arbitration service, buyer insurance, chargebac
## Future live custody is not active
The intended direction is direct wallet/daemon observation into a pooled liability ledger; it does not depend on a third-party invoice processor. That future flow is not implemented end-to-end. There is no customer-facing deposit route, automatic customer credit, treasury withdrawal service, or real sale settlement. No confirmation threshold, funded reserve, loss allocation, refund policy or dispute process has been approved. This document describes current behavior and does not promise future buyer protection.
The intended direction is direct wallet/daemon observation into a pooled liability ledger; it does not depend on a third-party invoice processor. An opt-in mainnet operator workflow can stage observed outputs as pending and release them to a customer's available ledger balance after signed review. A separate opt-in API accepts signed withdrawal requests and reserves funds, but does not create or relay Monero transactions. Neither flow has a customer UI, and the address-enrollment API does not itself credit funds. No customer-facing deposit address should be issued until withdrawals, outgoing reconciliation, backup recovery, and custody policy are complete. No confirmation threshold, funded reserve, loss allocation, refund policy or dispute process has been approved. This document describes current behavior and does not promise buyer protection.
## Privacy, evidence and host accountability
+1
View File
@@ -89,5 +89,6 @@ For **review**, preserve backups and inspect canonical blocks, pending transfers
## Future live XMR custody
The selected direction is a pooled custodial ledger, not per-sale processor invoices. Real deposits would be assigned to durable Monero subaddresses and reconciled against wallet-owned outputs; purchases would transfer liabilities internally. Withdrawals would require a separate, policy-constrained treasury signer and durable reconciliation. A mainnet-only address-enrollment API now exists behind `WIKIDECK_MONERO_DEPOSIT_ENROLLMENT=1`, but it is off by default and has no customer UI; its response explicitly says the address is not credited. There is still no customer credit worker, withdrawal signer, or paid-sale settlement path. **Do not enable the enrollment API or publish its addresses until customers can be credited, withdraw, and recover funds end-to-end.**
The selected direction is a pooled custodial ledger, not per-sale processor invoices. Deposits are associated with durable Monero subaddresses and an operator-reviewed observation can now be staged as a pending liability, then separately released to a customer's available balance. A separate mainnet-only API can accept signed withdrawal requests, reserve the exact amount plus maximum fee from that balance, and let the customer cancel before signer handoff. Both mainnet paths require dedicated PostgreSQL roles and explicit startup/operator configuration. **They are not a complete payment system:** the current withdrawal endpoint only reserves liabilities; there is no transaction construction, signer, relay, outgoing-chain confirmation or recovery process, and no customer UI. Deposits and withdrawals remain off by default. Do not publish addresses or solicit funds until the XMR send and recovery lifecycle is proven.
Do not send real XMR to this sandbox. Live activation requires an end-to-end reviewed implementation, funded-liability and custody policies, production wallet/daemon isolation, withdrawal/reorg recovery, backups and operations evidence. No payment-mode setting is an activation path.
+2 -2
View File
@@ -27,7 +27,7 @@ The runtime image can execute the entry point with `--launch-preflight` as well;
For the proposed **non-monetary testnet beta only**, use a separate scoped decision: the chain/network is explicitly testnet, no valuable cards are offered, real payment/custody settings remain off, mock market delivery is disabled or the deployment is demonstrably valueless, and experimental wallet/data-loss limitations are disclosed. This does not close any mainnet/live-payment hold point below. Apartment-hosting evidence must include ISP/CGNAT/reachability, TLS/proxy/backend isolation, firewall/admin/patch controls, off-host encrypted backup and restore, and external alert delivery. If these cannot be verified, do not expose the apartment host publicly; choose a managed host.
The real-money custody path is not implemented end-to-end. Production deposit/credit/withdrawal services, custody and refund policy, journal/backup/monitor integration, operator reconciliation and independent review remain missing.
The real-money path is still not end-to-end. Mainnet can now be configured for operator-reviewed deposit staging/release and signed customer withdrawal reservations, but those paths remain opt-in and there is no external treasury signer, relay, outgoing transaction confirmation/reorg reconciliation, customer UI or complete recovery procedure. Do not provide addresses or accept funds until a customer can actually withdraw and that send is reconciled against the chain.
[Stage-4 deployment and recovery tooling](operations-recovery.md) adds explicit proxy trust, private loopback diagnostics/checks and stopped-node pair backup/verification/non-overwriting restore. A local native proof-replay restore drill is automated, but actual-host TLS/proxy verification, off-host recovery, alert delivery and operational ownership still require external evidence.
@@ -41,7 +41,7 @@ For each item record **owner, artifact/release ID, date, evidence location, revi
| Consensus launch design | Final genesis and manifest; measured starting target/hashpower; reviewed timestamp/retarget/partition/reorg attack analysis | Missing |
| Node availability | Bounded public APIs/client; global resource/admission controls; realistic long-chain and hostile traffic capacity report | Missing |
| Production wallet | At-rest/unlock/signing policy, same-origin compromise analysis, user confirmation and offline recovery drill | Missing |
| Real-money market, if offered | Direct wallet deposit observation and exact liability crediting, customer-authorized withdrawals, isolated treasury signing, durable reconciliation, custody policy, refund/reorg dispute controls | Not implemented; real payment must stay blocked |
| Real-money market, if offered | Direct wallet deposit observation and exact liability crediting, customer-authorized withdrawals, isolated treasury signing/relay, outgoing-chain reconciliation, custody policy, refund/reorg dispute controls | Partial reservation and reviewed-credit code only; no XMR send; keep real payments blocked |
| Mock-market policy | Ensure public users cannot mistake buyer-triggered mock payment for money; disable sandbox selling/delivery for valuable mainnet cards unless explicitly confined to a valueless trial | Unresolved |
| Peer/miner topology | Multiple independently administered hosts/operators/failure domains; actual online matching manifests and sufficient independent hashpower, not just two URL strings | Unverified |
| Edge and host security | TLS/certificate renewal, firewall, loopback backend, reviewed proxy IP trust, connection/global limits, restricted administration, patched hosts, time synchronization | Unverified |
+3
View File
@@ -40,6 +40,9 @@ Status: active engineering plan; **not launch or payment authorization**. Update
**Mainnet enrollment API wiring (2026-10-22):** added `GET /api/monero/deposit-config` and signed `POST /api/monero/deposit-address`, only mounted when `WIKIDECK_MONERO_DEPOSIT_ENROLLMENT=1` on the gated mainnet profile. Startup pins the operator-provided custodian ID, wallet-generation UUID, expected wallet fingerprint, account index, matching mTLS endpoint and separate TLS allocator PostgreSQL role. Enrollment requests are rate-limited and signed by the beneficiary; responses explicitly say `address-enrolled-not-credited`, with crediting and withdrawals reported disabled. The flag defaults off; this API must not be enabled for customers until deposit crediting, withdrawals and recovery are end-to-end functional. No real wallet or database was contacted. The API has no customer UI yet. Validation: test-image build passed and offline suite **176/176**; the preceding complete PostgreSQL suite passed **38/38** after schema-v8 allocator protection.
**Mainnet reviewed credit and withdrawal-reservation path (2026-10-22):** added one-time `initialize-mainnet-custody-identity CUSTODIAN_UUID`, guarded by exact identity and `reviewed-credit-only` acknowledgements. It binds a migrated v9 custody database to the selected mainnet chain/custodian and refuses rebinding or existing financial/allocation records. The existing signed review workflow can stage an observed output as pending, then release it into the customer's available balance; it is restricted to registered beneficiary allocations, current observations, allow-listed reviewer signatures, and reorg holds. Added signed customer-only balance reads, withdrawal requests that atomically reserve recipient amount plus max-fee against that beneficiary's available balance, and customer cancellation before signer handoff. New withdrawal reservations cannot exceed that customer's cumulative reviewed, released deposits. A separate no-membership withdrawal database role has ledger-intent privileges only; no Monero signing or relay capability is exposed. **This is not a completed withdrawal/payment system:** reserved requests are not signed, broadcast, or confirmed on Monero, there is no customer UI, and no funded-treasury reconciliation or recovery process. Do not solicit deposits until the isolated treasury signer and end-to-end send/confirmation/reorg/recovery path exists. Validation: image build passed; offline suite **177/177** and disposable PostgreSQL suite **40/40** passed. No mainnet database, wallet, or funds were contacted.
The API-side switches are `WIKIDECK_MONERO_DEPOSIT_ENROLLMENT=1` for address allocation and `WIKIDECK_MONERO_WITHDRAWAL_REQUESTS=1` for signed balance/withdrawal intents; both require `WIKIDECK_NETWORK=mainnet`, protected wallet/DB config, and a separately initialized custody identity. Both default to off. Withdrawal endpoints never call Wallet RPC. There is intentionally no environment switch for transfer signing, transaction relay, or live sale settlement.
## 1A. MVP release boundary — proposed safe first production-hosted release
The apartment/single-operator constraint changes the rollout and availability claims, not the safety evidence required to hold customer funds or valuable assets. Recommended MVP: **a public, clearly labeled non-monetary beta on the existing testnet**, with real XMR completely disabled, no custodial balances/deposits/withdrawals, and marketplace checkout/delivery disabled for valuable assets. Keep mining, browsing and wallet use only if they are explicitly described as experimental and users accept that data/ownership may be lost. This would be a production-hosted beta, **not mainnet production**, a custody MVP, or an independently secured chain.
+19 -3
View File
@@ -3,14 +3,15 @@ import { open, unlink, mkdir } from 'node:fs/promises';
import { resolve, join } from 'node:path';
import { postgresConfig } from '../server/market/postgres-config.ts';
import { migrateCustody, grantCustodyRuntime, grantMoneroObserverRuntime,
grantMoneroDepositCreditRuntime, grantMoneroDepositAllocatorRuntime } from '../server/market/postgres-schema.ts';
grantMoneroDepositCreditRuntime, grantMoneroDepositAllocatorRuntime, grantMoneroWithdrawalRuntime } from '../server/market/postgres-schema.ts';
import { initializeMainnetCustodyIdentity, parseMainnetCustodianId } from '../server/market/mainnet-custody-identity.ts';
import { importSqliteCustody } from '../server/market/custody-import.ts';
import { PostgresCustody } from '../server/market/postgres-custody.ts';
import { IS_MAINNET } from '../shared/network.ts';
import { resolveCustodyIncident } from '../server/market/custody-incident.ts';
import { loadMoneroDepositReviewApproval, loadMoneroDepositReviewPolicy } from '../server/market/monero-deposit-review-config.ts';
import { assertCustodyDbActionAllowedOnNetwork, assertMainnetSchemaMigrationAcknowledged,
parseCustodyDbArguments } from '../server/market/custody-db-args.ts';
assertMainnetCustodyOperationAcknowledged, parseCustodyDbArguments } from '../server/market/custody-db-args.ts';
import { prepareDepositCreditReview, postReviewedDepositPendingCredit, prepareDepositCreditReleaseReview,
releaseReviewedDepositCredit, reconcileDepositCreditReorgHolds, prepareDepositReorgResolution,
resolveReleasedDepositReorgHold, type DepositCreditApproval, type DepositCreditReleaseApproval,
@@ -20,12 +21,17 @@ import { prepareDepositCreditReview, postReviewedDepositPendingCredit, prepareDe
const commandArgs = process.argv.slice(2);
const depositActions = ['deposit-prepare-credit', 'deposit-stage-credit', 'deposit-prepare-release', 'deposit-release',
'deposit-reconcile-holds', 'deposit-prepare-resolve-hold', 'deposit-resolve-hold'];
const custodyActions = ['migrate', 'migrate-mainnet-schema', 'grant-runtime', 'grant-monero-observer-runtime',
const custodyActions = ['migrate', 'migrate-mainnet-schema', 'initialize-mainnet-custody-identity', 'grant-runtime', 'grant-monero-observer-runtime',
'grant-monero-withdrawal-runtime',
'grant-monero-deposit-allocator-runtime', 'grant-monero-credit-runtime',
'import-sqlite', 'audit', 'resolve-integrity'];
const { action, argument, extraArgument } = parseCustodyDbArguments(commandArgs);
assertCustodyDbActionAllowedOnNetwork(action, IS_MAINNET);
assertMainnetSchemaMigrationAcknowledged(action, process.env.WIKIDECK_MAINNET_SCHEMA_MIGRATION);
assertMainnetCustodyOperationAcknowledged(action, process.env.WIKIDECK_MAINNET_CUSTODY_OPERATIONS);
if (action === 'initialize-mainnet-custody-identity' && process.env.WIKIDECK_MAINNET_CUSTODY_IDENTITY !== 'initialize-mainnet-custody-v1') {
throw new Error('Mainnet custody identity initialization requires WIKIDECK_MAINNET_CUSTODY_IDENTITY=initialize-mainnet-custody-v1');
}
if (!custodyActions.includes(action) && !depositActions.includes(action)) throw new Error('Unsupported custody operation');
try {
const config = await postgresConfig();
@@ -41,6 +47,12 @@ try {
? 'Mainnet custody schema verified/migrated; no runtime grants, deposits, or payments enabled.'
: 'Custody PostgreSQL schema verified/migrated. No funds activated.');
}
if (action === 'initialize-mainnet-custody-identity') {
if (!IS_MAINNET || extraArgument) throw new Error('Expected mainnet initialize-mainnet-custody-identity CUSTODIAN_UUID');
const identity = await initializeMainnetCustodyIdentity(pool, parseMainnetCustodianId(argument ?? ''));
console.log(JSON.stringify({ initialized: true, network: identity.network, chainId: identity.chainId,
custodianId: identity.id, crediting: 'reviewed-pending-only', withdrawals: 'disabled' }));
}
if (action === 'grant-runtime') { await grantCustodyRuntime(pool, argument ?? ''); console.log('Runtime grants applied; no credentials printed.'); }
if (action === 'grant-monero-observer-runtime') {
await grantMoneroObserverRuntime(pool, argument ?? '');
@@ -50,6 +62,10 @@ try {
await grantMoneroDepositAllocatorRuntime(pool, argument ?? '');
console.log('Separate Monero deposit-allocation grants applied; balance, journal, observation, and credit tables remain denied.');
}
if (action === 'grant-monero-withdrawal-runtime') {
await grantMoneroWithdrawalRuntime(pool, argument ?? '');
console.log('Separate customer-signed withdrawal-reservation grants applied; transaction signing and relay are not granted.');
}
if (action === 'grant-monero-credit-runtime') {
await grantMoneroDepositCreditRuntime(pool, argument ?? '');
console.log('Separate reviewed pending-credit coordinator grants applied; no credentials printed and no live deposits enabled.');
+19 -5
View File
@@ -1,9 +1,11 @@
export const CUSTODY_DB_ACTION_ARGUMENT_COUNTS = {
migrate: 0,
'migrate-mainnet-schema': 0,
'initialize-mainnet-custody-identity': 1,
'grant-runtime': 1,
'grant-monero-observer-runtime': 1,
'grant-monero-deposit-allocator-runtime': 1,
'grant-monero-withdrawal-runtime': 1,
'grant-monero-credit-runtime': 1,
'import-sqlite': 1,
audit: 0,
@@ -21,12 +23,16 @@ export type CustodyDbAction = keyof typeof CUSTODY_DB_ACTION_ARGUMENT_COUNTS;
export type CustodyDbArguments = { action: CustodyDbAction; argument?: string; extraArgument?: string };
export function assertCustodyDbActionAllowedOnNetwork(action: CustodyDbAction, isMainnet: boolean): void {
if (action === 'migrate-mainnet-schema' && !isMainnet) {
throw new Error('Mainnet schema migration action requires the mainnet profile');
if ((action === 'migrate-mainnet-schema' || action === 'initialize-mainnet-custody-identity') && !isMainnet) {
throw new Error('Mainnet custody operation requires the mainnet profile');
}
if (isMainnet && action !== 'grant-monero-observer-runtime' && action !== 'grant-monero-deposit-allocator-runtime' &&
action !== 'migrate-mainnet-schema') {
throw new Error('Only explicitly scoped Monero infrastructure-role grants and schema migration are allowed on mainnet');
const mainnetAllowed = ['grant-monero-observer-runtime', 'grant-monero-deposit-allocator-runtime',
'grant-monero-withdrawal-runtime',
'grant-monero-credit-runtime', 'migrate-mainnet-schema', 'initialize-mainnet-custody-identity',
'deposit-prepare-credit', 'deposit-stage-credit', 'deposit-prepare-release', 'deposit-release',
'deposit-reconcile-holds', 'deposit-prepare-resolve-hold', 'deposit-resolve-hold'];
if (isMainnet && !mainnetAllowed.includes(action)) {
throw new Error('Only scoped Monero infrastructure grants, identity initialization, and reviewed credit/withdrawal-intent operations are allowed on mainnet');
}
}
@@ -36,6 +42,14 @@ export function assertMainnetSchemaMigrationAcknowledged(action: CustodyDbAction
}
}
export function assertMainnetCustodyOperationAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void {
const requiresReviewAck = action === 'initialize-mainnet-custody-identity' || action === 'grant-monero-credit-runtime' ||
action.startsWith('deposit-');
if (requiresReviewAck && acknowledgement !== 'reviewed-credit-only') {
throw new Error('Mainnet deposit operations require WIKIDECK_MAINNET_CUSTODY_OPERATIONS=reviewed-credit-only');
}
}
export function parseCustodyDbArguments(args: readonly string[]): CustodyDbArguments {
const [action, ...values] = args;
if (!action || !Object.hasOwn(CUSTODY_DB_ACTION_ARGUMENT_COUNTS, action)) {
+92 -1
View File
@@ -10,6 +10,7 @@ import { address, verifySignature } from '../testnet/consensus.ts';
import { depositCreditApprovalMessage, depositCreditReleaseApprovalMessage,
depositReorgResolutionMessage, type DepositCreditReleaseSubject, type DepositCreditReviewSubject,
type DepositReorgResolutionSubject } from './monero-deposit-credit.ts';
import { mainnetWithdrawalCancellationMessage, mainnetWithdrawalMessage } from '../../shared/monero-withdrawal.ts';
/** Codes only: never attach customer IDs, payloads, SQL errors or addresses to operational diagnostics. */
export class CustodyIntegrityError extends Error {
@@ -319,6 +320,89 @@ async function checkDepositCredits(client: PoolClient, budget: () => void) {
h.observation_event_sequence<=c.last_observation_event_sequence LIMIT 1`);
if (invalidHold.rowCount) fail('deposit-reorg-hold-mismatch');
}
async function checkMainnetWithdrawalIntents(client: PoolClient, budget: () => void): Promise<void> {
type WithdrawalIntentAuditRow = { request_id: string; custodian_id: string; market_network: string; market_chain_id: string;
beneficiary: string; destination: string; amount_atomic_xmr: string; max_fee_atomic_xmr: string; public_key: string;
expires_at: string; request_signature: string; request_digest: string; status: string; created_at: string;
cancel_request_id: string | null; cancel_public_key: string | null; cancel_expires_at: string | null;
cancel_signature: string | null; cancelled_at: string | null };
let after: string | null = null;
const identityResult = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
if (!identityResult.rowCount) {
if ((await client.query('SELECT 1 FROM wk_custody.monero_withdrawal_intents LIMIT 1')).rowCount) fail('mainnet-withdrawal-identity-missing');
return;
}
const identity = JSON.parse(identityResult.rows[0].value) as Record<string, unknown>;
if (identity.mode !== 'mainnet-custody' || identity.network !== 'wikideck-randomx-mainnet-1' || identity.policy !== 'custody-mainnet-v1') {
if ((await client.query('SELECT 1 FROM wk_custody.monero_withdrawal_intents LIMIT 1')).rowCount) fail('mainnet-withdrawal-identity-mismatch');
return;
}
for (;;) {
budget();
const result: QueryResult<WithdrawalIntentAuditRow> = await client.query<WithdrawalIntentAuditRow>(`SELECT request_id::text,custodian_id::text,market_network,market_chain_id,beneficiary,
destination,amount_atomic_xmr::text,max_fee_atomic_xmr::text,public_key,expires_at::text,request_signature,
request_digest,status,created_at::text,cancel_request_id::text,cancel_public_key,cancel_expires_at::text,
cancel_signature,cancelled_at::text FROM wk_custody.monero_withdrawal_intents
WHERE ($1::uuid IS NULL OR request_id>$1::uuid) ORDER BY request_id LIMIT 128`, [after]);
const postings = await journals(client, result.rows.flatMap(row => [
`monero-withdrawal-reserve:${row.request_id}`, `monero-withdrawal-cancel:${row.request_id}`]));
for (const row of result.rows) {
let destination: string;
try { destination = await validateXmrAddress(row.destination); }
catch { fail('mainnet-withdrawal-destination-invalid'); }
const total = (BigInt(row.amount_atomic_xmr) + BigInt(row.max_fee_atomic_xmr)).toString();
const aggregate = await client.query(`SELECT
COALESCE((SELECT sum(amount_atomic_xmr+max_fee_atomic_xmr) FROM wk_custody.monero_withdrawal_intents
WHERE beneficiary=$1 AND status='reserved'),0)::text AS reserved,
COALESCE((SELECT sum(c.amount_atomic_xmr) FROM wk_custody.monero_deposit_credit_events c
JOIN wk_custody.monero_deposit_release_events r ON r.credit_id=c.credit_id WHERE c.beneficiary=$1),0)::text AS credited`,
[row.beneficiary]);
if (BigInt(aggregate.rows[0].reserved) > BigInt(aggregate.rows[0].credited)) {
fail('mainnet-withdrawal-exceeds-reviewed-credit');
}
const holdAccount = `customer:${row.beneficiary}:monero-withdrawal:${row.request_id}`;
const availableAccount = `customer:${row.beneficiary}:available`;
const hold = await client.query('SELECT balance::text,normal FROM wk_custody.accounts WHERE id=$1', [holdAccount]);
const active = row.status === 'reserved';
if (!['reserved','cancelled'].includes(row.status) || (active && (hold.rowCount !== 1 || hold.rows[0].normal !== 'credit' || hold.rows[0].balance !== total)) ||
(!active && hold.rowCount === 1 && (hold.rows[0].normal !== 'credit' || hold.rows[0].balance !== '0'))) fail('mainnet-withdrawal-reservation-mismatch');
try {
const request = { version: 1 as const, network: 'wikideck-randomx-mainnet-1' as const,
chainId: row.market_chain_id, custodianId: row.custodian_id, beneficiary: row.beneficiary,
publicKey: row.public_key, requestId: row.request_id, destination, amountAtomicXmr: row.amount_atomic_xmr,
maxFeeAtomicXmr: row.max_fee_atomic_xmr, expiresAt: Number(row.expires_at), signature: row.request_signature };
const { signature: _signature, ...unsigned } = request;
const message = mainnetWithdrawalMessage(unsigned);
if (row.market_network !== 'wikideck-randomx-mainnet-1' || row.custodian_id !== identity.id ||
address(row.public_key) !== row.beneficiary || createHash('sha256').update(message).digest('hex') !== row.request_digest ||
!verifySignature(row.public_key, row.beneficiary, message, row.request_signature)) fail('mainnet-withdrawal-authorization-invalid');
} catch { fail('mainnet-withdrawal-authorization-invalid'); }
expectJournal(postings, `monero-withdrawal-reserve:${row.request_id}`, 'monero-withdrawal-reservation', [
d(availableAccount, total), c(holdAccount, total)]);
if (active) {
if (row.cancel_request_id || row.cancel_signature || row.cancelled_at) fail('mainnet-withdrawal-cancellation-invalid');
expectJournal(postings, `monero-withdrawal-cancel:${row.request_id}`, 'monero-withdrawal-cancellation', null);
} else {
try {
const cancellation = { version: 1 as const, network: 'wikideck-randomx-mainnet-1' as const,
chainId: row.market_chain_id, custodianId: row.custodian_id, beneficiary: row.beneficiary,
publicKey: row.cancel_public_key!, cancellationId: row.cancel_request_id!,
withdrawalRequestId: row.request_id, expiresAt: Number(row.cancel_expires_at), signature: row.cancel_signature! };
const { signature: cancelSignature, ...unsigned } = cancellation;
const message = mainnetWithdrawalCancellationMessage(unsigned);
if (address(cancellation.publicKey) !== row.beneficiary ||
!verifySignature(cancellation.publicKey, row.beneficiary, message, cancelSignature)) fail('mainnet-withdrawal-cancellation-invalid');
} catch { fail('mainnet-withdrawal-cancellation-invalid'); }
expectJournal(postings, `monero-withdrawal-cancel:${row.request_id}`, 'monero-withdrawal-cancellation', [
d(holdAccount, total), c(availableAccount, total)]);
}
}
if (result.rows.length < 128) break;
after = result.rows.at(-1)!.request_id;
}
}
const withdrawalSelect = `SELECT w.id::text,w.actor,w.status,w.data,a.balance::text,a.normal,
b.kind AS box_kind,b.status AS box_status,b.data AS box_data FROM wk_custody.withdrawals w
LEFT JOIN wk_custody.accounts a ON a.id='customer:'||w.actor||':withdrawal:'||w.id::text
@@ -363,12 +447,16 @@ export async function auditBusinessLiabilities(client: PoolClient): Promise<void
if (result.rows.length < 128) break; after = result.rows.at(-1)!.id;
}
await checkDepositCredits(client, budget);
await checkMainnetWithdrawalIntents(client, budget);
// Check both directions: a correct order cannot excuse orphan reserves, tasks or terminal postings.
const checks: [string, string][] = [
['orphan-purchase-reserve', `SELECT 1 FROM wk_custody.accounts a WHERE a.id LIKE 'customer:%:purchase:%'
AND NOT EXISTS(SELECT 1 FROM wk_custody.orders o WHERE a.id='customer:'||o.buyer||':purchase:'||o.id::text) LIMIT 1`],
['orphan-withdrawal-reserve', `SELECT 1 FROM wk_custody.accounts a WHERE a.id LIKE 'customer:%:withdrawal:%'
AND NOT EXISTS(SELECT 1 FROM wk_custody.withdrawals w WHERE a.id='customer:'||w.actor||':withdrawal:'||w.id::text) LIMIT 1`],
['orphan-monero-withdrawal-reserve', `SELECT 1 FROM wk_custody.accounts a WHERE a.id LIKE 'customer:%:monero-withdrawal:%'
AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_withdrawal_intents w
WHERE a.id='customer:'||w.beneficiary||':monero-withdrawal:'||w.request_id::text) LIMIT 1`],
['orphan-outbox', `SELECT 1 FROM wk_custody.outbox b WHERE NOT EXISTS(SELECT 1 FROM wk_custody.orders o WHERE o.id=b.id AND b.kind='nft-lock')
AND NOT EXISTS(SELECT 1 FROM wk_custody.withdrawals w WHERE w.id=b.id AND b.kind='withdrawal') LIMIT 1`],
['listing-order-mismatch', `SELECT 1 FROM wk_custody.listings l LEFT JOIN wk_custody.orders o ON o.data->>'listingId'=l.id::text
@@ -379,7 +467,10 @@ export async function auditBusinessLiabilities(client: PoolClient): Promise<void
(j.kind IN ('purchase-reservation','purchase-cancellation','mock-sale-settlement') AND NOT EXISTS
(SELECT 1 FROM wk_custody.orders o WHERE j.id IN ('reserve:'||o.id::text,'cancel:'||o.id::text,'settle:'||o.id::text))) OR
(j.kind IN ('withdrawal-reservation','withdrawal-cancellation','mock-withdrawal') AND NOT EXISTS
(SELECT 1 FROM wk_custody.withdrawals w WHERE j.id IN ('withdraw:'||w.id::text,'withdraw-cancel:'||w.id::text,'withdraw-confirm:'||w.id::text))) LIMIT 1`],
(SELECT 1 FROM wk_custody.withdrawals w WHERE j.id IN ('withdraw:'||w.id::text,'withdraw-cancel:'||w.id::text,'withdraw-confirm:'||w.id::text))) OR
(j.kind IN ('monero-withdrawal-reservation','monero-withdrawal-cancellation') AND NOT EXISTS
(SELECT 1 FROM wk_custody.monero_withdrawal_intents w WHERE j.id IN
('monero-withdrawal-reserve:'||w.request_id::text,'monero-withdrawal-cancel:'||w.request_id::text))) LIMIT 1`],
['duplicate-prepared-attempt', `SELECT 1 FROM wk_custody.withdrawals WHERE status IN ('prepared','complete')
GROUP BY data->>'attemptId' HAVING count(*)>1 LIMIT 1`],
['duplicate-prepared-transaction', `SELECT 1 FROM wk_custody.withdrawals WHERE status IN ('prepared','complete')
+71
View File
@@ -0,0 +1,71 @@
import type { Pool } from 'pg';
import { NETWORK } from '../../shared/network.ts';
import { CHAIN_ID } from '../testnet/consensus.ts';
import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts';
export type MainnetCustodyIdentity = {
version: 1;
mode: 'mainnet-custody';
policy: 'custody-mainnet-v1';
network: 'wikideck-randomx-mainnet-1';
chainId: string;
id: string;
};
function uuid(value: string): boolean {
return /^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value);
}
/** One-time, schema-owner-only identity binding for the separately operated mainnet custody ledger. */
export async function initializeMainnetCustodyIdentity(pool: Pool, custodianId: string): Promise<MainnetCustodyIdentity> {
if (NETWORK !== 'wikideck-randomx-mainnet-1' || !uuid(custodianId)) {
throw new Error('Mainnet custody identity requires the mainnet profile and a valid custodian UUID');
}
const identity: MainnetCustodyIdentity = { version: 1, mode: 'mainnet-custody', policy: 'custody-mainnet-v1',
network: NETWORK, chainId: CHAIN_ID, id: custodianId };
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on");
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
await verifyCustodySchema(client);
const current = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity' FOR UPDATE");
if (current.rowCount) {
const parsed = JSON.parse(current.rows[0].value) as Partial<MainnetCustodyIdentity>;
if (parsed.version !== identity.version || parsed.mode !== identity.mode || parsed.policy !== identity.policy ||
parsed.network !== identity.network || parsed.chainId !== identity.chainId || parsed.id !== identity.id) {
throw new Error('Custody database is already bound to a different network or custodian identity');
}
} else {
const populated = await client.query(`SELECT
EXISTS(SELECT 1 FROM wk_custody.journals) AS has_journals,
EXISTS(SELECT 1 FROM wk_custody.monero_deposit_allocations) AS has_allocations`);
if (populated.rows[0].has_journals || populated.rows[0].has_allocations) {
throw new Error('Cannot initialize mainnet custody identity after financial or deposit records exist');
}
await client.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)', ['identity', JSON.stringify(identity)]);
}
await client.query('COMMIT');
return identity;
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
finally { client.release(); }
}
export async function verifyMainnetCustodyIdentity(pool: Pool, custodianId: string): Promise<MainnetCustodyIdentity> {
if (NETWORK !== 'wikideck-randomx-mainnet-1' || !uuid(custodianId)) {
throw new Error('Mainnet custody identity requires the mainnet profile and a valid custodian UUID');
}
const result = await pool.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
if (result.rowCount !== 1) throw new Error('Mainnet custody identity is not initialized');
const identity = JSON.parse(result.rows[0].value) as Partial<MainnetCustodyIdentity>;
if (identity.version !== 1 || identity.mode !== 'mainnet-custody' || identity.policy !== 'custody-mainnet-v1' ||
identity.network !== NETWORK || identity.chainId !== CHAIN_ID || identity.id !== custodianId) {
throw new Error('Mainnet custody identity does not match configured custodian');
}
return identity as MainnetCustodyIdentity;
}
export function parseMainnetCustodianId(value: string): string {
if (!uuid(value)) throw new Error('Invalid mainnet custodian UUID');
return value.toLowerCase();
}
+194
View File
@@ -0,0 +1,194 @@
import { createHash } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
import { address, verifySignature } from '../testnet/consensus.ts';
import { validateXmrAddress } from './xmr.ts';
import { assertXmrRpcAmount } from '../../shared/xmr-amount.ts';
import { CUSTODY_WRITE_LOCK } from './postgres-schema.ts';
import { mainnetWithdrawalCancellationMessage, mainnetWithdrawalCancellationSchema,
mainnetCustodyBalanceMessage, mainnetCustodyBalanceRequestSchema, mainnetWithdrawalMessage,
mainnetWithdrawalRequestSchema } from '../../shared/monero-withdrawal.ts';
const MAX_REQUEST_LIFETIME_MS = 5 * 60_000;
const digest = (text: string) => createHash('sha256').update(text).digest('hex');
const availableAccount = (beneficiary: string) => `customer:${beneficiary}:available`;
const withdrawalAccount = (beneficiary: string, requestId: string) => `customer:${beneficiary}:monero-withdrawal:${requestId}`;
export type MainnetWithdrawalIntent = {
requestId: string;
custodianId: string;
beneficiary: string;
destination: string;
amountAtomicXmr: string;
maxFeeAtomicXmr: string;
status: 'reserved' | 'cancelled';
createdAt: number;
cancelledAt: number | null;
};
export type MainnetCustodyBalance = {
availableAtomicXmr: string;
depositPendingAtomicXmr: string;
withdrawalReservedAtomicXmr: string;
};
async function insertJournal(client: PoolClient, journalId: string, kind: string, createdAt: number,
debitAccount: string, creditAccount: string, amount: string): Promise<void> {
for (const [accountId, normal] of [[debitAccount, 'credit'], [creditAccount, 'credit']] as const) {
await client.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,$2) ON CONFLICT(id) DO NOTHING', [accountId, normal]);
const row = await client.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [accountId]);
if (row.rowCount !== 1 || row.rows[0].normal !== normal) throw new Error('Mainnet withdrawal ledger account mismatch');
}
await client.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)', [journalId, kind, createdAt]);
await client.query('INSERT INTO wk_custody.entries VALUES($1,0,$2,$3,0),($1,1,$4,0,$3)',
[journalId, debitAccount, amount, creditAccount]);
}
async function mainnetIdentity(client: PoolClient, custodianId: string, chainId: string): Promise<void> {
const result = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
if (result.rowCount !== 1) throw new Error('Mainnet custody identity is not initialized');
const identity = JSON.parse(result.rows[0].value) as Record<string, unknown>;
if (identity.version !== 1 || identity.mode !== 'mainnet-custody' || identity.policy !== 'custody-mainnet-v1' ||
identity.network !== 'wikideck-randomx-mainnet-1' || identity.id !== custodianId || identity.chainId !== chainId) {
throw new Error('Withdrawal request does not match the initialized mainnet custody identity');
}
}
function expiryIsValid(expiresAt: number, now: number): boolean {
return Number.isSafeInteger(now) && now >= 0 && expiresAt >= now && expiresAt <= now + MAX_REQUEST_LIFETIME_MS;
}
/** Customer-signed request: atomically reserve available liability, but do not prepare or send XMR. */
export async function reserveMainnetWithdrawal(pool: Pool, rawRequest: unknown, now = Date.now()): Promise<MainnetWithdrawalIntent> {
const request = mainnetWithdrawalRequestSchema.parse(rawRequest);
const destination = await validateXmrAddress(request.destination);
if (address(request.publicKey) !== request.beneficiary ||
!verifySignature(request.publicKey, request.beneficiary, mainnetWithdrawalMessage(request), request.signature)) {
throw new Error('Mainnet withdrawal signature is invalid');
}
const requestDigest = digest(mainnetWithdrawalMessage(request));
const amount = BigInt(request.amountAtomicXmr), fee = BigInt(request.maxFeeAtomicXmr), total = amount + fee;
assertXmrRpcAmount(amount); assertXmrRpcAmount(fee);
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on");
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
await mainnetIdentity(client, request.custodianId, request.chainId);
const prior = await client.query('SELECT * FROM wk_custody.monero_withdrawal_intents WHERE request_id=$1 FOR UPDATE', [request.requestId]);
if (prior.rowCount) {
const row = prior.rows[0];
if (row.request_digest !== requestDigest || row.request_signature !== request.signature) throw new Error('Withdrawal request ID was reused with different terms');
await client.query('COMMIT');
return { requestId: row.request_id, custodianId: row.custodian_id, beneficiary: row.beneficiary,
destination: row.destination, amountAtomicXmr: row.amount_atomic_xmr, maxFeeAtomicXmr: row.max_fee_atomic_xmr,
status: row.status, createdAt: Number(row.created_at), cancelledAt: row.cancelled_at === null ? null : Number(row.cancelled_at) };
}
if (!expiryIsValid(request.expiresAt, now)) throw new Error('Mainnet withdrawal request is expired or outside the allowed window');
const hold = await client.query(`SELECT 1 FROM wk_custody.monero_deposit_credit_events d
JOIN wk_custody.monero_deposit_reorg_holds h ON h.credit_id=d.credit_id
LEFT JOIN wk_custody.monero_deposit_reorg_resolutions r ON r.hold_id=h.hold_id
WHERE d.beneficiary=$1 AND r.hold_id IS NULL LIMIT 1`, [request.beneficiary]);
if (hold.rowCount) throw new Error('Customer deposit is under reorg review; new withdrawals are held');
const creditTotal = await client.query(`SELECT COALESCE(sum(amount_atomic_xmr),0)::text AS credited
FROM wk_custody.monero_deposit_release_events r
JOIN wk_custody.monero_deposit_credit_events c ON c.credit_id=r.credit_id
WHERE c.beneficiary=$1`, [request.beneficiary]);
const activeTotals = await client.query(`SELECT
COALESCE((SELECT sum(amount_atomic_xmr+max_fee_atomic_xmr) FROM wk_custody.monero_withdrawal_intents
WHERE beneficiary=$1 AND status='reserved'),0)::text AS reserved`, [request.beneficiary]);
const available = await client.query('SELECT balance::text,normal FROM wk_custody.accounts WHERE id=$1',
[availableAccount(request.beneficiary)]);
if (available.rowCount !== 1 || available.rows[0].normal !== 'credit' || BigInt(available.rows[0].balance) < total) {
throw new Error('Insufficient available mainnet custody balance');
}
const alreadyReserved = BigInt(activeTotals.rows[0].reserved);
const backed = BigInt(creditTotal.rows[0].credited);
if (alreadyReserved + total > backed) throw new Error('Mainnet customer withdrawals exceed reviewed released deposit backing');
const createdAt = now, reserveJournal = `monero-withdrawal-reserve:${request.requestId}`;
await insertJournal(client, reserveJournal, 'monero-withdrawal-reservation', now,
availableAccount(request.beneficiary), withdrawalAccount(request.beneficiary, request.requestId), total.toString());
await client.query(`INSERT INTO wk_custody.monero_withdrawal_intents(
request_id,custodian_id,market_network,market_chain_id,beneficiary,destination,amount_atomic_xmr,max_fee_atomic_xmr,
public_key,expires_at,request_signature,request_digest,status,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'reserved',$13)`,
[request.requestId, request.custodianId, request.network, request.chainId, request.beneficiary, destination,
request.amountAtomicXmr, request.maxFeeAtomicXmr, request.publicKey, request.expiresAt, request.signature,
requestDigest, createdAt]);
await client.query('COMMIT');
return { requestId: request.requestId, custodianId: request.custodianId, beneficiary: request.beneficiary,
destination, amountAtomicXmr: request.amountAtomicXmr, maxFeeAtomicXmr: request.maxFeeAtomicXmr,
status: 'reserved', createdAt, cancelledAt: null };
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
finally { client.release(); }
}
/** Customer may release a reservation only before any separate treasury preparation has occurred. */
export async function cancelMainnetWithdrawal(pool: Pool, rawRequest: unknown, now = Date.now()): Promise<MainnetWithdrawalIntent> {
const cancellation = mainnetWithdrawalCancellationSchema.parse(rawRequest);
if (address(cancellation.publicKey) !== cancellation.beneficiary ||
!verifySignature(cancellation.publicKey, cancellation.beneficiary,
mainnetWithdrawalCancellationMessage(cancellation), cancellation.signature)) {
throw new Error('Mainnet withdrawal cancellation signature is invalid');
}
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on");
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
await mainnetIdentity(client, cancellation.custodianId, cancellation.chainId);
const result = await client.query('SELECT * FROM wk_custody.monero_withdrawal_intents WHERE request_id=$1 FOR UPDATE',
[cancellation.withdrawalRequestId]);
if (result.rowCount !== 1 || result.rows[0].beneficiary !== cancellation.beneficiary) {
throw new Error('Mainnet withdrawal request not found for this beneficiary');
}
const row = result.rows[0];
if (row.status === 'cancelled') {
if (row.cancel_request_id !== cancellation.cancellationId || row.cancel_signature !== cancellation.signature) {
throw new Error('Withdrawal was cancelled under different signed terms');
}
await client.query('COMMIT');
return { requestId: row.request_id, custodianId: row.custodian_id, beneficiary: row.beneficiary,
destination: row.destination, amountAtomicXmr: row.amount_atomic_xmr, maxFeeAtomicXmr: row.max_fee_atomic_xmr,
status: 'cancelled', createdAt: Number(row.created_at), cancelledAt: Number(row.cancelled_at) };
}
if (!expiryIsValid(cancellation.expiresAt, now)) throw new Error('Mainnet withdrawal cancellation is expired or outside the allowed window');
const amount = BigInt(row.amount_atomic_xmr), maxFee = BigInt(row.max_fee_atomic_xmr), total = amount + maxFee;
const cancelJournal = `monero-withdrawal-cancel:${row.request_id}`;
await insertJournal(client, cancelJournal, 'monero-withdrawal-cancellation', now,
withdrawalAccount(row.beneficiary, row.request_id), availableAccount(row.beneficiary), total.toString());
await client.query(`UPDATE wk_custody.monero_withdrawal_intents SET status='cancelled',cancel_request_id=$2,
cancel_public_key=$3,cancel_expires_at=$4,cancel_signature=$5,cancelled_at=$6 WHERE request_id=$1`,
[row.request_id, cancellation.cancellationId, cancellation.publicKey, cancellation.expiresAt, cancellation.signature, now]);
await client.query('COMMIT');
return { requestId: row.request_id, custodianId: row.custodian_id, beneficiary: row.beneficiary,
destination: row.destination, amountAtomicXmr: row.amount_atomic_xmr, maxFeeAtomicXmr: row.max_fee_atomic_xmr,
status: 'cancelled', createdAt: Number(row.created_at), cancelledAt: now };
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
finally { client.release(); }
}
/** Read the caller's own balances using a signed, expiring request; never accepts an actor from query parameters. */
export async function readMainnetCustodyBalance(pool: Pool, rawRequest: unknown, now = Date.now()): Promise<MainnetCustodyBalance> {
const request = mainnetCustodyBalanceRequestSchema.parse(rawRequest);
if (address(request.publicKey) !== request.beneficiary ||
!verifySignature(request.publicKey, request.beneficiary, mainnetCustodyBalanceMessage(request), request.signature)) {
throw new Error('Mainnet custody balance signature is invalid');
}
if (!expiryIsValid(request.expiresAt, now)) throw new Error('Mainnet custody balance request is expired or outside the allowed window');
const client = await pool.connect();
try {
await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'");
await mainnetIdentity(client, request.custodianId, request.chainId);
const available = await client.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1',
[availableAccount(request.beneficiary)]);
const pending = await client.query(`SELECT COALESCE(sum(balance),0)::text AS balance FROM wk_custody.accounts
WHERE id LIKE $1`, [`customer:${request.beneficiary}:deposit-pending:%`]);
const withdrawals = await client.query(`SELECT COALESCE(sum(amount_atomic_xmr+max_fee_atomic_xmr),0)::text AS balance
FROM wk_custody.monero_withdrawal_intents WHERE beneficiary=$1 AND status='reserved'`, [request.beneficiary]);
await client.query('COMMIT');
return { availableAtomicXmr: available.rows[0]?.balance ?? '0',
depositPendingAtomicXmr: pending.rows[0].balance,
withdrawalReservedAtomicXmr: withdrawals.rows[0].balance };
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
finally { client.release(); }
}
+6 -2
View File
@@ -171,9 +171,13 @@ async function readReviewSubject(client: PoolClient, walletFingerprint: string,
if (allocation.rowCount !== 1) throw new Error('Deposit output has no unique registered beneficiary allocation');
const identity = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
if (identity.rowCount !== 1) throw new Error('Custodian identity is unavailable');
const custodian = JSON.parse(identity.rows[0].value) as { id?: string; network?: string; chainId?: string; mode?: string };
const custodian = JSON.parse(identity.rows[0].value) as { id?: string; network?: string; chainId?: string; mode?: string; policy?: string };
const mapped = allocation.rows[0];
if (custodian.mode !== 'sandbox' || mapped.custodian_id !== custodian.id || mapped.market_network !== custodian.network ||
const validIdentity = (custodian.mode === 'sandbox' && custodian.network === 'wikideck-randomx-testnet-6' &&
custodian.policy === 'custody-sandbox-v1' && mapped.market_network === 'wikideck-randomx-testnet-6') ||
(custodian.mode === 'mainnet-custody' && custodian.network === 'wikideck-randomx-mainnet-1' &&
custodian.policy === 'custody-mainnet-v1' && mapped.market_network === 'wikideck-randomx-mainnet-1');
if (!validIdentity || mapped.custodian_id !== custodian.id || mapped.market_network !== custodian.network ||
mapped.market_chain_id !== custodian.chainId) throw new Error('Deposit allocation is outside this custody identity');
const events = await client.query(`SELECT sequence,event_type,payload_digest,wallet_height,observed_at
+96 -6
View File
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
export const CUSTODY_PG_VERSION = 8;
export const CUSTODY_PG_VERSION = 9;
export const CUSTODY_WRITE_LOCK = '7140824361';
export const MONERO_OBSERVER_WORKER_LOCK = '7140824362';
export const CUSTODY_SCHEMA_V1 = `
@@ -323,9 +323,58 @@ REVOKE ALL ON FUNCTION wk_custody.monero_deposit_allocation_insert_guard() FROM
`;
export const CUSTODY_SCHEMA_V8_DIGEST = createHash('sha256')
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}\n-- custody migration v5 --\n${CUSTODY_MIGRATION_V5}\n-- custody migration v6 --\n${CUSTODY_MIGRATION_V6}\n-- custody migration v7 --\n${CUSTODY_MIGRATION_V7}\n-- custody migration v8 --\n${CUSTODY_MIGRATION_V8}`).digest('hex');
export const CUSTODY_MIGRATION_V9 = `
CREATE TABLE wk_custody.monero_withdrawal_intents(
request_id uuid PRIMARY KEY,
custodian_id uuid NOT NULL,
market_network text NOT NULL CHECK(market_network='wikideck-randomx-mainnet-1'),
market_chain_id text NOT NULL CHECK(market_chain_id ~ '^[a-f0-9]{64}$'),
beneficiary text NOT NULL CHECK(beneficiary ~ '^[a-f0-9]{64}$'),
destination text NOT NULL CHECK(length(destination)=95),
amount_atomic_xmr numeric(20,0) NOT NULL CHECK(amount_atomic_xmr>0 AND amount_atomic_xmr<=18446744073709551615),
max_fee_atomic_xmr numeric(20,0) NOT NULL CHECK(max_fee_atomic_xmr>=0 AND max_fee_atomic_xmr<=18446744073709551615),
public_key text NOT NULL CHECK(length(public_key) BETWEEN 1 AND 256),
expires_at bigint NOT NULL CHECK(expires_at>=0),
request_signature text NOT NULL CHECK(length(request_signature) BETWEEN 1 AND 256),
request_digest text NOT NULL CHECK(request_digest ~ '^[a-f0-9]{64}$'),
status text NOT NULL CHECK(status IN ('reserved','cancelled')),
created_at bigint NOT NULL CHECK(created_at>=0),
cancel_request_id uuid UNIQUE,
cancel_public_key text CHECK(cancel_public_key IS NULL OR length(cancel_public_key) BETWEEN 1 AND 256),
cancel_expires_at bigint CHECK(cancel_expires_at IS NULL OR cancel_expires_at>=0),
cancel_signature text CHECK(cancel_signature IS NULL OR length(cancel_signature) BETWEEN 1 AND 256),
cancelled_at bigint CHECK(cancelled_at IS NULL OR cancelled_at>=created_at),
CHECK((status='reserved' AND cancel_request_id IS NULL AND cancel_signature IS NULL AND cancelled_at IS NULL) OR
(status='cancelled' AND cancel_request_id IS NOT NULL AND cancel_public_key IS NOT NULL AND
cancel_expires_at IS NOT NULL AND cancel_signature IS NOT NULL AND cancelled_at IS NOT NULL))
);
CREATE FUNCTION wk_custody.monero_withdrawal_intents_immutable() RETURNS trigger LANGUAGE plpgsql
SET search_path=wk_custody,pg_catalog AS $$
BEGIN
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'Monero withdrawal intent history cannot be deleted'; END IF;
IF OLD.status<>'reserved' OR NEW.status<>'cancelled' OR NEW.request_id IS DISTINCT FROM OLD.request_id OR
NEW.custodian_id IS DISTINCT FROM OLD.custodian_id OR NEW.market_network IS DISTINCT FROM OLD.market_network OR
NEW.market_chain_id IS DISTINCT FROM OLD.market_chain_id OR NEW.beneficiary IS DISTINCT FROM OLD.beneficiary OR
NEW.destination IS DISTINCT FROM OLD.destination OR NEW.amount_atomic_xmr IS DISTINCT FROM OLD.amount_atomic_xmr OR
NEW.max_fee_atomic_xmr IS DISTINCT FROM OLD.max_fee_atomic_xmr OR NEW.public_key IS DISTINCT FROM OLD.public_key OR
NEW.expires_at IS DISTINCT FROM OLD.expires_at OR NEW.request_signature IS DISTINCT FROM OLD.request_signature OR
NEW.request_digest IS DISTINCT FROM OLD.request_digest OR NEW.created_at IS DISTINCT FROM OLD.created_at OR
NEW.cancel_request_id IS NULL OR NEW.cancel_public_key IS NULL OR NEW.cancel_expires_at IS NULL OR
NEW.cancel_signature IS NULL OR NEW.cancelled_at IS NULL THEN
RAISE EXCEPTION 'Monero withdrawal intent identity is immutable';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER monero_withdrawal_intents_immutable BEFORE UPDATE OR DELETE ON wk_custody.monero_withdrawal_intents
FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_withdrawal_intents_immutable();
REVOKE ALL ON wk_custody.monero_withdrawal_intents FROM PUBLIC;
REVOKE ALL ON FUNCTION wk_custody.monero_withdrawal_intents_immutable() FROM PUBLIC;
`;
export const CUSTODY_SCHEMA_V9_DIGEST = createHash('sha256')
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}\n-- custody migration v5 --\n${CUSTODY_MIGRATION_V5}\n-- custody migration v6 --\n${CUSTODY_MIGRATION_V6}\n-- custody migration v7 --\n${CUSTODY_MIGRATION_V7}\n-- custody migration v8 --\n${CUSTODY_MIGRATION_V8}\n-- custody migration v9 --\n${CUSTODY_MIGRATION_V9}`).digest('hex');
/** Retained alias for callers that need the initial schema definition in disposable fixtures. */
export const CUSTODY_SCHEMA = CUSTODY_SCHEMA_V1;
export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V8_DIGEST;
export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V9_DIGEST;
/** Explicit operator migration; the HTTP runtime never creates or upgrades schema. */
export async function migrateCustody(pool: Pool): Promise<void> {
@@ -373,6 +422,11 @@ export async function migrateCustody(pool: Pool): Promise<void> {
if (current === 7) {
await c.query(CUSTODY_MIGRATION_V8);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(8,$1)', [CUSTODY_SCHEMA_V8_DIGEST]);
current = 8;
}
if (current === 8) {
await c.query(CUSTODY_MIGRATION_V9);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(9,$1)', [CUSTODY_SCHEMA_V9_DIGEST]);
}
if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application');
await verifyCustodySchema(c);
@@ -388,7 +442,7 @@ async function verifyCustodySchemaHistory(c: PoolClient): Promise<number> {
const rows = await c.query('SELECT version,digest FROM wk_custody.schema_versions ORDER BY version');
const expected = [CUSTODY_SCHEMA_V1_DIGEST, CUSTODY_SCHEMA_V2_DIGEST, CUSTODY_SCHEMA_V3_DIGEST,
CUSTODY_SCHEMA_V4_DIGEST, CUSTODY_SCHEMA_V5_DIGEST, CUSTODY_SCHEMA_V6_DIGEST, CUSTODY_SCHEMA_V7_DIGEST,
CUSTODY_SCHEMA_V8_DIGEST];
CUSTODY_SCHEMA_V8_DIGEST, CUSTODY_SCHEMA_V9_DIGEST];
if (!rows.rowCount || rows.rowCount > expected.length || rows.rows.some((row, index) =>
Number(row.version) !== index + 1 || row.digest !== expected[index])) {
throw new Error('Custody PostgreSQL schema version/digest history mismatch; explicit migration required');
@@ -455,10 +509,11 @@ export async function grantMoneroObserverRuntime(pool: Pool, role: string, custo
/** Separate least-privilege role for reviewed pending-credit posting and deposit reorg evidence. */
export async function grantMoneroDepositCreditRuntime(pool: Pool, role: string,
custodyRuntimeRole = 'custody_runtime', observerRole = 'monero_observer'): Promise<void> {
custodyRuntimeRole = 'custody_runtime', observerRole = 'monero_observer',
allocatorRole = 'monero_deposit_allocator'): Promise<void> {
if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid deposit-credit role identifier');
if (role === custodyRuntimeRole || role === observerRole) {
throw new Error('Deposit-credit coordinator requires a separate role from custody runtime and observer');
if ([custodyRuntimeRole, observerRole, allocatorRole].includes(role)) {
throw new Error('Deposit-credit coordinator requires a separate role from custody runtime, observer, and allocator');
}
const name = `"${role}"`;
const c = await pool.connect();
@@ -514,3 +569,38 @@ export async function grantMoneroDepositAllocatorRuntime(pool: Pool, role: strin
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
finally { c.release(); }
}
/** Mainnet customer withdrawal-intent role. It can reserve/cancel liabilities but has no signing or relay authority. */
export async function grantMoneroWithdrawalRuntime(pool: Pool, role: string,
custodyRuntimeRole = 'custody_runtime', observerRole = 'monero_observer',
creditRole = 'monero_deposit_credit', allocatorRole = 'monero_deposit_allocator'): Promise<void> {
if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid Monero withdrawal role identifier');
if ([custodyRuntimeRole, observerRole, creditRole, allocatorRole].includes(role)) {
throw new Error('Monero withdrawal role must be separate from custody, observer, credit, and allocator roles');
}
const name = `"${role}"`;
const c = await pool.connect();
try {
await c.query('BEGIN'); await verifyCustodySchema(c);
const r = await c.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolreplication,rolbypassrls FROM pg_roles WHERE rolname=$1', [role]);
if (!r.rowCount || Object.values(r.rows[0]).some(Boolean)) throw new Error('Monero withdrawal role must exist without administrative privileges');
const memberships = await c.query('SELECT 1 FROM pg_auth_members WHERE member=(SELECT oid FROM pg_roles WHERE rolname=$1) LIMIT 1', [role]);
if (memberships.rowCount) throw new Error('Monero withdrawal role must not inherit privileges through role membership');
const owner = await c.query("SELECT pg_get_userbyid(nspowner) AS owner FROM pg_namespace WHERE nspname='wk_custody'");
if (owner.rows[0].owner === role) throw new Error('Monero withdrawal role must not own the custody schema');
await c.query(`GRANT USAGE ON SCHEMA wk_custody TO ${name};
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA wk_custody FROM ${name};
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA wk_custody FROM ${name};
GRANT SELECT ON wk_custody.custody_meta,wk_custody.accounts,wk_custody.monero_deposit_credit_events,
wk_custody.monero_deposit_release_events,wk_custody.monero_deposit_reorg_holds,
wk_custody.monero_deposit_reorg_resolutions TO ${name};
GRANT SELECT,INSERT ON wk_custody.monero_withdrawal_intents TO ${name};
GRANT UPDATE(status,cancel_request_id,cancel_public_key,cancel_expires_at,cancel_signature,cancelled_at)
ON wk_custody.monero_withdrawal_intents TO ${name};
GRANT INSERT(id,normal) ON wk_custody.accounts TO ${name};
GRANT INSERT(id,kind,created) ON wk_custody.journals TO ${name};
GRANT INSERT ON wk_custody.entries TO ${name};`);
await c.query('COMMIT');
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
finally { c.release(); }
}
+61 -3
View File
@@ -20,11 +20,15 @@ import { marketCommandSchema } from '../../shared/market.ts';
import { marketCommandV3Schema } from '../../shared/market-v3.ts';
import { custodyCommandSchema } from '../../shared/custody.ts';
import { moneroDepositAddressRequestSchema } from '../../shared/monero-deposit.ts';
import { mainnetCustodyBalanceRequestSchema, mainnetWithdrawalRequestSchema,
mainnetWithdrawalCancellationSchema } from '../../shared/monero-withdrawal.ts';
import { CustodyMarket, CustodyError, custodyMode } from '../market/custody.ts';
import { openCustodyStorage } from '../market/custody-storage.ts';
import { PostgresCustody } from '../market/postgres-custody.ts';
import { CanonicalObserver } from '../market/canonical-observer.ts';
import { enrollMoneroDepositAddress, type MainnetEnrollmentContext } from '../market/monero-deposit-enrollment.ts';
import { cancelMainnetWithdrawal, readMainnetCustodyBalance, reserveMainnetWithdrawal } from '../market/mainnet-withdrawal.ts';
import { verifyMainnetCustodyIdentity } from '../market/mainnet-custody-identity.ts';
import { createMoneroDepositAddressRpc, loadMoneroObserverConfig } from '../market/monero-observer.ts';
import { loadMoneroMtlsTransport, loadMoneroMtlsTransportConfig } from '../market/monero-mtls-transport.ts';
import { postgresConfig } from '../market/postgres-config.ts';
@@ -50,6 +54,13 @@ if (depositEnrollmentMode !== '0' && depositEnrollmentMode !== '1') {
if (depositEnrollmentMode === '1' && !IS_MAINNET) {
throw new Error('Monero deposit enrollment requires the mainnet profile');
}
const withdrawalRequestMode = process.env.WIKIDECK_MONERO_WITHDRAWAL_REQUESTS ?? '0';
if (withdrawalRequestMode !== '0' && withdrawalRequestMode !== '1') {
throw new Error('WIKIDECK_MONERO_WITHDRAWAL_REQUESTS must be exactly 0 or 1');
}
if (withdrawalRequestMode === '1' && !IS_MAINNET) {
throw new Error('Monero withdrawal requests require the mainnet profile');
}
if (canonicalObserverMode === '1' && (process.env.NODE_ENV === 'production' || custodyConfig !== 'sandbox' ||
process.env.WIKIDECK_CUSTODY_STORAGE !== 'postgres' || process.env.WIKIDECK_PG_DISPOSABLE_TEST !== '1')) {
throw new Error('Canonical observer rehearsal requires sandbox custody, PostgreSQL, and disposable test isolation');
@@ -83,6 +94,8 @@ let custodyObserver: CanonicalObserver | undefined;
let moneroDepositPool: Pool | undefined;
let moneroDepositRpc: ReturnType<typeof createMoneroDepositAddressRpc> | undefined;
let moneroDepositContext: MainnetEnrollmentContext | undefined;
let moneroWithdrawalPool: Pool | undefined;
let moneroWithdrawalCustodianId: string | undefined;
try {
if (custodyConfig === 'sandbox') custody = await openCustodyStorage(dataDir, { ...paymentConfig,
card: (hash, owner, sequence) => {
@@ -123,7 +136,18 @@ try {
moneroDepositContext = { custodianId, chainId: CHAIN_ID, walletGenerationId, walletFingerprint: actualFingerprint,
accountIndex: Number(accountText) };
}
} catch(e) {await moneroDepositPool?.end();await custodyObserver?.close();await custody?.close();await market.close();peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
if (withdrawalRequestMode === '1') {
moneroWithdrawalCustodianId = z.string().uuid().parse(process.env.WIKIDECK_MONERO_WITHDRAWAL_CUSTODIAN_ID);
const urlFile = process.env.WIKIDECK_MONERO_WITHDRAWAL_PG_URL_FILE;
const caFile = process.env.WIKIDECK_MONERO_WITHDRAWAL_PG_CA_FILE;
if (!urlFile || !caFile) throw new Error('Monero withdrawal request PostgreSQL URL and CA files are required');
moneroWithdrawalPool = new Pool(await postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: urlFile,
WIKIDECK_CUSTODY_PG_CA_FILE: caFile, WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls' }));
moneroWithdrawalPool.on('error', () => console.error('Monero withdrawal request database unavailable.'));
await verifyMainnetCustodyIdentity(moneroWithdrawalPool, moneroWithdrawalCustodianId);
await moneroWithdrawalPool.query('SELECT 1 FROM wk_custody.monero_withdrawal_intents LIMIT 0');
}
} catch(e) {await moneroWithdrawalPool?.end();await moneroDepositPool?.end();await custodyObserver?.close();await custody?.close();await market.close();peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
const app = express();
app.disable('x-powered-by');
if(proxyTrust.length)app.set('trust proxy',proxyTrust);
@@ -194,6 +218,40 @@ if (depositEnrollmentMode === '1' && moneroDepositContext && moneroDepositRpc &&
}
});
}
if (withdrawalRequestMode === '1' && moneroWithdrawalPool && moneroWithdrawalCustodianId) {
app.get('/api/monero/withdrawal-config', (_req,res) => {
res.json({ network: 'wikideck-randomx-mainnet-1', chainId: CHAIN_ID,
custodianId: moneroWithdrawalCustodianId, withdrawalRequestsEnabled: true,
transactionSigningEnabled: false, transactionRelayEnabled: false });
});
app.post('/api/monero/custody-balance', rateLimit({windowMs:60000,limit:20,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res) => {
const parsed = mainnetCustodyBalanceRequestSchema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: 'Invalid signed mainnet custody balance request.' }); return; }
try { res.json(await readMainnetCustodyBalance(moneroWithdrawalPool!, parsed.data)); }
catch { res.status(403).json({ error: 'Mainnet custody balance is unavailable for this signed request.' }); }
});
app.post('/api/monero/withdrawals', rateLimit({windowMs:60000,limit:5,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res) => {
const parsed = mainnetWithdrawalRequestSchema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: 'Invalid signed mainnet withdrawal request.' }); return; }
try {
const result = await reserveMainnetWithdrawal(moneroWithdrawalPool!, parsed.data);
res.status(202).json({ ...result, status: result.status === 'reserved' ? 'reserved-not-sent' : result.status,
transactionSigningEnabled: false, transactionRelayEnabled: false });
} catch {
res.status(409).json({ error: 'Withdrawal request could not be reserved; verify signed terms and available balance.' });
}
});
app.post('/api/monero/withdrawals/cancel', rateLimit({windowMs:60000,limit:5,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res) => {
const parsed = mainnetWithdrawalCancellationSchema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ error: 'Invalid signed withdrawal cancellation.' }); return; }
try {
const result = await cancelMainnetWithdrawal(moneroWithdrawalPool!, parsed.data);
res.json({ ...result, transactionSigningEnabled: false, transactionRelayEnabled: false });
} catch {
res.status(409).json({ error: 'Withdrawal cancellation could not be applied; it may already be cancelled or under operator processing.' });
}
});
}
app.post('/api/market/command',rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}),async(req,res)=>{
const current = marketCommandV3Schema.safeParse(req.body);
const command = current.success ? current.data : marketCommandSchema.parse(req.body);
@@ -340,6 +398,6 @@ for(const signal of ['SIGTERM','SIGINT']as const)process.once(signal,()=>{
if(catchupImmediate)clearImmediate(catchupImmediate);
const observerDrain=custodyObserver?.close()??Promise.resolve();
const timeout=setTimeout(()=>process.exit(1),25000);timeout.unref();
server.close(async()=>{await observerDrain;await moneroDepositPool?.end();await custody?.close();await market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
server.close(async()=>{await observerDrain;await moneroWithdrawalPool?.end();await moneroDepositPool?.end();await custody?.close();await market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
});
server.on('error',async()=>{stopping=true;clearInterval(marketTimer);clearInterval(marketCatchupTimer);if(catchupImmediate)clearImmediate(catchupImmediate);operations.close();peers.close();await custodyObserver?.close();await moneroDepositPool?.end();await custody?.close();await market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
server.on('error',async()=>{stopping=true;clearInterval(marketTimer);clearInterval(marketCatchupTimer);if(catchupImmediate)clearImmediate(catchupImmediate);operations.close();peers.close();await custodyObserver?.close();await moneroWithdrawalPool?.end();await moneroDepositPool?.end();await custody?.close();await market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
+70
View File
@@ -0,0 +1,70 @@
import { z } from 'zod';
import { parseAtomicXmr } from './xmr-amount.ts';
const uuid = z.string().uuid();
const hex = z.string().regex(/^[a-f0-9]{64}$/);
const atomic = z.string().max(22).refine(value => { try { parseAtomicXmr(value); return true; } catch { return false; } });
const positiveAtomic = atomic.refine(value => BigInt(value) > 0n);
export const mainnetWithdrawalRequestSchema = z.object({
version: z.literal(1),
network: z.literal('wikideck-randomx-mainnet-1'),
chainId: hex,
custodianId: uuid,
beneficiary: hex,
publicKey: z.string().min(1).max(256),
requestId: uuid,
destination: z.string().length(95),
amountAtomicXmr: positiveAtomic,
maxFeeAtomicXmr: atomic,
expiresAt: z.number().int().nonnegative().safe(),
signature: z.string().min(1).max(256),
}).strict();
export type MainnetWithdrawalRequest = z.infer<typeof mainnetWithdrawalRequestSchema>;
export type MainnetWithdrawalRequestUnsigned = Omit<MainnetWithdrawalRequest, 'signature'>;
export const mainnetWithdrawalCancellationSchema = z.object({
version: z.literal(1),
network: z.literal('wikideck-randomx-mainnet-1'),
chainId: hex,
custodianId: uuid,
beneficiary: hex,
publicKey: z.string().min(1).max(256),
cancellationId: uuid,
withdrawalRequestId: uuid,
expiresAt: z.number().int().nonnegative().safe(),
signature: z.string().min(1).max(256),
}).strict();
export type MainnetWithdrawalCancellation = z.infer<typeof mainnetWithdrawalCancellationSchema>;
export type MainnetWithdrawalCancellationUnsigned = Omit<MainnetWithdrawalCancellation, 'signature'>;
export function mainnetWithdrawalMessage(request: MainnetWithdrawalRequestUnsigned): string {
return JSON.stringify(['wikideck-mainnet-withdrawal-request-v1', request.version, request.network,
request.chainId, request.custodianId, request.beneficiary, request.publicKey, request.requestId,
request.destination, request.amountAtomicXmr, request.maxFeeAtomicXmr, request.expiresAt]);
}
export function mainnetWithdrawalCancellationMessage(request: MainnetWithdrawalCancellationUnsigned): string {
return JSON.stringify(['wikideck-mainnet-withdrawal-cancel-v1', request.version, request.network,
request.chainId, request.custodianId, request.beneficiary, request.publicKey, request.cancellationId,
request.withdrawalRequestId, request.expiresAt]);
}
export const mainnetCustodyBalanceRequestSchema = z.object({
version: z.literal(1),
network: z.literal('wikideck-randomx-mainnet-1'),
chainId: hex,
custodianId: uuid,
beneficiary: hex,
publicKey: z.string().min(1).max(256),
nonce: uuid,
expiresAt: z.number().int().nonnegative().safe(),
signature: z.string().min(1).max(256),
}).strict();
export type MainnetCustodyBalanceRequest = z.infer<typeof mainnetCustodyBalanceRequestSchema>;
export type MainnetCustodyBalanceRequestUnsigned = Omit<MainnetCustodyBalanceRequest, 'signature'>;
export function mainnetCustodyBalanceMessage(request: MainnetCustodyBalanceRequestUnsigned): string {
return JSON.stringify(['wikideck-mainnet-custody-balance-v1', request.version, request.network,
request.chainId, request.custodianId, request.beneficiary, request.publicKey, request.nonce, request.expiresAt]);
}
+23 -3
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { assertCustodyDbActionAllowedOnNetwork, assertMainnetSchemaMigrationAcknowledged,
parseCustodyDbArguments } from '../server/market/custody-db-args.ts';
assertMainnetCustodyOperationAcknowledged, parseCustodyDbArguments } from '../server/market/custody-db-args.ts';
test('custody database CLI parses two-argument deposit operations without shifting arguments', () => {
assert.deepEqual(parseCustodyDbArguments(['deposit-prepare-credit', 'a'.repeat(64), `${'b'.repeat(64)}:${'c'.repeat(64)}`]), {
@@ -16,23 +16,43 @@ test('custody database CLI parses two-argument deposit operations without shifti
assert.deepEqual(parseCustodyDbArguments(['grant-monero-deposit-allocator-runtime', 'monero_allocator']), {
action: 'grant-monero-deposit-allocator-runtime', argument: 'monero_allocator', extraArgument: undefined,
});
assert.deepEqual(parseCustodyDbArguments(['grant-monero-withdrawal-runtime', 'monero_withdrawal']), {
action: 'grant-monero-withdrawal-runtime', argument: 'monero_withdrawal', extraArgument: undefined,
});
});
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('grant-monero-withdrawal-runtime', true));
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('migrate-mainnet-schema', true));
assert.throws(() => assertCustodyDbActionAllowedOnNetwork('migrate-mainnet-schema', false), /requires the mainnet profile/);
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('initialize-mainnet-custody-identity', true));
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('deposit-stage-credit', true));
assert.throws(() => assertCustodyDbActionAllowedOnNetwork('initialize-mainnet-custody-identity', false), /requires the mainnet profile/);
assert.throws(() => assertMainnetSchemaMigrationAcknowledged('migrate-mainnet-schema', undefined), /acknowledgement/);
assert.throws(() => assertMainnetSchemaMigrationAcknowledged('migrate-mainnet-schema', 'yes'), /acknowledgement/);
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 explicitly scoped/i);
for (const action of ['migrate', 'grant-runtime', 'audit'] as const) {
assert.throws(() => assertCustodyDbActionAllowedOnNetwork(action, true), /only scoped/i);
assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork(action, false));
}
});
test('mainnet reviewed deposit operations require exact credit-only acknowledgement', () => {
for (const action of ['initialize-mainnet-custody-identity', 'grant-monero-credit-runtime',
'deposit-prepare-credit', 'deposit-stage-credit', 'deposit-prepare-release', 'deposit-release',
'deposit-reconcile-holds', 'deposit-prepare-resolve-hold', 'deposit-resolve-hold'] as const) {
assert.throws(() => assertMainnetCustodyOperationAcknowledged(action, undefined), /reviewed-credit-only/);
assert.throws(() => assertMainnetCustodyOperationAcknowledged(action, 'yes'), /reviewed-credit-only/);
assert.doesNotThrow(() => assertMainnetCustodyOperationAcknowledged(action, 'reviewed-credit-only'));
}
assert.doesNotThrow(() => assertMainnetCustodyOperationAcknowledged('grant-monero-observer-runtime', undefined));
assert.doesNotThrow(() => assertMainnetCustodyOperationAcknowledged('grant-monero-deposit-allocator-runtime', undefined));
assert.doesNotThrow(() => assertMainnetCustodyOperationAcknowledged('grant-monero-withdrawal-runtime', undefined));
});
test('custody database CLI rejects unknown, missing, empty, and excess arguments', () => {
for (const args of [[], ['unknown'], ['audit', 'extra'], ['deposit-prepare-credit', 'wallet'],
['deposit-prepare-credit', 'wallet', 'observation', 'extra'], ['deposit-release', '']]) {
+11 -7
View File
@@ -108,11 +108,15 @@ test('operator preflight is read-only, fails closed, and does not disclose crede
} finally { await rm(dir, { recursive: true, force: true }); }
});
test('Monero deposit enrollment API cannot be enabled on testnet', () => {
const result = spawnSync(process.execPath, ['--experimental-strip-types', 'server/index.ts'], {
env: { ...process.env, WIKIDECK_NETWORK: 'testnet', WIKIDECK_MONERO_DEPOSIT_ENROLLMENT: '1' },
encoding: 'utf8', timeout: 30000,
});
assert.equal(result.status, 1);
assert.match(result.stderr, /Monero deposit enrollment requires the mainnet profile/);
test('Monero deposit and withdrawal request APIs cannot be enabled on testnet', () => {
for (const [name, value] of [['WIKIDECK_MONERO_DEPOSIT_ENROLLMENT', '1'],
['WIKIDECK_MONERO_WITHDRAWAL_REQUESTS', '1']]) {
const result = spawnSync(process.execPath, ['--experimental-strip-types', 'server/index.ts'], {
env: { ...process.env, WIKIDECK_NETWORK: 'testnet', [name]: value },
encoding: 'utf8', timeout: 30000,
});
assert.equal(result.status, 1);
assert.match(result.stderr, new RegExp(name.includes('DEPOSIT')
? 'Monero deposit enrollment requires the mainnet profile' : 'Monero withdrawal requests require the mainnet profile'));
}
});
+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`);