일단 기본적으로, ERC-4337의 트랜잭션 제출부터 실행까지의 일련의 과정에서 User Operation이 사용자에 의해 대체 멤풀에 제출되면, Bundler는 제출된 User Operation에 대해 시뮬레이션 과정을 거친다.
Bundler는 시뮬레이션에 성공한 User Operation들을 Bundler Transaction의 형태로 EntryPoint에 제출한다.
EntryPoint는 UserOperation에 대한 유효성을 검증한 뒤 이를 실행한다. 이후 paymaster 지정 여부에 따라 paymaster, 혹은 EntryPoint가 Bundler에게 수수료를 환급하는 과정을 거친다.
다시 말해,
1. 사용자가 User Operation을 대체 멤풀에 제출
2. Bundler는 제출된 User Operation에 대해 시뮬레이션 과정 수행
3. Bundler 시뮬레이션에 성공한 User Operation들을 Bundle Transaction의 형태로 EntryPoint에 제출
4. EntryPoint는 User Operation에 대한 유효성을 검증한 뒤 이를 실행
5. 이후 paymaster 지정 여부에 따라 paymaster, 혹은 EntryPoint가 번들러에게 수수료를 환급하는 과정 수행
사용자는 어떠한 트랜잭션이 실행되어야 하는지를 User Operation에 채워 대체 멤풀에 제출한다.
Bundler는 사용자가 대체 멤풀에 제출한 User Operation들을 묶어 Bundle Transaction을 생성하며, 생성한 Bundle Transaction을 EntryPoint에 전송하는 역할을 수행한다.
트랜잭션을 전송하는 역할을 수행하는 과정에서 소모한 가스비를 환급받을 수 있을지 여부를 판단하기 위해 오프체인에서 User Operation에 대한 시뮬레이션을 진행한다.
Bundler는 시뮬레이션 이후 EntryPoint 컨트랙트의 handleOps 함수를 호출하는 과정을 통해, 생성한 Bundle Transaction을 제출한다.
* User Operation의 시뮬레이션 과정
User Operation의 실행방식은 검증 단계와 실행 단계로 나누어져 있다. User Operation이 정상적으로 실행 가능한지를 확인하기 위해 오프체인에서 모든 작업을 수행하는 것은 많은 자원이 필요하고, 특히 유효하지 않은 트랜잭션이 많다면 이를 검증하는 데 오랜 시간이 소요되기 때문이다.
Bundler는 User Operation이 검증 단계만 정상적으로 통과하면 수수료 환급을 받을 수 있다. 이는 실행 단계가 컨트랙트에서 별도로 실행되고, 실행 단계의 실패가 트랜잭션의 실패로 연결되지 않기 때문이다.
따라서 Bundler는 멤풀에 있는 User Operation에 대해 오프체인에서 통과 여부를 빠르게 검사하고, 통과된 User Operation만으로 Bundle Transaction을 생성한다.그렇다면 시뮬레이션 성공 여부과 상관없이, User Operation의 온체인 실행의 실패가 발생한다면?
* User Operation의 온체인 실행 실패에 대한 방지책
시뮬레이션은 성공했지만, 실제 온체인에서 User Operation실행이 실패하는 경우 Bundler는 금전적인 손실을 보게 된다. 따라서 시뮬레이션과 실제 온체인 제출 결과가 달라지지 않게 하는것이 중요하다.
예를 들어, 검증 단계에서 블록의 타임스탬프 값이 1000 이하일 때만 통과시키는 User Operation이 있다고 가정해보자. 시뮬레이션할때는 이와 같은 조건이 통과될 수 있지만, 실제 트랜잭션이 블록에 포함될 때의 타임스탬프가 1000보다 높을 경우 온체인에서는 해당 트랜잭션이 실패할 것이다.
이러한 상황을 방지하고나, EIP-4337에서는 시뮬레이션과 실제 실행 결과에 차이를 줄 수 있는 모든 정보에 대한 접근을 검증 단계에서 허용하지 않는다.
따라서 검증 단계에서는 Block Time, Block Number, Block Hash와 같은 Opcode를 사용할 수 없다.
이 뿐만 아니라, 검증 단계에서는 트랜잭션 송신자 주소와 연관된 스토리지만 접근할 수 있는 제약이 있다.만약 여러 개의 User Operation이 같은 스토리지에 접근할 수 있다면, 하나의 User Operation이 스토리지에 접근한 결과때문에 같은 스토리지를 참고하여 연산을 수행하는 다른 User Operation들이 유효하지 않게 바뀔 수 있기 때문이다. 이러한 제약 조건의 준수 여부는 시뮬레이션시 Bundler 노드가 검증해야 한다.
Bundler는 Bundle Transaction 제출을 위해 EntryPoint 컨트랙트의 handleOps()
함수를 호출한다.
이 함수는 내부적으로 User Operation이 유효한지 확인하고, User Operation을 제출한 계정으로부터 수수료를 받는다. 검증 단계에서 계정이 수수료를 먼저 지불하기 때문에, 실행 단계가 실패하더라도 Bundler는 수수료를 받을 수 있다. 검증이 끝난 Bundle Transaction은 EntryPoint에 의해 실행된다.
handleOps()
함수의 소스 코드function handleOps(UserOperation[] calldata ops, address payable beneficiary) public nonReentrant {
uint256 opslen = ops.length;
UserOpInfo[] memory opInfos = new UserOpInfo[](opslen);
unchecked {
for (uint256 i = 0; i < opslen; i++) {
UserOpInfo memory opInfo = opInfos[i];
(uint256 validationData, uint256 pmValidationData) = _validatePrepayment(i, ops[i], opInfo);
_validateAccountAndPaymasterValidationData(i, validationData, pmValidationData, address(0));
}
uint256 collected = 0;
emit BeforeExecution();
for (uint256 i = 0; i < opslen; i++) {
collected += _executeUserOp(i, ops[i], opInfos[i]);
}
_compensate(beneficiary, collected);
}
}
위 코드에서 USer Operation의 유효 여부, 즉 _validatePrePayment()
에서 account.validateUserOp()
가 호출될 때의 검증 로직을 각 사용자가 원하는 대로 설계하고 동작하도록 할 수 있다는 것이 중요하다.
예를 들어, 사용자는 이더리움 타원곡선 디지털 서명 알고리즘(ECDSA) 방식을 벗어나 다양한 서명 알고리즘을 활용할 수 있다. 다만 앞서 Bundler의 검증 과정에서도 언급했듯, 시뮬레이션과 실제 온체인 실행 결과가 달라지지 않도록 검증 단계에선 opcode와 스토리지 접근에 대한 제약사항이 존재한다. 또한 서로 다른 EntryPoint 컨트랙트, 혹은 서로 다른 체인에서 재사용 공격이 발생하는 것을 방지하기 위해 서명은 chainId와 EntryPoint 컨트랙트 주소에 종속되어야 한다.
contract EntryPoint {
function getUserOpHash(UserOperation calldata userOp) public view returns (bytes32) {
return keccak256(abi.encode(userOp.hash(), address(this), block.chainid));
}
}
// ref: https://www.youtube.com/watch?v=edPJaUYWlhY&list=LL&index=1
contract Test {
function testSignature(UserOpration memory op, EntryPoint entryPoint) public {
bytes32 userOpHash = entryPoint.getUserOpHash(op);
bytes32 signHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", userOpHash));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, signHash);
op.signautre = abi.encodePacked(r, s, v);
}
}
위 코드는 사용자가 User Operation에 대한 서명을 생성하는 방법의 예시이다.
사용자는, 지정한 EntryPoint의 getUserOpHash()
함수를 통해 User Operation과 EntryPoint 컨트랙트 주소, 그리고 chainId에 종속되는 서명을 생성할 수 있다.
앞서 언급했듯이, User Operation을 제출한 계정(Account Contract)은 사용자가 정의한 검증 로직을 사용할 수 있다. EntryPoint에서 사용자 계정의 validateUserOp()
함수를 호출하면, 계정은 User Operation을 받아 서명이 유효한지 확인한다. 서명이 유효하다면, missingAccountFunds
만큼의 ETH를 EntyPoint에 입금하여 가스비를 Bundler에게 지불한다. 이때 missingAccountFunds
보다 더 큰 ETH를 입금할 수 있고, 남은 금액은 다음 User Operation을 위해 사용된다. 계정은 특정 EntryPoinbt 컨트랙트를 지정하여 화이트리스트로 등록해야 하며, 화이트리스트로 등록된 EntryPoint만이 validateUserOp()
함수를 호출할 수 있다.
function validateUserOp(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external virtual override returns (uint256 validationData) {
_requireFromEntryPoint();
validationData = _validateSignature(userOp, userOpHash);
_validateNonce(userOp.nonce);
_payPrefund(missingAccountFunds);
}
또한 EntryPoint 컨트랙트는 paymaster의 동작을 호출하는 역할을 담당한다.
User Operation이 별도의 paymaster를 지정할 경우, 검증 단계에서 사용자에게 수수료를 징수하는 대신 paymaster의 수수료 지불 의사를 확인하고, 이를 허용할 경우 수수료를 받아 번들러에게 지급한다.
EntryPoint가 제출한 트랜잭션이 정상적으로 실행될 경우, paymaster는 지정된 로직을 통해 Bundler에게 수수료를 지급한다. 또한 수수료 지급 이후 실제 소모한 가스비 측정 등 사전에 정의한 동작을 수행할 수 있는데, 이를 postOp라고 한다.
paymaster는 여러 가지 형태로 구현될 수 있으며, 현재는 크게 두 가지 시나리오가 존재.
비자(Visa)는 작년부터 계정 추상화를 이용한 자동 결제 및 이더리움의 가스비를 비자 카드로 결제할 수 있는 방법을 고안해 왔으며, 8월 11일 비자가 구현해 배포한 페이마스터의 동작 구조와 과정을 설명한다.
비자는 사용자의 신용카드를 이용해 오프체인 상에서 가스비에 해당하는 금액을 결제한다.
결제가 정상적으로 완료될 경우, 비자의 페이마스터 웹 애플리케이션은 페이마스터의 서명을 사용자에게 반환한다.
사용자가 페이마스터의 서명과 함께 User Operation을 제출한다.
User Operation의 검증 단계에서 EntryPoint가 페이마스터의 가스비 지불 의사를 확인한다. 이 과정에서 페이마스터의 validatePaymasterUserOp()
함수를 호출
비자의 페이마스터는 온체인에서 사용자의 서명을 검증하고 가스비를 대신 지불한다. 이때 페이마스터는 충분한 수량의 ETH를 EntryPoint에 미리 입금을 해놓아야 한다.
[ 참고 ]
이 시나리오에서는 페이마스터가 계정으로부터 수수료에 상응하는 ERC-20 토큰을 수령한다.
handleOps()
함수가 호출되고, 페이마스터가 예치한 ETH가 충분한지 확인한다. ETH가 충분할 경우, 페이마스터의 validatePaymasterUserOp()
함수를 호출하여 사용자 대신 가스비를 지불할 의사가 있는지 확인한다.postOp()
함수에서 이루어 진다._executeUserOp()
에서는 UserOperation의 본집행(main execution)을 수행한다. 본집행이 정상적으로 종료될 경우 페이마스터의 postOp()
가 호출된다.postOp()
는 실제로 사용된 가스비만큼의 USDC를 계산하여 사용자의 계정으로부터 USDC를 전송받는 함수를 호출한다.
글 잘 읽었습니다. 감사합니다. 근데 사용자가 본인의 EOA 에 있는 자산에 대해 approve 를 했다는 의미는 이미 가스비 지불이 발생한 tx 를 수행했다는 뜻 아닌가요? approve 는 어떻게 했을까요? 궁금하네요...