체인의정석

ERC4337 컨트렉트 핵심 요소 및 커스터마이징 해야할 부분 정리 본문

블록체인/계정추상화

ERC4337 컨트렉트 핵심 요소 및 커스터마이징 해야할 부분 정리

체인의정석 2024. 4. 8. 20:27
728x90
반응형

공식문서 : https://eips.ethereum.org/EIPS/eip-4337

 

ERC-4337: Account Abstraction Using Alt Mempool

An account abstraction proposal which completely avoids consensus-layer protocol changes, instead relying on higher-layer infrastructure.

eips.ethereum.org

공식 구현체 npm 패키지

https://www.npmjs.com/package/@account-abstraction/contracts

 

@account-abstraction/contracts

Account Abstraction (EIP 4337) contracts. Latest version: 0.6.0, last published: a year ago. Start using @account-abstraction/contracts in your project by running `npm i @account-abstraction/contracts`. There are 95 other projects in the npm registry using

www.npmjs.com

=> 계정 추상화를 실행하기 위해서는 위의 패키지를 받은 후 사용할 부분만 커스터마이징 하면 된다. 다만 서명 방식이나 검증에 대해서는 거의 다 오버라이딩이 필요한 상황이기 때문에 오버라이딩을 하지 못하면 계정 추상화를 배포하거나 실행할 수 없다.

 

0. 구성요소

-> 필수 구조

모든 플로우의 시작 지점 EntryPoint

import {EntryPoint} from "@account-abstraction/contracts/core/EntryPoint.sol";

-> Wallet 관련

AA Wallet을 제작하는 Wallet Factory, 

import {BaseAccount} from "@account-abstraction/contracts/core/BaseAccount.sol";

* nonce 꼬임 방지용 필요

Nonce를 관리하는 컨트렉트 (Counter Contract) - 호출할 때 마다 counter를 1씩 증가시켜주는 컨트렉트로 구현하거나 실제 nonce와 동일하게 관리.

*각 서명 규격을 지원할 시 필요

ex) passkey 지원시 => 
https://www.passkeys.io/technical-details

 

How do passkeys work?

Passkeys are a built-in capability of all major operating systems and browsers. Find out more about the technical details that make passkeys work.

www.passkeys.io

아래와 같이 지원하는 서명 규격에 따라 verify 함수를 만들어서 Base Account의 _validateSignature 오버라이딩 하기

UserOp의 Signature와 userOpHash를 통해서 검증 로직을 만들어야 한다.

    /// implement template method of BaseAccount
    function _validateSignature(
        UserOperation calldata userOp,
        bytes32 userOpHash
    )
        internal
        virtual
        override
        returns (uint256 validationData)

https://github.com/qd-qd/wallet-abstraction/blob/main/Contracts/src/Accounts/WebAuthnAccount.sol

 

wallet-abstraction/Contracts/src/Accounts/WebAuthnAccount.sol at main · qd-qd/wallet-abstraction

No more wallets in one year. Contribute to qd-qd/wallet-abstraction development by creating an account on GitHub.

github.com

위의 깃허브 링크가 passkey를 AA에 붙인 예시이다.

* 대납 기능 추가 시 필요

수수료 대납 등의 기능이 담긴 (Paymaster Contract) , 이건 기본 구조는 아니다.

import {BasePaymaster} from "@account-abstraction/contracts/core/BasePaymaster.sol";

 

1. 기초 지식 User operation 구조 

사실상 트랜잭션을 객체형태로 만든것이라고 보면 되며, 트랜잭션이랑 이름을 혼동하지 않기 위해서 UserOperation이라고 적었다고 한다. 백엔드에서 트랜잭션 정보를 채워서 넘겨주는것 처럼 UserOperation 값을 채워서 컨트렉트에 전달하면 해당 내용대로 트랜잭션이 실행되는 구조라고 보면 된다.

유저 오퍼레이션의 필드 구조는 다음과 같다.

FieldTypeDescription

sender address The account making the operation
nonce uint256 Anti-replay parameter (see “Semi-abstracted Nonce Support” )
factory address account factory, only for new accounts
factoryData bytes data for account factory (only if account factory exists)
callData bytes The data to pass to the sender during the main execution call
callGasLimit uint256 The amount of gas to allocate the main execution call
verificationGasLimit uint256 The amount of gas to allocate for the verification step
preVerificationGas uint256 Extra gas to pay the bunder
maxFeePerGas uint256 Maximum fee per gas (similar to EIP-1559 max_fee_per_gas)
maxPriorityFeePerGas uint256 Maximum priority fee per gas (similar to EIP-1559 max_priority_fee_per_gas)
paymaster address Address of paymaster contract, (or empty, if account pays for itself)
paymasterVerificationGasLimit uint256 The amount of gas to allocate for the paymaster validation code
paymasterPostOpGasLimit uint256 The amount of gas to allocate for the paymaster post-operation code
paymasterData bytes Data for paymaster (only if paymaster exists)
signature bytes Data passed into the account to verify authorization

* 계정 추상화를 사용하기 위해서는 다양한 방법이 있기 때문에 만약 내가 사용할 수치가 존재한다면 유의미한 값을 넣으면 되고 없다면 임의의 값을 넣고 패스 하는 것이 가능하다.

 

2. Entry Point 개념과 구현

2개의 분리된 entry point 함수가 존재 : handleOps & handleAggregatedOps (1개 실행 vs 배치 실행)

크게 Verification Loop와 Validation Loop 2개가 존재한다.

2.1 Verification Loop

계정이 아직 존재하지 않는 경우 UserOperation에 제공된 initCode를 사용하여 계정을 생성합니다. 계정이 존재하지 않고 초기 코드가 비어 있거나 '발신자' 주소에 컨트랙트를 배포하지 않으면 호출이 실패해야 합니다.

계정이 지불해야 하는 최대 가능한 수수료를 계산합니다(확인 및 통화 가스 한도, 현재 가스 값에 따라).
계정이 엔트리포인트의 '예치금'에 추가해야 하는 수수료를 계산합니다.

사용자 작업, 해시 및 필요한 수수료를 전달하여 계정에서 validateUserOp를 호출합니다. 계정은 오퍼레이션의 서명을 확인하고 오퍼레이션이 유효하다고 판단되면 수수료를 지불해야 합니다. validateUserOp 호출이 실패하면 handleOps는 최소한 해당 작업의 실행을 건너뛰어야 하며, 완전히 되돌아갈 수도 있습니다.

엔트리포인트에 있는 계정의 예치금이 가능한 최대 비용(이미 완료된 검증 및 최대 실행 가스를 충당)을 충당할 수 있을 만큼 충분히 높은지 확인합니다.

2.2 Execution Loop

사용자 작업의 호출 데이터로 계정을 호출합니다. 호출 데이터를 구문 분석하는 방법을 선택하는 것은 계정에 달려 있습니다. 예상되는 워크플로는 계정에 나머지 호출 데이터를 하나 이상의 호출로 구문 분석하는 실행 함수가 있어야 한다는 것입니다.

CallData가 IAccountExecute.executeUserOp 메서드로 시작하는 경우, 엔트리포인트는 executeUserOp(userOp,userOpHash)를 인코딩하여 콜데이터를 생성하고 해당 콜데이터를 사용하여 계정을 호출

Call 종료 후, 미리 충전된 초과 가스 비용과 함께 계정의 보증금을 환불
환불되는 가스 금액에는 10%의 페널티(UNUSED_GAS_PENALTY_PERCENT)가 적용
이 페널티는 번들에서 가스 공간의 많은 부분을 예약하고도 사용하지 않은 채로 두어 번들러가 다른 사용자 작업을 포함하지 못하게 하는 것을 방지하기 위해 필요

번들러는 UserOperation을 수락하기 전에 RPC 메서드를 사용하여 진입점에서 simulateValidation 함수를 로컬로 호출하여 서명이 정확하고 작업이 실제로 수수료를 지불하는지 확인해야 합니다(자세한 내용은 아래 시뮬레이션 섹션을 참조하세요). 노드/번들러는 유효성 검사에 실패한 UserOperation을 삭제(멤풀에 추가하지 않음)해야 합니다.

출처 : https://eips.ethereum.org/EIPS/eip-4337

Paymaster가 붙는다면 다음과 같다. 검증을 paymaster에서 해주고 paymaster에서 deposit 된 금액을 차감한다.

출처 : https://eips.ethereum.org/EIPS/eip-4337

 

그렇다면 EntryPoint 구현은?

"@account-abstraction/contracts": "^0.6.0",

https://www.npmjs.com/package/@account-abstraction/contracts

 

@account-abstraction/contracts

Account Abstraction (EIP 4337) contracts. Latest version: 0.6.0, last published: a year ago. Start using @account-abstraction/contracts in your project by running `npm i @account-abstraction/contracts`. There are 95 other projects in the npm registry using

www.npmjs.com

이미 다 되어 있기 때문에 그냥 여기 받아와서 import만 해주면 끝난다.

따로 조정할 필요가 없다.

3. Wallet & Wallet Factory

import {BaseAccount} from "@account-abstraction/contracts/core/BaseAccount.sol";

해당 BaseAccount 에서 오버라이딩이 필요한 부분을 일단 entryPoint를 지정하는함수이다.

    /**
     * return the entryPoint used by this account.
     * subclass should return the current entryPoint used by this account.
     */
    function entryPoint() public view virtual returns (IEntryPoint);

만약 서명 프로세스 전체를 오버라이딩 하지않는다면 검증하는 내부함수도 오버라이딩 해야 한다.

    /**
     * Validate user's signature and nonce.
     * subclass doesn't need to override this method. Instead, it should override the specific internal validation methods.
     */
    function validateUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds)
    external override virtual returns (uint256 validationData) {
        _requireFromEntryPoint();
        validationData = _validateSignature(userOp, userOpHash);
        _validateNonce(userOp.nonce);
        _payPrefund(missingAccountFunds);
    }

실제로는 기본 예제에서 _validateSignature 부분만 오버라이딩하면 잘작동하긴하는데,

그 예시는 smaples의 SimpleAccountFactory.sol과 SimpleAccount.sol을 보면 된다.

SimpleAccount.sol

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.12;

/* solhint-disable avoid-low-level-calls */
/* solhint-disable no-inline-assembly */
/* solhint-disable reason-string */

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";

import "../core/BaseAccount.sol";
import "./callback/TokenCallbackHandler.sol";

/**
  * minimal account.
  *  this is sample minimal account.
  *  has execute, eth handling methods
  *  has a single signer that can send requests through the entryPoint.
  */
contract SimpleAccount is BaseAccount, TokenCallbackHandler, UUPSUpgradeable, Initializable {
    using ECDSA for bytes32;

    address public owner;

    IEntryPoint private immutable _entryPoint;

    event SimpleAccountInitialized(IEntryPoint indexed entryPoint, address indexed owner);

    modifier onlyOwner() {
        _onlyOwner();
        _;
    }

    /// @inheritdoc BaseAccount
    function entryPoint() public view virtual override returns (IEntryPoint) {
        return _entryPoint;
    }


    // solhint-disable-next-line no-empty-blocks
    receive() external payable {}

    constructor(IEntryPoint anEntryPoint) {
        _entryPoint = anEntryPoint;
        _disableInitializers();
    }

    function _onlyOwner() internal view {
        //directly from EOA owner, or through the account itself (which gets redirected through execute())
        require(msg.sender == owner || msg.sender == address(this), "only owner");
    }

    /**
     * execute a transaction (called directly from owner, or by entryPoint)
     */
    function execute(address dest, uint256 value, bytes calldata func) external {
        _requireFromEntryPointOrOwner();
        _call(dest, value, func);
    }

    /**
     * execute a sequence of transactions
     */
    function executeBatch(address[] calldata dest, bytes[] calldata func) external {
        _requireFromEntryPointOrOwner();
        require(dest.length == func.length, "wrong array lengths");
        for (uint256 i = 0; i < dest.length; i++) {
            _call(dest[i], 0, func[i]);
        }
    }

    /**
     * @dev The _entryPoint member is immutable, to reduce gas consumption.  To upgrade EntryPoint,
     * a new implementation of SimpleAccount must be deployed with the new EntryPoint address, then upgrading
      * the implementation by calling `upgradeTo()`
     */
    function initialize(address anOwner) public virtual initializer {
        _initialize(anOwner);
    }

    function _initialize(address anOwner) internal virtual {
        owner = anOwner;
        emit SimpleAccountInitialized(_entryPoint, owner);
    }

    // Require the function call went through EntryPoint or owner
    function _requireFromEntryPointOrOwner() internal view {
        require(msg.sender == address(entryPoint()) || msg.sender == owner, "account: not Owner or EntryPoint");
    }

    /// implement template method of BaseAccount
    function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash)
    internal override virtual returns (uint256 validationData) {
        bytes32 hash = userOpHash.toEthSignedMessageHash();
        if (owner != hash.recover(userOp.signature))
            return SIG_VALIDATION_FAILED;
        return 0;
    }

    function _call(address target, uint256 value, bytes memory data) internal {
        (bool success, bytes memory result) = target.call{value : value}(data);
        if (!success) {
            assembly {
                revert(add(result, 32), mload(result))
            }
        }
    }

    /**
     * check current account deposit in the entryPoint
     */
    function getDeposit() public view returns (uint256) {
        return entryPoint().balanceOf(address(this));
    }

    /**
     * deposit more funds for this account in the entryPoint
     */
    function addDeposit() public payable {
        entryPoint().depositTo{value : msg.value}(address(this));
    }

    /**
     * withdraw value from the account's deposit
     * @param withdrawAddress target to send to
     * @param amount to withdraw
     */
    function withdrawDepositTo(address payable withdrawAddress, uint256 amount) public onlyOwner {
        entryPoint().withdrawTo(withdrawAddress, amount);
    }

    function _authorizeUpgrade(address newImplementation) internal view override {
        (newImplementation);
        _onlyOwner();
    }
}

Simple Account Factory.sol

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.12;

import "@openzeppelin/contracts/utils/Create2.sol";
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";

import "./SimpleAccount.sol";

/**
 * A sample factory contract for SimpleAccount
 * A UserOperations "initCode" holds the address of the factory, and a method call (to createAccount, in this sample factory).
 * The factory's createAccount returns the target account address even if it is already installed.
 * This way, the entryPoint.getSenderAddress() can be called either before or after the account is created.
 */
contract SimpleAccountFactory {
    SimpleAccount public immutable accountImplementation;

    constructor(IEntryPoint _entryPoint) {
        accountImplementation = new SimpleAccount(_entryPoint);
    }

    /**
     * create an account, and return its address.
     * returns the address even if the account is already deployed.
     * Note that during UserOperation execution, this method is called only if the account is not deployed.
     * This method returns an existing account address so that entryPoint.getSenderAddress() would work even after account creation
     */
    function createAccount(address owner,uint256 salt) public returns (SimpleAccount ret) {
        address addr = getAddress(owner, salt);
        uint codeSize = addr.code.length;
        if (codeSize > 0) {
            return SimpleAccount(payable(addr));
        }
        ret = SimpleAccount(payable(new ERC1967Proxy{salt : bytes32(salt)}(
                address(accountImplementation),
                abi.encodeCall(SimpleAccount.initialize, (owner))
            )));
    }

    /**
     * calculate the counterfactual address of this account as it would be returned by createAccount()
     */
    function getAddress(address owner,uint256 salt) public view returns (address) {
        return Create2.computeAddress(bytes32(salt), keccak256(abi.encodePacked(
                type(ERC1967Proxy).creationCode,
                abi.encode(
                    address(accountImplementation),
                    abi.encodeCall(SimpleAccount.initialize, (owner))
                )
            )));
    }
}

해당 예시외에도 thirdweb의 예시 코드에는 지갑에 대한 코드가 종류별로 존재한다. (업그레이더블 지갑 버전, 업그레이더블이 없는 버젼)
https://github.com/thirdweb-dev/contracts/blob/main/contracts/prebuilts/account/utils/BaseAccount.sol

 

contracts/contracts/prebuilts/account/utils/BaseAccount.sol at main · thirdweb-dev/contracts

Collection of smart contracts deployable via thirdweb - thirdweb-dev/contracts

github.com

4. Paymaster

Paymaster의 경우 서명 로직에 맞게 검증 로직을 구현하는 부분을 수정하고

    function _validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost)
    internal virtual returns (bytes memory context, uint256 validationData);

트랜잭션이 실행 되고 난 후에 실행되는 함수인

    /**
     * post-operation handler.
     * (verified to be called only through the entryPoint)
     * @dev if subclass returns a non-empty context from validatePaymasterUserOp, it must also implement this method.
     * @param mode enum with the following options:
     *      opSucceeded - user operation succeeded.
     *      opReverted  - user op reverted. still has to pay for gas.
     *      postOpReverted - user op succeeded, but caused postOp (in mode=opSucceeded) to revert.
     *                       Now this is the 2nd call, after user's op was deliberately reverted.
     * @param context - the context value returned by validatePaymasterUserOp
     * @param actualGasCost - actual gas used so far (without this postOp call).
     */
    function _postOp(PostOpMode mode, bytes calldata context, uint256 actualGasCost) internal virtual {

        (mode,context,actualGasCost); // unused params
        // subclass must override this method if validatePaymasterUserOp returns a context
        revert("must override");
    }

해당 함수를 오버라이딩 해주어야 한다.

해당 부분들을 커스터마이징 하면 Bundler를 제외한 부분은 자체적으로 구현이 가능해진다.

*지난번에 계정 추상화 구현을 포기했다가 lukepark님의 도움으로 다시 정리를 해서 작성한 글입니다.

https://github.com/lukepark327

 

lukepark327 - Overview

I ❤️ AI & Blockchain. lukepark327 has 75 repositories available. Follow their code on GitHub.

github.com

 

728x90
반응형
Comments