Skip to content

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. #600

Description

@FrankD6EAF

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.bytecodecreation 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

  1. 部署任意带构造函数参数的合约(或通过 proxy 部署,proxy 的 creation bytecode 末尾有 constructor args)
  2. 修改合约源码
  3. 重新运行 hardhat deploy
  4. 观察到 "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 类型通过 OmitalwaysOverride 从可用选项中排除。

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions