diff --git a/Makefile b/Makefile index 49a35958..55ab736f 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,7 @@ build :; forge clean && forge build --optimize --optimizer-runs 1000000 # Tests tests :; forge clean && forge test --mt test --optimize --optimizer-runs 1000000 -v # --ffi # enable if you need the `ffi` cheat code on HEVM test-with-gas-report :; forge clean && forge build && forge test --mt test --optimize --optimizer-runs 1000000 -v --gas-report # --ffi # enable if you need the `ffi` cheat code on HEVM +test-unit :; forge clean && forge test --no-match-test invariant --optimize --optimizer-runs 1000000 -v # --ffi # enable if you need the `ffi` cheat code on HEVM test-invariant :; ./test/invariants/test-invariant.sh ${SCENARIO} ${NUM_ACTORS} ${NUM_PROPOSALS} ${PER_ADDRESS_TOKEN_REQ_CAP} test-invariant-all :; forge clean && forge t --mt invariant test-invariant-multiple-distribution :; forge clean && ./test/invariants/test-invariant.sh MultipleDistribution 2 25 200 diff --git a/script/GrantFund.s.sol b/script/GrantFund.s.sol index 31b7da02..0a2a1049 100644 --- a/script/GrantFund.s.sol +++ b/script/GrantFund.s.sol @@ -22,6 +22,6 @@ contract DeployGrantFund is Script { vm.stopBroadcast(); console.log("GrantFund deployed to %s", grantFund); - console.log("Please transfer %s AJNA (%s WAD) into the treasury", treasury / 1e18, treasury); + console.log("Please transfer %s AJNA (%s WAD) to the treasury using the fundTreasury() method found in GrantFund.sol", treasury / 1e18, treasury); } } diff --git a/src/grants/GrantFund.sol b/src/grants/GrantFund.sol index a371487d..03154ebd 100644 --- a/src/grants/GrantFund.sol +++ b/src/grants/GrantFund.sol @@ -277,7 +277,7 @@ contract GrantFund is IGrantFund, Storage, ReentrancyGuard { uint24 distributionId = proposal.distributionId; - // check that the distribution period has ended, and one week has passed to enable competing slates to be checked + // check that the distribution period has ended if (block.number <= _distributions[distributionId].endBlock) revert ExecuteProposalInvalid(); // check proposal is successful and hasn't already been executed @@ -307,8 +307,8 @@ contract GrantFund is IGrantFund, Storage, ReentrancyGuard { DistributionPeriod storage currentDistribution = _distributions[_currentDistributionId]; - // cannot add new proposal after end of screening period - // screening period ends 72000 blocks before end of distribution period, ~ 80 days. + // cannot add new proposal after the screening period ends + // screening period ends 525_600 blocks after the start of the distribution period, ~73 days. if (block.number > _getScreeningStageEndBlock(currentDistribution.startBlock)) revert ScreeningPeriodEnded(); // store new proposal information @@ -732,7 +732,7 @@ contract GrantFund is IGrantFund, Storage, ReentrancyGuard { * @dev Votes can be allocated to multiple proposals, quadratically, for or against. * @param currentDistribution_ The current distribution period. * @param proposal_ The current proposal being voted upon. - * @param voter_ The voter data struct tracking available votes. + * @param voter_ The VoterInfo struct tracking votes. * @param voteParams_ The amount of votes being allocated to the proposal. Not squared. If less than 0, vote is against. * @return incrementalVotesUsed_ The amount of funding stage votes allocated to the proposal. */ @@ -825,6 +825,7 @@ contract GrantFund is IGrantFund, Storage, ReentrancyGuard { /** * @notice Vote on a proposal in the screening stage of the Distribution Period. * @param proposal_ The current proposal being voted upon. + * @param voter_ The VoterInfo struct tracking votes. * @param votes_ The amount of votes being cast. */ function _screeningVote( @@ -1098,6 +1099,8 @@ contract GrantFund is IGrantFund, Storage, ReentrancyGuard { DistributionPeriod storage currentDistribution = _distributions[distributionId_]; VoterInfo storage voter = _voterInfo[distributionId_][voter_]; + if (voter.screeningVotesCast == 0) return 0; + rewards_ = _getDelegateReward(currentDistribution, voter); } diff --git a/src/grants/interfaces/IGrantFundActions.sol b/src/grants/interfaces/IGrantFundActions.sol index c8389247..9b9ee749 100644 --- a/src/grants/interfaces/IGrantFundActions.sol +++ b/src/grants/interfaces/IGrantFundActions.sol @@ -68,8 +68,7 @@ interface IGrantFundActions is IGrantFundState { * @param targets_ The addresses of the contracts to call. * @param values_ The amounts of ETH to send to each target. * @param calldatas_ The calldata to send to each target. - * @param descriptionHash_ The hash of the proposal's description string. Generated by `abi.encode(DESCRIPTION_PREFIX_HASH, keccak256(bytes(description_))`. - * The `DESCRIPTION_PREFIX_HASH` is unique for each funding mechanism: `keccak256(bytes("Standard Funding: "))` for standard funding + * @param descriptionHash_ The hash of the proposal's description string. Generated by `keccak256(bytes(description_))` or by calling `getDescriptionHash`. * @return proposalId_ The hashed proposalId created from the provided params. */ function hashProposal( @@ -81,7 +80,7 @@ interface IGrantFundActions is IGrantFundState { /** * @notice Submit a new proposal to the Grant Coordination Fund Standard Funding mechanism. - * @dev All proposals can be submitted by anyone. There can only be one value in each array. Interface is compliant with OZ.propose(). + * @dev Proposals can be submitted by anyone. Interface is compliant with OZ.propose(). * @param targets_ List of contracts the proposal calldata will interact with. Should be the Ajna token contract for all proposals. * @param values_ List of values to be sent with the proposal calldata. Should be 0 for all proposals. * @param calldatas_ List of calldata to be executed. Should be the transfer() method. @@ -97,7 +96,7 @@ interface IGrantFundActions is IGrantFundState { /** * @notice Find the status of a given proposal. - * @dev Check proposal status based upon Grant Fund specific logic. + * @dev Proposal status depends on the stage of the distribution period in which it was submitted, and vote counts on the proposal. * @param proposalId_ The id of the proposal to query the status of. * @return ProposalState of the given proposal. */ @@ -106,7 +105,7 @@ interface IGrantFundActions is IGrantFundState { ) external view returns (ProposalState); /** - * @notice Check if a slate of proposals meets requirements, and maximizes votes. If so, update DistributionPeriod. + * @notice Check if a slate of proposals meets requirements, and maximizes votes. If so, set the provided proposal slate as the new top slate of proposals. * @param proposalIds_ Array of proposal Ids to check. * @param distributionId_ Id of the current distribution period. * @return newTopSlate_ Boolean indicating whether the new proposal slate was set as the new top slate for distribution. @@ -122,7 +121,7 @@ interface IGrantFundActions is IGrantFundState { /** * @notice Cast an array of funding votes in one transaction. - * @dev Calls out to StandardFunding._fundingVote(). + * @dev Calls StandardFunding._fundingVote(). * @dev Only iterates through a maximum of 10 proposals that made it through the screening round. * @dev Counters incremented in an unchecked block due to being bounded by array length. * @param voteParams_ The array of votes on proposals to cast. @@ -134,7 +133,7 @@ interface IGrantFundActions is IGrantFundState { /** * @notice Cast an array of screening votes in one transaction. - * @dev Calls out to StandardFunding._screeningVote(). + * @dev Calls StandardFunding._screeningVote(). * @dev Counters incremented in an unchecked block due to being bounded by array length. * @param voteParams_ The array of votes on proposals to cast. * @return votesCast_ The total number of votes cast across all of the proposals. @@ -167,9 +166,9 @@ interface IGrantFundActions is IGrantFundState { /** * @notice Calculate the description hash of a proposal. - * @dev The description hash is used as a unique identifier for a proposal. It is created by hashing the description string with a prefix. + * @dev The description hash is used as a unique identifier for a proposal. It is created by hashing the description string. * @param description_ The proposal's description string. - * @return The hash of the proposal's prefix and description string. + * @return The hash of the proposal's description string. */ function getDescriptionHash(string memory description_) external pure returns (bytes32); @@ -204,7 +203,7 @@ interface IGrantFundActions is IGrantFundState { /** * @notice Get the block number at which this distribution period's funding stage ends. - * @param startBlock_ The end block of a distribution period to get the funding stage end block for. + * @param startBlock_ The start block of a distribution period to get the funding stage end block for. * @return The block number at which this distribution period's funding stage ends. */ function getFundingStageEndBlock(uint256 startBlock_) external pure returns (uint256); diff --git a/src/grants/interfaces/IGrantFundErrors.sol b/src/grants/interfaces/IGrantFundErrors.sol index d2d5f736..bf115fee 100644 --- a/src/grants/interfaces/IGrantFundErrors.sol +++ b/src/grants/interfaces/IGrantFundErrors.sol @@ -12,11 +12,6 @@ interface IGrantFundErrors { /*** Errors ***/ /**************/ - /** - * @notice Voter has already voted on a proposal in the screening stage in a quarter. - */ - error AlreadyVoted(); - /** * @notice User attempted to start a new distribution or claim delegation rewards before the distribution period ended. */ diff --git a/src/token/BurnWrapper.sol b/src/token/BurnWrapper.sol index 78b91400..01a9b5b2 100644 --- a/src/token/BurnWrapper.sol +++ b/src/token/BurnWrapper.sol @@ -11,6 +11,32 @@ import { ERC20Permit } from "@oz/token/ERC20/extensions/draft-ERC20Permit.sol import { ERC20Wrapper } from "@oz/token/ERC20/extensions/ERC20Wrapper.sol"; import { IERC20Metadata } from "@oz/token/ERC20/extensions/IERC20Metadata.sol"; +/** + * @title Ajna Token `ERC20` token interface. + * @dev Ajna Token `ERC20` token interface, including the following functions: + * - `burnFrom()` + * @dev Used by the `BurnWrappedAjna` contract to burn Ajna tokens on wrapping. +*/ +interface IERC20Token { + /** + * @notice Burns `amount` tokens from `account`, deducting from the caller's allowance and balance. + * @param account Account to burn tokens from. + * @param amount Amount of tokens to burn. + */ + function burnFrom(address account, uint256 amount) external; +} + + +/** + * @title BurnWrappedAjna Contract + * @notice Entrypoint of BurnWrappedAjna actions for Ajna token holders looking to migrate their tokens to a sidechain: + * - `TokenHolders`: Approve the BurnWrappedAjna contract to burn a specified amount of Ajna tokens, and call `depositFor()` to mint them a corresponding amount of bwAJNA tokens. + * @dev This contract is intended for usage in cases where users are attempting to migrate their Ajna to a sidechain that lacks a permissionless bridge. + * Usage of this contract protects holders from the risk of a compromised sidechain bridge. + * @dev Contract inherits from OpenZeppelin ERC20Burnable and ERC20Wrapper extensions. + * @dev Only mainnet Ajna token can be wrapped. Tokens that have been wrapped cannot be unwrapped, as they are burned on wrapping. + * @dev Holders must call `depositFor()` to wrap their tokens. Transferring Ajna tokens to the wrapper contract directly results in loss of tokens. + */ contract BurnWrappedAjna is ERC20, ERC20Burnable, ERC20Permit, ERC20Wrapper { /** @@ -50,6 +76,18 @@ contract BurnWrappedAjna is ERC20, ERC20Burnable, ERC20Permit, ERC20Wrapper { return 18; } + /** + * @notice Override wrap method to burn Ajna tokens on wrapping instead of transferring to the wrapper contract. + */ + function depositFor(address account, uint256 amount) public override returns (bool) { + // burn the existing ajna tokens + IERC20Token(AJNA_TOKEN_ADDRESS).burnFrom(account, amount); + + // mint the new wrapped tokens + _mint(account, amount); + return true; + } + /** * @notice Override unwrap method to ensure burn wrapped tokens can't be unwrapped. */ diff --git a/test/unit/BurnWrappedToken.t.sol b/test/unit/BurnWrappedToken.t.sol index 37214162..a2677133 100644 --- a/test/unit/BurnWrappedToken.t.sol +++ b/test/unit/BurnWrappedToken.t.sol @@ -8,6 +8,8 @@ import { Test } from "@std/Test.sol"; import { AjnaToken } from "../../src/token/AjnaToken.sol"; import { BurnWrappedAjna } from "../../src/token/BurnWrapper.sol"; +import { SigUtils } from "../utils/SigUtils.sol"; + contract BurnWrappedTokenTest is Test { /*************/ @@ -16,6 +18,7 @@ contract BurnWrappedTokenTest is Test { AjnaToken internal _token; BurnWrappedAjna internal _wrappedToken; + SigUtils internal _sigUtils; address internal _ajnaAddress = 0x9a96ec9B57Fb64FbC60B423d1f4da7691Bd35079; // mainnet ajna token address address internal _tokenDeployer = 0x666cf594fB18622e1ddB91468309a7E194ccb799; // mainnet token deployer @@ -35,6 +38,8 @@ contract BurnWrappedTokenTest is Test { // reference mainnet deployment _token = AjnaToken(_ajnaAddress); _wrappedToken = new BurnWrappedAjna(IERC20(address(_token))); + + _sigUtils = new SigUtils(_wrappedToken.DOMAIN_SEPARATOR()); } function approveAndWrapTokens(address account_, uint256 amount_) internal { @@ -42,7 +47,7 @@ contract BurnWrappedTokenTest is Test { _token.approve(address(_wrappedToken), amount_); vm.expectEmit(true, true, false, true); - emit Transfer(address(account_), address(_wrappedToken), amount_); + emit Transfer(address(account_), address(0), amount_); vm.expectEmit(true, true, false, true); emit Transfer(address(0), address(account_), amount_); (bool wrapSuccess) = _wrappedToken.depositFor(account_, amount_); @@ -77,6 +82,7 @@ contract BurnWrappedTokenTest is Test { // check initial token supply assertEq(_token.totalSupply(), 1_000_000_000 * 10 ** _token.decimals()); + assertEq(_token.totalSupply(), _initialAjnaTokenSupply); assertEq(_wrappedToken.totalSupply(), 0); // transfer some tokens to the test address @@ -99,8 +105,8 @@ contract BurnWrappedTokenTest is Test { assertEq(_token.balanceOf(address(_tokenDeployer)), _initialAjnaTokenSupply - tokensToWrap); assertEq(_wrappedToken.balanceOf(address(_tokenDeployer)), 0); - // check token supply after wrapping - assertEq(_token.totalSupply(), 1_000_000_000 * 10 ** _token.decimals()); + // check token supply has decreased after wrapping by the wrapped amount + assertEq(_token.totalSupply(), _initialAjnaTokenSupply - tokensToWrap); assertEq(_wrappedToken.totalSupply(), tokensToWrap); } @@ -127,4 +133,157 @@ contract BurnWrappedTokenTest is Test { _wrappedToken.withdrawTo(_tokenHolder, 25 * 1e18); } + function testApproveAndTransfer() external { + uint256 tokensToTransfer = 500 * 1e18; + address tokenReceiver = makeAddr("tokenReceiver"); + + // transfer some tokens to the test address + vm.startPrank(_tokenDeployer); + _token.approve(address(_tokenDeployer), tokensToTransfer); + _token.transferFrom(_tokenDeployer, _tokenHolder, tokensToTransfer); + + // wrap tokens + approveAndWrapTokens(_tokenHolder, tokensToTransfer); + + // approve some tokens to the receiver address + vm.startPrank(_tokenHolder); + _wrappedToken.approve(tokenReceiver, tokensToTransfer); + + // check token allowance + assertEq(_wrappedToken.allowance(_tokenHolder, tokenReceiver), tokensToTransfer); + + // transfer tokens + changePrank(tokenReceiver); + _wrappedToken.transferFrom(_tokenHolder, tokenReceiver, tokensToTransfer); + + // ensure tokens are transferred + assertEq(_wrappedToken.balanceOf(tokenReceiver), tokensToTransfer); + } + + function testIncreaseAndDecreaseAllowance() external { + uint256 tokensToTransfer = 500 * 1e18; + address tokenReceiver = makeAddr("tokenReceiver"); + + // transfer some tokens to the test address + vm.startPrank(_tokenDeployer); + _token.approve(address(_tokenDeployer), tokensToTransfer); + _token.transferFrom(_tokenDeployer, _tokenHolder, tokensToTransfer); + + // wrap tokens + approveAndWrapTokens(_tokenHolder, tokensToTransfer); + + // approve some tokens to the test address + vm.startPrank(_tokenHolder); + _wrappedToken.approve(tokenReceiver, tokensToTransfer); + + // increase token allowance to 1000 tokens + _wrappedToken.increaseAllowance(tokenReceiver, 500 * 1e18); + + // check allowance is increased + assertEq(_wrappedToken.allowance(_tokenHolder, tokenReceiver), 1000 * 1e18); + + // decrease token allowance to 200 tokens + _wrappedToken.decreaseAllowance(tokenReceiver, 800 * 1e18); + + // check allowance is decreased + assertEq(_wrappedToken.allowance(_tokenHolder, tokenReceiver), 200 * 1e18); + } + + function testDomainSeparatorAndUnderlyingToken() external { + bytes32 expectedDomainSeparator = keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes("Burn Wrapped AJNA")), + keccak256(bytes("1")), + block.chainid, + address(_wrappedToken) + ) + ); + + assertEq(_wrappedToken.DOMAIN_SEPARATOR(), expectedDomainSeparator); + + assertEq(address(_wrappedToken.underlying()), address(_token)); + } + + function testTransferWithPermit() external { + uint256 tokensToTransfer = 500 * 1e18; + + // define owner and spender addresses + (address owner, uint256 ownerPrivateKey) = makeAddrAndKey("owner"); + address spender = makeAddr("spender"); + address newOwner = makeAddr("newOwner"); + + // set owner balance + deal(address(_wrappedToken), owner, tokensToTransfer); + + // check owner and spender balances + assertEq(_wrappedToken.balanceOf(owner), tokensToTransfer); + assertEq(_wrappedToken.balanceOf(spender), 0); + assertEq(_wrappedToken.balanceOf(newOwner), 0); + + // TEST transfer with ERC20 permit + vm.startPrank(owner); + SigUtils.Permit memory permit = SigUtils.Permit({ + owner: owner, + spender: spender, + value: tokensToTransfer, + nonce: 0, + deadline: block.timestamp + 1 days + }); + + bytes32 digest = _sigUtils.getTypedDataHash(permit); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest); + _wrappedToken.permit(owner, spender, tokensToTransfer, permit.deadline, v, r, s); + + // check nonces is increased + assertEq(_wrappedToken.nonces(owner), 1); + + changePrank(spender); + _wrappedToken.transferFrom(owner, newOwner, tokensToTransfer); + + // check owner and spender balances after transfer + assertEq(_wrappedToken.balanceOf(owner), 0); + assertEq(_wrappedToken.balanceOf(spender), 0); + assertEq(_wrappedToken.balanceOf(newOwner), tokensToTransfer); + assertEq(_wrappedToken.allowance(owner, spender), 0); + } + + function testBurn() external { + uint256 tokensToBurn = 500 * 1e18; + + // transfer some tokens to the test address + vm.startPrank(_tokenDeployer); + _token.approve(address(_tokenDeployer), tokensToBurn); + _token.transferFrom(_tokenDeployer, _tokenHolder, tokensToBurn); + + // wrap tokens + approveAndWrapTokens(_tokenHolder, tokensToBurn); + + uint256 totalTokenSupply = _wrappedToken.totalSupply(); + + uint256 snapshot = vm.snapshot(); + + // burn tokens + _wrappedToken.burn(tokensToBurn); + + // ensure tokens are burned + assertEq(_wrappedToken.balanceOf(_tokenHolder), 0); + assertEq(_wrappedToken.totalSupply(), totalTokenSupply - tokensToBurn); + + vm.revertTo(snapshot); + + address tokenBurner = makeAddr("tokenBurner"); + + // approve tokens to token burner address + _wrappedToken.approve(tokenBurner, tokensToBurn); + + // burn tokens + changePrank(tokenBurner); + _wrappedToken.burnFrom(_tokenHolder, tokensToBurn); + + // ensure tokens are burned + assertEq(_wrappedToken.balanceOf(_tokenHolder), 0); + assertEq(_wrappedToken.totalSupply(), totalTokenSupply - tokensToBurn); + } + } diff --git a/test/unit/StandardFunding.t.sol b/test/unit/StandardFunding.t.sol index bf3478ca..39467cfd 100644 --- a/test/unit/StandardFunding.t.sol +++ b/test/unit/StandardFunding.t.sol @@ -1277,6 +1277,10 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { /******************************/ // Claim delegate reward for all delegatees + // delegates who didn't vote in the screening stage have zero rewards + delegateRewards = _grantFund.getDelegateReward(distributionId, _tokenHolder11); + assertEq(delegateRewards, 0); + // delegates who didn't vote with their full power receive fewer rewards delegateRewards = _grantFund.getDelegateReward(distributionId, _tokenHolder1); assertEq(delegateRewards, 327_029.344384908148174595 * 1e18);