Read-only Monero wallet-RPC observer

This commit is contained in:
2026-10-21 13:05:57 -04:00
parent c07f3a737b
commit 79abaa78f2
6 changed files with 314 additions and 4 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ Status: implemented local infrastructure, **not full M2 acceptance or production
## Tests and measurements
The final Docker test-target build passed TypeScript/Vite compilation. All **178 offline tests passed, zero failures/skips**, including native RandomX multi-process HTTP/peer/restart and backup rehearsal. The focused market suite passed **27 tests**, and the disposable PostgreSQL suite passed **31/31**.
The final Docker test-target build passed TypeScript/Vite compilation. All **182 offline tests passed, zero failures/skips**, including native RandomX multi-process HTTP/peer/restart, backup rehearsal and four offline Monero observer contract/security tests. The focused market suite passed **27 tests**, and the disposable PostgreSQL suite passed **31/31** in the previous run.
- indexed ownership/sequence versus full replay for transfers, multi-card trades and forks;
- append/disconnect/reconnect ordering, side-branch/mempool exclusion, bounded defensive-copy pages and durable restart;
+2
View File
@@ -19,6 +19,8 @@ The operator has authorized proceeding with implementation instead of the previo
**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.
**Read-only Monero observer prototype:** [server/market/monero-observer.ts](../server/market/monero-observer.ts) defines a narrow mainnet wallet-RPC client for primary-address network check and bounded incoming-transfer scans. It requires an explicit protected config file and observer opt-in, uses authenticated requests with strict HTTPS/loopback-only HTTP, no redirects, bounded response/deadline, JSON-RPC request-ID validation and lossless JSON integer handling. The module exposes no subaddress creation, credit posting, transfer construction, relay, sweep or withdrawal operation and is not wired into application startup/routes. Fixture tests cover uint64 preservation, malformed/oversized responses, wrong RPC IDs and unsafe endpoint schemes. No wallet endpoint, credentials or funds were accessed. This is preparatory read-only plumbing, not live deposit crediting or an approved mainnet switch.
Validation: TypeScript/Vite build and 144 offline tests passed in the serial native-RandomX test run; the initial parallel run encountered a peer-gossip timeout. Browser smoke verified a disposable wallet's signed test credit, withdrawal hold and cancellation restoring the exact balance, plus a 390px viewport without horizontal overflow. No real funds were used. Tests and remaining acceptance gates are below; this is not launch readiness.
**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.
+3 -1
View File
@@ -2,7 +2,7 @@
The [production delivery plan](production-plan.md) defines the next engineering milestones, acceptance criteria and operator decisions. Its XMR-native pricing direction supersedes earlier production USD-price plans; existing sandbox behavior and historical order terms remain unchanged until an integrated versioned migration. Trustless settlement is a research gate, and any host-custodial fallback requires explicit approval.
**Current decision: HOLD / NO-GO.** Review [the security findings](security-review.md) before scheduling production. The automated preflight intentionally exits nonzero while known production blockers remain. An environment variable, successful container startup or genesis timestamp cannot substitute for closing these findings.
**Current decision: HOLD / NO-GO for mainnet, valuable assets, or payments.** The production plan now distinguishes a proposed non-monetary testnet beta hosted from the operator's apartment. That beta would not be mainnet production or permission to custody funds. Review [the security findings](security-review.md) before any public exposure. The automated preflight intentionally exits nonzero for mainnet while required blockers remain. An environment variable, successful container startup or genesis timestamp cannot substitute for closing findings.
[Stage-1 node resource controls and local load tooling](node-resources.md) are implemented. This is partial remediation, not capacity certification or closure of the public-node hold point.
@@ -25,6 +25,8 @@ The runtime image can execute the entry point with `--launch-preflight` as well;
## 2. Evidence register — all required before production sign-off
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.
[Stage-5 SHKeeper prototype](shkeeper-settlement.md) is fixture-only and disconnected from the public market. Production payment activation, real processor verification, custody/refund policy, journal/backup/monitor integration and operator reconciliation remain missing. It cannot justify closing the real-money hold point.
[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.
+20 -2
View File
@@ -6,7 +6,7 @@ Status: active engineering plan; **not launch or payment authorization**. Update
| Topic | Decision / boundary |
| --- | --- |
| Initial deployment | One operator, the project owner, in the United States; a capable x86 server and low initial user volume. Design for independently operated markets and larger volumes, without claiming capacity before measurement. |
| Initial MVP hosting | One operator (the project owner) in a US apartment, one x86 host and low initial volume. Assume a single failure domain, no on-call team and no HA. Confirm ISP terms, static/reachable ingress or CGNAT, power/network reliability, DDoS exposure and off-site recovery before choosing this over a small managed host. Do not imply independent-host security or availability. |
| Settlement architecture | Trustless and arbitrated routes were investigated; the operator has now authorized **implementing the custodial pooled-balance market**. [ADR 002](custodial-market-design.md) tracks the first isolated sandbox accounting/UI increment and remaining treasury/consensus work. Implementation authorization is not live deposit, key access, consensus activation or launch authorization. |
| Host responsibility | Host responsibility, reserves and exposure caps are the selected policy direction. Exact liability, amounts and funding remain undecided. This is not authorization for custody and does not make settlement trustless or guarantee solvency. The author fee does not make the author a guarantor of independent markets. |
| First live market | Fixed-price sales first. Live auctions remain disabled until their funding, nonpayment and abuse controls pass a separate release gate. Existing sandbox auctions can remain clearly labeled. |
@@ -20,6 +20,24 @@ Status: active engineering plan; **not launch or payment authorization**. Update
**Current design deliverable:** [custodial market design](custodial-market-design.md). Read the workstreams below with this update: pooled customer liabilities replace per-order incoming invoices and external sale payouts in the proposed architecture. Earlier trustless-first research gates are historical, not a prerequisite to investigating custody. Exact ledger, withdrawal, fee, custody/legal and host-attested NFT consensus rules still require approval and evidence; the existing mock domain cannot authorize real balance debits.
**Read-only XMR groundwork:** a disabled-by-default Monero wallet-RPC observer client is implemented for mainnet address verification and bounded incoming-transfer scans, with protected config-file loading, transport limits and lossless uint64 parsing. It is deliberately not started by the server and cannot create addresses, post credits or spend funds. It has offline fixture coverage only. No actual RPC endpoint, wallet credential, address allocation or deposit was configured. Remaining deposit milestone: backed-up non-reusable subaddress allocation, durable output observation/inclusion/reorg history, exact idempotent ledger credit rules, controlled testnet/stagenet rehearsal, and independent accounting/security review before any real deposit can be accepted.
## 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.
If “MVP” instead means paid fixed-price sales or valuable mainnet NFTs, do not use the reduced beta checklist: P0/P3/P4/P5/P6 and the full NO-GO hold points still apply. Small volume, one host, mock confirmations, or a low exposure cap do not substitute for a reviewed lock/settlement protocol, real deposit/withdrawal reconciliation, funded loss policy, and legal/operational acceptance. In particular, the active v6 consensus still rejects the sale-lock candidate.
For the non-monetary beta, the remaining engineering/operations sequence is:
1. Freeze scope and disclosures: testnet identity, no-value policy, marketplace disabled for valuable cards, experimental wallet/data-loss notice, support contact, abuse/report channel and clear stop conditions.
2. Prove the apartment can safely serve traffic: ISP permission and reachable ingress/CGNAT, DNS/TLS renewal, trusted proxy and no direct backend access, firewall, patched host, restricted SSH/admin, power/network monitoring, and a documented way to stop accepting traffic. If these cannot be demonstrated, use a managed host rather than weakening controls.
3. Repeat load/reorg/restart/restore tests on the exact release candidate. The current one-shot 160-request benchmark is preliminary; define request mix/rate, run repeated trials with concurrent API traffic during reconciliation/mining/peer sync, set explicit latency/error/resource limits, and do a clean isolated restore. Keep assets valueless until consensus and recovery review passes.
4. Freeze artifacts and get independent review appropriate to the exposure. Pin image/base/native/dependency digests, generate SBOM and scans, verify the launch preflight remains NO-GO for mainnet/live payments, and record the operator’s limited beta go/no-go separately from mainnet/payment authorization.
5. Operate conservatively: encrypted off-host backups and restore drill, disk/WAL and peer/tip/reorg/worker alerts delivered somewhere other than the apartment host, patch/incident/rollback procedure, and a person responsible for responding. A single operator cannot promise 24/7 response or HA.
This scope is a proposed release boundary, not an approval to deploy. The operator must explicitly accept the beta’s non-monetary/experimental limits and sign off on apartment-host evidence before a public beta. No real funds, valuable NFT launch, mainnet activation, or consensus identity change is authorized by this proposal.
## 2. Resolve the cross-chain requirement before selecting architecture
### What “confirmed before on-chain completion” requires
@@ -63,7 +81,7 @@ Acceptance: exact-amount property/boundary tests, versioned signature tests, zer
Can run alongside P0/P1. Addresses SEC-01/SEC-06 rather than adding more fixed caps.
**Implemented M2 increments, 2026-10-20–21:** [canonical indexes and durable market observation](canonical-indexes.md). Canonical ownership/transaction SQL indexes now serve NFT, deck, custody availability and mock-market queries; per-block undo and anchored connect/disconnect events commit with ledger updates. Market checkpoint/reorg holds persist atomically and bounded catch-up blocks new mutations. Order/listing reconciliation advances in durable, stable-ID keyset batches of at most 32 records per table, resumes after restart, and re-fences when the chain tip/revision changes. The HTTP process now uses a single worker thread to select/evaluate bounded row batches, read canonical facts using its own read-only Ledger connection, and atomically persist changes with cursor progress. Final TypeScript/Vite build and **178 offline tests passed**; focused market tests passed 27/27; the disposable PostgreSQL suite passed 31/31. Regressions verify a main-loop timer runs during worker backlog and that a second server process cannot start against the same data directory. A synthetic 10,000 confirmed-delivery run with 160 loopback health/market requests measured worker HTTP p50/p95/max of 285/322/324 ms versus 1,416/2,844/2,971 ms synchronously. This single local run is preliminary (no repeated confidence intervals or realistic request arrivals), not an SLO. Main-thread event-page acquisition/post-batch fencing, realistic concurrent HTTP load, multi-process fencing beyond the existing exclusive directory lock, target hardware/data, production financial storage/recovery, retained-DAG capacity and operator SLO decisions remain open.
**Implemented M2 increments, 2026-10-20–21:** [canonical indexes and durable market observation](canonical-indexes.md). Canonical ownership/transaction SQL indexes now serve NFT, deck, custody availability and mock-market queries; per-block undo and anchored connect/disconnect events commit with ledger updates. Market checkpoint/reorg holds persist atomically and bounded catch-up blocks new mutations. Order/listing reconciliation advances in durable, stable-ID keyset batches of at most 32 records per table, resumes after restart, and re-fences when the chain tip/revision changes. The HTTP process now uses a single worker thread to select/evaluate bounded row batches, read canonical facts using its own read-only Ledger connection, and atomically persist changes with cursor progress. The offline suite has **182 passing tests**; focused market tests passed 27/27; the disposable PostgreSQL suite passed 31/31 in the previous run. Regressions verify a main-loop timer runs during worker backlog and that a second server process cannot start against the same data directory. A synthetic 10,000 confirmed-delivery run with 160 loopback health/market requests measured worker HTTP p50/p95/max of 285/322/324 ms versus 1,416/2,844/2,971 ms synchronously. This single local run is preliminary (no repeated confidence intervals or realistic request arrivals), not an SLO. Main-thread event-page acquisition/post-batch fencing, realistic concurrent HTTP load, multi-process fencing beyond the existing exclusive directory lock, target hardware/data, production financial storage/recovery, retained-DAG capacity and operator SLO decisions remain open.
**Next financial-storage increment:** [ADR 003 — PostgreSQL](postgres-financial-storage.md) selects and integrates the custody SQL backend with existing HTTP callers, exact constrained accounting, explicit migration/runtime roles, source-freezing SQLite import and dump/restore rehearsal. The adapter remains on the fictitious sandbox domain; it is not a production deposit/withdrawal worker. SQLite node-only backup deliberately refuses PostgreSQL-bound financial state. Production hardware/SLO tests, coordinated backup/PITR/failover, real payment schema/worker controls and independent review remain open, so this does not close M2/M4 or authorize funds.
+200
View File
@@ -0,0 +1,200 @@
import { randomUUID } from 'node:crypto';
import { lstat, readFile } from 'node:fs/promises';
import { isIP } from 'node:net';
import { z } from 'zod';
import { parseAtomicXmr, XMR_RPC_UINT64_MAX } from '../../shared/xmr-amount.ts';
import { validateXmrAddress } from './xmr.ts';
const MAX_CONFIG_BYTES = 4096;
const MAX_RESPONSE_BYTES = 1024 * 1024;
const RPC_TIMEOUT_MS = 5000;
const MAX_TRANSFERS = 4096;
const configSchema = z.object({
network: z.literal('mainnet'),
url: z.string().min(1).max(2048),
username: z.string().min(1).max(256),
password: z.string().min(1).max(1024),
}).strict();
const rpcEnvelopeSchema = z.object({
jsonrpc: z.literal('2.0'), id: z.string().uuid(), result: z.record(z.string(), z.unknown()),
}).strict();
const integerString = z.string().regex(/^(0|[1-9]\d{0,19})$/);
const heightResultSchema = z.object({ height: integerString, status: z.literal('OK') }).passthrough();
const addressResultSchema = z.object({ address: z.string().length(95), addresses: z.array(z.unknown()).optional(), status: z.literal('OK') }).passthrough();
const incomingTransferSchema = z.object({
txid: z.string().regex(/^[a-f0-9]{64}$/),
amount: integerString,
height: integerString,
timestamp: integerString,
unlock_time: integerString,
locked: z.boolean(),
double_spend_seen: z.boolean(),
subaddr_index: z.object({ major: integerString, minor: integerString }).strict(),
}).passthrough();
const transfersResultSchema = z.object({
in: z.array(incomingTransferSchema).max(MAX_TRANSFERS).default([]),
status: z.literal('OK'),
}).passthrough();
export type MoneroIncomingObservation = {
observationId: string;
transactionId: string;
amountAtomicXmr: string;
height: number;
confirmations: number;
timestamp: number;
unlockTime: string;
accountIndex: number;
addressIndex: number;
locked: boolean;
doubleSpendSeen: boolean;
};
export type MoneroObservationPage = {
network: 'mainnet';
walletHeight: number;
fromHeight: number;
throughHeight: number;
primaryAddress: string;
incoming: MoneroIncomingObservation[];
};
export type MoneroFixtureTransport = (url: string, init: RequestInit) => Promise<Response>;
function integer(value: string, maximum: bigint, name: string): number {
const parsed = BigInt(value);
if (parsed > maximum || parsed > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`Invalid Monero ${name}`);
return Number(parsed);
}
/** JSON.parse rounds uint64 amounts. Convert JSON numeric tokens to strings before parsing,
* preserving exact wallet-RPC integer text so all monetary values can be checked as bigint. */
function parseLosslessJson(text: string): unknown {
let output = '', inString = false, escaped = false;
for (let i = 0; i < text.length;) {
const char = text[i];
if (inString) {
output += char;
if (escaped) escaped = false;
else if (char === '\\') escaped = true;
else if (char === '"') inString = false;
i++;
continue;
}
if (char === '"') { inString = true; output += char; i++; continue; }
if (char === '-' || (char >= '0' && char <= '9')) {
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i));
if (!match) throw new Error('Invalid JSON numeric token');
output += JSON.stringify(match[0]); i += match[0].length; continue;
}
output += char; i++;
}
return JSON.parse(output) as unknown;
}
function validateEndpoint(value: string): URL {
let url: URL;
try { url = new URL(value); } catch { throw new Error('Invalid Monero wallet-RPC endpoint'); }
if (url.username || url.password || url.search || url.hash || url.pathname !== '/json_rpc') {
throw new Error('Monero wallet-RPC endpoint must be a bare /json_rpc URL');
}
const loopback = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname) || isIP(url.hostname) === 6 && url.hostname === '[::1]';
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
throw new Error('Wallet RPC requires HTTPS, except loopback-only HTTP');
}
if (!url.port && !['http:', 'https:'].includes(url.protocol)) throw new Error('Invalid Monero wallet-RPC port');
return url;
}
export async function loadMoneroObserverConfig(env: NodeJS.ProcessEnv = process.env) {
if (env.WIKIDECK_MONERO_OBSERVER !== '1') throw new Error('Monero observer is disabled');
const path = env.WIKIDECK_MONERO_WALLET_RPC_CONFIG_FILE;
if (!path) throw new Error('Monero wallet-RPC config file is required');
try {
const info = await lstat(path);
if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > MAX_CONFIG_BYTES) throw new Error();
if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error();
const parsed = configSchema.parse(JSON.parse(await readFile(path, 'utf8')));
validateEndpoint(parsed.url);
if (/[\r\n\x00]/.test(parsed.username) || /[\r\n\x00]/.test(parsed.password)) throw new Error();
return parsed;
} catch {
throw new Error('Invalid or inaccessible Monero wallet-RPC config; credentials must be in a protected regular file');
}
}
/** Restricted read-only surface. No generic RPC method, subaddress creation, transfer or sweep API exists. */
export function createMoneroWalletObserver(config: z.infer<typeof configSchema>, transport: MoneroFixtureTransport = fetch) {
const endpoint = validateEndpoint(config.url);
const authorization = `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`;
async function rpc(method: 'get_height' | 'get_address' | 'get_transfers', params: Record<string, unknown> = {}) {
const id = randomUUID(), controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS); timeout.unref();
const abort = new Promise<never>((_, reject) => controller.signal.addEventListener('abort',
() => reject(new Error('Monero wallet-RPC deadline exceeded')), { once: true }));
const operation = (async () => {
let response: Response;
try {
response = await transport(endpoint.href, { method: 'POST', redirect: 'error', signal: controller.signal,
headers: { authorization, accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }) });
} catch { throw new Error('Monero wallet-RPC request failed'); }
if (!response.ok || response.redirected || !response.body ||
!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '')) {
await response.body?.cancel(); throw new Error('Monero wallet-RPC response rejected');
}
const contentLength = response.headers.get('content-length');
if (contentLength !== null && (!/^\d+$/.test(contentLength) || Number(contentLength) > MAX_RESPONSE_BYTES)) {
await response.body.cancel(); throw new Error('Monero wallet-RPC response exceeds size limit');
}
const reader = response.body.getReader(), chunks: Uint8Array[] = []; let size = 0;
try {
for (;;) {
const part = await reader.read(); if (part.done) break;
size += part.value.byteLength;
if (size > MAX_RESPONSE_BYTES) throw new Error('Monero wallet-RPC response exceeds size limit');
chunks.push(part.value);
}
} finally { await reader.cancel(); reader.releaseLock(); }
let raw: unknown;
try { raw = parseLosslessJson(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))); }
catch { throw new Error('Monero wallet-RPC returned malformed JSON'); }
const envelope = rpcEnvelopeSchema.parse(raw);
if (envelope.id !== id) throw new Error('Monero wallet-RPC response ID mismatch');
return envelope.result;
})();
try { return await Promise.race([operation, abort]); }
finally { clearTimeout(timeout); }
}
return {
async verifyMainnetWallet(): Promise<string> {
const result = addressResultSchema.parse(await rpc('get_address', { account_index: 0 }));
return validateXmrAddress(result.address);
},
async scanIncoming(fromHeight: number): Promise<MoneroObservationPage> {
if (!Number.isSafeInteger(fromHeight) || fromHeight < 0) throw new Error('Invalid Monero scan height');
const [heightRaw, primaryAddress] = await Promise.all([
rpc('get_height'), this.verifyMainnetWallet(),
]);
const walletHeight = integer(heightResultSchema.parse(heightRaw).height, BigInt(Number.MAX_SAFE_INTEGER), 'height');
if (fromHeight > walletHeight) throw new Error('Monero observer cursor is ahead of wallet height');
const transferRaw = transfersResultSchema.parse(await rpc('get_transfers', {
in: true, filter_by_height: true, min_height: fromHeight, max_height: walletHeight,
}));
const incoming = transferRaw.in.map(item => {
const amount = BigInt(item.amount);
if (amount <= 0n || amount > XMR_RPC_UINT64_MAX) throw new Error('Invalid incoming Monero amount');
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 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,
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 unique = new Set(incoming.map(row => row.observationId));
if (unique.size !== incoming.length) throw new Error('Duplicate Monero wallet output observation');
return { network: 'mainnet', walletHeight, fromHeight, throughHeight: walletHeight, primaryAddress, incoming };
},
};
}
+88
View File
@@ -0,0 +1,88 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts';
import { createMoneroWalletObserver, type MoneroFixtureTransport } from '../server/market/monero-observer.ts';
const config = { network: 'mainnet' as const, url: 'http://127.0.0.1:18083/json_rpc', username: 'fixture', password: 'not-a-live-secret' };
function response(id: string, result: Record<string, unknown>, status = 200, type = 'application/json') {
return new Response(JSON.stringify({ jsonrpc: '2.0', id, result }), { status, headers: { 'content-type': type } });
}
function fakeTransport(incoming: 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');
assert.ok(init.signal);
const body = JSON.parse(init.body as string) as { id: string; method: string; params: unknown };
calls.push({ method: body.method, params: body.params, auth: new Headers(init.headers).get('authorization') });
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' });
throw new Error(`unexpected method ${body.method}`);
};
return { transport, calls };
}
test('read-only Monero observer preserves uint64 amount text and returns observations without crediting balances', async () => {
const { transport, calls } = fakeTransport([{ txid: 'a'.repeat(64), amount: '9007199254740993', height: 91,
timestamp: 1_800_000_000, unlock_time: 0, locked: false, double_spend_seen: false,
subaddr_index: { major: 0, minor: 7 }, address: ADDITIONAL_MARKET_FEE.address }]);
const observer = createMoneroWalletObserver(config, transport);
const page = await observer.scanIncoming(90);
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),
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']);
assert.ok(calls.every(call => call.auth === `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`));
assert.deepEqual(calls.find(call => call.method === 'get_transfers')?.params,
{ in: true, filter_by_height: true, min_height: 90, max_height: 100 });
assert.equal(Object.keys(observer).some(key => /transfer|sweep|send|credit/i.test(key)), false);
});
test('observer refuses non-loopback plaintext, URL credentials and malformed height ranges', async () => {
assert.throws(() => createMoneroWalletObserver({ ...config, url: 'http://wallet.example/json_rpc' }), /HTTPS/);
assert.throws(() => createMoneroWalletObserver({ ...config, url: 'https://user:pass@wallet.example/json_rpc' }), /bare/);
const { transport } = fakeTransport(); const observer = createMoneroWalletObserver(config, transport);
await assert.rejects(observer.scanIncoming(-1), /height/);
await assert.rejects(observer.scanIncoming(101), /ahead/);
});
test('observer rejects unsafe amounts, duplicate observations, malformed transfers and wrong RPC identity', async () => {
const unsafeTransport: 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' });
return new Response(`{"jsonrpc":"2.0","id":"${body.id}","result":{"status":"OK","in":[{"txid":"${'b'.repeat(64)}","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' } });
};
await assert.rejects(createMoneroWalletObserver(config, unsafeTransport).scanIncoming(90), /amount/);
const duplicate = { txid: 'c'.repeat(64), amount: '1', height: 91, timestamp: 1, unlock_time: 0,
locked: true, double_spend_seen: false, subaddr_index: { major: 0, minor: 1 } };
const duplicateFixture = fakeTransport([duplicate, duplicate]);
await assert.rejects(createMoneroWalletObserver(config, duplicateFixture.transport).scanIncoming(90), /Duplicate/);
const wrongId: MoneroFixtureTransport = async (_url, init) => {
JSON.parse(init.body as string) as { id: string };
return response('00000000-0000-4000-8000-000000000001', { height: 100, status: 'OK' });
};
await assert.rejects(createMoneroWalletObserver(config, wrongId).scanIncoming(90), /ID mismatch/);
});
test('observer refuses redirects, wrong content type and oversized RPC response without exposing credentials', async () => {
const badResponses = [new Response('{}', { status: 302, headers: { 'content-type': 'application/json' } }),
new Response('<html/>', { headers: { 'content-type': 'text/html' } }),
new Response('x'.repeat(1024 * 1024 + 1), { headers: { 'content-type': 'application/json' } })];
for (const bad of badResponses) {
let call = 0;
const transport: MoneroFixtureTransport = async () => {
call++;
if (call === 1) return response('unused', { height: 100, status: 'OK' });
return bad;
};
await assert.rejects(createMoneroWalletObserver(config, transport).scanIncoming(90), error =>
error instanceof Error && !error.message.includes(config.password));
}
});