‘이더리움 댑 개발’ 세미나 15-2. 오픈제플린 2
오픈제플린 컨트랙트는 안전한 스마트 컨트랙트 개발을 위한 라이브러리로, 크게 4개의 주제를 다룹니다.
- Access Control
- decide who can perform each of the actions on your system.
- Tokens
- create tradable assets or collectibles, like the well known ERC20 and ERC721 standards.
- Gas Station Network
- let your users interact with your contracts without having to pay for gas themselves.
- Utilities
- generic useful tools, including non-overflowing math, signature verification, and trustless paying systems.
다음과 같이 설치하고, 임포트해서 사용할 수 있습니다.
- npm install @openzeppelin/contracts
- import “@openzeppelin/contracts/token/ERC721/ERC721.sol”;
오플제플린 컨트랙트는 객체지향 언어에서 사용하는 기법들이나 패턴들을 사용해서 코드를 작성했습니다. 따라서 자바나 C#과 같은 객체지향 언어에 대한 경험이 있는 개발자들은 쉽게 접근할 수 있을 것입니다.
오픈제플린은 컨트랙트 작성자가 오픈제플린 컨트랙트를 상속해서 사용할 거라고 가정합니다.
- 상속과 관련된 솔리디티 키워드
- abstract, is, virtual, override, super
오픈제플린은 업그레이드 가능성을 지원하기 위해 기존 컨트랙트에 업그레이드 가능하도록 변경한 분리된 컨트랙트 패키지를 제공합니다. 또한 업그레이드 가능한 컨트랙트를 배포하기 위해 OpenZeppelin Upgrades Plugins를 제공합니다.
- you will need to use the Upgrade Safe variant of OpenZeppelin Contracts.
- npm install @openzeppelin/contracts-upgradeable
- It follows all of the rules for Writing Upgradeable Contracts: constructors are replaced by initializer functions, state variables are initialized in initializer functions, and we additionally check for storage incompatibilities across minor versions.
- 업그레이드 가능한 컨트랙트가 필요할 경우 기존 컨트랙트 사용은 다음과 같이 변경됩니다.
- import “@openzeppelin/contracts/token/ERC721/ERC721.sol”;
- import “@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol”;
- contract MyCollectible is ERC721
- contract MyCollectible is ERC721Upgradeable
- constructor() ERC721(“MyCollectible”, “MCO”) public
- function initialize() initializer public { __ERC721_init(“MyCollectible”, “MCO”); }
- import “@openzeppelin/contracts/token/ERC721/ERC721.sol”;
Access Control
Access Control은 권한과 관련된 컨트랙트 입니다.
- Access control—that is, “who is allowed to do this thing”—is incredibly important in the world of smart contracts. The access control of your contract may govern who can mint tokens, vote on proposals, freeze transfers, and many other things. It is therefore critical to understand how you implement it, lest someone else steals your whole system.
contracts/access 폴더에 위치하고, 다음과 같은 세 개의 컨트랙트를 포함합니다.
- Ownable
- AccessControl
- TimelockController (3.3.0에서 추가)
Ownable
이름 그대로 owner 권한을 설정할 수 있도록 하는 컨트랙트입니다. owner 권한을 가진 계정만 접근할 수 있는 메소드를 갖는 컨트랙트를 작성해야 한다면 이 컨트랙트를 상속받으면 됩니다.
- contract Ownable is Context
- Context를 상속받습니다.
- Context 컨트랙트
- 실행 컨텍스트에 대한 개념으로 트랜잭션 송신자와 트랜잭션 데이터를 포함합니다.
- 보통은 실행 컨텍스트에 대한 정보가 필요할 경우 msg.sender와 msg.data로 접근하는데, GSN은 수수료 대납에 대한 개념을 지원하는데 이를 지원하기 위해 오플제플린 컨트랙트는 msg.sender와 msg.data를 직접적으로 사용하지 않도록 하고 있습니다.
- contracts/GSN/ 폴더에 위치합니다.
- abstract contract Context
- 실행 컨텍스트 개념을 일반화한 것으로 추상 컨트랙트입니다.
- 추상 컨트랙트임으로 인스턴스를 생성할 수 없습니다.
- 솔리디티는 추상 컨트랙트에 abstract 키워드를 사용합니다.
- 추상 컨트랙트임으로 인스턴스를 생성할 수 없습니다.
- 실행 컨텍스트 개념을 일반화한 것으로 추상 컨트랙트입니다.
- 두 개의 internal 메소드를 갖습니다.
- function _msgSender() internal view virtual returns (address payable)
- 오픈제플린은 internal 메소드 이름에 ‘_’를 접두어로 사용합니다.
- 하위 컨트랙트에서 재정의할 수 있기 때문에 virtual로 작성합니다.
- 상태를 읽기만 하기 때문에 view로 작성합니다.
- address payable 타입 값을 리턴합니다.
- return msg.sender;
- 기본 구현은 msg.sender를 그대로 리턴합니다. msg.sender는 address payable 타입입니다. GSN 관련 컨트랙트에서 재정의될 것입니다.
- function _msgData() internal view virtual returns (bytes memory)
- bytes는 참조타입으로 데이터 위치를 명시해야 합니다.
-
12this;return msg.data;
- 기본 구현은 msg.data를 그대로 리턴합니다.
- this; 는 컴파일러 경고를 숨기기 위해 작성한 것입니다.
- function _msgSender() internal view virtual returns (address payable)
- 실행 컨텍스트에 대한 개념으로 트랜잭션 송신자와 트랜잭션 데이터를 포함합니다.
- Context 컨트랙트
- Context를 상속받습니다.
- Ownable 컨트랙트는 ‘owner 권한을 갖는’이라는 개념을 일반화한 컨트랙트입니다.
- 이 컨트랙트 또한 Context와 마찬가지로 컨트랙트 자체로 인스턴스를 생성하는 것은 의미가 없습니다. 추상 컨트랙트로 작성하는게 더 적당합니다.
-
12345constructor () internal {address msgSender = _msgSender();_owner = msgSender;emit OwnershipTransferred(address(0), msgSender);}
- 추상 컨트랙트는 인스턴스를 작성하지 않기 때문에 생성자를 internal로 작성합니다. 외부에서 직접적으로 생성할 수는 없고 하위 컨트랙트의 생성자에서 호출할 수 있도록만 하기 위해서 입니다.
- 생성자에서는 msgSender를 _owner로 설정하고 owner가 바뀌었다는 이벤트를 발생시킵니다.
- _msgSender()는 address payable을 리턴하고, _owner는 address 타입입니다. 암시적 형변환이 이루어집니다.
- msgSender라는 지역변수를 사용할 필요는 없어보입니다. 아래와 같이 작성하는 것으로도 충분해 보입니다.
- _owner = _msgSender();
emit OwnershipTransferred(address(0), _owner);
- _owner = _msgSender();
- address private _owner;
- _owner를 설정할 수 있는 속성(상태변수)입니다.
- event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
- owner가 바뀔 때마다 발생되는 이벤트입니다.
- previousOwner와 newOwner를 인덱스로 사용합니다.
-
123function owner() public view returns (address) {return _owner;}
- _owner 속성에 대한 get 메소드입니다.
-
1234modifier onlyOwner() {require(_owner == _msgSender(), "Ownable: caller is not the owner");_;}
- 메시지 송신자가 owner일 경우에만 실행될 수 있도록 제약하는 modifier입니다.
- 권한과 관련된 처리에 modifier를 사용할 수 있음을 보여주는 좋은 예시입니다.
-
12345function transferOwnership(address newOwner) public virtual onlyOwner {require(newOwner != address(0), "Ownable: new owner is the zero address");emit OwnershipTransferred(_owner, newOwner);_owner = newOwner;}
- owner를 변경하는 메소드입니다.
- owner 권한이 있을 때에만 owner를 변경할 수 있습니다. 즉 이전 owner만이 새로운 owner를 설정할 수 있다는 것입니다. 이를 위해 onlyOwner modifier를 사용합니다.
- 하위 컨트랙트에서 재정의할 수 있도록 virtual 키워드를 사용합니다.
- 새로 설정하는 owner는 0주소가 아니어야 합니다. owner 변경 이벤트를 발생시키고 새로운 owner를 설정합니다.
-
1234function renounceOwnership() public virtual onlyOwner {emit OwnershipTransferred(_owner, address(0));_owner = address(0);}
- owner를 0주소로 설정하는 메소드입니다. 0주소로 owner를 설정한다는 것은 누구도 owner가 될 수 없음을 의미합니다. 따라서 이 메소드가 호출되고 나면 owner 권한으로만 실행하도록 설정된 메소드는 더 이상 호출될 수 없게 됩니다.
- 초기의 특정 기간 동안에만 owner 권한이 필요한 경우에 사용할 수 있습니다.
Note that a contract can also be the owner of another one! This opens the door to using, for example, a Gnosis Multisig or Gnosis Safe, an Aragon DAO, an ERC725/uPort identity contract, or a totally custom contract that you create.
In this way you can use composability to add additional layers of access control complexity to your contracts. Instead of having a single regular Ethereum account (Externally Owned Account, or EOA) as the owner, you could use a 2-of-3 multisig run by your project leads, for example. Prominent projects in the space, such as MakerDAO, use systems similar to this one.
AccessControl
역할 기반으로 접근 권한을 제어할 수 있도록 하는 컨트랙트입니다.
- abstract contract AccessControl is Context
- Context를 상속받고 있고, 추상 컨트랙트입니다.
- mapping (bytes32 => RoleData) private _roles;
- 역할을 관리하기 위한 상태변수
- byte32 타입의 역할을 나타내는 식별자를 RoleData 구조체 값에 매핑
- 역할 별로 다수의 계정을 관리할 수 있도록 함
-
1234struct RoleData {EnumerableSet.AddressSet members;bytes32 adminRole;}
- 반복문에서 열거 가능한 EnumerableSet 라이브러리의 AddressSet 타입을 사용해서 역할을 부여 받는 계정을 관리
- 역할을 부여(grant)하거나 회수(revoke)하기 위한 관리자 그룹을 나타내는 역할 식별자
- bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00;
- 기본 관리자 그룹 역할에 식별자를 정의
- 관리자 그룹 역할이 하나만 필요하고, 별도의 관리자 그룹에 대한 역할을 정의하고 싶지 않을 때 이 역할 식별자를 사용할 수 있습니다.
- AccessControl을 상속받는 컨트랙트의 생성자에서 다음과 같이 _setupRole을 사용해서 설정할 수 있습니다. _setupRole에 대해서는 뒤에서 좀 더 자세히 설명합니다.
- _setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
- 기본 관리자 그룹 역할에 식별자를 정의
- bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00;
- using EnumerableSet for EnumerableSet.AddressSet;
- using for를 사용해서 EnumerableSet 라이브러리의 함수들을 EnumerableSet.AddressSet의 메소드인 것처럼(attached) 사용할 수 있게 합니다.
- 역할을 관리하기 위한 상태변수
- function grantRole(bytes32 role, address account) public virtual
- 계정에 역할을 부여합니다. 하위 컨트랙트에서 재정의 가능하도록 virtual로 작성합니다.
- require(hasRole(_roles[role].adminRole, _msgSender()), “AccessControl: sender must be an admin to grant”);
- msg sender가 역할을 부여할 수 있는 관리자 그룹 역할의 멤버여야 합니다.
- function hasRole(bytes32 role, address account) public view returns (bool)
- 계정에 역할이 부여되어 있는지를 확인합니다.
- _roles[role].members.contains(account);
- EnumerableSet.AddressSet에 정의된 contains 함수를 members의 메소드인 것처럼 사용합니다.
- using for 문이 이것을 가능하게 합니다.
- EnumerableSet.AddressSet에 정의된 contains 함수를 members의 메소드인 것처럼 사용합니다.
- _grantRole(role, account);
- 계정에 역할을 부여합니다.
- function _grantRole(bytes32 role, address account) private
- 하위 컨트랙트에서 직접 호출하지 못하도록 private입니다.
- _roles[role].members.add(account)
- 계정을 역할 멤버로 추가합니다.
- emit RoleGranted(role, account, _msgSender());
- 계정에 역할이 성공적으로 부여되면 RoleGranted 이벤트를 발생시킵니다.
- emit RoleGranted(role, account, _msgSender());
- event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender);
- function revokeRole(bytes32 role, address account) public virtual
- 계정에 부여된 역할을 취소합니다.
- function renounceRole(bytes32 role, address account) public virtual
- 계정에 부여된 역할을 취소합니다. revokeRole은 msg sender가 취소하고자 하는 역할에 대한 관리자 그룹 역할의 멤버여야 하는데, renounceRole은 msg sender가 역할을 취소하고자 하는 계정 자신이 된다는 점입니다.
- 실제적인 역할 취소는 revokeRole과 마찬가지로 _revokeRole을 사용합니다.
- function _setRoleAdmin(bytes32 role, bytes32 adminRole) internal virtual
- 하위 컨트랙트에서 직접 호출할 수 있도록 internal입니다. 일반적으로 하위 컨트랙트의 생성자에서 호출됩니다.
- _roles[role].adminRole = adminRole;
- 역할의 관리자 그룹 역할을 설정합니다.
- emit RoleAdminChanged(role, _roles[role].adminRole, adminRole);
- RoleAdminChanged 이벤트를 발생시킵니다.
- event RoleAdminChanged(bytes32 indexed role, bytes32 indexed previousAdminRole, bytes32 indexed newAdminRole);
- RoleAdminChanged 이벤트를 발생시킵니다.
- function _setupRole(bytes32 role, address account) internal virtual
- 역할에 대한 관리자 그룹 역할 없이 역할을 설정하고자 할 수도 있습니다. 하위 컨트랙트에서 직접 호출할 수 있도록 internal입니다. 보통은 하위 컨트랙트의 생성자에서 호출합니다.
- 사용 예: 네 번째 코드 블록
- function getRoleMemberCount(bytes32 role) public view returns (uint256)
- 역할 멤버 수를 리턴
- function getRoleMember(bytes32 role, uint256 index) public view returns (address)
- 역할이 부여된 멤버들 중에 index에 해당하는 계정을 리턴
- 사용 예: 다섯 번째 코드 블록
- function getRoleAdmin(bytes32 role) public view returns (bytes32)
- 역할에 대한 관리자그룹 역할을 리턴
TimelockControler
TimelockController는 타임락을 가지고 컨트랙트를 제어할 수 있도록 합니다. 일반적인 사용 사례는 이 TimelockController를 컨트랙트의 owner로 설정하는 것입니다.
- contract TimelockController is AccessControl
- AccessControl을 상속 받습니다.
- bytes32 public constant TIMELOCK_ADMIN_ROLE = keccak256(“TIMELOCK_ADMIN_ROLE”);
bytes32 public constant PROPOSER_ROLE = keccak256(“PROPOSER_ROLE”);
bytes32 public constant EXECUTOR_ROLE = keccak256(“EXECUTOR_ROLE”);
- 타임락 제어를 위해 요구되는 timelock admin, proposer, executor 역할을 나타내는 상수 값을 설정합니다.
- uint256 internal constant _DONE_TIMESTAMP = uint256(1);
- 타임락된 오퍼레이션의 실행이 완료되었음을 나타내는 타임스탬프 상수 값을 설정합니다. 1로 설정합니다.
- mapping(bytes32 => uint256) private _timestamps;
- 오퍼레이션과 타임락(지연시간)을 매핑합니다.
- 오퍼레이션의 타임락 설정(schedule, scheduleBatch), 타임락 설정 취소(cancel), 오퍼레이션 실행(execute, executeBatch)과 관련된 상태 입니다.
- getTimestamp로 오퍼레이션 id에 해당하는 timestamp를 구할 수 있습니다.
- uint256 private _minDelay;
- 지연되는 최소 시간을 나타냅니다.
- updateDelay로 변경할 수 있습니다.
- getMinDelay로 해당 값을 구할 수 있습니다.
- CallScheduled, CallExecuted, Cancelled, MinDelayChange
- 특정 오퍼레이션에 대해 타임락을 설정했을 때, 타임락된 행위가 실행되었을 때, 타임락 설정이 취소되었을 때, 지연 시간을 변경했을 때 발생하는 이벤트를 선언합니다.
- constructor(uint256 minDelay, address[] memory proposers, address[] memory executors) public
- timelock admin, proposer, executor 역할을 부여하거나 회수할 수 있는 관리자 역할을 설정합니다. timelock admin으로 모든 역할에 대한 관리자 역할로 설정됩니다.
- _setRoleAdmin(TIMELOCK_ADMIN_ROLE, TIMELOCK_ADMIN_ROLE);
_setRoleAdmin(PROPOSER_ROLE, TIMELOCK_ADMIN_ROLE);
_setRoleAdmin(EXECUTOR_ROLE, TIMELOCK_ADMIN_ROLE);
- _setRoleAdmin(TIMELOCK_ADMIN_ROLE, TIMELOCK_ADMIN_ROLE);
- timelock admin 역할로 배포자와 컨트랙트 자체를 설정합니다. 컨트랙트는 자기 자신을 자체적으로 관리할 수 있습니다.
- _setupRole(TIMELOCK_ADMIN_ROLE, _msgSender());
_setupRole(TIMELOCK_ADMIN_ROLE, address(this));
- _setupRole(TIMELOCK_ADMIN_ROLE, _msgSender());
- proposers와 executors 역할을 설정합니다.
- 최소 지연시간을 설정합니다.
- MinDelayChange 이벤트를 발생시킵니다.
- timelock admin, proposer, executor 역할을 부여하거나 회수할 수 있는 관리자 역할을 설정합니다. timelock admin으로 모든 역할에 대한 관리자 역할로 설정됩니다.
- modifier onlyRole(bytes32 role)
- msg.sender나 0주소가 해당 역할로 설정된 경우에만 유효한 것으로 제약하는 modifier입니다.
- 0주소로 해당 역할을 설정했다는 것은 누구나 그 역할을 수행할 수 있음을 의미합니다.
- msg.sender나 0주소가 해당 역할로 설정된 경우에만 유효한 것으로 제약하는 modifier입니다.
- receive() external payable
- Contract might receive/hold ETH as part of the maintenance process.
- 스케줄링 하려면 등록된 오퍼레이션인지 확인할 수 있어야 합니다.
- isOperation
- 타임스탬프가 0보다 크면 타임락으로 스케줄된 오퍼레이션입니다.
- isOperation
- 스케줄링된 오퍼레이션은 Pending, Ready, Done 상태를 갖습니다.
- 타임스탬프가 _DONE_TIMESTAMP보다 크면 Pending된 상태입니다.
- isOperationPending
- 타임스탬프가 _DONE_TIMESTAMP보다 크면 Pending된 상태입니다.
-
- 타임스탬프가 _DONE_TIMESTAMP보다 크고 block.timestamp 보다 작거나 같으면 Ready 상태입니다.
- isOperationReady
- // solhint-disable-next-line not-rely-on-time
- block.timestamp 사용에 대한 경고를 disable하기 위한 것입니다.
- 타임스탬프가 _DONE_TIMESTAMP와 같으면 Done 상태입니다.
- isOperationDone
- 타임스탬프가 _DONE_TIMESTAMP보다 크고 block.timestamp 보다 작거나 같으면 Ready 상태입니다.
- 스케줄링된 오퍼레이션을 실행합니다.
- execute
- 오퍼레이션을 실행하려면 msg.sender의 역할이 executor로 등록되어 있어야 하고 합니다.
- 오퍼레이션 실행은 target 주소에 해당하는 컨트랙트 함수를 호출하는 것입니다. 호출 데이터는 data로 받습니다.
- (bool success,) = target.call{value: value}(data);
- // solhint-disable-next-line avoid-low-level-calls
- 저수준 call을 사용하는데 대한 컴파일 경고를 disable하기 위한 것입니다.
- 실행할 오퍼레이션을 구하기 위해 오퍼레이션 id를 구할 수 있어야 합니다.
- 오퍼레이션 id는 target 주소, 전송 이더 수량, 선행 오퍼레이션, salt 값을 가지고 생성되는 해시 값입니다.
- hashOperation(target, value, data, predecessor, salt);
- 오퍼레이션 id는 target 주소, 전송 이더 수량, 선행 오퍼레이션, salt 값을 가지고 생성되는 해시 값입니다.
- 워크플로어 내의 오퍼레이션 이라면 선행 오퍼레이션이 있을 수 있습니다.
- 선행 오퍼레이션이 없거나 선행 오퍼레이션이 있다면 완료된 상태여야 합니다.
- 오퍼레이션 실행 후에는 Ready 상태여야 하고, 타임 스탬프를 _DONE_TIMESTAMP로 설정합니다.
- 스케줄링과 실행은 배치 방식을 적용할 수 있어야 합니다.
- scheduleBatch, executeBatch
- execute