rocketh Bug Report
概述 / Overview
在使用 rocketh 部署 UUPS 代理合约时,发现修改合约源码后重新部署不会触发 proxy 升级。
经调试定位到两个 bug,分别在 @rocketh/deploy@0.19.2 和 @rocketh/proxy@0.19.7。
When deploying UUPS proxy contracts with rocketh, modifying contract source code and redeploying
does not trigger a proxy upgrade. Two bugs were identified in @rocketh/deploy@0.19.2 and
@rocketh/proxy@0.19.7.
Bug 1 — @rocketh/deploy: CBOR length read from wrong bytecode
Package: @rocketh/deploy@0.19.2
File: src/index.ts (line ~302), dist/index.js (line ~188)
Severity: Critical — upgrade detection is completely broken for contracts with constructor arguments
问题描述 / Description
deploy() 函数通过比较新旧 bytecode 来判断合约是否变化。它从旧 bytecode 末尾读取 2 字节作为 CBOR 元数据长度,然后裁掉 CBOR 再比较。
但代码使用的是 existingDeployment.bytecode(creation bytecode),而 CBOR 元数据只附加在 deployed bytecode(runtime code) 末尾。
The deploy() function detects contract changes by comparing old and new bytecode. It reads the
last 2 bytes of the old bytecode as the CBOR metadata length, strips it, then compares.
However, the code uses existingDeployment.bytecode (creation bytecode), while CBOR metadata
is only appended to deployed bytecode (runtime code).
根因 / Root Cause
creation bytecode = deployed bytecode + constructor calldata
Creation bytecode 末尾是构造函数参数,不是 CBOR。读到的 2 字节是随机数据(例如 6a00 = 27136 字节),导致裁剪位置错误,两边都被裁空,比较结果永远相等。
The last 2 bytes of creation bytecode are constructor arguments, not CBOR. The parsed "CBOR length"
is garbage (e.g., 6a00 = 27136 bytes), causing both sides to be stripped to empty strings,
making the comparison always equal.
问题代码 / Buggy Code
// src/index.ts ~302
if (existingDeployment && !alwaysOverride) {
const previousBytecode = existingDeployment.bytecode; // ← creation bytecode, NOT deployed
const previousArgsData = existingDeployment.argsData;
// we assume cbor encoding of hash at the end
const last2Bytes = previousBytecode.slice(-4); // ← reads constructor args, not CBOR
const cborLength = parseInt(last2Bytes, 16); // ← garbage value (e.g. 27136)
const previousBytecodeWithoutCBOR = previousBytecode.slice(0, -cborLength * 2); // ← strips everything
const newBytecodeWithoutCBOR = bytecode.slice(0, -cborLength * 2); // ← strips everything
if (previousBytecodeWithoutCBOR === newBytecodeWithoutCBOR && ...) {
return {...existingDeployment, newlyDeployed: false}; // ← always returns here
}
}
修复 / Fix
// src/index.ts ~302
if (existingDeployment && !alwaysOverride) {
const previousArgsData = existingDeployment.argsData;
// CBOR metadata is appended to deployed bytecode (runtime code), not creation bytecode.
const previousDeployedBytecode = existingDeployment.deployedBytecode;
const newDeployedBytecode = artifactToUse.deployedBytecode;
let bytecodeMatches: boolean;
if (previousDeployedBytecode && newDeployedBytecode) {
const last2Bytes = previousDeployedBytecode.slice(-4);
const cborLength = parseInt(last2Bytes, 16);
const previousWithoutCBOR = previousDeployedBytecode.slice(0, -cborLength * 2);
const newWithoutCBOR = newDeployedBytecode.slice(0, -cborLength * 2);
bytecodeMatches = previousWithoutCBOR === newWithoutCBOR;
} else {
// Fallback: compare creation bytecode directly if deployedBytecode unavailable.
bytecodeMatches = existingDeployment.bytecode === bytecode;
}
if (bytecodeMatches && previousArgsData === argsData) {
return {...(existingDeployment as Deployment<TAbi>), newlyDeployed: false};
}
}
复现步骤 / Reproduction
- 部署任意带构造函数参数的合约(或通过 proxy 部署,proxy 的 creation bytecode 末尾有 constructor args)
- 修改合约源码
- 重新运行
hardhat deploy
- 观察到 "Contract unchanged, no upgrade needed",但合约实际已变化
Bug 2 — @rocketh/proxy: alwaysOverride hardcoded to false
Package: @rocketh/proxy@0.19.7
File: src/index.ts (line ~123), dist/index.js (line ~20)
Severity: Medium — alwaysOverride option silently ignored for implementation deployment
问题描述 / Description
deployViaProxy() 在构造 optionsForImplementation 时将 alwaysOverride 硬编码为 false,
同时 ProxyDeployOptions 类型通过 Omit 将 alwaysOverride 从可用选项中排除。
deployViaProxy() hardcodes alwaysOverride: false in optionsForImplementation, and
ProxyDeployOptions uses Omit<DeployOptions, 'alwaysOverride'> to exclude it from the type.
Users have no way to force-redeploy the implementation.
问题代码 / Buggy Code
// src/index.ts ~29
export type ProxyDeployOptions = Omit<DeployOptions, 'skipIfAlreadyDeployed' | 'alwaysOverride'> & {
// ^^^^^^^^^^^^^^^^
// alwaysOverride excluded from type entirely
// src/index.ts ~123
let optionsForImplementation = options
? {
alwaysOverride: false, // ← hardcoded, user value ignored
deterministic: options.deterministic || options.deterministicImplementation,
libraries: options.libraries,
}
: undefined;
修复 / Fix
// src/index.ts ~29
export type ProxyDeployOptions = Omit<DeployOptions, 'skipIfAlreadyDeployed'> & {
// remove 'alwaysOverride' from Omit
// src/index.ts ~123
let optionsForImplementation = options
? {
alwaysOverride: options.alwaysOverride ?? false, // propagate user value
deterministic: options.deterministic || options.deterministicImplementation,
libraries: options.libraries,
}
: undefined;
附加说明 / Additional Notes
execute 选项的正确用法 / Correct usage of execute option
当使用 execute: { methodName: 'initialize', args: [...] } 形式时,rocketh 在每次升级时都会调用该方法,包括升级场景。对于 OpenZeppelin 的 initializer 修饰符,这会导致 InvalidInitialization 错误。
应使用 { init, onUpgrade } 形式区分初始化和升级:
When using execute: { methodName: 'initialize', args: [...] }, rocketh calls this method on
every deployment including upgrades. With OpenZeppelin's initializer modifier, this causes
InvalidInitialization revert.
Use { init, onUpgrade } form to distinguish initialization from upgrades:
execute: {
init: {
methodName: 'initialize',
args: [deployer],
},
// onUpgrade: { methodName: 'reinitialize', args: [...] } // optional
},
环境 / Environment
@rocketh/deploy: 0.19.2
@rocketh/proxy: 0.19.7
rocketh: 0.19.4
hardhat: 3.4.4
hardhat-deploy: 2.0.5
- Solidity: 0.8.27 with
viaIR: true, optimizer enabled
rocketh Bug Report
概述 / Overview
在使用 rocketh 部署 UUPS 代理合约时,发现修改合约源码后重新部署不会触发 proxy 升级。
经调试定位到两个 bug,分别在
@rocketh/deploy@0.19.2和@rocketh/proxy@0.19.7。When deploying UUPS proxy contracts with rocketh, modifying contract source code and redeploying
does not trigger a proxy upgrade. Two bugs were identified in
@rocketh/deploy@0.19.2and@rocketh/proxy@0.19.7.Bug 1 — @rocketh/deploy: CBOR length read from wrong bytecode
Package:
@rocketh/deploy@0.19.2File:
src/index.ts(line ~302),dist/index.js(line ~188)Severity: Critical — upgrade detection is completely broken for contracts with constructor arguments
问题描述 / Description
deploy()函数通过比较新旧 bytecode 来判断合约是否变化。它从旧 bytecode 末尾读取 2 字节作为 CBOR 元数据长度,然后裁掉 CBOR 再比较。但代码使用的是
existingDeployment.bytecode(creation bytecode),而 CBOR 元数据只附加在 deployed bytecode(runtime code) 末尾。The
deploy()function detects contract changes by comparing old and new bytecode. It reads thelast 2 bytes of the old bytecode as the CBOR metadata length, strips it, then compares.
However, the code uses
existingDeployment.bytecode(creation bytecode), while CBOR metadatais only appended to deployed bytecode (runtime code).
根因 / Root Cause
Creation bytecode 末尾是构造函数参数,不是 CBOR。读到的 2 字节是随机数据(例如
6a00= 27136 字节),导致裁剪位置错误,两边都被裁空,比较结果永远相等。The last 2 bytes of creation bytecode are constructor arguments, not CBOR. The parsed "CBOR length"
is garbage (e.g.,
6a00= 27136 bytes), causing both sides to be stripped to empty strings,making the comparison always equal.
问题代码 / Buggy Code
修复 / Fix
复现步骤 / Reproduction
hardhat deployBug 2 — @rocketh/proxy:
alwaysOverridehardcoded tofalsePackage:
@rocketh/proxy@0.19.7File:
src/index.ts(line ~123),dist/index.js(line ~20)Severity: Medium —
alwaysOverrideoption silently ignored for implementation deployment问题描述 / Description
deployViaProxy()在构造optionsForImplementation时将alwaysOverride硬编码为false,同时
ProxyDeployOptions类型通过Omit将alwaysOverride从可用选项中排除。deployViaProxy()hardcodesalwaysOverride: falseinoptionsForImplementation, andProxyDeployOptionsusesOmit<DeployOptions, 'alwaysOverride'>to exclude it from the type.Users have no way to force-redeploy the implementation.
问题代码 / Buggy Code
修复 / Fix
附加说明 / Additional Notes
execute 选项的正确用法 / Correct usage of
executeoption当使用
execute: { methodName: 'initialize', args: [...] }形式时,rocketh 在每次升级时都会调用该方法,包括升级场景。对于 OpenZeppelin 的initializer修饰符,这会导致InvalidInitialization错误。应使用
{ init, onUpgrade }形式区分初始化和升级:When using
execute: { methodName: 'initialize', args: [...] }, rocketh calls this method onevery deployment including upgrades. With OpenZeppelin's
initializermodifier, this causesInvalidInitializationrevert.Use
{ init, onUpgrade }form to distinguish initialization from upgrades:环境 / Environment
@rocketh/deploy: 0.19.2@rocketh/proxy: 0.19.7rocketh: 0.19.4hardhat: 3.4.4hardhat-deploy: 2.0.5viaIR: true, optimizer enabled