-
Notifications
You must be signed in to change notification settings - Fork 3
chore: add verifyBytecode script for governance proposal check #179
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| #!/usr/bin/env node | ||
|
|
||
| // Verifies that the runtime bytecode deployed at a given address matches a | ||
| // locally compiled Hardhat artifact. Immutables (e.g. OZ UUPSUpgradeable's | ||
| // `address(this)` self-reference) are masked using the immutableReferences map | ||
| // from the artifact's build-info, so a legitimate deployment reports a match. | ||
|
|
||
| const fs = require('fs') | ||
| const path = require('path') | ||
| const { isAddress, JsonRpcProvider } = require('ethers') | ||
|
|
||
| const SCRIPT_NAME = 'verifyBytecode.js' | ||
| const DEFAULT_RPC_URL = 'https://ethereum-rpc.publicnode.com' | ||
|
|
||
| function strip0x(hex) { | ||
| return hex.toLowerCase().replace(/^0x/, '') | ||
| } | ||
|
|
||
| // Loads { deployedBytecode, immutableReferences } from a Hardhat artifact path. | ||
| // immutableReferences is read from the build-info pointed to by the sibling | ||
| // .dbg.json; it is {} when unavailable (older artifacts / no immutables). | ||
| function loadArtifact(artifactPath) { | ||
| const artifact = JSON.parse(fs.readFileSync(artifactPath, 'utf8')) | ||
| if (!artifact.deployedBytecode) { | ||
| throw new Error(`No deployedBytecode field in artifact ${artifactPath}`) | ||
| } | ||
|
|
||
| let immutableReferences = {} | ||
| const dbgPath = artifactPath.replace(/\.json$/, '.dbg.json') | ||
| try { | ||
| const dbg = JSON.parse(fs.readFileSync(dbgPath, 'utf8')) | ||
| const buildInfoPath = path.resolve(path.dirname(dbgPath), dbg.buildInfo) | ||
| const buildInfo = JSON.parse(fs.readFileSync(buildInfoPath, 'utf8')) | ||
| const contract = buildInfo.output.contracts[artifact.sourceName][artifact.contractName] | ||
| immutableReferences = contract.evm.deployedBytecode.immutableReferences || {} | ||
| } catch (err) { | ||
| console.warn(`Warning: could not read immutableReferences (${err.message}); comparing without masking.`) | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [AUTOMATED] Nit: Broad try/catch could distinguish "missing .dbg.json" from "corrupted build-info" The catch block on line 38 handles every failure the same way — a warning + fallback to no masking. This is safe (it can only produce false negatives, never false positives), but for a security-critical tool it would be helpful to distinguish:
Currently both cases print a generic warning. Consider at minimum checking whether the Confidence: 82/100 |
||
|
|
||
| return { deployedBytecode: strip0x(artifact.deployedBytecode), immutableReferences } | ||
| } | ||
|
|
||
| // Zeroes out every immutable byte-range in a hex string (no 0x prefix). | ||
| // Ranges come straight from solc's immutableReferences (byte offsets/lengths). | ||
| function maskImmutables(hex, immutableReferences) { | ||
| const bytes = Buffer.from(hex, 'hex') | ||
| for (const refs of Object.values(immutableReferences)) { | ||
| for (const { start, length } of refs) { | ||
| // Skip ranges outside this buffer (e.g. when the on-chain code is shorter | ||
| // than the artifact, as for a proxy) — those bytes can't match anyway. | ||
| if (start >= bytes.length) continue | ||
| bytes.fill(0, start, Math.min(start + length, bytes.length)) | ||
| } | ||
| } | ||
| return bytes.toString('hex') | ||
| } | ||
|
|
||
| async function verifyBytecode(address, artifactPath, options = {}) { | ||
| const rpcUrl = options.rpcUrl || DEFAULT_RPC_URL | ||
| const provider = new JsonRpcProvider(rpcUrl) | ||
|
|
||
| const { deployedBytecode: local, immutableReferences } = loadArtifact(artifactPath) | ||
|
|
||
| const onchain = strip0x(await provider.getCode(address)) | ||
| if (onchain === '') { | ||
| throw new Error(`No contract code found at ${address} on ${rpcUrl}`) | ||
| } | ||
|
|
||
| const exact = onchain === local | ||
| const maskedOnchain = maskImmutables(onchain, immutableReferences) | ||
| const maskedLocal = maskImmutables(local, immutableReferences) | ||
| const matchesMasked = maskedOnchain === maskedLocal | ||
|
|
||
| const immutableCount = Object.values(immutableReferences).reduce((n, r) => n + r.length, 0) | ||
|
|
||
| // Locate the first residual mismatch (after masking) for diagnostics. | ||
| let firstDiff = null | ||
| if (!matchesMasked) { | ||
| const len = Math.max(maskedOnchain.length, maskedLocal.length) | ||
| for (let i = 0; i < len; i += 2) { | ||
| if (maskedOnchain.slice(i, i + 2) !== maskedLocal.slice(i, i + 2)) { | ||
| firstDiff = { | ||
| byte: i / 2, | ||
| onchain: maskedOnchain.slice(i, i + 2) || '(end)', | ||
| local: maskedLocal.slice(i, i + 2) || '(end)', | ||
| } | ||
| break | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| match: exact || matchesMasked, | ||
| exact, | ||
| matchesMasked, | ||
| lengthsEqual: onchain.length === local.length, | ||
| immutableSlots: immutableCount, | ||
| firstDiff, | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [AUTOMATED] Bug — If a user runs For a governance security tool, verifying against the wrong network/RPC is a meaningful failure mode. Suggested fix: validate that if (rpcIdx !== -1 && (rpcIdx + 1 >= process.argv.length || process.argv[rpcIdx + 1].startsWith('--'))) {
console.error('Error: --rpc requires a URL argument')
process.exit(2)
}Confidence: 92/100 |
||
|
|
||
| async function main() { | ||
| const rpcIdx = process.argv.indexOf('--rpc') | ||
| const rpcUrl = rpcIdx !== -1 ? process.argv[rpcIdx + 1] : undefined | ||
| const positional = process.argv.slice(2).filter((a, i, arr) => { | ||
| return a !== '--rpc' && arr[i - 1] !== '--rpc' | ||
| }) | ||
| const [address, artifactPath] = positional | ||
|
|
||
| if (!address || !artifactPath) { | ||
| console.error(`Usage: node ${SCRIPT_NAME} <address> <artifact-path> [--rpc <url>]`) | ||
| console.error(`Example: node ${SCRIPT_NAME} 0x5226... \\`) | ||
| console.error(' ../../contracts/confidential-wrapper/artifacts/contracts/upgrades/ConfidentialWrapperV3.sol/ConfidentialWrapperV3.json') | ||
| process.exit(2) | ||
| } | ||
| if (!isAddress(address)) { | ||
| console.error(`Invalid Ethereum address: ${address}`) | ||
| process.exit(2) | ||
| } | ||
| if (!fs.existsSync(artifactPath)) { | ||
| console.error(`Artifact not found: ${artifactPath}`) | ||
| process.exit(2) | ||
| } | ||
|
|
||
| try { | ||
| console.log(`Verifying ${path.basename(artifactPath)} against ${address}...`) | ||
| const r = await verifyBytecode(address, artifactPath, { rpcUrl }) | ||
|
|
||
| console.log(` immutable slots: ${r.immutableSlots}`) | ||
|
|
||
| if (r.match) { | ||
| console.log( | ||
| r.immutableSlots === 0 | ||
| ? '\n✅ MATCH — deployed runtime bytecode is byte-for-byte identical to the artifact.' | ||
| : `\n✅ MATCH — deployed runtime bytecode matches the artifact (the ${r.immutableSlots} immutable slot(s) hold deployment-time values, as expected).` | ||
| ) | ||
| process.exit(0) | ||
| } else { | ||
| console.log( | ||
| `\n❌ NO MATCH — first differing byte at offset ${r.firstDiff.byte} ` + | ||
| `(onchain=${r.firstDiff.onchain} local=${r.firstDiff.local}).` | ||
| ) | ||
| console.log(' Likely a different compiler version/settings, different source, or unmapped immutables.') | ||
| process.exit(1) | ||
| } | ||
| } catch (error) { | ||
| console.error(`Error: ${error.message}`) | ||
| process.exit(2) | ||
| } | ||
| } | ||
|
|
||
| module.exports = { verifyBytecode, loadArtifact, maskImmutables, DEFAULT_RPC_URL } | ||
|
|
||
| if (require.main === module) { | ||
| main() | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[AUTOMATED] Nit: Inconsistent value quoting
The new
RPC_SEPOLIAentry uses double quotes around the URL:But existing entries don't use quotes:
For consistency, consider removing the quotes to match the existing style.
Confidence: 90/100