commit 3fd738b996339d15f6a392037ed7f685bd87f243 Author: Theo Date: Mon Jul 31 18:55:10 2023 +0200 Initial commit diff --git a/.env.bat.example b/.env.bat.example new file mode 100644 index 0000000..25246cf --- /dev/null +++ b/.env.bat.example @@ -0,0 +1,3 @@ +set MAINNET_RPC_URL= +set PRIVATE_KEY= +set ETHERSCAN_KEY= \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ecd9cc1 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +MAINNET_RPC_URL= +PRIVATE_KEY= +ETHERSCAN_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db72ca3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env +.env.bat + +# Node modules +node_modules/ +package-lock.json + +# yarn +yarn.lock + +# VScode files +.vscode \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..888d42d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/README.md b/README.md new file mode 100644 index 0000000..e63c749 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Proposal title + +### Changes / effect description + +- 1 +- 2 +- ... + +### Requirements + +- Rust ([Need only for Windows](https://doc.rust-lang.org/cargo/getting-started/installation.html)) +- Foundryup ([Windows](https://github.com/altugbakan/foundryup-windows), [Linux](https://book.getfoundry.sh/getting-started/installation)) +- Node 14 or higher ([Windows](https://github.com/coreybutler/nvm-windows), [Linux](https://github.com/nvm-sh/nvm)) + +### Installation + +```text +git clone --recurse-submodules +cd +npm install +npm run init +``` + +### Testing + +```text +npm run test:windows +``` + +or + +```text +npm run test:linux +``` + +##### Test with gas + +```text +npm run test:gas:windows +``` + +or + +```text +npm run test:gas:linux +``` diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..28a09c4 --- /dev/null +++ b/foundry.toml @@ -0,0 +1,26 @@ +[profile.default] +# General +src = 'src' +out = 'out' +libs = ["node_modules", "lib"] + +# Compiler +auto_detect_solc = true +via_ir = true +optimizer = true +optimizer_runs = 200 +auto_detect_remappings = true + +# Network +chain_id = 1 + +# Tests +verbosity = 3 + +[rpc_endpoints] +mainnet = "${MAINNET_RPC_URL}" + +[fmt] +line_length = 140 +number_underscore = 'thousands' +bracket_spacing = true \ No newline at end of file diff --git a/lib/.gitkeep b/lib/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json new file mode 100644 index 0000000..02065b5 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "forge-proposal-template", + "version": "1.0.0", + "repository": "https://git.tornado.ws/Theo/forge-proposal-template", + "author": "Theo", + "license": "MIT", + "private": false, + "scripts": { + "init": "cd lib && git clone --recurse-submodules https://github.com/foundry-rs/forge-std", + "test:windows": ".\\.env.bat && forge test", + "test:linux": ". .env && forge test", + "test:gas:windows": ".\\.env.bat && forge test --gas-report", + "test:gas:linux": ". .env && forge test --gas-report" + }, + "dependencies": { + "@ensdomains/ens-contracts": "^0.0.21", + "@openzeppelin/contracts": "^4.9.0", + "@openzeppelin/upgrades-core": "^1.26.2" + }, + "optionalDependencies": { + "@gnosis.pm/ido-contracts": "^0.5.0", + "@gnosis.pm/safe-contracts": "1.3.0" + } +} diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..91d487a --- /dev/null +++ b/remappings.txt @@ -0,0 +1,11 @@ +@root/=src/ +@interfaces/=src/interfaces +@proprietary/=src/proprietary +@forge-std/=lib/forge-std/src/ + +@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/ +@openzeppelin/upgrades/=node_modules/@openzeppelin/upgrades-core/ +@ens/contracts/=node_modules/@ensdomains/ens-contracts/contracts/ +@gnosis/contracts/=node_modules/@gnosis.pm/safe-contracts/contracts/ +@gnosis/ido-contracts/=node_modules/@gnosis.pm/ido-contracts/contracts/ +@torn-token/=node_modules/torn-token/ diff --git a/src/ExampleProposal.sol b/src/ExampleProposal.sol new file mode 100644 index 0000000..68fd33d --- /dev/null +++ b/src/ExampleProposal.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.19; + +import { IGovernance } from "@interfaces/IGovernance.sol"; + +contract ExampleProposal { + function executeProposal() public { + /* ... */ + } +} diff --git a/src/interfaces/IGnosisSafe.sol b/src/interfaces/IGnosisSafe.sol new file mode 100644 index 0000000..5a96277 --- /dev/null +++ b/src/interfaces/IGnosisSafe.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.19; + +interface IGnosisSafe { + enum Operation { + Call, + DelegateCall + } + + function NAME() external view returns (string memory); + + function VERSION() external view returns (string memory); + + function nonce() external view returns (uint256); + + function domainSeparator() external view returns (bytes32); + + function signedMessages(bytes32) external view returns (uint256); + + function approvedHashes(address, bytes32) external view returns (uint256); + + function setup( + address[] calldata _owners, + uint256 _threshold, + address to, + bytes calldata data, + address fallbackHandler, + address paymentToken, + uint256 payment, + address payable paymentReceiver + ) external; + + function execTransaction( + address to, + uint256 value, + bytes calldata data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + bytes calldata signatures + ) external returns (bool success); + + function requiredTxGas(address to, uint256 value, bytes calldata data, Operation operation) + external + returns (uint256); + + function approveHash(bytes32 hashToApprove) external; + + function signMessage(bytes calldata _data) external; + + function isValidSignature(bytes calldata _data, bytes calldata _signature) external returns (bytes4); + + function getMessageHash(bytes memory message) external view returns (bytes32); + + function encodeTransactionData( + address to, + uint256 value, + bytes memory data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + uint256 _nonce + ) external view returns (bytes memory); + + function getTransactionHash( + address to, + uint256 value, + bytes memory data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + uint256 _nonce + ) external view returns (bytes32); +} diff --git a/src/interfaces/IGovernance.sol b/src/interfaces/IGovernance.sol new file mode 100644 index 0000000..87b124d --- /dev/null +++ b/src/interfaces/IGovernance.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.19; + +enum ProposalState { + Pending, + Active, + Defeated, + Timelocked, + AwaitingExecution, + Executed, + Expired +} + +struct Proposal { + // Creator of the proposal + address proposer; + // target addresses for the call to be made + address target; + // The block at which voting begins + uint256 startTime; + // The block at which voting ends: votes must be cast prior to this block + uint256 endTime; + // Current number of votes in favor of this proposal + uint256 forVotes; + // Current number of votes in opposition to this proposal + uint256 againstVotes; + // Flag marking whether the proposal has been executed + bool executed; + // Flag marking whether the proposal voting time has been extended + // Voting time can be extended once, if the proposal outcome has changed during CLOSING_PERIOD + bool extended; +} + +interface IGovernance { + function initialized() external view returns (bool); + function initializing() external view returns (bool); + function EXECUTION_DELAY() external view returns (uint256); + function EXECUTION_EXPIRATION() external view returns (uint256); + function QUORUM_VOTES() external view returns (uint256); + function PROPOSAL_THRESHOLD() external view returns (uint256); + function VOTING_DELAY() external view returns (uint256); + function VOTING_PERIOD() external view returns (uint256); + function CLOSING_PERIOD() external view returns (uint256); + function VOTE_EXTEND_TIME() external view returns (uint256); + function torn() external view returns (address); + function proposals(uint256 index) external view returns (Proposal memory); + function proposalCount() external view returns (uint256); + function lockedBalance(address account) external view returns (uint256); + function propose(address target, string memory description) external returns (uint256); + function castVote(uint256 proposalId, bool support) external; + function lock(address owner, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external; + function lockWithApproval(uint256 amount) external; + function unlock(uint256 amount) external; + function execute(uint256 proposalId) external payable; + function state(uint256 proposalId) external view returns (ProposalState); +} diff --git a/src/proprietary/TornadoAddresses.sol b/src/proprietary/TornadoAddresses.sol new file mode 100644 index 0000000..7d9e98d --- /dev/null +++ b/src/proprietary/TornadoAddresses.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.19; + +contract TornadoAddresses { + address public constant ENSAddress = 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e; + address public constant tornTokenAddress = 0x77777FeDdddFfC19Ff86DB637967013e6C6A116C; + address public constant multisigAddress = 0xb04E030140b30C27bcdfaafFFA98C57d80eDa7B4; + address public constant governanceAddress = 0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce; + address public constant stakingAddress = 0x5B3f656C80E8ddb9ec01Dd9018815576E9238c29; + address public constant relayerRegistryAddress = 0x58E8dCC13BE9780fC42E8723D8EaD4CF46943dF2; + address public constant feeManagerAddress = 0x5f6c97C6AD7bdd0AE7E0Dd4ca33A4ED3fDabD4D7; + address public constant governanceVaultAddress = 0x2F50508a8a3D323B91336FA3eA6ae50E55f32185; +} diff --git a/test/ExampleProposal.t.sol b/test/ExampleProposal.t.sol new file mode 100644 index 0000000..9efd7bc --- /dev/null +++ b/test/ExampleProposal.t.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import { ProposalUtils } from "./utils/ProposalUtils.sol"; +import { ExampleProposal } from "@root/ExampleProposal.sol"; + +import { console2 } from "@forge-std/console2.sol"; + +contract TestExampleProposal is ProposalUtils { + modifier executeCurrentProposalBefore() { + createAndExecuteProposal(); + _; + } + + function createAndExecuteProposal() public { + address proposalAddress = address(new ExampleProposal()); /* your proposal initialization */ + + proposeAndExecute(proposalAddress); + } + + /* your tests */ + + function testProposal() public executeCurrentProposalBefore { } +} diff --git a/test/utils/Mock.sol b/test/utils/Mock.sol new file mode 100644 index 0000000..f3d0c00 --- /dev/null +++ b/test/utils/Mock.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import { IGovernance } from "@interfaces/IGovernance.sol"; + +contract Mock { + // Developer address with 22 staked TORN + address public constant TEST_REAL_ADDRESS_WITH_BALANCE = 0x9Ff3C1Bea9ffB56a78824FE29f457F066257DD58; + address public constant TEST_RELAYER_ADDRESS = 0x30F96AEF199B399B722F8819c9b0723016CEAe6C; // moon-relayer.eth (just for testing) + + // Address and private key to test staking, Governance lock and rewards accruals + address public constant TEST_STAKER_ADDRESS = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; + uint256 public constant TEST_STAKER_PRIVATE_KEY = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; + + // Test account to create proposal and vote for it + address public constant TEST_ADDRESS_ONE = 0x118251976c65AFAf291f5255450ddb5b6A4d8B88; + uint256 public constant TEST_PRIVATE_KEY_ONE = 0x66ddbd7cbe4a566df405f6ded0b908c669f88cdb1656380c050e3a457bd21df0; +} diff --git a/test/utils/MockProposal.sol b/test/utils/MockProposal.sol new file mode 100644 index 0000000..398e48d --- /dev/null +++ b/test/utils/MockProposal.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import { IGovernance } from "@interfaces/IGovernance.sol"; +import { TornadoAddresses } from "@proprietary/TornadoAddresses.sol"; + +import { Test } from "@forge-std/Test.sol"; +import { console2 } from "@forge-std/console2.sol"; + +contract MockProposal is TornadoAddresses, Test { + uint256 public PROPOSAL_VOTING_DURATION; + uint256 public PROPOSAL_LOCKED_DURATION; + uint256 public PROPOSAL_DURATION; + uint256 public PROPOSAL_EXECUTION_MAX_DURATION; + uint256 public PROPOSAL_QOURUM_THRESHOLD; + uint256 public PROPOSAL_VOTE_EXTEND_TIME; + string public constant PROPOSAL_DESCRIPTION = "{title:'Some proposal',description:''}"; + + function _fetchConfiguration() internal { + IGovernance governance = IGovernance(governanceAddress); + + PROPOSAL_LOCKED_DURATION = governance.EXECUTION_DELAY(); + PROPOSAL_EXECUTION_MAX_DURATION = governance.EXECUTION_EXPIRATION(); + PROPOSAL_QOURUM_THRESHOLD = governance.QUORUM_VOTES(); + PROPOSAL_VOTING_DURATION = governance.VOTING_PERIOD(); + PROPOSAL_VOTE_EXTEND_TIME = governance.VOTE_EXTEND_TIME(); + PROPOSAL_DURATION = PROPOSAL_VOTING_DURATION + PROPOSAL_LOCKED_DURATION; + } + + function setUp() public virtual { + // If fork block number unitialized, then set latest + if (block.number == 1) vm.createSelectFork(vm.rpcUrl("mainnet")); + else vm.createSelectFork(vm.rpcUrl("mainnet"), block.number); + + _fetchConfiguration(); + } +} diff --git a/test/utils/ProposalUtils.sol b/test/utils/ProposalUtils.sol new file mode 100644 index 0000000..31b9197 --- /dev/null +++ b/test/utils/ProposalUtils.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { Utils } from "./Utils.sol"; +import { Proposal, IGovernance } from "@interfaces/IGovernance.sol"; + +contract ProposalUtils is Utils { + IGovernance internal governance = IGovernance(governanceAddress); + + function getProposalExecutableTime(uint256 proposalId) internal view returns (uint256) { + Proposal memory proposal = getProposal(proposalId); + return proposal.endTime + PROPOSAL_LOCKED_DURATION + 1 seconds; + } + + function getProposal(uint256 proposalId) internal view returns (Proposal memory) { + return governance.proposals(proposalId); + } + + function hasProposal(uint256 proposalId) internal view returns (bool) { + return governance.proposalCount() >= proposalId; + } + + function waitUntilExecutable(uint256 proposalId) internal { + uint256 proposalExecutableTime = getProposalExecutableTime(proposalId); + require(block.timestamp < proposalExecutableTime + PROPOSAL_EXECUTION_MAX_DURATION, "Too late to execute proposal"); + + vm.warp(proposalExecutableTime); + } + + function proposeAndVote(address proposalAddress) public returns (uint256) { + retrieveAndLockBalance(TEST_PRIVATE_KEY_ONE, TEST_ADDRESS_ONE, PROPOSAL_QOURUM_THRESHOLD + 1 ether); + + /* ----------PROPOSE------------ */ + vm.startPrank(TEST_ADDRESS_ONE); + + uint256 proposalId = governance.propose(proposalAddress, PROPOSAL_DESCRIPTION); + + /* ------------------------------ */ + + // TIME-TRAVEL + vm.warp(block.timestamp + 6 hours); + + /* ------------VOTE-------------- */ + + governance.castVote(proposalId, true); + + vm.stopPrank(); + /* ------------------------------ */ + + return proposalId; + } + + function proposeAndExecute(address proposalAddress) public { + uint256 proposalId = proposeAndVote(proposalAddress); + + waitUntilExecutable(proposalId); + governance.execute(proposalId); + + returnTokensToGovernanceAfterVoting(proposalId); + } + + function returnTokensToGovernanceAfterVoting(uint256 proposalId) public { + uint256 retrievedAmount = PROPOSAL_QOURUM_THRESHOLD + 1 ether; + + uint256 proposalExecutableTime = getProposalExecutableTime(proposalId); + uint256 tokensUnlockTime = proposalExecutableTime + PROPOSAL_VOTE_EXTEND_TIME + PROPOSAL_EXECUTION_MAX_DURATION + 1 seconds; + + if (block.timestamp < tokensUnlockTime) vm.warp(tokensUnlockTime); + + vm.startPrank(TEST_ADDRESS_ONE); + + governance.unlock(retrievedAmount); + IERC20(tornTokenAddress).transfer(governanceAddress, retrievedAmount); + + vm.stopPrank(); + } +} diff --git a/test/utils/Utils.sol b/test/utils/Utils.sol new file mode 100644 index 0000000..51b1acd --- /dev/null +++ b/test/utils/Utils.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; +import { Test } from "@forge-std/Test.sol"; + +import { TornadoAddresses } from "@proprietary/TornadoAddresses.sol"; +import { Mock } from "./Mock.sol"; +import { MockProposal } from "./MockProposal.sol"; +import { IGovernance } from "@interfaces/IGovernance.sol"; + +contract Utils is TornadoAddresses, Test, Mock, MockProposal { + address public constant VERIFIER_ADDRESS = tornTokenAddress; + + bytes32 public constant PERMIT_TYPEHASH = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + + bytes32 public constant EIP712_DOMAIN = keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes("TornadoCash")), + keccak256(bytes("1")), + 1, + VERIFIER_ADDRESS + ) + ); + + uint16 public constant PERMIT_FUNC_SELECTOR = uint16(0x1901); + + function retrieveAndLockBalance(uint256 privateKey, address voter, uint256 amount) public { + uint256 lockTimestamp = block.timestamp + PROPOSAL_DURATION; + uint256 accountNonce = ERC20Permit(tornTokenAddress).nonces(voter); + + bytes32 messageHash = keccak256( + abi.encodePacked( + PERMIT_FUNC_SELECTOR, + EIP712_DOMAIN, + keccak256(abi.encode(PERMIT_TYPEHASH, voter, governanceAddress, amount, accountNonce, lockTimestamp)) + ) + ); + + /* ----------GOVERNANCE------- */ + vm.startPrank(governanceAddress); + IERC20(tornTokenAddress).transfer(voter, amount); + vm.stopPrank(); + /* ----------------------------*/ + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, messageHash); + + /* ----------VOTER------------ */ + vm.startPrank(voter); + IGovernance(governanceAddress).lock(voter, amount, lockTimestamp, v, r, s); + vm.stopPrank(); + /* ----------------------------*/ + } +}