在 dApp 上實現 ERC-4337:以去中心化領稿費機制實驗為例

本文详细介绍了如何使用ERC-4337构建无需支付手续费的dApp互动流程,以去中心化领稿费机制为例,涵盖了合约验证机制的实现、前端开发流程以及dApp集成ERC-4337接口时遇到的问题,并提供了相应的解决方案,例如在执行阶段获取userOp的签名者地址以及避免gas耗尽等安全问题。

透過 ERC-4337 打造無需支付手續費的 dApp 互動流程,實作合約驗證機制與前端開發全紀錄。

本專案由 TEM 去中心化領稿費機制實驗 Grant 贊助。

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

專案介紹

這份專案的目標是開發一個 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 中的 原圖

Account 2Account 1EntryPointBundlerAccount 2Account 1EntryPointBundlerloop[validation phase]loop[execution phase]Usereth_sendUserOperationSimulationhandleOps(userOps[])validateUserOpdepositdeduct Account 1 depositvalidateUserOpdepositdeduct Account 2 depositinnerHandleOpuserOp.callDatarefund Account 1userOp.callDatarefund Account 2compensate(beneficiary)User

手續費補償: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 的流程圖:

Bundler & NodeBundler & Nodeloop[waiting]Usereth_call (EntryPoint.getNonce)nonceCreate UserOpeth_getBlockByNumberblock.baseFeePerGasrundler_maxPriorityFeePerGasCreate maxFeePerGas & maxPriorityFeePerGaseth_estimateUserOperationGaspreVerificationGas, verificationGasLimit, callGasLimitCompute userOpHash & signMessage(userOpHash)eth_sendUserOperationeth_getUserOperationReceiptUserOp receiptUser

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 条评论

请先 登录 后评论
johnson86tw
johnson86tw
江湖只有他的大名,没有他的介绍。