diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c90deea..ba36b9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,3 +52,4 @@ jobs: FOUNDRY_PROFILE: pr FORGE_SNAPSHOT_CHECK: true MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} + UNICHAIN_RPC_URL: ${{ secrets.UNICHAIN_RPC_URL }} diff --git a/.gitmodules b/.gitmodules index 0e66edc..b7a268c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -25,3 +25,6 @@ [submodule "lib/BokkyPooBahsDateTimeLibrary"] path = lib/BokkyPooBahsDateTimeLibrary url = https://github.com/bokkypoobah/BokkyPooBahsDateTimeLibrary +[submodule "lib/dao-signer"] + path = lib/dao-signer + url = https://github.com/ScopeLift/dao-signer diff --git a/foundry.lock b/foundry.lock index 471609d..9847bcb 100644 --- a/foundry.lock +++ b/foundry.lock @@ -5,6 +5,9 @@ "lib/briefcase": { "rev": "62c718ec25b64ecc95b4e09c2751134d78b89a4f" }, + "lib/dao-signer": { + "rev": "ee1e73fbda9b3328aca30d9e42ca78819f4383a3" + }, "lib/forge-std": { "rev": "77041d2ce690e692d6e03cc812b57d1ddaa4d505" }, diff --git a/foundry.toml b/foundry.toml index 73c8e0d..cda5489 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,39 +1,41 @@ [profile.default] - evm_version = "cancun" - optimizer = true - optimizer_runs = 10_000_000 - solc_version = "0.8.29" - verbosity = 3 - gas_limit = "9223372036854775807" - no_match_test = "test_fuzz_gas_release_malicious" +evm_version = "cancun" +optimizer = true +optimizer_runs = 10_000_000 +solc_version = "0.8.29" +verbosity = 3 +gas_limit = "9223372036854775807" +no_match_test = "test_fuzz_gas_release_malicious" [rpc_endpoints] - mainnet = "${MAINNET_RPC_URL}" +mainnet = "${MAINNET_RPC_URL}" +unichain = "${UNICHAIN_RPC_URL}" [profile.ci] - fuzz = { runs = 5000 } - invariant = { runs = 1000 } +fuzz = { runs = 5000 } +invariant = { runs = 1000 } [profile.coverage] - fuzz = { runs = 100 } - invariant = { runs = 0 } +fuzz = { runs = 100 } +invariant = { runs = 0 } [profile.lite] - fuzz = { runs = 50 } - invariant = { runs = 10 } - # Speed up compilation and tests during development. - optimizer = false +fuzz = { runs = 50 } +invariant = { runs = 10 } +# Speed up compilation and tests during development. +optimizer = false [fmt] - bracket_spacing = false - int_types = "long" - line_length = 100 - multiline_func_header = "attributes_first" - number_underscore = "thousands" - quote_style = "double" - single_line_statement_blocks = "single" - tab_width = 2 - wrap_comments = true +bracket_spacing = false +int_types = "long" +line_length = 100 +multiline_func_header = "attributes_first" +number_underscore = "thousands" +quote_style = "double" +single_line_statement_blocks = "single" +tab_width = 2 +wrap_comments = true [lint] -exclude_lints = ["mixed-case-variable", "mixed-case-function"] \ No newline at end of file +exclude_lints = ["mixed-case-variable", "mixed-case-function"] + diff --git a/lib/dao-signer b/lib/dao-signer new file mode 160000 index 0000000..ee1e73f --- /dev/null +++ b/lib/dao-signer @@ -0,0 +1 @@ +Subproject commit ee1e73fbda9b3328aca30d9e42ca78819f4383a3 diff --git a/remappings.txt b/remappings.txt index 99b9585..f3b8e8e 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,2 +1,3 @@ solmate/=lib/solmate/ @eth-optimism-bedrock/=lib/optimism/packages/contracts-bedrock +eas-contracts/=lib/dao-signer/lib/eas-contracts/contracts/ diff --git a/script/01_DeployMainnet.s.sol b/script/01_DeployMainnet.s.sol new file mode 100644 index 0000000..efb4db1 --- /dev/null +++ b/script/01_DeployMainnet.s.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {console2} from "forge-std/console2.sol"; +import "forge-std/Script.sol"; +import {MainnetDeployer} from "./deployers/MainnetDeployer.sol"; + +contract DeployMainnet is Script { + function setUp() public {} + + function run() public { + require(block.chainid == 1, "Not mainnet"); + + vm.startBroadcast(); + + MainnetDeployer deployer = new MainnetDeployer(); + console2.log("Deployed Deployer at:", address(deployer)); + console2.log("TOKEN_JAR at:", address(deployer.TOKEN_JAR())); + console2.log("RELEASER at:", address(deployer.RELEASER())); + console2.log("V3_FEE_ADAPTER at:", address(deployer.V3_FEE_ADAPTER())); + console2.log("UNI_VESTING at:", address(deployer.UNI_VESTING())); + + vm.stopBroadcast(); + } +} diff --git a/script/02_DeployUnichain.s.sol b/script/02_DeployUnichain.s.sol new file mode 100644 index 0000000..c91961e --- /dev/null +++ b/script/02_DeployUnichain.s.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {console2} from "forge-std/console2.sol"; +import {Script} from "forge-std/Script.sol"; +import {UnichainDeployer} from "./deployers/UnichainDeployer.sol"; +import {OptimismBridgedResourceFirepit} from "../src/releasers/OptimismBridgedResourceFirepit.sol"; + +contract DeployUnichain is Script { + function setUp() public {} + + function run() public { + require(block.chainid == 130, "Not Unichain"); + + vm.startBroadcast(); + + UnichainDeployer deployer = new UnichainDeployer(); + console2.log("Deployed Deployer at:", address(deployer)); + console2.log("TOKEN_JAR at:", address(deployer.TOKEN_JAR())); + console2.log("RELEASER at:", address(deployer.RELEASER())); + + vm.stopBroadcast(); + } +} diff --git a/script/03_CreateAgreementAnchorsMainnet.s.sol b/script/03_CreateAgreementAnchorsMainnet.s.sol new file mode 100644 index 0000000..5d7fdc5 --- /dev/null +++ b/script/03_CreateAgreementAnchorsMainnet.s.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.29; + +import {Script, console2} from "forge-std/Script.sol"; +import {IAgreementAnchorFactory} from "dao-signer/src/interfaces/IAgreementAnchorFactory.sol"; +import {AgreementAnchor} from "dao-signer/src/AgreementAnchor.sol"; + +contract CreateAgreementAnchors is Script { + IAgreementAnchorFactory public constant AGREEMENT_ANCHOR_FACTORY = + IAgreementAnchorFactory(0x5Ef3cCf9eC7E0af61E1767b2EEbB50e052b5Df47); + + // TODO: set content hashes and counterparty addresses for DUNI agreements + bytes32 public constant AGREEMENT_ANCHOR_1_CONTENT_HASH = ""; + address public constant AGREEMENT_ANCHOR_1_COUNTER_SIGNER = address(0); + bytes32 public constant AGREEMENT_ANCHOR_2_CONTENT_HASH = ""; + address public constant AGREEMENT_ANCHOR_2_COUNTER_SIGNER = address(0); + + function run() public returns (address, address) { + require(block.chainid == 1, "Not mainnet"); + vm.startBroadcast(); + address agreementAnchor1 = address( + AGREEMENT_ANCHOR_FACTORY.createAgreementAnchor( + AGREEMENT_ANCHOR_1_CONTENT_HASH, AGREEMENT_ANCHOR_1_COUNTER_SIGNER + ) + ); + + address agreementAnchor2 = address( + AGREEMENT_ANCHOR_FACTORY.createAgreementAnchor( + AGREEMENT_ANCHOR_2_CONTENT_HASH, AGREEMENT_ANCHOR_2_COUNTER_SIGNER + ) + ); + console2.log("Agreement Anchor 1:", agreementAnchor1); + console2.log("Agreement Anchor 2:", agreementAnchor2); + vm.stopBroadcast(); + return (agreementAnchor1, agreementAnchor2); + } +} diff --git a/script/04_UnificationProposal.s.sol b/script/04_UnificationProposal.s.sol new file mode 100644 index 0000000..a4a0d68 --- /dev/null +++ b/script/04_UnificationProposal.s.sol @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {console2} from "forge-std/console2.sol"; +import {AttestationRequestData, AttestationRequest, IEAS} from "eas-contracts/IEAS.sol"; +import {Script} from "forge-std/Script.sol"; +import {StdAssertions} from "forge-std/StdAssertions.sol"; +import {MainnetDeployer} from "./deployers/MainnetDeployer.sol"; +import {IUniswapV2Factory} from "briefcase/protocols/v2-core/interfaces/IUniswapV2Factory.sol"; +import {IUniswapV3Factory} from "briefcase/protocols/v3-core/interfaces/IUniswapV3Factory.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {AgreementAnchor} from "dao-signer/src/AgreementAnchor.sol"; +import {AgreementResolver} from "dao-signer/src/AgreementResolver.sol"; + +struct ProposalAction { + address target; + uint256 value; + string signature; + bytes data; +} + +contract UnificationProposal is Script, StdAssertions { + // TODO: Fill in these values + AgreementAnchor public constant AGREEMENT_ANCHOR_1 = + AgreementAnchor(0x0000000000000000000000000000000000000000); + AgreementAnchor public constant AGREEMENT_ANCHOR_2 = + AgreementAnchor(0x0000000000000000000000000000000000000000); + string public constant PROPOSAL_DESCRIPTION = ""; + + IGovernorBravo internal constant GOVERNOR_BRAVO = + IGovernorBravo(0x408ED6354d4973f66138C91495F2f2FCbd8724C3); + IERC20 UNI = IERC20(0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984); + IUniswapV2Factory public V2_FACTORY = + IUniswapV2Factory(0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f); + IUniswapV3Factory public constant V3_FACTORY = + IUniswapV3Factory(0x1F98431c8aD98523631AE4a59f267346ea31F984); + address public constant OLD_FEE_TO_SETTER = 0x18e433c7Bf8A2E1d0197CE5d8f9AFAda1A771360; + + // EAS Constants + IEAS internal constant EAS = IEAS(0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587); + bytes32 public constant AGREEMENT_SCHEMA_UID = + 0x504f10498bcdb19d4960412dbade6fa1530b8eed65c319f15cbe20fadafe56bd; + + function setUp() public {} + + function run(MainnetDeployer deployer) public { + vm.startBroadcast(); + ProposalAction[] memory actions = _run(deployer); + console2.log("Calldata details:"); + for (uint256 i = 0; i < actions.length; i++) { + ProposalAction memory action = actions[i]; + assertTrue(action.target != address(0)); + console2.log("Action #", i); + console2.log("Target", action.target); + console2.log("Value", action.value); + console2.log("Signature"); + console2.log(action.signature); + console2.log("Calldata", i); + console2.logBytes(action.data); + console2.log("--------------------------------"); + } + + console2.log("Description:"); + console2.log(PROPOSAL_DESCRIPTION); + // Prepare GovernorBravo propose() parameters + address[] memory targets = new address[](actions.length); + uint256[] memory values = new uint256[](actions.length); + string[] memory signatures = new string[](actions.length); + bytes[] memory calldatas = new bytes[](actions.length); + for (uint256 i = 0; i < actions.length; i++) { + ProposalAction memory action = actions[i]; + targets[i] = action.target; + values[i] = action.value; + signatures[i] = action.signature; + calldatas[i] = action.data; + } + + bytes memory proposalCalldata = abi.encodeCall( + IGovernorBravo.propose, (targets, values, signatures, calldatas, PROPOSAL_DESCRIPTION) + ); + console2.log("GovernorBravo.propose() Calldata:"); + console2.logBytes(proposalCalldata); + + GOVERNOR_BRAVO.propose(targets, values, signatures, calldatas, PROPOSAL_DESCRIPTION); + vm.stopBroadcast(); + } + + function runAnvil(MainnetDeployer deployer) public { + vm.startBroadcast(V3_FACTORY.owner()); + ProposalAction[] memory actions = _run(deployer); + for (uint256 i = 0; i < actions.length; i++) { + ProposalAction memory action = actions[i]; + (bool success,) = action.target.call{value: action.value}(action.data); + require(success, "Call failed"); + } + vm.stopBroadcast(); + } + + function runPranked(MainnetDeployer deployer) public { + vm.startPrank(V3_FACTORY.owner()); + ProposalAction[] memory actions = _run(deployer); + for (uint256 i = 0; i < actions.length; i++) { + ProposalAction memory action = actions[i]; + (bool success,) = action.target.call{value: action.value}(action.data); + require(success, "Call failed"); + } + vm.stopPrank(); + } + + function _run(MainnetDeployer deployer) public returns (ProposalAction[] memory actions) { + address timelock = deployer.V3_FACTORY().owner(); + + // --- Proposal Actions Setup --- + actions = new ProposalAction[](7); + + // Burn 100M UNI + actions[0] = ProposalAction({ + target: address(UNI), + value: 0, + signature: "", + data: abi.encodeCall(UNI.transfer, (address(0xdead), 100_000_000 ether)) + }); + + // Set the owner of the v3 factory to the configured fee controller + actions[1] = ProposalAction({ + target: address(V3_FACTORY), + value: 0, + signature: "", + data: abi.encodeCall(V3_FACTORY.setOwner, (address(deployer.V3_FEE_ADAPTER()))) + }); + + // Update the v2 fee to setter to the timelock + actions[2] = ProposalAction({ + target: address(OLD_FEE_TO_SETTER), + value: 0, + signature: "", + data: abi.encodeCall(IFeeToSetter.setFeeToSetter, (timelock)) + }); + + // Set the recipient of v2 protocol fees to the token jar + actions[3] = ProposalAction({ + target: address(V2_FACTORY), + value: 0, + signature: "", + data: abi.encodeCall(V2_FACTORY.setFeeTo, (address(deployer.TOKEN_JAR()))) + }); + + // Approve two years of vesting to the UNIvester smart contract + // UNI stays in treasury until vested and unvested UNI can be cancelled by setting approve back + // to 0 + actions[4] = ProposalAction({ + target: address(UNI), + value: 0, + signature: "", + data: abi.encodeCall(UNI.approve, (address(deployer.UNI_VESTING()), 40_000_000 ether)) + }); + + // DAO attests to Agreement 1 + if (address(AGREEMENT_ANCHOR_1) != address(0)) { + actions[5] = ProposalAction({ + target: address(EAS), + value: 0, + signature: "", + data: abi.encodeCall( + EAS.attest, + (AttestationRequest({ + schema: AGREEMENT_SCHEMA_UID, + data: AttestationRequestData({ + recipient: address(AGREEMENT_ANCHOR_1), + expirationTime: 0, + revocable: false, + refUID: bytes32(0), + data: abi.encode(AGREEMENT_ANCHOR_1.CONTENT_HASH()), + value: 0 + }) + })) + ) + }); + } + + // DAO attests to Agreement 2 + if (address(AGREEMENT_ANCHOR_2) != address(0)) { + actions[6] = ProposalAction({ + target: address(EAS), + value: 0, + signature: "", + data: abi.encodeCall( + EAS.attest, + (AttestationRequest({ + schema: AGREEMENT_SCHEMA_UID, + data: AttestationRequestData({ + recipient: address(AGREEMENT_ANCHOR_2), + expirationTime: 0, + revocable: false, + refUID: bytes32(0), + data: abi.encode(AGREEMENT_ANCHOR_2.CONTENT_HASH()), + value: 0 + }) + })) + ) + }); + } + } +} + +// interface for: +// https://etherscan.io/address/0x18e433c7Bf8A2E1d0197CE5d8f9AFAda1A771360#code +// the current V2_FACTORY.feeToSetter() +interface IFeeToSetter { + function setFeeToSetter(address) external; +} + +interface IGovernorBravo { + function propose( + address[] memory targets, + uint256[] memory values, + string[] memory signatures, + bytes[] memory calldatas, + string memory description + ) external returns (uint256); +} diff --git a/script/deployers/MainnetDeployer.sol b/script/deployers/MainnetDeployer.sol new file mode 100644 index 0000000..7a56b13 --- /dev/null +++ b/script/deployers/MainnetDeployer.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.29; + +import {V3FeeAdapter} from "../../src/feeAdapters/V3FeeAdapter.sol"; +import {ITokenJar} from "../../src/interfaces/ITokenJar.sol"; +import {TokenJar} from "../../src/TokenJar.sol"; +import {Firepit} from "../../src/releasers/Firepit.sol"; +import {IUNIVesting} from "../../src/interfaces/IUNIVesting.sol"; +import {UNIVesting} from "../../src/UNIVesting.sol"; +import {IReleaser} from "../../src/interfaces/IReleaser.sol"; +import {IV3FeeAdapter} from "../../src/interfaces/IV3FeeAdapter.sol"; +import {IOwned} from "../../src/interfaces/base/IOwned.sol"; +import {IUniswapV3Factory} from "v3-core/contracts/interfaces/IUniswapV3Factory.sol"; + +/// @title Deployer +/// @notice A deployment contract for the Uniswap fee collection infrastructure +/// @dev Deploys and configures TokenJar, Firepit Releaser, and V3FeeAdapter contracts +/// in a single transaction with deterministic addresses using CREATE2 +/// @custom:security-contact security@uniswap.org +contract MainnetDeployer { + /// @notice The deployed TokenJar contract instance + /// @dev Immutable reference to the fee collection destination contract + ITokenJar public immutable TOKEN_JAR; + + /// @notice The deployed Releaser contract instance + /// @dev Immutable reference to the Firepit releaser contract + IReleaser public immutable RELEASER; + + /// @notice The deployed V3FeeAdapter contract instance + /// @dev Immutable reference to the fee adapter for V3 pools + IV3FeeAdapter public immutable V3_FEE_ADAPTER; + IUNIVesting public immutable UNI_VESTING; + + /// @notice The UNI token address used as the resource token for the releaser + /// @dev Address of the UNI token on mainnet + address public constant RESOURCE = 0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984; + + /// @notice The initial threshold amount of UNI tokens required for release + /// @dev Set to 4000 UNI tokens as the initial release threshold + uint256 public constant THRESHOLD = 4000e18; + + /// @notice The Uniswap V3 Factory contract address + /// @dev Reference to the mainnet V3 Factory for ownership transfer + IUniswapV3Factory public constant V3_FACTORY = + IUniswapV3Factory(0x1F98431c8aD98523631AE4a59f267346ea31F984); + address public constant LABS_UNI_RECIPIENT = 0xaBA63748c4b4DeF4a3319C3A29fE4829029D926F; + + // Using the real merkle root from the generated merkle tree in ./merkle-generator + // TODO: Regenerate the merkle tree + bytes32 constant INITIAL_MERKLE_ROOT = + bytes32(0x472c8960ea78de635eb7e32c5085f9fb963e626b5a68c939bfad24e022383b3a); + + uint8 constant DEFAULT_FEE_100 = 4 << 4 | 4; // default fee for 0.01% tier + uint8 constant DEFAULT_FEE_500 = 4 << 4 | 4; // default fee for 0.05% tier + uint8 constant DEFAULT_FEE_3000 = 6 << 4 | 6; // default fee for 0.3% tier + uint8 constant DEFAULT_FEE_10000 = 6 << 4 | 6; // default fee for 1% tier + + /// @dev CREATE2 salt for deterministic TokenJar deployment + bytes32 constant SALT_TOKEN_JAR = bytes32(uint256(1)); + /// @dev CREATE2 salt for deterministic Releaser deployment + bytes32 constant SALT_RELEASER = bytes32(uint256(2)); + /// @dev CREATE2 salt for deterministic FeeAdapter deployment + bytes32 constant SALT_V3_FEE_ADAPTER = bytes32(uint256(3)); + /// @dev CREATE2 salt for deterministic UNIVesting deployment + bytes32 constant SALT_UNI_VESTING = bytes32(uint256(4)); + + /// @notice Deploys and configures the entire fee collection infrastructure + /// @dev Performs the following operations in sequence: + /// TOKEN JAR: + /// 1. Deploy the TokenJar + /// 3. Set the releaser on the token jar + /// 4. Update the owner on the token jar + /// + /// RELEASER: + /// 2. Deploy the Releaser + /// 5. Update the thresholdSetter on the releaser to the owner + /// 6. Update the owner on the releaser + /// + /// FEE_ADAPTER: + /// 7. Deploy the FeeAdapter. + /// 8. Set this contract as the feeSetter + /// 9. Set initial merkle root + /// 10. Set default fees + /// 11. Update the feeSetter to the owner. + /// 12. Store fee tiers. + /// 13. Update the owner on the fee adapter. + /// + /// UNI_VESTING: + /// 14. Deploy the UNIVesting contract. + /// 15. Update the owner on the UNIVesting contract. + /// + /// All ownership is transferred to the current V3Factory owner + constructor() { + address owner = V3_FACTORY.owner(); + /// 1. Deploy the TokenJar. + TOKEN_JAR = new TokenJar{salt: SALT_TOKEN_JAR}(); + /// 2. Deploy the Releaser. + RELEASER = new Firepit{salt: SALT_RELEASER}(RESOURCE, THRESHOLD, address(TOKEN_JAR)); + /// 3. Set the releaser on the token jar. + TOKEN_JAR.setReleaser(address(RELEASER)); + /// 4. Update the owner on the token jar. + IOwned(address(TOKEN_JAR)).transferOwnership(owner); + + /// 5. Update the thresholdSetter on the releaser to the owner. + RELEASER.setThresholdSetter(owner); + /// 6. Update the owner on the releaser. + IOwned(address(RELEASER)).transferOwnership(owner); + + /// 7. Deploy the FeeAdapter. + V3_FEE_ADAPTER = + new V3FeeAdapter{salt: SALT_V3_FEE_ADAPTER}(address(V3_FACTORY), address(TOKEN_JAR)); + + /// 8. Set this contract as the feeSetter + V3_FEE_ADAPTER.setFeeSetter(address(this)); + + /// 9. Set initial merkle root + V3_FEE_ADAPTER.setMerkleRoot(INITIAL_MERKLE_ROOT); + + /// 10. Set default fees + V3_FEE_ADAPTER.setDefaultFeeByFeeTier(100, DEFAULT_FEE_100); + V3_FEE_ADAPTER.setDefaultFeeByFeeTier(500, DEFAULT_FEE_500); + V3_FEE_ADAPTER.setDefaultFeeByFeeTier(3000, DEFAULT_FEE_3000); + V3_FEE_ADAPTER.setDefaultFeeByFeeTier(10_000, DEFAULT_FEE_10000); + + /// 11. Update the feeSetter to the owner. + V3_FEE_ADAPTER.setFeeSetter(owner); + + /// 12. Store fee tiers. + V3_FEE_ADAPTER.storeFeeTier(100); + V3_FEE_ADAPTER.storeFeeTier(500); + V3_FEE_ADAPTER.storeFeeTier(3000); + V3_FEE_ADAPTER.storeFeeTier(10_000); + + /// 13. Update the owner on the fee adapter. + IOwned(address(V3_FEE_ADAPTER)).transferOwnership(owner); + + /// 14. Deploy the UNIVesting contract. + UNI_VESTING = + IUNIVesting(new UNIVesting{salt: SALT_UNI_VESTING}(address(RESOURCE), LABS_UNI_RECIPIENT)); + + /// 15. Update the owner on the UNIVesting contract. + IOwned(address(UNI_VESTING)).transferOwnership(owner); + } +} diff --git a/script/deployers/UnichainDeployer.sol b/script/deployers/UnichainDeployer.sol new file mode 100644 index 0000000..9fb752b --- /dev/null +++ b/script/deployers/UnichainDeployer.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.29; + +import {ITokenJar} from "../../src/interfaces/ITokenJar.sol"; +import {TokenJar} from "../../src/TokenJar.sol"; +import {IReleaser} from "../../src/interfaces/IReleaser.sol"; +import {IOwned} from "../../src/interfaces/base/IOwned.sol"; +import { + OptimismBridgedResourceFirepit +} from "../../src/releasers/OptimismBridgedResourceFirepit.sol"; + +contract UnichainDeployer { + ITokenJar public immutable TOKEN_JAR; + IReleaser public immutable RELEASER; + + // Native Bridge UNI + address public constant RESOURCE = 0x8f187aA05619a017077f5308904739877ce9eA21; + uint256 public constant THRESHOLD = 2000e18; + // UNI Timelock alias address on Unichain + // Calculated from the aliasing scheme defined here + // https://docs.optimism.io/concepts/stack/differences#address-aliasing + // targeting 0x1a9C8182C09F50C8318d769245beA52c32BE35BC on mainnet + address public constant OWNER = 0x2BAD8182C09F50c8318d769245beA52C32Be46CD; + + bytes32 constant SALT_TOKEN_JAR = bytes32(uint256(1)); + bytes32 constant SALT_RELEASER = bytes32(uint256(2)); + + //// TOKEN JAR: + /// 1. Deploy the TokenJar + /// 3. Set the releaser on the token jar. + /// 4. Update the owner on the token jar. + + /// RELEASER: + /// 2. Deploy the Releaser. + /// 5. Update the thresholdSetter on the releaser to the owner. + /// 6. Update the owner on the releaser. + constructor() { + /// 1. Deploy the TokenJar. + TOKEN_JAR = new TokenJar{salt: SALT_TOKEN_JAR}(); + /// 2. Deploy the Releaser. + RELEASER = new OptimismBridgedResourceFirepit{salt: SALT_RELEASER}( + RESOURCE, THRESHOLD, address(TOKEN_JAR) + ); + /// 3. Set the releaser on the token jar. + TOKEN_JAR.setReleaser(address(RELEASER)); + /// 4. Update the owner on the token jar. + IOwned(address(TOKEN_JAR)).transferOwnership(OWNER); + + /// 5. Update the thresholdSetter on the releaser to the owner. + RELEASER.setThresholdSetter(OWNER); + /// 6. Update the owner on the releaser. + IOwned(address(RELEASER)).transferOwnership(OWNER); + } +} diff --git a/src/Deployer.sol b/src/Deployer.sol deleted file mode 100644 index afe0e3f..0000000 --- a/src/Deployer.sol +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -pragma solidity ^0.8.29; - -import {V3FeeAdapter} from "./feeAdapters/V3FeeAdapter.sol"; -import {ITokenJar} from "./interfaces/ITokenJar.sol"; -import {TokenJar} from "./TokenJar.sol"; -import {Firepit} from "./releasers/Firepit.sol"; -import {IReleaser} from "./interfaces/IReleaser.sol"; -import {IV3FeeAdapter} from "./interfaces/IV3FeeAdapter.sol"; -import {IOwned} from "./interfaces/base/IOwned.sol"; -import {IUniswapV3Factory} from "v3-core/contracts/interfaces/IUniswapV3Factory.sol"; - -/// @title Deployer -/// @notice A deployment contract for the Uniswap fee collection infrastructure -/// @dev Deploys and configures TokenJar, Firepit Releaser, and V3FeeAdapter contracts -/// in a single transaction with deterministic addresses using CREATE2 -/// @custom:security-contact security@uniswap.org -contract Deployer { - /// @notice The deployed TokenJar contract instance - /// @dev Immutable reference to the fee collection destination contract - ITokenJar public immutable TOKEN_JAR; - - /// @notice The deployed Releaser contract instance - /// @dev Immutable reference to the Firepit releaser contract - IReleaser public immutable RELEASER; - - /// @notice The deployed V3FeeAdapter contract instance - /// @dev Immutable reference to the fee adapter for V3 pools - IV3FeeAdapter public immutable FEE_ADAPTER; - - /// @notice The UNI token address used as the resource token for the releaser - /// @dev Address of the UNI token on mainnet - address public constant RESOURCE = 0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984; - - /// @notice The initial threshold amount of UNI tokens required for release - /// @dev Set to 69,420 UNI tokens as the initial release threshold - uint256 public constant THRESHOLD = 69_420; - - /// @notice The Uniswap V3 Factory contract address - /// @dev Reference to the mainnet V3 Factory for ownership transfer - IUniswapV3Factory public constant V3_FACTORY = - IUniswapV3Factory(0x1F98431c8aD98523631AE4a59f267346ea31F984); - - /// @dev CREATE2 salt for deterministic TokenJar deployment - bytes32 constant SALT_TOKEN_JAR = 0; - - /// @dev CREATE2 salt for deterministic Releaser deployment - bytes32 constant SALT_RELEASER = 0; - - /// @dev CREATE2 salt for deterministic FeeAdapter deployment - bytes32 constant SALT_FEE_ADAPTER = 0; - - /// @notice Deploys and configures the entire fee collection infrastructure - /// @dev Performs the following operations in sequence: - /// TOKEN JAR: - /// 1. Deploy the TokenJar - /// 3. Set the releaser on the token jar - /// 4. Update the owner on the token jar - /// - /// RELEASER: - /// 2. Deploy the Releaser - /// 5. Update the thresholdSetter on the releaser to the owner - /// 6. Update the owner on the releaser - /// - /// FEE_ADAPTER: - /// 7. Deploy the FeeAdapter - /// 8. Update the feeSetter to the owner - /// 9. Store fee tiers (100, 500, 3000, 10000) - /// 10. Update the owner on the fee adapter - /// - /// All ownership is transferred to the current V3Factory owner - constructor() { - address owner = V3_FACTORY.owner(); - /// 1. Deploy the TokenJar. - TOKEN_JAR = new TokenJar{salt: SALT_TOKEN_JAR}(); - /// 2. Deploy the Releaser. - RELEASER = new Firepit{salt: SALT_RELEASER}(RESOURCE, THRESHOLD, address(TOKEN_JAR)); - /// 3. Set the releaser on the token jar. - TOKEN_JAR.setReleaser(address(RELEASER)); - /// 4. Update the owner on the token jar. - IOwned(address(TOKEN_JAR)).transferOwnership(owner); - - /// 5. Update the thresholdSetter on the releaser to the owner. - RELEASER.setThresholdSetter(owner); - /// 6. Update the owner on the releaser. - IOwned(address(RELEASER)).transferOwnership(owner); - - /// 7. Deploy the FeeAdapter. - FEE_ADAPTER = new V3FeeAdapter{salt: SALT_FEE_ADAPTER}(address(V3_FACTORY), address(TOKEN_JAR)); - - /// 8. Update the feeSetter to the owner. - FEE_ADAPTER.setFeeSetter(owner); - - /// 9. Store fee tiers. - FEE_ADAPTER.storeFeeTier(100); - FEE_ADAPTER.storeFeeTier(500); - FEE_ADAPTER.storeFeeTier(3000); - FEE_ADAPTER.storeFeeTier(10_000); - - /// 10. Update the owner on the fee adapter. - IOwned(address(FEE_ADAPTER)).transferOwnership(owner); - } -} diff --git a/src/releasers/OptimismBridgedResourceFirepit.sol b/src/releasers/OptimismBridgedResourceFirepit.sol index 9a21eb2..2e90431 100644 --- a/src/releasers/OptimismBridgedResourceFirepit.sol +++ b/src/releasers/OptimismBridgedResourceFirepit.sol @@ -18,7 +18,7 @@ import {ExchangeReleaser} from "./ExchangeReleaser.sol"; /// - L2StandardBridge burns the L2 tokens held by this contract /// - Cross-domain message is queued (7-day challenge period on mainnet) /// - L1StandardBridge finalizes withdrawal and transfers tokens to 0xdead on L1 -abstract contract OptimismBridgedResourceFirepit is ExchangeReleaser { +contract OptimismBridgedResourceFirepit is ExchangeReleaser { /// @dev The minimum gas limit for the withdrawal transaction to L1. /// @dev Gas required for a simple UNI transfer to 0xdead on L1 uint32 internal constant WITHDRAWAL_MIN_GAS = 100_000; diff --git a/test/CreateAgreementAnchorsMainnet.fork.t.sol b/test/CreateAgreementAnchorsMainnet.fork.t.sol new file mode 100644 index 0000000..3b78869 --- /dev/null +++ b/test/CreateAgreementAnchorsMainnet.fork.t.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {CreateAgreementAnchors} from "script/03_CreateAgreementAnchorsMainnet.s.sol"; + +contract CreateAgreementAnchorsMainnetForkTest is Test { + CreateAgreementAnchors public script; + + function setUp() public { + vm.createSelectFork("mainnet"); + script = new CreateAgreementAnchors(); + } + + function test_RevertIf_wrongChainId() public { + vm.chainId(31_337); + vm.expectRevert("Not mainnet"); + script.run(); + } + + function test_createsAgreementAnchors() public { + (address agreementAnchor1, address agreementAnchor2) = script.run(); + assertEq( + IAgreementAnchor(agreementAnchor1).CONTENT_HASH(), script.AGREEMENT_ANCHOR_1_CONTENT_HASH() + ); + assertEq( + IAgreementAnchor(agreementAnchor2).CONTENT_HASH(), script.AGREEMENT_ANCHOR_2_CONTENT_HASH() + ); + assertEq( + IAgreementAnchor(agreementAnchor2).PARTY_B(), script.AGREEMENT_ANCHOR_1_COUNTER_SIGNER() + ); + assertEq( + IAgreementAnchor(agreementAnchor2).PARTY_B(), script.AGREEMENT_ANCHOR_2_COUNTER_SIGNER() + ); + } +} + +interface IAgreementAnchor { + function PARTY_A() external view returns (address); + function PARTY_B() external view returns (address); + function CONTENT_HASH() external view returns (bytes32); +} diff --git a/test/Firepit.t.sol b/test/Firepit.t.sol index b02c763..670af07 100644 --- a/test/Firepit.t.sol +++ b/test/Firepit.t.sol @@ -8,8 +8,6 @@ import {INonce} from "../src/interfaces/base/INonce.sol"; import {IOwned} from "../src/interfaces/base/IOwned.sol"; import {IResourceManager} from "../src/interfaces/base/IResourceManager.sol"; import {IReleaser} from "../src/interfaces/IReleaser.sol"; -import {Firepit} from "../src/releasers/Firepit.sol"; -import {ExchangeReleaser} from "../src/releasers/ExchangeReleaser.sol"; contract FirepitTest is ProtocolFeesTestBase { function setUp() public override { diff --git a/test/Deployer.t.sol b/test/MainnetDeployer.t.sol similarity index 90% rename from test/Deployer.t.sol rename to test/MainnetDeployer.t.sol index d07daff..72d6b31 100644 --- a/test/Deployer.t.sol +++ b/test/MainnetDeployer.t.sol @@ -6,14 +6,14 @@ import { UniswapV3FactoryDeployer, IUniswapV3Factory } from "briefcase/deployers/v3-core/UniswapV3FactoryDeployer.sol"; -import {Deployer} from "../src/Deployer.sol"; +import {MainnetDeployer} from "../script/deployers/MainnetDeployer.sol"; import {ITokenJar} from "../src/interfaces/ITokenJar.sol"; import {IReleaser} from "../src/interfaces/IReleaser.sol"; import {IOwned} from "../src/interfaces/base/IOwned.sol"; import {IV3FeeAdapter} from "../src/interfaces/IV3FeeAdapter.sol"; contract DeployerTest is Test { - Deployer public deployer; + MainnetDeployer public deployer; IUniswapV3Factory public factory; @@ -40,11 +40,11 @@ contract DeployerTest is Test { factory.enableFeeAmount(10_000, 200); vm.stopPrank(); - deployer = new Deployer(); + deployer = new MainnetDeployer(); tokenJar = deployer.TOKEN_JAR(); releaser = deployer.RELEASER(); - feeAdapter = deployer.FEE_ADAPTER(); + feeAdapter = deployer.V3_FEE_ADAPTER(); } function test_deployer_tokenJar_setUp() public view { @@ -55,7 +55,7 @@ contract DeployerTest is Test { function test_deployer_releaser_setUp() public view { assertEq(IOwned(address(releaser)).owner(), factory.owner()); assertEq(releaser.thresholdSetter(), factory.owner()); - assertEq(releaser.threshold(), 69_420); + assertEq(releaser.threshold(), 4000 ether); assertEq(address(releaser.TOKEN_JAR()), address(tokenJar)); assertEq(releaser.RESOURCE_RECIPIENT(), address(0xdead)); assertEq(address(releaser.RESOURCE()), address(0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984)); diff --git a/test/ProtocolFees.fork.t.sol b/test/ProtocolFees.fork.t.sol index 477c8fc..cf5c7d7 100644 --- a/test/ProtocolFees.fork.t.sol +++ b/test/ProtocolFees.fork.t.sol @@ -3,12 +3,9 @@ pragma solidity ^0.8.29; import {Test} from "forge-std/Test.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; -import { - UniswapV3FactoryDeployer, - IUniswapV3Factory -} from "briefcase/deployers/v3-core/UniswapV3FactoryDeployer.sol"; +import {IUniswapV3Factory} from "briefcase/deployers/v3-core/UniswapV3FactoryDeployer.sol"; import {IUniswapV3Pool} from "v3-core/contracts/interfaces/IUniswapV3Pool.sol"; -import {Deployer} from "../src/Deployer.sol"; +import {MainnetDeployer} from "../script/deployers/MainnetDeployer.sol"; import {ITokenJar} from "../src/interfaces/ITokenJar.sol"; import {IReleaser} from "../src/interfaces/IReleaser.sol"; import {IOwned} from "../src/interfaces/base/IOwned.sol"; @@ -19,11 +16,12 @@ import {Currency} from "v4-core/types/Currency.sol"; import {IUniswapV2Factory} from "./interfaces/IUniswapV2Factory.sol"; import {IUniswapV2Pair} from "./interfaces/IUniswapV2Pair.sol"; import {IUniswapV2Router02} from "./interfaces/IUniswapV2Router02.sol"; +import {UnificationProposal} from "../script/04_UnificationProposal.s.sol"; contract ProtocolFeesForkTest is Test { using FixedPointMathLib for uint256; - Deployer public deployer; + MainnetDeployer public deployer; IUniswapV3Factory public factory; IUniswapV2Factory public v2Factory; IUniswapV2Router02 public v2Router; @@ -60,21 +58,15 @@ contract ProtocolFeesForkTest is Test { v2Router = IUniswapV2Router02(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D); owner = factory.owner(); - deployer = new Deployer(); + deployer = new MainnetDeployer(); + UnificationProposal proposal = new UnificationProposal(); + proposal.runPranked(deployer); tokenJar = deployer.TOKEN_JAR(); releaser = deployer.RELEASER(); - feeAdapter = deployer.FEE_ADAPTER(); + feeAdapter = deployer.V3_FEE_ADAPTER(); merkle = new Merkle(); - // set the fee adapter on the v3 factory - vm.prank(owner); - factory.setOwner(address(feeAdapter)); - - // assumes governance timelock takes back control of the feeSetter - vm.prank(owner); - IFeeToSetter(0x18e433c7Bf8A2E1d0197CE5d8f9AFAda1A771360).setFeeToSetter(owner); - // USDC-WETH pools pool0 = factory.getPool(WETH, USDC, 100); // 1 bip pool pool1 = factory.getPool(WETH, USDC, 500); // 5 bip pool @@ -95,13 +87,6 @@ contract ProtocolFeesForkTest is Test { function test_enableFeeV3() public { assertEq(feeAdapter.feeSetter(), owner); - vm.startPrank(owner); - feeAdapter.setDefaultFeeByFeeTier(100, 10 << 4 | 10); - feeAdapter.setDefaultFeeByFeeTier(500, 8 << 4 | 8); - feeAdapter.setDefaultFeeByFeeTier(3000, 6 << 4 | 6); - feeAdapter.setDefaultFeeByFeeTier(10_000, 4 << 4 | 4); - vm.stopPrank(); - // Generate merkle root from leaves bytes32 targetLeaf = _hashLeaf(USDC, WETH); bytes32 dummyLeaf = _hashLeaf(address(0), address(1)); @@ -117,25 +102,19 @@ contract ProtocolFeesForkTest is Test { bytes32[] memory proof = merkle.getProof(leaves, 0); feeAdapter.triggerFeeUpdate(USDC, WETH, proof); - // fees were set correctly + // fees were set correctly, from the Deployer.sol (,,,,, uint8 protocolFee,) = IUniswapV3Pool(pool0).slot0(); - assertEq(protocolFee, 10 << 4 | 10); + assertEq(protocolFee, 4 << 4 | 4); (,,,,, protocolFee,) = IUniswapV3Pool(pool1).slot0(); - assertEq(protocolFee, 8 << 4 | 8); + assertEq(protocolFee, 4 << 4 | 4); (,,,,, protocolFee,) = IUniswapV3Pool(pool2).slot0(); assertEq(protocolFee, 6 << 4 | 6); (,,,,, protocolFee,) = IUniswapV3Pool(pool3).slot0(); - assertEq(protocolFee, 4 << 4 | 4); + assertEq(protocolFee, 6 << 4 | 6); } function test_enableFeeV3MultiProof() public { assertEq(feeAdapter.feeSetter(), owner); - vm.startPrank(owner); - feeAdapter.setDefaultFeeByFeeTier(100, 10 << 4 | 10); - feeAdapter.setDefaultFeeByFeeTier(500, 8 << 4 | 8); - feeAdapter.setDefaultFeeByFeeTier(3000, 6 << 4 | 6); - feeAdapter.setDefaultFeeByFeeTier(10_000, 4 << 4 | 4); - vm.stopPrank(); // Using the real merkle root from the generated merkle tree bytes32 merkleRoot = hex"472c8960ea78de635eb7e32c5085f9fb963e626b5a68c939bfad24e022383b3a"; @@ -185,23 +164,23 @@ contract ProtocolFeesForkTest is Test { // Verify fees were set correctly for USDC-WETH pools (,,,,, uint8 protocolFee,) = IUniswapV3Pool(pool0).slot0(); - assertEq(protocolFee, 10 << 4 | 10); + assertEq(protocolFee, 4 << 4 | 4); (,,,,, protocolFee,) = IUniswapV3Pool(pool1).slot0(); - assertEq(protocolFee, 8 << 4 | 8); + assertEq(protocolFee, 4 << 4 | 4); (,,,,, protocolFee,) = IUniswapV3Pool(pool2).slot0(); assertEq(protocolFee, 6 << 4 | 6); (,,,,, protocolFee,) = IUniswapV3Pool(pool3).slot0(); - assertEq(protocolFee, 4 << 4 | 4); + assertEq(protocolFee, 6 << 4 | 6); // Verify fees were set correctly for DAI-WETH pools (,,,,, protocolFee,) = IUniswapV3Pool(daiPool0).slot0(); - assertEq(protocolFee, 10 << 4 | 10); + assertEq(protocolFee, 4 << 4 | 4); (,,,,, protocolFee,) = IUniswapV3Pool(daiPool1).slot0(); - assertEq(protocolFee, 8 << 4 | 8); + assertEq(protocolFee, 4 << 4 | 4); (,,,,, protocolFee,) = IUniswapV3Pool(daiPool2).slot0(); assertEq(protocolFee, 6 << 4 | 6); (,,,,, protocolFee,) = IUniswapV3Pool(daiPool3).slot0(); - assertEq(protocolFee, 4 << 4 | 4); + assertEq(protocolFee, 6 << 4 | 6); } function test_enableFeeV2() public { @@ -247,8 +226,8 @@ contract ProtocolFeesForkTest is Test { _exactInSwapV3(pool1, false, 1e18); (uint128 token0Pool1, uint128 token1Pool1) = IUniswapV3Pool(pool1).protocolFees(); - assertApproxEqRel(token0Pool1, uint256(1000e6).mulWadDown(0.0005e18) / 8, 0.0001e18); - assertApproxEqRel(token1Pool1, uint256(1e18).mulWadDown(0.0005e18) / 8, 0.0001e18); + assertApproxEqRel(token0Pool1, uint256(1000e6).mulWadDown(0.0005e18) / 4, 0.0001e18); + assertApproxEqRel(token1Pool1, uint256(1e18).mulWadDown(0.0005e18) / 4, 0.0001e18); // swap on 30 bip pool _exactInSwapV3(pool2, true, 1000e6); @@ -261,8 +240,8 @@ contract ProtocolFeesForkTest is Test { _exactInSwapV3(pool3, true, 1000e6); _exactInSwapV3(pool3, false, 1e18); (uint128 token0Pool3, uint128 token1Pool3) = IUniswapV3Pool(pool3).protocolFees(); - assertApproxEqRel(token0Pool3, uint256(1000e6).mulWadDown(0.01e18) / 4, 0.0001e18); - assertApproxEqRel(token1Pool3, uint256(1e18).mulWadDown(0.01e18) / 4, 0.0001e18); + assertApproxEqRel(token0Pool3, uint256(1000e6).mulWadDown(0.01e18) / 6, 0.0001e18); + assertApproxEqRel(token1Pool3, uint256(1e18).mulWadDown(0.01e18) / 6, 0.0001e18); IV3FeeAdapter.CollectParams[] memory params = new IV3FeeAdapter.CollectParams[](3); params[0] = IV3FeeAdapter.CollectParams({ diff --git a/test/UNIVesting.fork.t.sol b/test/UNIVesting.fork.t.sol new file mode 100644 index 0000000..d198f85 --- /dev/null +++ b/test/UNIVesting.fork.t.sol @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.29; + +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {MainnetDeployer} from "../script/deployers/MainnetDeployer.sol"; +import {IUNIVesting} from "../src/interfaces/IUNIVesting.sol"; +import {UnificationProposal} from "../script/04_UnificationProposal.s.sol"; +import {IOwned} from "../src/interfaces/base/IOwned.sol"; +import {IUniswapV3Factory} from "v3-core/contracts/interfaces/IUniswapV3Factory.sol"; + +contract UNIVestingForkTest is Test { + MainnetDeployer public deployer; + IUNIVesting public uniVesting; + IUniswapV3Factory public factory; + + address public owner; + address public recipient; + address public UNI = 0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984; + + // January 1, 2026 00:00:00 UTC + uint256 constant FIRST_UNLOCK_TIMESTAMP = 1_767_225_600; + uint256 constant MONTHS_PER_QUARTER = 3; + uint256 constant QUARTERLY_AMOUNT = 5_000_000 ether; + + function setUp() public { + vm.createSelectFork("mainnet"); + factory = IUniswapV3Factory(0x1F98431c8aD98523631AE4a59f267346ea31F984); + owner = factory.owner(); + + // Deploy and run the proposal + deployer = new MainnetDeployer(); + UnificationProposal proposal = new UnificationProposal(); + proposal.runPranked(deployer); + + uniVesting = deployer.UNI_VESTING(); + recipient = uniVesting.recipient(); + } + + function test_initialSetup() public view { + // Verify deployment + assertEq(address(uniVesting.UNI()), UNI, "UNI token address mismatch"); + assertEq(uniVesting.recipient(), deployer.LABS_UNI_RECIPIENT(), "Recipient mismatch"); + assertEq(uniVesting.quarterlyVestingAmount(), QUARTERLY_AMOUNT, "Quarterly amount mismatch"); + assertEq(IOwned(address(uniVesting)).owner(), owner, "Owner mismatch"); + + // Verify owner has approved UNIVesting contract + uint256 allowance = IERC20(UNI).allowance(owner, address(uniVesting)); + assertEq(allowance, 40_000_000 ether, "Allowance not set correctly"); + + // Verify no quarters are available yet + assertEq(uniVesting.quartersPassed(), 0, "Should have no quarters passed initially"); + } + + function test_withdrawBeforeFirstUnlock() public { + // Warp to a time before the first unlock (e.g., December 31, 2025) + vm.warp(FIRST_UNLOCK_TIMESTAMP - 1 days); + + // Should revert when trying to withdraw before first unlock + vm.expectRevert(IUNIVesting.OnlyQuarterly.selector); + uniVesting.withdraw(); + } + + function test_withdrawSingleQuarter() public { + // Warp to just after the first unlock + vm.warp(FIRST_UNLOCK_TIMESTAMP + 1 days); + + // Should have exactly 1 quarter available + assertEq(uniVesting.quartersPassed(), 1, "Should have 1 quarter passed"); + + uint256 recipientBalanceBefore = IERC20(UNI).balanceOf(recipient); + uint256 ownerBalanceBefore = IERC20(UNI).balanceOf(owner); + + // Anyone can call withdraw + vm.prank(address(0xdead)); + uniVesting.withdraw(); + + // Verify transfer + uint256 recipientBalanceAfter = IERC20(UNI).balanceOf(recipient); + uint256 ownerBalanceAfter = IERC20(UNI).balanceOf(owner); + + assertEq( + recipientBalanceAfter - recipientBalanceBefore, + QUARTERLY_AMOUNT, + "Recipient should receive quarterly amount" + ); + assertEq( + ownerBalanceBefore - ownerBalanceAfter, + QUARTERLY_AMOUNT, + "Owner balance should decrease by quarterly amount" + ); + + // Should have no quarters available after withdrawal + assertEq(uniVesting.quartersPassed(), 0, "Should have no quarters after withdrawal"); + } + + function test_withdrawMultipleQuarters() public { + // Warp to 3 quarters after first unlock + // Using 270 days for approximately 9 months (3 quarters) + vm.warp(FIRST_UNLOCK_TIMESTAMP + 270 days); + + // Should have 3 quarters available + uint48 quarters = uniVesting.quartersPassed(); + assertGe(quarters, 3, "Should have at least 3 quarters passed"); + + uint256 recipientBalanceBefore = IERC20(UNI).balanceOf(recipient); + + uniVesting.withdraw(); + + uint256 recipientBalanceAfter = IERC20(UNI).balanceOf(recipient); + assertEq( + recipientBalanceAfter - recipientBalanceBefore, + QUARTERLY_AMOUNT * quarters, + "Should receive correct number of quarters" + ); + + // Should have no quarters available after withdrawal + assertEq(uniVesting.quartersPassed(), 0, "Should have no quarters after withdrawal"); + } + + function test_partialWithdrawal() public { + // Warp to 3 quarters after first unlock + vm.warp(FIRST_UNLOCK_TIMESTAMP + 270 days); + uint48 quarters = uniVesting.quartersPassed(); + assertGe(quarters, 3, "Should have at least 3 quarters passed"); + + // Reduce allowance to only cover 2 quarters + vm.prank(owner); + IERC20(UNI).approve(address(uniVesting), QUARTERLY_AMOUNT * 2); + + uint256 recipientBalanceBefore = IERC20(UNI).balanceOf(recipient); + + uniVesting.withdraw(); + + uint256 recipientBalanceAfter = IERC20(UNI).balanceOf(recipient); + assertEq( + recipientBalanceAfter - recipientBalanceBefore, + QUARTERLY_AMOUNT * 2, + "Should only receive 2 quarters due to limited allowance" + ); + + // Should still have remaining quarters available + uint48 remainingQuarters = uniVesting.quartersPassed(); + assertGe(remainingQuarters, 1, "Should have at least 1 quarter remaining"); + + // Restore allowance and withdraw remaining quarters + vm.prank(owner); + IERC20(UNI).approve(address(uniVesting), QUARTERLY_AMOUNT * remainingQuarters); + + recipientBalanceBefore = recipientBalanceAfter; + uniVesting.withdraw(); + recipientBalanceAfter = IERC20(UNI).balanceOf(recipient); + + assertEq( + recipientBalanceAfter - recipientBalanceBefore, + QUARTERLY_AMOUNT * remainingQuarters, + "Should receive the remaining quarters" + ); + assertEq(uniVesting.quartersPassed(), 0, "Should have no quarters remaining"); + } + + function test_insufficientAllowance() public { + // Warp to after first unlock + vm.warp(FIRST_UNLOCK_TIMESTAMP + 1 days); + + // Remove allowance completely + vm.prank(owner); + IERC20(UNI).approve(address(uniVesting), 0); + + // Should revert due to insufficient allowance + vm.expectRevert(IUNIVesting.InsufficientAllowance.selector); + uniVesting.withdraw(); + } + + function test_updateRecipientByOwner() public { + address newRecipient = address(0x123); + + // Owner can update recipient + vm.prank(owner); + uniVesting.updateRecipient(newRecipient); + + assertEq(uniVesting.recipient(), newRecipient, "Recipient should be updated"); + + // Verify vesting works with new recipient + vm.warp(FIRST_UNLOCK_TIMESTAMP + 1 days); + + uint256 newRecipientBalanceBefore = IERC20(UNI).balanceOf(newRecipient); + uniVesting.withdraw(); + uint256 newRecipientBalanceAfter = IERC20(UNI).balanceOf(newRecipient); + + assertEq( + newRecipientBalanceAfter - newRecipientBalanceBefore, + QUARTERLY_AMOUNT, + "New recipient should receive tokens" + ); + } + + function test_updateRecipientByRecipient() public { + address newRecipient = address(0x456); + + // Current recipient can update to new recipient + vm.prank(recipient); + uniVesting.updateRecipient(newRecipient); + + assertEq(uniVesting.recipient(), newRecipient, "Recipient should be updated"); + } + + function test_updateRecipientUnauthorized() public { + address newRecipient = address(0x789); + + // Random address cannot update recipient + vm.prank(address(0xdead)); + vm.expectRevert(IUNIVesting.NotAuthorized.selector); + uniVesting.updateRecipient(newRecipient); + } + + function test_updateVestingAmountNoQuarters() public { + uint256 newAmount = 10_000_000 ether; + + // Owner can update vesting amount when no quarters are available + vm.prank(owner); + uniVesting.updateVestingAmount(newAmount); + + assertEq(uniVesting.quarterlyVestingAmount(), newAmount, "Vesting amount should be updated"); + + // Verify the new amount is used for withdrawals + vm.warp(FIRST_UNLOCK_TIMESTAMP + 1 days); + + // Update allowance for new amount + vm.prank(owner); + IERC20(UNI).approve(address(uniVesting), newAmount); + + uint256 recipientBalanceBefore = IERC20(UNI).balanceOf(recipient); + uniVesting.withdraw(); + uint256 recipientBalanceAfter = IERC20(UNI).balanceOf(recipient); + + assertEq( + recipientBalanceAfter - recipientBalanceBefore, + newAmount, + "Should receive new quarterly amount" + ); + } + + function test_updateVestingAmountWithQuartersAvailable() public { + // Warp to after first unlock + vm.warp(FIRST_UNLOCK_TIMESTAMP + 1 days); + assertEq(uniVesting.quartersPassed(), 1, "Should have 1 quarter available"); + + uint256 newAmount = 10_000_000 ether; + + // Should revert when trying to update with quarters available + vm.prank(owner); + vm.expectRevert(IUNIVesting.CannotUpdateAmount.selector); + uniVesting.updateVestingAmount(newAmount); + } + + function test_updateVestingAmountNoChange() public { + // Should revert when trying to update to the same amount + vm.prank(owner); + vm.expectRevert(IUNIVesting.NoChangeUpdate.selector); + uniVesting.updateVestingAmount(QUARTERLY_AMOUNT); + } + + function test_longTermVesting() public { + uint256 twoYears = 729 days; + vm.warp(FIRST_UNLOCK_TIMESTAMP + twoYears); + + uint48 quarters = uniVesting.quartersPassed(); + assertEq(quarters, 8, "Should have at least 8 quarters after 2 years"); + + uint256 recipientBalanceBefore = IERC20(UNI).balanceOf(recipient); + uniVesting.withdraw(); + uint256 recipientBalanceAfter = IERC20(UNI).balanceOf(recipient); + + assertEq( + recipientBalanceAfter - recipientBalanceBefore, + QUARTERLY_AMOUNT * quarters, + "Should receive correct amount" + ); + } + + function test_allVestingComplete() public { + // 40M UNI approved, 5M per quarter = 8 quarters total + // Warp to after all vesting is complete + vm.warp(FIRST_UNLOCK_TIMESTAMP + 730 days); // 2 years + + uint256 totalVested = 0; + + // Withdraw all available quarters + uniVesting.withdraw(); + totalVested = IERC20(UNI).balanceOf(recipient); + + // Due to calendar-based quarters, the actual amount might be slightly more + assertGe(totalVested, QUARTERLY_AMOUNT * 8, "Should vest at least 40M total"); + assertLe(totalVested, QUARTERLY_AMOUNT * 9, "Should not vest more than 45M"); + + // Try to withdraw again - should fail due to insufficient allowance or no quarters + // After withdrawing, either: + // 1. No more quarters are available (OnlyQuarterly error) + // 2. More quarters available but no allowance (InsufficientAllowance error) + vm.warp(block.timestamp + 365 days); + + // Check if more quarters are available + uint48 remainingQuarters = uniVesting.quartersPassed(); + if (remainingQuarters > 0) { + // If quarters are available, should fail due to insufficient allowance + vm.expectRevert(IUNIVesting.InsufficientAllowance.selector); + } else { + // If no quarters available, should fail with OnlyQuarterly + vm.expectRevert(IUNIVesting.OnlyQuarterly.selector); + } + uniVesting.withdraw(); + } + + function test_ownershipTransfer() public { + address newOwner = address(0xbeef); + + // Transfer ownership of UNIVesting contract + vm.prank(owner); + IOwned(address(uniVesting)).transferOwnership(newOwner); + + assertEq(IOwned(address(uniVesting)).owner(), newOwner, "Ownership should be transferred"); + + // New owner needs to approve tokens for vesting to continue + deal(UNI, newOwner, 50_000_000 ether); + vm.prank(newOwner); + IERC20(UNI).approve(address(uniVesting), 50_000_000 ether); + + // Verify vesting still works with new owner + vm.warp(FIRST_UNLOCK_TIMESTAMP + 1 days); + + uint256 recipientBalanceBefore = IERC20(UNI).balanceOf(recipient); + uniVesting.withdraw(); + uint256 recipientBalanceAfter = IERC20(UNI).balanceOf(recipient); + + assertEq( + recipientBalanceAfter - recipientBalanceBefore, + QUARTERLY_AMOUNT, + "Vesting should work with new owner" + ); + } +} + diff --git a/test/UnichainProtocolFees.fork.t.sol b/test/UnichainProtocolFees.fork.t.sol new file mode 100644 index 0000000..89cfd7f --- /dev/null +++ b/test/UnichainProtocolFees.fork.t.sol @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.29; + +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {UnichainDeployer} from "../script/deployers/UnichainDeployer.sol"; +import {DeployUnichain} from "../script/02_DeployUnichain.s.sol"; +import {ITokenJar} from "../src/interfaces/ITokenJar.sol"; +import {IReleaser} from "../src/interfaces/IReleaser.sol"; +import {IOwned} from "../src/interfaces/base/IOwned.sol"; +import {Currency} from "v4-core/types/Currency.sol"; + +contract UnichainProtocolFeesForkTest is Test { + UnichainDeployer public deployer; + DeployUnichain public deployScript; + + ITokenJar public tokenJar; + IReleaser public releaser; + + address public constant RESOURCE = 0x8f187aA05619a017077f5308904739877ce9eA21; // Native Bridge + // UNI + uint256 public constant THRESHOLD = 2000e18; + + // Expected owner address (UNI Timelock alias on Unichain) + address public constant owner = 0x2BAD8182C09F50c8318d769245beA52C32Be46CD; + + function setUp() public { + // Fork Unichain + vm.createSelectFork("unichain"); + + // Verify we're on the right chain + assertEq(block.chainid, 130, "Not on Unichain"); + + // Deploy the contracts using UnichainDeployer + deployer = new UnichainDeployer(); + tokenJar = deployer.TOKEN_JAR(); + releaser = deployer.RELEASER(); + } + + function test_deploymentConfiguration() public view { + // Test TokenJar deployment and configuration + assertEq(tokenJar.releaser(), address(releaser), "Incorrect releaser on TokenJar"); + assertEq(IOwned(address(tokenJar)).owner(), owner, "Incorrect owner on TokenJar"); + + // Test Releaser deployment and configuration + assertEq(address(releaser.RESOURCE()), RESOURCE, "Incorrect resource token"); + assertEq(releaser.threshold(), THRESHOLD, "Incorrect threshold"); + assertEq(address(releaser.TOKEN_JAR()), address(tokenJar), "Incorrect TokenJar address"); + assertEq(releaser.thresholdSetter(), owner, "Incorrect threshold setter"); + assertEq(IOwned(address(releaser)).owner(), owner, "Incorrect owner on Releaser"); + } + + function test_sequencerFeesAccumulation() public { + // Simulate sequencer fees being sent to TokenJar + uint256 initialBalance = address(tokenJar).balance; + + // Send ETH to TokenJar (simulating sequencer fees) + uint256 feeAmount = 1 ether; + vm.deal(address(this), feeAmount); + (bool success,) = address(tokenJar).call{value: feeAmount}(""); + assertTrue(success, "Failed to send ETH to TokenJar"); + + // Verify ETH accumulated in TokenJar + assertEq( + address(tokenJar).balance, initialBalance + feeAmount, "ETH not accumulated in TokenJar" + ); + } + + function test_releaseWithUNIBurn() public { + // Setup: Send sequencer fees to TokenJar + uint256 ethAmount = 5 ether; + vm.deal(address(this), ethAmount); + (bool success,) = address(tokenJar).call{value: ethAmount}(""); + assertTrue(success, "Failed to send ETH to TokenJar"); + + // Deal UNI tokens to the caller for burning + address caller = address(0x1234); + deal(RESOURCE, caller, THRESHOLD); + assertEq(IERC20(RESOURCE).balanceOf(caller), THRESHOLD, "UNI not dealt to caller"); + + // Record balances before release + uint256 recipientBalanceBefore = address(0x5678).balance; + uint256 tokenJarBalanceBefore = address(tokenJar).balance; + uint256 uniSupplyBefore = IERC20(RESOURCE).totalSupply(); + + // Execute release (burning UNI to release ETH) + uint256 _nonce = releaser.nonce(); + Currency[] memory currencies = new Currency[](1); + currencies[0] = Currency.wrap(address(0)); // ETH represented as address(0) + + vm.startPrank(caller); + IERC20(RESOURCE).approve(address(releaser), THRESHOLD); + releaser.release(_nonce, currencies, address(0x5678)); + vm.stopPrank(); + + // Verify ETH transferred from TokenJar to recipient + assertEq(address(tokenJar).balance, 0, "TokenJar should be empty"); + assertEq( + address(0x5678).balance - recipientBalanceBefore, + tokenJarBalanceBefore, + "Incorrect ETH transferred to recipient" + ); + + // Verify UNI was burned + assertEq( + uniSupplyBefore - IERC20(RESOURCE).totalSupply(), THRESHOLD, "UNI not burned correctly" + ); + } + + function test_multipleSequencerFeeReleases() public { + // Test multiple rounds of fee accumulation and release + address[] memory callers = new address[](3); + callers[0] = address(0xAAA1); + callers[1] = address(0xAAA2); + callers[2] = address(0xAAA3); + + for (uint256 i = 0; i < 3; i++) { + // Send sequencer fees + uint256 feeAmount = (i + 1) * 2 ether; + vm.deal(address(this), feeAmount); + (bool success,) = address(tokenJar).call{value: feeAmount}(""); + assertTrue(success, "Failed to send ETH to TokenJar"); + + // Deal UNI and release + deal(RESOURCE, callers[i], THRESHOLD); + + uint256 _nonce = releaser.nonce(); + Currency[] memory currencies = new Currency[](1); + currencies[0] = Currency.wrap(address(0)); // ETH + + vm.startPrank(callers[i]); + IERC20(RESOURCE).approve(address(releaser), THRESHOLD); + + uint256 recipientBalanceBefore = callers[i].balance; + releaser.release(_nonce, currencies, callers[i]); + + // Verify release + assertEq(callers[i].balance - recipientBalanceBefore, feeAmount, "Incorrect ETH released"); + assertEq(address(tokenJar).balance, 0, "TokenJar not emptied"); + vm.stopPrank(); + } + } + + function test_ownershipTransfer() public { + // Test that ownership can be transferred by current owner + address newOwner = address(0x9999); + + // Transfer TokenJar ownership + vm.prank(owner); + IOwned(address(tokenJar)).transferOwnership(newOwner); + assertEq(IOwned(address(tokenJar)).owner(), newOwner, "TokenJar ownership not transferred"); + + // Transfer Releaser ownership + vm.prank(owner); + IOwned(address(releaser)).transferOwnership(newOwner); + assertEq(IOwned(address(releaser)).owner(), newOwner, "Releaser ownership not transferred"); + + // Transfer threshold setter + vm.prank(newOwner); + releaser.setThresholdSetter(newOwner); + assertEq(releaser.thresholdSetter(), newOwner, "Threshold setter not transferred"); + } + + function test_thresholdUpdate() public { + // Test that threshold can be updated by thresholdSetter + uint256 newThreshold = 20_000e18; + + vm.prank(owner); + releaser.setThreshold(newThreshold); + assertEq(releaser.threshold(), newThreshold, "Threshold not updated"); + } + + function test_releaserUpdate() public { + // Test that releaser can be updated on TokenJar + address newReleaser = address(0x8888); + + vm.prank(owner); + tokenJar.setReleaser(newReleaser); + assertEq(tokenJar.releaser(), newReleaser, "Releaser not updated on TokenJar"); + } + + function test_invalidRelease_insufficientUNI() public { + // Test that release fails without sufficient UNI + address caller = address(0x7777); + + // Send ETH to TokenJar + vm.deal(address(this), 1 ether); + (bool success,) = address(tokenJar).call{value: 1 ether}(""); + assertTrue(success); + + // Give caller less than threshold UNI + deal(RESOURCE, caller, THRESHOLD - 1); + + uint256 _nonce = releaser.nonce(); + Currency[] memory currencies = new Currency[](1); + currencies[0] = Currency.wrap(address(0)); + + vm.startPrank(caller); + // max approve, but still revert on insufficient balance + IERC20(RESOURCE).approve(address(releaser), type(uint256).max); + + // Should revert due to insufficient UNI + vm.expectRevert(RESOURCE); + releaser.release(_nonce, currencies, caller); + vm.stopPrank(); + } + + function test_deploymentAddressDeterminism() public { + // Test that deployment addresses are deterministic with salt + UnichainDeployer deployer2 = new UnichainDeployer(); + + // Addresses should be different for different deployer instances + // but the pattern should be consistent + assertTrue(address(deployer2.TOKEN_JAR()) != address(0), "TokenJar not deployed"); + assertTrue(address(deployer2.RELEASER()) != address(0), "Releaser not deployed"); + } +} + diff --git a/test/mocks/ExchangeReleaserMock.sol b/test/mocks/ExchangeReleaserMock.sol index c7de2aa..b09d04e 100644 --- a/test/mocks/ExchangeReleaserMock.sol +++ b/test/mocks/ExchangeReleaserMock.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.29; -import {Currency} from "v4-core/types/Currency.sol"; import {ExchangeReleaser} from "../../src/releasers/ExchangeReleaser.sol"; contract ExchangeReleaserMock is ExchangeReleaser {