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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
142 changes: 142 additions & 0 deletions script/mongoDb/remove-network-rpc.ts
Original file line number Diff line number Diff line change
@@ -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) })
Comment thread
gvladika marked this conversation as resolved.
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] }
: mongoEq(environment)
const result = await collection.updateOne(
{ chainName: mongoEq(network) },
Comment thread
gvladika marked this conversation as resolved.
{
$set: { lastUpdated: new Date() },
$pull: {
rpcs: { url: mongoEq(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)
64 changes: 64 additions & 0 deletions script/mongoDb/rpcEndpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { describe, expect, it } from 'bun:test'
import {
buildEnvLines,
findEndpointIndex,
findEndpointsByHost,
findUncredentialedPrimaries,
hasApiCredentials,
isPublicPrimaryActionable,
Expand Down Expand Up @@ -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
Expand Down
32 changes: 31 additions & 1 deletion script/mongoDb/rpcEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,14 @@ export function lowestPriorityFor(rpcs: IRpcEndpoint[]): number {
return Math.min(...rpcs.map((rpc) => rpc.priority ?? 0)) - 1
}

const UNPARSABLE_HOST = '<unparsable url>'

/** 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 '<unparsable url>'
return UNPARSABLE_HOST
}
}

Expand Down Expand Up @@ -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
)
})
}
Loading