diff --git a/.env.example b/.env.example index 7398aef..34e41ed 100644 --- a/.env.example +++ b/.env.example @@ -102,3 +102,10 @@ SYSCOIN_SENTRY_GENESIS_HASH= # Unused pools stay idle. See docs/privacy-index.md for lifecycle and feed trust. PRIVACY_INDEX_ENABLED=false PRIVACY_INDEX_RPC_URL= + +# Optional anonymous public relayer directory. Uses the operator-controlled +# Ethereum mainnet RPC above but is independent of PRIVACY_INDEX_ENABLED. +# Never accepts caller URLs or notes. Defaults off; first bulk read opts in. +# See docs/privacy-relayers.md before enabling; no relayers are contacted by +# installation, tests or deployments while this flag remains false. +PRIVACY_RELAYER_DIRECTORY_ENABLED=false diff --git a/README.md b/README.md index a3a05f9..509a593 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ should use lowercase. - `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). +- `GET /api/privacy/v1/relayers` — separately opt-in anonymous Classic ETH + relayer discovery; see [source checks and endpoint boundaries](docs/privacy-relayers.md). Authenticated routes (cookie + CSRF, same-site): diff --git a/docs/privacy-index.md b/docs/privacy-index.md index 69378dd..d27e260 100644 --- a/docs/privacy-index.md +++ b/docs/privacy-index.md @@ -53,7 +53,9 @@ 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 +The internal `/privacy/v1` mount also supports existing proxies which strip +`/api`; clients still use the public `/api/privacy/v1` URL. See the +`/api/privacy/v1/` location in the nginx example for path-preserving proxies. 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 diff --git a/docs/privacy-relayers.md b/docs/privacy-relayers.md new file mode 100644 index 0000000..6306008 --- /dev/null +++ b/docs/privacy-relayers.md @@ -0,0 +1,118 @@ +# Public Classic ETH relayer directory + +`GET /api/privacy/v1/relayers` is a single anonymous bulk directory. It accepts +no query strings, request body, wallet address, note, nullifier, proof, pool +selector or caller-supplied URL. It is independent of note storage, local note +balance computation and the optional direct-withdrawal path. + +## Deployment + +The directory is disabled by default. Opt in with +`PRIVACY_RELAYER_DIRECTORY_ENABLED=true` and the existing +`PRIVACY_INDEX_RPC_URL` set to an operator-controlled HTTPS Ethereum mainnet RPC. +`PRIVACY_INDEX_ENABLED` does not enable the directory and need not be enabled +for this route. The RPC needs historical `eth_getLogs`, `eth_getStorageAt`, +`eth_getCode` and `eth_call`, plus finalized block support. There is no fallback +to `latest` or a caller-provided provider. Existing deployments stripping `/api` +before proxying are supported by the internal `/privacy/v1` mount; clients +continue to use `/api/privacy/v1`. The reference nginx configuration preserves +the external path. No new nginx location is required where the existing `/api` +proxy already forwards this namespace. Access logs should be disabled for the +public privacy namespace, including any upstream proxy/CDN, if operators do not +want to retain client IP/access metadata. + +The first request activates one directory scanner. It persists only public ENS +registration candidates and a canonical scan checkpoint under +`data/privacy-relayers/registry-checkpoint.json`. A persisted checkpoint resumes +after restart; an unused directory sends no RPC or relayer requests. Endpoint +status, fee observations and verification are never restored as trusted from +disk. One writer process is supported. No database migration is needed. + +Each job has a 45-second scheduling deadline and checks it before every outbound +request. A started operator RPC has a 10-second timeout; a relayer DNS/TLS/body +request has a 5-second total deadline. Jobs scan at most 16 ranges of 50,000 +blocks and inspect at most eight candidates before yielding for one second. +Provider range errors shrink subsequent ranges. At most 256 distinct ENS +candidates are retained; an overflow fails explicitly, without truncation. A +single unreferenced timer refreshes every five minutes; failures back off for +30 seconds, and HTTP polling cannot bypass that backoff. `close()` cancels future +work and waits for any already-started bounded request. The app factory exposes +`privacyRelayers` for lifecycle cleanup. + +## Pinned source and current registration + +The source is Ethereum mainnet registry proxy +`0x58e8dcc13be9780fc42e8723d8ead4cf46943df2`, from block 14,173,129. The reviewed +implementation is `0x84b0c75c1f6211450fec7289c32d717adadd702a`, runtime Keccak-256 +`0x8c83915c9dca7f9306b1930e43f3a05d7d2424bd0f9edbb324b06d2cfb03ca68`. +Its proxy slot and code are checked at the same finalized block used for the +registry reads. An implementation change fails closed until reviewed. Sources +are independently available from +[Sourcify's verified registry source](https://sourcify.dev/server/v2/contract/1/0x84b0c75c1f6211450fec7289c32d717adadd702a?fields=sources). + +Historical `RelayerRegistered` events only discover candidate ENS names. Before +contacting an endpoint, the service reads the current ENS owner, requires that +owner's registry metadata to contain the exact ENS hash, and requires +`isRelayerRegistered(owner, owner)`. Regular and governance registration both set +that self-mapping; the contract prevents unregistering the master as a worker. +A status `rewardAccount` may be a registered worker, but it must separately pass +`isRelayerRegistered(owner, rewardAccount)`. Initial minimum stake is not treated +as an ongoing eligibility threshold: the contract permits governance admission +below it, and balance can change. The current registry balance is reported. + +The URL is the parent ENS resolver's `text` record for +`mainnet-tornado.`, key `url`. This matches the original +[verified RelayerAggregator](https://sourcify.dev/server/v2/contract/1/0xE8F47A78A6D52D317D0D2FFFac56739fE14D1b49?fields=sources): it uses the registered +parent's resolver to read the child-node text record. All ENS/registry reads +share one finalized block, fenced before publication. Reorgs or a changed +implementation discard the published directory. The operator RPC is still a +trust assumption; these reads are not a cryptographic Ethereum light client. + +## Endpoint boundaries and response meaning + +ENS records are untrusted. Only a root HTTPS URL on a DNS hostname with the +standard 443 port is accepted; credentials, IP literals, custom paths, queries +and fragments are rejected. Only fixed `/status` is requested. DNS must return +one to eight public addresses, all of which pass IPv4/IPv6 special-range checks. +An approved address is pinned into the actual TLS request, with the original +hostname retained for certificate verification. There is no second DNS lookup, +redirect, environment proxy, decompression or caller-selected path. Headers and +body are bounded to 8 KiB and 64 KiB respectively. The status schema bounds +advertised fields and matches supported ETH denominations to the four fixed +Classic pool addresses. Unknown assets and raw errors/logs are not forwarded. +An operator may additionally restrict service egress to public networks. + +HTTP 202 means initial backfill/verification is incomplete; 503 means disabled +or unavailable. HTTP 200 includes `source`, `asOfBlockNumber`, `asOfBlockHash`, +`asOfBlockTimestamp`, observation time, candidate/unavailable counts, and current +registered relayers. Entries with an unavailable/rejected status retain public +identity only, without an endpoint or fee to select. A healthy status includes +`endpoint`, `rewardAccount`, `supportedPools`, `advertisedServiceFeePercent`, +`advertisedQueueLength`, `version`, and its own `statusObservedAt`. + +The fee is the endpoint's advertised **percentage**, not a guaranteed total quote; +gas and relayer rules can add cost. Health/queue are self-reported and do not prove +that a withdrawal will be accepted. The status schema follows the original +[relayer status handler](https://github.com/tornadocash/tornado-relayer/blob/2247730603f2c249f90da0b07058325619435387/src/contollers/status.js). +No submission, proof generation or funds transfer is performed by this service. +A client must freshly check the selected relayer and final fee/reward parameters +before constructing a withdrawal proof. A directory is optional discovery, +not an authorization to submit private data automatically. + +`X-Privacy-Index-State` reports `ready`, `refreshing` or `stale`; the ETag and +body retain the original observation while a refresh fails. A catalogue is also stale if its earliest included endpoint status is over five +minutes old, even when assembly has just completed. Old data does not +become fresh because it was downloaded again. Unreachable/unverified candidates +are counted explicitly; the directory does not claim every historical candidate +is currently serviceable or that a stale directory remains current. + +## Validation + +```sh +npm test -- --runInBand tests/privacyRelayerStatus.test.js tests/privacyRelayerRpc.test.js tests/privacyRelayerDirectory.test.js tests/privacyRelayer.routes.test.js +npm test -- --runInBand --ci +``` + +Tests use synthetic registry/RPC/DNS/HTTPS responses and public verified runtime +bytecode fixtures. They make no relayer connections, send no on-chain transactions, +activate no live service and contain no wallet notes. diff --git a/lib/appFactory.js b/lib/appFactory.js index 75faeb2..773ea65 100644 --- a/lib/appFactory.js +++ b/lib/appFactory.js @@ -30,7 +30,8 @@ const { createRecoveryRepository } = require('./recoveryRepository'); const { createRecoveryMonitor } = require('./recoveryMonitor'); const { createRecoveryRouter } = require('../routes/recovery'); const { configuredPrivacyIndex } = require('./privacyIndex'); -const { PRIVACY_PREFIX, createPrivacyRouter } = require('../routes/privacy'); +const { configuredPrivacyRelayerDirectory } = require('./privacyRelayerDirectory'); +const { PRIVACY_PREFIXES, createPrivacyRouter } = require('../routes/privacy'); function parseBooleanEnv(value, name) { if (value === undefined || value === '') return undefined; @@ -366,6 +367,7 @@ function createApp({ sentryAdapter = null, recoveryChains = [], privacyIndex = configuredPrivacyIndex(), + privacyRelayers = configuredPrivacyRelayerDirectory(), // 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 @@ -397,7 +399,7 @@ function createApp({ 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(PRIVACY_PREFIXES, createPrivacyRouter({ index: privacyIndex, relayers: privacyRelayers })); app.use(cors({ origin: effectiveCorsOrigin, credentials: true })); app.use(express.json({ limit: '256kb' })); app.use(cookieParser()); @@ -552,6 +554,7 @@ function createApp({ recoveryMonitor, recoveryRepository: services.recoveryRepository, privacyIndex, + privacyRelayers, }; } diff --git a/lib/privacyRelayerDirectory.js b/lib/privacyRelayerDirectory.js new file mode 100644 index 0000000..2931889 --- /dev/null +++ b/lib/privacyRelayerDirectory.js @@ -0,0 +1,206 @@ +'use strict'; + +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { createHash, randomBytes } = require('node:crypto'); +const { createPrivacyRelayerRpc } = require('./privacyRelayerRpc'); +const { createPrivacyRelayerStatus } = require('./privacyRelayerStatus'); +const { SOURCE, DEPLOYMENT_BLOCK, MAX_CANDIDATES, candidate } = require('./privacyRelayerRegistry'); +const { word } = require('./privacyRegistry'); +const REFRESH_MS = 5 * 60 * 1000; +const RETRY_MS = 30000; +const JOB_MS = 45000; +const MAX_CHUNKS = 16; +const RANGE = 50000; +const MAX_FILE_BYTES = 256 * 1024; +const digest = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex'); +const SOURCE_ID = digest(SOURCE); + +// Only the public registry scan is persisted. Endpoint observations and trust +// never survive process restart without new finalized on-chain verification. +function createRelayerCheckpointStore(directory) { + const filename = path.join(directory, 'registry-checkpoint.json'); + return { + async read() { + try { + const stat = await fs.stat(filename); + if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return null; + const envelope = JSON.parse(await fs.readFile(filename, 'utf8')); + if (envelope.sha256 !== digest(envelope.value)) return null; + return envelope.value; + } catch (_) { return null; } + }, + async write(value) { + const encoded = JSON.stringify({ sha256: digest(value), value }); + if (Buffer.byteLength(encoded) > MAX_FILE_BYTES) throw new Error('privacy_relayer_checkpoint_too_large'); + await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + const temporary = filename + '.' + randomBytes(8).toString('hex') + '.tmp'; + let file; + try { + file = await fs.open(temporary, 'wx', 0o600); await file.writeFile(encoded); await file.sync(); await file.close(); file = null; + await fs.rename(temporary, filename); + const dir = await fs.open(directory, 'r'); try { await dir.sync(); } finally { await dir.close(); } + } finally { if (file) await file.close(); await fs.rm(temporary, { force: true }); } + }, + }; +} +function validCheckpoint(value) { + if (!value || value.version !== 1 || value.source !== SOURCE_ID || !Number.isSafeInteger(value.scannedBlock) || + value.scannedBlock < DEPLOYMENT_BLOCK || !Array.isArray(value.candidates) || value.candidates.length > MAX_CANDIDATES) return null; + try { + const seen = new Set(); + const candidates = value.candidates.map((v) => { + const c = candidate(v); + if (seen.has(c.ensHash)) throw new Error('duplicate'); + seen.add(c.ensHash); return c; + }); + return { scannedBlock: value.scannedBlock, scannedHash: word(value.scannedHash), candidates }; + } catch (_) { return null; } +} +function createPrivacyRelayerDirectory({ rpc, inspect, store, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout } = {}) { + if (!rpc || !inspect || !store) throw new Error('privacy_relayer_dependencies_missing'); + let scan = { scannedBlock: DEPLOYMENT_BLOCK - 1, scannedHash: null, candidates: [] }; + let loaded = false, loading, active = false, closed = false, running, timer, stage, snapshot, failed = false; + let nextRun = 0, range = RANGE; + function schedule(delay) { + if (closed || !active || timer) return; + timer = setTimer(() => { timer = null; kick(); }, delay); timer?.unref?.(); + } + async function load() { + if (!loading) loading = (async () => { const saved = validCheckpoint(await store.read()); if (saved) { scan = saved; active = true; } loaded = true; })(); + await loading; + } + async function job() { + const deadline = now() + JOB_MS; + const beforeRequest = () => { + if (closed) throw new Error('privacy_relayer_closed'); + if (now() >= deadline) throw new Error('privacy_relayer_job_deadline'); + }; + const context = { beforeRequest }; + if (!stage) { + const header = await rpc.head(context); + if (header.number < DEPLOYMENT_BLOCK) throw new Error('privacy_relayer_before_deployment'); + await rpc.assertSource(header, context); + if (scan.scannedHash) { + // A reset provider may not even have the saved height. Compare first, + // so an unavailable future block cannot permanently strand recovery. + const invalid = scan.scannedBlock > header.number || + (await rpc.block(scan.scannedBlock, context)).hash !== scan.scannedHash; + if (invalid) { + scan = { scannedBlock: DEPLOYMENT_BLOCK - 1, scannedHash: null, candidates: [] }; + snapshot = null; + } + } + let chunks = 0; + while (scan.scannedBlock < header.number && chunks++ < MAX_CHUNKS) { + beforeRequest(); + const to = Math.min(header.number, scan.scannedBlock + range); + let rows; + try { rows = await rpc.events(scan.scannedBlock + 1, to, context); } + catch (error) { + if (['privacy_relayer_rpc_unavailable', 'privacy_relayer_rpc_rejected'].includes(error.message) && range > 1) range = Math.max(1, Math.floor(range / 2)); + throw error; + } + const candidates = new Map(scan.candidates.map((c) => [c.ensHash, c])); + for (const row of rows) { const c = candidate(row); candidates.set(c.ensHash, c); } + if (candidates.size > MAX_CANDIDATES) throw new Error('privacy_relayer_registry_capacity'); + const anchor = await rpc.block(to, context); + await rpc.assertBlock(header, context); + beforeRequest(); + const next = { scannedBlock: to, scannedHash: anchor.hash, candidates: [...candidates.values()] }; + await store.write({ version: 1, source: SOURCE_ID, ...next }); + scan = next; + } + if (scan.scannedBlock < header.number) { nextRun = now() + 1000; return; } + stage = { header, index: 0, entries: [], unavailableEntries: 0 }; + } + let processed = 0; + while (stage.index < scan.candidates.length && processed++ < 8) { + beforeRequest(); + const c = scan.candidates[stage.index]; + let entry; + try { + const current = await rpc.resolve(c, stage.header, context); + if (current) { + entry = { ensName: current.ensName, ensHash: current.ensHash, owner: current.owner, + registryBalance: current.registryBalance, health: 'unavailable' }; + const status = await inspect(current.endpoint, context); + if (await rpc.rewardRegistered(current.owner, status.rewardAccount, stage.header, context)) { + const { origin, rewardAccount, supportedPools, serviceFeePercent, reportedHealthy, queueLength, version, observedAt } = status; + entry = { ...entry, endpoint: origin, rewardAccount, supportedPools, advertisedServiceFeePercent: serviceFeePercent, + health: reportedHealthy ? 'reported-healthy' : 'reported-unhealthy', advertisedQueueLength: queueLength, + version, statusObservedAt: observedAt }; + } else stage.unavailableEntries++; + } + } catch (error) { + // A bad or unreachable record is not an endorsement. If no current + // registry identity was verified, omit it and report the incomplete count. + if (closed || error.message === 'privacy_relayer_job_deadline') throw error; + stage.unavailableEntries++; + } + beforeRequest(); + if (entry) stage.entries.push(entry); + stage.index++; + } + if (stage.index < scan.candidates.length) { nextRun = now() + 1000; return; } + if (now() - stage.header.timestamp * 1000 > 30 * 60 * 1000) { stage = null; throw new Error('privacy_relayer_stale_chain'); } + await rpc.assertSource(stage.header, context); + await rpc.assertBlock(stage.header, context); + beforeRequest(); + const body = { version: 1, chainId: 1, source: SOURCE, asOfBlockNumber: stage.header.number, + asOfBlockHash: stage.header.hash, asOfBlockTimestamp: stage.header.timestamp, + observedAt: now(), candidateCount: scan.candidates.length, unavailableEntries: stage.unavailableEntries, + relayers: stage.entries.sort((a, b) => a.ensName.localeCompare(b.ensName)) }; + const encoded = JSON.stringify(body); + snapshot = { body, encoded, etag: '"' + createHash('sha256').update(encoded).digest('hex') + '"' }; + failed = false; stage = null; nextRun = now() + REFRESH_MS; + } + function kick() { + if (closed || !active || running || !loaded) return; + if (timer) { clearTimer(timer); timer = null; } + running = job().catch((error) => { + if (error.message === 'privacy_relayer_closed') return; + if (error.message === 'privacy_relayer_job_deadline') nextRun = now() + 1000; + else { + failed = true; stage = null; nextRun = now() + RETRY_MS; + if (['privacy_relayer_reorg', 'privacy_relayer_source_changed'].includes(error.message)) { + snapshot = null; scan = { scannedBlock: DEPLOYMENT_BLOCK - 1, scannedHash: null, candidates: [] }; + } + } + }).finally(() => { + running = null; + schedule(Math.max(1000, nextRun - now())); + }); + } + async function read() { + await load(); + if (closed) return { status: 503, body: { error: 'privacy_relayer_directory_closed' } }; + active = true; + if (now() >= nextRun) kick(); + if (!snapshot) return { status: failed ? 503 : 202, body: { phase: failed ? 'unavailable' : 'syncing', + scannedBlock: scan.scannedBlock, startBlock: DEPLOYMENT_BLOCK } }; + // A long catalogue scan does not make its earliest endpoint observations + // fresh again when the final entry is published. + const oldestStatus = snapshot.body.relayers.reduce((oldest, entry) => + entry.statusObservedAt === undefined ? oldest : Math.min(oldest, entry.statusObservedAt), snapshot.body.observedAt); + const stale = failed || now() - oldestStatus >= REFRESH_MS || + now() - snapshot.body.asOfBlockTimestamp * 1000 > 30 * 60 * 1000; + return { status: 200, ...snapshot, phase: stale ? 'stale' : running ? 'refreshing' : 'ready' }; + } + async function close() { closed = true; if (timer) clearTimer(timer); timer = null; await loading; await running; } + async function start() { await load(); if (active) kick(); } + return { read, start, close, idle: async () => { await loading; await running; } }; +} +function configuredPrivacyRelayerDirectory(env = process.env) { + const enabled = env.PRIVACY_RELAYER_DIRECTORY_ENABLED; + if (enabled !== undefined && enabled !== '' && enabled !== 'true' && enabled !== 'false') throw new Error('PRIVACY_RELAYER_DIRECTORY_ENABLED_invalid'); + if (enabled !== 'true') return null; + if (!env.PRIVACY_INDEX_RPC_URL) throw new Error('privacy_relayer_rpc_required'); + const directory = createPrivacyRelayerDirectory({ rpc: createPrivacyRelayerRpc(env.PRIVACY_INDEX_RPC_URL), + inspect: createPrivacyRelayerStatus(), store: createRelayerCheckpointStore(path.resolve('data/privacy-relayers')) }); + // Persisted public registry checkpoints resume; a never-used directory is idle. + directory.start().catch(() => {}); + return directory; +} +module.exports = { createPrivacyRelayerDirectory, createRelayerCheckpointStore, configuredPrivacyRelayerDirectory, + validCheckpoint, SOURCE_ID, REFRESH_MS, JOB_MS, RANGE, MAX_CHUNKS }; diff --git a/lib/privacyRelayerRegistry.js b/lib/privacyRelayerRegistry.js new file mode 100644 index 0000000..27a10f9 --- /dev/null +++ b/lib/privacyRelayerRegistry.js @@ -0,0 +1,38 @@ +'use strict'; + +const { Interface, ZeroAddress, ensNormalize, namehash, keccak256, toUtf8Bytes, concat } = require('ethers'); + +// Mainnet Tornado Classic registry. Its reviewed implementation is pinned: +// governance upgrades must be reviewed before this directory follows them. +const REGISTRY = '0x58e8dcc13be9780fc42e8723d8ead4cf46943df2'; +const IMPLEMENTATION = '0x84b0c75c1f6211450fec7289c32d717adadd702a'; +const IMPLEMENTATION_CODE_HASH = '0x8c83915c9dca7f9306b1930e43f3a05d7d2424bd0f9edbb324b06d2cfb03ca68'; +const ENS = '0x00000000000c2e074ec69a0dfb2997ba6c7d2e1e'; +const DEPLOYMENT_BLOCK = 14173129; +const IMPLEMENTATION_SLOT = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc'; +const MAX_CANDIDATES = 256; +const SOURCE = Object.freeze({ chainId: 1, registry: REGISTRY, implementation: IMPLEMENTATION, + implementationCodeHash: IMPLEMENTATION_CODE_HASH, ensRegistry: ENS, deploymentBlock: DEPLOYMENT_BLOCK, + urlRecord: 'mainnet-tornado.:url' }); +const ABI = new Interface([ + 'event RelayerRegistered(bytes32 relayer,string ensName,address relayerAddress,uint256 stakedAmount)', + 'function relayers(address) view returns (uint256 balance,bytes32 ensHash)', + 'function isRelayerRegistered(address,address) view returns (bool)', + 'function owner(bytes32) view returns (address)', + 'function resolver(bytes32) view returns (address)', + 'function text(bytes32,string) view returns (string)', +]); +function address(value) { + if (typeof value !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(value)) throw new Error('privacy_relayer_address_invalid'); + return value.toLowerCase(); +} +function candidate(value) { + if (!value || typeof value.ensName !== 'string' || Buffer.byteLength(value.ensName) > 255 || + !value.ensName || ensNormalize(value.ensName) !== value.ensName || namehash(value.ensName) !== value.ensHash) throw new Error('privacy_relayer_identity_invalid'); + const registeredAddress = address(value.registeredAddress); + if (registeredAddress === ZeroAddress) throw new Error('privacy_relayer_identity_invalid'); + return { ensName: value.ensName, ensHash: value.ensHash, registeredAddress }; +} +function urlNode(ensHash) { return keccak256(concat([ensHash, keccak256(toUtf8Bytes('mainnet-tornado'))])); } +module.exports = { REGISTRY, IMPLEMENTATION, IMPLEMENTATION_CODE_HASH, IMPLEMENTATION_SLOT, ENS, + DEPLOYMENT_BLOCK, MAX_CANDIDATES, SOURCE, ABI, address, candidate, urlNode }; diff --git a/lib/privacyRelayerRpc.js b/lib/privacyRelayerRpc.js new file mode 100644 index 0000000..44384ed --- /dev/null +++ b/lib/privacyRelayerRpc.js @@ -0,0 +1,98 @@ +'use strict'; + +const axios = require('axios'); +const { toQuantity, keccak256, ZeroAddress } = require('ethers'); +const { quantity } = require('./privacyRpc'); +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'); + let id = 0; + async function call(method, params, context = {}) { + context.beforeRequest?.(); + const current = ++id; + let res; + try { + 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' }, + }); + } catch (_) { throw new Error('privacy_relayer_rpc_unavailable'); } + if (res.status !== 200 || !res.data || res.data.jsonrpc !== '2.0' || res.data.id !== current || + !Object.hasOwn(res.data, 'result') && !Object.hasOwn(res.data, 'error')) throw new Error('privacy_relayer_rpc_invalid'); + if (Object.hasOwn(res.data, 'error')) throw new Error('privacy_relayer_rpc_rejected'); + return res.data.result; + } + async function block(tag, context) { + const raw = await call('eth_getBlockByNumber', [typeof tag === 'number' ? toQuantity(tag) : tag, false], context); + if (!raw) throw new Error('privacy_relayer_missing_block'); + const result = { number: quantity(raw.number), hash: word(raw.hash), timestamp: quantity(raw.timestamp) }; + if (typeof tag === 'number' && result.number !== tag) throw new Error('privacy_relayer_wrong_block'); + return result; + } + async function head(context) { + if (quantity(await call('eth_chainId', [], context)) !== 1) throw new Error('privacy_relayer_wrong_chain'); + const h = await block('finalized', context); + if (now() - h.timestamp * 1000 > 30 * 60 * 1000 || now() - h.timestamp * 1000 < -30000) throw new Error('privacy_relayer_stale_chain'); + return h; + } + async function assertBlock(header, context) { + if ((await block(header.number, context)).hash !== header.hash) throw new Error('privacy_relayer_reorg'); + } + async function assertSource(header, context) { + const tag = toQuantity(header.number); + const slot = word(await call('eth_getStorageAt', [REGISTRY, IMPLEMENTATION_SLOT, tag], context)); + if (slot !== '0x' + '0'.repeat(24) + IMPLEMENTATION.slice(2)) throw new Error('privacy_relayer_source_changed'); + const code = await call('eth_getCode', [IMPLEMENTATION, tag], context); + if (typeof code !== 'string' || !/^0x(?:[0-9a-f]{2})+$/.test(code) || keccak256(code) !== IMPLEMENTATION_CODE_HASH) throw new Error('privacy_relayer_source_changed'); + await assertBlock(header, context); + } + async function view(to, name, args, header, context) { + const data = await call('eth_call', [{ to, data: ABI.encodeFunctionData(name, args), gas: '0x7a120' }, toQuantity(header.number)], context); + if (typeof data !== 'string' || data.length > 8194 || !/^0x(?:[0-9a-f]{2})*$/.test(data)) throw new Error('privacy_relayer_state_invalid'); + try { return ABI.decodeFunctionResult(name, data); } catch (_) { throw new Error('privacy_relayer_state_invalid'); } + } + async function events(from, to, context) { + const rows = await call('eth_getLogs', [{ address: REGISTRY, fromBlock: toQuantity(from), toBlock: toQuantity(to), topics: [TOPIC] }], context); + if (!Array.isArray(rows) || rows.length > 1000) throw new Error('privacy_relayer_logs_invalid'); + const positions = new Set(); + return rows.map((row) => { + if (!row || row.removed !== false || row.address !== REGISTRY || !Array.isArray(row.topics) || + row.topics.length !== 1 || row.topics[0] !== TOPIC || typeof row.data !== 'string' || row.data.length > 4098) throw new Error('privacy_relayer_logs_invalid'); + const blockNumber = quantity(row.blockNumber), txIndex = quantity(row.transactionIndex), logIndex = quantity(row.logIndex); + if (blockNumber < from || blockNumber > to) throw new Error('privacy_relayer_logs_invalid'); + word(row.blockHash); word(row.transactionHash); + const position = `${blockNumber}:${txIndex}:${logIndex}`; + if (positions.has(position)) throw new Error('privacy_relayer_logs_invalid'); + positions.add(position); + let decoded; + try { decoded = ABI.parseLog(row).args; } catch (_) { throw new Error('privacy_relayer_logs_invalid'); } + return { ...candidate({ ensName: decoded.ensName, ensHash: decoded.relayer, registeredAddress: decoded.relayerAddress }), blockNumber, txIndex, logIndex }; + }).sort((a, b) => a.blockNumber - b.blockNumber || a.txIndex - b.txIndex || a.logIndex - b.logIndex); + } + async function resolve(value, header, context) { + const c = candidate(value); + const owner = address((await view(ENS, 'owner', [c.ensHash], header, context))[0]); + if (owner === ZeroAddress) return null; + const metadata = await view(REGISTRY, 'relayers', [owner], header, context); + if (metadata.ensHash !== c.ensHash || !(await view(REGISTRY, 'isRelayerRegistered', [owner, owner], header, context))[0]) return null; + // Match the original RelayerAggregator: use the registered parent's + // resolver for the mainnet-tornado child node's URL text record. + const resolver = address((await view(ENS, 'resolver', [c.ensHash], header, context))[0]); + if (resolver === ZeroAddress) return null; + const endpoint = (await view(resolver, 'text', [urlNode(c.ensHash), 'url'], header, context))[0]; + if (Buffer.byteLength(endpoint) > 2048) throw new Error('privacy_relayer_endpoint_invalid'); + // The current owner must still own this exact registered ENS node. A past + // registration event or a sufficient historical stake alone is not admission. + return { ensName: c.ensName, ensHash: c.ensHash, owner, registryBalance: metadata.balance.toString(), endpoint }; + } + async function rewardRegistered(owner, reward, header, context) { + return (await view(REGISTRY, 'isRelayerRegistered', [address(owner), address(reward)], header, context))[0]; + } + return { head, block, assertBlock, assertSource, events, resolve, rewardRegistered }; +} +module.exports = { createPrivacyRelayerRpc, TOPIC }; diff --git a/lib/privacyRelayerStatus.js b/lib/privacyRelayerStatus.js new file mode 100644 index 0000000..445c0af --- /dev/null +++ b/lib/privacyRelayerStatus.js @@ -0,0 +1,112 @@ +'use strict'; + +const dns = require('node:dns').promises; +const https = require('node:https'); +const { isIP, BlockList } = require('node:net'); +const { POOLS } = require('./privacyRegistry'); +const { address } = require('./privacyRelayerRegistry'); +const MAX_STATUS_BYTES = 64 * 1024; +const STATUS_TIMEOUT_MS = 5000; +const blocked = new BlockList(); +for (const [ip, bits] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], + ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], + ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], + ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4]]) blocked.addSubnet(ip, bits, 'ipv4'); +const global6 = new BlockList(); global6.addSubnet('2000::', 3, 'ipv6'); +for (const [ip, bits] of [['2001::', 23], ['2001:db8::', 32], ['2002::', 16], ['3fff::', 20]]) blocked.addSubnet(ip, bits, 'ipv6'); +function publicAddress(ip) { + const family = isIP(ip); + return family === 4 ? !blocked.check(ip, 'ipv4') : family === 6 && global6.check(ip, 'ipv6') && !blocked.check(ip, 'ipv6'); +} +function endpoint(value) { + if (typeof value !== 'string' || value.length > 2048 || /[\s\\]/.test(value)) throw new Error('privacy_relayer_endpoint_invalid'); + let url; + try { url = new URL(value); } catch (_) { throw new Error('privacy_relayer_endpoint_invalid'); } + if (url.protocol !== 'https:' || url.username || url.password || url.port || url.search || url.hash || + url.pathname !== '/' || isIP(url.hostname) || url.hostname.startsWith('[') || url.hostname.length > 253 || + !url.hostname.includes('.') || url.hostname.split('.').some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) throw new Error('privacy_relayer_endpoint_invalid'); + return url.origin; +} +function parseStatus(raw) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw) || raw.netId !== 1 || + !raw.instances || typeof raw.instances !== 'object' || Object.keys(raw.instances).length > 32) throw new Error('privacy_relayer_status_invalid'); + const eth = raw.instances.eth; + if (!eth || eth.decimals !== 18 || !eth.instanceAddress || typeof eth.instanceAddress !== 'object' || + Array.isArray(eth.instanceAddress) || Object.keys(eth.instanceAddress).length > 32) throw new Error('privacy_relayer_status_invalid'); + const supportedPools = []; + // Never publish an endpoint-supplied contract. The address must match the + // fixed Classic ETH pool for that denomination; other assets are ignored. + for (const pool of POOLS) { + const denom = pool.id.slice(4); + if (Object.hasOwn(eth.instanceAddress, denom)) { + if (address(eth.instanceAddress[denom]) !== pool.address) throw new Error('privacy_relayer_status_invalid'); + supportedPools.push(pool.id); + } + } + const fee = raw.tornadoServiceFee; + if ((typeof fee !== 'string' && typeof fee !== 'number') || + !/^(?:(?:0|[1-9][0-9]?)(?:\.[0-9]{1,18})?|100(?:\.0{1,18})?)$/.test(String(fee)) || !supportedPools.length) throw new Error('privacy_relayer_status_invalid'); + const h = raw.health?.status; + if (![true, false, 'true', 'false'].includes(h)) throw new Error('privacy_relayer_status_invalid'); + if (!Number.isSafeInteger(raw.currentQueue) || raw.currentQueue < 0 || raw.currentQueue > 1000000 || + typeof raw.version !== 'string' || raw.version.length > 64 || !/^[a-zA-Z0-9.+_-]+$/.test(raw.version)) throw new Error('privacy_relayer_status_invalid'); + return { rewardAccount: address(raw.rewardAccount), supportedPools, serviceFeePercent: String(fee), + reportedHealthy: h === true || h === 'true', queueLength: raw.currentQueue, version: raw.version }; +} +function createPrivacyRelayerStatus({ lookup = dns.lookup, request = https.request, now = Date.now } = {}) { + return async function inspect(value, context = {}) { + const origin = endpoint(value); + const host = new URL(origin).hostname; + context.beforeRequest?.(); + // This deadline includes DNS, TLS and response bytes, rather than only socket + // inactivity. Native HTTPS never consults HTTP(S)_PROXY environment values. + let timer, req, response; + let expired = false; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { expired = true; response?.destroy(); req?.destroy(); reject(new Error('privacy_relayer_status_timeout')); }, STATUS_TIMEOUT_MS); + }); + const run = async () => { + const answers = await lookup(host, { all: true, verbatim: true }); + if (expired) throw new Error('privacy_relayer_status_timeout'); + if (!Array.isArray(answers) || !answers.length || answers.length > 8 || answers.some((a) => + !a || !publicAddress(a.address) || isIP(a.address) !== a.family)) throw new Error('privacy_relayer_dns_rejected'); + context.beforeRequest?.(); + // All answers were checked. Pin one immutable answer; the connect cannot + // perform a second DNS lookup or follow a redirect to a private address. + const chosen = { address: answers[0].address, family: answers[0].family }; + const raw = await new Promise((resolve, reject) => { + req = request({ protocol: 'https:', hostname: host, servername: host, port: 443, method: 'GET', path: '/status', + agent: false, rejectUnauthorized: true, maxHeaderSize: 8192, + headers: { Accept: 'application/json', 'Accept-Encoding': 'identity' }, + lookup: (_name, options, cb) => typeof options === 'function' ? options(null, chosen.address, chosen.family) : + options?.all ? cb(null, [{ ...chosen }]) : cb(null, chosen.address, chosen.family), + }, (res) => { + response = res; + if (res.statusCode !== 200 || !/^application\/json(?:\s*;|$)/i.test(res.headers['content-type'] || '') || + (res.headers['content-encoding'] && res.headers['content-encoding'] !== 'identity') || + (res.headers['content-length'] && (!/^\d+$/.test(res.headers['content-length']) || Number(res.headers['content-length']) > MAX_STATUS_BYTES))) { + res.destroy(); reject(new Error('privacy_relayer_status_rejected')); return; + } + let size = 0; const parts = []; + res.on('data', (part) => { + size += part.length; + if (size > MAX_STATUS_BYTES) { res.destroy(); reject(new Error('privacy_relayer_status_too_large')); } + else parts.push(part); + }); + res.on('error', () => reject(new Error('privacy_relayer_status_unavailable'))); + res.on('aborted', () => reject(new Error('privacy_relayer_status_unavailable'))); + res.on('end', () => { + try { resolve(JSON.parse(Buffer.concat(parts).toString('utf8'))); } + catch (_) { reject(new Error('privacy_relayer_status_invalid')); } + }); + }); + req.on('error', () => reject(new Error('privacy_relayer_status_unavailable'))); + req.end(); + }); + return { origin, ...parseStatus(raw), observedAt: now() }; + }; + try { return await Promise.race([run(), timeout]); } + finally { clearTimeout(timer); } + }; +} +module.exports = { createPrivacyRelayerStatus, publicAddress, endpoint, parseStatus, MAX_STATUS_BYTES, STATUS_TIMEOUT_MS }; diff --git a/routes/privacy.js b/routes/privacy.js index 3d2444b..91a1da6 100644 --- a/routes/privacy.js +++ b/routes/privacy.js @@ -7,7 +7,9 @@ const { ipKeyGenerator } = require('express-rate-limit'); const { getPool } = require('../lib/privacyRegistry'); const PRIVACY_PREFIX = '/api/privacy/v1'; -function createPrivacyRouter({ index = null, limiter } = {}) { +// Existing production proxies strip /api; direct/same-origin proxies retain it. +const PRIVACY_PREFIXES = Object.freeze([PRIVACY_PREFIX, '/privacy/v1']); +function createPrivacyRouter({ index = null, relayers = 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, @@ -20,6 +22,20 @@ function createPrivacyRouter({ index = null, limiter } = {}) { (req.body && Object.keys(req.body).length)) return res.status(400).json({ error: 'privacy_request_parameters_not_allowed' }); next(); }); + router.get('/relayers', async (req, res) => { + if (!relayers) return res.status(503).json({ error: 'privacy_relayer_directory_disabled' }); + try { + const result = await relayers.read(); + 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_relayer_directory_unavailable' }); } + }); 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' }); @@ -39,4 +55,4 @@ function createPrivacyRouter({ index = null, limiter } = {}) { router.use((_req, res) => res.status(404).json({ error: 'unknown_privacy_route' })); return router; } -module.exports = { PRIVACY_PREFIX, createPrivacyRouter }; +module.exports = { PRIVACY_PREFIX, PRIVACY_PREFIXES, createPrivacyRouter }; diff --git a/server.js b/server.js index 5709a36..4299d0c 100644 --- a/server.js +++ b/server.js @@ -46,7 +46,8 @@ const { createConfiguredSentryAdapter, createSentryRpc } = require('./lib/sentry const { configuredRecoveryChains } = require('./lib/recoveryRegistry'); const { createRecoveryMonitor } = require('./lib/recoveryMonitor'); const { configuredPrivacyIndex } = require('./lib/privacyIndex'); -const { PRIVACY_PREFIX, createPrivacyRouter } = require('./routes/privacy'); +const { configuredPrivacyRelayerDirectory } = require('./lib/privacyRelayerDirectory'); +const { PRIVACY_PREFIXES, createPrivacyRouter } = require('./routes/privacy'); const { createMasternodeCountRepo, } = require('./lib/masternodeCountRepo'); @@ -72,6 +73,7 @@ const currentVotesCache = createCurrentVotesCache({ const app = express(); const privacyIndex = configuredPrivacyIndex(); +const privacyRelayers = configuredPrivacyRelayerDirectory(); // Reverse-proxy awareness. When deployed behind nginx / a load balancer, // Express's default `req.ip` is the proxy's socket address, which collapses @@ -95,7 +97,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(PRIVACY_PREFIXES, createPrivacyRouter({ index: privacyIndex, relayers: privacyRelayers })); app.use(bodyParser.json({ limit: '256kb' })); app.use(cookieParser()); diff --git a/tests/fixtures/privacy-relayer-implementation.hex b/tests/fixtures/privacy-relayer-implementation.hex new file mode 100644 index 0000000..d9c4062 --- /dev/null +++ b/tests/fixtures/privacy-relayer-implementation.hex @@ -0,0 +1 @@ +0x608060405234801561001057600080fd5b50600436106101c45760003560e01c806385a29683116100f9578063d990231d11610097578063eb4af04511610071578063eb4af04514610502578063f18876841461051e578063f489eb631461053c578063f9e5423414610558576101c4565b8063d990231d146104ae578063e37e8bcc146104ca578063e43fdb3c146104e6576101c4565b8063ae53941c116100d3578063ae53941c14610400578063b69fd4ab14610430578063b971a6bf14610460578063d0fb020314610490576101c4565b806385a29683146103aa5780639498bd71146103c6578063adf898a4146103e2576101c4565b80634cb16c2e116101665780635300f841116101405780635300f841146102fb578063541d55481461032c5780635aa6e6751461035c5780635c23bdf51461037a576101c4565b80634cb16c2e146102a35780634cf088d9146102c15780634d4efd04146102df576101c4565b80633f15457f116101a25780633f15457f1461021d5780634048a2571461023b57806345a11cec1461026b5780634ba16d9d14610287576101c4565b806314d92307146101c95780632e650649146101e55780633523dc8514610201575b600080fd5b6101e360048036038101906101de9190612f87565b610588565b005b6101ff60048036038101906101fa9190612fd9565b61070d565b005b61021b60048036038101906102169190612f87565b6107ec565b005b610225610ad5565b6040516102329190613d63565b60405180910390f35b61025560048036038101906102509190612f87565b610af9565b6040516102629190613baa565b60405180910390f35b61028560048036038101906102809190613015565b610b2c565b005b6102a1600480360381019061029c9190612f87565b610fc3565b005b6102ab6110cc565b6040516102b89190613baa565b60405180910390f35b6102c96110f2565b6040516102d69190613db4565b60405180910390f35b6102f960048036038101906102f49190612f87565b611116565b005b61031560048036038101906103109190612f87565b6112f9565b6040516103239291906140a7565b60405180910390f35b61034660048036038101906103419190612f87565b61131d565b6040516103539190613cdf565b60405180910390f35b6103646113b5565b6040516103719190613baa565b60405180910390f35b610394600480360381019061038f9190613214565b6113d9565b6040516103a19190613baa565b60405180910390f35b6103c460048036038101906103bf919061310c565b611531565b005b6103e060048036038101906103db9190613214565b6115dc565b005b6103ea61170e565b6040516103f79190613dcf565b60405180910390f35b61041a60048036038101906104159190612f87565b611732565b6040516104279190613cfa565b60405180910390f35b61044a60048036038101906104459190612fd9565b6117dd565b6040516104579190613cdf565b60405180910390f35b61047a60048036038101906104759190612f87565b611875565b604051610487919061408c565b60405180910390f35b610498611920565b6040516104a59190613d7e565b60405180910390f35b6104c860048036038101906104c391906130d0565b611944565b005b6104e460048036038101906104df91906132ef565b611953565b005b61050060048036038101906104fb9190613266565b611a04565b005b61051c60048036038101906105179190613403565b611a19565b005b610526611ae8565b604051610533919061408c565b60405180910390f35b61055660048036038101906105519190613064565b611aee565b005b610572600480360381019061056d91906131aa565b611f1a565b60405161057f9190613cbd565b60405180910390f35b7f0000000000000000000000005efda50f22d34f262c29268506c5fa42cb56a1ce73ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614610616576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040161060d90613ecc565b60405180910390fd5b61061f81611116565b600260008273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008082016000905560018201600090555050600360008273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81549073ffffffffffffffffffffffffffffffffffffffff02191690557f490a66cc56c789979052c7052fc0c10a6c4627d8e6165caec80db97a4c383521816040516107029190613baa565b60405180910390a150565b33828073ffffffffffffffffffffffffffffffffffffffff16600360008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146107dc576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016107d390613fcc565b60405180910390fd5b6107e68484611fe7565b50505050565b3373ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16146108ed573373ffffffffffffffffffffffffffffffffffffffff16600360008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146108ec576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016108e39061400c565b60405180910390fd5b5b8073ffffffffffffffffffffffffffffffffffffffff16600360008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614156109bb576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016109b290613f2c565b60405180910390fd5b7fb2a8e18b9e887f502d65c1683e60b723fa582a6903ea4e8eb23907a19c1ce8a0600360008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1682604051610a4b929190613bc5565b60405180910390a16000600360008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555050565b7f00000000000000000000000000000000000c2e074ec69a0dfb2997ba6c7d2e1e81565b60036020528060005260406000206000915054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b600060029054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614610bbc576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401610bb390613e2c565b60405180910390fd5b6000600360008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff169050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff161415610d2957600073ffffffffffffffffffffffffffffffffffffffff16600360008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614610d23576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401610d1a90613f6c565b60405180910390fd5b50610fbe565b8273ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614610d97576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401610d8e90613fcc565b60405180910390fd5b60007f0000000000000000000000005f6c97c6ad7bdd0ae7e0dd4ca33a4ed3fdabd4d773ffffffffffffffffffffffffffffffffffffffff16632efbf384846040518263ffffffff1660e01b8152600401610df29190613d99565b602060405180830381600087803b158015610e0c57600080fd5b505af1158015610e20573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610e4491906133da565b73ffffffffffffffffffffffffffffffffffffffff169050610eb181600260008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000206000015461217090919063ffffffff16565b600260008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020600001819055507f0000000000000000000000005b3f656c80e8ddb9ec01dd9018815576e9238c2973ffffffffffffffffffffffffffffffffffffffff1663338610af826040518263ffffffff1660e01b8152600401610f50919061408c565b600060405180830381600087803b158015610f6a57600080fd5b505af1158015610f7e573d6000803e3d6000fd5b505050507f659f33fc6677bebf3a9bf3101092792e31f35766d0358e54577bdd91a655f6a08482604051610fb3929190613c94565b60405180910390a150505b505050565b7f0000000000000000000000005efda50f22d34f262c29268506c5fa42cb56a1ce73ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614611051576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040161104890613ecc565b60405180910390fd5b80600060026101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507f94df8c3a8087dce110e5fbc5acf380c83c94bbd31b2c8ed4c08e1396a696e1a8816040516110c19190613baa565b60405180910390a150565b600060029054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b7f0000000000000000000000005b3f656c80e8ddb9ec01dd9018815576e9238c2981565b7f0000000000000000000000005efda50f22d34f262c29268506c5fa42cb56a1ce73ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16146111a4576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040161119b90613ecc565b60405180910390fd5b6000600360008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690508073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614611276576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040161126d9061406c565b60405180910390fd5b6000600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020600001819055507fafa759fb3c68e89eaaba359f0930ab40c24875b73cc9e2f6a38b0180019eb8f3826040516112ed9190613baa565b60405180910390a15050565b60026020528060005260406000206000915090508060000154908060010154905082565b60008073ffffffffffffffffffffffffffffffffffffffff16600360008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614159050919050565b7f0000000000000000000000005efda50f22d34f262c29268506c5fa42cb56a1ce81565b60008060016113e66121c0565b1461140557738595bfb0d940dfedc98943fa8a907091203f25ee611416565b6e0c2e074ec69a0dfb2997ba6c7d2e1e5b90508073ffffffffffffffffffffffffffffffffffffffff16630178b8bf846040518263ffffffff1660e01b81526004016114519190613cfa565b60206040518083038186803b15801561146957600080fd5b505afa15801561147d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906114a1919061323d565b73ffffffffffffffffffffffffffffffffffffffff16633b3b57de846040518263ffffffff1660e01b81526004016114d99190613cfa565b60206040518083038186803b1580156114f157600080fd5b505afa158015611505573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906115299190612fb0565b915050919050565b7f00000000000000000000000077777feddddffc19ff86db637967013e6c6a116c73ffffffffffffffffffffffffffffffffffffffff1663d505accf863089888888886040518863ffffffff1660e01b81526004016115969796959493929190613c25565b600060405180830381600087803b1580156115b057600080fd5b505af11580156115c4573d6000803e3d6000fd5b505050506115d38588886121cd565b50505050505050565b600060019054906101000a900460ff16806115fb57506115fa6123e0565b5b80611611575060008054906101000a900460ff16155b611650576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040161164790613f0c565b60405180910390fd5b60008060019054906101000a900460ff1615905080156116a0576001600060016101000a81548160ff02191690831515021790555060016000806101000a81548160ff0219169083151502179055505b6116a9826113d9565b600060026101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550801561170a5760008060016101000a81548160ff0219169083151502179055505b5050565b7f00000000000000000000000077777feddddffc19ff86db637967013e6c6a116c81565b600060026000600360008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020600101549050919050565b60008273ffffffffffffffffffffffffffffffffffffffff16600360008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614905092915050565b600060026000600360008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020600001549050919050565b7f0000000000000000000000005f6c97c6ad7bdd0ae7e0dd4ca33a4ed3fdabd4d781565b61194f3383836121cd565b5050565b7f00000000000000000000000077777feddddffc19ff86db637967013e6c6a116c73ffffffffffffffffffffffffffffffffffffffff1663d505accf86308b888888886040518863ffffffff1660e01b81526004016119b89796959493929190613c25565b600060405180830381600087803b1580156119d257600080fd5b505af11580156119e6573d6000803e3d6000fd5b505050506119f8858b8b8b8b8b6123f1565b50505050505050505050565b611a123386868686866123f1565b5050505050565b7f0000000000000000000000005efda50f22d34f262c29268506c5fa42cb56a1ce73ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614611aa7576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401611a9e90613ecc565b60405180910390fd5b806001819055507f404663163d528ec45288abc4389b81bd96fabf858ff57577ebd4ee7f15d7b0a681604051611add919061408c565b60405180910390a150565b60015481565b7f0000000000000000000000005efda50f22d34f262c29268506c5fa42cb56a1ce73ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614611b7c576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401611b7390613ecc565b60405180910390fd5b6000611bcb84848080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050612951565b905060007f00000000000000000000000000000000000c2e074ec69a0dfb2997ba6c7d2e1e73ffffffffffffffffffffffffffffffffffffffff166302571be3836040518263ffffffff1660e01b8152600401611c289190613cfa565b60206040518083038186803b158015611c4057600080fd5b505afa158015611c54573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190611c789190612fb0565b90508073ffffffffffffffffffffffffffffffffffffffff168673ffffffffffffffffffffffffffffffffffffffff1614611ce8576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401611cdf90613fec565b60405180910390fd5b8573ffffffffffffffffffffffffffffffffffffffff16600360008873ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff161415611db6576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401611dad90613f4c565b60405180910390fd5b6000600260008873ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002090506000801b816001015414611e42576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401611e3990613e0c565b60405180910390fd5b83816000018190555082816001018190555086600360008973ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507f9ca7c9c762eff27b021608f232b4c4b8f9b8bf9a3d322297e47cc4209a67d5e28387878a88604051611f09959493929190613d15565b60405180910390a150505050505050565b6060815167ffffffffffffffff81118015611f3457600080fd5b50604051908082528060200260200182016040528015611f635781602001602082028036833780820191505090505b50905060005b8251811015611fe157611f8e838281518110611f8157fe5b60200260200101516113d9565b828281518110611f9a57fe5b602002602001019073ffffffffffffffffffffffffffffffffffffffff16908173ffffffffffffffffffffffffffffffffffffffff16815250508080600101915050611f69565b50919050565b600073ffffffffffffffffffffffffffffffffffffffff16600360008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146120b5576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016120ac90613eec565b60405180910390fd5b81600360008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507fcde75bd02c5f739608c891bcd9aa6809e6c4a7035ac7b9f3fd5fea756db747248282604051612164929190613bc5565b60405180910390a15050565b6000828211156121b5576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016121ac90613e6c565b60405180910390fd5b818303905092915050565b6000804690508091505090565b8173ffffffffffffffffffffffffffffffffffffffff16600360008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff161461229a576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040161229190613e8c565b60405180910390fd5b612307837f0000000000000000000000005b3f656c80e8ddb9ec01dd9018815576e9238c29837f00000000000000000000000077777feddddffc19ff86db637967013e6c6a116c73ffffffffffffffffffffffffffffffffffffffff16612965909392919063ffffffff16565b61235c600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060000154826129ee90919063ffffffff16565b600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020600001819055507f1275dbe2a271b2b822e60f1d44894fa5fb337e7e2dc6a200205b1a5b17c07d6482826040516123d3929190613c94565b60405180910390a1505050565b60006123eb30612a43565b15905090565b600061244086868080601f016020809104026020016040519081016040528093929190818152602001838380828437600081840152601f19601f82011690508083019250505050505050612951565b905060007f00000000000000000000000000000000000c2e074ec69a0dfb2997ba6c7d2e1e73ffffffffffffffffffffffffffffffffffffffff166302571be3836040518263ffffffff1660e01b815260040161249d9190613cfa565b60206040518083038186803b1580156124b557600080fd5b505afa1580156124c9573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906124ed9190612fb0565b9050600073d4416b13d2b3a9abae7acd5d6c2bbdbe2568640190508073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff161415612577576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040161256e90613f8c565b60405180910390fd5b8173ffffffffffffffffffffffffffffffffffffffff168973ffffffffffffffffffffffffffffffffffffffff16146125e5576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016125dc90613fec565b60405180910390fd5b600073ffffffffffffffffffffffffffffffffffffffff16600360008b73ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146126b3576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016126aa90613f4c565b60405180910390fd5b6000600260008b73ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002090506000801b81600101541461273f576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040161273690613e0c565b60405180910390fd5b600154871015612784576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040161277b9061404c565b60405180910390fd5b6127f18a7f0000000000000000000000005b3f656c80e8ddb9ec01dd9018815576e9238c29897f00000000000000000000000077777feddddffc19ff86db637967013e6c6a116c73ffffffffffffffffffffffffffffffffffffffff16612965909392919063ffffffff16565b7f1275dbe2a271b2b822e60f1d44894fa5fb337e7e2dc6a200205b1a5b17c07d648a88604051612822929190613c94565b60405180910390a186816000018190555083816001018190555089600360008c73ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555060005b868690508110156129055760008787838181106128d657fe5b90506020020160208101906128eb9190612f87565b90506128f78c82611fe7565b5080806001019150506128bd565b507f9ca7c9c762eff27b021608f232b4c4b8f9b8bf9a3d322297e47cc4209a67d5e2848a8a8d8b60405161293d959493929190613d15565b60405180910390a150505050505050505050565b600061295e826000612a56565b9050919050565b6129e8846323b872dd60e01b85858560405160240161298693929190613bee565b604051602081830303815290604052907bffffffffffffffffffffffffffffffffffffffffffffffffffffffff19166020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff8381831617835250505050612ac4565b50505050565b600080828401905083811015612a39576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401612a3090613e4c565b60405180910390fd5b8091505092915050565b600080823b905060008111915050919050565b600081835111612a6b576000801b9050612abe565b6000612a778484612b8b565b9050612a8884600183860101612a56565b612a93858584612bfc565b604051602001612aa4929190613b67565b604051602081830303815290604052805190602001209150505b92915050565b6060612b26826040518060400160405280602081526020017f5361666545524332303a206c6f772d6c6576656c2063616c6c206661696c65648152508573ffffffffffffffffffffffffffffffffffffffff16612c1f9092919063ffffffff16565b9050600081511115612b865780806020019051810190612b4691906131eb565b612b85576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401612b7c9061402c565b60405180910390fd5b5b505050565b6000805b835181840114158015612be05750602e60f81b8482850181518110612bb057fe5b602001015160f81c60f81b7effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff191614155b15612bf2578080600101915050612b8f565b8091505092915050565b600083518284011115612c0e57600080fd5b818360208601012090509392505050565b6060612c2e8484600085612c37565b90509392505050565b606082471015612c7c576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401612c7390613eac565b60405180910390fd5b612c8585612a43565b612cc4576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401612cbb90613fac565b60405180910390fd5b600060608673ffffffffffffffffffffffffffffffffffffffff168587604051612cee9190613b93565b60006040518083038185875af1925050503d8060008114612d2b576040519150601f19603f3d011682016040523d82523d6000602084013e612d30565b606091505b5091509150612d40828286612d4c565b92505050949350505050565b60608315612d5c57829050612dac565b600083511115612d6f5782518084602001fd5b816040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401612da39190613dea565b60405180910390fd5b9392505050565b600081359050612dc281614324565b92915050565b600081519050612dd781614324565b92915050565b60008083601f840112612def57600080fd5b8235905067ffffffffffffffff811115612e0857600080fd5b602083019150836020820283011115612e2057600080fd5b9250929050565b600082601f830112612e3857600080fd5b8135612e4b612e46826140fd565b6140d0565b91508181835260208401935060208101905083856020840282011115612e7057600080fd5b60005b83811015612ea05781612e868882612ebf565b845260208401935060208301925050600181019050612e73565b5050505092915050565b600081519050612eb98161433b565b92915050565b600081359050612ece81614352565b92915050565b600081359050612ee381614369565b92915050565b600081519050612ef881614380565b92915050565b60008083601f840112612f1057600080fd5b8235905067ffffffffffffffff811115612f2957600080fd5b602083019150836001820283011115612f4157600080fd5b9250929050565b600081519050612f5781614397565b92915050565b600081359050612f6c816143ae565b92915050565b600081359050612f81816143c5565b92915050565b600060208284031215612f9957600080fd5b6000612fa784828501612db3565b91505092915050565b600060208284031215612fc257600080fd5b6000612fd084828501612dc8565b91505092915050565b60008060408385031215612fec57600080fd5b6000612ffa85828601612db3565b925050602061300b85828601612db3565b9150509250929050565b60008060006060848603121561302a57600080fd5b600061303886828701612db3565b935050602061304986828701612db3565b925050604061305a86828701612ed4565b9150509250925092565b6000806000806060858703121561307a57600080fd5b600061308887828801612db3565b945050602085013567ffffffffffffffff8111156130a557600080fd5b6130b187828801612efe565b935093505060406130c487828801612f5d565b91505092959194509250565b600080604083850312156130e357600080fd5b60006130f185828601612db3565b925050602061310285828601612f5d565b9150509250929050565b600080600080600080600060e0888a03121561312757600080fd5b60006131358a828b01612db3565b97505060206131468a828b01612f5d565b96505060406131578a828b01612db3565b95505060606131688a828b01612f5d565b94505060806131798a828b01612f72565b93505060a061318a8a828b01612ebf565b92505060c061319b8a828b01612ebf565b91505092959891949750929550565b6000602082840312156131bc57600080fd5b600082013567ffffffffffffffff8111156131d657600080fd5b6131e284828501612e27565b91505092915050565b6000602082840312156131fd57600080fd5b600061320b84828501612eaa565b91505092915050565b60006020828403121561322657600080fd5b600061323484828501612ebf565b91505092915050565b60006020828403121561324f57600080fd5b600061325d84828501612ee9565b91505092915050565b60008060008060006060868803121561327e57600080fd5b600086013567ffffffffffffffff81111561329857600080fd5b6132a488828901612efe565b955095505060206132b788828901612f5d565b935050604086013567ffffffffffffffff8111156132d457600080fd5b6132e088828901612ddd565b92509250509295509295909350565b6000806000806000806000806000806101008b8d03121561330f57600080fd5b60008b013567ffffffffffffffff81111561332957600080fd5b6133358d828e01612efe565b9a509a505060206133488d828e01612f5d565b98505060408b013567ffffffffffffffff81111561336557600080fd5b6133718d828e01612ddd565b975097505060606133848d828e01612db3565b95505060806133958d828e01612f5d565b94505060a06133a68d828e01612f72565b93505060c06133b78d828e01612ebf565b92505060e06133c88d828e01612ebf565b9150509295989b9194979a5092959850565b6000602082840312156133ec57600080fd5b60006133fa84828501612f48565b91505092915050565b60006020828403121561341557600080fd5b600061342384828501612f5d565b91505092915050565b60006134388383613444565b60208301905092915050565b61344d81614190565b82525050565b61345c81614190565b82525050565b600061346d82614135565b6134778185614163565b935061348283614125565b8060005b838110156134b357815161349a888261342c565b97506134a583614156565b925050600181019050613486565b5085935050505092915050565b6134c9816141a2565b82525050565b6134d8816141ae565b82525050565b6134ef6134ea826141ae565b614309565b82525050565b600061350082614140565b61350a8185614174565b935061351a8185602086016142d6565b80840191505092915050565b61352f81614213565b82525050565b61353e81614237565b82525050565b61354d8161425b565b82525050565b61355c8161427f565b82525050565b61356b816142a3565b82525050565b600061357d838561417f565b935061358a8385846142c7565b61359383614313565b840190509392505050565b60006135a98261414b565b6135b3818561417f565b93506135c38185602086016142d6565b6135cc81614313565b840191505092915050565b60006135e460128361417f565b91507f7265676973746572656420616c726561647900000000000000000000000000006000830152602082019050919050565b6000613624600a8361417f565b91507f6f6e6c792070726f7879000000000000000000000000000000000000000000006000830152602082019050919050565b6000613664601b8361417f565b91507f536166654d6174683a206164646974696f6e206f766572666c6f7700000000006000830152602082019050919050565b60006136a4601e8361417f565b91507f536166654d6174683a207375627472616374696f6e206f766572666c6f7700006000830152602082019050919050565b60006136e4600b8361417f565b91507f21726567697374657265640000000000000000000000000000000000000000006000830152602082019050919050565b600061372460268361417f565b91507f416464726573733a20696e73756666696369656e742062616c616e636520666f60008301527f722063616c6c00000000000000000000000000000000000000000000000000006020830152604082019050919050565b600061378a600f8361417f565b91507f6f6e6c7920676f7665726e616e636500000000000000000000000000000000006000830152602082019050919050565b60006137ca60168361417f565b91507f63616e277420737465616c20616e2061646472657373000000000000000000006000830152602082019050919050565b600061380a602e8361417f565b91507f496e697469616c697a61626c653a20636f6e747261637420697320616c72656160008301527f647920696e697469616c697a65640000000000000000000000000000000000006020830152604082019050919050565b600061387060168361417f565b91507f63616e7420756e7265676973746572206d6173746572000000000000000000006000830152602082019050919050565b60006138b060138361417f565b91507f63616e7420726567697374657220616761696e000000000000000000000000006000830152602082019050919050565b60006138f060138361417f565b91507f4f6e6c7920637573746f6d2072656c61796572000000000000000000000000006000830152602082019050919050565b6000613930601a8361417f565b91507f6f6e6c7920756e7772617070656420656e7320646f6d61696e730000000000006000830152602082019050919050565b6000613970601d8361417f565b91507f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006000830152602082019050919050565b60006139b0600c8361417f565b91507f6f6e6c792072656c6179657200000000000000000000000000000000000000006000830152602082019050919050565b60006139f060158361417f565b91507f6f6e6c7920656e7320646f6d61696e206f776e657200000000000000000000006000830152602082019050919050565b6000613a3060148361417f565b91507f6f6e6c79206f776e6572206f6620776f726b65720000000000000000000000006000830152602082019050919050565b6000613a70602a8361417f565b91507f5361666545524332303a204552433230206f7065726174696f6e20646964206e60008301527f6f742073756363656564000000000000000000000000000000000000000000006020830152604082019050919050565b6000613ad6600a8361417f565b91507f216d696e5f7374616b65000000000000000000000000000000000000000000006000830152602082019050919050565b6000613b16600e8361417f565b91507f6d757374206265206d61737465720000000000000000000000000000000000006000830152602082019050919050565b613b52816141fc565b82525050565b613b6181614206565b82525050565b6000613b7382856134de565b602082019150613b8382846134de565b6020820191508190509392505050565b6000613b9f82846134f5565b915081905092915050565b6000602082019050613bbf6000830184613453565b92915050565b6000604082019050613bda6000830185613453565b613be76020830184613453565b9392505050565b6000606082019050613c036000830186613453565b613c106020830185613453565b613c1d6040830184613b49565b949350505050565b600060e082019050613c3a600083018a613453565b613c476020830189613453565b613c546040830188613b49565b613c616060830187613b49565b613c6e6080830186613b58565b613c7b60a08301856134cf565b613c8860c08301846134cf565b98975050505050505050565b6000604082019050613ca96000830185613453565b613cb66020830184613b49565b9392505050565b60006020820190508181036000830152613cd78184613462565b905092915050565b6000602082019050613cf460008301846134c0565b92915050565b6000602082019050613d0f60008301846134cf565b92915050565b6000608082019050613d2a60008301886134cf565b8181036020830152613d3d818688613571565b9050613d4c6040830185613453565b613d596060830184613b49565b9695505050505050565b6000602082019050613d786000830184613526565b92915050565b6000602082019050613d936000830184613535565b92915050565b6000602082019050613dae6000830184613544565b92915050565b6000602082019050613dc96000830184613553565b92915050565b6000602082019050613de46000830184613562565b92915050565b60006020820190508181036000830152613e04818461359e565b905092915050565b60006020820190508181036000830152613e25816135d7565b9050919050565b60006020820190508181036000830152613e4581613617565b9050919050565b60006020820190508181036000830152613e6581613657565b9050919050565b60006020820190508181036000830152613e8581613697565b9050919050565b60006020820190508181036000830152613ea5816136d7565b9050919050565b60006020820190508181036000830152613ec581613717565b9050919050565b60006020820190508181036000830152613ee58161377d565b9050919050565b60006020820190508181036000830152613f05816137bd565b9050919050565b60006020820190508181036000830152613f25816137fd565b9050919050565b60006020820190508181036000830152613f4581613863565b9050919050565b60006020820190508181036000830152613f65816138a3565b9050919050565b60006020820190508181036000830152613f85816138e3565b9050919050565b60006020820190508181036000830152613fa581613923565b9050919050565b60006020820190508181036000830152613fc581613963565b9050919050565b60006020820190508181036000830152613fe5816139a3565b9050919050565b60006020820190508181036000830152614005816139e3565b9050919050565b6000602082019050818103600083015261402581613a23565b9050919050565b6000602082019050818103600083015261404581613a63565b9050919050565b6000602082019050818103600083015261406581613ac9565b9050919050565b6000602082019050818103600083015261408581613b09565b9050919050565b60006020820190506140a16000830184613b49565b92915050565b60006040820190506140bc6000830185613b49565b6140c960208301846134cf565b9392505050565b6000604051905081810181811067ffffffffffffffff821117156140f357600080fd5b8060405250919050565b600067ffffffffffffffff82111561411457600080fd5b602082029050602081019050919050565b6000819050602082019050919050565b600081519050919050565b600081519050919050565b600081519050919050565b6000602082019050919050565b600082825260208201905092915050565b600081905092915050565b600082825260208201905092915050565b600061419b826141dc565b9050919050565b60008115159050919050565b6000819050919050565b60006141c382614190565b9050919050565b60006141d582614190565b9050919050565b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b6000819050919050565b600060ff82169050919050565b600061421e82614225565b9050919050565b6000614230826141dc565b9050919050565b600061424282614249565b9050919050565b6000614254826141dc565b9050919050565b60006142668261426d565b9050919050565b6000614278826141dc565b9050919050565b600061428a82614291565b9050919050565b600061429c826141dc565b9050919050565b60006142ae826142b5565b9050919050565b60006142c0826141dc565b9050919050565b82818337600083830152505050565b60005b838110156142f45780820151818401526020810190506142d9565b83811115614303576000848401525b50505050565b6000819050919050565b6000601f19601f8301169050919050565b61432d81614190565b811461433857600080fd5b50565b614344816141a2565b811461434f57600080fd5b50565b61435b816141ae565b811461436657600080fd5b50565b614372816141b8565b811461437d57600080fd5b50565b614389816141ca565b811461439457600080fd5b50565b6143a0816141dc565b81146143ab57600080fd5b50565b6143b7816141fc565b81146143c257600080fd5b50565b6143ce81614206565b81146143d957600080fd5b5056fea264697066735822122098f6885045b01196bf8a10bcf6a95aaa31b12d3b0f2aa0b078a1790c7a80d3a364736f6c634300060c0033 diff --git a/tests/privacyRelayer.routes.test.js b/tests/privacyRelayer.routes.test.js new file mode 100644 index 0000000..627bda6 --- /dev/null +++ b/tests/privacyRelayer.routes.test.js @@ -0,0 +1,51 @@ +'use strict'; + +const express = require('express'); +const request = require('supertest'); +const { createPrivacyRouter, PRIVACY_PREFIXES } = require('../routes/privacy'); +const { buildTestApp } = require('./helpers/buildTestApp'); +const next = (_req, _res, proceed) => proceed(); +function app(relayers, index) { + const a = express(); a.use(PRIVACY_PREFIXES, createPrivacyRouter({ index, relayers, limiter: next })); return a; +} +test.each(PRIVACY_PREFIXES)('bulk directory is anonymous and parameter-free at %s', async (prefix) => { + const read = jest.fn(async () => ({ status: 202, body: { phase: 'syncing' } })); + const a = app({ read }); const url = prefix + '/relayers'; + const res = await request(a).get(url).set('Origin', 'chrome-extension://pali'); + expect(res.status).toBe(202); expect(read).toHaveBeenCalledTimes(1); + expect(res.headers['access-control-allow-origin']).toBe('*'); expect(res.headers['access-control-allow-credentials']).toBeUndefined(); + expect(res.headers['set-cookie']).toBeUndefined(); + for (const suffix of ['?note=secret', '?address=0x123', '?url=https://private.example', '?pool=eth-1']) { + expect((await request(a).get(url + suffix)).status).toBe(400); + } + expect((await request(a).get(url).send({ note: 'secret' })).status).toBe(400); + expect((await request(a).post(url).send({ url: 'https://private.example' })).status).toBe(405); + expect(read).toHaveBeenCalledTimes(1); +}); +test('disabled directory does not require or enable the pool index', async () => { + const a = app(); expect((await request(a).get('/api/privacy/v1/relayers')).body).toEqual({ error: 'privacy_relayer_directory_disabled' }); + expect((await request(a).get('/api/privacy/v1/pools/eth-1/snapshot')).body).toEqual({ error: 'privacy_index_disabled' }); +}); +test.each(PRIVACY_PREFIXES)('existing pool snapshot route works under preserved or stripped proxy prefix %s', async (prefix) => { + const read = jest.fn(async () => ({ status: 202, body: { phase: 'syncing' } })); + expect((await request(app(null, { read })).get(prefix + '/pools/eth-1/snapshot')).status).toBe(202); + expect(read).toHaveBeenCalledWith('eth-1'); + const unknown = await request(app()).get(prefix + '/status'); expect(unknown.status).toBe(404); expect(unknown.body.error).toBe('unknown_privacy_route'); +}); +test('directory ETag carries stale observation marker; errors do not leak internal text', async () => { + const read = jest.fn(async () => ({ status: 200, encoded: '{"relayers":[]}', etag: '"r1"', phase: 'stale' })); + const a = app({ read }); + const res = await request(a).get('/api/privacy/v1/relayers').set('If-None-Match', '"r1"'); + expect(res.status).toBe(304); expect(res.headers['x-privacy-index-state']).toBe('stale'); expect(res.headers['cache-control']).toContain('must-revalidate'); + read.mockRejectedValue(new Error('secret rpc url')); + expect((await request(a).get('/api/privacy/v1/relayers')).body).toEqual({ error: 'privacy_relayer_directory_unavailable' }); +}); +test('production app factory wires stripped public prefix before credentialed middleware', async () => { + const read = jest.fn(async () => ({ status: 202, body: { phase: 'syncing' } })); + const ctx = buildTestApp({ privacyRelayers: { read } }); + try { + const res = await request(ctx.app).get('/privacy/v1/relayers').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(ctx.privacyRelayers).toEqual({ read }); + } finally { ctx.db.close(); } +}); diff --git a/tests/privacyRelayerDirectory.test.js b/tests/privacyRelayerDirectory.test.js new file mode 100644 index 0000000..dfc07ec --- /dev/null +++ b/tests/privacyRelayerDirectory.test.js @@ -0,0 +1,175 @@ +'use strict'; + +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { namehash } = require('ethers'); +const { createPrivacyRelayerDirectory, createRelayerCheckpointStore, configuredPrivacyRelayerDirectory, + SOURCE_ID, REFRESH_MS, JOB_MS, RANGE, MAX_CHUNKS } = require('../lib/privacyRelayerDirectory'); +const { DEPLOYMENT_BLOCK, MAX_CANDIDATES } = require('../lib/privacyRelayerRegistry'); +const owner = '0x1111111111111111111111111111111111111111'; +const candidate = { ensName: 'relay.eth', ensHash: namehash('relay.eth'), registeredAddress: owner }; +const hash = '0x' + 'aa'.repeat(32); +function setup({ saved, candidates = [candidate], end = DEPLOYMENT_BLOCK + 2, overrides = {} } = {}) { + let clock = 1700000000000; let disk = saved; const timers = new Set(); + const header = { number: end, hash, timestamp: clock / 1000 }; + const store = { read: jest.fn(async () => disk), write: jest.fn(async (value) => { disk = structuredClone(value); }) }; + const guard = (ctx) => ctx?.beforeRequest?.(); + const rpc = { + head: jest.fn(async (ctx) => { guard(ctx); return { ...header }; }), + assertSource: jest.fn(async (_h, ctx) => { guard(ctx); }), + assertBlock: jest.fn(async (_h, ctx) => { guard(ctx); }), + block: jest.fn(async (number, ctx) => { guard(ctx); return { number, hash, timestamp: clock / 1000 }; }), + events: jest.fn(async (_from, _to, ctx) => { guard(ctx); return candidates; }), + resolve: jest.fn(async (c, _h, ctx) => { guard(ctx); return { ensName: c.ensName, ensHash: c.ensHash, owner, registryBalance: '123', endpoint: 'https://public.example' }; }), + rewardRegistered: jest.fn(async (_owner, _reward, _h, ctx) => { guard(ctx); return true; }), ...overrides, + }; + const inspect = jest.fn(async (_endpoint, ctx) => { guard(ctx); return { origin: 'https://public.example', rewardAccount: owner, + supportedPools: ['eth-0.1'], serviceFeePercent: '0.3', reportedHealthy: true, queueLength: 0, version: '5.0.1', observedAt: clock }; }); + const directory = createPrivacyRelayerDirectory({ rpc, inspect, store, now: () => clock, + setTimer: (fn, delay) => { const t = { fn, delay }; timers.add(t); return t; }, clearTimer: (t) => timers.delete(t) }); + return { directory, rpc, inspect, store, timers, header, + advance: (ms) => { clock += ms; }, + tick: async () => { const timer = [...timers][0]; if (timer) { timers.delete(timer); clock += timer.delay; timer.fn(); await directory.idle(); } }, + get disk() { return disk; }, + }; +} +async function ready(s) { await s.directory.read(); await s.directory.idle(); return s.directory.read(); } + +test('directory is opt-in and configuration requires a separately enabled flag', () => { + expect(configuredPrivacyRelayerDirectory({ PRIVACY_INDEX_RPC_URL: 'https://operator.example' })).toBeNull(); + expect(() => configuredPrivacyRelayerDirectory({ PRIVACY_RELAYER_DIRECTORY_ENABLED: 'true' })).toThrow('rpc_required'); +}); +test('unused directory stays idle; first bulk read publishes only public anchored observations', async () => { + const s = setup(); await s.directory.start(); expect(s.rpc.head).not.toHaveBeenCalled(); + expect((await s.directory.read()).status).toBe(202); await s.directory.idle(); + const res = await s.directory.read(); expect(res.status).toBe(200); expect(res.phase).toBe('ready'); + expect(res.body).toMatchObject({ version: 1, chainId: 1, asOfBlockNumber: s.header.number, asOfBlockHash: hash, unavailableEntries: 0, + relayers: [{ owner, advertisedServiceFeePercent: '0.3', supportedPools: ['eth-0.1'], health: 'reported-healthy' }] }); + expect(s.rpc.resolve).toHaveBeenCalledWith(candidate, s.header, expect.objectContaining({ beforeRequest: expect.any(Function) })); + expect(s.rpc.rewardRegistered).toHaveBeenCalledWith(owner, owner, s.header, expect.any(Object)); + expect(s.disk).toMatchObject({ version: 1, source: SOURCE_ID, scannedBlock: s.header.number }); + expect(JSON.stringify(s.disk)).not.toContain('endpoint'); expect(JSON.stringify(s.disk)).not.toContain('Fee'); + await s.directory.close(); expect(s.timers.size).toBe(0); +}); +test('deduplicates concurrent demand into one registry scan and one status request', async () => { + const s = setup(); await Promise.all(Array.from({ length: 10 }, () => s.directory.read())); await s.directory.idle(); + expect(s.rpc.head).toHaveBeenCalledTimes(1); expect(s.inspect).toHaveBeenCalledTimes(1); await s.directory.close(); +}); +test('cold checkpoint never supplies a trusted directory before fresh validation', async () => { + const saved = { version: 1, source: SOURCE_ID, scannedBlock: DEPLOYMENT_BLOCK + 2, scannedHash: hash, candidates: [candidate], relayers: [{ health: 'reported-healthy' }] }; + const s = setup({ saved, overrides: { head: jest.fn(async () => { throw new Error('offline'); }) } }); + await s.directory.start(); await s.directory.idle(); + expect((await s.directory.read()).status).toBe(503); expect(s.inspect).not.toHaveBeenCalled(); await s.directory.close(); +}); +test('invalid old source checkpoints rebuild from pinned deployment', async () => { + const s = setup({ saved: { version: 1, source: 'old', scannedBlock: DEPLOYMENT_BLOCK + 2, scannedHash: hash, candidates: [candidate] } }); + await ready(s); expect(s.rpc.events.mock.calls[0][0]).toBe(DEPLOYMENT_BLOCK); await s.directory.close(); +}); +test('restored checkpoint with reorg starts a new canonical registry scan', async () => { + const s = setup({ saved: { version: 1, source: SOURCE_ID, scannedBlock: DEPLOYMENT_BLOCK + 1, scannedHash: '0x' + 'bb'.repeat(32), candidates: [candidate] } }); + await ready(s); expect(s.rpc.events.mock.calls[0][0]).toBe(DEPLOYMENT_BLOCK); await s.directory.close(); +}); +test('current registration refusals remove old candidates rather than endorsing old endpoints', async () => { + const s = setup({ overrides: { resolve: jest.fn(async () => null) } }); + const res = await ready(s); expect(res.body.relayers).toEqual([]); expect(s.inspect).not.toHaveBeenCalled(); await s.directory.close(); +}); +test('unavailable or mismatched reward status never supplies selectable advertised fees', async () => { + const s = setup({ overrides: { rewardRegistered: jest.fn(async () => false) } }); + const res = await ready(s); expect(res.body.relayers[0]).toEqual({ ensName: candidate.ensName, ensHash: candidate.ensHash, owner, registryBalance: '123', health: 'unavailable' }); + expect(res.body.unavailableEntries).toBe(1); await s.directory.close(); + const offline = setup(); offline.inspect.mockRejectedValue(new Error('timeout')); + const failed = await ready(offline); expect(failed.body.relayers[0].health).toBe('unavailable'); expect(failed.body.relayers[0].endpoint).toBeUndefined(); + await offline.directory.close(); +}); +test('periodic refresh retains old as-of snapshot with stale header on failure', async () => { + const s = setup(); const old = await ready(s); s.advance(REFRESH_MS); + s.rpc.head.mockRejectedValue(new Error('offline')); + const refreshing = await s.directory.read(); expect(refreshing.body.asOfBlockNumber).toBe(old.body.asOfBlockNumber); + await s.directory.idle(); const after = await s.directory.read(); expect(after.phase).toBe('stale'); expect(after.etag).toBe(old.etag); + await s.directory.close(); +}); +test('source upgrade and anchor reorg discard an already-published directory', async () => { + for (const message of ['privacy_relayer_source_changed', 'privacy_relayer_reorg']) { + const s = setup(); await ready(s); s.advance(REFRESH_MS); s.rpc.assertSource.mockRejectedValue(new Error(message)); + await s.directory.read(); await s.directory.idle(); expect((await s.directory.read()).status).toBe(503); await s.directory.close(); + } +}); +test('large backfill is bounded to sixteen chunks before yielding', async () => { + const s = setup({ candidates: [], end: DEPLOYMENT_BLOCK + RANGE * (MAX_CHUNKS + 1) }); + await s.directory.read(); await s.directory.idle(); expect(s.rpc.events).toHaveBeenCalledTimes(MAX_CHUNKS); expect(s.inspect).not.toHaveBeenCalled(); + await s.tick(); expect((await s.directory.read()).status).toBe(200); await s.directory.close(); +}); +test('candidate capacity overflow is explicit and never silently truncates', async () => { + const candidates = Array.from({ length: MAX_CANDIDATES + 1 }, (_, i) => ({ ensName: `relay${i}.eth`, ensHash: namehash(`relay${i}.eth`), registeredAddress: owner })); + const s = setup({ candidates }); const res = await ready(s); expect(res.status).toBe(503); expect(s.inspect).not.toHaveBeenCalled(); await s.directory.close(); +}); +test('close during an awaited RPC prevents later requests and publication', async () => { + const s = setup(); let finish; s.rpc.head.mockImplementation(() => new Promise((resolve) => { finish = resolve; })); + await s.directory.read(); const closing = s.directory.close(); finish(s.header); await closing; + expect(s.rpc.assertSource).toHaveBeenCalledTimes(1); // fake adapter calls the real beforeRequest fence before any transport. + expect(s.rpc.events).not.toHaveBeenCalled(); expect(s.inspect).not.toHaveBeenCalled(); expect(s.timers.size).toBe(0); + expect((await s.directory.read()).status).toBe(503); +}); +test('deadline during a long RPC yields without launching the next transport', async () => { + const s = setup(); s.rpc.head.mockImplementation(async (ctx) => { ctx.beforeRequest(); s.advance(JOB_MS); return s.header; }); + await s.directory.read(); await s.directory.idle(); expect(s.rpc.events).not.toHaveBeenCalled(); expect(s.inspect).not.toHaveBeenCalled(); + expect(s.timers.size).toBe(1); await s.directory.close(); +}); +test('checkpoint store checks checksum and only uses one fixed public filename', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'relayer-checkpoint-')); + try { + const store = createRelayerCheckpointStore(dir); await store.write({ public: 'data' }); expect(await store.read()).toEqual({ public: 'data' }); + const file = path.join(dir, 'registry-checkpoint.json'); const value = JSON.parse(await fs.readFile(file, 'utf8')); value.value.public = 'changed'; + await fs.writeFile(file, JSON.stringify(value)); expect(await store.read()).toBeNull(); expect(await fs.readdir(dir)).toEqual(['registry-checkpoint.json']); + } finally { await fs.rm(dir, { recursive: true, force: true }); } +}); + +test('polling cannot bypass failure backoff or bounded continuation delay', async () => { + const s = setup({ overrides: { head: jest.fn(async () => { throw new Error('offline'); }) } }); + await s.directory.read(); await s.directory.idle(); + await Promise.all(Array.from({ length: 10 }, () => s.directory.read())); + expect(s.rpc.head).toHaveBeenCalledTimes(1); + await s.tick(); expect(s.rpc.head).toHaveBeenCalledTimes(2); await s.directory.close(); + const backlog = setup({ candidates: [], end: DEPLOYMENT_BLOCK + RANGE * (MAX_CHUNKS + 1) }); + await backlog.directory.read(); await backlog.directory.idle(); + await Promise.all(Array.from({ length: 10 }, () => backlog.directory.read())); + expect(backlog.rpc.events).toHaveBeenCalledTimes(MAX_CHUNKS); await backlog.directory.close(); +}); +test('provider range rejection shrinks subsequent public scans without skipping blocks', async () => { + const s = setup({ candidates: [], end: DEPLOYMENT_BLOCK + RANGE }); + s.rpc.events.mockImplementation(async (from, to) => { + if (to - from + 1 > RANGE / 2) throw new Error('privacy_relayer_rpc_rejected'); + return []; + }); + await s.directory.read(); await s.directory.idle(); + expect(s.store.write).not.toHaveBeenCalled(); await s.tick(); + expect(s.rpc.events.mock.calls[1].slice(0, 2)).toEqual([DEPLOYMENT_BLOCK, DEPLOYMENT_BLOCK + RANGE / 2 - 1]); + expect((await s.directory.read()).status).toBe(200); await s.directory.close(); +}); + +test('checkpoint beyond current chain head resets without requesting the unavailable future block', async () => { + const future = DEPLOYMENT_BLOCK + 100; + const s = setup({ saved: { version: 1, source: SOURCE_ID, scannedBlock: future, scannedHash: hash, candidates: [candidate] } }); + const originalBlock = s.rpc.block.getMockImplementation(); + s.rpc.block.mockImplementation(async (number, context) => { + if (number === future) throw new Error('privacy_relayer_missing_block'); + return originalBlock(number, context); + }); + const res = await ready(s); expect(res.status).toBe(200); + expect(s.rpc.events.mock.calls[0][0]).toBe(DEPLOYMENT_BLOCK); + expect(s.rpc.block.mock.calls.some(([number]) => number === future)).toBe(false); + await s.directory.close(); +}); + +test('long catalogue assembly does not re-date an earlier endpoint status', async () => { + const candidates = Array.from({ length: 72 }, (_, i) => ({ ensName: `relay${i}.eth`, ensHash: namehash(`relay${i}.eth`), registeredAddress: owner })); + const s = setup({ candidates }); const inspect = s.inspect.getMockImplementation(); + s.inspect.mockImplementation(async (...args) => { const result = await inspect(...args); s.advance(5000); return result; }); + try { + await s.directory.read(); await s.directory.idle(); + for (let i = 0; i < 8; i++) await s.tick(); + const res = await s.directory.read(); expect(res.status).toBe(200); expect(res.phase).toBe('stale'); + expect(res.body.observedAt - res.body.relayers[0].statusObservedAt).toBeGreaterThan(REFRESH_MS); + } finally { await s.directory.close(); } +}); diff --git a/tests/privacyRelayerRpc.test.js b/tests/privacyRelayerRpc.test.js new file mode 100644 index 0000000..ff5633a --- /dev/null +++ b/tests/privacyRelayerRpc.test.js @@ -0,0 +1,88 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { namehash, toQuantity } = require('ethers'); +const { createPrivacyRelayerRpc, TOPIC } = require('../lib/privacyRelayerRpc'); +const { ABI, REGISTRY, IMPLEMENTATION, ENS, IMPLEMENTATION_SLOT, urlNode } = require('../lib/privacyRelayerRegistry'); +const code = fs.readFileSync(path.join(__dirname, 'fixtures/privacy-relayer-implementation.hex'), 'utf8').trim(); +const owner = '0x1111111111111111111111111111111111111111'; +const resolver = '0x2222222222222222222222222222222222222222'; +const c = { ensName: 'relay.eth', ensHash: namehash('relay.eth'), registeredAddress: owner }; +const header = { number: 20000000, hash: '0x' + 'aa'.repeat(32), timestamp: 1700000000 }; +function fixture(overrides = {}) { + const calls = []; + const post = jest.fn(async (_url, body, options) => { + calls.push(body); + let result; + if (overrides[body.method]) result = await overrides[body.method](body.params, body); + else if (body.method === 'eth_chainId') result = '0x1'; + else if (body.method === 'eth_getBlockByNumber') result = { number: toQuantity(header.number), hash: header.hash, timestamp: toQuantity(header.timestamp) }; + else if (body.method === 'eth_getStorageAt') result = '0x' + '0'.repeat(24) + IMPLEMENTATION.slice(2); + else if (body.method === 'eth_getCode') result = code; + else if (body.method === 'eth_call') { + const parsed = ABI.parseTransaction(body.params[0]); + const values = { owner: [owner], resolver: [resolver], relayers: [123n, c.ensHash], isRelayerRegistered: [true], text: ['https://public.example'] }; + result = ABI.encodeFunctionResult(parsed.name, values[parsed.name]); + } else if (body.method === 'eth_getLogs') result = []; + else throw new Error('unexpected method'); + expect(options).toMatchObject({ timeout: 10000, maxRedirects: 0 }); + return { status: 200, data: { jsonrpc: '2.0', id: body.id, result } }; + }); + return { rpc: createPrivacyRelayerRpc('https://operator.example/rpc', { post, now: () => header.timestamp * 1000 }), post, calls }; +} +test('relayer source checks mainnet, finalized block and pinned implementation runtime', async () => { + const { rpc, calls } = fixture(); expect(await rpc.head()).toEqual(header); await rpc.assertSource(header); + expect(calls).toContainEqual(expect.objectContaining({ method: 'eth_getStorageAt', params: [REGISTRY, IMPLEMENTATION_SLOT, toQuantity(header.number)] })); + expect(calls).toContainEqual(expect.objectContaining({ method: 'eth_getCode', params: [IMPLEMENTATION, toQuantity(header.number)] })); +}); +test('registry implementation upgrade and changed code fail closed', async () => { + await expect(fixture({ eth_getStorageAt: () => '0x' + '00'.repeat(32) }).rpc.assertSource(header)).rejects.toThrow('source_changed'); + await expect(fixture({ eth_getCode: () => '0x00' }).rpc.assertSource(header)).rejects.toThrow('source_changed'); +}); +test('reads current owner, exact registered ENS identity and URL at the same block', async () => { + const { rpc, calls } = fixture(); + expect(await rpc.resolve(c, header)).toEqual({ ensName: c.ensName, ensHash: c.ensHash, owner, registryBalance: '123', endpoint: 'https://public.example' }); + const reads = calls.filter((x) => x.method === 'eth_call'); + expect(reads.every((x) => x.params[1] === toQuantity(header.number))).toBe(true); + expect(reads.map((x) => x.params[0].to)).toEqual([ENS, REGISTRY, REGISTRY, ENS, resolver]); + const textCall = ABI.parseTransaction(reads.at(-1).params[0]); expect(textCall.args[0]).toBe(urlNode(c.ensHash)); +}); +test('past registration does not admit a deregistered owner or different current ENS node', async () => { + const { rpc } = fixture({ eth_call: ([tx]) => { + const parsed = ABI.parseTransaction(tx); + return ABI.encodeFunctionResult(parsed.name, parsed.name === 'owner' ? [owner] : parsed.name === 'relayers' ? [999n, namehash('different.eth')] : [false]); + } }); + expect(await rpc.resolve(c, header)).toBeNull(); + const { rpc: unregistered } = fixture({ eth_call: ([tx]) => { + const parsed = ABI.parseTransaction(tx); + return ABI.encodeFunctionResult(parsed.name, parsed.name === 'owner' ? [owner] : parsed.name === 'relayers' ? [123n, c.ensHash] : [false]); + } }); + expect(await unregistered.resolve(c, header)).toBeNull(); +}); +test('registered worker rewards are admitted only by registry worker mapping', async () => { + const { rpc, calls } = fixture(); expect(await rpc.rewardRegistered(owner, resolver, header)).toBe(true); + const parsed = ABI.parseTransaction(calls.at(-1).params[0]); expect([...parsed.args]).toEqual([owner, resolver]); +}); +function log(overrides = {}) { + const event = ABI.encodeEventLog(ABI.getEvent('RelayerRegistered'), [c.ensHash, c.ensName, owner, 5000n]); + return { address: REGISTRY, removed: false, ...event, blockNumber: toQuantity(header.number), blockHash: header.hash, + transactionHash: '0x' + 'bb'.repeat(32), transactionIndex: '0x0', logIndex: '0x1', ...overrides }; +} +test('registration discovery binds namehash and canonical log envelope', async () => { + const { rpc, calls } = fixture({ eth_getLogs: () => [log()] }); + expect(await rpc.events(header.number, header.number)).toEqual([{ ...c, blockNumber: header.number, txIndex: 0, logIndex: 1 }]); + expect(calls[0].params[0]).toEqual({ address: REGISTRY, topics: [TOPIC], fromBlock: toQuantity(header.number), toBlock: toQuantity(header.number) }); +}); +test.each([{ removed: true }, { address: owner }, { topics: [] }, { blockNumber: '0x1' }])('rejects invalid registry event %p', async (override) => { + await expect(fixture({ eth_getLogs: () => [log(override)] }).rpc.events(header.number, header.number)).rejects.toThrow(); +}); +test('rejects duplicated event position', async () => { + await expect(fixture({ eth_getLogs: () => [log(), log()] }).rpc.events(header.number, header.number)).rejects.toThrow(); +}); +test('every underlying RPC honors the deadline after an awaited reply', async () => { + let stopped = false; + const { rpc, post } = fixture({ eth_getStorageAt: async () => { stopped = true; return '0x' + '0'.repeat(24) + IMPLEMENTATION.slice(2); } }); + await expect(rpc.assertSource(header, { beforeRequest: () => { if (stopped) throw new Error('closed'); } })).rejects.toThrow('closed'); + expect(post).toHaveBeenCalledTimes(1); +}); diff --git a/tests/privacyRelayerStatus.test.js b/tests/privacyRelayerStatus.test.js new file mode 100644 index 0000000..e525643 --- /dev/null +++ b/tests/privacyRelayerStatus.test.js @@ -0,0 +1,91 @@ +'use strict'; + +const { EventEmitter } = require('node:events'); +const { PassThrough } = require('node:stream'); +const { POOLS } = require('../lib/privacyRegistry'); +const { createPrivacyRelayerStatus, publicAddress, endpoint, parseStatus, MAX_STATUS_BYTES, STATUS_TIMEOUT_MS } = require('../lib/privacyRelayerStatus'); +function status(overrides = {}) { + return { netId: 1, rewardAccount: '0x1111111111111111111111111111111111111111', + instances: { eth: { decimals: 18, instanceAddress: Object.fromEntries(POOLS.map((p) => [p.id.slice(4), p.address])) } }, + tornadoServiceFee: 0.3, health: { status: 'true', errorsLog: 'never forward this' }, currentQueue: 2, version: '5.0.1', ...overrides }; +} +function transport({ body = JSON.stringify(status()), statusCode = 200, headers = {}, chunks } = {}) { + const request = jest.fn((options, callback) => { + const req = new EventEmitter(); req.destroy = jest.fn(); + req.end = () => queueMicrotask(() => { + const res = new PassThrough(); res.statusCode = statusCode; + res.headers = { 'content-type': 'application/json', ...headers }; + callback(res); + if (!res.destroyed) { for (const chunk of chunks || [body]) res.write(chunk); res.end(); } + }); + return req; + }); + return request; +} +const publicDns = async () => [{ address: '8.8.8.8', family: 4 }]; + +describe('public relayer status transport', () => { + test.each(['http://public.example', 'https://127.1', 'https://2130706433', 'https://[::1]', + 'https://169.254.169.254', 'https://user:pass@public.example', 'https://public.example:8443', + 'https://public.example/path', 'https://public.example?recipient=x', 'https://public.example#x', + 'https://localhost', 'https://public.example.', 'https://public.example\\@private.example'])('rejects non-fixed endpoint %s', (url) => { + expect(() => endpoint(url)).toThrow(); + }); + test.each(['0.1.2.3', '10.0.0.1', '100.64.0.1', '127.0.0.1', '169.254.169.254', '172.31.1.2', + '192.168.1.1', '192.0.0.2', '198.18.0.1', '203.0.113.2', '224.0.0.1', '255.255.255.255', + '::', '::1', 'fc00::1', 'fe80::1', '::ffff:8.8.8.8', '64:ff9b::a00:1', '2001:db8::1', '2002:0808:0808::1'])('blocks non-public address %s', (ip) => { + expect(publicAddress(ip)).toBe(false); + }); + test.each(['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111', '2001:4860:4860::8888'])('allows public address %s', (ip) => expect(publicAddress(ip)).toBe(true)); + test('pins checked DNS to TLS host and projects only advertised fields', async () => { + const answers = [{ address: '8.8.8.8', family: 4 }]; + const request = transport(); + const result = await createPrivacyRelayerStatus({ lookup: async () => answers, request, now: () => 123 })('https://public.example/'); + const options = request.mock.calls[0][0]; + expect(options).toMatchObject({ hostname: 'public.example', servername: 'public.example', path: '/status', port: 443, rejectUnauthorized: true, agent: false }); + answers[0].address = '127.0.0.1'; + const cb = jest.fn(); options.lookup('public.example', {}, cb); + expect(cb).toHaveBeenCalledWith(null, '8.8.8.8', 4); + expect(result).toEqual({ origin: 'https://public.example', rewardAccount: status().rewardAccount, + supportedPools: POOLS.map((p) => p.id), serviceFeePercent: '0.3', reportedHealthy: true, queueLength: 2, version: '5.0.1', observedAt: 123 }); + expect(JSON.stringify(result)).not.toContain('errorsLog'); + }); + test('rejects mixed private DNS and never sends the request', async () => { + const request = transport(); + const inspect = createPrivacyRelayerStatus({ lookup: async () => [...await publicDns(), { address: '127.0.0.1', family: 4 }], request }); + await expect(inspect('https://public.example')).rejects.toThrow('dns_rejected'); expect(request).not.toHaveBeenCalled(); + }); + test('close/deadline guard runs again after DNS and before HTTPS', async () => { + const request = transport(); let stopped = false; + const inspect = createPrivacyRelayerStatus({ lookup: async () => { stopped = true; return publicDns(); }, request }); + await expect(inspect('https://public.example', { beforeRequest: () => { if (stopped) throw new Error('closed'); } })).rejects.toThrow('closed'); + expect(request).not.toHaveBeenCalled(); + }); + test.each([{ statusCode: 302 }, { headers: { 'content-encoding': 'gzip' } }, + { headers: { 'content-type': 'text/html' } }, { headers: { 'content-length': String(MAX_STATUS_BYTES + 1) } }])('rejects redirects and unsupported response envelope %p', async (opts) => { + await expect(createPrivacyRelayerStatus({ lookup: publicDns, request: transport(opts) })('https://public.example')).rejects.toThrow('status_rejected'); + }); + test('limits streamed body even without content-length', async () => { + await expect(createPrivacyRelayerStatus({ lookup: publicDns, request: transport({ chunks: ['x'.repeat(MAX_STATUS_BYTES), 'x'] }) })('https://public.example')).rejects.toThrow('too_large'); + }); + test('total deadline covers unresolved DNS and prevents late network access', async () => { + jest.useFakeTimers(); + try { + let complete; const lookup = () => new Promise((resolve) => { complete = resolve; }); const request = transport(); + const result = createPrivacyRelayerStatus({ lookup, request })('https://public.example'); + const assertion = expect(result).rejects.toThrow('timeout'); + await jest.advanceTimersByTimeAsync(STATUS_TIMEOUT_MS); await assertion; + complete(await publicDns()); await Promise.resolve(); expect(request).not.toHaveBeenCalled(); + } finally { jest.useRealTimers(); } + }); + test.each([{ netId: 5 }, { tornadoServiceFee: -1 }, { tornadoServiceFee: 101 }, { tornadoServiceFee: 'NaN' }, { tornadoServiceFee: '100.000000000000000001' }, + { health: { status: 'probably' } }, { rewardAccount: 'invalid' }, { currentQueue: -1 }, { version: '