티스토리 뷰
Governor Smart Contract
Governor는 온체인 거버넌스에 필요한 로직을 구현한 컨트랙트입니다. OpenZeppelin 공식문서에 정리가 잘 되어있기는 하지만, 전체적인 동작 과정에 대한 설명이 부족한 것 같아서 이에 대해서 한 번 정리해보고자 합니다.
OpenZeppelin Wizard로 컨트랙트 구성하기
OpenZeppelin Wizard를 사용하여 OpenZeppelin에서 제공하는 토큰 및 거버넌스 관련된 컨트랙트를 빠르고 쉽게 구성할 수 있습니다.
Governor 컨트랙트
Governor 컨트랙트의 세팅은 다음과 같이 구성되어 있습니다.
- Name: 컨트랙트의 이름
- Voting Delay: 제안(proposal)이 생성되고 투표가 이루어지기까지의 딜레이
- Voting Period: 투표 기간
- Proposal Threshold: 제안자가 제안을 생성하기 위해 가지고 있어야 할 최소한의 투표권
- Quorum: 제안이 통과되기 위해 필요한 정족수
- Updatable Settings: 거버넌스를 통해서 설정(딜레이, 투표 기간, 임계값)을 변경 가능
- Storage: 제안의 상세정보를 확인할 수 있도록 스토리지에 저장 가능
- Votes: 투표권으로 사용할 토큰 표준 (ERC-20, ERC-721)
- Token Clock Mode: EIP-6372에 따라 컨트랙트 상에서 시간을 표현할 방식을 선택. (블록 번호, 타임스탬프)
- Block Number: 1개의 블록이 평균 12초에 생성되므로 1일의 시간은 7200개의 블록으로 표현
- Timestamp: 1일의 시간을 표현하려면 86400 또는 1 days로 표현
- Timelock: 거버넌스 작업이 실행되기까지 일정한 타임락을 걸어주는 컨트랙트
- Upgradability: 거버넌스 컨트랙트가 업그레이드 가능한지 여부
설정값이 굉장히 많은데 일단은 Timelock 설정의 체크만 해제하고 기본적으로 선택되어 있는 값들을 사용해 보도록 하겠습니다.
투표를 위한 ERC-20 토큰 컨트랙트
ERC-20 토큰 컨트랙트의 세팅은 다음과 같이 구성되어 있습니다.
- Name: 토큰의 이름
- Symbol: 토큰의 심벌
- Premint: 컨트랙트 배포와 함께 배포자에게 민팅할 토큰의 수량
- Features: 토큰 컨트랙트의 기능을 확장하기 위한 특성들
- Votes: 거버넌스 투표에 사용할 수 있도록 토큰 컨트랙트의 기능 확장 여부
- Access Control: 컨트랙트 제어 권한
- Upgradability: 업그레이드가 가능한지 여부
여기서는 기본 설정에 더해 Features에서 Mintable을 선택, Votes에서 Block Number 그리고 Access Control에서 Ownable을 선택해 줍니다.
Foundry 프로젝트 생성
다음 명령어를 통해 foundry 프로젝트를 초기화합니다. foundry가 설치되어 있지 않으시다면 새로 설치를 하셔야 합니다.
$ forge init <프로젝트명>
다음으로 OpenZeppelin 라이브러리를 설치합니다.
$ forge install OpenZeppelin/openzeppelin-contracts --no-commit
설치된 라이브러리를 프로젝트에서 인식할 수 있게 프로젝트의 루트 디렉터리에 있는 foundry.toml 파일에 다음과 같이 remapping 정보를 추가해 줍니다.
remappings = ["@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/"]
다음으로 src 폴더 안에 MyGovernor.sol 파일을 생성하여 방금 전에 Wizard를 사용해서 생성한 컨트랙트를 붙여 넣습니다.
// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/governance/Governor.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorSettings.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorCountingSimple.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol";
contract MyGovernor is
Governor,
GovernorSettings,
GovernorCountingSimple,
GovernorVotes,
GovernorVotesQuorumFraction
{
constructor(IVotes _token)
Governor("MyGovernor")
GovernorSettings(7200, /* 1 day */ 50400, /* 1 week */ 0)
GovernorVotes(_token)
GovernorVotesQuorumFraction(4)
{}
// The following functions are overrides required by Solidity.
...
}
Governor 컨트랙트를 추가한 다음은 ERC-20 토큰 컨트랙트 차례입니다. src 폴더 안에 MyToken.sol 파일을 만들어주고 Wizard에서 생성한 토큰 컨트랙트를 붙여 넣습니다.
// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
contract MyToken is ERC20, Ownable, ERC20Permit, ERC20Votes {
constructor(address initialOwner) ERC20("MyToken", "MTK") Ownable(initialOwner) ERC20Permit("MyToken") {}
...
}
추가한 컨트랙트가 정상적으로 빌드되는지 확인하고 프로젝트 초기화를 마무리합니다.
$ forge build
[⠒] Compiling...
[⠔] Compiling 1 files with 0.8.24
[⠒] Solc 0.8.24 finished in 1.30s
Compiler run successful!
Foundry 테스트 코드 초기 설정
test 디렉터리 안에 MyGovernor.t.sol 파일을 생성해 줍시다. 그리고 Counter.t.sol 파일은 필요 없으니 삭제해 주세요.
MyGovernor.t.sol 파일에는 다음과 같이 테스트 코드 초기 설정을 작성해 줍니다.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.23;
import {Test, console} from "forge-std/Test.sol";
import {MyGovernor, IGovernor} from "../src/MyGovernor.sol";
import {MyToken} from "../src/MyToken.sol";
import {Counter} from "../src/Counter.sol";
contract MyGovernorTest is Test {
uint256 public constant VOTING_DELAY = 7200; // 1 day
uint256 public constant VOTING_PERIOD = 50400; // 1 week
uint256 public constant VOTER_BALANCE = 10 ether;
MyGovernor public governor;
MyToken public token;
Counter public counter;
address public deployer;
address public proposer;
address public voter;
function setUp() public {
// address 초기화
deployer = makeAddr("deployer");
proposer = makeAddr("proposer");
voter = makeAddr("voter");
vm.label(deployer, "deployer");
vm.label(proposer, "proposer");
vm.label(voter, "voter");
// deployer가 governor, token, counter를 배포
vm.startPrank(deployer);
token = new MyToken(deployer);
governor = new MyGovernor(token);
counter = new Counter();
vm.label(address(token), "token");
vm.label(address(governor), "governor");
vm.label(address(counter), "counter");
// voter에게 VOTER_BALANCE만큼 토큰을 mint
token.mint(voter, VOTER_BALANCE);
vm.stopPrank();
// 투표권을 위임하기 전에 voter의 토큰 상태 확인
assertEq(token.getVotes(voter), 0);
// voter가 자신에게 투표권을 위임
vm.prank(voter);
token.delegate(voter);
assertEq(token.balanceOf(voter), VOTER_BALANCE);
assertEq(token.delegates(voter), voter);
assertEq(token.getVotes(voter), VOTER_BALANCE);
}
}
테스트 코드 상에는 세 개의 EOA와 세 개의 CA가 존재합니다.
- EOA
- deployer: 세 개의 컨트랙트를 배포하고 voter에게 토큰을 민팅하는 역할
- proposer: 새로운 제안을 생성하는 역할
- voter: 투표를 실행하는 역할
- CA
- governor: 거버넌스 로직을 구현한 컨트랙트
- token: 투표에 사용되는 토큰 컨트랙트
- counter: 제안을 생성할 때 사용되는 컨트랙트 (foundry 프로젝트 초기화 시에 생성됨)
deployer는 먼저 세 개의 컨트랙트를 배포합니다. 그리고 voter에게 VOTER_BALANCE 만큼의 토큰을 민팅합니다.
// deployer가 governor, token, counter를 배포
vm.startPrank(deployer);
token = new MyToken(deployer);
governor = new MyGovernor(token);
counter = new Counter();
vm.label(address(token), "token");
vm.label(address(governor), "governor");
vm.label(address(counter), "counter");
// voter에게 VOTER_BALANCE만큼 토큰을 mint
token.mint(voter, VOTER_BALANCE);
vm.stopPrank();
그러고 나면 voter는 자기 자신에게 투표권을 위임합니다. 이 과정이 이루어지지 않는다면 voter가 토큰을 가지고 있더라도 표수는 0이 되므로 정상적인 투표를 진행할 수 없게 됩니다.
// 투표권을 위임하기 전에 voter의 토큰 상태 확인
assertEq(token.getVotes(voter), 0);
// voter가 자신에게 투표권을 위임
vm.prank(voter);
token.delegate(voter);
assertEq(token.balanceOf(voter), VOTER_BALANCE);
assertEq(token.delegates(voter), voter);
assertEq(token.getVotes(voter), VOTER_BALANCE);
테스트 코드 초기 설정은 이것으로 마치고 이제 테스트로 넘어가 Governor 컨트랙트의 동작 과정을 살펴보겠습니다.
제안 생성하기 - propose
IGovernor.sol
제안을 생성하기 위해서는 propose 함수를 호출해야 합니다. 함수의 매개변수는 다음과 같습니다.
- targets: address 타입의 배열, 호출할 대상을 지정
- values: uint256 타입의 배열, target을 호출할 때 전송할 네이티브 재화의 수량
- calldatas: bytes 타입의 배열, target을 호출할 때 사용할 calldata
- description: string 타입, 제안에 대한 간단한 설명
/**
* @dev Create a new proposal. Vote start after a delay specified by {IGovernor-votingDelay} and lasts for a
* duration specified by {IGovernor-votingPeriod}.
*
* Emits a {ProposalCreated} event.
*/
function propose(address[] memory targets, uint256[] memory values, bytes[] memory calldatas, string memory description)
external
returns (uint256 proposalId);
Governor.sol
propose 함수 내부에서 실행되어 제안을 생성하는 _propose 함수의 구현은 다음과 같습니다.
- targets, values 그리고 calldatas의 길이가 동일해야 합니다. (call을 사용하여 target에 대한 저수준 호출을 실행하므로)
- proposalId와 매핑된 데이터의 voteStart가 0이어야 합니다. 그렇지 않은 경우는 이미 존재하는 proposalId로 간주합니다.
- snapshot은 제안의 투표가 시작되는 시간, duration은 투표가 진행되는 기간을 의미하며 이 값들을 사용해 proposald와 매핑된 스토리지 데이터를 갱신합니다.
- ProposalCreated 이벤트를 내보냅니다.
function _propose(
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas,
string memory description,
address proposer
) internal virtual returns (uint256 proposalId) {
proposalId = hashProposal(targets, values, calldatas, keccak256(bytes(description)));
if (targets.length != values.length || targets.length != calldatas.length || targets.length == 0) {
revert GovernorInvalidProposalLength(targets.length, calldatas.length, values.length);
}
if (_proposals[proposalId].voteStart != 0) {
revert GovernorUnexpectedProposalState(proposalId, state(proposalId), bytes32(0));
}
uint256 snapshot = clock() + votingDelay();
uint256 duration = votingPeriod();
ProposalCore storage proposal = _proposals[proposalId];
proposal.proposer = proposer;
proposal.voteStart = SafeCast.toUint48(snapshot);
proposal.voteDuration = SafeCast.toUint32(duration);
emit ProposalCreated(
proposalId,
proposer,
targets,
values,
new string[](targets.length),
calldatas,
snapshot,
snapshot + duration,
description
);
// Using a named return variable to avoid stack too deep errors
}
MyGovernor.t.sol
위의 내용을 기반으로 counter의 increment 함수를 호출하는 제안을 생성하는 테스트 코드를 다음과 같이 작성할 수 있습니다.
function test_Propose() public {
address[] memory targets = new address[](1);
uint256[] memory values = new uint256[](1);
bytes[] memory calldatas = new bytes[](1);
string memory description = "Increment counter";
targets[0] = address(counter);
values[0] = 0;
calldatas[0] = abi.encodeWithSignature("increment()");
bytes32 descriptionHash = keccak256(abi.encodePacked(description));
// proposalId 계산
uint256 proposalId = governor.hashProposal(targets, values, calldatas, descriptionHash);
uint256 voteStart = block.timestamp + VOTING_DELAY;
uint256 voteEnd = voteStart + VOTING_PERIOD;
// 발생할 이벤트 예상
vm.expectEmit(true, true, true, true);
emit IGovernor.ProposalCreated(
proposalId, proposer, targets, values, new string[](1), calldatas, voteStart, voteEnd, description
);
// proposer가 proposal을 생성
vm.prank(proposer);
governor.propose(targets, values, calldatas, description);
// proposal 상태 확인
assertEq(uint256(governor.state(proposalId)), uint256(IGovernor.ProposalState.Pending));
assertEq(governor.proposalProposer(proposalId), proposer);
assertEq(governor.proposalSnapshot(proposalId), voteStart);
assertEq(governor.proposalDeadline(proposalId), voteEnd);
}
$ forge test --mt test_Propose
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/MyGovernor.t.sol:MyGovernorTest
[PASS] test_Propose() (gas: 85969)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.30ms (426.30µs CPU time)
Ran 1 test suite in 5.90ms (2.30ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
투표하기 - castVote
IGovernor.sol
투표는 castVote로 시작하는 여러 개의 함수가 존재하는데, castVote만 살펴보도록 하겠습니다. castVote의 매개변수는 proposalId와 찬반여부를 나타내는 uint8 타입의 spport입니다.
/**
* @dev Cast a vote
*
* Emits a {VoteCast} event.
*/
function castVote(uint256 proposalId, uint8 support) external returns (uint256 balance);
Governor.sol
/**
* @dev See {IGovernor-castVote}.
*/
function castVote(uint256 proposalId, uint8 support) public virtual returns (uint256) {
address voter = _msgSender();
return _castVote(proposalId, voter, support, "");
}
castVote 함수 내부에서 실행되는 _castVote 함수의 실행 과정은 다음과 같습니다.
- 제안의 상태가 Active인지 확인합니다. 즉, 투표가 시작되었고 아직 마감되지 않은 상태여야 합니다.
- _getVotes 함수를 통해 투표가 시작된 시점의 투표자의 표수를 가져옵니다.
- _countVote 함수를 호출하여 투표 상태를 갱신합니다.
- VoteCast 이벤트를 내보냅니다.
/**
* @dev Internal vote casting mechanism: Check that the vote is pending, that it has not been cast yet, retrieve
* voting weight using {IGovernor-getVotes} and call the {_countVote} internal function. Uses the _defaultParams().
*
* Emits a {IGovernor-VoteCast} event.
*/
function _castVote(uint256 proposalId, address account, uint8 support, string memory reason)
internal
virtual
returns (uint256)
{
return _castVote(proposalId, account, support, reason, _defaultParams());
}
/**
* @dev Internal vote casting mechanism: Check that the vote is pending, that it has not been cast yet, retrieve
* voting weight using {IGovernor-getVotes} and call the {_countVote} internal function.
*
* Emits a {IGovernor-VoteCast} event.
*/
function _castVote(uint256 proposalId, address account, uint8 support, string memory reason, bytes memory params)
internal
virtual
returns (uint256)
{
_validateStateBitmap(proposalId, _encodeStateBitmap(ProposalState.Active));
uint256 weight = _getVotes(account, proposalSnapshot(proposalId), params);
_countVote(proposalId, account, support, weight, params);
if (params.length == 0) {
emit VoteCast(account, proposalId, support, weight, reason);
} else {
emit VoteCastWithParams(account, proposalId, support, weight, reason, params);
}
return weight;
}
MyGovernor.t.sol
앞서 생성한 제안에 대해 투표를 실행하는 테스트 코드를 다음과 같이 작성할 수 있습니다.
function test_CastVote() public {
address[] memory targets = new address[](1);
uint256[] memory values = new uint256[](1);
bytes[] memory calldatas = new bytes[](1);
string memory description = "Increment counter";
targets[0] = address(counter);
values[0] = 0;
calldatas[0] = abi.encodeWithSignature("increment()");
// proposer가 proposal을 생성
vm.prank(proposer);
uint256 proposalId = governor.propose(targets, values, calldatas, description);
/*
[support]
0: Against
1: For
2: Abstain
*/
// voter가 투표, 그러나 투표 기간이 아님
vm.expectRevert();
vm.prank(voter);
governor.castVote(proposalId, 1);
// 투표 기간으로 이동
vm.roll(block.number + VOTING_DELAY + 1);
vm.warp(block.timestamp + (VOTING_DELAY + 1) * 12);
assertEq(uint256(governor.state(proposalId)), uint256(IGovernor.ProposalState.Active));
// 정족수 확인
uint256 quorum = governor.quorum(block.number - 1);
// voter가 투표
vm.expectEmit(true, true, true, true);
emit IGovernor.VoteCast(voter, proposalId, 1, VOTER_BALANCE, "");
vm.prank(voter);
uint256 weight = governor.castVote(proposalId, 1);
// 투표 상태 확인
assertEq(weight, VOTER_BALANCE);
assertEq(governor.hasVoted(proposalId, voter), true);
(uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) = governor.proposalVotes(proposalId);
assertEq(againstVotes, 0);
assertEq(forVotes, VOTER_BALANCE);
assertEq(abstainVotes, 0);
assertGt(forVotes + abstainVotes, quorum);
}
$ forge test --mt test_CastVote
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/MyGovernor.t.sol:MyGovernorTest
[PASS] test_CastVote() (gas: 146816)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.72ms (634.90µs CPU time)
Ran 1 test suite in 7.11ms (3.72ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
실행하기 - execute
GovernorCountingSimple.sol
투표가 성공적으로 마무리된 제안은 execute 함수를 통해 실행에 옮길 수 있습니다. 투표가 성공적으로 마무리되려면 찬성표와 중립표의 합이 정족수와 같거나 커야 하며, 찬성표의 수가 반대표의 수보다 커야 합니다.
function _quorumReached(uint256 proposalId) internal view virtual override returns (bool) {
ProposalVote storage proposalVote = _proposalVotes[proposalId];
return quorum(proposalSnapshot(proposalId)) <= proposalVote.forVotes + proposalVote.abstainVotes;
}
function _voteSucceeded(uint256 proposalId) internal view virtual override returns (bool) {
ProposalVote storage proposalVote = _proposalVotes[proposalId];
return proposalVote.forVotes > proposalVote.againstVotes;
}
IGovernor.sol
앞선 조건이 만족이 되면 execute 함수를 호출할 수 있습니다.
/**
* @dev Execute a successful proposal. This requires the quorum to be reached, the vote to be successful, and the
* deadline to be reached. Depending on the governor it might also be required that the proposal was queued and
* that some delay passed.
*
* Emits a {ProposalExecuted} event.
*
* NOTE: Some modules can modify the requirements for execution, for example by adding an additional timelock.
*/
function execute(address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash)
external
payable
returns (uint256 proposalId);
Governor.sol
execute 함수의 실행 과정은 다음과 같습니다.
- proposalId에 해당하는 제안의 상태가 Succeeded 또는 Queued인지 확인합니다.
- Checks-Effects-Interactions 패턴에 따라 상태를 먼저 변경하고 작업을 실행합니다.
- ProposalExecuted 이벤트를 내보냅니다.
/**
* @dev See {IGovernor-execute}.
*/
function execute(address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash)
public
payable
virtual
returns (uint256)
{
uint256 proposalId = hashProposal(targets, values, calldatas, descriptionHash);
_validateStateBitmap(
proposalId, _encodeStateBitmap(ProposalState.Succeeded) | _encodeStateBitmap(ProposalState.Queued)
);
// mark as executed before calls to avoid reentrancy
_proposals[proposalId].executed = true;
// before execute: register governance call in queue.
if (_executor() != address(this)) {
for (uint256 i = 0; i < targets.length; ++i) {
if (targets[i] == address(this)) {
_governanceCall.pushBack(keccak256(calldatas[i]));
}
}
}
_executeOperations(proposalId, targets, values, calldatas, descriptionHash);
// after execute: cleanup governance call queue.
if (_executor() != address(this) && !_governanceCall.empty()) {
_governanceCall.clear();
}
emit ProposalExecuted(proposalId);
return proposalId;
}
/**
* @dev Internal execution mechanism. Can be overridden (without a super call) to modify the way execution is
* performed (for example adding a vault/timelock).
*
* NOTE: Calling this function directly will NOT check the current state of the proposal, set the executed flag to
* true or emit the `ProposalExecuted` event. Executing a proposal should be done using {execute} or {_execute}.
*/
function _executeOperations(
uint256, /* proposalId */
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas,
bytes32 /*descriptionHash*/
) internal virtual {
for (uint256 i = 0; i < targets.length; ++i) {
(bool success, bytes memory returndata) = targets[i].call{value: values[i]}(calldatas[i]);
Address.verifyCallResult(success, returndata);
}
}
흥미로운 부분은 execute 함수 내부에서 작업을 실행하기 전, 작업 실행, 작업 실행 후로 실행 구간이 나누어집니다. 먼저 작업 실행 전에 수행되는 코드를 살펴보면, _executor가 자기 자신이 아닌 경우에 targets 배열을 돌면서 target이 자기 자신인 경우에 calldata를 해싱하여 디큐에 집어넣습니다. 왜 이런 동작이 필요한 걸까요?
// before execute: register governance call in queue.
if (_executor() != address(this)) {
for (uint256 i = 0; i < targets.length; ++i) {
if (targets[i] == address(this)) {
_governanceCall.pushBack(keccak256(calldatas[i]));
}
}
}
일반적으로는 Governor 컨트랙트와 함께 Timelock 컨트랙트를 사용하는데, 이때 Timelock 컨트랙트가 Governor 컨트랙트의 executor가 됩니다. 즉, Governor 자기 자신이 아닌 외부 컨트랙트에 의해 제안이 실행됨을 의미합니다.
Governor 컨트랙트의 executor가 자기 자신일 때는 onlyGovernance 수정자가 붙어있는 함수를 호출하게 하기 위해서 반드시 제안을 생성하고 투표를 거쳐 실행하는 과정이 포함되어야 하지만, 외부 컨트랙트의 경우는 이 과정을 거치지 않고도 언제든지 원하는 함수를 호출할 수 있는 위험 요소가 있습니다.
/**
* @dev Restricts a function so it can only be executed through governance proposals. For example, governance
* parameter setters in {GovernorSettings} are protected using this modifier.
*
* The governance executing address may be different from the Governor's own address, for example it could be a
* timelock. This can be customized by modules by overriding {_executor}. The executor is only able to invoke these
* functions during the execution of the governor's {execute} function, and not under any other circumstances. Thus,
* for example, additional timelock proposers are not able to change governance parameters without going through the
* governance protocol (since v4.6).
*/
modifier onlyGovernance() {
_checkGovernance();
_;
}
따라서 외부의 executor가 Governor 컨트랙트의 onlyGovernance 수정자가 붙어있는 함수를 실행하려면 반드시 execute 함수를 통해서만 실행할 수 있도록 제한하는 방법이 필요했고, 이를 execute 함수에서 작업을 실행하기 전에 target이 Governor 컨트랙트인 작업의 calldata 해시를 디큐에 넣어놓고, 해당 작업이 실제로 실행되었을 때 디큐에서 제거해야만 실행할 수 있도록 제한하는 방식으로 구현한 것 같습니다.
/**
* @dev Reverts if the `msg.sender` is not the executor. In case the executor is not this contract
* itself, the function reverts if `msg.data` is not whitelisted as a result of an {execute}
* operation. See {onlyGovernance}.
*/
function _checkGovernance() internal virtual {
if (_executor() != _msgSender()) {
revert GovernorOnlyExecutor(_msgSender());
}
if (_executor() != address(this)) {
bytes32 msgDataHash = keccak256(_msgData());
// loop until popping the expected operation - throw if deque is empty (operation not authorized)
while (_governanceCall.popFront() != msgDataHash) {}
}
}
Governor.t.sol
투표가 성공적으로 마무리된 제안을 실행하는 테스트 코드를 다음과 같이 작성할 수 있습니다.
function test_Execute() public {
address[] memory targets = new address[](1);
uint256[] memory values = new uint256[](1);
bytes[] memory calldatas = new bytes[](1);
string memory description = "Increment counter";
targets[0] = address(counter);
values[0] = 0;
calldatas[0] = abi.encodeWithSignature("increment()");
// proposer가 proposal을 생성
vm.prank(proposer);
uint256 proposalId = governor.propose(targets, values, calldatas, description);
// 투표 기간으로 이동
vm.roll(block.number + VOTING_DELAY + 1);
vm.warp(block.timestamp + (VOTING_DELAY + 1) * 12);
vm.prank(voter);
governor.castVote(proposalId, 1);
// 투표 마감으로 이동
vm.roll(block.number + VOTING_PERIOD + 1);
vm.warp(block.timestamp + (VOTING_PERIOD + 1) * 12);
assertEq(uint256(governor.state(proposalId)), uint256(IGovernor.ProposalState.Succeeded));
// proposal 실행
uint256 countBefore = counter.number();
bytes32 descriptionHash = keccak256(abi.encodePacked(description));
vm.expectEmit(true, true, true, true);
emit IGovernor.ProposalExecuted(proposalId);
governor.execute(targets, values, calldatas, descriptionHash);
uint256 countAfter = counter.number();
assertEq(countBefore + 1, countAfter);
assertEq(uint256(governor.state(proposalId)), uint256(IGovernor.ProposalState.Executed));
}
$ forge test --mt test_Execute
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/MyGovernor.t.sol:MyGovernorTest
[PASS] test_Execute() (gas: 191365)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.86ms (650.10µs CPU time)
Ran 1 test suite in 6.86ms (2.86ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
마치며
간단하게 제안 생성, 투표 그리고 실행하는 과정을 테스트를 통해 살펴봤습니다. 그러나 이는 전체 Governor 컨트랙트의 편린에 불과합니다. 실제 코드는 더 복잡하고 이해하기 어렵습니다. Aragon이나 Nouns 같은 유용한 DAO 프레임워크들이 존재하기 때문에 굳이 Openzeppelin 컨트랙트 봐야 될까 싶기도 하지만, 수 많은 테스트와 실전 경험을 통해 검증된 코드를 리뷰하는 것 만큼 도움이 되는 것도 없기 때문에 조금 더 깊게 파보실 생각이 있는 분들은 OpenZepplien 깃허브를 통해서 테스트 코드를 살펴보시면 될 것 같습니다.
전체 코드
'Solidity' 카테고리의 다른 글
[Solitidy+Go] geth로 스마트 컨트랙트 배포하기 - 7. standard json input을 사용해 배포한 컨트랙트 검증 (0) | 2023.12.16 |
---|---|
[Solitidy+Go] geth로 스마트 컨트랙트 배포하기 - 6. 메타데이터를 사용한 standard json input 생성 (0) | 2023.12.15 |
[Solitidy+Go] geth로 스마트 컨트랙트 배포하기 - 5. 이벤트 구독 (0) | 2023.12.14 |
[Solitidy+Go] geth로 스마트 컨트랙트 배포하기 - 4. 배포된 스마트 컨트랙트와 상호작용 (0) | 2023.12.14 |
[Solitidy+Go] geth로 스마트 컨트랙트 배포하기 - 3. 생성된 Go 코드로 스마트 컨트랙트 배포 (0) | 2023.12.14 |