Skip to content

Commit a2aad39

Browse files
authored
improve: update verify bytecode script to work with foundry deployments (#82)
Signed-off-by: Ihor Farion <ihor@umaproject.org>
1 parent b8ec0d7 commit a2aad39

File tree

1 file changed

+297
-72
lines changed

1 file changed

+297
-72
lines changed

tasks/verifyBytecode.ts

Lines changed: 297 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,99 +2,324 @@ import { task } from "hardhat/config";
22
import type { HardhatRuntimeEnvironment } from "hardhat/types";
33
import "hardhat-deploy";
44
import "@nomiclabs/hardhat-ethers";
5+
import fs from "fs";
6+
import path from "path";
7+
import { execSync } from "child_process";
8+
9+
type VerifyBytecodeArgs = {
10+
contract?: string;
11+
txHash?: string;
12+
libraries?: string;
13+
broadcast?: string;
14+
};
15+
16+
/**
17+
* Best-effort parser for `foundry.toml` that extracts the default profile's `out` directory.
18+
* Falls back to `<repo>/out` if anything goes wrong.
19+
*/
20+
function getFoundryOutDir(): string {
21+
const root = process.cwd();
22+
const configPath = path.join(root, "foundry.toml");
23+
24+
if (!fs.existsSync(configPath)) {
25+
return path.join(root, "out");
26+
}
27+
28+
const contents = fs.readFileSync(configPath, "utf8");
29+
const lines = contents.split(/\r?\n/);
30+
31+
let inDefaultProfile = false;
32+
for (const rawLine of lines) {
33+
const line = rawLine.trim();
34+
35+
if (line.startsWith("[") && line.endsWith("]")) {
36+
// Enter or exit `[profile.default]` section.
37+
inDefaultProfile = line === "[profile.default]";
38+
continue;
39+
}
40+
41+
if (!inDefaultProfile) continue;
42+
43+
const match = line.match(/^out\s*=\s*"(.*)"\s*$/);
44+
if (match) {
45+
const configuredOut = match[1].trim();
46+
if (configuredOut.length > 0) {
47+
return path.isAbsolute(configuredOut) ? configuredOut : path.join(root, configuredOut);
48+
}
49+
}
50+
}
51+
52+
// Default Foundry output directory.
53+
return path.join(root, "out");
54+
}
55+
56+
function normalizeFoundryBytecode(raw: any, key: "bytecode" | "deployedBytecode"): [string, any] {
57+
const value = raw[key];
58+
if (!value) {
59+
return ["0x", {}];
60+
}
61+
62+
if (typeof value === "string") {
63+
const linksKey = key === "bytecode" ? "linkReferences" : "deployedLinkReferences";
64+
const links = raw[linksKey] ?? {};
65+
return [value, links];
66+
}
67+
68+
if (typeof value === "object") {
69+
return [value.object ?? "0x", value.linkReferences ?? {}];
70+
}
71+
72+
return ["0x", {}];
73+
}
574

675
/**
7-
* Verify that the deployment init code (creation bytecode + encoded constructor args)
8-
* matches the locally reconstructed init code from artifacts and recorded args.
9-
*
10-
* Compares keccak256(initCodeOnChain) vs keccak256(initCodeLocal).
11-
*
12-
* Sample usage:
13-
* yarn hardhat verify-bytecode --contract Arbitrum_Adapter --network mainnet
14-
* yarn hardhat verify-bytecode --contract Arbitrum_Adapter --tx-hash 0x... --network mainnet
15-
* yarn hardhat verify-bytecode --contract X --tx-hash 0x... --libraries "MyLib=0x...,OtherLib=0x..." --network mainnet
76+
* Load a Foundry artifact (`out/...json`) and adapt it into a Hardhat-style artifact
77+
* that can be consumed by `ethers.getContractFactoryFromArtifact`.
78+
*/
79+
function loadFoundryArtifact(contractName: string): any {
80+
const outDir = getFoundryOutDir();
81+
const candidates = [
82+
path.join(outDir, `${contractName}.sol`, `${contractName}.json`),
83+
path.join(outDir, `${contractName}.json`),
84+
];
85+
86+
const artifactPath = candidates.find((p) => fs.existsSync(p));
87+
if (!artifactPath) {
88+
throw new Error(
89+
`Could not find Foundry artifact for contract "${contractName}". Tried:\n` +
90+
candidates.map((p) => ` - ${p}`).join("\n")
91+
);
92+
}
93+
94+
const rawJson = fs.readFileSync(artifactPath, "utf8");
95+
const raw: any = JSON.parse(rawJson);
96+
97+
const abi = raw.abi ?? [];
98+
const [bytecode, linkReferences] = normalizeFoundryBytecode(raw, "bytecode");
99+
const [deployedBytecode, deployedLinkReferences] = normalizeFoundryBytecode(raw, "deployedBytecode");
100+
101+
return {
102+
_format: "hh-foundry-compat-0",
103+
contractName,
104+
sourceName: raw.sourceName ?? raw.source_name ?? path.basename(artifactPath),
105+
abi,
106+
bytecode,
107+
deployedBytecode,
108+
linkReferences,
109+
deployedLinkReferences,
110+
};
111+
}
112+
113+
function ensureForgeBuildArtifacts() {
114+
try {
115+
// This keeps Foundry's `out/` artifacts up to date when verifying Foundry deployments.
116+
console.log("Running `forge build` to refresh Foundry artifacts...");
117+
execSync("forge build", { stdio: "inherit" });
118+
} catch (error: any) {
119+
throw new Error(`forge build failed: ${error?.message ?? String(error)}`);
120+
}
121+
}
122+
123+
/**
124+
Verify that the deployment init code (creation bytecode + encoded constructor args)
125+
matches the locally reconstructed init code from artifacts and recorded args.
126+
127+
Compares keccak256(initCodeOnChain) vs keccak256(initCodeLocal).
128+
129+
Sample usage:
130+
yarn hardhat verify-bytecode --contract Arbitrum_Adapter --network mainnet
131+
yarn hardhat verify-bytecode --contract Arbitrum_Adapter --tx-hash 0x... --network mainnet
132+
yarn hardhat verify-bytecode --contract X --tx-hash 0x... --libraries "MyLib=0x...,OtherLib=0x..." --network mainnet
133+
134+
For Foundry deployments that used `forge script --broadcast`, you can instead
135+
point this task at the Foundry broadcast JSON:
136+
137+
yarn hardhat verify-bytecode \
138+
--contract DstOFTHandler \
139+
--broadcast broadcast/DeployDstHandler.s.sol/999/run-latest.json \
140+
--network hyperevm
16141
*/
17142
task("verify-bytecode", "Verify deploy transaction input against local artifacts")
18143
.addOptionalParam("contract", "Contract name; falls back to env CONTRACT")
19144
// @dev For proxies, we don't save transactionHash in deployments/. You have to provide it manually via --tx-hash 0x... by checking e.g. block explorer first
20145
.addOptionalParam("txHash", "Deployment transaction hash (defaults to deployments JSON)")
21146
.addOptionalParam("libraries", "Libraries to link. JSON string or 'Name=0x..,Other=0x..'")
22-
.setAction(
23-
async (args: { contract?: string; txHash?: string; libraries?: string }, hre: HardhatRuntimeEnvironment) => {
24-
const { deployments, ethers, artifacts, network } = hre;
147+
.addOptionalParam(
148+
"broadcast",
149+
"Path to Foundry broadcast JSON (e.g. broadcast/DeployFoo.s.sol/1/run-latest.json). " +
150+
"If set, constructor args and default txHash are taken from this file instead of hardhat-deploy deployments."
151+
)
152+
.setAction(async (args: VerifyBytecodeArgs, hre: HardhatRuntimeEnvironment) => {
153+
const { deployments, ethers, artifacts, network } = hre;
25154

26-
// make sure we're using latest local contract artifacts for verification
155+
const useFoundryArtifacts = Boolean(args.broadcast);
156+
157+
// For Hardhat deployments, make sure we're using latest local Hardhat artifacts.
158+
if (!useFoundryArtifacts) {
27159
await hre.run("compile");
160+
} else {
161+
// For Foundry deployments, refresh Foundry's `out/` artifacts instead.
162+
ensureForgeBuildArtifacts();
163+
}
28164

29-
const contractName = args.contract || process.env.CONTRACT;
30-
if (!contractName) throw new Error("Please provide --contract or set CONTRACT env var");
165+
const contractName = args.contract || process.env.CONTRACT;
166+
if (!contractName) throw new Error("Please provide --contract or set CONTRACT env var");
31167

32-
const deployment = await deployments.get(contractName);
33-
const deployedAddress: string = deployment.address;
34-
const constructorArgs: any[] = deployment.args || [];
35-
36-
const parseLibraries = (s?: string): Record<string, string> => {
37-
if (!s) return {};
38-
const out: Record<string, string> = {};
39-
const trimmed = s.trim();
40-
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
41-
const parsed = JSON.parse(trimmed);
42-
for (const [k, v] of Object.entries(parsed)) out[k] = String(v);
43-
return out;
44-
}
45-
for (const part of trimmed.split(/[\,\n]/)) {
46-
const [k, v] = part.split("=").map((x) => x.trim());
47-
if (k && v) out[k] = v;
48-
}
49-
return out;
168+
/**
169+
* Resolve constructor args, deployed address and default tx hash either from:
170+
* - hardhat-deploy deployments (default), or
171+
* - Foundry broadcast JSON (when --broadcast is provided).
172+
*/
173+
let deployedAddress: string | undefined;
174+
let constructorArgs: any[] = [];
175+
let defaultTxHash: string | undefined;
176+
177+
if (args.broadcast) {
178+
const resolvedPath = path.isAbsolute(args.broadcast) ? args.broadcast : path.join(process.cwd(), args.broadcast);
179+
180+
if (!fs.existsSync(resolvedPath)) {
181+
throw new Error(`Broadcast file not found at path ${resolvedPath}`);
182+
}
183+
184+
// Narrow JSON structure to only what we need.
185+
type BroadcastTx = {
186+
hash?: string;
187+
transactionType?: string;
188+
contractName?: string;
189+
contractAddress?: string;
190+
arguments?: any[];
191+
transaction?: {
192+
input?: string;
193+
};
50194
};
195+
type BroadcastJson = {
196+
transactions?: BroadcastTx[];
197+
};
198+
199+
const raw = fs.readFileSync(resolvedPath, "utf8");
200+
const parsed: BroadcastJson = JSON.parse(raw);
201+
const txs = parsed.transactions || [];
51202

52-
// Read local compilation artifact
53-
const artifact = await artifacts.readArtifact(contractName);
54-
console.log("Reading compilation artifact for", artifact.sourceName);
55-
56-
/**
57-
* TODO
58-
* the `libraries` bit is untested. Could be wrong. Could remove this part if we don't have contracts with dynamic libraries
59-
* artifact.linkReferences might help solve this better. Also, deployments.libraries. Implement only if required later.
60-
*/
61-
const libraries: Record<string, string> = parseLibraries(args.libraries);
62-
const factory = await ethers.getContractFactoryFromArtifact(
63-
artifact,
64-
Object.keys(libraries).length ? { libraries } : {}
203+
const createTxsForContract = txs.filter(
204+
(tx) => tx.transactionType === "CREATE" && tx.contractName === contractName
65205
);
66206

67-
// Note: `factory.getDeployTransaction` populates the transaction with whatever data we WOULD put in it if we were deploying it right now
68-
const populatedDeployTransaction = factory.getDeployTransaction(...constructorArgs);
69-
const expectedInit: string = ethers.utils.hexlify(populatedDeployTransaction.data!).toLowerCase();
70-
if (!expectedInit || expectedInit === "0x") {
71-
throw new Error("Failed to reconstruct deployment init code from local artifacts");
207+
if (!createTxsForContract.length) {
208+
throw new Error(`No CREATE transaction for contract "${contractName}" found in broadcast file ${resolvedPath}`);
209+
}
210+
211+
let selected: BroadcastTx;
212+
if (args.txHash) {
213+
const match = createTxsForContract.find(
214+
(tx) => tx.hash && tx.hash.toLowerCase() === args.txHash!.toLowerCase()
215+
);
216+
if (!match) {
217+
throw new Error(
218+
`No CREATE transaction with hash ${args.txHash} for contract "${contractName}" in ${resolvedPath}`
219+
);
220+
}
221+
selected = match;
222+
} else if (createTxsForContract.length === 1) {
223+
selected = createTxsForContract[0];
224+
} else {
225+
const hashes = createTxsForContract
226+
.map((tx) => tx.hash)
227+
.filter(Boolean)
228+
.join(", ");
229+
throw new Error(
230+
`Multiple CREATE transactions for contract "${contractName}" found in ${resolvedPath}. ` +
231+
`Please re-run with --tx-hash set to one of: ${hashes}`
232+
);
233+
}
234+
235+
if (!selected.hash) {
236+
throw new Error(`Selected broadcast transaction for "${contractName}" is missing a tx hash`);
72237
}
73238

74-
// Get on-chain creation input
75-
const txHash = args.txHash ?? deployment.transactionHash;
76-
if (!txHash) {
77-
throw new Error("Could not find deployment tx hash. Pass --tx-hash when running script.");
239+
deployedAddress = selected.contractAddress;
240+
constructorArgs = selected.arguments || [];
241+
defaultTxHash = selected.hash;
242+
} else {
243+
const deployment = await deployments.get(contractName);
244+
deployedAddress = deployment.address;
245+
constructorArgs = deployment.args || [];
246+
defaultTxHash = deployment.transactionHash;
247+
}
248+
249+
const parseLibraries = (s?: string): Record<string, string> => {
250+
if (!s) return {};
251+
const out: Record<string, string> = {};
252+
const trimmed = s.trim();
253+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
254+
const parsed = JSON.parse(trimmed);
255+
for (const [k, v] of Object.entries(parsed)) out[k] = String(v);
256+
return out;
78257
}
79-
const tx = await ethers.provider.getTransaction(txHash);
80-
if (!tx) throw new Error(`Transaction not found for hash ${txHash}`);
81-
if (tx.to && tx.to != "") {
82-
throw new Error(`Transaction ${txHash} is not a direct contract creation (tx.to=${tx.to})`);
258+
for (const part of trimmed.split(/[\,\n]/)) {
259+
const [k, v] = part.split("=").map((x) => x.trim());
260+
if (k && v) out[k] = v;
83261
}
262+
return out;
263+
};
84264

85-
const expectedHash = ethers.utils.keccak256(expectedInit);
86-
const onchainHash = ethers.utils.keccak256(tx.data.toLowerCase());
265+
// Read local compilation artifact (Hardhat or Foundry) for reconstructing init code.
266+
const artifact = useFoundryArtifacts
267+
? loadFoundryArtifact(contractName)
268+
: await artifacts.readArtifact(contractName);
269+
console.log(
270+
"Reading compilation artifact for",
271+
(artifact as any).sourceName ?? (useFoundryArtifacts ? "<foundry>" : "<hardhat>")
272+
);
87273

88-
console.log("\n=============== Deploy Tx Verification ===============");
89-
console.log(`Contract : ${contractName}`);
90-
console.log(`Network : ${network.name}`);
274+
/**
275+
* TODO
276+
* the `libraries` bit is untested. Could be wrong. Could remove this part if we don't have contracts with dynamic libraries
277+
* artifact.linkReferences might help solve this better. Also, deployments.libraries. Implement only if required later.
278+
*/
279+
const libraries: Record<string, string> = parseLibraries(args.libraries);
280+
const factory = await ethers.getContractFactoryFromArtifact(
281+
artifact,
282+
Object.keys(libraries).length ? { libraries } : {}
283+
);
284+
285+
// Note: `factory.getDeployTransaction` populates the transaction with whatever data we WOULD put in it if we were deploying it right now
286+
const populatedDeployTransaction = factory.getDeployTransaction(...constructorArgs);
287+
const expectedInit: string = ethers.utils.hexlify(populatedDeployTransaction.data!).toLowerCase();
288+
if (!expectedInit || expectedInit === "0x") {
289+
throw new Error("Failed to reconstruct deployment init code from local artifacts");
290+
}
291+
292+
// Get on-chain creation input
293+
const txHash = args.txHash ?? defaultTxHash;
294+
if (!txHash) {
295+
throw new Error(
296+
"Could not find deployment tx hash. Pass --tx-hash when running script, " +
297+
"or ensure deployments / broadcast metadata includes it."
298+
);
299+
}
300+
const tx = await ethers.provider.getTransaction(txHash);
301+
if (!tx) throw new Error(`Transaction not found for hash ${txHash}`);
302+
if (tx.to && tx.to != "") {
303+
throw new Error(`Transaction ${txHash} is not a direct contract creation (tx.to=${tx.to})`);
304+
}
305+
306+
const expectedHash = ethers.utils.keccak256(expectedInit);
307+
const onchainHash = ethers.utils.keccak256(tx.data.toLowerCase());
308+
309+
console.log("\n=============== Deploy Tx Verification ===============");
310+
console.log(`Contract : ${contractName}`);
311+
console.log(`Network : ${network.name}`);
312+
if (deployedAddress) {
91313
console.log(`Deployed address : ${deployedAddress}`);
92-
if (txHash) console.log(`Tx hash : ${txHash}`);
93-
console.log("-------------------------------------------------------");
94-
console.log(`On-chain init hash : ${onchainHash}`);
95-
console.log(`Local init hash : ${expectedHash}`);
96-
console.log("-------------------------------------------------------");
97-
console.log(onchainHash === expectedHash ? "✅ MATCH" : "❌ MISMATCH – init code differs");
98-
console.log("=======================================================\n");
99314
}
100-
);
315+
if (args.broadcast) {
316+
console.log(`Broadcast file : ${args.broadcast}`);
317+
}
318+
if (txHash) console.log(`Tx hash : ${txHash}`);
319+
console.log("-------------------------------------------------------");
320+
console.log(`On-chain init hash : ${onchainHash}`);
321+
console.log(`Local init hash : ${expectedHash}`);
322+
console.log("-------------------------------------------------------");
323+
console.log(onchainHash === expectedHash ? "✅ MATCH" : "❌ MISMATCH – init code differs");
324+
console.log("=======================================================\n");
325+
});

0 commit comments

Comments
 (0)