Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ coverage/
*.sqlite-journal
data/sysnode.db*
data/privacy-index/
data/privacy-relayers/
.DS_Store
npm-debug.log*
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/privacy-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/privacy-relayers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions docs/privacy-standalone.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions lib/privacyRelayerRpc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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?.();
Expand All @@ -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 ||
Expand Down
7 changes: 3 additions & 4 deletions lib/privacyRpc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand All @@ -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
Expand All @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions lib/privacyRpcUrl.js
Original file line number Diff line number Diff line change
@@ -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 };
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
95 changes: 95 additions & 0 deletions privacy-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
'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) {
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) {
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 };
20 changes: 20 additions & 0 deletions tests/helpers/privacyStandaloneFence.cjs
Original file line number Diff line number Diff line change
@@ -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'); };
81 changes: 81 additions & 0 deletions tests/privacyLocalRpc.test.js
Original file line number Diff line number Diff line change
@@ -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))));
}
});
Loading
Loading