Biconomy Smart Account is a smart contract wallet that builds on core concepts of Gnosis / Argent safes and implements an interface to support calls from account abstraction Entry Point contract. We took all the the good parts of existing smart contract wallets.
These smart wallets have a single owner (1/1 Multisig) and are designed in such a way that it is
이더리움에서 Account는 두 종류로 나뉜다.
- Externally Owned Account : 메타마스크를 사용하는 일반적인 유저
- Contract Account : 스마트컨트랙트
EIP-4337(Account Abstraction)의 주요 골자는 두 종류로 나뉘는 Account를 하나로 추상화하자는 얘기다. 그렇다면 왜 이런 제안이 나온걸까? 현재 EOA의 경우, private key를 통해 트랜잭션에 서명을 한다. 이러한 과정을 통해 자신이 해당 Account의 소유주임을 증명하는 것이다. 그런데 만약 소유주가 private key를 분실한다면 소유주임을 증명할 수 없고 지갑에 있던 자산들은 모두 분실된다.
또 다른 문제점은 지갑 디자인의 한계점이다. 지갑에서 서명을 생성하는 로직은 이더리움 프로토콜 단에서 ECDSA 방식을 활용한다. 하지만 해당 방식은 양자 컴퓨터에 취약하기 때문에 더 다양한 알고리즘을 활용할 필요가 있다.
EIP-4337(Account Abstraction)을 통해 얻는 장점은 무엇일까? 추상화를 위해서는 이더리움 프로토콜에 하드코딩 되어있는 트랜잭션의 검증 로직을 스마트 컨트랙트로 수행할 수 있도록 변경해야 한다.(조금 더 자세한 내용은 jeff lee님의 글을 참고하자) 이러한 변경을 거치면 다양한 시도가 가능하다. EOA만 지갑으로 사용하던 방식에서 스마트컨트랙트를 지갑으로 사용할 수 있도록 디자인 할 수 있고, 각 Account의 검증 로직을 커스텀 할 수도 있다. 심지어 ETH가 아닌 다른 토큰으로 가스비를 내도록 할 수 있으며, 미리 컨트랙트 지갑에 가스비로 사용되는 토큰을 넣어놔서 사용자가 가스비를 지불하지 않도록 만들 수도 있다!
이를 통해 Biconomy에서는 Smart Contract Wallet(SCW)을 활용하는 SDK를 제공한다. 유저는 자신의 EOA로 SCW를 생성하고, SCW를 통해 dApp을 사용한다. 실제 dApp에 사용하는 지갑과 유저가 소유하고 있는 EOA를 분리함으로써 보안 측면에서 강점을 제공한다. 또한 SCW를 통해 사용자는 dApp 내에서 Custom Transaction Bundling, Gasless transactions 등 다양한 이점을 얻을 수 있다.
//SmartAccount.sol
function execute(address dest, uint value, bytes calldata func) external onlyOwner{
_requireFromEntryPointOrOwner();
_call(dest, value, func);
}
function executeBatch(address[] calldata dest, bytes[] calldata func) external onlyOwner{
_requireFromEntryPointOrOwner();
require(dest.length == func.length, "wrong array lengths");
for (uint i = 0; i < dest.length;) {
_call(dest[i], 0, func[i]);
unchecked {
++i;
}
}
}
같은 컨트랙트에서 execute
와 executeBatch
는 모두 _call
함수를 호출하게 되어있다. 하지만 executeBatch
를 통해 호출할 경우 _call
함수의 value 값이 0으로 하드코딩 된다. 이는 향후 value 값을 사용하는 dApp을 이용하지 못하는 것으로 이어진다. 아래와 같은 상황을 생각해보자.
deposit
함수가 있다고 가정해보자.(예시를 위해 간단한 함수를 작성했다.)function deposit(uint256 _value) public {
bool success = msg.sender.call{value: _value}("");
require(success, "Failed to send value");
balances[msg.sender] += _value;
}
executeBatch
를 통해 deposit
함수를 호출한다._call
함수의 value 값이 0으로 하드코딩 되어있기 때문에 유저는 입금할 수 없다.따라서 _call
함수의 value 값이 0으로 하드코딩 되지 않고 다른 프로젝트의 함수를 호출할 수 있도록 수정이 필요하다.
function execute(address dest, uint value, bytes calldata func) external onlyOwner{
_requireFromEntryPointOrOwner();
_call(dest, value, func);
}
function execFromEntryPoint(address dest, uint value, bytes calldata func, Enum.Operation operation, uint256 gasLimit) external onlyEntryPoint returns (bool success) {
success = execute(dest, value, func, operation, gasLimit);
require(success, "Userop Failed");
}
execFromEntryPoint
는 entrypoint만 호출할 수 있는 함수다. entrypoint가 해당 함수를 호출하면 execute
를 호출하게 되어있다. 그런데 execute
는 owner만 호출할 수 있다. 함수 안에 _requireFromEntryPointOrOwner();
가 있어서 entrypoint도 호출할 수 있을 것 같지만 execute
는 onlyOwner modifier를 사용하고 있기 때문에 owner가 아니면 호출할 수 없다.
modifier onlyOwner {
require(msg.sender == owner, "Smart Account:: Sender is not authorized");
_;
}
modifier onlyEntryPoint {
require(msg.sender == address(entryPoint()), "wallet: not from EntryPoint");
_;
}
따라서 execFromEntryPoint
는 항상 revert될 것이며, 다음과 같이 mixedAuth
modifier를 사용하도록 수정해야 한다. _requireFromEntryPointOrOwner();
를 삭제하더라도 owner는mixedAuth
를 통해 접근할 수 있다.
function execute(address dest, uint value, bytes calldata func) external mixedAuth{
_call(dest, value, func);
}
//SmartAccountFactory.sol
function deployCounterFactualWallet(address _owner, address _entryPoint, address _handler, uint _index) public returns(address proxy){
bytes32 salt = keccak256(abi.encodePacked(_owner, address(uint160(_index))));
bytes memory deploymentData = abi.encodePacked(type(Proxy).creationCode, uint(uint160(_defaultImpl)));
// solhint-disable-next-line no-inline-assembly
assembly {
proxy := create2(0x0, add(0x20, deploymentData), mload(deploymentData), salt)
}
require(address(proxy) != address(0), "Create2 call failed");
// EOA + Version tracking
emit SmartAccountCreated(proxy,_defaultImpl,_owner, VERSION, _index);
BaseSmartAccount(proxy).init(_owner, _entryPoint, _handler);
isAccountExist[proxy] = true;
}
function deployWallet(address _owner, address _entryPoint, address _handler) public returns(address proxy){
bytes memory deploymentData = abi.encodePacked(type(Proxy).creationCode, uint(uint160(_defaultImpl)));
// solhint-disable-next-line no-inline-assembly
assembly {
proxy := create(0x0, add(0x20, deploymentData), mload(deploymentData))
}
BaseSmartAccount(proxy).init(_owner, _entryPoint, _handler);
isAccountExist[proxy] = true;
}
사용자는 deployCounterFactualWallet
또는 deployWallet
을 이용해 지갑을 생성한다. 하지만 현재 지갑이 이미 존재하는지 체크하는 코드가 없으며 항상 isAccountExist[proxy]
에 true를 전달하기 때문에 지갑은 중복되어 생성될 수 있다. Attacker는 이를 활용해 사용자가 만들기 전에 지갑을 미리 만들어 놓을 수 있다.
이를 방지하기 위해 현재 지갑이 이미 존재하는지 확인하는 코드가 필요하다.
require(isAccountExist[proxy] != true, "Address is aleary exist")
v,r,s는 서명의 결과물이라고 할 수 있다. 이 값들을 통해 Account의 public key를 얻을 수 있다. 이더리움에서는 ECDSA 방식을 사용해 서명(Signature)을 생성한다. 이 때 생성된 v
는 recovery id를 말하며 r
과 s
는 ECDSA 결과값을 말한다.