From 85645b6356a7327f1864d8ed85c8d339398974d6 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Tue, 27 Oct 2026 17:43:03 -0700 Subject: [PATCH 1/3] Add standalone local privacy API --- .env.example | 4 +- .gitignore | 1 + README.md | 3 + docs/privacy-index.md | 4 +- docs/privacy-relayers.md | 4 +- docs/privacy-standalone.md | 65 ++++++++++++++++ lib/privacyRelayerRpc.js | 6 +- lib/privacyRpc.js | 7 +- lib/privacyRpcUrl.js | 19 +++++ package.json | 1 + privacy-server.js | 94 ++++++++++++++++++++++++ tests/helpers/privacyStandaloneFence.cjs | 20 +++++ tests/privacyLocalRpc.test.js | 81 ++++++++++++++++++++ tests/privacyStandalone.test.js | 59 +++++++++++++++ tests/privacyStandaloneProcess.test.js | 71 ++++++++++++++++++ 15 files changed, 429 insertions(+), 10 deletions(-) create mode 100644 docs/privacy-standalone.md create mode 100644 lib/privacyRpcUrl.js create mode 100644 privacy-server.js create mode 100644 tests/helpers/privacyStandaloneFence.cjs create mode 100644 tests/privacyLocalRpc.test.js create mode 100644 tests/privacyStandalone.test.js create mode 100644 tests/privacyStandaloneProcess.test.js diff --git a/.env.example b/.env.example index ccbfc89..c958313 100644 --- a/.env.example +++ b/.env.example @@ -97,7 +97,9 @@ SYSCOIN_SENTRY_GENESIS_HASH= # Public Tornado Classic ETH pool index. Enabled by default when the RPC below # is configured; set PRIVACY_INDEX_ENABLED=false to disable it. The RPC must support finalized # headers, historical state and eth_getLogs from the 2019 deployment blocks. -# HTTPS only; no wallet keys or caller-supplied endpoints. First request opts a +# Remote RPCs use HTTPS; local HTTP is allowed only for localhost or literal +# 127.0.0.1/[::1] with explicit port. No wallet keys or caller-supplied endpoints. +# First request opts a # pool into backfill and periodic refresh; previously used pools resume on restart. # Unused pools stay idle. See docs/privacy-index.md for lifecycle and feed trust. PRIVACY_INDEX_ENABLED= diff --git a/.gitignore b/.gitignore index ac2026e..c3b3329 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,6 @@ coverage/ *.sqlite-journal data/sysnode.db* data/privacy-index/ +data/privacy-relayers/ .DS_Store npm-debug.log* diff --git a/README.md b/README.md index c7cc765..92c218b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,9 @@ should use lowercase. - `GET /api/privacy/v1/relayers` — anonymous Classic ETH relayer discovery; see [source checks and endpoint boundaries](docs/privacy-relayers.md). +For a local privacy-only API without the other Sysnode services, see +[local privacy API setup](docs/privacy-standalone.md). + Authenticated routes (cookie + CSRF, same-site): - `/auth/*` — registration, verification, login, session, delete account diff --git a/docs/privacy-index.md b/docs/privacy-index.md index bd093ba..59e1e05 100644 --- a/docs/privacy-index.md +++ b/docs/privacy-index.md @@ -19,7 +19,9 @@ These are pinned from the original ## Configuration and deployment The feature is enabled by default when `PRIVACY_INDEX_RPC_URL` supplies an -operator-controlled HTTPS Ethereum mainnet RPC. Set `PRIVACY_INDEX_ENABLED=false` +operator-controlled Ethereum mainnet RPC. Remote endpoints require HTTPS; +local HTTP is supported only for `localhost`, `127.0.0.1`, or `[::1]` with an +explicit port; `localhost` is pinned to `127.0.0.1` without DNS. Set `PRIVACY_INDEX_ENABLED=false` to disable it independently. With no RPC it remains disabled; explicitly setting `PRIVACY_INDEX_ENABLED=true` without an RPC is a configuration error. No default RPC is selected. URLs containing credentials or fragments are rejected. A provider API diff --git a/docs/privacy-relayers.md b/docs/privacy-relayers.md index bd0a17c..f89c517 100644 --- a/docs/privacy-relayers.md +++ b/docs/privacy-relayers.md @@ -8,7 +8,9 @@ balance computation and the optional direct-withdrawal path. ## Deployment The directory is enabled by default when `PRIVACY_INDEX_RPC_URL` is set to an -operator-controlled HTTPS Ethereum mainnet RPC. Set +operator-controlled Ethereum mainnet RPC. Remote endpoints require HTTPS; +local HTTP is supported only for `localhost`, `127.0.0.1`, or `[::1]` with an +explicit port; `localhost` is pinned to `127.0.0.1` without DNS. Set `PRIVACY_RELAYER_DIRECTORY_ENABLED=false` to disable it independently. With no RPC it remains disabled; explicitly setting the directory flag to `true` without an RPC is a configuration error. `PRIVACY_INDEX_ENABLED` does not control the diff --git a/docs/privacy-standalone.md b/docs/privacy-standalone.md new file mode 100644 index 0000000..da6b07b --- /dev/null +++ b/docs/privacy-standalone.md @@ -0,0 +1,65 @@ +# Run the local privacy API + +The privacy-only process serves public Ethereum Classic pool snapshots and the +public relayer directory. It does not start Syscoin RPC, masternode/governance +workers, the account database, or wallet/secret storage. It uses the existing +pool index and directory implementations and their normal verification and +backfill rules. The repository installation still includes the full backend's +dependencies. + +## Start + +Use Node.js 26.12.1 or later in the 26.x series. From this repository: + +```sh +npm ci +cp .env.example .env +chmod 600 .env +# Edit .env and set PRIVACY_INDEX_RPC_URL to your Ethereum mainnet RPC. +npm run privacy:start +``` + +For example, if an Ethereum node is listening on this machine: + +```text +PRIVACY_INDEX_RPC_URL=http://127.0.0.1:8545 +``` + +`http://localhost:8545` is mapped to `127.0.0.1` without DNS; explicit IPv6 +loopback such as `http://[::1]:8545` is also accepted. Remote RPC endpoints +must use HTTPS. The node must provide finalized blocks, historical logs and +the historical contract/state reads described in [pool indexing](privacy-index.md) +and [relayer discovery](privacy-relayers.md). A pruned or partially synced full +node may not serve all of that history; Ethereum mainnet is required. + +The local API binds only `127.0.0.1`, port **3477** by default. Set +`PRIVACY_PORT` in `.env` to another port if needed. Its endpoints are +`http://127.0.0.1:3477/api/privacy/v1/relayers` and +`http://127.0.0.1:3477/api/privacy/v1/pools/eth-0.1/snapshot` (and the other +fixed pool IDs). A wallet must support a configurable privacy API URL and have +browser permission to access loopback; installing this service alone does not +configure an existing wallet. Pali also uses its selected Ethereum RPC for +independent public pool-wide verification. + +Both services are enabled when the RPC is configured, unless their separate +`PRIVACY_INDEX_ENABLED=false` or `PRIVACY_RELAYER_DIRECTORY_ENABLED=false` +option is set. First use activates backfill; afterward checkpoints and complete +snapshots persist in `data/privacy-index/` and `data/privacy-relayers/` under +the working directory and resume after restart. Stop with Ctrl-C for graceful +shutdown. Run **one process per data directory**; these files do not have +cross-process writer locking. The full backend and this service must not share +the same data directory while both are running. + +Initial historical backfill takes time and RPC resources. A `202` response +means it is still syncing; `503` means disabled or unavailable. A ready +snapshot is anchored to a finalized block. The pool root does not prove that +the withdrawal list is complete, so its completeness still depends on the +configured Ethereum RPC and scanner. See [pool indexing](privacy-index.md). + +The local API accepts no wallet addresses, notes or nullifier queries. It +binds to loopback, validates the Host header, accepts browser requests only +from extension or local origins, and permits originless native clients such as +curl. Local programs and browser extensions can still contact it; these +checks are not authentication against other software on the same computer. +The upstream Ethereum provider can see pool-wide synchronization traffic and +timing. No relayer withdrawal submissions are proxied by this API. diff --git a/lib/privacyRelayerRpc.js b/lib/privacyRelayerRpc.js index 44384ed..0082d7d 100644 --- a/lib/privacyRelayerRpc.js +++ b/lib/privacyRelayerRpc.js @@ -3,14 +3,13 @@ const axios = require('axios'); const { toQuantity, keccak256, ZeroAddress } = require('ethers'); const { quantity } = require('./privacyRpc'); +const { privacyRpcUrl } = require('./privacyRpcUrl'); const { word } = require('./privacyRegistry'); const { REGISTRY, IMPLEMENTATION, IMPLEMENTATION_CODE_HASH, IMPLEMENTATION_SLOT, ENS, ABI, candidate, address, urlNode } = require('./privacyRelayerRegistry'); const TOPIC = ABI.getEvent('RelayerRegistered').topicHash; function createPrivacyRelayerRpc(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'); + const { url, localHttp } = privacyRpcUrl(rpcUrl); let id = 0; async function call(method, params, context = {}) { context.beforeRequest?.(); @@ -20,6 +19,7 @@ function createPrivacyRelayerRpc(rpcUrl, { post = axios.post, now = Date.now } = res = await post(url.href, { jsonrpc: '2.0', id: current, method, params }, { timeout: 10000, maxRedirects: 0, maxContentLength: 4 * 1024 * 1024, maxBodyLength: 16384, responseType: 'json', transitional: { silentJSONParsing: false }, headers: { 'Content-Type': 'application/json' }, + ...(localHttp ? { proxy: false } : {}), }); } catch (_) { throw new Error('privacy_relayer_rpc_unavailable'); } if (res.status !== 200 || !res.data || res.data.jsonrpc !== '2.0' || res.data.id !== current || diff --git a/lib/privacyRpc.js b/lib/privacyRpc.js index d251f6b..d08aa06 100644 --- a/lib/privacyRpc.js +++ b/lib/privacyRpc.js @@ -3,6 +3,7 @@ const axios = require('axios'); const { Interface, toQuantity } = require('ethers'); const { MAX_LEAVES, word, fieldWord } = require('./privacyRegistry'); +const { privacyRpcUrl } = require('./privacyRpcUrl'); const ABI = new Interface([ 'event Deposit(bytes32 indexed commitment,uint32 leafIndex,uint256 timestamp)', @@ -24,9 +25,7 @@ function quantity(value) { } 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'); + const { url, localHttp } = privacyRpcUrl(rpcUrl); let id = 0; async function call(method, params, { beforeRequest } = {}) { // A logical read such as state() uses several HTTP requests. Check the @@ -39,7 +38,7 @@ function createPrivacyRpc(rpcUrl, { post = axios.post, now = Date.now } = {}) { timeout: 10000, maxRedirects: 0, maxContentLength: MAX_RPC_BYTES, maxBodyLength: 16384, responseType: 'json', transitional: { silentJSONParsing: false }, - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json' }, ...(localHttp ? { proxy: false } : {}), }); } catch (_) { throw new Error('privacy_rpc_unavailable'); } const body = response.data; diff --git a/lib/privacyRpcUrl.js b/lib/privacyRpcUrl.js new file mode 100644 index 0000000..8cb6a29 --- /dev/null +++ b/lib/privacyRpcUrl.js @@ -0,0 +1,19 @@ +'use strict'; + +// HTTP is permitted only for an explicitly named loopback endpoint. Check the +// raw authority as well as the parsed hostname: WHATWG URL canonicalizes short +// and numeric IPv4 forms, which must not become implicit aliases here. Map +// localhost to a literal before connecting, so DNS cannot redirect it. +function privacyRpcUrl(value) { + let url; + try { url = new URL(value); } catch (_) { throw new Error('privacy_rpc_url_invalid'); } + if (url.username || url.password || url.hash) throw new Error('privacy_rpc_url_invalid'); + const localHttp = url.protocol === 'http:' && typeof value === 'string' && + /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):[0-9]+(?:[/?]|$)/i.test(value) && + (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]') && Boolean(url.port); + if (url.protocol !== 'https:' && !localHttp) throw new Error('privacy_rpc_url_invalid'); + if (localHttp && url.hostname === 'localhost') url.hostname = '127.0.0.1'; + return { url, localHttp }; +} + +module.exports = { privacyRpcUrl }; diff --git a/package.json b/package.json index ad9489e..66da7a1 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "main": "server.js", "scripts": { "start": "node server.js", + "privacy:start": "node --env-file-if-exists=.env privacy-server.js", "dev": "nodemon server.js", "test": "jest", "test:watch": "jest --watch", diff --git a/privacy-server.js b/privacy-server.js new file mode 100644 index 0000000..81b620a --- /dev/null +++ b/privacy-server.js @@ -0,0 +1,94 @@ +'use strict'; + +// A local-only public-data service. It does not load server.js, the Syscoin +// RPC client, the database, or any of the masternode/governance workers. +const express = require('express'); +const { configuredPrivacyIndex } = require('./lib/privacyIndex'); +const { configuredPrivacyRelayerDirectory } = require('./lib/privacyRelayerDirectory'); +const { PRIVACY_PREFIXES, createPrivacyRouter } = require('./routes/privacy'); + +const HOST = '127.0.0.1'; +const DEFAULT_PORT = 3477; + +function portFrom(value) { + if (value === undefined || value === '') return DEFAULT_PORT; + if (!/^[0-9]+$/.test(value) || Number(value) < 1 || Number(value) > 65535) { + throw new Error('PRIVACY_PORT must be a port between 1 and 65535'); + } + return Number(value); +} + +function localHost(value, port) { + return typeof value === 'string' && + new RegExp(`^(?:localhost|127\\.0\\.0\\.1):${port}$`, 'i').test(value); +} + +function allowedOrigin(value) { + if (!value) return true; // Native local clients, including curl. + if (/^chrome-extension:\/\/[a-p]{32}$/.test(value) || + /^moz-extension:\/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) return true; + try { + const url = new URL(value); + return (url.protocol === 'http:' || url.protocol === 'https:') && + (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && url.origin === value; + } catch (_) { return false; } +} + +function createPrivacyApp({ index, relayers }) { + const app = express(); + // Host checks prevent a DNS name pointing at loopback from acting as this + // service; origin checks keep ordinary remote web pages from reading it. + app.use((req, res, next) => { + if (!localHost(req.headers.host, req.socket.localPort) || !allowedOrigin(req.headers.origin) || + (!req.headers.origin && req.headers['sec-fetch-site'] && req.headers['sec-fetch-site'] !== 'none')) { + return res.status(403).json({ error: 'local_privacy_request_rejected' }); + } + next(); + }); + app.get('/health', (_req, res) => res.json({ ok: true })); + app.use(PRIVACY_PREFIXES, createPrivacyRouter({ index, relayers })); + return app; +} + +async function main(env = process.env) { + const port = portFrom(env.PRIVACY_PORT); + let index, relayers; + try { + index = configuredPrivacyIndex({ enabled: env.PRIVACY_INDEX_ENABLED, rpcUrl: env.PRIVACY_INDEX_RPC_URL }); + relayers = configuredPrivacyRelayerDirectory(env); + if (!index && !relayers) throw new Error('Configure PRIVACY_INDEX_RPC_URL and enable at least one privacy service'); + const app = createPrivacyApp({ index, relayers }); + const server = await new Promise((resolve, reject) => { + const listener = app.listen(port, HOST, () => resolve(listener)); + listener.once('error', reject); + }); + let closing; + const close = () => { + if (!closing) closing = Promise.all([ + new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())), + ...[index, relayers].filter(Boolean).map((service) => service.close()), + ]); + return closing; + }; + return { server, close }; + } catch (error) { + await Promise.allSettled([index, relayers].filter(Boolean).map((service) => service.close())); + throw error; + } +} + +if (require.main === module) { + main().then(({ server, close }) => { + console.log(`Local privacy API listening on http://${HOST}:${server.address().port}`); + const stop = () => close().catch(() => { console.error('Privacy API shutdown failed'); process.exitCode = 1; }); + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + }).catch((error) => { + // Never print RPC URLs or provider errors, which may contain API keys. + console.error(error.message === 'Configure PRIVACY_INDEX_RPC_URL and enable at least one privacy service' + ? error.message : 'Unable to start privacy API; check configuration and port'); + process.exitCode = 1; + }); +} + +module.exports = { HOST, DEFAULT_PORT, portFrom, localHost, allowedOrigin, createPrivacyApp, main }; diff --git a/tests/helpers/privacyStandaloneFence.cjs b/tests/helpers/privacyStandaloneFence.cjs new file mode 100644 index 0000000..df4db13 --- /dev/null +++ b/tests/helpers/privacyStandaloneFence.cjs @@ -0,0 +1,20 @@ +'use strict'; + +// This subprocess may call only its synthetic loopback node. Loading the full +// backend or its Syscoin/database workers is also a test failure. +const Module = require('module'); +const load = Module._load; +Module._load = function (name, ...args) { + if (name === 'better-sqlite3' || /(?:^|\/)services\/(?:sysMain|masternodeTracker|rpcClient)$/.test(name) || + /(?:^|\/)server\.js$/.test(name)) throw new Error('unexpected full backend import'); + return load.call(this, name, ...args); +}; +const http = require('http'); +const https = require('https'); +const request = http.request; +http.request = function (options, ...args) { + const host = typeof options === 'string' ? new URL(options).hostname : options.hostname || options.host; + if (host !== '127.0.0.1') throw new Error('non-loopback network request'); + return request.call(this, options, ...args); +}; +https.request = () => { throw new Error('external HTTPS request'); }; diff --git a/tests/privacyLocalRpc.test.js b/tests/privacyLocalRpc.test.js new file mode 100644 index 0000000..9cef514 --- /dev/null +++ b/tests/privacyLocalRpc.test.js @@ -0,0 +1,81 @@ +'use strict'; + +const http = require('http'); +const { privacyRpcUrl } = require('../lib/privacyRpcUrl'); +const { createPrivacyRpc } = require('../lib/privacyRpc'); +const { createPrivacyRelayerRpc } = require('../lib/privacyRelayerRpc'); + +test.each(['http://localhost:8545', 'http://127.0.0.1:8545', 'http://[::1]:8545/rpc'])('permits explicit local node %s', (input) => { + expect(privacyRpcUrl(input).localHttp).toBe(true); + expect(() => createPrivacyRpc(input)).not.toThrow(); + expect(() => createPrivacyRelayerRpc(input)).not.toThrow(); +}); +test('maps localhost to literal IPv4 and retains supported remote HTTPS', () => { + expect(privacyRpcUrl('http://localhost:8545/path').url.href).toBe('http://127.0.0.1:8545/path'); + expect(privacyRpcUrl('https://rpc.example/v2/token?x=1').localHttp).toBe(false); +}); +test.each([ + 'http://example.com:8545', 'http://localhost.example:8545', 'http://localhost:80', + 'http://127.0.0.2:8545', 'http://127.1:8545', 'http://2130706433:8545', + 'http://0x7f000001:8545', 'http://[::ffff:127.0.0.1]:8545', 'http://192.168.1.1:8545', + 'http://user:password@127.0.0.1:8545', 'http://127.0.0.1:8545/#fragment', + 'https://user:password@rpc.example', 'https://rpc.example/#fragment', 'file:///tmp/rpc', +])('rejects unsafe or ambiguous RPC endpoint %s', (input) => { + expect(() => createPrivacyRpc(input)).toThrow('privacy_rpc_url_invalid'); + expect(() => createPrivacyRelayerRpc(input)).toThrow('privacy_rpc_url_invalid'); +}); + +test('both clients use the real loopback node without proxy, preserve finalized and reject the wrong chain', async () => { + let requests = 0, proxyRequests = 0, chainId = '0x1'; + const rpcServer = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + requests++; + const { id, method, params } = JSON.parse(body); + const block = { number: '0x1', hash: `0x${'11'.repeat(32)}`, + parentHash: `0x${'22'.repeat(32)}`, timestamp: `0x${Math.floor(Date.now() / 1000).toString(16)}` }; + const result = method === 'eth_chainId' ? chainId : params[0] === 'finalized' ? block : null; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ jsonrpc: '2.0', id, result })); + }); + }); + const proxy = http.createServer((_req, res) => { proxyRequests++; res.writeHead(500).end(); }); + await Promise.all([rpcServer, proxy].map((s) => new Promise((resolve) => s.listen(0, '127.0.0.1', resolve)))); + const original = { HTTP_PROXY: process.env.HTTP_PROXY, http_proxy: process.env.http_proxy, + NO_PROXY: process.env.NO_PROXY, no_proxy: process.env.no_proxy }; + try { + process.env.HTTP_PROXY = process.env.http_proxy = `http://127.0.0.1:${proxy.address().port}`; + process.env.NO_PROXY = process.env.no_proxy = ''; + const url = `http://localhost:${rpcServer.address().port}`; + const rpc = createPrivacyRpc(url), relay = createPrivacyRelayerRpc(url); + expect((await rpc.head()).number).toBe(1); + expect((await relay.head()).number).toBe(1); + expect(requests).toBe(4); + expect(proxyRequests).toBe(0); + chainId = '0xaa36a7'; + await expect(rpc.head()).rejects.toThrow('privacy_wrong_chain'); + await expect(relay.head()).rejects.toThrow('privacy_relayer_wrong_chain'); + } finally { + for (const [key, value] of Object.entries(original)) value === undefined ? delete process.env[key] : process.env[key] = value; + await Promise.all([rpcServer, proxy].map((s) => new Promise((resolve) => s.close(resolve)))); + } +}); + +test('both clients refuse HTTP redirects from a local node', async () => { + let redirected = 0; + const other = http.createServer((_req, res) => { redirected++; res.end('{}'); }); + await new Promise((resolve) => other.listen(0, '127.0.0.1', resolve)); + const redirect = http.createServer((_req, res) => { + res.writeHead(302, { location: `http://127.0.0.1:${other.address().port}` }).end(); + }); + await new Promise((resolve) => redirect.listen(0, '127.0.0.1', resolve)); + try { + const url = `http://127.0.0.1:${redirect.address().port}`; + await expect(createPrivacyRpc(url).head()).rejects.toThrow('privacy_rpc_unavailable'); + await expect(createPrivacyRelayerRpc(url).head()).rejects.toThrow('privacy_relayer_rpc_unavailable'); + expect(redirected).toBe(0); + } finally { + await Promise.all([redirect, other].map((s) => new Promise((resolve) => s.close(resolve)))); + } +}); diff --git a/tests/privacyStandalone.test.js b/tests/privacyStandalone.test.js new file mode 100644 index 0000000..3f667b8 --- /dev/null +++ b/tests/privacyStandalone.test.js @@ -0,0 +1,59 @@ +'use strict'; + +const expressRequest = require('supertest'); +const { HOST, portFrom, createPrivacyApp } = require('../privacy-server'); + +const extension = `chrome-extension://${'a'.repeat(32)}`; +const pool = '/api/privacy/v1/pools/eth-0.1/snapshot'; +let listener, index, relayers; + +beforeEach(async () => { + index = { read: jest.fn(async () => ({ status: 202, body: { phase: 'syncing' } })) }; + relayers = { read: jest.fn(async () => ({ status: 202, body: { phase: 'syncing' } })) }; + const app = createPrivacyApp({ index, relayers }); + listener = await new Promise((resolve) => { const s = app.listen(0, HOST, () => resolve(s)); }); +}); +afterEach(async () => { await new Promise((resolve, reject) => listener.close((error) => error ? reject(error) : resolve())); }); + +test('binds IPv4 loopback and serves existing public endpoints to extension origins', async () => { + expect(listener.address().address).toBe('127.0.0.1'); + const api = expressRequest(listener); + const result = await api.get(pool).set('Origin', extension); + expect(result.status).toBe(202); + expect(result.headers['access-control-allow-origin']).toBe('*'); + expect(result.headers['set-cookie']).toBeUndefined(); + expect(index.read).toHaveBeenCalledWith('eth-0.1'); + expect((await api.get('/privacy/v1/relayers').set('Origin', extension)).status).toBe(202); + expect(relayers.read).toHaveBeenCalledTimes(1); + expect((await api.get('/health')).body).toEqual({ ok: true }); + // Chromium MV3 service-worker fetches may omit Origin and send site=none. + expect((await api.get('/privacy/v1/relayers').set('Sec-Fetch-Site', 'none')).status).toBe(202); +}); + +test('rejects untrusted Host and web Origin before activating index work', async () => { + const api = expressRequest(listener); + const port = listener.address().port; + for (const host of [`attacker.example:${port}`, `localhost.attacker.example:${port}`, `localhost@attacker.example:${port}`, `127.0.0.1:${port + 1}`]) { + expect((await api.get(pool).set('Host', host)).status).toBe(403); + } + for (const origin of ['https://attacker.example', 'http://localhost.attacker.example', 'null', `${extension}.attacker.example`]) { + expect((await api.get(pool).set('Origin', origin)).status).toBe(403); + } + expect((await api.get(pool).set('Sec-Fetch-Site', 'cross-site')).status).toBe(403); + expect(index.read).not.toHaveBeenCalled(); +}); + +test('retains privacy router query, body, write and unknown-pool guards', async () => { + const api = expressRequest(listener); + expect((await api.get(`${pool}?note=secret`).set('Origin', extension)).status).toBe(400); + expect((await api.get(pool).send({ note: 'secret' })).status).toBe(400); + expect((await api.post(pool).send({ note: 'secret' })).status).toBe(405); + expect((await api.get('/api/privacy/v1/pools/arbitrary/snapshot')).status).toBe(404); + expect(index.read).not.toHaveBeenCalled(); +}); + +test('uses a bounded explicit port', () => { + expect(portFrom()).toBe(3477); + expect(portFrom('9876')).toBe(9876); + for (const value of ['0', '-1', '65536', '1.5', '3477x']) expect(() => portFrom(value)).toThrow(); +}); diff --git a/tests/privacyStandaloneProcess.test.js b/tests/privacyStandaloneProcess.test.js new file mode 100644 index 0000000..ec6450c --- /dev/null +++ b/tests/privacyStandaloneProcess.test.js @@ -0,0 +1,71 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const http = require('http'); +const net = require('net'); +const { spawn } = require('child_process'); + +const source = path.resolve(__dirname, '../privacy-server.js'); +const fence = path.resolve(__dirname, 'helpers/privacyStandaloneFence.cjs'); +async function withTimeout(promise, ms, message) { + let timer; + try { return await Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); })]); } + finally { clearTimeout(timer); } +} + +test('standalone process uses no Syscoin workers, activates locally, and waits for in-flight work on shutdown', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-standalone-test-')); + const reservation = net.createServer(); + await new Promise((resolve) => reservation.listen(0, '127.0.0.1', resolve)); + const port = reservation.address().port; + await new Promise((resolve) => reservation.close(resolve)); + let gotCall; + const called = new Promise((resolve) => { gotCall = resolve; }); + let release; + const rpc = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + const payload = JSON.parse(body); + release = () => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ jsonrpc: '2.0', id: payload.id, result: '0xaa36a7' })); }; + gotCall(); + }); + }); + await new Promise((resolve) => rpc.listen(0, '127.0.0.1', resolve)); + const env = { ...process.env }; + for (const key of Object.keys(env)) if (key.startsWith('PRIVACY_')) delete env[key]; + Object.assign(env, { PRIVACY_PORT: String(port), PRIVACY_INDEX_ENABLED: 'false', + PRIVACY_INDEX_RPC_URL: `http://localhost:${rpc.address().port}`, + PRIVACY_RELAYER_DIRECTORY_ENABLED: 'true', NODE_OPTIONS: `--require=${fence}` }); + const child = spawn(process.execPath, [source], { cwd: dir, env, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = '', stderr = ''; + child.stdout.on('data', (v) => { stdout += v; }); + child.stderr.on('data', (v) => { stderr += v; }); + const exited = new Promise((resolve) => child.once('exit', (code, signal) => resolve({ code, signal }))); + try { + for (let i = 0; i < 100 && !stdout.includes('listening'); i++) await new Promise((resolve) => setTimeout(resolve, 50)); + expect(stdout).toContain('Local privacy API listening'); + expect(fs.existsSync(path.join(dir, 'data/sysnode.db'))).toBe(false); + const get = (pathname) => new Promise((resolve, reject) => { + http.get(`http://127.0.0.1:${port}${pathname}`, (res) => { res.resume(); res.on('end', () => resolve(res.statusCode)); }).on('error', reject); + }); + expect(await get('/health')).toBe(200); + expect(await get('/api/privacy/v1/relayers')).toBe(202); + await withTimeout(called, 3000, 'RPC was not activated'); + child.kill('SIGTERM'); + // An already-started RPC is allowed to finish before the process exits. + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(child.exitCode).toBeNull(); + release(); release = null; + const result = await withTimeout(exited, 5000, 'shutdown stalled'); + expect(result).toEqual({ code: 0, signal: null }); + expect(stderr).toBe(''); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + if (release) release(); + await new Promise((resolve) => rpc.close(resolve)); + fs.rmSync(dir, { recursive: true, force: true }); + } +}, 15000); From 2d8855500a42c929b496c41b24bbb4c69e947da4 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Tue, 27 Oct 2026 17:44:10 -0700 Subject: [PATCH 2/3] Test local pool restart and checkpoint recovery --- tests/privacyStandaloneProcess.test.js | 73 ++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/privacyStandaloneProcess.test.js b/tests/privacyStandaloneProcess.test.js index ec6450c..936292e 100644 --- a/tests/privacyStandaloneProcess.test.js +++ b/tests/privacyStandaloneProcess.test.js @@ -6,6 +6,8 @@ const path = require('path'); const http = require('http'); const net = require('net'); const { spawn } = require('child_process'); +const { ABI } = require('../lib/privacyRpc'); +const { POOLS } = require('../lib/privacyRegistry'); const source = path.resolve(__dirname, '../privacy-server.js'); const fence = path.resolve(__dirname, 'helpers/privacyStandaloneFence.cjs'); @@ -69,3 +71,74 @@ test('standalone process uses no Syscoin workers, activates locally, and waits f fs.rmSync(dir, { recursive: true, force: true }); } }, 15000); + +test('standalone pool snapshot persists and resumes after restart without starting Syscoin', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-pool-restart-')); + const reserve = net.createServer(); + await new Promise((resolve) => reserve.listen(0, '127.0.0.1', resolve)); + const port = reserve.address().port; + await new Promise((resolve) => reserve.close(resolve)); + let calls = 0; + const block = (number) => ({ number: `0x${number.toString(16)}`, + hash: `0x${'11'.repeat(32)}`, parentHash: `0x${'22'.repeat(32)}`, + timestamp: `0x${Math.floor(Date.now() / 1000).toString(16)}` }); + const target = POOLS[0].deploymentBlock + 1; + const rpc = http.createServer((req, res) => { + let body = ''; + req.on('data', (v) => { body += v; }); + req.on('end', () => { + calls++; + const { id, method, params } = JSON.parse(body); + let result; + if (method === 'eth_chainId') result = '0x1'; + else if (method === 'eth_getBlockByNumber') result = block(params[0] === 'finalized' ? target : Number(BigInt(params[0]))); + else if (method === 'eth_getCode') result = '0x01'; + else if (method === 'eth_getLogs') result = []; + else if (method === 'eth_call') { + const { name } = ABI.parseTransaction(params[0]); + const values = { levels: 20n, denomination: BigInt(POOLS[0].denomination), nextIndex: 0n, getLastRoot: `0x${'00'.repeat(32)}` }; + result = ABI.encodeFunctionResult(name, [values[name]]); + } else { res.writeHead(500).end(); return; } + res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ jsonrpc: '2.0', id, result })); + }); + }); + await new Promise((resolve) => rpc.listen(0, '127.0.0.1', resolve)); + const env = { ...process.env }; + for (const key of Object.keys(env)) if (key.startsWith('PRIVACY_')) delete env[key]; + Object.assign(env, { PRIVACY_PORT: String(port), PRIVACY_INDEX_RPC_URL: `http://127.0.0.1:${rpc.address().port}`, + PRIVACY_RELAYER_DIRECTORY_ENABLED: 'false', NODE_OPTIONS: `--require=${fence}` }); + let child; + const launch = async () => { + child = spawn(process.execPath, [source], { cwd: dir, env, stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; child.stdout.on('data', (v) => { output += v; }); + for (let i = 0; i < 100 && !output.includes('listening'); i++) await new Promise((resolve) => setTimeout(resolve, 50)); + expect(output).toContain('Local privacy API listening'); + }; + const stop = async () => { const exited = new Promise((resolve) => child.once('exit', resolve)); child.kill('SIGTERM'); await withTimeout(exited, 5000, 'shutdown stalled'); }; + const read = () => new Promise((resolve, reject) => http.get(`http://127.0.0.1:${port}/api/privacy/v1/pools/eth-0.1/snapshot`, (res) => { + let body = ''; res.on('data', (v) => { body += v; }); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(body) })); + }).on('error', reject)); + try { + await launch(); + expect((await read()).status).toBe(202); + let snapshot; + for (let i = 0; i < 100; i++) { snapshot = await read(); if (snapshot.status === 200) break; await new Promise((resolve) => setTimeout(resolve, 50)); } + expect(snapshot.status).toBe(200); + expect(snapshot.body).toEqual(expect.objectContaining({ asOfBlockNumber: target, leafCount: 0, withdrawnNullifiers: [] })); + expect(fs.existsSync(path.join(dir, 'data/privacy-index/eth-0.1.snapshot.json'))).toBe(true); + expect(fs.existsSync(path.join(dir, 'data/sysnode.db'))).toBe(false); + await stop(); + const priorCalls = calls; + await launch(); + for (let i = 0; i < 100 && calls === priorCalls; i++) await new Promise((resolve) => setTimeout(resolve, 50)); + expect(calls).toBeGreaterThan(priorCalls); // Existing active pool revalidates without another request. + for (let i = 0; i < 100; i++) { snapshot = await read(); if (snapshot.status === 200) break; await new Promise((resolve) => setTimeout(resolve, 50)); } + expect(snapshot.status).toBe(200); + expect(snapshot.body.asOfBlockNumber).toBe(target); + await stop(); + } finally { + if (child && child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await new Promise((resolve) => rpc.close(resolve)); + fs.rmSync(dir, { recursive: true, force: true }); + } +}, 15000); From 425ccccc80121fea176e226affbcd91fb88321fc Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Tue, 27 Oct 2026 17:45:28 -0700 Subject: [PATCH 3/3] Allow standard port 80 loopback Host headers --- privacy-server.js | 5 +++-- tests/privacyStandalone.test.js | 11 ++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/privacy-server.js b/privacy-server.js index 81b620a..ce8f761 100644 --- a/privacy-server.js +++ b/privacy-server.js @@ -19,8 +19,9 @@ function portFrom(value) { } function localHost(value, port) { - return typeof value === 'string' && - new RegExp(`^(?:localhost|127\\.0\\.0\\.1):${port}$`, 'i').test(value); + if (typeof value !== 'string') return false; + const match = /^(?:localhost|127\.0\.0\.1)(?::([0-9]+))?$/i.exec(value); + return Boolean(match && (match[1] === undefined ? port === 80 : match[1] === String(port))); } function allowedOrigin(value) { diff --git a/tests/privacyStandalone.test.js b/tests/privacyStandalone.test.js index 3f667b8..b9765bc 100644 --- a/tests/privacyStandalone.test.js +++ b/tests/privacyStandalone.test.js @@ -1,7 +1,7 @@ 'use strict'; const expressRequest = require('supertest'); -const { HOST, portFrom, createPrivacyApp } = require('../privacy-server'); +const { HOST, portFrom, localHost, createPrivacyApp } = require('../privacy-server'); const extension = `chrome-extension://${'a'.repeat(32)}`; const pool = '/api/privacy/v1/pools/eth-0.1/snapshot'; @@ -57,3 +57,12 @@ test('uses a bounded explicit port', () => { expect(portFrom('9876')).toBe(9876); for (const value of ['0', '-1', '65536', '1.5', '3477x']) expect(() => portFrom(value)).toThrow(); }); + +test('accepts a portless Host only when listening on default HTTP port 80', () => { + expect(localHost('localhost', 80)).toBe(true); + expect(localHost('127.0.0.1', 80)).toBe(true); + expect(localHost('localhost:80', 80)).toBe(true); + expect(localHost('127.0.0.1', 3477)).toBe(false); + expect(localHost('localhost:81', 80)).toBe(false); + expect(localHost('evil.example', 80)).toBe(false); +});