From 8fd90ff3f6d45376457d3cde32f59e975cdb2645 Mon Sep 17 00:00:00 2001 From: Goran Vladika Date: Thu, 15 Oct 2026 14:28:50 +0200 Subject: [PATCH 1/2] feat(rpc): add remove-network-rpc script for RpcEndpoints (EXSC-1216) Co-Authored-By: Claude Opus 5.5 (1M context) --- package.json | 1 + script/mongoDb/remove-network-rpc.ts | 142 +++++++++++++++++++++++++++ script/mongoDb/rpcEndpoints.test.ts | 64 ++++++++++++ script/mongoDb/rpcEndpoints.ts | 32 +++++- 4 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 script/mongoDb/remove-network-rpc.ts diff --git a/package.json b/package.json index b66385bc60..e3f7ac14e5 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "abi:removeDuplicateEvents": "bunx tsx ./script/removeDuplicateEventsFromABI.ts", "add-network-rpc": "bunx tsx ./script/mongoDb/add-network-rpc.ts", "audit-rpc-priorities": "bunx tsx ./script/mongoDb/audit-rpc-priorities.ts", + "remove-network-rpc": "bunx tsx ./script/mongoDb/remove-network-rpc.ts", "add-safe-owners-and-threshold": "bash script/deploy/safe/with-safe-tunnel.sh && bunx tsx ./script/deploy/safe/add-safe-owners-and-threshold.ts", "codegen": "plop", "compile": "forge build", diff --git a/script/mongoDb/remove-network-rpc.ts b/script/mongoDb/remove-network-rpc.ts new file mode 100644 index 0000000000..759f423e2a --- /dev/null +++ b/script/mongoDb/remove-network-rpc.ts @@ -0,0 +1,142 @@ +/** + * Remove one RPC endpoint from a chain in the `RpcEndpoints` collection, named by its host so the + * operator never pastes a URL that carries an API key. Use it to drop dead or misconfigured + * endpoints; run `bun fetch-rpcs` afterwards to regenerate the env file. + * + * Dry run unless `--apply` is passed. A host stored more than once on the chain is refused rather + * than guessed at. + */ +import 'dotenv/config' +import { defineCommand, runMain } from 'citty' +import { consola } from 'consola' +import { MongoClient } from 'mongodb' + +import { mongoEq } from '../deploy/shared/mongo-log-utils' + +import { + findEndpointsByHost, + hasApiCredentials, + hostOf, + selectEndpoints, + type IRpcEndpoint, +} from './rpcEndpoints' + +const main = defineCommand({ + meta: { + name: 'remove-network-rpc', + description: 'Remove an RPC endpoint from the blockchain configuration', + }, + args: { + network: { + type: 'string', + description: 'Network name the endpoint is stored under', + required: true, + }, + host: { + type: 'string', + description: + 'Host of the endpoint to remove, as audit-rpc-priorities prints it', + required: true, + }, + environment: { + type: 'string', + description: 'Environment of the endpoint (default is production)', + required: false, + default: 'production', + }, + apply: { + type: 'boolean', + description: 'Write the removal to MongoDB (default is a dry run)', + required: false, + default: false, + }, + }, + async run({ args }) { + const { network, host, environment } = args + + const MONGODB_URI = process.env.MONGODB_URI + if (!MONGODB_URI) { + consola.error('MONGODB_URI is not defined in the environment') + process.exit(1) + } + + const client = new MongoClient(MONGODB_URI) + let exitCode = 0 + try { + await client.connect() + const collection = client + .db('blockchain-configs') + .collection('RpcEndpoints') + + const doc = await collection.findOne({ chainName: mongoEq(network) }) + if (!doc) throw new Error(`No RpcEndpoints document for "${network}"`) + const rpcs: IRpcEndpoint[] = Array.isArray(doc.rpcs) ? doc.rpcs : [] + + const matches = findEndpointsByHost(rpcs, host, environment) + if (matches.length === 0) + throw new Error( + `${network} has no ${environment} endpoint on host "${host}". Run audit-rpc-priorities to list its hosts.` + ) + if (matches.length > 1) + throw new Error( + `${network} has ${matches.length} ${environment} endpoints on host "${host}"; refusing to guess which one to remove` + ) + + const [target] = matches as [IRpcEndpoint] + const remaining = selectEndpoints( + rpcs.filter((rpc) => rpc !== target), + environment + ) + if (remaining.length === 0) + throw new Error( + `Removing ${host} would leave ${network} with no ${environment} endpoint; add a replacement first` + ) + + const label = `${network} [${environment}] ${hostOf(target.url)} p=${ + target.priority + } (${hasApiCredentials(target.url) ? 'keyed' : 'no key'})` + + if (!args.apply) { + consola.info(`Dry run — would remove ${label}`) + consola.info( + `Resulting order: ${remaining + .map((rpc) => hostOf(rpc.url)) + .join(', ')}` + ) + return + } + + // Endpoints predating the environment field count as production, so a production removal + // must also match a record with no environment, and must not touch the same URL stored for + // another environment. + const environmentMatch = + environment === 'production' + ? { $in: ['production', null] } + : environment + const result = await collection.updateOne( + { chainName: mongoEq(network) }, + { + $set: { lastUpdated: new Date() }, + $pull: { + rpcs: { url: target.url, environment: environmentMatch }, + } as never, + } + ) + if (result.modifiedCount !== 1) + throw new Error(`MongoDB reported no change while removing ${label}`) + + consola.success(`Removed ${label}`) + consola.info( + `Resulting order: ${remaining.map((rpc) => hostOf(rpc.url)).join(', ')}` + ) + } catch (error) { + consola.error(error instanceof Error ? error.message : error) + exitCode = 1 + } finally { + await client.close() + } + if (exitCode !== 0) process.exit(exitCode) + }, +}) + +runMain(main) diff --git a/script/mongoDb/rpcEndpoints.test.ts b/script/mongoDb/rpcEndpoints.test.ts index 7d7da91220..db7a72b85e 100644 --- a/script/mongoDb/rpcEndpoints.test.ts +++ b/script/mongoDb/rpcEndpoints.test.ts @@ -11,6 +11,7 @@ import { describe, expect, it } from 'bun:test' import { buildEnvLines, findEndpointIndex, + findEndpointsByHost, findUncredentialedPrimaries, hasApiCredentials, isPublicPrimaryActionable, @@ -464,6 +465,69 @@ describe('findEndpointIndex', () => { }) }) +describe('findEndpointsByHost', () => { + const KEYED_A = 'https://gw.example.invalid/v1/aaaaaaaaaaaaaaaaaaaaaaaa' + const KEYED_B = 'https://gw.example.invalid/v1/bbbbbbbbbbbbbbbbbbbbbbbb' + const WITH_PORT = 'https://node.example.invalid:8545/' + + it('matches on host without the caller supplying the keyed path', () => { + const rpcs: IRpcEndpoint[] = [ + { url: KEYED_A, priority: 2 }, + { url: KEYLESS_PUBLIC, priority: 1 }, + ] + expect( + findEndpointsByHost(rpcs, 'gw.example.invalid', 'production') + ).toEqual([{ url: KEYED_A, priority: 2 }]) + }) + + it('ignores case and surrounding whitespace in the requested host', () => { + const rpcs: IRpcEndpoint[] = [{ url: KEYED_A, priority: 1 }] + expect( + findEndpointsByHost(rpcs, ' GW.Example.INVALID ', 'production') + ).toHaveLength(1) + }) + + it('returns every endpoint sharing the host, so the caller can refuse', () => { + const rpcs: IRpcEndpoint[] = [ + { url: KEYED_A, priority: 2 }, + { url: KEYED_B, priority: 1 }, + ] + expect( + findEndpointsByHost(rpcs, 'gw.example.invalid', 'production') + ).toHaveLength(2) + }) + + it('keeps other environments out of the match', () => { + const rpcs: IRpcEndpoint[] = [ + { url: KEYED_A, priority: 2, environment: 'staging' }, + { url: KEYED_B, priority: 1 }, + ] + expect( + findEndpointsByHost(rpcs, 'gw.example.invalid', 'production') + ).toEqual([{ url: KEYED_B, priority: 1 }]) + expect(findEndpointsByHost(rpcs, 'gw.example.invalid', 'staging')).toEqual([ + { url: KEYED_A, priority: 2, environment: 'staging' }, + ]) + }) + + it('requires the port when the stored host carries one', () => { + const rpcs: IRpcEndpoint[] = [{ url: WITH_PORT, priority: 1 }] + expect( + findEndpointsByHost(rpcs, 'node.example.invalid', 'production') + ).toEqual([]) + expect( + findEndpointsByHost(rpcs, 'node.example.invalid:8545', 'production') + ).toHaveLength(1) + }) + + it('never matches an unparsable stored url by its sentinel', () => { + const rpcs: IRpcEndpoint[] = [{ url: 'not a url', priority: 1 }] + expect( + findEndpointsByHost(rpcs, hostOf('not a url'), 'production') + ).toEqual([]) + }) +}) + describe('normalizeRpcUrlForNetwork', () => { // The rewrite is keyed on the TronGrid host family, which a synthetic host cannot stand in for. const TRONGRID_ROOT = 'https://api.trongrid.io' // pre-commit-checker: not a secret diff --git a/script/mongoDb/rpcEndpoints.ts b/script/mongoDb/rpcEndpoints.ts index 2e84925950..686c961288 100644 --- a/script/mongoDb/rpcEndpoints.ts +++ b/script/mongoDb/rpcEndpoints.ts @@ -81,12 +81,14 @@ export function lowestPriorityFor(rpcs: IRpcEndpoint[]): number { return Math.min(...rpcs.map((rpc) => rpc.priority ?? 0)) - 1 } +const UNPARSABLE_HOST = '' + /** Host of an RPC URL, never its path or query — either can carry the API key. */ export function hostOf(url: string): string { try { return new URL(url).host } catch { - return '' + return UNPARSABLE_HOST } } @@ -279,3 +281,31 @@ export function findEndpointIndex( rpc.url === url && (rpc.environment ?? 'production') === environment ) } + +/** + * Stored endpoints in `environment` whose host is `host`, compared case-insensitively. + * + * Matching on host lets an operator name an endpoint without pasting a URL that carries its API + * key. A host can be stored more than once on a chain (several keyed URLs on one provider), so + * callers must treat more than one match as ambiguous rather than pick one. + * + * @param rpcs - The chain's stored endpoints. + * @param host - Host as {@link hostOf} prints it, port included when non-default. + * @param environment - Environment to match; endpoints without one count as production. + * @returns The matching endpoints, in stored order. + */ +export function findEndpointsByHost( + rpcs: IRpcEndpoint[], + host: string, + environment: string +): IRpcEndpoint[] { + const wanted = host.trim().toLowerCase() + return rpcs.filter((rpc) => { + const stored = hostOf(rpc.url) + return ( + stored !== UNPARSABLE_HOST && + stored.toLowerCase() === wanted && + (rpc.environment ?? 'production') === environment + ) + }) +} From 9d96ad15dc841c64cbe2706ea65970d1080b9126 Mon Sep 17 00:00:00 2001 From: Goran Vladika Date: Thu, 15 Oct 2026 14:48:41 +0200 Subject: [PATCH 2/2] fix(security): wrap $pull fields in mongoEq [aikido:4283058100] Co-Authored-By: Claude Opus 5.5 (1M context) --- script/mongoDb/remove-network-rpc.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/mongoDb/remove-network-rpc.ts b/script/mongoDb/remove-network-rpc.ts index 759f423e2a..31f0024dd8 100644 --- a/script/mongoDb/remove-network-rpc.ts +++ b/script/mongoDb/remove-network-rpc.ts @@ -112,13 +112,13 @@ const main = defineCommand({ const environmentMatch = environment === 'production' ? { $in: ['production', null] } - : environment + : mongoEq(environment) const result = await collection.updateOne( { chainName: mongoEq(network) }, { $set: { lastUpdated: new Date() }, $pull: { - rpcs: { url: target.url, environment: environmentMatch }, + rpcs: { url: mongoEq(target.url), environment: environmentMatch }, } as never, } )