First deposit integration slice
This commit is contained in:
@@ -15,13 +15,10 @@ The operator has authorized proceeding with implementation instead of the previo
|
||||
- [Custody UI](../src/CustodyPanel.tsx): marketplace “Custody preview” tab with reviewed signatures, private/paged balances and activity, test funding, listing/reservation and withdrawal-hold cancellation. A lost response retains the original signed envelope for replay rather than creating a new debit nonce.
|
||||
- HTTP endpoints `/api/custody` and `/api/custody/command` are off by default; explicit `WIKIDECK_CUSTODY_MODE=sandbox` enables testnet only. All other enabled modes and mainnet sandbox are rejected before node resources open. Requests use the node's origin/admission/body limits, per-route mutation throttling and sanitized errors. No observer/admin settlement or balance-credit endpoint exists.
|
||||
- [Stopped-node recovery](../server/recovery.ts) includes the optional custody sandbox database and its identity while continuing to accept existing two-database backups. Financial receipts are not pruned. Restore/startup checks do not substitute for production reconciliation.
|
||||
- Offline SQLite fixture methods exercise fee allocation and prepared-withdrawal identity/retry/cancellation boundaries. They never access Monero and are not callable through HTTP. Public reservations stop at `awaiting-lock`; there is **no automatic NFT delivery or settled sale** in this increment. No real-address withdrawal, deposit subaddress, wallet RPC, signer, author payout or reorg compensation is implemented yet.
|
||||
- Offline SQLite fixture methods exercise fee allocation and prepared-withdrawal identity/retry/cancellation boundaries. They never access Monero and are not callable through HTTP. Public reservations stop at `awaiting-lock`; there is **no automatic NFT delivery or settled sale** in this increment. Monero address allocation and read-only observation groundwork now exist, but no deposit scan worker, customer credit, live withdrawal, signer, author payout or reorg compensation is wired or enabled.
|
||||
- **Financial storage update:** [ADR 003](postgres-financial-storage.md) selects PostgreSQL and records the integrated asynchronous custody backend, restricted runtime/schema roles, deferred accounting constraints, explicit SQLite freeze/import, and full dump/restore rehearsal. SQLite remains an opt-in-compatible local sandbox default, not an automatic fallback. Real custody and production operating acceptance remain blocked.
|
||||
|
||||
**Financial storage update:** [ADR 003](postgres-financial-storage.md) selects PostgreSQL and records the integrated asynchronous custody backend, restricted runtime/schema roles, deferred accounting constraints, explicit SQLite freeze/import, and full dump/restore rehearsal. SQLite remains an opt-in-compatible local sandbox default, not an automatic fallback. Real custody and production operating acceptance remain blocked.
|
||||
|
||||
**Monero observation and deposit-address allocation groundwork:** [server/market/monero-observer.ts](../server/market/monero-observer.ts) keeps the existing observer client strictly read-only: mainnet wallet validation and bounded incoming scans, authenticated requests, strict HTTPS/literal-loopback-only HTTP, no redirects, bounded response/deadline, JSON-RPC ID validation and lossless integer handling. A separate, narrow `createMoneroDepositAddressRpc()` capability verifies a mainnet primary address with `get_address` plus `validate_address`, then derives a domain-separated fingerprint. It can inspect one indexed subaddress or request exactly one labeled subaddress; it exposes no transfer, relay, sweep or withdrawal operation. Actual testing against the verified Monero v0.18.5.4 Wallet RPC found that `get_wallet_info` returns JSON-RPC “Method not found” despite appearing in current docs; the implementation therefore does not depend on it. The allocator [server/market/monero-deposit-allocation-store.ts](../server/market/monero-deposit-allocation-store.ts) combines PostgreSQL v3 beneficiary/index reservations with this capability: it reserves first, serializes cooperating allocators per wallet/account, checks that the live wallet fingerprint matches the durable generation, checks the exact reserved index, and recovers a lost `create_address` response by looking up the deterministic allocation label before retrying creation. Actual v0.18.5.4 testing also confirmed that an unused address index returns RPC error `-15` (“address index is out of bound”), rather than an empty `addresses` array; the client recognizes only that exact error as “not created” and fails closed for other errors. Address registration is immutable and performed only after the wallet returns the expected index/address/label. The installed v0.18.5.4 interface does not provide a supported `get_wallet_info` query to attest the wallet type, so watch-only provisioning is an operator/deployment requirement, not a property this RPC client can prove. If another wallet client consumes indexes outside this allocator, the operation fails for reconciliation rather than reassigning an address; the wallet RPC must therefore be dedicated to the allocator. The configured wallet-generation UUID still requires controlled operator enrollment and backup. Neither client nor orchestration is wired to startup, routes, UI, deposit scans or financial posting. The registry remains inaccessible to both custody API and observer roles. Observation storage continues to record reorg/reappearance evidence only; no credit, debit, reversal or journal can be posted. Schema v1→v3 is explicit and additive. The test-target build succeeded; **187/187** offline tests and **35/35** disposable PostgreSQL tests passed, including rejection of wallet identity mismatch and simulated create-success/response-loss/recovery without duplicate address creation or journal posting. The mTLS gateway method allowlist and config guard have unit coverage; an end-to-end certificate handshake remains open.
|
||||
|
||||
**Security claim:** operator-custodied XMR, seller-authorized and host-attested NFT sale completion, with durable accounting and recoverable coordination. Not trustless, not an atomic Monero/NFT swap, not insured, and not an independently consensus-verified Monero payment. The operator can steal, censor, become insolvent or lose keys; segregation and review reduce but do not remove that risk.
|
||||
**Monero observation and deposit-address allocation groundwork:** [server/market/monero-observer.ts](../server/market/monero-observer.ts) keeps the observer strictly read-only, with bounded requests, strict HTTPS/literal-loopback-only HTTP, no redirects, bounded responses/deadlines, JSON-RPC ID validation and lossless integer handling. A separate, narrow `createMoneroDepositAddressRpc()` capability verifies a mainnet primary address with `get_address` plus `validate_address`, then derives a domain-separated fingerprint. It can inspect one indexed subaddress or request exactly one labeled subaddress; it exposes no transfer, relay, sweep or withdrawal operation. The allocator [server/market/monero-deposit-allocation-store.ts](../server/market/monero-deposit-allocation-store.ts) uses PostgreSQL v3 beneficiary/index reservations: it reserves first, serializes cooperating allocators per wallet/account, checks the live wallet fingerprint and exact reserved index, and recovers a lost `create_address` response by looking up the deterministic label before retrying. Actual v0.18.5.4 testing confirmed `get_wallet_info` is unavailable and an unused address index returns RPC error `-15` (“address index is out of bound”); the client recognizes only that precise unused-index response. Watch-only provisioning remains a deployment requirement. PostgreSQL v4 now records a per-output public key and global index while preserving historical observations without a creditable output identity. The observer joins transaction-level `get_transfers` metadata to bounded `incoming_transfers` output records and identifies each output as `txid:pubkey`; the gateway allows only bounded read parameters. The source-contract parser and fixtures were updated, but this output-level method has not yet been tested against the actual Stagenet wallet binary. The app does not start the gateway, observer, or allocator; no output assessment/credit journal, customer deposit API, withdrawal worker or sale-settlement path is wired. No financial journal can be posted by the Monero observer or allocator. This is integration groundwork, not a live payment path.
|
||||
|
||||
## 2. Identity and trust boundaries
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Use **PostgreSQL** for financial state. Exact arithmetic, transactional constrai
|
||||
|
||||
This is an integrated backend, not a second disconnected balance demo: [custody storage selection](../server/market/custody-storage.ts) supplies [the existing HTTP custody routes](../server/testnet/http.ts) with [PostgreSQL custody](../server/market/postgres-custody.ts). Signed command domains, user identities, amounts and UI stay unchanged. SQLite remains the default disposable/local sandbox for compatibility; choose PostgreSQL explicitly. Chain DAG/canonical indexes and legacy mock marketplace remain in their original SQLite databases. Moving those independent concerns is not needed to use PostgreSQL for customer liabilities.
|
||||
|
||||
**All balances still represent fictitious sandbox XMR.** No deposit observer/subaddress allocation, Monero signer, real-address withdrawal, NFT sale witness or production payout is introduced. The PostgreSQL backend supports public listing/reservation/cancellation/withdrawal-hold commands; old prepared fixture withdrawals are preserved by import but cannot be cancelled or paid through HTTP. Old SQLite fixture settlement methods are not production PostgreSQL payment workers.
|
||||
**All balances still represent fictitious sandbox XMR.** Monero observer and subaddress allocation groundwork are implemented separately in additive custody migrations v2-v4, but remain unwired, non-posting and unavailable to public custody routes. No deposit credit journal, live signer, real-address withdrawal, NFT sale witness or production payout is introduced. The PostgreSQL backend supports sandbox listing/reservation/cancellation/withdrawal-hold commands; old prepared fixture withdrawals are preserved by import but cannot be cancelled or paid through HTTP. Old SQLite fixture settlement methods are not production PostgreSQL payment workers.
|
||||
|
||||
## Storage and transaction guarantees
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ Status: active engineering plan; **not launch or payment authorization**. Update
|
||||
|
||||
**Actual Wallet RPC + mTLS gateway rehearsal completed (2026-10-21):** the test image obtains Monero CLI v0.18.5.4 only from the official downloads host, verifies the `binaryFate` fingerprint `81AC 591F E9C4 B65C 5806 AFC3 F0AF 4D46 2A0B DF92`, verifies the GPG-signed canonical hashes file and checks the Linux x64 archive digest `ba2928962c101b609434bf8c1b43ad59ebe96c5525977284ca599d8bf0f02d10` before extraction. Its rehearsal creates ephemeral CA/server/approved-client/untrusted-client certificates, then runs `monero-wallet-rpc` in Docker `--network none`, `--stagenet --offline`, loopback-only, with login disabled behind the tested TLS 1.3 mTLS gateway. The real wallet was verified watch-only by the `query_key(spend_key)` denial. The configured client cert successfully read the primary address and created one subaddress; the gateway rejected a transfer request (HTTP 400 before upstream), a CA-signed but unapproved client certificate, and a no-certificate request. Wallet files were stopped and deleted with the ephemeral container. No `monerod`, peer, network, live credential, mainnet or funds were involved. This proves protocol interoperability in that isolated single-container topology; it does **not** prove Kubernetes/apartment firewall isolation, certificate issuance/rotation, deployment configuration, hard power-loss durability, deposits/credits/withdrawals, or production readiness. The current app still does not start this gateway or observer; HTTP auth with Monero's own `--rpc-login` remains intentionally unused. Full offline suite after these changes: **187/187 passed**; disposable PostgreSQL suite: **35/35 passed**; test image build succeeded.
|
||||
|
||||
**Output failure/reorg test results (2026-10-21):** offline coverage now rejects missing outputs, duplicate public keys, subaddress misattribution, and malformed output records. PostgreSQL coverage follows a stable output identity through not-seen, reappeared, and changed-height re-inclusion states; it rejects output-key reuse across transaction IDs and confirms zero journal postings throughout. The image build passed; offline suite **189/189 passed** and disposable PostgreSQL suite **35/35 passed**. This exercises persistence and review signals only—no customer credit or automatic reversal exists. The updated method has not yet been tested against the actual Stagenet wallet binary.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -127,6 +127,10 @@ try {
|
||||
const sub=await gatewayCall(validTransport,'create_address',{account_index:0,label:`wikideck-deposit-v1:${randomUUID()}`,count:1});
|
||||
console.log('stage: authorized mTLS subaddress creation');
|
||||
assert.equal(sub.status,200); assert.equal(sub.data.result.address_index,1);
|
||||
const outputs=await gatewayCall(validTransport,'incoming_transfers',{transfer_type:'all',account_index:0,subaddr_indices:[1]});
|
||||
assert.equal(outputs.status,200); assert.deepEqual(outputs.data.result.transfers,[],
|
||||
'a fresh watch-only Stagenet address must return an empty owned-output list');
|
||||
console.log('stage: authorized bounded incoming-output read');
|
||||
const forbiddenStatus=await rawGatewayStatus('transfer',{destinations:[]},certs['client.crt'],certs['client.key'],certs['ca.crt']);
|
||||
assert.equal(forbiddenStatus,400,'gateway must reject transfer before Wallet RPC');
|
||||
console.log('stage: forbidden transfer blocked');
|
||||
@@ -141,7 +145,7 @@ try {
|
||||
passed=true;
|
||||
console.log(JSON.stringify({result:'passed',moneroWalletRpc:'0.18.5.4',network:'Stagenet',containerNetwork:'none',
|
||||
rpcAuthentication:'mTLS',tlsMinimum:'1.3',approvedClientAccepted:true,untrustedClientRejected:true,
|
||||
noCertificateRejected:true,forbiddenTransferRejected:true,allowedAddressCreation:true,watchOnly:true,
|
||||
noCertificateRejected:true,forbiddenTransferRejected:true,allowedAddressCreation:true,allowedOutputRead:true,watchOnly:true,
|
||||
realFundsUsed:false,secretsOrAddressesLogged:false}));
|
||||
} finally {
|
||||
if(gateway) await new Promise(resolveClose=>gateway.close(()=>resolveClose()));
|
||||
|
||||
@@ -25,6 +25,7 @@ const observationSchema = z.object({
|
||||
height: z.number().int().positive().safe(), confirmations: z.number().int().positive().safe(),
|
||||
timestamp: z.number().int().nonnegative().safe(), unlockTime: z.string().regex(/^(0|[1-9]\d{0,19})$/),
|
||||
accountIndex: z.number().int().nonnegative().safe(), addressIndex: z.number().int().nonnegative().safe(),
|
||||
outputPublicKey: z.string().regex(/^[a-f0-9]{64}$/), globalOutputIndex: z.number().int().nonnegative().safe(),
|
||||
locked: z.boolean(), doubleSpendSeen: z.boolean(),
|
||||
}).strict();
|
||||
const MAX_SCAN_RANGE = 100_000;
|
||||
@@ -53,7 +54,8 @@ function digest(value: unknown): string {
|
||||
function rowDigest(observation: MoneroIncomingObservation): string {
|
||||
return digest([observation.observationId, observation.transactionId, observation.amountAtomicXmr,
|
||||
observation.height, observation.timestamp, observation.unlockTime,
|
||||
observation.accountIndex, observation.addressIndex, observation.locked, observation.doubleSpendSeen]);
|
||||
observation.accountIndex, observation.addressIndex, observation.outputPublicKey, observation.globalOutputIndex,
|
||||
observation.locked, observation.doubleSpendSeen]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,7 +76,7 @@ export async function persistMoneroObservationScan(pool: Pool, input: PersistObs
|
||||
const ids = new Set<string>();
|
||||
for (const observation of observations) {
|
||||
await validateXmrAddress(observation.address);
|
||||
if (observation.observationId !== `${observation.transactionId}:${observation.accountIndex}:${observation.addressIndex}` ||
|
||||
if (observation.observationId !== `${observation.transactionId}:${observation.outputPublicKey}` ||
|
||||
BigInt(observation.amountAtomicXmr) > XMR_RPC_UINT64_MAX || observation.height < page.fromHeight ||
|
||||
observation.height > page.throughHeight || observation.confirmations !== page.walletHeight - observation.height + 1 ||
|
||||
(observation.locked && observation.confirmations <= 0)) throw new Error('Monero observation is inconsistent with its scan page');
|
||||
@@ -119,20 +121,22 @@ export async function persistMoneroObservationScan(pool: Pool, input: PersistObs
|
||||
const eventType = !current.rowCount ? 'seen' : !current.rows[0].currently_present ? 'reappeared'
|
||||
: current.rows[0].payload_digest !== payloadDigest ? 'changed' : null;
|
||||
await client.query(`INSERT INTO wk_custody.monero_transfer_observations(
|
||||
wallet_fingerprint,observation_id,txid,monero_address,account_index,address_index,amount_atomic_xmr,block_height,
|
||||
wallet_fingerprint,observation_id,txid,monero_address,account_index,address_index,output_public_key,global_output_index,
|
||||
amount_atomic_xmr,block_height,
|
||||
wallet_height,confirmations,timestamp,unlock_time,locked,double_spend_seen,payload_digest,
|
||||
currently_present,last_seen_scan_id,first_observed_at,last_observed_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true,$16,$17,$17)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,true,$18,$19,$19)
|
||||
ON CONFLICT(wallet_fingerprint,observation_id) DO UPDATE SET
|
||||
monero_address=excluded.monero_address,
|
||||
output_public_key=excluded.output_public_key,global_output_index=excluded.global_output_index,
|
||||
amount_atomic_xmr=excluded.amount_atomic_xmr,block_height=excluded.block_height,wallet_height=excluded.wallet_height,
|
||||
confirmations=excluded.confirmations,timestamp=excluded.timestamp,unlock_time=excluded.unlock_time,
|
||||
locked=excluded.locked,double_spend_seen=excluded.double_spend_seen,payload_digest=excluded.payload_digest,
|
||||
currently_present=true,last_seen_scan_id=excluded.last_seen_scan_id,last_observed_at=excluded.last_observed_at`,
|
||||
[parsed.walletFingerprint, observation.observationId, observation.transactionId, observation.address,
|
||||
observation.accountIndex, observation.addressIndex, observation.amountAtomicXmr, observation.height, page.walletHeight,
|
||||
observation.confirmations, observation.timestamp, observation.unlockTime, observation.locked,
|
||||
observation.doubleSpendSeen, payloadDigest, parsed.scanId,
|
||||
observation.accountIndex, observation.addressIndex, observation.outputPublicKey, observation.globalOutputIndex,
|
||||
observation.amountAtomicXmr, observation.height, page.walletHeight, observation.confirmations,
|
||||
observation.timestamp, observation.unlockTime, observation.locked, observation.doubleSpendSeen, payloadDigest, parsed.scanId,
|
||||
current.rowCount ? Number(current.rows[0].first_observed_at) : parsed.scannedAt]);
|
||||
if (eventType) {
|
||||
await client.query(`INSERT INTO wk_custody.monero_observation_events
|
||||
|
||||
@@ -44,6 +44,19 @@ const transfersResultSchema = z.object({
|
||||
in: z.array(incomingTransferSchema).max(MAX_TRANSFERS).default([]),
|
||||
status: z.literal('OK'),
|
||||
}).passthrough();
|
||||
const incomingOutputSchema = z.object({
|
||||
amount: integerString,
|
||||
global_index: integerString,
|
||||
tx_hash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
subaddr_index: z.object({ major: integerString, minor: integerString }).strict(),
|
||||
unlocked: z.boolean(),
|
||||
block_height: integerString,
|
||||
pubkey: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
}).passthrough();
|
||||
const incomingOutputsResultSchema = z.object({
|
||||
transfers: z.array(incomingOutputSchema).max(MAX_TRANSFERS),
|
||||
status: z.literal('OK'),
|
||||
}).passthrough();
|
||||
|
||||
export type MoneroIncomingObservation = {
|
||||
observationId: string;
|
||||
@@ -56,6 +69,8 @@ export type MoneroIncomingObservation = {
|
||||
unlockTime: string;
|
||||
accountIndex: number;
|
||||
addressIndex: number;
|
||||
outputPublicKey: string;
|
||||
globalOutputIndex: number;
|
||||
locked: boolean;
|
||||
doubleSpendSeen: boolean;
|
||||
};
|
||||
@@ -144,10 +159,10 @@ export async function loadMoneroObserverConfig(env: NodeJS.ProcessEnv = process.
|
||||
/** Restricted read-only surface. No generic RPC method, subaddress creation, transfer or sweep API exists. */
|
||||
/** Restricted RPC surface. Authentication is delegated to an explicitly injected secure transport. */
|
||||
function createWalletRpc(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport,
|
||||
allowedMethods: readonly ('get_height' | 'get_address' | 'get_transfers' | 'validate_address' | 'create_address')[]) {
|
||||
allowedMethods: readonly ('get_height' | 'get_address' | 'get_transfers' | 'incoming_transfers' | 'validate_address' | 'create_address')[]) {
|
||||
const endpoint = validateEndpoint(config.url);
|
||||
// Removed Basic auth header
|
||||
async function rpc(method: 'get_height' | 'get_address' | 'get_transfers' | 'validate_address' | 'create_address', params: Record<string, unknown> = {}) {
|
||||
async function rpc(method: 'get_height' | 'get_address' | 'get_transfers' | 'incoming_transfers' | 'validate_address' | 'create_address', params: Record<string, unknown> = {}) {
|
||||
if (!allowedMethods.includes(method)) throw new Error('Monero wallet-RPC method is not allowed for this client');
|
||||
const id = randomUUID(), controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS); timeout.unref();
|
||||
@@ -193,7 +208,7 @@ function createWalletRpc(config: z.infer<typeof configSchema>, transport: Monero
|
||||
|
||||
/** Read-only observer capability. It deliberately cannot create addresses or move funds. */
|
||||
export function createMoneroWalletObserver(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport) {
|
||||
const rpc = createWalletRpc(config, transport, ['get_height', 'get_address', 'get_transfers']);
|
||||
const rpc = createWalletRpc(config, transport, ['get_height', 'get_address', 'get_transfers', 'incoming_transfers']);
|
||||
return {
|
||||
async verifyMainnetWallet(): Promise<string> {
|
||||
const result = addressResultSchema.parse(await rpc('get_address', { account_index: 0 }));
|
||||
@@ -210,23 +225,70 @@ export function createMoneroWalletObserver(config: z.infer<typeof configSchema>,
|
||||
in: true, filter_by_height: true, min_height: fromHeight, max_height: walletHeight,
|
||||
}));
|
||||
if (transferRaw.in.length >= MAX_TRANSFERS) throw new Error('Monero transfer page reached its safety limit; refusing to advance scan cursor');
|
||||
const incoming = await Promise.all(transferRaw.in.map(async item => {
|
||||
const amount = BigInt(item.amount);
|
||||
if (amount <= 0n || amount > XMR_RPC_UINT64_MAX) throw new Error('Invalid incoming Monero amount');
|
||||
const address = item.address;
|
||||
await validateXmrAddress(address);
|
||||
const height = integer(item.height, BigInt(Number.MAX_SAFE_INTEGER), 'transfer height');
|
||||
if (height < fromHeight || height > walletHeight) throw new Error('Wallet returned transfer outside requested height range');
|
||||
const transferMetadata = new Map<string, z.infer<typeof incomingTransferSchema>>();
|
||||
const keyForIndex = (txid: string, major: number, minor: number) => `${txid}:${major}:${minor}`;
|
||||
for (const item of transferRaw.in) {
|
||||
const key = keyForIndex(item.txid, integer(item.subaddr_index.major, 0xffffffffn, 'account index'),
|
||||
integer(item.subaddr_index.minor, 0xffffffffn, 'address index'));
|
||||
if (transferMetadata.has(key)) throw new Error('Duplicate incoming Monero transaction metadata');
|
||||
transferMetadata.set(key, item);
|
||||
}
|
||||
|
||||
// get_transfers groups amounts by transaction/subaddress. incoming_transfers exposes each
|
||||
// owned output and its public key, which is the stable per-output identity needed for credit idempotency.
|
||||
const requestedIndices = new Map<number, Set<number>>();
|
||||
for (const item of transferRaw.in) {
|
||||
const major = integer(item.subaddr_index.major, 0xffffffffn, 'account index');
|
||||
const minor = integer(item.subaddr_index.minor, 0xffffffffn, 'address index');
|
||||
return { observationId: `${item.txid}:${major}:${minor}`, transactionId: item.txid, address,
|
||||
amountAtomicXmr: amount.toString(), height, confirmations: walletHeight - height + 1,
|
||||
timestamp: integer(item.timestamp, BigInt(Number.MAX_SAFE_INTEGER), 'timestamp'),
|
||||
unlockTime: item.unlock_time, accountIndex: major, addressIndex: minor,
|
||||
locked: item.locked, doubleSpendSeen: item.double_spend_seen };
|
||||
const indices = requestedIndices.get(major) ?? new Set<number>();
|
||||
indices.add(minor); requestedIndices.set(major, indices);
|
||||
}
|
||||
const incoming: MoneroIncomingObservation[] = [];
|
||||
const seenOutputKeys = new Set<string>();
|
||||
for (const [major, indices] of requestedIndices) {
|
||||
const minors = [...indices];
|
||||
for (let offset = 0; offset < minors.length; offset += 64) {
|
||||
const selected = minors.slice(offset, offset + 64);
|
||||
const outputRaw = incomingOutputsResultSchema.parse(await rpc('incoming_transfers', {
|
||||
transfer_type: 'all', account_index: major, subaddr_indices: selected,
|
||||
}));
|
||||
const unique = new Set(incoming.map(row => row.observationId));
|
||||
if (unique.size !== incoming.length) throw new Error('Duplicate Monero wallet output observation');
|
||||
if (outputRaw.transfers.length >= MAX_TRANSFERS) {
|
||||
throw new Error('Monero output page reached its safety limit; refusing to advance scan cursor');
|
||||
}
|
||||
for (const output of outputRaw.transfers) {
|
||||
const outputMajor = integer(output.subaddr_index.major, 0xffffffffn, 'account index');
|
||||
const outputMinor = integer(output.subaddr_index.minor, 0xffffffffn, 'address index');
|
||||
if (outputMajor !== major || !indices.has(outputMinor)) throw new Error('Wallet returned an output outside requested subaddresses');
|
||||
const metadata = transferMetadata.get(keyForIndex(output.tx_hash, outputMajor, outputMinor));
|
||||
if (!metadata) continue;
|
||||
const height = integer(output.block_height, BigInt(Number.MAX_SAFE_INTEGER), 'output height');
|
||||
const expectedHeight = integer(metadata.height, BigInt(Number.MAX_SAFE_INTEGER), 'transfer height');
|
||||
if (height !== expectedHeight || height < fromHeight || height > walletHeight) continue;
|
||||
const amount = BigInt(output.amount);
|
||||
if (amount <= 0n || amount > XMR_RPC_UINT64_MAX) throw new Error('Invalid incoming Monero output amount');
|
||||
const globalOutputIndex = integer(output.global_index, BigInt(Number.MAX_SAFE_INTEGER), 'global output index');
|
||||
const outputPublicKey = output.pubkey;
|
||||
if (seenOutputKeys.has(outputPublicKey)) throw new Error('Duplicate Monero wallet output public key');
|
||||
if (incoming.length >= MAX_TRANSFERS) throw new Error('Monero output page reached its safety limit; refusing to advance scan cursor');
|
||||
seenOutputKeys.add(outputPublicKey);
|
||||
incoming.push({ observationId: `${output.tx_hash}:${outputPublicKey}`, transactionId: output.tx_hash,
|
||||
address: metadata.address, amountAtomicXmr: amount.toString(), height,
|
||||
confirmations: walletHeight - height + 1,
|
||||
timestamp: integer(metadata.timestamp, BigInt(Number.MAX_SAFE_INTEGER), 'timestamp'),
|
||||
unlockTime: metadata.unlock_time, accountIndex: outputMajor, addressIndex: outputMinor,
|
||||
outputPublicKey, globalOutputIndex, locked: !output.unlocked, doubleSpendSeen: metadata.double_spend_seen });
|
||||
}
|
||||
}
|
||||
}
|
||||
const totals = new Map<string, bigint>();
|
||||
for (const output of incoming) {
|
||||
const key = keyForIndex(output.transactionId, output.accountIndex, output.addressIndex);
|
||||
totals.set(key, (totals.get(key) ?? 0n) + BigInt(output.amountAtomicXmr));
|
||||
}
|
||||
for (const [key, metadata] of transferMetadata) {
|
||||
if (totals.get(key) !== BigInt(metadata.amount)) throw new Error('Wallet transaction amount does not match its owned output set');
|
||||
await validateXmrAddress(metadata.address);
|
||||
}
|
||||
return { network: 'mainnet', walletHeight, fromHeight, throughHeight: walletHeight, primaryAddress, incoming };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -87,6 +87,10 @@ export function validateMoneroRpcGatewayRequest(value: unknown): string {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'incoming_transfers':
|
||||
z.object({ transfer_type: z.literal('all'), account_index: uint32,
|
||||
subaddr_indices: z.array(uint32).min(1).max(64) }).strict().parse(params);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Wallet RPC method is not allow-listed');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
|
||||
export const CUSTODY_PG_VERSION = 3;
|
||||
export const CUSTODY_PG_VERSION = 4;
|
||||
export const CUSTODY_WRITE_LOCK = '7140824361';
|
||||
export const CUSTODY_SCHEMA_V1 = `
|
||||
CREATE SCHEMA IF NOT EXISTS wk_custody;
|
||||
@@ -175,9 +175,32 @@ REVOKE ALL ON FUNCTION wk_custody.monero_deposit_allocations_immutable() FROM PU
|
||||
`;
|
||||
export const CUSTODY_SCHEMA_V3_DIGEST = createHash('sha256')
|
||||
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}`).digest('hex');
|
||||
export const CUSTODY_MIGRATION_V4 = `
|
||||
DO $$
|
||||
DECLARE old_constraint text;
|
||||
BEGIN
|
||||
SELECT conname INTO old_constraint FROM pg_constraint
|
||||
WHERE conrelid='wk_custody.monero_transfer_observations'::regclass AND contype='u'
|
||||
AND pg_get_constraintdef(oid)='UNIQUE (wallet_fingerprint, txid, account_index, address_index)';
|
||||
IF old_constraint IS NOT NULL THEN
|
||||
EXECUTE format('ALTER TABLE wk_custody.monero_transfer_observations DROP CONSTRAINT %I',old_constraint);
|
||||
END IF;
|
||||
END $$;
|
||||
ALTER TABLE wk_custody.monero_transfer_observations
|
||||
ADD COLUMN output_public_key text CHECK(output_public_key IS NULL OR output_public_key ~ '^[a-f0-9]{64}$'),
|
||||
ADD COLUMN global_output_index bigint CHECK(global_output_index IS NULL OR global_output_index>=0),
|
||||
ADD CONSTRAINT monero_observation_output_identity CHECK(
|
||||
(output_public_key IS NULL AND global_output_index IS NULL) OR
|
||||
(output_public_key IS NOT NULL AND global_output_index IS NOT NULL AND observation_id=(txid || ':' || output_public_key)));
|
||||
CREATE UNIQUE INDEX monero_observations_output_key
|
||||
ON wk_custody.monero_transfer_observations(wallet_fingerprint,output_public_key)
|
||||
WHERE output_public_key IS NOT NULL;
|
||||
`;
|
||||
export const CUSTODY_SCHEMA_V4_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}`).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_V2_DIGEST;
|
||||
export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V4_DIGEST;
|
||||
|
||||
/** Explicit operator migration; the HTTP runtime never creates or upgrades schema. */
|
||||
export async function migrateCustody(pool: Pool): Promise<void> {
|
||||
@@ -200,6 +223,11 @@ export async function migrateCustody(pool: Pool): Promise<void> {
|
||||
if (current === 2) {
|
||||
await c.query(CUSTODY_MIGRATION_V3);
|
||||
await c.query('INSERT INTO wk_custody.schema_versions VALUES(3,$1)', [CUSTODY_SCHEMA_V3_DIGEST]);
|
||||
current = 3;
|
||||
}
|
||||
if (current === 3) {
|
||||
await c.query(CUSTODY_MIGRATION_V4);
|
||||
await c.query('INSERT INTO wk_custody.schema_versions VALUES(4,$1)', [CUSTODY_SCHEMA_V4_DIGEST]);
|
||||
}
|
||||
if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application');
|
||||
await verifyCustodySchema(c);
|
||||
@@ -213,7 +241,7 @@ export async function verifyCustodySchema(c: PoolClient): Promise<void> {
|
||||
}
|
||||
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];
|
||||
const expected = [CUSTODY_SCHEMA_V1_DIGEST, CUSTODY_SCHEMA_V2_DIGEST, CUSTODY_SCHEMA_V3_DIGEST, CUSTODY_SCHEMA_V4_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');
|
||||
|
||||
@@ -16,7 +16,7 @@ function rpcErrorResponse(id: string, code: number, message: string) {
|
||||
return new Response(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }),
|
||||
{ headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
function fakeTransport(incoming: Record<string, unknown>[] = []): { transport: MoneroFixtureTransport; calls: { method: string; params: unknown; auth: string | null }[] } {
|
||||
function fakeTransport(incoming: Record<string, unknown>[] = [], outputs?: Record<string, unknown>[]): { transport: MoneroFixtureTransport; calls: { method: string; params: unknown; auth: string | null }[] } {
|
||||
const calls: { method: string; params: unknown; auth: string | null }[] = [];
|
||||
const transport: MoneroFixtureTransport = async (url, init) => {
|
||||
assert.equal(url, config.url); assert.equal(init.method, 'POST'); assert.equal(init.redirect, 'error');
|
||||
@@ -26,6 +26,10 @@ function fakeTransport(incoming: Record<string, unknown>[] = []): { transport: M
|
||||
if (body.method === 'get_height') return response(body.id, { height: 100, status: 'OK' });
|
||||
if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' });
|
||||
if (body.method === 'get_transfers') return response(body.id, { in: incoming, status: 'OK' });
|
||||
if (body.method === 'incoming_transfers') return response(body.id, { transfers: outputs ?? incoming.map((item, index) => ({
|
||||
amount: item.amount, global_index: index, tx_hash: item.txid, subaddr_index: item.subaddr_index,
|
||||
unlocked: !(item.locked as boolean), block_height: item.height, pubkey: item.outputPublicKey ?? item.txid,
|
||||
})), status: 'OK' });
|
||||
throw new Error(`unexpected method ${body.method}`);
|
||||
};
|
||||
return { transport, calls };
|
||||
@@ -54,16 +58,62 @@ test('read-only Monero observer preserves uint64 amount text and returns observa
|
||||
assert.equal(page.network, 'mainnet'); assert.equal(page.walletHeight, 100);
|
||||
assert.equal(page.primaryAddress, ADDITIONAL_MARKET_FEE.address);
|
||||
assert.equal(page.incoming.length, 1);
|
||||
assert.deepEqual(page.incoming[0], { observationId: `${'a'.repeat(64)}:0:7`, transactionId: 'a'.repeat(64), address: ADDITIONAL_MARKET_FEE.address,
|
||||
assert.deepEqual(page.incoming[0], { observationId: `${'a'.repeat(64)}:${'a'.repeat(64)}`, transactionId: 'a'.repeat(64), address: ADDITIONAL_MARKET_FEE.address,
|
||||
amountAtomicXmr: '9007199254740993', height: 91, confirmations: 10, timestamp: 1_800_000_000,
|
||||
unlockTime: '0', accountIndex: 0, addressIndex: 7, locked: false, doubleSpendSeen: false });
|
||||
assert.deepEqual(calls.map(call => call.method).sort(), ['get_address', 'get_height', 'get_transfers']);
|
||||
unlockTime: '0', accountIndex: 0, addressIndex: 7, outputPublicKey: 'a'.repeat(64), globalOutputIndex: 0,
|
||||
locked: false, doubleSpendSeen: false });
|
||||
assert.deepEqual(calls.map(call => call.method).sort(), ['get_address', 'get_height', 'get_transfers', 'incoming_transfers']);
|
||||
assert.ok(calls.every(call => call.auth === null), 'Wallet RPC credentials must never be sent as Basic/Digest headers');
|
||||
assert.deepEqual(calls.find(call => call.method === 'get_transfers')?.params,
|
||||
{ in: true, filter_by_height: true, min_height: 90, max_height: 100 });
|
||||
assert.deepEqual(calls.find(call => call.method === 'incoming_transfers')?.params,
|
||||
{ transfer_type: 'all', account_index: 0, subaddr_indices: [7] });
|
||||
assert.equal(Object.keys(observer).some(key => /transfer|sweep|send|credit/i.test(key)), false);
|
||||
});
|
||||
|
||||
test('observer distinguishes multiple outputs in one transaction by owned output public key', async () => {
|
||||
const txid = 'a'.repeat(64), firstKey = 'b'.repeat(64), secondKey = 'c'.repeat(64);
|
||||
const { transport } = fakeTransport([{ txid, address: ADDITIONAL_MARKET_FEE.address, amount: '30', height: 91,
|
||||
timestamp: 1_800_000_000, unlock_time: 0, locked: false, double_spend_seen: false,
|
||||
subaddr_index: { major: 0, minor: 7 } }], [
|
||||
{ tx_hash: txid, pubkey: firstKey, amount: '10', global_index: 42, block_height: 91, unlocked: true,
|
||||
subaddr_index: { major: 0, minor: 7 } },
|
||||
{ tx_hash: txid, pubkey: secondKey, amount: '20', global_index: 43, block_height: 91, unlocked: true,
|
||||
subaddr_index: { major: 0, minor: 7 } },
|
||||
]);
|
||||
const page = await createMoneroWalletObserver(config, transport).scanIncoming(90);
|
||||
assert.deepEqual(page.incoming.map(row => [row.observationId, row.amountAtomicXmr, row.globalOutputIndex]), [
|
||||
[`${txid}:${firstKey}`, '10', 42], [`${txid}:${secondKey}`, '20', 43],
|
||||
]);
|
||||
});
|
||||
|
||||
test('observer fails closed on missing, duplicated, or misattributed incoming outputs', async () => {
|
||||
const txid = 'd'.repeat(64), firstKey = 'e'.repeat(64);
|
||||
const transfer = { txid, address: ADDITIONAL_MARKET_FEE.address, amount: '2', height: 91,
|
||||
timestamp: 1_800_000_000, unlock_time: 0, locked: false, double_spend_seen: false,
|
||||
subaddr_index: { major: 0, minor: 7 } };
|
||||
const output = { tx_hash: txid, pubkey: firstKey, amount: '1', global_index: 42, block_height: 91,
|
||||
unlocked: true, subaddr_index: { major: 0, minor: 7 } };
|
||||
|
||||
const incomplete = fakeTransport([transfer], [output]);
|
||||
await assert.rejects(createMoneroWalletObserver(config, incomplete.transport).scanIncoming(90), /transaction amount/);
|
||||
|
||||
const duplicate = fakeTransport([transfer], [output, { ...output, global_index: 43 }]);
|
||||
await assert.rejects(createMoneroWalletObserver(config, duplicate.transport).scanIncoming(90), /Duplicate Monero wallet output public key/);
|
||||
|
||||
const misattributed = fakeTransport([transfer], [{ ...output, subaddr_index: { major: 0, minor: 8 } }]);
|
||||
await assert.rejects(createMoneroWalletObserver(config, misattributed.transport).scanIncoming(90), /outside requested subaddresses/);
|
||||
|
||||
const malformed: MoneroFixtureTransport = async (_url, init) => {
|
||||
const body = JSON.parse(init.body as string) as { id: string; method: string };
|
||||
if (body.method === 'get_height') return response(body.id, { height: 100, status: 'OK' });
|
||||
if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' });
|
||||
if (body.method === 'get_transfers') return response(body.id, { in: [transfer], status: 'OK' });
|
||||
return response(body.id, { transfers: [{ ...output, pubkey: 'not-a-public-key' }], status: 'OK' });
|
||||
};
|
||||
await assert.rejects(createMoneroWalletObserver(config, malformed).scanIncoming(90));
|
||||
});
|
||||
|
||||
test('deposit-address RPC client is narrowly allow-listed and creates only one verified subaddress', async () => {
|
||||
const subaddress = await syntheticSubaddress();
|
||||
const calls: { method: string; params: unknown }[] = [];
|
||||
@@ -138,6 +188,12 @@ test('mTLS gateway allowlists only bounded observation and one-subaddress alloca
|
||||
assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('get_transfers', {
|
||||
in: true, filter_by_height: true, min_height: 0, max_height: 100,
|
||||
})));
|
||||
assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('incoming_transfers', {
|
||||
transfer_type: 'all', account_index: 0, subaddr_indices: [2, 3],
|
||||
})));
|
||||
assert.throws(() => validateMoneroRpcGatewayRequest(request('incoming_transfers', {
|
||||
transfer_type: 'all', account_index: 0, subaddr_indices: [2], key_image: true,
|
||||
})));
|
||||
for (const method of ['transfer', 'transfer_split', 'sweep_all', 'relay_tx', 'get_tx_key', 'query_key', 'open_wallet', 'get_balance']) {
|
||||
assert.throws(() => validateMoneroRpcGatewayRequest(request(method)), /not allow-listed/);
|
||||
}
|
||||
@@ -173,8 +229,11 @@ test('observer rejects unsafe amounts, duplicate observations, malformed transfe
|
||||
const body = JSON.parse(init.body as string) as { id: string; method: string };
|
||||
if (body.method === 'get_height') return response(body.id, { height: 100, status: 'OK' });
|
||||
if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' });
|
||||
return new Response(`{"jsonrpc":"2.0","id":"${body.id}","result":{"status":"OK","in":[{"txid":"${'b'.repeat(64)}","address":"${ADDITIONAL_MARKET_FEE.address}","amount":18446744073709551616,"height":91,"timestamp":1,"unlock_time":0,"locked":false,"double_spend_seen":false,"subaddr_index":{"major":0,"minor":1}}]}}`,
|
||||
if (body.method === 'get_transfers') return new Response(`{"jsonrpc":"2.0","id":"${body.id}","result":{"status":"OK","in":[{"txid":"${'b'.repeat(64)}","address":"${ADDITIONAL_MARKET_FEE.address}","amount":18446744073709551616,"height":91,"timestamp":1,"unlock_time":0,"locked":false,"double_spend_seen":false,"subaddr_index":{"major":0,"minor":1}}]}}`,
|
||||
{ headers: { 'content-type': 'application/json' } });
|
||||
return response(body.id, { status: 'OK', transfers: [{ tx_hash: 'b'.repeat(64), pubkey: 'c'.repeat(64),
|
||||
amount: 18446744073709551616n.toString(), global_index: 1, block_height: 91, unlocked: true,
|
||||
subaddr_index: { major: 0, minor: 1 } }] });
|
||||
};
|
||||
await assert.rejects(createMoneroWalletObserver(config, unsafeTransport).scanIncoming(90), /amount/);
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ test('custody schema v1 upgrades additively through v3 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]);
|
||||
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4]);
|
||||
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']) {
|
||||
@@ -229,7 +229,7 @@ test('custody schema v2 upgrades explicitly to v3 address-allocation registry',
|
||||
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]);
|
||||
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4]);
|
||||
assert.equal((await admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='v2-marker'")).rows[0].value, 'preserve');
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); await admin.end(); }
|
||||
@@ -334,11 +334,11 @@ test('Monero scan persistence is idempotent, reorg-visible and never posts custo
|
||||
await assert.rejects(grantMoneroObserverRuntime(f.admin, 'custody_runtime'), /separate role/);
|
||||
const runtime = new Pool({ ...f.runtime, user: 'monero_observer' });
|
||||
const custodyRuntime = new Pool(f.runtime), wallet = 'd'.repeat(64), scan1 = randomUUID();
|
||||
const txid = 'e'.repeat(64), observationId = `${txid}:0:4`;
|
||||
const txid = 'e'.repeat(64), outputPublicKey = 'f'.repeat(64), observationId = `${txid}:${outputPublicKey}`;
|
||||
const observation = { observationId, transactionId: txid, amountAtomicXmr: '9007199254740993', height: 95,
|
||||
address: ADDITIONAL_MARKET_FEE.address,
|
||||
confirmations: 6, timestamp: 1_800_000_000, unlockTime: '0', accountIndex: 0, addressIndex: 4,
|
||||
locked: false, doubleSpendSeen: false };
|
||||
outputPublicKey, globalOutputIndex: 123, locked: false, doubleSpendSeen: false };
|
||||
const page = { network: 'mainnet' as const, walletHeight: 100, fromHeight: 0, throughHeight: 100,
|
||||
primaryAddress: ADDITIONAL_MARKET_FEE.address, incoming: [observation] };
|
||||
try {
|
||||
@@ -369,12 +369,29 @@ test('Monero scan persistence is idempotent, reorg-visible and never posts custo
|
||||
incoming: [{ ...observation, confirmations: 26 }] } });
|
||||
assert.equal(reappeared.reappeared, 1);
|
||||
|
||||
const observed = await runtime.query(`SELECT amount_atomic_xmr::text AS amount,monero_address,currently_present FROM wk_custody.monero_transfer_observations
|
||||
const observed = await runtime.query(`SELECT amount_atomic_xmr::text AS amount,monero_address,output_public_key,global_output_index,currently_present FROM wk_custody.monero_transfer_observations
|
||||
WHERE wallet_fingerprint=$1 AND observation_id=$2`, [wallet, observationId]);
|
||||
assert.equal(observed.rows[0].amount, '9007199254740993'); assert.equal(observed.rows[0].currently_present, true);
|
||||
assert.equal(observed.rows[0].monero_address, ADDITIONAL_MARKET_FEE.address);
|
||||
assert.equal(observed.rows[0].output_public_key, outputPublicKey); assert.equal(Number(observed.rows[0].global_output_index), 123);
|
||||
|
||||
const reorgMovedOutput = await persistMoneroObservationScan(runtime, { scanId: randomUUID(), walletFingerprint: wallet,
|
||||
expectedScanHeight: 120, scannedAt: 1_800_000_325,
|
||||
page: { ...page, fromHeight: 90, walletHeight: 121, throughHeight: 121,
|
||||
incoming: [{ ...observation, height: 96, confirmations: 26 }] } });
|
||||
assert.equal(reorgMovedOutput.changed, 1, 'same output re-included at a different height must be visible as changed evidence');
|
||||
const updated = await runtime.query(`SELECT block_height,output_public_key FROM wk_custody.monero_transfer_observations
|
||||
WHERE wallet_fingerprint=$1 AND observation_id=$2`, [wallet, observationId]);
|
||||
assert.equal(Number(updated.rows[0].block_height), 96);
|
||||
assert.equal(updated.rows[0].output_public_key, outputPublicKey, 'output identity survives a reorg/re-inclusion');
|
||||
|
||||
await assert.rejects(persistMoneroObservationScan(runtime, { scanId: randomUUID(), walletFingerprint: wallet,
|
||||
expectedScanHeight: 121, scannedAt: 1_800_000_350,
|
||||
page: { ...page, fromHeight: 90, walletHeight: 121, throughHeight: 121,
|
||||
incoming: [{ ...observation, transactionId: 'a'.repeat(64), observationId: `${'a'.repeat(64)}:${outputPublicKey}`, height: 96, confirmations: 26 }] } }),
|
||||
/output key|unique/i, 'one wallet output key cannot be assigned to two transaction identities');
|
||||
const eventTypes = await runtime.query('SELECT event_type FROM wk_custody.monero_observation_events ORDER BY sequence');
|
||||
assert.deepEqual(eventTypes.rows.map(row => row.event_type), ['seen', 'not-seen', 'reappeared']);
|
||||
assert.deepEqual(eventTypes.rows.map(row => row.event_type), ['seen', 'not-seen', 'reappeared', 'changed']);
|
||||
await assert.rejects(runtime.query('UPDATE wk_custody.monero_observation_events SET event_type=\'seen\''));
|
||||
await assert.rejects(runtime.query('DELETE FROM wk_custody.monero_observation_scans'));
|
||||
await assert.rejects(runtime.query('SELECT count(*) FROM wk_custody.journals'), /permission denied/);
|
||||
|
||||
Reference in New Issue
Block a user