Skip to content

Commit 0c98a72

Browse files
committed
feat(contracts): implement ERC20Votes policy
1 parent d073829 commit 0c98a72

File tree

6 files changed

+265
-0
lines changed

6 files changed

+265
-0
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.20;
3+
4+
import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
5+
6+
import {BaseChecker} from "../../checker/BaseChecker.sol";
7+
8+
/// @title ERC20VotesChecker
9+
/// @notice ERC20Votes validator.
10+
/// @dev Extends BaseChecker to implement ERC20Votes validation logic.
11+
contract ERC20VotesChecker is BaseChecker {
12+
/// @notice the token to check
13+
IVotes public token;
14+
15+
/// @notice the snapshot block
16+
uint256 public snapshotBlock;
17+
18+
/// @notice the threshold
19+
uint256 public threshold;
20+
21+
/// @notice the balance is too low
22+
error BalanceTooLow();
23+
24+
/// @notice Initializes the contract.
25+
function _initialize() internal override {
26+
super._initialize();
27+
28+
bytes memory data = _getAppendedBytes();
29+
(address _token, uint256 _snapshotBlock, uint256 _threshold) = abi.decode(data, (address, uint256, uint256));
30+
31+
token = IVotes(_token);
32+
snapshotBlock = _snapshotBlock;
33+
threshold = _threshold;
34+
}
35+
36+
/// @notice Returns true for everycall.
37+
/// @param subject Address to validate.
38+
/// @param evidence Encoded data used for validation.
39+
/// @return Boolean indicating whether the subject passes the check.
40+
function _check(address subject, bytes calldata evidence) internal view override returns (bool) {
41+
super._check(subject, evidence);
42+
43+
// get the token balance at the snapshot block
44+
uint256 balance = token.getPastVotes(subject, snapshotBlock);
45+
46+
// check if the balance is enough
47+
if (balance <= threshold) {
48+
revert BalanceTooLow();
49+
}
50+
51+
return true;
52+
}
53+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.20;
3+
4+
import {Factory} from "../../proxy/Factory.sol";
5+
import {ERC20VotesChecker} from "./ERC20VotesChecker.sol";
6+
7+
/// @title ERC20VotesCheckerFactory
8+
/// @notice Factory contract for deploying minimal proxy instances of ERC20VotesChecker.
9+
/// @dev Simplifies deployment of ERC20VotesChecker clones with appended configuration data.
10+
contract ERC20VotesCheckerFactory is Factory {
11+
/// @notice Initializes the factory with the ERC20VotesChecker implementation.
12+
constructor() Factory(address(new ERC20VotesChecker())) {}
13+
14+
/// @notice Deploys a new ERC20VotesChecker clone.
15+
function deploy(address _token, uint256 _snapshotBlock, uint256 _threshold) public {
16+
bytes memory data = abi.encode(_token, _snapshotBlock, _threshold);
17+
address clone = super._deploy(data);
18+
19+
ERC20VotesChecker(clone).initialize();
20+
}
21+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.20;
3+
4+
import {BasePolicy} from "../../policy/BasePolicy.sol";
5+
6+
/// @title ERC20VotesPolicy
7+
/// @notice A policy which allows anyone with a token balance > 0
8+
/// at snapshot time to sign up.
9+
contract ERC20VotesPolicy is BasePolicy {
10+
/// @notice Store the addreses that have been enforced
11+
mapping(address => bool) public enforcedUsers;
12+
13+
/// @notice Create a new instance of ERC20VotesPolicy
14+
// solhint-disable-next-line no-empty-blocks
15+
constructor() payable {}
16+
17+
/// @notice Enforce a user based on their token balance
18+
/// @dev Throw if the token balance is not valid or just complete silently
19+
/// @param subject The user's Ethereum address.
20+
function _enforce(address subject, bytes calldata evidence) internal override {
21+
if (enforcedUsers[subject]) {
22+
revert AlreadyEnforced();
23+
}
24+
25+
enforcedUsers[subject] = true;
26+
27+
super._enforce(subject, evidence);
28+
}
29+
30+
/// @notice Get the trait of the Policy
31+
/// @return The type of the Policy
32+
function trait() public pure override returns (string memory) {
33+
return "ERC20Votes";
34+
}
35+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.20;
3+
4+
import {Factory} from "../../proxy/Factory.sol";
5+
import {ERC20VotesPolicy} from "./ERC20VotesPolicy.sol";
6+
7+
/// @title ERC20VotesPolicyFactory
8+
/// @notice Factory contract for deploying minimal proxy instances of ERC20VotesPolicy.
9+
/// @dev Simplifies deployment of ERC20VotesPolicy clones with appended configuration data.
10+
contract ERC20VotesPolicyFactory is Factory {
11+
/// @notice Initializes the factory with the ERC20VotesPolicy implementation.
12+
constructor() Factory(address(new ERC20VotesPolicy())) {}
13+
14+
/// @notice Deploys a new ERC20VotesPolicy clone with the specified checker address.
15+
/// @dev Encodes the checker address and caller as configuration data for the clone.
16+
/// @param _checkerAddress Address of the checker to use for validation.
17+
function deploy(address _checkerAddress) public {
18+
bytes memory data = abi.encode(msg.sender, _checkerAddress);
19+
20+
address clone = super._deploy(data);
21+
22+
ERC20VotesPolicy(clone).initialize();
23+
}
24+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.20;
3+
4+
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
5+
6+
/// @title MockERC20Votes
7+
/// @notice A mock ERC20Votes contract
8+
contract MockERC20Votes is ERC20 {
9+
constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {
10+
_mint(msg.sender, 100e18);
11+
}
12+
13+
/// @notice Get the past votes for an account
14+
/// @return The past votes for the account
15+
function getPastVotes(address subject, uint256) public view returns (uint256) {
16+
return balanceOf(subject);
17+
}
18+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { expect } from "chai"
2+
import { AbiCoder, Signer, ZeroAddress } from "ethers"
3+
import { ethers } from "hardhat"
4+
5+
import {
6+
ERC20VotesPolicy__factory as ERC20VotesPolicyFactory,
7+
ERC20VotesPolicy,
8+
MockERC20Votes,
9+
ERC20VotesChecker,
10+
ERC20VotesChecker__factory as ERC20VotesCheckerFactory
11+
} from "../../typechain-types"
12+
13+
describe("ERC20Votes", () => {
14+
let policy: ERC20VotesPolicy
15+
let checker: ERC20VotesChecker
16+
let mockERC20Votes: MockERC20Votes
17+
let deployer: Signer
18+
let subject: Signer
19+
let target: Signer
20+
let notSubject: Signer
21+
22+
const snapshotBlock = 1n
23+
const threshold = 5n
24+
25+
before(async () => {
26+
;[deployer, subject, target, notSubject] = await ethers.getSigners()
27+
28+
const MockERC20VotesFactory = await ethers.getContractFactory("MockERC20Votes")
29+
mockERC20Votes = await MockERC20VotesFactory.connect(deployer).deploy("MockERC20Votes", "MKV")
30+
const mockERC20VotesAddress = await mockERC20Votes.getAddress()
31+
32+
await mockERC20Votes.transfer(await subject.getAddress(), threshold + 1n)
33+
await mockERC20Votes.transfer(await notSubject.getAddress(), threshold - 1n)
34+
35+
const CheckerFactory = await ethers.getContractFactory("ERC20VotesCheckerFactory")
36+
const checkerFactory = await CheckerFactory.connect(deployer).deploy()
37+
38+
const PolicyFactory = await ethers.getContractFactory("ERC20VotesPolicyFactory")
39+
const policyFactory = await PolicyFactory.connect(deployer).deploy()
40+
41+
const checkerTx = await checkerFactory.deploy(mockERC20VotesAddress, snapshotBlock, threshold)
42+
const checkerReceipt = await checkerTx.wait()
43+
44+
const checkerDeployEvent = CheckerFactory.interface.parseLog(
45+
checkerReceipt?.logs[0] as unknown as { topics: string[]; data: string }
46+
) as unknown as {
47+
args: {
48+
clone: string
49+
}
50+
}
51+
52+
const policyTx = await policyFactory.deploy(checkerDeployEvent.args.clone)
53+
const policyReceipt = await policyTx.wait()
54+
const policyEvent = PolicyFactory.interface.parseLog(
55+
policyReceipt?.logs[0] as unknown as { topics: string[]; data: string }
56+
) as unknown as {
57+
args: {
58+
clone: string
59+
}
60+
}
61+
62+
policy = ERC20VotesPolicyFactory.connect(policyEvent.args.clone, deployer)
63+
checker = ERC20VotesCheckerFactory.connect(checkerDeployEvent.args.clone, deployer)
64+
})
65+
66+
describe("Deployment", () => {
67+
it("should be deployed correctly", () => {
68+
expect(policy).to.not.eq(undefined)
69+
})
70+
})
71+
72+
describe("Policy", () => {
73+
it("should set guarded target correctly", async () => {
74+
await policy.setTarget(target).then((tx) => tx.wait())
75+
76+
expect(await policy.guarded()).to.eq(target)
77+
})
78+
79+
it("should return trait properly", async () => {
80+
expect(await policy.trait()).to.eq("ERC20Votes")
81+
})
82+
83+
it("should fail to set guarded target when the caller is not the owner", async () => {
84+
await expect(policy.connect(notSubject).setTarget(target)).to.be.revertedWithCustomError(
85+
policy,
86+
"OwnableUnauthorizedAccount"
87+
)
88+
})
89+
90+
it("should fail to set guarded target when the target is not valid", async () => {
91+
await expect(policy.setTarget(ZeroAddress)).to.be.revertedWithCustomError(policy, "ZeroAddress")
92+
})
93+
94+
it("should enforce a user if the function is called with the valid data", async () => {
95+
const tx = await policy.connect(target).enforce(subject, AbiCoder.defaultAbiCoder().encode([], []))
96+
97+
const receipt = await tx.wait()
98+
99+
expect(receipt?.status).to.eq(1)
100+
})
101+
102+
it("should not enforce twice", async () => {
103+
await expect(
104+
policy.connect(target).enforce(subject, AbiCoder.defaultAbiCoder().encode([], []))
105+
).to.be.revertedWithCustomError(policy, "AlreadyEnforced")
106+
})
107+
108+
it("should revert when the balance is too low", async () => {
109+
await expect(
110+
policy.connect(target).enforce(notSubject, AbiCoder.defaultAbiCoder().encode([], []))
111+
).to.be.revertedWithCustomError(checker, "BalanceTooLow")
112+
})
113+
})
114+
})

0 commit comments

Comments
 (0)