From 0ad8b46bf98b493c36b3005670e8093e4a518435 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Sat, 24 Oct 2026 22:36:03 -0700 Subject: [PATCH] Add opt-in public Classic pool snapshot index --- .env.example | 8 ++ .gitignore | 1 + README.md | 2 + deploy/nginx/sysnode.conf.example | 7 ++ docs/privacy-index.md | 110 ++++++++++++++++++ lib/appFactory.js | 5 + lib/privacyIndex.js | 186 ++++++++++++++++++++++++++++++ lib/privacyRegistry.js | 29 +++++ lib/privacyRpc.js | 105 +++++++++++++++++ lib/privacyStore.js | 82 +++++++++++++ routes/privacy.js | 42 +++++++ server.js | 4 + tests/privacy.routes.test.js | 44 +++++++ tests/privacyIndex.test.js | 180 +++++++++++++++++++++++++++++ tests/privacyRpc.test.js | 63 ++++++++++ 15 files changed, 868 insertions(+) create mode 100644 docs/privacy-index.md create mode 100644 lib/privacyIndex.js create mode 100644 lib/privacyRegistry.js create mode 100644 lib/privacyRpc.js create mode 100644 lib/privacyStore.js create mode 100644 routes/privacy.js create mode 100644 tests/privacy.routes.test.js create mode 100644 tests/privacyIndex.test.js create mode 100644 tests/privacyRpc.test.js diff --git a/.env.example b/.env.example index f94cc55..75e7170 100644 --- a/.env.example +++ b/.env.example @@ -93,3 +93,11 @@ SYSCOIN_BLOCKBOOK_URL= # Requires Core's protx_capabilities and extended protx_operator_key_info RPCs. # Blank disables management. No operator keys belong in this file or service. SYSCOIN_SENTRY_GENESIS_HASH= + +# Optional public Tornado Classic ETH pool index. Disabled by default. +# Set BOTH values to opt in. The RPC must support Ethereum mainnet finalized +# headers, historical state and eth_getLogs from the 2019 deployment blocks. +# HTTPS only; no wallet keys or caller-supplied endpoints. No RPC runs until a +# public snapshot request arrives. See docs/privacy-index.md for lifecycle. +PRIVACY_INDEX_ENABLED=false +PRIVACY_INDEX_RPC_URL= diff --git a/.gitignore b/.gitignore index 3105ade..ac2026e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ coverage/ *.sqlite *.sqlite-journal data/sysnode.db* +data/privacy-index/ .DS_Store npm-debug.log* diff --git a/README.md b/README.md index 8ee07c9..a3a05f9 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ should use lowercase. - `GET /mnlist` — fresh `masternode_list` RPC passthrough - `POST /mnsearch` — paginated/searched view over the in-memory tracker snapshot - `POST /govlist` — active + historical governance proposals (Syscoin Core `gobject list`) +- `GET /api/privacy/v1/pools/:poolId/snapshot` — opt-in anonymous Classic ETH + pool snapshots for local wallet verification; see [configuration and protocol](docs/privacy-index.md). Authenticated routes (cookie + CSRF, same-site): diff --git a/deploy/nginx/sysnode.conf.example b/deploy/nginx/sysnode.conf.example index f3219a4..c931761 100644 --- a/deploy/nginx/sysnode.conf.example +++ b/deploy/nginx/sysnode.conf.example @@ -69,6 +69,13 @@ server { location /recovery/ { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; } location /api/recovery/ { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; } location /api/sentry/v1/ { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; } + # Anonymous pool-wide snapshots. Preserve the path and avoid access logs + # linking a requesting IP to a pool. No note/leaf parameters are supported. + location /api/privacy/v1/ { + access_log off; + proxy_pass http://127.0.0.1:3001; + include /etc/nginx/snippets/sysnode-proxy.conf; + } # Public anonymous data routes. Canonical casing is lowercase # (matches the historical `https://syscoin.dev/mnstats` URL still diff --git a/docs/privacy-index.md b/docs/privacy-index.md new file mode 100644 index 0000000..ba2e5fd --- /dev/null +++ b/docs/privacy-index.md @@ -0,0 +1,110 @@ +# Public Classic ETH pool snapshots + +`GET /api/privacy/v1/pools/:poolId/snapshot` supplies public deposit data for +Pali's local Tornado Classic Merkle-tree reconstruction. It never receives a +note, commitment, nullifier, leaf position, account address, signing key, RPC +URL or arbitrary contract address. Every query string or request body is +rejected. Only the following Ethereum mainnet pools are accepted: + +| ID | Pool address | Deployment block | +| --- | --- | --- | +| `eth-0.1` | `0x12d66f87a04a9e220743712ce6d9bb1b5616b8fc` | 9116966 | +| `eth-1` | `0x47ce0c6ed5b0ce3d3a51fdb1c52dc66a7c3c2936` | 9117609 | +| `eth-10` | `0x910cbd523d972eb0a6f4cae4618ad62622b39dbf` | 9117720 | +| `eth-100` | `0xa160cdab225685da1d56aa342ad8841c3b53f291` | 9161895 | + +These are pinned from the original +[CLI registry](https://github.com/tornadocash/tornado-cli/blob/378ddf8b8b92a4924037d7b64a94dbfd5a7dd6e8/config.js). + +## Configuration and deployment + +The feature is disabled unless `PRIVACY_INDEX_ENABLED=true` and +`PRIVACY_INDEX_RPC_URL` supplies an operator-controlled HTTPS Ethereum mainnet +RPC. URLs containing credentials or fragments are rejected. A provider API +token may be in its path/query; it never enters responses or application logs. +The RPC must support `finalized` headers, historical `eth_call`/`eth_getCode`, +and `eth_getLogs` from the deployment blocks. Scans start with 5,000-block +ranges and shrink on provider range/response-size failures. There is no +fallback to unfinalized data or to a different provider. A provider which cannot +serve even one block of historical logs needs a suitable endpoint before enabling. + +No RPC is sent at startup. The first request starts a job, bounded to 16 chunks +and a 45-second scheduling budget (an already-started RPC can take another +10 seconds). A global queue runs only one job at a time; requests for the same +pool share it. Further requests resume incomplete work. Successful snapshots +are checked for refresh at most once a minute; failures back off for 30 seconds. +Per-process IP limits allow 60 requests/minute and apply IPv6 subnet bucketing. +Run one writer process for this file store; multiple workers require explicit +shared locking and shared request limits before deployment. + +The backend process needs write access to `data/privacy-index/`, resolved from +its working directory. Checkpoints and complete snapshots live in separate +files with checksums and are published by same-directory atomic rename. They +contain only public data, can be rebuilt, and never change `sysnode.db` or its +schema. The existing deployment preserves `data/`. The feature needs no SQL +migration, but the opt-in environment values and nginx route must be configured +separately; automatic deployment does not modify the host environment or nginx. +See the `/api/privacy/v1/` location in the nginx example. It disables access +logging for this path; any upstream CDN/load balancer also needs an equivalent +policy if operators want to avoid recording client IP/pool access metadata. +The endpoint is anonymous with wildcard CORS and does not parse sessions or +set cookies. This does not conceal a client's IP from the serving operator. + +## Response contract + +An initial or incomplete index returns HTTP 202: + +```json +{"phase":"syncing","scannedBlock":9116965,"startBlock":9116966,"targetBlock":9116965} +``` + +Before the first RPC completes, `targetBlock` equals `startBlock - 1`; it is +then the pinned finalized target for that scan. `scannedBlock` is the last +fully accepted block, so initially it also equals `startBlock - 1`. + +HTTP 200 contains only: + +```text +{ version: 1, chainId: 1, poolAddress, asOfBlockNumber, asOfBlockHash, + leafCount, root, leaves: ["0x<64 lowercase hex digits>", ...] } +``` + +Deposits are ordered by canonical event position and must have contiguous +indices starting at zero. The server validates field ranges, the pool's +20-level configuration and denomination, and matches total count and root +metadata at the pinned finalized block. It rejects removed/malformed logs, +missing indices, count mismatches and changed block anchors. JSON-RPC responses +have a 10-second timeout, 4-MiB bound, no redirects and exact response-ID checks. + +The server does **not** independently reconstruct the MiMC root or authenticate +the configured RPC cryptographically. Pali must reconstruct the tree and check +the returned root against its own trusted chain observation before use. This +service is a public-data accelerator, not a replacement for wallet verification. + +Complete snapshots remain immutable while a refresh is incomplete or fails. +`X-Privacy-Index-State` reports `ready`, `refreshing`, or `stale`; the block/hash +in the response remain the observation anchor, not the current HTTP time. +Strong ETags support `If-None-Match`/304 with the same freshness header. +`Cache-Control: public, max-age=0, must-revalidate` requires revalidation. +202/503 responses are not cached and carry `Retry-After`. + +A persisted snapshot is withheld after restart until its block/hash, +on-chain count and root are checked again. Known checkpoint/snapshot reorgs +discard the affected pool's files and restart from deployment. A gap discards +unfinished work and retries from the last complete snapshot or deployment. An RPC +outage does not destroy a previously validated in-memory snapshot; cold starts +during an outage return 503. Invalid/corrupt files are never served. + +## Validation + +```sh +npm test -- --runInBand tests/privacyIndex.test.js tests/privacyRpc.test.js tests/privacy.routes.test.js +npm test -- --runInBand --ci +npm run test:sentry-codec +npm run audit:prod +``` + +Tests use synthetic RPCs and temporary directories and never make mainnet +writes. They cover restart/resume, gaps, reorgs, unavailable sources, +deduplication, atomic snapshot availability, malformed inputs and anonymous +HTTP/ETag behavior. No deployment or live pool claim is part of these tests. diff --git a/lib/appFactory.js b/lib/appFactory.js index 669b119..ff33b49 100644 --- a/lib/appFactory.js +++ b/lib/appFactory.js @@ -29,6 +29,8 @@ const { CREDENTIALED_PREFIXES, SENTRY_PREFIXES, RECOVERY_PREFIXES } = require('. const { createRecoveryRepository } = require('./recoveryRepository'); const { createRecoveryMonitor } = require('./recoveryMonitor'); const { createRecoveryRouter } = require('../routes/recovery'); +const { configuredPrivacyIndex } = require('./privacyIndex'); +const { PRIVACY_PREFIX, createPrivacyRouter } = require('../routes/privacy'); function parseBooleanEnv(value, name) { if (value === undefined || value === '') return undefined; @@ -363,6 +365,7 @@ function createApp({ proposalRpc = null, sentryAdapter = null, recoveryChains = [], + privacyIndex = configuredPrivacyIndex(), // If true and a proposalRpc is wired, start the background // proposal dispatcher interval. Production: true. Tests: false // (they prefer to advance the dispatcher manually by calling @@ -393,6 +396,8 @@ function createApp({ const app = express(); const recoveryMonitor = createRecoveryMonitor({repository:services.recoveryRepository,users:services.users,chains:recoveryChains,mailer,now}); app.use(helmet()); + // Public pool snapshots never enter the session or credentialed-CORS path. + app.use(PRIVACY_PREFIX, createPrivacyRouter({ index: privacyIndex })); app.use(cors({ origin: effectiveCorsOrigin, credentials: true })); app.use(express.json({ limit: '256kb' })); app.use(cookieParser()); diff --git a/lib/privacyIndex.js b/lib/privacyIndex.js new file mode 100644 index 0000000..6f96791 --- /dev/null +++ b/lib/privacyIndex.js @@ -0,0 +1,186 @@ +'use strict'; + +const { createHash } = require('crypto'); +const { performance } = require('perf_hooks'); +const { getPool, MAX_LEAVES } = require('./privacyRegistry'); +const { createPrivacyRpc } = require('./privacyRpc'); +const { createPrivacyStore } = require('./privacyStore'); + +// One process-wide queue. Only a request can enqueue work; no boot-time RPC, +// timers, wallet records or account-specific queries are involved. +function createPrivacyIndex({ rpc, store = createPrivacyStore(), now = Date.now, + chunkBlocks = 5000, maxChunks = 16, jobBudgetMs = 45000, + refreshMs = 60000, retryMs = 30000, log = () => {} } = {}) { + if (!rpc) throw new Error('privacy_rpc_required'); + const states = new Map(); + const queue = []; + let running = null; + async function stateFor(pool) { + if (!states.has(pool.id)) { + const state = { snapshot: null, encoded: null, etag: null, checkpoint: null, + candidate: null, syncing: false, retryAt: 0, error: false }; + state.loaded = Promise.all([store.load(pool.id, 'snapshot'), store.load(pool.id, 'checkpoint')]) + .then(([snapshot, checkpoint]) => { state.candidate = snapshot; state.checkpoint = checkpoint; }); + states.set(pool.id, state); + } + const state = states.get(pool.id); + await state.loaded; + return state; + } + function publish(state, snapshot) { + state.snapshot = snapshot; + state.encoded = JSON.stringify(snapshot); + state.etag = `"${createHash('sha256').update(state.encoded).digest('hex')}"`; + state.candidate = null; + } + async function reset(pool, state) { + state.snapshot = state.candidate = state.checkpoint = state.encoded = state.etag = null; + await store.remove(pool.id, 'snapshot'); + await store.remove(pool.id, 'checkpoint'); + } + async function verifySaved(pool, snapshot, head) { + if (snapshot.asOfBlockNumber > head.number) throw new Error('privacy_reorg'); + const header = await rpc.block(snapshot.asOfBlockNumber); + if (header.hash !== snapshot.asOfBlockHash) throw new Error('privacy_reorg'); + const observed = await rpc.state(pool, header); + if (observed.nextIndex !== snapshot.leafCount || observed.root !== snapshot.root) throw new Error('privacy_reorg'); + } + async function sync(pool, state) { + const deadline = performance.now() + jobBudgetMs; + try { + const head = await rpc.head(); + if (head.number < pool.deploymentBlock) throw new Error('privacy_pool_not_deployed'); + // In-memory snapshots are also checked before extending. On restart the + // saved snapshot is withheld until this independent canonical read passes. + const saved = state.snapshot || state.candidate; + if (saved) { + await verifySaved(pool, saved, head); + if (!state.snapshot) publish(state, saved); + } + let checkpoint = state.checkpoint; + if (checkpoint && state.snapshot && checkpoint.targetBlock < state.snapshot.asOfBlockNumber) checkpoint = null; + if (checkpoint) { + if (checkpoint.targetBlock > head.number || + (await rpc.block(checkpoint.targetBlock)).hash !== checkpoint.targetHash || + (checkpoint.scannedBlock >= pool.deploymentBlock && + (await rpc.block(checkpoint.scannedBlock)).hash !== checkpoint.scannedHash)) throw new Error('privacy_reorg'); + } else if (state.snapshot && state.snapshot.asOfBlockNumber === head.number) { + state.error = false; + state.retryAt = now() + refreshMs; + return; + } else { + checkpoint = { + version: 1, poolAddress: pool.address, + scannedBlock: state.snapshot?.asOfBlockNumber ?? pool.deploymentBlock - 1, + scannedHash: state.snapshot?.asOfBlockHash ?? null, + targetBlock: head.number, targetHash: head.hash, + leaves: state.snapshot ? state.snapshot.leaves.slice() : [], + }; + } + state.checkpoint = checkpoint; + const target = await rpc.block(checkpoint.targetBlock); + if (target.hash !== checkpoint.targetHash) throw new Error('privacy_reorg'); + const expected = await rpc.state(pool, target); + if (checkpoint.leaves.length > expected.nextIndex) throw new Error('privacy_index_gap'); + let blockSpan = Math.min(checkpoint.blockSpan || chunkBlocks, chunkBlocks); + for (let chunk = 0; checkpoint.scannedBlock < target.number && chunk < maxChunks && performance.now() < deadline; chunk++) { + const from = checkpoint.scannedBlock + 1; + let to, boundary, rows; + for (;;) { + to = Math.min(target.number, from + blockSpan - 1); + boundary = await rpc.block(to); + try { rows = await rpc.deposits(pool, from, to); break; } + catch (error) { + // Read-only provider size/range limits can be retried on a smaller + // public range. The job deadline and a one-block floor bound this. + if (!['privacy_rpc_unavailable', 'privacy_rpc_rejected', 'privacy_logs_too_large'].includes(error.message) || + to === from || performance.now() >= deadline) throw error; + blockSpan = Math.max(1, Math.floor((to - from + 1) / 2)); + } + } + // Validate the complete chunk before mutating its checkpoint. + let next = checkpoint.leaves.length; + let previous = null; + for (const row of rows) { + if (row.leafIndex !== next || next >= expected.nextIndex || next >= MAX_LEAVES || + (previous && row.blockNumber === previous.blockNumber && row.logIndex <= previous.logIndex) || + (row.blockNumber === boundary.number && row.blockHash !== boundary.hash)) throw new Error('privacy_index_gap'); + previous = row; next++; + } + await rpc.assertBlock(boundary); + checkpoint.leaves.push(...rows.map((row) => row.leaf)); + checkpoint.scannedBlock = to; + checkpoint.scannedHash = boundary.hash; + checkpoint.blockSpan = blockSpan; + } + // A reorg during any read cannot publish a mixed-fork view. + await rpc.assertBlock(target); + if (checkpoint.scannedBlock === target.number) { + if (checkpoint.leaves.length !== expected.nextIndex) throw new Error('privacy_index_gap'); + const snapshot = { version: 1, chainId: 1, poolAddress: pool.address, + asOfBlockNumber: target.number, asOfBlockHash: target.hash, + leafCount: checkpoint.leaves.length, root: expected.root, leaves: checkpoint.leaves }; + // The root comes from the pool at this exact block. Wallets must rebuild + // MiMC independently before trusting these public RPC-provided leaves. + await store.save(pool.id, 'snapshot', snapshot); + publish(state, snapshot); + state.checkpoint = null; + await store.remove(pool.id, 'checkpoint'); + state.retryAt = now() + refreshMs; + } else { + await store.save(pool.id, 'checkpoint', checkpoint); + state.retryAt = 0; // the next explicit poll may advance another bounded job + } + state.error = false; + } catch (error) { + if (error.message === 'privacy_reorg') await reset(pool, state); + if (error.message === 'privacy_index_gap') { + // Missing new logs invalidate only unfinished work. A previously + // checked complete snapshot remains a useful, explicitly stale view. + state.checkpoint = null; + await store.remove(pool.id, 'checkpoint'); + } + state.error = true; + state.retryAt = now() + retryMs; + log('privacy_sync_failed', { poolId: pool.id, code: /^privacy_[a-z_]+$/.test(error.message) ? error.message : 'privacy_index_unavailable' }); + } + } + function enqueue(pool, state) { + if (state.syncing || now() < state.retryAt) return; + state.syncing = true; + queue.push({ pool, state }); + if (running) return; + running = (async () => { + while (queue.length) { + const next = queue.shift(); + try { await sync(next.pool, next.state); } + catch (_) { next.state.error = true; next.state.retryAt = now() + retryMs; } + finally { next.state.syncing = false; } + } + })().finally(() => { running = null; }); + } + async function read(id) { + const pool = getPool(id); + if (!pool) return { status: 404, body: { error: 'unknown_privacy_pool' } }; + let state; + try { state = await stateFor(pool); } + catch (_) { return { status: 503, body: { error: 'privacy_index_unavailable' } }; } + enqueue(pool, state); + if (state.snapshot) return { status: 200, body: state.snapshot, encoded: state.encoded, etag: state.etag, + phase: state.error ? 'stale' : state.syncing ? 'refreshing' : 'ready' }; + if (state.error) return { status: 503, body: { error: 'privacy_index_unavailable' } }; + return { status: 202, body: { phase: 'syncing', scannedBlock: state.checkpoint?.scannedBlock ?? pool.deploymentBlock - 1, + startBlock: pool.deploymentBlock, targetBlock: state.checkpoint?.targetBlock ?? pool.deploymentBlock - 1 } }; + } + return { read, whenIdle: () => running || Promise.resolve() }; +} + +function configuredPrivacyIndex({ enabled = process.env.PRIVACY_INDEX_ENABLED, + rpcUrl = process.env.PRIVACY_INDEX_RPC_URL, ...options } = {}) { + if (enabled !== undefined && enabled !== '' && enabled !== 'true' && enabled !== 'false') throw new Error('PRIVACY_INDEX_ENABLED_invalid'); + if (enabled !== 'true') return null; + if (!rpcUrl) throw new Error('PRIVACY_INDEX_RPC_URL_required'); + return createPrivacyIndex({ rpc: createPrivacyRpc(rpcUrl), ...options }); +} + +module.exports = { createPrivacyIndex, configuredPrivacyIndex }; diff --git a/lib/privacyRegistry.js b/lib/privacyRegistry.js new file mode 100644 index 0000000..7dca48e --- /dev/null +++ b/lib/privacyRegistry.js @@ -0,0 +1,29 @@ +'use strict'; + +// Original Tornado Classic ETH instances. Pinned upstream registry: +// tornadocash/tornado-cli@378ddf8b8b92a4924037d7b64a94dbfd5a7dd6e8/config.js +const POOLS = Object.freeze([ + { id: 'eth-0.1', address: '0x12d66f87a04a9e220743712ce6d9bb1b5616b8fc', denomination: '100000000000000000', deploymentBlock: 9116966 }, + { id: 'eth-1', address: '0x47ce0c6ed5b0ce3d3a51fdb1c52dc66a7c3c2936', denomination: '1000000000000000000', deploymentBlock: 9117609 }, + { id: 'eth-10', address: '0x910cbd523d972eb0a6f4cae4618ad62622b39dbf', denomination: '10000000000000000000', deploymentBlock: 9117720 }, + { id: 'eth-100', address: '0xa160cdab225685da1d56aa342ad8841c3b53f291', denomination: '100000000000000000000', deploymentBlock: 9161895 }, +].map(Object.freeze)); +const MAX_LEAVES = 2 ** 20; +const FIELD_SIZE = 21888242871839275222246405745257275088548364400416034343698204186575808495617n; + +function getPool(id) { + return POOLS.find((pool) => pool.id === id); +} + +function word(value) { + if (typeof value !== 'string' || !/^0x[0-9a-f]{64}$/.test(value)) throw new Error('privacy_invalid_word'); + return value; +} + +function fieldWord(value) { + word(value); + if (BigInt(value) >= FIELD_SIZE) throw new Error('privacy_invalid_field'); + return value; +} + +module.exports = { POOLS, MAX_LEAVES, FIELD_SIZE, getPool, word, fieldWord }; diff --git a/lib/privacyRpc.js b/lib/privacyRpc.js new file mode 100644 index 0000000..5acba0b --- /dev/null +++ b/lib/privacyRpc.js @@ -0,0 +1,105 @@ +'use strict'; + +const axios = require('axios'); +const { Interface, toQuantity } = require('ethers'); +const { MAX_LEAVES, word, fieldWord } = require('./privacyRegistry'); + +const ABI = new Interface([ + 'event Deposit(bytes32 indexed commitment,uint32 leafIndex,uint256 timestamp)', + 'function levels() view returns (uint32)', + 'function denomination() view returns (uint256)', + 'function nextIndex() view returns (uint32)', + 'function getLastRoot() view returns (bytes32)', +]); +const DEPOSIT_TOPIC = ABI.getEvent('Deposit').topicHash; +const MAX_RPC_BYTES = 4 * 1024 * 1024; + +function quantity(value) { + if (typeof value !== 'string' || value.length > 16 || !/^0x(?:0|[1-9a-f][0-9a-f]*)$/.test(value)) throw new Error('privacy_invalid_quantity'); + const n = Number(BigInt(value)); + if (!Number.isSafeInteger(n)) throw new Error('privacy_invalid_quantity'); + return n; +} + +function createPrivacyRpc(rpcUrl, { post = axios.post, now = Date.now } = {}) { + let url; + try { url = new URL(rpcUrl); } catch (_) { throw new Error('privacy_rpc_url_invalid'); } + if (url.protocol !== 'https:' || url.username || url.password || url.hash) throw new Error('privacy_rpc_url_invalid'); + let id = 0; + async function call(method, params) { + const current = ++id; + let response; + try { + response = await post(url.href, { jsonrpc: '2.0', id: current, method, params }, { + timeout: 10000, maxRedirects: 0, maxContentLength: MAX_RPC_BYTES, + maxBodyLength: 16384, responseType: 'json', + transitional: { silentJSONParsing: false }, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (_) { throw new Error('privacy_rpc_unavailable'); } + const body = response.data; + if (response.status !== 200 || !body || body.jsonrpc !== '2.0' || body.id !== current) throw new Error('privacy_rpc_invalid_response'); + if (Object.hasOwn(body, 'error')) throw new Error('privacy_rpc_rejected'); + if (!Object.hasOwn(body, 'result')) throw new Error('privacy_rpc_invalid_response'); + return body.result; + } + async function block(tag) { + const value = await call('eth_getBlockByNumber', [typeof tag === 'number' ? toQuantity(tag) : tag, false]); + if (!value || typeof value !== 'object') throw new Error('privacy_missing_block'); + const result = { number: quantity(value.number), hash: word(value.hash), parentHash: word(value.parentHash), timestamp: quantity(value.timestamp) }; + if (typeof tag === 'number' && result.number !== tag) throw new Error('privacy_wrong_block'); + return result; + } + async function assertBlock(header) { + if ((await block(header.number)).hash !== header.hash) throw new Error('privacy_reorg'); + } + async function head() { + if (quantity(await call('eth_chainId', [])) !== 1) throw new Error('privacy_wrong_chain'); + // Never silently substitute latest/safe for finalized. + const result = await block('finalized'); + const age = now() - result.timestamp * 1000; + if (age < -30000 || age > 30 * 60 * 1000) throw new Error('privacy_stale_chain'); + return result; + } + async function state(pool, header) { + const tag = toQuantity(header.number); + const code = await call('eth_getCode', [pool.address, tag]); + if (typeof code !== 'string' || !/^0x(?:[0-9a-f]{2})+$/.test(code)) throw new Error('privacy_missing_pool'); + async function view(name) { + const result = await call('eth_call', [{ to: pool.address, data: ABI.encodeFunctionData(name), gas: '0x186a0' }, tag]); + word(result); + try { return ABI.decodeFunctionResult(name, result)[0]; } catch (_) { throw new Error('privacy_invalid_pool_state'); } + } + const levels = await view('levels'); + const denomination = await view('denomination'); + const nextIndex = Number(await view('nextIndex')); + const root = fieldWord(await view('getLastRoot')); + if (levels !== 20n || denomination !== BigInt(pool.denomination) || + !Number.isSafeInteger(nextIndex) || nextIndex < 0 || nextIndex > MAX_LEAVES) throw new Error('privacy_pool_mismatch'); + await assertBlock(header); + return { nextIndex, root }; + } + async function deposits(pool, from, to) { + const rows = await call('eth_getLogs', [{ address: pool.address, fromBlock: toQuantity(from), toBlock: toQuantity(to), topics: [DEPOSIT_TOPIC] }]); + if (!Array.isArray(rows)) throw new Error('privacy_invalid_logs'); + if (rows.length > 10000) throw new Error('privacy_logs_too_large'); + return rows.map((row) => { + if (!row || row.removed !== false || row.address !== pool.address || + !Array.isArray(row.topics) || row.topics.length !== 2 || row.topics[0] !== DEPOSIT_TOPIC || + typeof row.data !== 'string' || !/^0x[0-9a-f]{128}$/.test(row.data)) throw new Error('privacy_invalid_log'); + fieldWord(row.topics[1]); + const blockNumber = quantity(row.blockNumber); + if (blockNumber < from || blockNumber > to) throw new Error('privacy_invalid_log'); + let parsed; + try { parsed = ABI.parseLog(row); } catch (_) { throw new Error('privacy_invalid_log'); } + const leafIndex = Number(parsed.args.leafIndex); + if (!Number.isSafeInteger(leafIndex) || leafIndex < 0 || leafIndex >= MAX_LEAVES) throw new Error('privacy_invalid_log'); + return { leaf: fieldWord(parsed.args.commitment), leafIndex, blockNumber, + blockHash: word(row.blockHash), txHash: word(row.transactionHash), + txIndex: quantity(row.transactionIndex), logIndex: quantity(row.logIndex) }; + }).sort((a, b) => a.blockNumber - b.blockNumber || a.txIndex - b.txIndex || a.logIndex - b.logIndex); + } + return { head, block, assertBlock, state, deposits }; +} + +module.exports = { createPrivacyRpc, ABI, DEPOSIT_TOPIC, quantity, MAX_RPC_BYTES }; diff --git a/lib/privacyStore.js b/lib/privacyStore.js new file mode 100644 index 0000000..fc8afe7 --- /dev/null +++ b/lib/privacyStore.js @@ -0,0 +1,82 @@ +'use strict'; + +const fs = require('fs/promises'); +const path = require('path'); +const { createHash, randomBytes } = require('crypto'); +const { getPool, MAX_LEAVES, word, fieldWord } = require('./privacyRegistry'); +const MAX_FILE_BYTES = 80 * 1024 * 1024; + +function validateSnapshot(value, pool) { + if (!value || value.version !== 1 || value.chainId !== 1 || value.poolAddress !== pool.address || + !Number.isSafeInteger(value.asOfBlockNumber) || value.asOfBlockNumber < pool.deploymentBlock || + !Array.isArray(value.leaves) || value.leaves.length > MAX_LEAVES || value.leafCount !== value.leaves.length) throw new Error('privacy_invalid_snapshot'); + word(value.asOfBlockHash); fieldWord(value.root); value.leaves.forEach(fieldWord); + // Return a strict public projection, never expose file bookkeeping. + return { version: 1, chainId: 1, poolAddress: pool.address, asOfBlockNumber: value.asOfBlockNumber, + asOfBlockHash: value.asOfBlockHash, leafCount: value.leaves.length, root: value.root, leaves: value.leaves }; +} +function validateCheckpoint(value, pool) { + if (!value || value.version !== 1 || value.poolAddress !== pool.address || + !Number.isSafeInteger(value.scannedBlock) || value.scannedBlock < pool.deploymentBlock - 1 || + !Number.isSafeInteger(value.targetBlock) || value.targetBlock < value.scannedBlock || + !Array.isArray(value.leaves) || value.leaves.length > MAX_LEAVES) throw new Error('privacy_invalid_checkpoint'); + if (value.scannedBlock >= pool.deploymentBlock) word(value.scannedHash); + word(value.targetHash); + if (value.blockSpan !== undefined && (!Number.isSafeInteger(value.blockSpan) || value.blockSpan < 1 || value.blockSpan > 5000)) throw new Error('privacy_invalid_checkpoint'); + if (value.scannedBlock === pool.deploymentBlock - 1 && value.leaves.length) throw new Error('privacy_invalid_checkpoint'); + value.leaves.forEach(fieldWord); + return value; +} + +function createPrivacyStore(directory = path.resolve('data/privacy-index')) { + function file(id, kind) { + if (!getPool(id) || !['snapshot', 'checkpoint'].includes(kind)) throw new Error('privacy_unknown_pool'); + return path.join(directory, `${id}.${kind}.json`); + } + async function load(id, kind) { + try { + const name = file(id, kind); + if ((await fs.stat(name)).size > MAX_FILE_BYTES) throw new Error('privacy_file_too_large'); + const envelope = JSON.parse(await fs.readFile(name, 'utf8')); + if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope) || + typeof envelope.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(envelope.sha256) || + !envelope.value || typeof envelope.value !== 'object' || Array.isArray(envelope.value)) throw new Error('privacy_file_envelope'); + const data = JSON.stringify(envelope.value); + if (envelope.sha256 !== createHash('sha256').update(data).digest('hex')) throw new Error('privacy_file_checksum'); + return kind === 'snapshot' ? validateSnapshot(envelope.value, getPool(id)) : validateCheckpoint(envelope.value, getPool(id)); + } catch (error) { + if (error.code === 'ENOENT') return null; + // Rebuildable public cache: corrupted state is never exposed or trusted. + if (error instanceof SyntaxError || error.message.startsWith('privacy_')) return null; + throw new Error('privacy_storage_unavailable'); + } + } + async function save(id, kind, value) { + const target = file(id, kind); + if (kind === 'snapshot') validateSnapshot(value, getPool(id)); else validateCheckpoint(value, getPool(id)); + const data = JSON.stringify(value); + const encoded = JSON.stringify({ sha256: createHash('sha256').update(data).digest('hex'), value }); + if (Buffer.byteLength(encoded) > MAX_FILE_BYTES) throw new Error('privacy_file_too_large'); + await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + const temp = `${target}.${randomBytes(8).toString('hex')}.tmp`; + let handle; + try { + handle = await fs.open(temp, 'wx', 0o600); + await handle.writeFile(encoded); + await handle.sync(); + await handle.close(); handle = null; + await fs.rename(temp, target); + const directoryHandle = await fs.open(directory, 'r'); + try { await directoryHandle.sync(); } finally { await directoryHandle.close(); } + } finally { + if (handle) await handle.close(); + await fs.unlink(temp).catch(() => {}); + } + } + async function remove(id, kind) { + try { await fs.unlink(file(id, kind)); } catch (error) { if (error.code !== 'ENOENT') throw new Error('privacy_storage_unavailable'); } + } + return { load, save, remove }; +} + +module.exports = { createPrivacyStore, validateSnapshot, validateCheckpoint, MAX_FILE_BYTES }; diff --git a/routes/privacy.js b/routes/privacy.js new file mode 100644 index 0000000..3d2444b --- /dev/null +++ b/routes/privacy.js @@ -0,0 +1,42 @@ +'use strict'; + +const express = require('express'); +const cors = require('cors'); +const rateLimit = require('express-rate-limit'); +const { ipKeyGenerator } = require('express-rate-limit'); +const { getPool } = require('../lib/privacyRegistry'); + +const PRIVACY_PREFIX = '/api/privacy/v1'; +function createPrivacyRouter({ index = null, limiter } = {}) { + const router = express.Router(); + router.use(cors({ origin: '*', credentials: false, exposedHeaders: ['ETag', 'X-Privacy-Index-State', 'Retry-After'] })); + router.use(limiter || rateLimit({ windowMs: 60000, limit: 60, + keyGenerator: (req) => ipKeyGenerator(req.ip || ''), standardHeaders: true, legacyHeaders: false, + message: { error: 'too_many_privacy_requests' } })); + router.use((req, res, next) => { + res.set('Cache-Control', 'no-store'); + if (req.method !== 'GET' && req.method !== 'HEAD') return res.status(405).json({ error: 'privacy_read_only' }); + if (req.url.includes('?') || req.headers['transfer-encoding'] || Number(req.headers['content-length'] || 0) > 0 || + (req.body && Object.keys(req.body).length)) return res.status(400).json({ error: 'privacy_request_parameters_not_allowed' }); + next(); + }); + router.get('/pools/:poolId/snapshot', async (req, res) => { + if (!getPool(req.params.poolId)) return res.status(404).json({ error: 'unknown_privacy_pool' }); + if (!index) return res.status(503).json({ error: 'privacy_index_disabled' }); + try { + const result = await index.read(req.params.poolId); + if (result.status === 200) { + res.set('Cache-Control', 'public, max-age=0, must-revalidate'); + res.set('ETag', result.etag); + res.set('X-Privacy-Index-State', result.phase); + if (req.fresh) return res.status(304).end(); + return res.status(200).type('json').send(result.encoded); + } + res.set('Retry-After', result.status === 202 ? '2' : '30'); + return res.status(result.status).json(result.body); + } catch (_) { return res.status(503).json({ error: 'privacy_index_unavailable' }); } + }); + router.use((_req, res) => res.status(404).json({ error: 'unknown_privacy_route' })); + return router; +} +module.exports = { PRIVACY_PREFIX, createPrivacyRouter }; diff --git a/server.js b/server.js index fc22620..5709a36 100644 --- a/server.js +++ b/server.js @@ -45,6 +45,8 @@ const { createPaliChainGuard } = require('./lib/paliChainGuard'); const { createConfiguredSentryAdapter, createSentryRpc } = require('./lib/sentryAdapter'); const { configuredRecoveryChains } = require('./lib/recoveryRegistry'); const { createRecoveryMonitor } = require('./lib/recoveryMonitor'); +const { configuredPrivacyIndex } = require('./lib/privacyIndex'); +const { PRIVACY_PREFIX, createPrivacyRouter } = require('./routes/privacy'); const { createMasternodeCountRepo, } = require('./lib/masternodeCountRepo'); @@ -69,6 +71,7 @@ const currentVotesCache = createCurrentVotesCache({ }); const app = express(); +const privacyIndex = configuredPrivacyIndex(); // Reverse-proxy awareness. When deployed behind nginx / a load balancer, // Express's default `req.ip` is the proxy's socket address, which collapses @@ -92,6 +95,7 @@ app.set( // Security headers apply everywhere. helmet defaults are safe for JSON APIs. app.use(helmet()); +app.use(PRIVACY_PREFIX, createPrivacyRouter({ index: privacyIndex })); app.use(bodyParser.json({ limit: '256kb' })); app.use(cookieParser()); diff --git a/tests/privacy.routes.test.js b/tests/privacy.routes.test.js new file mode 100644 index 0000000..43c6f23 --- /dev/null +++ b/tests/privacy.routes.test.js @@ -0,0 +1,44 @@ +'use strict'; + +const express = require('express'); +const request = require('supertest'); +const { createPrivacyRouter, PRIVACY_PREFIX } = require('../routes/privacy'); +const { POOLS } = require('../lib/privacyRegistry'); +const { buildTestApp } = require('./helpers/buildTestApp'); +const endpoint = `${PRIVACY_PREFIX}/pools/eth-0.1/snapshot`; +function app(index, limiter = (_req, _res, next) => next()) { + const a = express(); a.use(express.json()); a.use(PRIVACY_PREFIX, createPrivacyRouter({ index, limiter })); return a; +} +test('anonymous progress and disabled responses are JSON without cookies', async () => { + expect((await request(app()).get(endpoint)).body).toEqual({ error: 'privacy_index_disabled' }); + const res = await request(app({ read: async () => ({ status: 202, body: { phase: 'syncing', scannedBlock: 1, startBlock: 2, targetBlock: 3 } }) })).get(endpoint).set('Origin', 'chrome-extension://pali'); + expect(res.status).toBe(202); expect(res.headers['access-control-allow-origin']).toBe('*'); + expect(res.headers['set-cookie']).toBeUndefined(); expect(res.headers['access-control-allow-credentials']).toBeUndefined(); +}); +test.each(['?note=secret', '?leafIndex=1', '?rpcUrl=https://attacker.example'])('rejects extra request data %s before index invocation', async (query) => { + const read = jest.fn(); expect((await request(app({ read })).get(endpoint + query)).status).toBe(400); expect(read).not.toHaveBeenCalled(); +}); +test('unknown pool, write methods and arbitrary paths cannot reach indexing', async () => { + const read = jest.fn(); const a = app({ read }); + expect((await request(a).get(endpoint.replace('eth-0.1', '0x12d66f87a04a9e220743712ce6d9bb1b5616b8fc'))).status).toBe(404); + expect((await request(a).post(endpoint).send({ note: 'secret' })).status).toBe(405); + expect((await request(a).get(PRIVACY_PREFIX + '/anything')).status).toBe(404); + expect((await request(a).get(endpoint).send({ note: 'secret' })).status).toBe(400); + expect(read).not.toHaveBeenCalled(); +}); +test('ETag revalidation preserves freshness marker and unchanged anchored body', async () => { + const body = { version: 1, chainId: 1, poolAddress: POOLS[0].address, asOfBlockNumber: 123, asOfBlockHash: 'hash', leafCount: 0, root: 'root', leaves: [] }; + const index = { read: async () => ({ status: 200, body, encoded: JSON.stringify(body), etag: '"snapshot"', phase: 'stale' }) }; + const res = await request(app(index)).get(endpoint).set('If-None-Match', '"snapshot"'); + expect(res.status).toBe(304); expect(res.headers['x-privacy-index-state']).toBe('stale'); + expect(res.headers['cache-control']).toContain('must-revalidate'); expect(res.headers['etag']).toBe('"snapshot"'); +}); +test('standalone app wires public CORS before auth and exposes no session', async () => { + const read = jest.fn(async () => ({ status: 202, body: { phase: 'syncing' } })); + const ctx = buildTestApp({ privacyIndex: { read } }); + try { + const res = await request(ctx.app).get(endpoint).set('Origin', 'https://unrelated.example'); + expect(res.status).toBe(202); expect(res.headers['access-control-allow-origin']).toBe('*'); expect(res.headers['set-cookie']).toBeUndefined(); + expect(read).toHaveBeenCalledWith('eth-0.1'); + } finally { ctx.db.close(); } +}); diff --git a/tests/privacyIndex.test.js b/tests/privacyIndex.test.js new file mode 100644 index 0000000..c7061a6 --- /dev/null +++ b/tests/privacyIndex.test.js @@ -0,0 +1,180 @@ +'use strict'; + +const fs = require('fs/promises'); +const os = require('os'); +const path = require('path'); +const { createPrivacyIndex, configuredPrivacyIndex } = require('../lib/privacyIndex'); +const { createPrivacyStore } = require('../lib/privacyStore'); +const { POOLS } = require('../lib/privacyRegistry'); + +const pool = POOLS[0]; +const word = (n) => `0x${BigInt(n).toString(16).padStart(64, '0')}`; +function fixture({ count = 3, blocks = 4 } = {}) { + const start = pool.deploymentBlock; + const state = { height: start + blocks - 1, fork: 0, fail: false, gap: false, extra: false, reads: 0, active: 0, peak: 0 }; + const header = (n) => ({ number: n, hash: word(n + state.fork * 100000000), parentHash: word(n - 1), timestamp: 1700000000 }); + const deposits = Array.from({ length: count }, (_, i) => ({ leaf: word(i + 1), leafIndex: i, + blockNumber: start + i, blockHash: header(start + i).hash, txHash: word(i + 100), txIndex: 0, logIndex: i })); + const rpc = { + async head() { state.reads++; if (state.fail) throw new Error('privacy_rpc_unavailable'); return header(state.height); }, + async block(n) { if (state.fail) throw new Error('privacy_rpc_unavailable'); return header(n); }, + async assertBlock(h) { if (h.hash !== header(h.number).hash) throw new Error('privacy_reorg'); }, + async state(_pool, h) { return { nextIndex: deposits.filter((d) => d.blockNumber <= h.number).length, root: word(1234) }; }, + async deposits(_pool, from, to) { + state.active++; state.peak = Math.max(state.peak, state.active); + await new Promise((resolve) => setImmediate(resolve)); + state.active--; + const rows = deposits.filter((d) => d.blockNumber >= from && d.blockNumber <= to); + return state.gap ? rows.filter((d) => d.leafIndex !== 1) : rows; + }, + }; + return { rpc, state, deposits, header }; +} + +describe('public privacy index lifecycle', () => { + let dir; + beforeEach(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), 'privacy-index-test-')); }); + afterEach(async () => { await fs.rm(dir, { recursive: true, force: true }); }); + const setup = (dir, rpc, extra = {}) => createPrivacyIndex({ rpc, store: createPrivacyStore(dir), refreshMs: 0, retryMs: 0, ...extra }); + + test('disabled config performs no RPC or file IO and opt-in validates configuration', () => { + expect(configuredPrivacyIndex({ enabled: 'false', rpcUrl: 'broken' })).toBeNull(); + expect(() => configuredPrivacyIndex({ enabled: 'true', rpcUrl: '' })).toThrow('PRIVACY_INDEX_RPC_URL_required'); + expect(() => configuredPrivacyIndex({ enabled: 'yes' })).toThrow('PRIVACY_INDEX_ENABLED_invalid'); + expect(() => configuredPrivacyIndex({ enabled: 'true', rpcUrl: 'http://localhost' })).toThrow('privacy_rpc_url_invalid'); + }); + + test('returns progress first and only publishes complete pinned snapshots', async () => { + const f = fixture(); const index = setup(dir, f.rpc, { chunkBlocks: 1, maxChunks: 2 }); + expect(f.state.reads).toBe(0); + expect((await index.read(pool.id)).status).toBe(202); + await index.whenIdle(); + const progress = await index.read(pool.id); + expect(progress.status).toBe(202); + expect(progress.body.scannedBlock).toBe(pool.deploymentBlock + 1); + await index.whenIdle(); + const ready = await index.read(pool.id); await index.whenIdle(); + expect(ready.status).toBe(200); + expect(ready.body).toEqual({ version: 1, chainId: 1, poolAddress: pool.address, + asOfBlockNumber: f.state.height, asOfBlockHash: f.header(f.state.height).hash, + leafCount: 3, root: word(1234), leaves: [word(1), word(2), word(3)] }); + }); + + test('simultaneous polls share one job, and other pools cannot run RPC scans in parallel', async () => { + const f = fixture({ count: 0 }); const index = setup(dir, f.rpc); + await Promise.all(Array.from({ length: 20 }, () => index.read(pool.id))); + await index.whenIdle(); + expect(f.state.reads).toBe(1); expect(f.state.peak).toBe(1); + await Promise.all(POOLS.map((p) => index.read(p.id))); await index.whenIdle(); + expect(f.state.peak).toBe(1); + }); + + test('checkpoint survives restart; saved snapshot is withheld until revalidated', async () => { + const f = fixture(); const index = setup(dir, f.rpc, { chunkBlocks: 1, maxChunks: 2 }); + await index.read(pool.id); await index.whenIdle(); + const resumed = setup(dir, f.rpc, { chunkBlocks: 1, maxChunks: 2 }); + expect((await resumed.read(pool.id)).status).toBe(202); await resumed.whenIdle(); + expect((await resumed.read(pool.id)).status).toBe(200); await resumed.whenIdle(); + const restarted = setup(dir, f.rpc); + expect((await restarted.read(pool.id)).status).toBe(202); await restarted.whenIdle(); + expect((await restarted.read(pool.id)).status).toBe(200); await restarted.whenIdle(); + }); + + test('provider range limits shrink the public scan range without losing progress', async () => { + const f = fixture({ blocks: 16 }); + const original = f.rpc.deposits; + f.rpc.deposits = jest.fn(async (p, from, to) => { + if (to - from + 1 > 2) throw new Error('privacy_rpc_unavailable'); + return original(p, from, to); + }); + const index = setup(dir, f.rpc, { maxChunks: 8 }); + await index.read(pool.id); await index.whenIdle(); + const ready = await index.read(pool.id); await index.whenIdle(); + expect(ready.status).toBe(200); expect(ready.body.leafCount).toBe(3); + expect(f.rpc.deposits.mock.calls.length).toBeGreaterThan(8); + }); + + test('incomplete gaps are never served as a complete tree and resets are persistent', async () => { + const f = fixture(); f.state.gap = true; const index = setup(dir, f.rpc); + await index.read(pool.id); await index.whenIdle(); + expect((await index.read(pool.id)).status).toBe(503); await index.whenIdle(); + expect(await createPrivacyStore(dir).load(pool.id, 'checkpoint')).toBeNull(); + expect(await createPrivacyStore(dir).load(pool.id, 'snapshot')).toBeNull(); + }); + + test('missing trailing deposit fails final count despite contiguous earlier indices', async () => { + const f = fixture(); const original = f.rpc.deposits; + f.rpc.deposits = async (...args) => (await original(...args)).filter((r) => r.leafIndex !== 2); + const index = setup(dir, f.rpc); await index.read(pool.id); await index.whenIdle(); + expect((await index.read(pool.id)).status).toBe(503); await index.whenIdle(); + }); + + test('refresh failure retains only the old complete snapshot with a stale marker', async () => { + const f = fixture(); const index = setup(dir, f.rpc); + await index.read(pool.id); await index.whenIdle(); + const previous = await index.read(pool.id); await index.whenIdle(); + f.state.fail = true; await index.read(pool.id); await index.whenIdle(); + const stale = await index.read(pool.id); await index.whenIdle(); + expect(stale.status).toBe(200); expect(stale.phase).toBe('stale'); + expect(stale.body).toEqual(previous.body); expect(stale.etag).toBe(previous.etag); + const restarted = setup(dir, f.rpc); + await restarted.read(pool.id); await restarted.whenIdle(); + expect((await restarted.read(pool.id)).status).toBe(503); await restarted.whenIdle(); + }); + + test('a gap in refreshed logs retains the previous complete canonical snapshot', async () => { + const f = fixture(); const index = setup(dir, f.rpc); + await index.read(pool.id); await index.whenIdle(); + const old = await index.read(pool.id); await index.whenIdle(); + f.state.height += 2; + f.deposits.push({ leaf: word(99), leafIndex: 3, blockNumber: f.state.height, + blockHash: f.header(f.state.height).hash, txHash: word(999), txIndex: 0, logIndex: 0 }); + const original = f.rpc.deposits; + f.rpc.deposits = async (...args) => (await original(...args)).filter((r) => r.leafIndex !== 3); + await index.read(pool.id); await index.whenIdle(); + const stale = await index.read(pool.id); await index.whenIdle(); + expect(stale.status).toBe(200); expect(stale.phase).toBe('stale'); expect(stale.body).toEqual(old.body); + }); + + test('ready snapshot and checkpoints are discarded when their anchor reorgs', async () => { + const f = fixture(); const index = setup(dir, f.rpc); + await index.read(pool.id); await index.whenIdle(); + f.state.fork = 1; await index.read(pool.id); await index.whenIdle(); + expect(await createPrivacyStore(dir).load(pool.id, 'snapshot')).toBeNull(); + expect((await index.read(pool.id)).status).toBe(503); await index.whenIdle(); + }); + + test('an in-flight target reorg cannot publish a mixed-fork snapshot', async () => { + const f = fixture(); const original = f.rpc.deposits; + f.rpc.deposits = async (...args) => { const rows = await original(...args); f.state.fork = 1; return rows; }; + const index = setup(dir, f.rpc); await index.read(pool.id); await index.whenIdle(); + expect(await createPrivacyStore(dir).load(pool.id, 'snapshot')).toBeNull(); + }); + + test('corrupt cache is rebuilt and only allowlisted IDs can become file names', async () => { + await fs.writeFile(path.join(dir, `${pool.id}.snapshot.json`), '{broken'); + const f = fixture(); const index = setup(dir, f.rpc); + expect((await index.read('../../anything')).status).toBe(404); + expect(f.state.reads).toBe(0); + await index.read(pool.id); await index.whenIdle(); + expect((await index.read(pool.id)).status).toBe(200); await index.whenIdle(); + await expect(createPrivacyStore(dir).save('../escape', 'snapshot', {})).rejects.toThrow('privacy_unknown_pool'); + }); + + test.each(['null', '{}', '[]', '{"sha256":0,"value":{}}', '{"sha256":"bad","value":{}}'])('malformed JSON envelope %s is discarded and rebuilt', async (body) => { + await fs.writeFile(path.join(dir, `${pool.id}.snapshot.json`), body); + const index = setup(dir, fixture().rpc); + expect((await index.read(pool.id)).status).toBe(202); await index.whenIdle(); + expect((await index.read(pool.id)).status).toBe(200); await index.whenIdle(); + }); + + test('a new finalized target does not replace a ready snapshot with partial refresh data', async () => { + const f = fixture(); const index = setup(dir, f.rpc, { chunkBlocks: 1, maxChunks: 4 }); + await index.read(pool.id); await index.whenIdle(); + const old = await index.read(pool.id); await index.whenIdle(); + f.state.height += 10; + await index.read(pool.id); await index.whenIdle(); + const stillReady = await index.read(pool.id); await index.whenIdle(); + expect(stillReady.status).toBe(200); expect(stillReady.body).toEqual(old.body); + }); +}); diff --git a/tests/privacyRpc.test.js b/tests/privacyRpc.test.js new file mode 100644 index 0000000..ced7d27 --- /dev/null +++ b/tests/privacyRpc.test.js @@ -0,0 +1,63 @@ +'use strict'; + +const { createPrivacyRpc, ABI, DEPOSIT_TOPIC, quantity, MAX_RPC_BYTES } = require('../lib/privacyRpc'); +const { POOLS, FIELD_SIZE } = require('../lib/privacyRegistry'); +const pool = POOLS[0]; +const w = (n) => `0x${BigInt(n).toString(16).padStart(64, '0')}`; +const q = (n) => `0x${n.toString(16)}`; +function fixture(overrides = {}) { + const block = { number: q(12000000), hash: w(99), parentHash: w(98), timestamp: q(1700000000) }; + const values = { eth_chainId: '0x1', eth_getBlockByNumber: block, eth_getCode: '0x6000', + eth_getLogs: [{ address: pool.address, removed: false, topics: [DEPOSIT_TOPIC, w(1)], data: ABI.encodeEventLog(ABI.getEvent('Deposit'), [w(1), 0, 123]).data, + blockNumber: block.number, blockHash: block.hash, transactionHash: w(4), transactionIndex: '0x0', logIndex: '0x1' }], ...overrides }; + const post = jest.fn(async (_url, payload) => { + let result = values[payload.method]; + if (payload.method === 'eth_call') { + const fn = ABI.parseTransaction(payload.params[0]).name; + result = ABI.encodeFunctionResult(fn, [{ levels: 20, denomination: pool.denomination, nextIndex: 1, getLastRoot: w(33) }[fn]]); + } + return { status: 200, data: { jsonrpc: '2.0', id: payload.id, result } }; + }); + return { post, values, rpc: createPrivacyRpc('https://archive.example/rpc', { post, now: () => 1700000000000 }) }; +} +test('RPC has strict transport guards and only fixed read methods and pool address', async () => { + const f = fixture(); const head = await f.rpc.head(); + expect((await f.rpc.state(pool, head)).nextIndex).toBe(1); + expect((await f.rpc.deposits(pool, 12000000, 12000000))[0].leaf).toBe(w(1)); + expect(new Set(f.post.mock.calls.map(([, p]) => p.method))).toEqual(new Set(['eth_chainId', 'eth_getBlockByNumber', 'eth_getCode', 'eth_call', 'eth_getLogs'])); + for (const [, , opts] of f.post.mock.calls) expect(opts).toMatchObject({ timeout: 10000, maxRedirects: 0, maxContentLength: MAX_RPC_BYTES }); +}); +test.each(['0x01', '0x', '0x-1', '1', 1, '0x20000000000000'])('rejects malformed/unsafe RPC quantity %p', (value) => { expect(() => quantity(value)).toThrow(); }); +test('rejects wrong chain, stale headers and wrong block numbers', async () => { + await expect(fixture({ eth_chainId: '0x39' }).rpc.head()).rejects.toThrow('privacy_wrong_chain'); + await expect(fixture({ eth_getBlockByNumber: { number: '0x1', hash: w(1), parentHash: w(0), timestamp: '0x1' } }).rpc.head()).rejects.toThrow('privacy_stale_chain'); + await expect(fixture().rpc.block(1)).rejects.toThrow('privacy_wrong_block'); +}); +test('rejects mismatched IDs, JSON-RPC error, status and failed transport without leaking upstream body', async () => { + for (const response of [ + { status: 200, data: { jsonrpc: '2.0', id: 999, result: '0x1' } }, + { status: 302, data: { jsonrpc: '2.0', id: 1, result: '0x1' } }, + ]) await expect(createPrivacyRpc('https://a.example', { post: async () => response }).head()).rejects.toThrow('privacy_rpc_invalid_response'); + await expect(createPrivacyRpc('https://a.example', { post: async () => ({ status: 200, data: { jsonrpc: '2.0', id: 1, error: { message: 'secret' } } }) }).head()).rejects.toThrow('privacy_rpc_rejected'); + await expect(createPrivacyRpc('https://a.example', { post: async () => { throw new Error('https://a.example/secret'); } }).head()).rejects.toThrow('privacy_rpc_unavailable'); +}); +test('rejects wrong pool parameters', async () => { + const f = fixture(); + const post = async (url, payload, opts) => { + const response = await f.post(url, payload, opts); + if (payload.method === 'eth_call' && ABI.parseTransaction(payload.params[0]).name === 'levels') response.data.result = ABI.encodeFunctionResult('levels', [21]); + return response; + }; + const rpc = createPrivacyRpc('https://a.example', { post }); + await expect(rpc.state(pool, { number: 12000000 })).rejects.toThrow('privacy_pool_mismatch'); +}); +test.each(['removed', 'address', 'field', 'range', 'data', 'index'])('rejects malicious %s log', async (kind) => { + const f = fixture(); const row = f.values.eth_getLogs[0]; + if (kind === 'removed') row.removed = true; + if (kind === 'address') row.address = POOLS[1].address; + if (kind === 'field') row.topics[1] = w(FIELD_SIZE); + if (kind === 'range') row.blockNumber = '0x1'; + if (kind === 'data') row.data += '00'; + if (kind === 'index') row.logIndex = '0x00'; + await expect(f.rpc.deposits(pool, 12000000, 12000000)).rejects.toThrow(); +});