diff --git a/scripts/governance-proposal-builder/.env.example b/scripts/governance-proposal-builder/.env.example
index 306b88b1..9b78065d 100644
--- a/scripts/governance-proposal-builder/.env.example
+++ b/scripts/governance-proposal-builder/.env.example
@@ -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
+RPC_SEPOLIA="https://ethereum-sepolia-rpc.publicnode.com/"
+
# Etherscan v2 API key (optional, used by aragon-proposal-inspector).
ETHERSCAN_API_KEY=
\ No newline at end of file
diff --git a/scripts/governance-proposal-builder/README.md b/scripts/governance-proposal-builder/README.md
index 7571a3d1..cd035ea7 100644
--- a/scripts/governance-proposal-builder/README.md
+++ b/scripts/governance-proposal-builder/README.md
@@ -27,6 +27,7 @@ Currently availabe scripts are:
[*] fill-options-gateway-proposal
[*] decode-options-gateway-proposal
[*] aragon-proposal-inspector
+[*] verify-bytecode
```
### fillOptionsGatewayProposal
@@ -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).
\ No newline at end of file
+ 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
[--rpc ]
+# or via npm (the -- forwards args to the script):
+npm run verify-bytecode -- [--rpc ]
+```
+
+- `` — the deployed contract address (for a proxied contract, pass the **implementation** address, not the proxy).
+- `` — path to the compiled Hardhat artifact JSON (the file containing `deployedBytecode`).
+- `--rpc ` — 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.
+```
\ No newline at end of file
diff --git a/scripts/governance-proposal-builder/package.json b/scripts/governance-proposal-builder/package.json
index 2369a315..667bcdf1 100644
--- a/scripts/governance-proposal-builder/package.json
+++ b/scripts/governance-proposal-builder/package.json
@@ -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",
diff --git a/scripts/governance-proposal-builder/verifyBytecode.js b/scripts/governance-proposal-builder/verifyBytecode.js
new file mode 100644
index 00000000..9f85063b
--- /dev/null
+++ b/scripts/governance-proposal-builder/verifyBytecode.js
@@ -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.`)
+ }
+
+ 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,
+ }
+}
+
+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} [--rpc ]`)
+ 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()
+}