在 dApp 上實現 ERC-4337:以去中心化領稿费机制实验為例

本文详细介绍了如何使用 ERC-4337 构建无需支付手续费的 dApp 互动流程,包括合约验证机制的实现和前端开发的全过程。

透过 ERC-4337 打造无需支付手续费的 dApp 互動流程,實作合约验证机制與前端开发全紀錄。

本案例由 TEM 去中心化領稿费机制實驗 Grant 贊助。

感謝 Nic 在开发过程中的協助與對本文內容提供許多修正與優化建議!

Photo by A Chosen Soul on Unsplash

案例介紹

這份案例的目標是开发一个 dApp 包含合约和前端,並整合 ERC-4337,让所有角色都能使用合约內的 ETH 支付交易手续费,用戶只需用錢包簽章並送出 userOp 即可执行合约函数。

合约所有人可以指定管理員 (changeAdmin),管理員負責登記稿件 (registerSubmission) 與指派审稿人 (updateReviewers)。审稿人审稿並評鑑稿费等級 (reviewSubmission),收款人自行領取稿费 (claimRoyalty)。

合约需符合 ERC-4337 的规范,必須有 validateUserOp 函数作为 userOp 接觸合约的入口,只能由 EntryPoint 呼叫。 validateUserOp 必須规范不同角色的函数权限,例如收款人只能 claimRoyalty。有了权限验证,代表能操作合约的角色都是合约账户的部分擁有者,角色們共享合约內儲存用於支付手续费的 ETH。

前情提要

  • 本文暫不討論 ERC-4337 合约账户之外的其他 entities,如 Factory, Paymaster, Aggregator,以及相關的 staking 和 reputation system。
  • 本案例使用 EntryPoint v0.7。
  • 針對 Gas 的部分,預期读者已熟悉 EIP-1559 手续费計算規則,或可參考 這篇

ERC-4337 流程:實作 validateUserOp

ERC-4337: Account Abstraction Using Alt Mempool 有一个 bundler 的角色負責處理不同於以太坊交易池的另一个 mempool,裡面放用戶送出的 User Operation,簡稱 userOp。

首先,用戶送 userOp 給 bundler,bundler 先模擬 userOp 的可行性,確認在验证阶段不會失敗,才將 userOp 放进 mempool。bundler 從 mempool 挑出不會互相干涉的 userOps 綁成一捆验证第二次,確認沒問題後,使用 EOA 向 EntryPoint.handleOps 發起交易,將 userOps 送上鏈。

EntryPoint 負責搭建 bundler 與合约账户信任基礎的橋樑,它是一个 singleton 合约,由 以太坊官方團隊 infinitism 主導开发,其處理 userOps 的过程分为验证阶段與执行阶段。

// EntryPoint.sol
function handleOps(
    PackedUserOperation[] calldata ops,
    address payable beneficiary
) public nonReentrant

合约账户需要實作 validateUserOp 介面如下:

interface IAccount {
    function validateUserOp(
        PackedUserOperation calldata userOp,
        bytes32 userOpHash,
        uint256 missingAccountFunds
    ) external returns (uint256 validationData);
}

EntryPoint.handleOps 先进入验证阶段,將每个合约账户的 validateUserOp 执行一遍(如果有人的 validateUserOp 失敗,整笔交易就會失敗,bundler 會承擔手续费損失,所以 bundler 一定會事先確保验证阶段不會失敗),然後再一次迴圈执行 EntryPoint.innerHandleOp,为执行阶段,分別對每个合约账户呼叫 userOp.callData (或 executeUserOp原始碼參考)。

流程图如下,簡單修改於 ERC-4337 中的 原图

手续费補償:prefund 與 compensate

bundler 負責上鏈的作業,必須先墊手续费,實際上應付手续费的是合约账户,因此合约账户在 validateUserOp 执行結束前,要支付 prefund 給 EntryPoint,EntryPoint 呼叫 validateUserOp 時會告訴合约目前账户在 EntryPoint 的充值與這笔交易所需的 gas 的差額( missingAccountFunds)。

在 EntryPoint 上可以幫合约账户充值 (depositTo)、查餘額(balanceOf)、提領(withdrawTo),詳見 StakeManager。如果合约內沒錢,EntryPoint 上也沒有充值,那送 userOp 的話會得到 EntryPoint 的錯誤訊息: AA21 didn't pay prefund

handleOps 的最後,EntryPoint 會將收集到的每个 userOp 所使用的 gas,一起還給 bundler。詳見 _compensate

因此, validateUserOp 雛形大概長這樣:

modifier onlyEntryPoint() virtual {
    if (msg.sender != entryPoint()) {
        revert NotFromEntryPoint();
    }
    _;
}

modifier payPrefund(uint256 missingAccountFunds) {
    _;
    assembly {
        if missingAccountFunds {
            pop(call(gas(), caller(), missingAccountFunds, codesize(), 0x00, codesize(), 0x00))
        }
    }
}

function validateUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds)
    external
    onlyEntryPoint
    payPrefund(missingAccountFunds)
    returns (uint256 validationData)
{
    // validation phase
}

實作权限验证

一般合约账户通常會實作 execute 方法,让账户的擁有者可以透过自己的合约账户與外部合约互動。但本案例是一个 dApp 合约,並非合约账户。用戶是直接與本合约互動,而不是透过本合约去操作其他合约。因此,本合约不需要實作通用的 execute 方法,用戶則是使用 EOA 來操作本合约,它可以走一般的交易流程由用戶自行支付手续费,也可以透过 ERC-4337 流程由本合约代付手续费來执行合约上的函数。

因此在 validateUserOp 的地方,首先確認用戶要操作的函数是合约上有的函数(檢查 function selector),任何合约上不存在的函数都 revert,接著验证使用者权限,檢查簽章,最後回傳 0 代表验证成功。

bytes4 selector = bytes4(userOp.callData[0:4]);
bytes memory actualSignature = bytes(userOp.signature[:65]);
address appendedSigner = address(bytes20(userOp.signature[65:]));
address signer = ECDSA.recover(ECDSA.toEthSignedMessageHash(userOpHash), actualSignature);

if (
    selector == this.upgradeToAndCall.selector || selector == this.changeAdmin.selector
        || selector == this.changeRoyaltyToken.selector || selector == this.transferOwnership.selector
        || selector == this.emergencyWithdraw.selector
) {
    require(appendedSigner == owner(), Unauthorized(appendedSigner));
    if (signer != appendedSigner) {
        return SIG_VALIDATION_FAILED;
    }
    return 0;
}
revert UnsupportSelector(selector);

权限验证可否寫在执行阶段?

不行,因为 EntryPoint 只要判斷验证阶段通过那合约账户就要付手续费,不管执行阶段是成功還是失敗。因此如果权限验证寫在执行阶段,任何人都能替合约送 userOp 造成执行阶段失敗,合约账户支付手续费,惡意人士能藉此耗盡合约账户內的 ETH。

revert 跟回傳 1 (SIG_VALIDATION_FAILED) 的差別?

在验证阶段回傳 1(SIG_VALIDATION_FAILED)是为了让 bundler 能夠成功进行 Gas Estimation,而不是在验证失敗時直接 revert。

當用戶呼叫 eth_estimateUserOperationGas 時,送出的 userOp 通常還不完整,可能缺少 gas 相關欄位,signature 則會是一組 dummy signature,例如一个 ECDSA dummy signature 可能長這樣:

0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c

這組簽章對應不到正確的 signer,因此在验证阶段會失敗。但若直接 revert,bundler 在模擬時(透过 debug_traceCall)就无法完成整个流程,自然也无法估算执行阶段所需的 gas。

为了避免 dummy signature 直接被當成有效簽章,所以使用 dummy signature 的話最後要回傳 1(SIG_VALIDATION_FAILED),让 Bundler 知道簽章验证沒有成功,不要把 userOp 上鏈执行。Bundler 也能透过這个標記,完成 Gas Estimation,取得 preVerificationGas, verificationGasLimit, callGasLimit 等資訊。

为什麼要附加 signer address 在 userOp.signature 後方,而不直接用 ECDSA.recover 導出的 signer?

如果不附加 signer address 於簽章後方,而是用以下寫法:

require(signer == owner(), Unauthorized(signer));
return 0;

直接送 userOp 的話也許會成功,但 Gas Estimation 會失敗: AA23 reverted Unauthorized,因为 Gas Estimation 使用 dummy signature,恢復的 signer 不會是 owner,導致验证阶段被 revert 而无法估計 gas。如前一題所述,當簽章验证失敗時應該要回傳 1(SIG_VALIDATION_FAILED)才能完成 Gas Estimation。

前端送 userOp 流程

本案例的本地开发使用 Pimlico Bundler,而正式環境 (Sepolia & Mainnet) 則使用 Alchemy Rundler,與以太坊客戶端共用同一个網域。

以下是前端送一个 userOp 給 bundler/node 的流程图:

User Operation

以下是 EntryPoint v0.7 的 PackedUserOperation:

struct PackedUserOperation {
    address sender;
    uint256 nonce; // EntryPoint.getNonce(sender, key)
    bytes initCode; // factory + factoryData
    bytes callData;
    bytes32 accountGasLimits; // verificationGasLimit + callGasLimit
    uint256 preVerificationGas;
    bytes32 gasFees; // maxPriorityFeePerGas + maxFeePerGas
    bytes paymasterAndData; // (52 bytes): paymaster (20) + paymasterVerificationGasLimit (16) + paymasterPostOpGasLimit (16) + paymaster-specific extra data
    bytes signature;
}

以及真正送給 bundler 的 userOp:

export type UserOp = {
    sender: string
    nonce: string
    factory: string | null
    factoryData: string | '0x'
    callData: string
    callGasLimit: string | '0x0'
    verificationGasLimit: string | '0x0'
    preVerificationGas: string | '0x0'
    maxFeePerGas: string | '0x0'
    maxPriorityFeePerGas: string | '0x0'
    paymaster: string | null
    paymasterVerificationGasLimit: string | '0x0'
    paymasterPostOpGasLimit: string | '0x0'
    paymasterData: string | '0x'
    signature: string | '0x'
}

Gas values

maxFeePerGas & maxPriorityFeePerGas

這份案例中,前端向 Alchemy 取得 maxFeePerGas & maxPriorityFeePerGas 的方法是使用以下兩个 RPC:

const [block, maxPriorityFeePerGas] = await Promise.all([\
    this.rpcProvider.send({ method: 'eth_getBlockByNumber', params: ['latest', true] }),\
    this.rpcProvider.send({ method: 'rundler_maxPriorityFeePerGas' }),\
])

計算 maxFeePerGas 的方法是用 1.5 倍的 baseFeePerGas + maxPriorityFeePerGas

const maxFeePerGas = (BigInt(block.baseFeePerGas) * 150n) / 100n + BigInt(maxPriorityFeePerGas)

1.5 倍是參考 Alchemy account-kit 的實作方式,若倍率太低,userOp 可能送出去後等很久都上不了鏈,此時若再送一笔 userOp,可能會出現錯誤: Replacement Underpriced

要將卡在 mempool 的 userOp 覆蓋掉,需要上調原本的 maxPriorityFeePerGas & maxFeePerGas 至少 10%,參考 這裡

preVerificationGas

preVerificationGas 是用戶給 bundler 的服務费,它用來補貼:

  • handleOps 的基本交易费
  • handleOps 的 calldata 成本 (Zero Byte: 4 Gas、Non-Zero Byte: 16 Gas)
  • bundler 運行服務的間接成本,bundler 为了验证 userOp 进行模擬,確保验证阶段成功所需的運算成本。

bundler 可以決定它願意接受的最低 preVerificationGas,用戶給太少 bundler 不幫你送 userOp,用戶也可以找價格較低的 bundler 送交易。

verificationGasLimit & callGasLimit

分別是验证阶段與执行阶段所能花费的最大 gas cost。

  • verificationGasLimit 包含

- Account creation (如果有 initCode)

- validateUserOp

- validatePaymasterUserOp (如果有 paymaster)

  • 當 callGasLimit 額度設定过高,導致超出使用量,超出的部分會被收取 10% 的懲罰 ( EntryPoint v0.7 source),目的是避免用戶保留过多未使用的 gas 空間,以防 bundler 因为單一 userOp 佔用过多 gas 額度,而无法在同一个 bundle 中打包更多的 userOps。 (註:在 EntryPoint v0.8 中新增需要超过 4 萬 gas 才會懲罰的門檻,詳見 ERC-4337)

dApp 搭載 ERC-4337 介面的問題

执行阶段无法存取 userOp 的 signer,因此执行函数內无法得知是誰在执行

以本案例的 reviewSubmission 为例, hasReviewed 用以紀錄某个 reviewer 已經审过稿了,不能再审一次。

function _reviewSubmission(string memory title, uint16 royaltyLevel, address reviewer) internal {
        MainStorage storage $ = _getMainStorage();
        $.hasReviewed[title][reviewer] = true;
        ...
    }

如果走一般交易流程, msg.sender 可以得到 reviewer 的地址,但若是走 ERC-4337 流程, msg.sender 則是 EntryPoint,因此不能使用。

本案例的解法是將 userOp.signature 後面的 appendedSigner 在验证阶段儲存至 transient storage,並在执行阶段存取該地址,以此在执行阶段取得 reviewer 的地址並記錄於 hasReviewed 當中。

验证阶段

else if (selector == this.reviewSubmission.selector) {
    (string memory title, uint16 royaltyLevel) = abi.decode(userOp.callData[4:], (string, uint16));
    _requireReviewable(title, royaltyLevel, appendedSigner);

    assembly {
            tstore(TRANSIENT_SIGNER_SLOT, appendedSigner)
        }
        if (signer != appendedSigner) {
            return SIG_VALIDATION_FAILED;
        }
        return 0;
}

执行阶段

function reviewSubmission(string memory title, uint16 royaltyLevel) public {
    if (msg.sender == entryPoint()) {
        _reviewSubmission(title, royaltyLevel, _getUserOpSigner());
    } else {
        _requireReviewable(title, royaltyLevel, msg.sender);
        _reviewSubmission(title, royaltyLevel, msg.sender);
    }
}
function _getUserOpSigner() internal view returns (address) {
    address signer;
    assembly {
        signer := tload(TRANSIENT_SIGNER_SLOT)
    }
    if (signer == address(0)) {
        revert ZeroAddress();
    }
    return signer;
}

附註:另一个方法是使用 executeUserOp,就能在执行阶段存取 userOp,但程式碼會變得比較複雜,因此沒有使用,詳見 EntryPoint 的這裡

Gas draining

原本想让 claimRoyalty 不做任何权限验证,让任何人都能呼叫,反正最後代幣都是給收款人。但是這等同於是一个沒有权限验证的合约账户,允許让任何人都能替合约账户送 userOp,將導致惡意人士可以不斷送执行阶段會失敗的 userOp,藉此耗盡合约內的 ETH。

惡意人士可以

  • 調高 maxPriorityFeePerGas,让礦工多吃一點
  • 調高 callGasLimit 让合约账户受到 10% 多餘 gas 空間的懲罰
  • 調低 callGasLimit 導致合约在执行阶段失敗
  • 調高 preVerificationGas 串謀惡意 bundler,將合约內的 ETH 轉給 bundler。

此外,本案例的合约禁止使用 paymaster,原因是为了防止惡意人士透过 paymaster 來 drain ERC-20 代幣。paymaster 需要實作的兩个方法: validatePaymasterUserOppostOp 都可以將合约內的代幣轉走。

dApp 合约搭載 4337 的主要風險之一,就是只要通过权限验证的人們都可以對合约进行 draining,但我們能夠知道是誰在這麼做。

验证規則的限制 ERC-7562

userOp 在上鏈之前,會被 bundler 驗兩次:

  1. 进入 mempool 之前單獨驗一次
  2. 提交上鏈之前,一捆 userOp 一起驗一次

bundler 實作的验证規則決定一个 userOp 能不能进入 mempool,主要有兩方面的限制:

  • 禁止使用部分 opcode
  • 限縮 storage 的存取

验证規則細節规范於 ERC-7562: Account Abstraction Validation Scope Rules

限制 validateUserOp 目的是为了让 bundler 有能力辨識哪些 userOp 在验证阶段一定會成功,哪些會失敗。如果合约账户在验证阶段使用 require(block.number == 1234) 就无法保證 userOp 能否成功,或合约在验证阶段對另一合约寫入資料,可能因为該合约的狀態改變而導致不可預料的交易失敗。

handleOps 只要有一个 userOp 在验证阶段失敗,整捆 userOp 會一起失敗,造成 bundler 手续费的損失,而背後最根本要解決的問題是 bundler 可能被一堆无法預測成敗的 userOp 进行阻斷服務攻擊 (DoS attack)。

再更背後一層原因,因为 ERC-4337 做的是抗审查的去中心化 relayer 系統,而不是一个中心化的、需許可的 relayer。雖然任何人都可以做一个不大符合協定的 bundler 來跳过验证規則的限制,或以其他需許可的形式來預防 DoS,但這對於想發起一笔交易的用戶來說就成了中心化系統,无法避免單點故障,或者交易要被审查。

回到這份案例,是否能將 reviewSubmission 和 claimRayalty 完全放在验证阶段执行,而执行阶段不执行任何程式?

這个想法源於當前實作仍然无法避免惡意的审稿人和領稿人耗盡合约內的 ETH(但可以知道是誰在做這件事),如果把 reviewSubmission 和 claimRayalty 寫在验证阶段,bundler 就會確保它們上鏈一定會成功。

但因为 ERC-7562 的限制,claimRayalty 處理 ERC20 提領的程式會改動收款人的 ERC20 balance,這个 storage 不屬於 ERC-7562 associated storage 的规范,因此 bundler 會回報錯誤訊息: account accesses inaccessible storage at address...

而 reviewSubmission 因为都是改動合约自己的狀態,是可以完全寫在验证阶段的。(如果一捆 userOp 中有兩个以上的 userOp 屬於同一个合约账户,那合约狀態的改動就可能導致其中一个失敗,這也是为何 bundler 要在送 handleOps 之前需要 驗第二次的理由,bundler 可能會避免兩个相同 sender 的 userOp 放在同一个 bundle 內,倘若验证沒問題,放在一起也可以。)

其他

可升級合约要避免 storage collisions,使用 ERC-7201

本案例使用 UUPS 可升級合约,參考 這裡,为了避免新合约與舊合约的 storage layout 重疊導致衝突,在此將合约所有的狀態用 struct 儲存在一个 slot,slot hash 的生成與命名原則规范於 ERC-7201: Namespaced Storage Layout

/// @dev cast index-erc7201 royaltyautoclaim.storage.main
bytes32 private constant MAIN_STORAGE_SLOT = 0x41a2efc794119f946ab405955f96dacdfa298d25a3ae81c9a8cc1dea5771a900;

function _getMainStorage() private pure returns (MainStorage storage $) {
    assembly {
        $.slot := MAIN_STORAGE_SLOT
    }
}
/// @custom:storage-location erc7201:royaltyautoclaim.storage.main
struct MainStorage {
    Configs configs;
    mapping(string => Submission) submissions;
    mapping(string => mapping(address => bool)) hasReviewed;
}

nonce key 做何用?

EntryPoint 的 getNonce 如下:

function getNonce(address sender, uint192 key) public view override returns (uint256 nonce) {
    return nonceSequenceNumber[sender][key] | (uint256(key) << 64);
}

nonceKey 可以让前端放任意值,取得不同的 nonce,能做到平行送 userOp 的效果,例如此時前端有兩个領款人同時送 userOp 領款,其中一人可能因为 nonce 重複而失敗,如果前端將 nonceKey 設为當下的時間,就能解決同時送 userOp 的問題。

Kernel 和 Nexus 的合约账户都將 nonceKey (24 bytes) 的 20 bytes 拿來存 ERC-7579: Minimal Modular Smart Accounts 的 validator 地址。

Kernel v2 將 validator 放在 userOp.signature 後面,Kernel v3 改將 validator 放在 nonceKey,參考 這裡。放在 userOp.signature 後面會有問題,是攻擊者可以 front-run userOp 然後把 validator 的地址改成別的,因为放在 signature 後面表示沒有被放进簽章內容裡。

順帶一提,一个 userOp 的簽章內容是 userOpHash,它包含以下成分:

keccak256(abi.encode(packedUserOp.hash(), ENTRY_POINT_ADDRESS, block.chainid));

參考資料

關於 paymaster

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
EthTaipei
EthTaipei
Taipei Ethereum Meetup