Skip to content
Draft
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
3 changes: 3 additions & 0 deletions scripts/governance-proposal-builder/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,8 @@ RPC_GATEWAY_TESTNET=https://rpc-zama-testnet-0.t.conduit.xyz
# Ethereum L1 RPC (used by aragon-proposal-inspector).
RPC_ETHEREUM=https://eth.llamarpc.com

# Sepolia RPC

Copy link
Copy Markdown

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_SEPOLIA entry uses double quotes around the URL:

RPC_SEPOLIA="https://ethereum-sepolia-rpc.publicnode.com/"

But existing entries don't use quotes:

RPC_ETHEREUM=https://eth.llamarpc.com
RPC_GATEWAY_MAINNET=https://rpc.mainnet.zama.org

For consistency, consider removing the quotes to match the existing style.

Confidence: 90/100

RPC_SEPOLIA="https://ethereum-sepolia-rpc.publicnode.com/"

# Etherscan v2 API key (optional, used by aragon-proposal-inspector).
ETHERSCAN_API_KEY=
51 changes: 50 additions & 1 deletion scripts/governance-proposal-builder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Currently availabe scripts are:
[*] fill-options-gateway-proposal
[*] decode-options-gateway-proposal
[*] aragon-proposal-inspector
[*] verify-bytecode
```

### fillOptionsGatewayProposal
Expand Down Expand Up @@ -205,4 +206,52 @@ For the human-readable mode:
- `value`: in wei,
- `data`: the full raw calldata,
- (optional) `function`: the decoded signature and arguments (when Etherscan is enabled and
the calldata can be decoded).
the calldata can be decoded).

### verifyBytecode

Checks that the runtime bytecode deployed at a given address matches a locally compiled Hardhat artifact. Useful when reviewing a governance upgrade proposal: confirm the implementation it points to is the code you compiled from source. Unlike the other scripts, it takes positional arguments rather than reading from `.env`.

#### Usage

```bash
node verifyBytecode.js <address> <artifact-path> [--rpc <url>]
# or via npm (the -- forwards args to the script):
npm run verify-bytecode -- <address> <artifact-path> [--rpc <url>]
```

- `<address>` — the deployed contract address (for a proxied contract, pass the **implementation** address, not the proxy).
- `<artifact-path>` — path to the compiled Hardhat artifact JSON (the file containing `deployedBytecode`).
- `--rpc <url>` — optional RPC endpoint. Defaults to `https://ethereum-rpc.publicnode.com`.

#### What the script does

1. Reads `deployedBytecode` from the artifact and the on-chain runtime code via `eth_getCode`.
2. Resolves the artifact's sibling `.dbg.json` → build-info to load the `immutableReferences` map.
3. Masks those immutable byte-ranges on both sides before comparing, since immutables (e.g. OpenZeppelin `UUPSUpgradeable`'s `address(this)` self-reference) are written at deployment time and legitimately differ from the zeroed artifact.
4. Reports whether the bytecode matches, and on mismatch prints the first differing byte offset.

Exit codes: `0` match, `1` no match, `2` usage/error — suitable for CI.

#### Example

```bash
node verifyBytecode.js 0x5226fe30fa7bf20c1cd33f125f77d0c42d3c23b5 \
../../contracts/confidential-wrapper/artifacts/contracts/upgrades/ConfidentialWrapperV3.sol/ConfidentialWrapperV3.json
```

Example output (a UUPS implementation with 3 self-address immutable slots):

```
Verifying ConfidentialWrapperV3.json against 0x5226fe30fa7bf20c1cd33f125f77d0c42d3c23b5...
immutable slots: 3

✅ MATCH — deployed runtime bytecode matches the artifact (the 3 immutable slot(s) hold deployment-time values, as expected).
```

A mismatch (e.g. checking against the proxy address instead of the implementation) looks like:

```
❌ NO MATCH — first differing byte at offset 6 (onchain=0a local=04).
Likely a different compiler version/settings, different source, or unmapped immutables.
```
3 changes: 2 additions & 1 deletion scripts/governance-proposal-builder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"fill-options-gateway-proposal:mainnet": "node fillOptionsGatewayProposal.js --network mainnet",
"fill-options-gateway-proposal:testnet": "node fillOptionsGatewayProposal.js --network testnet",
"decode-options-gateway-proposal": "node decodeOptionsGatewayProposal.js",
"aragon-proposal-inspector": "node aragonProposalInspector.js"
"aragon-proposal-inspector": "node aragonProposalInspector.js",
"verify-bytecode": "node verifyBytecode.js"
},
"dependencies": {
"@layerzerolabs/lz-v2-utilities": "^3.0.75",
Expand Down
156 changes: 156 additions & 0 deletions scripts/governance-proposal-builder/verifyBytecode.js
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.`)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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:

  1. .dbg.json not found → expected for some artifacts, warn and continue
  2. Build-info exists but has unexpected structure → suggests a real problem the user should investigate

Currently both cases print a generic warning. Consider at minimum checking whether the .dbg.json exists before entering the try block, so that a missing file is a quiet/expected path, while structural errors in the build-info are more prominently surfaced.

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,
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[AUTOMATED] Bug — --rpc as last argument silently uses default RPC

If a user runs node verifyBytecode.js 0xABC artifact.json --rpc (forgetting the URL), process.argv[rpcIdx + 1] is undefined. This silently falls back to DEFAULT_RPC_URL in verifyBytecode() via options.rpcUrl || DEFAULT_RPC_URL, so the user believes they specified a custom RPC but verification runs against the public default instead.

For a governance security tool, verifying against the wrong network/RPC is a meaningful failure mode.

Suggested fix: validate that --rpc has a following argument:

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()
}
Loading