Skip to content

Commit 379d5d7

Browse files
authored
Merge pull request #639 from balancer:add-env-check-script
Add env check script.
2 parents bfc301b + 944be3e commit 379d5d7

2 files changed

Lines changed: 341 additions & 1 deletion

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"write-erc4626-review": "ts-node scripts/write-erc4626-review.ts",
1414
"generate-agent": "ts-node scripts/create-agent.ts",
1515
"format": "npx prettier --write 'scripts/**/*.ts' 'src/**/*.ts'",
16-
"process-issue": "ts-node scripts/process-issue.ts \"$(cat test/utils/issueString.json)\""
16+
"process-issue": "ts-node scripts/process-issue.ts \"$(cat test/utils/issueString.json)\"",
17+
"check-env": "npx ts-node scripts/check-env.ts"
1718
},
1819
"author": "",
1920
"license": "MIT",

scripts/check-env.ts

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
import * as dotenv from 'dotenv'
2+
import { createPublicClient, http } from 'viem'
3+
import type { Chain } from 'viem'
4+
5+
dotenv.config()
6+
7+
const RPC_CHECK_TIMEOUT_MS = 10_000
8+
9+
// Network keys from process-issue.ts networks map; env var is {KEY.toUpperCase()}_RPC_URL
10+
const RPC_ENV_KEYS = [
11+
'base',
12+
'mainnet',
13+
'arbitrum',
14+
'avalanche',
15+
'gnosis',
16+
'fraxtal',
17+
'optimism',
18+
'sonic',
19+
'sepolia',
20+
'polygon',
21+
'polygonZkEvm',
22+
'mode',
23+
'hyperEvm',
24+
'plasma',
25+
'xlayer',
26+
'monad',
27+
] as const
28+
29+
function envName(key: string): string {
30+
return `${key.toUpperCase()}_RPC_URL`
31+
}
32+
33+
function isValidHttpUrl(s: string): boolean {
34+
try {
35+
const u = new URL(s)
36+
return u.protocol === 'http:' || u.protocol === 'https:'
37+
} catch {
38+
return false
39+
}
40+
}
41+
42+
function minimalChain(rpcUrl: string): Chain {
43+
return {
44+
id: 1,
45+
name: 'Unknown',
46+
nativeCurrency: { decimals: 18, name: 'Ether', symbol: 'ETH' },
47+
rpcUrls: { default: { http: [rpcUrl] } },
48+
}
49+
}
50+
51+
async function checkRpcUrl(rpcUrl: string): Promise<{ ok: true } | { ok: false; reason: string }> {
52+
const controller = new AbortController()
53+
const timeoutId = setTimeout(() => controller.abort(), RPC_CHECK_TIMEOUT_MS)
54+
55+
const publicClient = createPublicClient({
56+
chain: minimalChain(rpcUrl),
57+
transport: http(rpcUrl, {
58+
fetchOptions: { signal: controller.signal },
59+
}),
60+
})
61+
62+
try {
63+
await publicClient.createAccessList({
64+
to: '0x0000000000000000000000000000000000000000',
65+
data: '0x',
66+
})
67+
clearTimeout(timeoutId)
68+
return { ok: true }
69+
} catch (err) {
70+
clearTimeout(timeoutId)
71+
const reason = err instanceof Error ? err.message : String(err)
72+
if (controller.signal.aborted) return { ok: false, reason: 'timeout' }
73+
return { ok: false, reason }
74+
}
75+
}
76+
77+
async function checkEtherscanApiKey(apiKey: string): Promise<{ ok: true } | { ok: false; reason: string }> {
78+
const controller = new AbortController()
79+
const timeoutId = setTimeout(() => controller.abort(), RPC_CHECK_TIMEOUT_MS)
80+
81+
// WETH on mainnet - always verified contract
82+
const address = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
83+
const url = `https://api.etherscan.io/v2/api?chainid=1&module=contract&action=getsourcecode&address=${address}&apikey=${apiKey}`
84+
85+
try {
86+
const response = await fetch(url, { signal: controller.signal })
87+
clearTimeout(timeoutId)
88+
89+
if (!response.ok) {
90+
return { ok: false, reason: `HTTP ${response.status}: ${response.statusText}` }
91+
}
92+
93+
const data = await response.json()
94+
95+
// Check for API errors in the response
96+
if (data.message) {
97+
const message = data.message.toLowerCase()
98+
if (message.includes('invalid api key') || message.includes('invalid api')) {
99+
return { ok: false, reason: 'invalid API key' }
100+
}
101+
if (message.includes('rate limit') || message.includes('max rate limit')) {
102+
return { ok: false, reason: 'rate limit' }
103+
}
104+
}
105+
106+
// Success criteria: status "1" and valid result
107+
if (data.status === '1' && Array.isArray(data.result) && data.result.length > 0) {
108+
// Check that contract is verified (not "Contract source code not verified")
109+
if (data.result[0].ABI && data.result[0].ABI !== 'Contract source code not verified') {
110+
return { ok: true }
111+
}
112+
}
113+
114+
// If we get here, something unexpected happened
115+
return { ok: false, reason: data.message || 'unexpected response format' }
116+
} catch (err) {
117+
clearTimeout(timeoutId)
118+
if (controller.signal.aborted) {
119+
return { ok: false, reason: 'timeout' }
120+
}
121+
const reason = err instanceof Error ? err.message : String(err)
122+
return { ok: false, reason }
123+
}
124+
}
125+
126+
async function checkTenderlyCredentials(
127+
accountSlug: string,
128+
projectSlug: string,
129+
apiKey: string,
130+
): Promise<{ ok: true } | { ok: false; reason: string }> {
131+
const controller = new AbortController()
132+
const timeoutId = setTimeout(() => controller.abort(), RPC_CHECK_TIMEOUT_MS)
133+
134+
const url = `https://api.tenderly.co/api/v1/account/${accountSlug}/project/${projectSlug}/simulations?page_size=1`
135+
136+
try {
137+
const response = await fetch(url, {
138+
signal: controller.signal,
139+
headers: {
140+
Accept: 'application/json',
141+
'X-Access-Key': apiKey,
142+
},
143+
})
144+
clearTimeout(timeoutId)
145+
146+
if (!response.ok) {
147+
if (response.status === 401 || response.status === 403) {
148+
return { ok: false, reason: 'invalid API key or insufficient permissions' }
149+
}
150+
if (response.status === 404) {
151+
return { ok: false, reason: 'project not found' }
152+
}
153+
if (response.status === 400) {
154+
return { ok: false, reason: 'invalid account or project slug' }
155+
}
156+
return { ok: false, reason: `HTTP ${response.status}: ${response.statusText}` }
157+
}
158+
159+
// Verify response is valid JSON
160+
await response.json()
161+
162+
return { ok: true }
163+
} catch (err) {
164+
clearTimeout(timeoutId)
165+
if (controller.signal.aborted) {
166+
return { ok: false, reason: 'timeout' }
167+
}
168+
const reason = err instanceof Error ? err.message : String(err)
169+
return { ok: false, reason }
170+
}
171+
}
172+
173+
async function checkHypernativeCredentials(
174+
clientId: string,
175+
clientSecret: string,
176+
): Promise<{ ok: true } | { ok: false; reason: string }> {
177+
const controller = new AbortController()
178+
const timeoutId = setTimeout(() => controller.abort(), RPC_CHECK_TIMEOUT_MS)
179+
180+
const url = 'https://api.hypernative.xyz/custom-agents'
181+
182+
try {
183+
const response = await fetch(url, {
184+
signal: controller.signal,
185+
headers: {
186+
accept: 'application/json',
187+
'x-client-id': clientId,
188+
'x-client-secret': clientSecret,
189+
},
190+
})
191+
clearTimeout(timeoutId)
192+
193+
if (!response.ok) {
194+
if (response.status === 401 || response.status === 403) {
195+
return { ok: false, reason: 'invalid credentials' }
196+
}
197+
return { ok: false, reason: `HTTP ${response.status}: ${response.statusText}` }
198+
}
199+
200+
// Verify response is valid JSON
201+
await response.json()
202+
203+
return { ok: true }
204+
} catch (err) {
205+
clearTimeout(timeoutId)
206+
if (controller.signal.aborted) {
207+
return { ok: false, reason: 'timeout' }
208+
}
209+
const reason = err instanceof Error ? err.message : String(err)
210+
return { ok: false, reason }
211+
}
212+
}
213+
214+
async function main(): Promise<void> {
215+
const results: { name: string; status: 'pass' | 'fail'; reason?: string }[] = []
216+
217+
for (const key of RPC_ENV_KEYS) {
218+
const name = envName(key)
219+
const value = process.env[name]?.trim()
220+
221+
if (value === undefined || value === '') {
222+
results.push({ name, status: 'fail', reason: 'missing' })
223+
console.log(`${name}: fail — missing`)
224+
continue
225+
}
226+
227+
if (!isValidHttpUrl(value)) {
228+
results.push({ name, status: 'fail', reason: 'invalid URL' })
229+
console.log(`${name}: fail — invalid URL`)
230+
continue
231+
}
232+
233+
const check = await checkRpcUrl(value)
234+
if (check.ok) {
235+
results.push({ name, status: 'pass' })
236+
console.log(`${name}: success`)
237+
} else {
238+
results.push({ name, status: 'fail', reason: check.reason })
239+
console.log(`${name}: fail — ${check.reason}`)
240+
}
241+
}
242+
243+
// Check Etherscan API key
244+
const etherscanApiKey = process.env.ETHERSCAN_API_KEY?.trim()
245+
if (etherscanApiKey === undefined || etherscanApiKey === '') {
246+
results.push({ name: 'ETHERSCAN_API_KEY', status: 'fail', reason: 'missing' })
247+
console.log(`ETHERSCAN_API_KEY: fail — missing`)
248+
} else {
249+
const check = await checkEtherscanApiKey(etherscanApiKey)
250+
if (check.ok) {
251+
results.push({ name: 'ETHERSCAN_API_KEY', status: 'pass' })
252+
console.log(`ETHERSCAN_API_KEY: success`)
253+
} else {
254+
results.push({ name: 'ETHERSCAN_API_KEY', status: 'fail', reason: check.reason })
255+
console.log(`ETHERSCAN_API_KEY: fail — ${check.reason}`)
256+
}
257+
}
258+
259+
// Check Tenderly credentials
260+
const tenderlyAccountSlug = process.env.TENDERLY_ACCOUNT_SLUG?.trim()
261+
const tenderlyProjectSlug = process.env.TENDERLY_PROJECT_SLUG?.trim()
262+
const tenderlyApiKey = process.env.TENDERLY_API_ACCESS_KEY?.trim()
263+
264+
if (tenderlyAccountSlug === undefined || tenderlyAccountSlug === '') {
265+
results.push({ name: 'TENDERLY_ACCOUNT_SLUG', status: 'fail', reason: 'missing' })
266+
console.log(`TENDERLY_ACCOUNT_SLUG: fail — missing`)
267+
}
268+
if (tenderlyProjectSlug === undefined || tenderlyProjectSlug === '') {
269+
results.push({ name: 'TENDERLY_PROJECT_SLUG', status: 'fail', reason: 'missing' })
270+
console.log(`TENDERLY_PROJECT_SLUG: fail — missing`)
271+
}
272+
if (tenderlyApiKey === undefined || tenderlyApiKey === '') {
273+
results.push({ name: 'TENDERLY_API_ACCESS_KEY', status: 'fail', reason: 'missing' })
274+
console.log(`TENDERLY_API_ACCESS_KEY: fail — missing`)
275+
}
276+
277+
// If all three are present, validate with API call
278+
if (
279+
tenderlyAccountSlug !== undefined &&
280+
tenderlyAccountSlug !== '' &&
281+
tenderlyProjectSlug !== undefined &&
282+
tenderlyProjectSlug !== '' &&
283+
tenderlyApiKey !== undefined &&
284+
tenderlyApiKey !== ''
285+
) {
286+
const check = await checkTenderlyCredentials(tenderlyAccountSlug, tenderlyProjectSlug, tenderlyApiKey)
287+
if (check.ok) {
288+
results.push({ name: 'TENDERLY', status: 'pass' })
289+
console.log(`TENDERLY: success`)
290+
} else {
291+
results.push({ name: 'TENDERLY', status: 'fail', reason: check.reason })
292+
console.log(`TENDERLY: fail — ${check.reason}`)
293+
}
294+
}
295+
296+
// Check Hypernative credentials
297+
const hypernativeClientId = process.env.HYPERNATIVE_CLIENT_ID?.trim()
298+
const hypernativeClientSecret = process.env.HYPERNATIVE_CLIENT_SECRET?.trim()
299+
300+
if (hypernativeClientId === undefined || hypernativeClientId === '') {
301+
results.push({ name: 'HYPERNATIVE_CLIENT_ID', status: 'fail', reason: 'missing' })
302+
console.log(`HYPERNATIVE_CLIENT_ID: fail — missing`)
303+
}
304+
if (hypernativeClientSecret === undefined || hypernativeClientSecret === '') {
305+
results.push({ name: 'HYPERNATIVE_CLIENT_SECRET', status: 'fail', reason: 'missing' })
306+
console.log(`HYPERNATIVE_CLIENT_SECRET: fail — missing`)
307+
}
308+
309+
// If both are present, validate with API call
310+
if (
311+
hypernativeClientId !== undefined &&
312+
hypernativeClientId !== '' &&
313+
hypernativeClientSecret !== undefined &&
314+
hypernativeClientSecret !== ''
315+
) {
316+
const check = await checkHypernativeCredentials(hypernativeClientId, hypernativeClientSecret)
317+
if (check.ok) {
318+
results.push({ name: 'HYPERNATIVE', status: 'pass' })
319+
console.log(`HYPERNATIVE: success`)
320+
} else {
321+
results.push({ name: 'HYPERNATIVE', status: 'fail', reason: check.reason })
322+
console.log(`HYPERNATIVE: fail — ${check.reason}`)
323+
}
324+
}
325+
326+
const passed = results.filter((r) => r.status === 'pass').length
327+
const failed = results.filter((r) => r.status === 'fail').length
328+
console.log('')
329+
console.log(`Summary: ${passed} passed, ${failed} failed`)
330+
if (failed > 0) {
331+
console.log('Failed:')
332+
results.filter((r) => r.status === 'fail').forEach((r) => console.log(` ${r.name}`))
333+
}
334+
}
335+
336+
main().catch((err) => {
337+
console.error(err)
338+
process.exit(1)
339+
})

0 commit comments

Comments
 (0)