diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b725086 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +export FOUNDRY_SCRIPT_DEPS=deployed +export FOUNDRY_EXPORTS_OVERWRITE_LATEST=true +export L1="sepolia" +export L2="arbitrum_one_sepolia" +export MAINNET_RPC_URL= +export ARBITRUM_ONE_RPC_URL= +export SEPOLIA_RPC_URL= +export ARBITRUM_ONE_SEPOLIA_RPC_URL= +export L1_PRIVATE_KEY="0x$(cat /path/to/pkey1)" +export L2_PRIVATE_KEY="0x$(cat /path/to/pkey2)" +export ETHERSCAN_KEY= +export ARBISCAN_KEY= diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fd09b9d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,37 @@ +name: test + +on: [push, pull_request] + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + strategy: + fail-fast: true + + name: Foundry project + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Run Forge build + run: | + forge --version + forge build --sizes + id: build + + - name: Run Forge tests + run: | + forge test -vvv + id: test + env: + MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} + ARBITRUM_ONE_RPC_URL: ${{ secrets.ARBITRUM_ONE_RPC_URL }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87132a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +/broadcast + +# Docs +docs/ + +# Dotenv file +.env + +deployed-[0-9]*.json diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..da3b3e9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "lib/dss-test"] + path = lib/dss-test + url = https://github.com/makerdao/dss-test +[submodule "lib/arbitrum-token-bridge"] + path = lib/arbitrum-token-bridge + url = https://github.com/makerdao/arbitrum-token-bridge +[submodule "lib/endgame-toolkit"] + path = lib/endgame-toolkit + url = https://github.com/makerdao/endgame-toolkit diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1cd6bd --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# Arbitrum Farms + +## Overview + +This repository implements a mechanism to distribute rewards vested in a [DssVest](https://github.com/makerdao/dss-vest) contract on L1 to users staking tokens in a [StakingRewards](https://github.com/makerdao/endgame-toolkit/blob/master/src/synthetix/StakingRewards.sol) farm on Arbitrum. It uses the [Arbitrum Token Bridge](https://github.com/makerdao/arbitrum-token-bridge) to transfer the rewards from L1 to L2. + +## Contracts + +- `L1FarmProxy.sol` - Proxy to the farm on the L1 side. Receives the token reward (expected to come from a [`VestedRewardDistribution`](https://github.com/makerdao/endgame-toolkit/blob/master/src/VestedRewardsDistribution.sol) contract) and transfers it cross-chain to the `L2FarmProxy`. An instance of `L1FarmProxy` must be deployed for each supported pair of staking and rewards token. +- `L2FarmProxy.sol` - Proxy to the farm on the L2 side. Receives the token reward (expected to be bridged from the `L1FarmProxy`) and forwards it to the [StakingRewards](https://github.com/makerdao/endgame-toolkit/blob/master/src/synthetix/StakingRewards.sol) farm where it gets distributed to stakers. An instance of `L2FarmProxy` must be deployed for each supported pair of staking and rewards token. +- `EtherForwader.sol` - A simple ether forwarding contract deployed on L2 to collect excess fee refunds and forward those to the `L2GovernanceRelay`. + +### External dependencies + +- The L2 staking tokens and the L1 and L2 rewards tokens are not provided as part of this repository. It is assumed that only simple, regular ERC20 tokens will be used. In particular, the supported tokens are assumed to revert on failure (instead of returning false) and do not execute any hook on transfer. +- [`DssVest`](https://github.com/makerdao/dss-vest) is used to vest the rewards token on L1. +- [`VestedRewardDistribution`](https://github.com/makerdao/endgame-toolkit/blob/master/src/VestedRewardsDistribution.sol) is used to vest the rewards tokens from `DssVest`, transfer them to the `L1FarmProxy` and trigger the bridging of the tokens. +- The [Arbitrum Token Bridge](https://github.com/makerdao/arbitrum-token-bridge) is used to bridge the tokens from L1 to L2. +- The [escrow contract](https://etherscan.io/address/0xA10c7CE4b876998858b1a9E12b10092229539400#code) is used by the Arbitrum Token Bridge to hold the bridged tokens on L1. +- [`StakingRewards`](https://github.com/makerdao/endgame-toolkit/blob/master/src/synthetix/StakingRewards.sol) is used to distribute the bridged rewards to stakers on L2. +- The [`L1GovernanceRelay`](https://etherscan.io/address/0x9ba25c289e351779E0D481Ba37489317c34A899d#code) & [`L2GovernanceRelay`](https://arbiscan.io/address/0x10E6593CDda8c58a1d0f14C5164B376352a55f2F#code) allow governance to exert admin control over the deployed L2 contracts. These contracts have been previously deployed to control the Arbitrum Dai Bridge. + +## Expected flow + +- It is expected that the ether balance of the `L1FarmProxy` is continuously monitored and topped up as needed to ensure the successful operation of the proxy. +- Once the vested amount of rewards tokens exceeds `L1FarmProxy.rewardThreshold`, a keeper calls `VestedRewardDistribution.distribute()` to vest the rewards and have them bridged to L2. +- Once the bridged amount of rewards tokens exceeds `L2FarmProxy.rewardThreshold`, anyone (e.g. a keeper or an L2 staker) can call `L2FarmProxy.forwardReward()` to distribute the rewards to the L2 farm. + +Note that `L1FarmProxy.rewardThreshold` must be sufficiently large to reduce the frequency of cross-chain transfers (thereby also reducing the amount of ether that needs to be provisionned into the `L1FarmProxy`). `L2FarmProxy.rewardThreshold` must also be sufficiently large to limit the reduction of the farm's rate of rewards distribution. Consider also choosing `L2FarmProxy.rewardThreshold <= L1FarmProxy.rewardThreshold` so that the bridged rewards can be promptly distributed to the farm. In the initialization library, these two variables are assigned the same value. + +## Deployment + +### Declare env variables + +Add the required env variables listed in `.env.example` to your `.env` file, and run `source .env`. + +Make sure to set the `L1` and `L2` env variables according to your desired deployment environment. + +Mainnet deployment: + +``` +L1=mainnet +L2=arbitrum_one +``` + +Testnet deployment: + +``` +L1=sepolia +L2=arbitrum_one_sepolia +``` + +### Deploy the farm L1 & L2 proxies + +The deployment assumes that the [arbitrum-token-bridge](https://github.com/makerdao/arbitrum-token-bridge) has already been deployed and was properly initialized. + +Fill in the addresses of the L2 staking token and L1 and L2 rewards tokens in `script/input/{chainId}/config.json` under the `"stakingToken"` and `"rewardsToken"` keys. It is assumed that these tokens have been registered with the Arbitrum Token Bridge. + +Fill in the address of the mainnet DssVest contract in `script/input/1/config.json` under the `vest` key. It is assumed that the vesting contract was properly initialized. On testnet, a mock DssVest contract will automatically be deployed. + +Start by deploying the `EtherForwarder` and `L2FarmProxySpell` singletons. You must use a deployment key for which the current nonce on L2 has been "burned" on L1 (i.e. has already been spent on L1 in a transaction that is not a contract creation transaction). This is required to make sure the address of the `EtherForwarder` can never contain code on L1. If that address ever had code on L1, it would no longer be usable as an excess fee refund receiver (see the reason why [here](https://github.com/OffchainLabs/nitro-contracts/blob/61204dd455966cb678192427a07aa9795ff91c14/src/bridge/AbsInbox.sol#L248)). + +``` +forge script script/DeploySingletons.s.sol:DeploySingletons --slow --multi --broadcast --verify +``` + +Next, run the following command to deploy the L1 vested rewards distribution contract, the L2 farm and the L1 and L2 proxies: + +``` +forge script script/DeployProxy.s.sol:DeployProxy --slow --multi --broadcast --verify +``` + +### Initialize the farm L1 & L2 proxies + +On mainnet, the farm proxies should be initialized via the spell process. To determine an adequate value for the `maxGas` storage variable of `L1FarmProxy`, the `Estimate` script can be run: + +``` +forge script script/Estimate.s.sol:Estimate +``` + +On testnet, the proxies initialization can be performed via the following command: + +``` +forge script script/Init.s.sol:Init --slow --multi --broadcast +``` + +### Run a test distribution + +Run the following command to distribute the vested funds to the L1 proxy: + +``` +forge script script/Distribute.s.sol:Distribute --slow --multi --broadcast +``` + +Wait for the transaction to be relayed to L2, then run the following command to forward the bridged funds from the L2 proxy to the farm: + +``` +forge script script/Forward.s.sol:Forward --slow --multi --broadcast +``` diff --git a/audit/20240813-ChainSecurity_MakerDAO_Arbitrum_Farms_audit.pdf b/audit/20240813-ChainSecurity_MakerDAO_Arbitrum_Farms_audit.pdf new file mode 100644 index 0000000..7816614 Binary files /dev/null and b/audit/20240813-ChainSecurity_MakerDAO_Arbitrum_Farms_audit.pdf differ diff --git a/deploy/FarmProxyDeploy.sol b/deploy/FarmProxyDeploy.sol new file mode 100644 index 0000000..f2212eb --- /dev/null +++ b/deploy/FarmProxyDeploy.sol @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: © 2024 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity >=0.8.0; + +import { ScriptTools } from "dss-test/ScriptTools.sol"; + +import { L2FarmProxySpell } from "./L2FarmProxySpell.sol"; +import { L1FarmProxy } from "src/L1FarmProxy.sol"; +import { L2FarmProxy } from "src/L2FarmProxy.sol"; +import { EtherForwarder } from "src/EtherForwarder.sol"; + +library FarmProxyDeploy { + function deployL1Proxy( + address deployer, + address owner, + address rewardsToken, + address l2Proxy, + address feeRecipient, + address l1Gateway + ) internal returns (address l1Proxy) { + l1Proxy = address(new L1FarmProxy(rewardsToken, l2Proxy, feeRecipient, l1Gateway)); + ScriptTools.switchOwner(l1Proxy, deployer, owner); + } + + function deployL2Proxy( + address deployer, + address owner, + address farm + ) internal returns (address l2Proxy) { + l2Proxy = address(new L2FarmProxy(farm)); + ScriptTools.switchOwner(l2Proxy, deployer, owner); + } + + function deployL2ProxySpell() internal returns (address l2Spell) { + l2Spell = address(new L2FarmProxySpell()); + } + + function deployL2EtherForwarder(address receiver) internal returns (address forwarder) { + forwarder = address(new EtherForwarder(receiver)); + } +} diff --git a/deploy/FarmProxyInit.sol b/deploy/FarmProxyInit.sol new file mode 100644 index 0000000..34c4706 --- /dev/null +++ b/deploy/FarmProxyInit.sol @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: © 2024 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity >=0.8.0; + +import { DssInstance } from "dss-test/MCD.sol"; +import { L2FarmProxySpell } from "./L2FarmProxySpell.sol"; + +interface DssVestLike { + function gem() external view returns (address); + function create(address _usr, uint256 _tot, uint256 _bgn, uint256 _tau, uint256 _eta, address _mgr) external returns (uint256 id); + function restrict(uint256 _id) external; +} + +interface VestedRewardsDistributionLike { + function dssVest() external view returns (address); + function stakingRewards() external view returns (address); + function gem() external view returns (address); + function file(bytes32 what, uint256 data) external; +} + +interface L1FarmProxyLike { + function rewardsToken() external view returns (address); + function l2Proxy() external view returns (address); + function feeRecipient() external view returns (address); + function l1Gateway() external view returns (address); + function file(bytes32 what, uint256 data) external; +} + +interface L1RelayLike { + function l2GovernanceRelay() external view returns (address); + function relay( + address target, + bytes calldata targetData, + uint256 l1CallValue, + uint256 maxGas, + uint256 gasPriceBid, + uint256 maxSubmissionCost + ) external payable; +} + +struct MessageParams { + uint256 maxGas; + uint256 gasPriceBid; + uint256 maxSubmissionCost; +} + +struct ProxiesConfig { + address vest; // DssVest, assumed to have been fully init'ed for l1RewardsToken + uint256 vestTot; + uint256 vestBgn; + uint256 vestTau; + address vestedRewardsDistribution; + address l1RewardsToken; + address l2RewardsToken; + address stakingToken; + address l1Gateway; + uint256 maxGas; // For the L1 proxy + uint256 gasPriceBid; // For the L1 proxy + uint256 rewardThreshold; // For the L1 and L2 proxies + address farm; // The L2 farm + uint256 rewardsDuration; // For the L2 farm + MessageParams xchainMsg; // For the xchain message executing the L2 spell + bytes32 proxyChainlogKey; // Chainlog key for the L1 proxy + bytes32 distrChainlogKey; // Chainlog key for vestedRewardsDistribution +} + +library FarmProxyInit { + function initProxies( + DssInstance memory dss, + address l1Proxy_, + address l2Proxy, + address etherForwarder, + address l2Spell, + ProxiesConfig memory cfg + ) internal { + L1FarmProxyLike l1Proxy = L1FarmProxyLike(l1Proxy_); + DssVestLike vest = DssVestLike(cfg.vest); + VestedRewardsDistributionLike distribution = VestedRewardsDistributionLike(cfg.vestedRewardsDistribution); + L1RelayLike l1GovRelay = L1RelayLike(dss.chainlog.getAddress("ARBITRUM_GOV_RELAY")); + + // sanity checks + + require(vest.gem() == cfg.l1RewardsToken, "FarmProxyInit/vest-gem-mismatch"); + require(distribution.gem() == cfg.l1RewardsToken, "FarmProxyInit/distribution-gem-mismatch"); + require(distribution.stakingRewards() == l1Proxy_, "FarmProxyInit/distribution-farm-mismatch"); + require(distribution.dssVest() == cfg.vest, "FarmProxyInit/distribution-vest-mismatch"); + require(l1Proxy.rewardsToken() == cfg.l1RewardsToken, "FarmProxyInit/rewards-token-mismatch"); + require(l1Proxy.l2Proxy() == l2Proxy, "FarmProxyInit/l2-proxy-mismatch"); + require(l1Proxy.feeRecipient() == etherForwarder, "FarmProxyInit/fee-recipient-mismatch"); + require(l1Proxy.l1Gateway() == cfg.l1Gateway, "FarmProxyInit/l1-gateway-mismatch"); + require(cfg.maxGas <= 10_000_000_000, "FarmProxyInit/max-gas-out-of-bounds"); + require(cfg.gasPriceBid <= 10_000 gwei, "FarmProxyInit/gas-price-bid-out-of-bounds"); + require(cfg.rewardThreshold <= type(uint128).max, "FarmProxyInit/reward-threshold-out-of-bounds"); + require(etherForwarder.code.length == 0, "FarmProxyInit/forwarder-addr-has-code-on-l1"); + + // setup vest + + uint256 vestId = vest.create({ + _usr: cfg.vestedRewardsDistribution, + _tot: cfg.vestTot, + _bgn: cfg.vestBgn, + _tau: cfg.vestTau, + _eta: 0, + _mgr: address(0) + }); + vest.restrict(vestId); + distribution.file("vestId", vestId); + + // setup L1 proxy + + l1Proxy.file("maxGas", cfg.maxGas); + l1Proxy.file("gasPriceBid", cfg.gasPriceBid); + l1Proxy.file("rewardThreshold", cfg.rewardThreshold); + + // setup L2 proxy + + uint256 l1CallValue = cfg.xchainMsg.maxSubmissionCost + cfg.xchainMsg.maxGas * cfg.xchainMsg.gasPriceBid; + + // not strictly necessary (as the retryable ticket creation would otherwise fail) + // but makes the eth balance requirement more explicit + require(address(l1GovRelay).balance >= l1CallValue, "FarmProxyInit/insufficient-relay-balance"); + + l1GovRelay.relay({ + target: l2Spell, + targetData: abi.encodeCall(L2FarmProxySpell.init, ( + l2Proxy, + etherForwarder, + cfg.l2RewardsToken, + cfg.stakingToken, + cfg.farm, + cfg.rewardThreshold, + cfg.rewardsDuration + )), + l1CallValue: l1CallValue, + maxGas: cfg.xchainMsg.maxGas, + gasPriceBid: cfg.xchainMsg.gasPriceBid, + maxSubmissionCost: cfg.xchainMsg.maxSubmissionCost + }); + + // update chainlog + + dss.chainlog.setAddress(cfg.proxyChainlogKey, l1Proxy_); + dss.chainlog.setAddress(cfg.distrChainlogKey, cfg.vestedRewardsDistribution); + } +} diff --git a/deploy/L2FarmProxySpell.sol b/deploy/L2FarmProxySpell.sol new file mode 100644 index 0000000..2f79c14 --- /dev/null +++ b/deploy/L2FarmProxySpell.sol @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: © 2024 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity >=0.8.0; + +interface L2FarmProxyLike { + function rewardsToken() external view returns (address); + function farm() external view returns (address); + function rely(address) external; + function deny(address) external; + function file(bytes32, uint256) external; + function recover(address, address, uint256) external; +} + +interface FarmLike { + function rewardsToken() external view returns (address); + function stakingToken() external view returns (address); + function nominateNewOwner(address) external; + function setPaused(bool) external; + function recoverERC20(address, uint256) external; + function setRewardsDuration(uint256) external; + function setRewardsDistribution(address) external; +} + +interface ForwarderLike { + function receiver() external view returns (address); +} + +// A reusable L2 spell to be used by the L2GovernanceRelay to exert admin control over L2 farms and their proxies +contract L2FarmProxySpell { + function rely(address l2Proxy, address usr) external { L2FarmProxyLike(l2Proxy).rely(usr); } + function deny(address l2Proxy, address usr) external { L2FarmProxyLike(l2Proxy).deny(usr); } + function file(address l2Proxy, bytes32 what, uint256 data) external { L2FarmProxyLike(l2Proxy).file(what, data); } + function recover(address l2Proxy, address token, address receiver, uint256 amount) external { L2FarmProxyLike(l2Proxy).recover(token, receiver, amount); } + + function nominateNewOwner(address farm, address owner) external { FarmLike(farm).nominateNewOwner(owner); } + function setPaused(address farm, bool paused) external { FarmLike(farm).setPaused(paused); } + function recoverERC20(address farm, address token, uint256 amount) external { FarmLike(farm).recoverERC20(token, amount); } + function setRewardsDuration(address farm, uint256 rewardsDuration) external { FarmLike(farm).setRewardsDuration(rewardsDuration); } + function setRewardsDistribution(address farm, address rewardsDistribution) external { FarmLike(farm).setRewardsDistribution(rewardsDistribution); } + + function init( + address l2Proxy, + address etherForwarder, + address rewardsToken, + address stakingToken, + address farm, + uint256 rewardThreshold, + uint256 rewardsDuration + ) external { + // sanity checks + require(L2FarmProxyLike(l2Proxy).rewardsToken() == rewardsToken, "L2FarmProxySpell/rewards-token-mismatch"); + require(L2FarmProxyLike(l2Proxy).farm() == farm, "L2FarmProxySpell/farm-mismatch"); + require(ForwarderLike(etherForwarder).receiver() == address(this), "L2FarmProxySpell/forwarder-receiver-not-gov-relay"); + require(FarmLike(farm).stakingToken() == stakingToken, "L2FarmProxySpell/farm-staking-token-mismatch"); + require(stakingToken != rewardsToken, "L2FarmProxySpell/rewards-token-same-as-staking-token"); + + L2FarmProxyLike(l2Proxy).file("rewardThreshold", rewardThreshold); + + FarmLike(farm).setRewardsDistribution(l2Proxy); + FarmLike(farm).setRewardsDuration(rewardsDuration); + } +} diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..96b91ae --- /dev/null +++ b/foundry.toml @@ -0,0 +1,15 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +solc = "0.8.21" +fs_permissions = [ + { access = "read", path = "./script/input/"}, + { access = "read-write", path = "./script/output/"} +] + +[etherscan] +mainnet = { key = "${ETHERSCAN_KEY}" } +sepolia = { key = "${ETHERSCAN_KEY}", chain = 11155111 } +arbitrum_one = { key = "${ARBISCAN_KEY}", chain = 42161, url = "https://api.arbiscan.io/api" } +arbitrum_one_sepolia = { key = "${ARBISCAN_KEY}", chain = 421614, url = "https://api-sepolia.arbiscan.io/api" } diff --git a/lib/arbitrum-token-bridge b/lib/arbitrum-token-bridge new file mode 160000 index 0000000..542f2cf --- /dev/null +++ b/lib/arbitrum-token-bridge @@ -0,0 +1 @@ +Subproject commit 542f2cf623cd8c9873c07b52e068346a00600b30 diff --git a/lib/dss-test b/lib/dss-test new file mode 160000 index 0000000..f2a2b2b --- /dev/null +++ b/lib/dss-test @@ -0,0 +1 @@ +Subproject commit f2a2b2bbea71921103c5b7cf3cb1d241b957bec7 diff --git a/lib/endgame-toolkit b/lib/endgame-toolkit new file mode 160000 index 0000000..8c879af --- /dev/null +++ b/lib/endgame-toolkit @@ -0,0 +1 @@ +Subproject commit 8c879af2c5a4a666995d8f5e20647a128d03fb0a diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..2061cc3 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,2 @@ +forge-std/=lib/dss-test/lib/forge-std/src/ +openzeppelin-contracts/=lib/endgame-toolkit/lib/openzeppelin-contracts/contracts/ \ No newline at end of file diff --git a/script/DeployProxy.s.sol b/script/DeployProxy.s.sol new file mode 100644 index 0000000..e3815a8 --- /dev/null +++ b/script/DeployProxy.s.sol @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Copyright (C) 2024 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +import "forge-std/Script.sol"; + +import { ScriptTools } from "dss-test/ScriptTools.sol"; +import { Domain } from "dss-test/domains/Domain.sol"; +import { VestedRewardsDistributionDeploy, VestedRewardsDistributionDeployParams } from "lib/endgame-toolkit/script/dependencies/VestedRewardsDistributionDeploy.sol"; +import { StakingRewardsDeploy, StakingRewardsDeployParams } from "lib/endgame-toolkit/script/dependencies/StakingRewardsDeploy.sol"; +import { DssVestMintableMock } from "test/mocks/DssVestMock.sol"; +import { FarmProxyDeploy } from "deploy/FarmProxyDeploy.sol"; + +interface L1GovernanceRelayLike { + function l2GovernanceRelay() external view returns (address); +} + +interface ChainLogLike { + function getAddress(bytes32) external view returns (address); +} + +interface AuthLike { + function rely(address usr) external; +} + +contract DeployProxy is Script { + using stdJson for string; + + uint256 l1PrivKey = vm.envUint("L1_PRIVATE_KEY"); + uint256 l2PrivKey = vm.envUint("L2_PRIVATE_KEY"); + address l1Deployer = vm.addr(l1PrivKey); + address l2Deployer = vm.addr(l2PrivKey); + + StdChains.Chain l1Chain; + StdChains.Chain l2Chain; + string config; + string deps; + Domain l1Domain; + Domain l2Domain; + ChainLogLike chainlog; + address l1GovRelay; + address l2GovRelay; + address owner; + address l1Gateway; + address vest; + address stakingToken; + address l1RewardsToken; + address l2RewardsToken; + address l1Proxy; + address vestedRewardsDistribution; + address farm; + address l2Proxy; + + function run() external { + l1Chain = getChain(string(vm.envOr("L1", string("mainnet")))); + l2Chain = getChain(string(vm.envOr("L2", string("arbitrum_one")))); + vm.setEnv("FOUNDRY_ROOT_CHAINID", vm.toString(l1Chain.chainId)); // used by ScriptTools to determine config path + config = ScriptTools.loadConfig("config"); + deps = ScriptTools.loadDependencies(); + l1Domain = new Domain(config, l1Chain); + l2Domain = new Domain(config, l2Chain); + l1Domain.selectFork(); + + chainlog = ChainLogLike(l1Domain.readConfigAddress("chainlog")); + l1GovRelay = chainlog.getAddress("ARBITRUM_GOV_RELAY"); + l2GovRelay = L1GovernanceRelayLike(payable(l1GovRelay)).l2GovernanceRelay(); + l1Gateway = chainlog.getAddress("ARBITRUM_TOKEN_BRIDGE"); + stakingToken = l2Domain.readConfigAddress("stakingToken"); + l1RewardsToken = l1Domain.readConfigAddress("rewardsToken"); + l2RewardsToken = l2Domain.readConfigAddress("rewardsToken"); + + if (keccak256(bytes(l1Chain.chainAlias)) == keccak256("mainnet")) { + owner = chainlog.getAddress("MCD_PAUSE_PROXY"); + vest = l1Domain.readConfigAddress("vest"); + } else { + owner = l1Deployer; + vm.startBroadcast(l1PrivKey); + vest = address(new DssVestMintableMock(l1RewardsToken)); + DssVestMintableMock(vest).file("cap", type(uint256).max); + AuthLike(l1RewardsToken).rely(address(vest)); + vm.stopBroadcast(); + } + + // L2 deployment + + StakingRewardsDeployParams memory farmParams = StakingRewardsDeployParams({ + owner: l2GovRelay, + stakingToken: stakingToken, + rewardsToken: l2RewardsToken + }); + l2Domain.selectFork(); + vm.startBroadcast(l2PrivKey); + farm = StakingRewardsDeploy.deploy(farmParams); + l2Proxy = FarmProxyDeploy.deployL2Proxy(l2Deployer, l2GovRelay, farm); + vm.stopBroadcast(); + + // L1 deployment + + l1Domain.selectFork(); + vm.startBroadcast(l1PrivKey); + l1Proxy = FarmProxyDeploy.deployL1Proxy( + l1Deployer, + owner, + l1RewardsToken, + l2Proxy, + deps.readAddress(".etherForwarder"), + l1Gateway + ); + VestedRewardsDistributionDeployParams memory distributionParams = VestedRewardsDistributionDeployParams({ + deployer: l1Deployer, + owner: owner, + vest: vest, + rewards: l1Proxy + }); + vestedRewardsDistribution = (VestedRewardsDistributionDeploy.deploy(distributionParams)); + vm.stopBroadcast(); + + // Export contract addresses + + // TODO: load the existing json so this is not required + ScriptTools.exportContract("deployed", "chainlog", deps.readAddress(".chainlog")); + ScriptTools.exportContract("deployed", "l2ProxySpell", deps.readAddress(".l2ProxySpell")); + ScriptTools.exportContract("deployed", "etherForwarder", deps.readAddress(".etherForwarder")); + ScriptTools.exportContract("deployed", "l1GovRelay", deps.readAddress(".l1GovRelay")); + ScriptTools.exportContract("deployed", "l2GovRelay", deps.readAddress(".l2GovRelay")); + + ScriptTools.exportContract("deployed", "farm", farm); + ScriptTools.exportContract("deployed", "l2Proxy", l2Proxy); + ScriptTools.exportContract("deployed", "l2RewardsToken", l2RewardsToken); + ScriptTools.exportContract("deployed", "stakingToken", stakingToken); + ScriptTools.exportContract("deployed", "l1Proxy", l1Proxy); + ScriptTools.exportContract("deployed", "vest", vest); + ScriptTools.exportContract("deployed", "vestedRewardsDistribution", vestedRewardsDistribution); + ScriptTools.exportContract("deployed", "l1RewardsToken", l1RewardsToken); + ScriptTools.exportContract("deployed", "l1Gateway", l1Gateway); + } +} diff --git a/script/DeploySingletons.s.sol b/script/DeploySingletons.s.sol new file mode 100644 index 0000000..d7a41c6 --- /dev/null +++ b/script/DeploySingletons.s.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Copyright (C) 2024 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +import "forge-std/Script.sol"; + +import { ScriptTools } from "dss-test/ScriptTools.sol"; +import { Domain } from "dss-test/domains/Domain.sol"; +import { FarmProxyDeploy } from "deploy/FarmProxyDeploy.sol"; + +interface ChainLogLike { + function getAddress(bytes32) external view returns (address); +} + +interface L1GovernanceRelayLike { + function l2GovernanceRelay() external view returns (address); +} + +contract DeploySingletons is Script { + + uint256 l2PrivKey = vm.envUint("L2_PRIVATE_KEY"); + address l2Deployer = vm.addr(l2PrivKey); + + StdChains.Chain l1Chain; + StdChains.Chain l2Chain; + string config; + Domain l1Domain; + Domain l2Domain; + address deployer; + ChainLogLike chainlog; + address l1GovRelay; + address l2GovRelay; + address l2Spell; + address etherForwarder; + + function run() external { + l1Chain = getChain(string(vm.envOr("L1", string("mainnet")))); + l2Chain = getChain(string(vm.envOr("L2", string("arbitrum_one")))); + vm.setEnv("FOUNDRY_ROOT_CHAINID", vm.toString(l1Chain.chainId)); // used by ScriptTools to determine config path + config = ScriptTools.loadConfig("config"); + l1Domain = new Domain(config, l1Chain); + l2Domain = new Domain(config, l2Chain); + + // Check deployer's L2 nonce was burned on L1 + + l2Domain.selectFork(); + uint256 l2Nonce = vm.getNonce(l2Deployer); + l1Domain.selectFork(); + address next = vm.computeCreateAddress(l2Deployer, l2Nonce); + require(next.code.length == 0, "Deployer's next L2 address has code on L1"); + uint256 l1Nonce = vm.getNonce(l2Deployer); + require(l1Nonce > l2Nonce, "Deployer requires nonce burning on L1"); + + chainlog = ChainLogLike(l1Domain.readConfigAddress("chainlog")); + l1GovRelay = chainlog.getAddress("ARBITRUM_GOV_RELAY"); + l2GovRelay = L1GovernanceRelayLike(payable(l1GovRelay)).l2GovernanceRelay(); + + l2Domain.selectFork(); + + vm.startBroadcast(l2PrivKey); + etherForwarder = FarmProxyDeploy.deployL2EtherForwarder(l2GovRelay); + l2Spell = FarmProxyDeploy.deployL2ProxySpell(); + vm.stopBroadcast(); + + // Export contract addresses + + ScriptTools.exportContract("deployed", "chainlog", address(chainlog)); + ScriptTools.exportContract("deployed", "l2ProxySpell", l2Spell); + ScriptTools.exportContract("deployed", "etherForwarder", etherForwarder); + ScriptTools.exportContract("deployed", "l1GovRelay", l1GovRelay); + ScriptTools.exportContract("deployed", "l2GovRelay", l2GovRelay); + } +} diff --git a/script/Distribute.s.sol b/script/Distribute.s.sol new file mode 100644 index 0000000..e17cd8a --- /dev/null +++ b/script/Distribute.s.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Copyright (C) 2024 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +import "forge-std/Script.sol"; + +import { ScriptTools } from "dss-test/ScriptTools.sol"; +import { Domain } from "dss-test/domains/Domain.sol"; + +interface DistributionLike { + function distribute() external returns (uint256); +} + +interface L1ProxyLike { + function estimateDepositCost(uint256, uint256, uint256) external view returns (uint256, uint256); +} + +// Run vestedRewardsDistribution.distribute() to test deployement +contract Distribute is Script { + using stdJson for string; + + uint256 l1PrivKey = vm.envUint("L1_PRIVATE_KEY"); + + function run() external { + StdChains.Chain memory l1Chain = getChain(string(vm.envOr("L1", string("mainnet")))); + vm.setEnv("FOUNDRY_ROOT_CHAINID", vm.toString(l1Chain.chainId)); // used by ScriptTools to determine config path + string memory config = ScriptTools.loadConfig("config"); + string memory deps = ScriptTools.loadDependencies(); + Domain l1Domain = new Domain(config, l1Chain); + l1Domain.selectFork(); + + DistributionLike distribution = DistributionLike(deps.readAddress(".vestedRewardsDistribution")); + address l1Proxy = deps.readAddress(".l1Proxy"); + (uint256 l1CallValue,) = L1ProxyLike(l1Proxy).estimateDepositCost(2 * block.basefee, 0, 0); + + vm.startBroadcast(l1PrivKey); + if (l1Proxy.balance < l1CallValue) { + (bool success,) = l1Proxy.call{value: l1CallValue - l1Proxy.balance}(""); + require(success, "l1Proxy topup failed"); + } + distribution.distribute(); + vm.stopBroadcast(); + } +} diff --git a/script/Estimate.s.sol b/script/Estimate.s.sol new file mode 100644 index 0000000..079130f --- /dev/null +++ b/script/Estimate.s.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Copyright (C) 2024 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +import "forge-std/Script.sol"; + +import { ScriptTools } from "dss-test/ScriptTools.sol"; +import { Domain } from "dss-test/domains/Domain.sol"; + +interface GatewayLike { + function getOutboundCalldata( + address l1Token, + address from, + address to, + uint256 amount, + bytes memory data + ) external pure returns (bytes memory); + function counterpartGateway() external view returns (address); +} + +interface ChainLogLike { + function getAddress(bytes32) external view returns (address); +} + +// Estimate `maxGas` for L1FarmProxy +contract Estimate is Script { + using stdJson for string; + + uint256 constant MAX_L1_BASE_FEE_ESTIMATE = 1 gwei; // worst-case estimate for l1BaseFeeEstimate (representing the blob base fee) returned from https://github.com/OffchainLabs/nitro-contracts/blob/90037b996509312ef1addb3f9352457b8a99d6a6/src/node-interface/NodeInterface.sol#L95 + bool constant USE_DAI_BRIDGE = true; // set to true if the new token gateway isn't yet initiated + + uint256 l1PrivKey = vm.envUint("L1_PRIVATE_KEY"); + address l1Deployer = vm.addr(l1PrivKey); + + function run() external { + // Note: this script should not be run on testnet as l1BaseFeeEstimate can sometimes be 0 on sepolia + StdChains.Chain memory l1Chain = getChain(string("mainnet")); + StdChains.Chain memory l2Chain = getChain(string("arbitrum_one")); + vm.setEnv("FOUNDRY_ROOT_CHAINID", vm.toString(l1Chain.chainId)); // used by ScriptTools to determine config path + string memory config = ScriptTools.loadConfig("config"); + Domain l1Domain = new Domain(config, l1Chain); + Domain l2Domain = new Domain(config, l2Chain); + l1Domain.selectFork(); + + ChainLogLike chainlog = ChainLogLike(l1Domain.readConfigAddress("chainlog")); + address l1Gateway; + address l1Token; + if (USE_DAI_BRIDGE) { + l1Gateway = chainlog.getAddress("ARBITRUM_DAI_BRIDGE"); + l1Token = chainlog.getAddress("MCD_DAI"); + } else { + l1Gateway = chainlog.getAddress("ARBITRUM_TOKEN_BRIDGE"); + l1Token = l1Domain.readConfigAddress("rewardsToken"); + } + address l2Gateway = GatewayLike(l1Gateway).counterpartGateway(); + + bytes memory finalizeDepositCalldata = GatewayLike(l1Gateway).getOutboundCalldata({ + l1Token: l1Token, + from: l1Deployer, + to: address(uint160(uint256(keccak256(abi.encode(l1Deployer, block.timestamp))))), // a pseudo-random address used as "fresh" destination address, + amount: uint128(uint256(keccak256(abi.encode(l1Deployer)))), // very large random-looking number => costlier calldata + data: "" + }); + bytes memory data = abi.encodeWithSignature( + "gasEstimateComponents(address,bool,bytes)", + l2Gateway, + false, + finalizeDepositCalldata + ); + address l2Sender = address(uint160(l1Gateway) + uint160(0x1111000000000000000000000000000000001111)); + + l2Domain.selectFork(); + bytes memory res = vm.rpc("eth_call", string(abi.encodePacked( + "[{\"to\": \"", + vm.toString(address(0xc8)), // NodeInterface + "\", \"from\": \"", + vm.toString(l2Sender), + "\", \"data\": \"", + vm.toString(data), + "\"}]" + ))); + + (uint64 gasEstimate, uint64 gasEstimateForL1,, uint256 l1BaseFeeEstimate) + = abi.decode(res, (uint64,uint64,uint256,uint256)); + + uint256 l2ExecutionGas = gasEstimate - gasEstimateForL1; + uint256 maxExtraGasForDataPosting = gasEstimateForL1 * MAX_L1_BASE_FEE_ESTIMATE / l1BaseFeeEstimate; + uint256 maxGas = l2ExecutionGas + maxExtraGasForDataPosting; + + console2.log(" L2 Execution Gas:", l2ExecutionGas); + console2.log("Cur Data Posting Gas:", gasEstimateForL1); + console2.log("Max Data Posting Gas:", maxExtraGasForDataPosting); + console2.log(" Recommended maxGas:", maxGas); + } +} diff --git a/script/Forward.s.sol b/script/Forward.s.sol new file mode 100644 index 0000000..0c18ded --- /dev/null +++ b/script/Forward.s.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Copyright (C) 2024 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +import "forge-std/Script.sol"; + +import { ScriptTools } from "dss-test/ScriptTools.sol"; +import { Domain } from "dss-test/domains/Domain.sol"; + +interface L2ProxyLike { + function forwardReward() external; +} + +// Run l2Proxy.forwardReward() to test deployement +contract Forward is Script { + using stdJson for string; + + uint256 l2PrivKey = vm.envUint("L2_PRIVATE_KEY"); + + function run() external { + StdChains.Chain memory l1Chain = getChain(string(vm.envOr("L1", string("mainnet")))); + StdChains.Chain memory l2Chain = getChain(string(vm.envOr("L2", string("arbitrum_one")))); + vm.setEnv("FOUNDRY_ROOT_CHAINID", vm.toString(l1Chain.chainId)); // used by ScriptTools to determine config path + string memory config = ScriptTools.loadConfig("config"); + string memory deps = ScriptTools.loadDependencies(); + Domain l2Domain = new Domain(config, l2Chain); + l2Domain.selectFork(); + + address l2Proxy = deps.readAddress(".l2Proxy"); + + vm.startBroadcast(l2PrivKey); + L2ProxyLike(l2Proxy).forwardReward(); + vm.stopBroadcast(); + } +} diff --git a/script/Init.s.sol b/script/Init.s.sol new file mode 100644 index 0000000..d2da947 --- /dev/null +++ b/script/Init.s.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Copyright (C) 2024 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +import "forge-std/Script.sol"; + +import { ScriptTools } from "dss-test/ScriptTools.sol"; +import { Domain } from "dss-test/domains/Domain.sol"; +import { MCD, DssInstance } from "dss-test/MCD.sol"; +import { FarmProxyInit, ProxiesConfig, MessageParams } from "deploy/FarmProxyInit.sol"; +import { L2FarmProxySpell } from "deploy/L2FarmProxySpell.sol"; +import { RetryableTickets } from "arbitrum-token-bridge/script/utils/RetryableTickets.sol"; + +interface L2GovernanceRelayLike { + function relay(address, bytes calldata) external; +} + +contract Init is Script { + using stdJson for string; + + uint256 l1PrivKey = vm.envUint("L1_PRIVATE_KEY"); + + StdChains.Chain l1Chain; + StdChains.Chain l2Chain; + string config; + string deps; + Domain l1Domain; + Domain l2Domain; + DssInstance dss; + address l1GovRelay; + address l2GovRelay; + + function run() external { + l1Chain = getChain(string(vm.envOr("L1", string("mainnet")))); + l2Chain = getChain(string(vm.envOr("L2", string("arbitrum_one")))); + vm.setEnv("FOUNDRY_ROOT_CHAINID", vm.toString(l1Chain.chainId)); // used by ScriptTools to determine config path + config = ScriptTools.loadConfig("config"); + deps = ScriptTools.loadDependencies(); + l1Domain = new Domain(config, l1Chain); + l2Domain = new Domain(config, l2Chain); + l1Domain.selectFork(); + + dss = MCD.loadFromChainlog(deps.readAddress(".chainlog")); + + l1GovRelay = deps.readAddress(".l1GovRelay"); + l2GovRelay = deps.readAddress(".l2GovRelay"); + RetryableTickets retryable = new RetryableTickets(l1Domain, l2Domain, l1GovRelay, l2GovRelay); + + address l2Proxy = deps.readAddress(".l2Proxy"); + address forwarder = deps.readAddress(".etherForwarder"); + address l2ProxySpell = deps.readAddress(".l2ProxySpell"); + address l2RewardsToken = deps.readAddress(".l2RewardsToken"); + address stakingToken = deps.readAddress(".stakingToken"); + address farm = deps.readAddress(".farm"); + uint256 rewardThreshold = 0; + uint256 rewardsDuration = 1 days; + + bytes memory initCalldata = abi.encodeCall(L2GovernanceRelayLike.relay, ( + l2ProxySpell, + abi.encodeCall(L2FarmProxySpell.init, ( + l2Proxy, + forwarder, + l2RewardsToken, + stakingToken, + farm, + rewardThreshold, + rewardsDuration + )) + )); + MessageParams memory xchainMsg = MessageParams({ + maxGas: retryable.getMaxGas(initCalldata) * 150 / 100, + gasPriceBid: retryable.getGasPriceBid() * 200 / 100, + maxSubmissionCost: retryable.getSubmissionFee(initCalldata) * 250 / 100 + }); + ProxiesConfig memory cfg = ProxiesConfig({ + vest: deps.readAddress(".vest"), + vestTot: 100 ether, + vestBgn: block.timestamp, + vestTau: 100 days, + vestedRewardsDistribution: deps.readAddress(".vestedRewardsDistribution"), + l1RewardsToken: deps.readAddress(".l1RewardsToken"), + l2RewardsToken: l2RewardsToken, + stakingToken: stakingToken, + l1Gateway: deps.readAddress(".l1Gateway"), + maxGas: 70_000_000, + gasPriceBid: 1 gwei, // 0.1 gwei arbitrum_one_sepolia gas price floor * 10x factor + rewardThreshold: rewardThreshold, + farm: farm, + rewardsDuration: rewardsDuration, + xchainMsg: xchainMsg, + proxyChainlogKey: "FARM_PROXY_TKA_TKB_ARB", + distrChainlogKey: "REWARDS_DISTRIBUTION_TKA_TKB_ARB" + }); + + vm.startBroadcast(l1PrivKey); + uint256 minGovRelayBal = cfg.xchainMsg.maxSubmissionCost + cfg.xchainMsg.maxGas * cfg.xchainMsg.gasPriceBid; + if (l1GovRelay.balance < minGovRelayBal) { + (bool success,) = l1GovRelay.call{value: minGovRelayBal - l1GovRelay.balance}(""); + require(success, "l1GovRelay topup failed"); + } + + FarmProxyInit.initProxies( + dss, + deps.readAddress(".l1Proxy"), + l2Proxy, + forwarder, + l2ProxySpell, + cfg + ); + vm.stopBroadcast(); + } +} diff --git a/script/input/1/config.json b/script/input/1/config.json new file mode 100644 index 0000000..ede643d --- /dev/null +++ b/script/input/1/config.json @@ -0,0 +1,15 @@ +{ + "domains": { + "mainnet": { + "chainlog": "0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F", + "rewardsToken": "0x0000000000000000000000000000000000000000", + "vest": "0x0000000000000000000000000000000000000000" + }, + "arbitrum_one": { + "inbox": "0x4Dbd4fc535Ac27206064B68FfCf827b0A60BAB3f", + "arbSys": "0x0000000000000000000000000000000000000064", + "rewardsToken": "0x0000000000000000000000000000000000000000", + "stakingToken": "0x0000000000000000000000000000000000000000" + } + } +} diff --git a/script/input/11155111/config.json b/script/input/11155111/config.json new file mode 100644 index 0000000..c105258 --- /dev/null +++ b/script/input/11155111/config.json @@ -0,0 +1,14 @@ +{ + "domains": { + "sepolia": { + "chainlog": "0x066eBcc55Ca699e14F3c3694CdB230a2B8cE3a83", + "rewardsToken": "0x0B2eaB37Ab96685Ad2b1A6FdDb8921e156073f84" + }, + "arbitrum_one_sepolia": { + "inbox": "0xaAe29B0366299461418F5324a79Afc425BE5ae21", + "arbSys": "0x0000000000000000000000000000000000000064", + "rewardsToken": "0x76F0DEE2c570401300f85c0Dc451780AD9f0e72c", + "stakingToken": "0x7E6c1b028E73B912eC3e0D56537DC380A1805813" + } + } +} diff --git a/script/output/1/deployed-latest.json b/script/output/1/deployed-latest.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/script/output/1/deployed-latest.json @@ -0,0 +1 @@ +{} diff --git a/script/output/11155111/deployed-latest.json b/script/output/11155111/deployed-latest.json new file mode 100644 index 0000000..272215d --- /dev/null +++ b/script/output/11155111/deployed-latest.json @@ -0,0 +1,16 @@ +{ + "chainlog": "0x066eBcc55Ca699e14F3c3694CdB230a2B8cE3a83", + "etherForwarder": "0x0e52119de617fc0F64495CcDD67CF8c9083De339", + "farm": "0x53CdCEE25dE66009cea356Ac74B3e37d0229125C", + "l1Gateway": "0x95a9f6c87F6BF487875c9C09e70A5c8DC9B41EF4", + "l1GovRelay": "0x1Fc16121472E5990A112EC43266edf32E2a97fF8", + "l1Proxy": "0xFB7716073036b616A5E20d82F3E2a94a83Eeb85D", + "l1RewardsToken": "0x0B2eaB37Ab96685Ad2b1A6FdDb8921e156073f84", + "l2GovRelay": "0xA87F8FFC547ca1613f0d22Ce288C39e1BBffEbf6", + "l2Proxy": "0x3868A614cCF9C99a8636cf0c6D6057D869C6B3D3", + "l2ProxySpell": "0x26DA7B8FDeDbead1fDDf8A8B4585F2A9a7F7EF5F", + "l2RewardsToken": "0x76F0DEE2c570401300f85c0Dc451780AD9f0e72c", + "stakingToken": "0x7E6c1b028E73B912eC3e0D56537DC380A1805813", + "vest": "0x148F291f0A83aa80660CFdfc2605D1e0387A489d", + "vestedRewardsDistribution": "0x2851cEA9c9a737f341D5Cd0e02aa7f9B0B24a32c" +} diff --git a/src/EtherForwarder.sol b/src/EtherForwarder.sol new file mode 100644 index 0000000..3351069 --- /dev/null +++ b/src/EtherForwarder.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Copyright (C) 2024 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +contract EtherForwarder { + address public immutable receiver; + + event Forward(uint256 amount); + + constructor(address _receiver) { + receiver = _receiver; + } + + receive() external payable {} + + function forward() external { + uint256 amount = address(this).balance; + (bool sent, ) = receiver.call{value: amount}(""); + require(sent, "EtherForwarder/failed-to-send-ether"); + emit Forward(amount); + } +} diff --git a/src/L1FarmProxy.sol b/src/L1FarmProxy.sol new file mode 100644 index 0000000..3c6e928 --- /dev/null +++ b/src/L1FarmProxy.sol @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Copyright (C) 2024 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +interface GemLike { + function approve(address, uint256) external; + function transfer(address, uint256) external; +} + +interface L1TokenGatewayLike { + function inbox() external view returns (address); + function outboundTransferCustomRefund( + address l1Token, + address refundTo, + address to, + uint256 amount, + uint256 maxGas, + uint256 gasPriceBid, + bytes calldata data + ) external payable returns (bytes memory); +} + +interface InboxLike { + function calculateRetryableSubmissionFee(uint256 dataLength, uint256 baseFee) external view returns (uint256); +} + +contract L1FarmProxy { + mapping (address => uint256) public wards; + uint64 public maxGas; + uint64 public gasPriceBid; + uint128 public rewardThreshold; + + address public immutable rewardsToken; + address public immutable l2Proxy; + address public immutable feeRecipient; // L2 recipient of excess fee. This address must never contain code on L1. + InboxLike public immutable inbox; + L1TokenGatewayLike public immutable l1Gateway; + + event Rely(address indexed usr); + event Deny(address indexed usr); + event File(bytes32 indexed what, uint256 data); + event RewardAdded(uint256 reward); + + constructor(address _rewardsToken, address _l2Proxy, address _feeRecipient, address _l1Gateway) { + rewardsToken = _rewardsToken; + l2Proxy = _l2Proxy; + feeRecipient = _feeRecipient; + l1Gateway = L1TokenGatewayLike(_l1Gateway); + inbox = InboxLike(l1Gateway.inbox()); + + GemLike(_rewardsToken).approve(_l1Gateway, type(uint256).max); + + wards[msg.sender] = 1; + emit Rely(msg.sender); + } + + modifier auth { + require(wards[msg.sender] == 1, "L1FarmProxy/not-authorized"); + _; + } + + function rely(address usr) external auth { wards[usr] = 1; emit Rely(usr); } + function deny(address usr) external auth { wards[usr] = 0; emit Deny(usr); } + + // @notice Validation of the `data` boundaries is outside the scope of this + // contract and is assumed to be carried out in the corresponding spell process + function file(bytes32 what, uint256 data) external auth { + if (what == "maxGas") maxGas = uint64(data); + else if (what == "gasPriceBid") gasPriceBid = uint64(data); + else if (what == "rewardThreshold") rewardThreshold = uint128(data); + else revert("L1FarmProxy/file-unrecognized-param"); + emit File(what, data); + } + + // @notice Allow contract to receive ether + receive() external payable {} + + // @notice Allow governance to reclaim stored ether + function reclaim(address receiver, uint256 amount) external auth { + (bool sent,) = receiver.call{value: amount}(""); + require(sent, "L1FarmProxy/failed-to-send-ether"); + } + + // @notice Allow governance to recover potentially stuck tokens + function recover(address token, address receiver, uint256 amount) external auth { + GemLike(token).transfer(receiver, amount); + } + + // @notice Estimate the amount of ETH consumed as msg.value from this contract to bridge the reward to the L2 proxy + // as well as the RetryableTicket submission cost. + // @param l1BaseFee L1 baseFee to use for the estimate. Pass 0 to use block.basefee + // @param _maxGas Max gas to cover the L2 execution of the deposit. Pass 0 to use the stored `maxGas` value. + // @param _gasPriceBid Gas price bid for the L2 execution of the deposit. Pass 0 to use the stored `gasPriceBid` value. + function estimateDepositCost( + uint256 l1BaseFee, + uint256 _maxGas, + uint256 _gasPriceBid + ) public view returns (uint256 l1CallValue, uint256 maxSubmissionCost) { + maxSubmissionCost = inbox.calculateRetryableSubmissionFee(324, l1BaseFee); // size of finalizeInboundTransfer calldata = 4 + 10*32 bytes + (uint256 maxGas_, uint256 gasPriceBid_) = (_maxGas > 0 ? _maxGas : maxGas, _gasPriceBid > 0 ? _gasPriceBid : gasPriceBid); + l1CallValue = maxSubmissionCost + maxGas_ * gasPriceBid_; + } + + // @notice As this function is permissionless, it could in theory be called at a time where + // maxGas and/or gasPriceBid are too low for the auto-redeem of the gem deposit RetryableTicket. + // This is mitigated by incorporating large enough safety factors in maxGas and gasPriceBid. + // Note that in any case a failed auto-redeem can be permissionlessly retried for 7 days + function notifyRewardAmount(uint256 reward) external { + (uint256 maxGas_, uint256 gasPriceBid_, uint256 rewardThreshold_) = (maxGas, gasPriceBid, rewardThreshold); + + require(reward > rewardThreshold_, "L1FarmProxy/reward-too-small"); + + (uint256 l1CallValue, uint256 maxSubmissionCost) = estimateDepositCost(0, maxGas_, gasPriceBid_); + + l1Gateway.outboundTransferCustomRefund{value: l1CallValue}({ + l1Token: rewardsToken, + refundTo: feeRecipient, + to: l2Proxy, + amount: reward, + maxGas: maxGas_, + gasPriceBid: gasPriceBid_, + data: abi.encode(maxSubmissionCost, bytes("")) + }); + + emit RewardAdded(reward); + } +} diff --git a/src/L2FarmProxy.sol b/src/L2FarmProxy.sol new file mode 100644 index 0000000..091803d --- /dev/null +++ b/src/L2FarmProxy.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Copyright (C) 2024 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +interface FarmLike { + function rewardsToken() external view returns (address); + function notifyRewardAmount(uint256 reward) external; +} + +interface GemLike { + function balanceOf(address) external view returns (uint256); + function transfer(address, uint256) external; +} + +contract L2FarmProxy { + mapping (address => uint256) public wards; + uint256 public rewardThreshold; + + GemLike public immutable rewardsToken; + FarmLike public immutable farm; + + event Rely(address indexed usr); + event Deny(address indexed usr); + event File(bytes32 indexed what, uint256 data); + + constructor(address _farm) { + farm = FarmLike(_farm); + rewardsToken = GemLike(farm.rewardsToken()); + + wards[msg.sender] = 1; + emit Rely(msg.sender); + } + + modifier auth { + require(wards[msg.sender] == 1, "L2FarmProxy/not-authorized"); + _; + } + + function rely(address usr) external auth { wards[usr] = 1; emit Rely(usr); } + function deny(address usr) external auth { wards[usr] = 0; emit Deny(usr); } + + function file(bytes32 what, uint256 data) external auth { + if (what == "rewardThreshold") rewardThreshold = data; + else revert("L2FarmProxy/file-unrecognized-param"); + emit File(what, data); + } + + // @notice Allow governance to recover potentially stuck tokens + function recover(address token, address receiver, uint256 amount) external auth { + GemLike(token).transfer(receiver, amount); + } + + // @notice The transferred reward must exceed a minimum threshold to reduce the impact of + // calling this function too frequently in an attempt to reduce the rewardRate of the farm + function forwardReward() external { + uint256 reward = rewardsToken.balanceOf(address(this)); + require(reward > rewardThreshold, "L2FarmProxy/reward-too-small"); + rewardsToken.transfer(address(farm), reward); + farm.notifyRewardAmount(reward); + } +} diff --git a/test/EtherForwarder.t.sol b/test/EtherForwarder.t.sol new file mode 100644 index 0000000..8ce9d81 --- /dev/null +++ b/test/EtherForwarder.t.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Copyright (C) 2024 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +import "dss-test/DssTest.sol"; + +import { EtherForwarder } from "src/EtherForwarder.sol"; + +contract EtherForwarderTest is DssTest { + + EtherForwarder forwarder; + address receiver = address(123); + + event Forward(uint256 amount); + + function setUp() public { + forwarder = new EtherForwarder(receiver); + assertEq(forwarder.receiver(), receiver); + } + + function testForward() public { + (bool success,) = address(forwarder).call{value: 1 ether}(""); // not using deal() here, so as to check receive() + assertTrue(success); + uint256 receiverBefore = receiver.balance; + + vm.expectEmit(true, true, true, true); + emit Forward(1 ether); + forwarder.forward(); + + assertEq(receiver.balance, receiverBefore + 1 ether); + assertEq(address(forwarder).balance, 0); + + EtherForwarder badForwarder = new EtherForwarder(address(this)); + vm.expectRevert("EtherForwarder/failed-to-send-ether"); + badForwarder.forward(); + } +} diff --git a/test/Integration.t.sol b/test/Integration.t.sol new file mode 100644 index 0000000..a85ade6 --- /dev/null +++ b/test/Integration.t.sol @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Copyright (C) 2024 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +import "dss-test/DssTest.sol"; + +import { Domain } from "dss-test/domains/Domain.sol"; +import { ArbitrumDomain } from "dss-test/domains/ArbitrumDomain.sol"; + +import { TokenGatewayDeploy } from "lib/arbitrum-token-bridge/deploy/TokenGatewayDeploy.sol"; +import { L2TokenGatewaySpell } from "lib/arbitrum-token-bridge/deploy/L2TokenGatewaySpell.sol"; +import { L2TokenGatewayInstance } from "lib/arbitrum-token-bridge/deploy/L2TokenGatewayInstance.sol"; +import { TokenGatewayInit, GatewaysConfig, MessageParams as GatewayMessageParams } from "lib/arbitrum-token-bridge/deploy/TokenGatewayInit.sol"; + +import { StakingRewards, StakingRewardsDeploy, StakingRewardsDeployParams } from "lib/endgame-toolkit/script/dependencies/StakingRewardsDeploy.sol"; +import { VestedRewardsDistributionDeploy, VestedRewardsDistributionDeployParams } from "lib/endgame-toolkit/script/dependencies/VestedRewardsDistributionDeploy.sol"; +import { VestedRewardsDistribution } from "lib/endgame-toolkit/src/VestedRewardsDistribution.sol"; + +import { GemMock } from "test/mocks/GemMock.sol"; +import { DssVestMintableMock } from "test/mocks/DssVestMock.sol"; + +import { FarmProxyDeploy } from "deploy/FarmProxyDeploy.sol"; +import { L2FarmProxySpell } from "deploy/L2FarmProxySpell.sol"; +import { FarmProxyInit, ProxiesConfig, MessageParams as ProxyMessageParams } from "deploy/FarmProxyInit.sol"; +import { L1FarmProxy } from "src/L1FarmProxy.sol"; +import { L2FarmProxy } from "src/L2FarmProxy.sol"; + +interface L1RelayLike { + function l2GovernanceRelay() external view returns (address); +} + +contract L1RouterMock { + function counterpartGateway() external view returns (address) {} +} + +contract IntegrationTest is DssTest { + string config; + Domain l1Domain; + ArbitrumDomain l2Domain; + + // L1-side + DssInstance dss; + address PAUSE_PROXY; + address ESCROW; + address L1_ROUTER; + GemMock l1Token; + address l1Gateway; + L1FarmProxy l1Proxy; + DssVestMintableMock vest; + uint256 vestId; + VestedRewardsDistribution vestedRewardsDistribution; + + // L2-side + address L2_GOV_RELAY; + GemMock l2Token; + address l2Gateway; + L2FarmProxy l2Proxy; + StakingRewards farm; + + function setupGateways() internal { + ESCROW = dss.chainlog.getAddress("ARBITRUM_ESCROW"); + vm.label(address(ESCROW), "ESCROW"); + + l2Domain = new ArbitrumDomain(config, getChain("arbitrum_one"), l1Domain); + address inbox = address(l2Domain.inbox()); + vm.label(inbox, "INBOX"); + + address l1Gateway_ = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 2); // foundry increments a global nonce across domains + l2Domain.selectFork(); + L2TokenGatewayInstance memory l2GatewayInstance = TokenGatewayDeploy.deployL2Gateway({ + deployer: address(this), + owner: L2_GOV_RELAY, + l1Gateway: l1Gateway_, + l2Router: address(0) + }); + l2Gateway = l2GatewayInstance.gateway; + assertEq(address(L2TokenGatewaySpell(l2GatewayInstance.spell).l2Gateway()), address(l2Gateway)); + + l1Domain.selectFork(); + l1Gateway = TokenGatewayDeploy.deployL1Gateway({ + deployer: address(this), + owner: PAUSE_PROXY, + l2Gateway: address(l2Gateway), + l1Router: L1_ROUTER, + inbox: inbox + }); + assertEq(address(l1Gateway), l1Gateway_); + + l2Domain.selectFork(); + l2Token = new GemMock(0); + l2Token.rely(L2_GOV_RELAY); + l2Token.deny(address(this)); + vm.label(address(l2Token), "l2Token"); + + address[] memory l1Tokens = new address[](1); + l1Tokens[0] = address(l1Token); + address[] memory l2Tokens = new address[](1); + l2Tokens[0] = address(l2Token); + GatewayMessageParams memory xchainMsg = GatewayMessageParams({ + gasPriceBid: 0.1 gwei, + maxGas: 300_000, + maxSubmissionCost: 0.01 ether + }); + GatewaysConfig memory cfg = GatewaysConfig({ + l1Router: L1_ROUTER, + inbox: inbox, + l1Tokens: l1Tokens, + l2Tokens: l2Tokens, + xchainMsg: xchainMsg + }); + + l1Domain.selectFork(); + vm.startPrank(PAUSE_PROXY); + TokenGatewayInit.initGateways(dss, address(l1Gateway), l2GatewayInstance, cfg); + vm.stopPrank(); + } + + function setUp() public { + vm.setEnv("FOUNDRY_ROOT_CHAINID", "1"); // used by ScriptTools to determine config path + config = ScriptTools.loadConfig("config"); + + l1Domain = new Domain(config, getChain("mainnet")); + l1Domain.selectFork(); + l1Domain.loadDssFromChainlog(); + dss = l1Domain.dss(); + PAUSE_PROXY = dss.chainlog.getAddress("MCD_PAUSE_PROXY"); + L2_GOV_RELAY = L1RelayLike(dss.chainlog.getAddress("ARBITRUM_GOV_RELAY")).l2GovernanceRelay(); + L1_ROUTER = address(new L1RouterMock()); + + vm.startPrank(PAUSE_PROXY); + l1Token = new GemMock(100 ether); + vest = new DssVestMintableMock(address(l1Token)); + l1Token.rely(address(vest)); + vest.file("cap", type(uint256).max); + vm.stopPrank(); + + setupGateways(); + + l2Domain.selectFork(); + + address stakingToken = address(new GemMock(100 ether)); + StakingRewardsDeployParams memory farmParams = StakingRewardsDeployParams({ + owner: L2_GOV_RELAY, + stakingToken: stakingToken, + rewardsToken: address(l2Token) + }); + farm = StakingRewards(StakingRewardsDeploy.deploy(farmParams)); + + l2Proxy = L2FarmProxy(FarmProxyDeploy.deployL2Proxy({ + deployer: address(this), + owner: L2_GOV_RELAY, + farm: address(farm) + })); + address l2Spell = FarmProxyDeploy.deployL2ProxySpell(); + address forwarder = FarmProxyDeploy.deployL2EtherForwarder(L2_GOV_RELAY); + + l1Domain.selectFork(); + l1Proxy = L1FarmProxy(payable(FarmProxyDeploy.deployL1Proxy({ + deployer: address(this), + owner: PAUSE_PROXY, + rewardsToken: address(l1Token), + l2Proxy: address(l2Proxy), + feeRecipient: forwarder, + l1Gateway: l1Gateway + }))); + + VestedRewardsDistributionDeployParams memory distributionParams = VestedRewardsDistributionDeployParams({ + deployer: address(this), + owner: PAUSE_PROXY, + vest: address(vest), + rewards: address(l1Proxy) + }); + vestedRewardsDistribution = VestedRewardsDistribution(VestedRewardsDistributionDeploy.deploy(distributionParams)); + + (bool success,) = address(l1Proxy).call{value: 1 ether}(""); + assertTrue(success); + ProxyMessageParams memory xchainMsg = ProxyMessageParams({ + gasPriceBid: 0.1 gwei, + maxGas: 300_000, + maxSubmissionCost: 0.01 ether + }); + ProxiesConfig memory cfg = ProxiesConfig({ + vest: address(vest), + vestTot: 100 * 1e18, + vestBgn: block.timestamp, + vestTau: 100 days, + vestedRewardsDistribution: address(vestedRewardsDistribution), + l1RewardsToken: address(l1Token), + l2RewardsToken: address(l2Token), + stakingToken: stakingToken, + l1Gateway: l1Gateway, + maxGas: 1_000_000, // determined by running deploy/Estimate.s.sol and adding some margin + gasPriceBid: 0.1 gwei, // 0.01 gwei arbitrum_one gas price floor * 10x factor + rewardThreshold: 1 ether, + farm: address(farm), + rewardsDuration: 1 days, + xchainMsg: xchainMsg, + proxyChainlogKey: "FARM_PROXY_TKA_TKB_ARB", + distrChainlogKey: "REWARDS_DISTRIBUTION_TKA_TKB_ARB" + }); + vm.startPrank(PAUSE_PROXY); + FarmProxyInit.initProxies(dss, address(l1Proxy), address(l2Proxy), forwarder, l2Spell, cfg); + vm.stopPrank(); + + // test L1 side of initProxies + vestId = vestedRewardsDistribution.vestId(); + assertEq(vest.usr(vestId), cfg.vestedRewardsDistribution); + assertEq(vest.bgn(vestId), cfg.vestBgn); + assertEq(vest.clf(vestId), cfg.vestBgn); + assertEq(vest.fin(vestId), cfg.vestBgn + cfg.vestTau); + assertEq(vest.tot(vestId), cfg.vestTot); + assertEq(vest.mgr(vestId), address(0)); + assertEq(vest.res(vestId), 1); + assertEq(l1Proxy.maxGas(), cfg.maxGas); + assertEq(l1Proxy.gasPriceBid(), cfg.gasPriceBid); + assertEq(l1Proxy.rewardThreshold(), cfg.rewardThreshold); + assertEq(dss.chainlog.getAddress("FARM_PROXY_TKA_TKB_ARB"), address(l1Proxy)); + assertEq(dss.chainlog.getAddress("REWARDS_DISTRIBUTION_TKA_TKB_ARB"), cfg.vestedRewardsDistribution); + + l2Domain.relayFromHost(true); + + // test L2 side of initProxies + assertEq(l2Proxy.rewardThreshold(), cfg.rewardThreshold); + assertEq(farm.rewardsDistribution(), address(l2Proxy)); + assertEq(farm.rewardsDuration(), cfg.rewardsDuration); + } + + function testDistribution() public { + l1Domain.selectFork(); + uint256 rewardThreshold = l1Proxy.rewardThreshold(); + vm.warp(vest.bgn(vestId) + rewardThreshold * (vest.fin(vestId) - vest.bgn(vestId)) / vest.tot(vestId) + 1); + uint256 amount = vest.unpaid(vestId); + assertGt(amount, rewardThreshold); + assertEq(l1Token.balanceOf(ESCROW), 0); + + vestedRewardsDistribution.distribute(); + + assertEq(l1Token.balanceOf(ESCROW), amount); + + l2Domain.relayFromHost(true); + + assertEq(l2Token.balanceOf(address(l2Proxy)), amount); + + l2Proxy.forwardReward(); + + assertEq(l2Token.balanceOf(address(l2Proxy)), 0); + assertEq(l2Token.balanceOf(address(farm)), amount); + assertEq(farm.rewardRate(), amount / farm.rewardsDuration()); + } +} diff --git a/test/L1FarmProxy.t.sol b/test/L1FarmProxy.t.sol new file mode 100644 index 0000000..2b0097b --- /dev/null +++ b/test/L1FarmProxy.t.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Copyright (C) 2024 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +import "dss-test/DssTest.sol"; + +import { L1FarmProxy } from "src/L1FarmProxy.sol"; +import { InboxMock } from "test/mocks/InboxMock.sol"; +import { GemMock } from "test/mocks/GemMock.sol"; +import { L1TokenGatewayMock } from "test/mocks/L1TokenGatewayMock.sol"; + +contract L1FarmProxyTest is DssTest { + + GemMock rewardsToken; + L1FarmProxy l1Proxy; + address inbox; + address gateway; + address escrow = address(0xeee); + address l2Proxy = address(0x222); + address feeRecipient = address(0xfee); + + event RewardAdded(uint256 rewards); + + function setUp() public { + inbox = address(new InboxMock()); + gateway = address(new L1TokenGatewayMock(inbox, escrow)); + rewardsToken = new GemMock(1_000_000 ether); + l1Proxy = new L1FarmProxy(address(rewardsToken), l2Proxy, feeRecipient, gateway); + } + + function testConstructor() public { + vm.expectEmit(true, true, true, true); + emit Rely(address(this)); + L1FarmProxy p = new L1FarmProxy(address(rewardsToken), l2Proxy, feeRecipient, gateway); + + assertEq(p.rewardsToken(), address(rewardsToken)); + assertEq(p.l2Proxy(), l2Proxy); + assertEq(p.feeRecipient(), feeRecipient); + assertEq(address(p.l1Gateway()), gateway); + assertEq(address(p.inbox()), inbox); + assertEq(rewardsToken.allowance(address(p), gateway), type(uint256).max); + assertEq(p.wards(address(this)), 1); + } + + function testAuth() public { + checkAuth(address(l1Proxy), "L1FarmProxy"); + } + + function testFile() public { + checkFileUint(address(l1Proxy), "L1FarmProxy", ["maxGas", "gasPriceBid", "rewardThreshold"]); + } + + function testAuthModifiers() public virtual { + l1Proxy.deny(address(this)); + + checkModifier(address(l1Proxy), string(abi.encodePacked("L1FarmProxy", "/not-authorized")), [ + l1Proxy.reclaim.selector, + l1Proxy.recover.selector + ]); + } + + function testReclaim() public { + (bool success,) = address(l1Proxy).call{value: 1 ether}(""); // not using deal() here, so as to check receive() + assertTrue(success); + address to = address(0x123); + uint256 proxyBefore = address(l1Proxy).balance; + uint256 toBefore = to.balance; + + l1Proxy.reclaim(to, 0.2 ether); + + assertEq(to.balance, toBefore + 0.2 ether); + assertEq(address(l1Proxy).balance, proxyBefore - 0.2 ether); + + vm.expectRevert("L1FarmProxy/failed-to-send-ether"); + l1Proxy.reclaim(to, 1 ether); // insufficient balance + } + + function testRecover() public { + address receiver = address(0x123); + rewardsToken.transfer(address(l1Proxy), 1 ether); + + assertEq(rewardsToken.balanceOf(receiver), 0); + assertEq(rewardsToken.balanceOf(address(l1Proxy)), 1 ether); + + l1Proxy.recover(address(rewardsToken), receiver, 1 ether); + + assertEq(rewardsToken.balanceOf(receiver), 1 ether); + assertEq(rewardsToken.balanceOf(address(l1Proxy)), 0); + } + + function testNotifyRewardAmount() public { + l1Proxy.file("rewardThreshold", 100 ether); + + vm.expectRevert("L1FarmProxy/reward-too-small"); + l1Proxy.notifyRewardAmount(100 ether); + + (bool success,) = address(l1Proxy).call{value: 1 ether}(""); + assertTrue(success); + rewardsToken.transfer(address(l1Proxy), 101 ether); + assertEq(rewardsToken.balanceOf(escrow), 0); + assertEq(rewardsToken.balanceOf(address(l1Proxy)), 101 ether); + uint256 ethBefore = address(l1Proxy).balance; + (uint256 l1CallValue,) = l1Proxy.estimateDepositCost(0, 0, 0); + + vm.expectEmit(true, true, true, true); + emit RewardAdded(101 ether); + l1Proxy.notifyRewardAmount(101 ether); + + assertEq(rewardsToken.balanceOf(escrow), 101 ether); + assertEq(rewardsToken.balanceOf(address(l1Proxy)), 0); + assertEq(address(l1Proxy).balance, ethBefore - l1CallValue); + } +} diff --git a/test/L2FarmProxy.t.sol b/test/L2FarmProxy.t.sol new file mode 100644 index 0000000..6037c55 --- /dev/null +++ b/test/L2FarmProxy.t.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Copyright (C) 2024 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +import "dss-test/DssTest.sol"; + +import { L2FarmProxy } from "src/L2FarmProxy.sol"; +import { FarmMock } from "test/mocks/FarmMock.sol"; +import { GemMock } from "test/mocks/GemMock.sol"; + +contract L2FarmProxyTest is DssTest { + + GemMock rewardsToken; + L2FarmProxy l2Proxy; + address farm; + + event RewardAdded(uint256 rewards); + + function setUp() public { + rewardsToken = new GemMock(1_000_000 ether); + farm = address(new FarmMock(address(rewardsToken), address(123))); + l2Proxy = new L2FarmProxy(farm); + } + + function testConstructor() public { + vm.expectEmit(true, true, true, true); + emit Rely(address(this)); + L2FarmProxy p = new L2FarmProxy(farm); + assertEq(address(p.farm()), farm); + assertEq(address(p.rewardsToken()), address(rewardsToken)); + assertEq(p.wards(address(this)), 1); + } + + function testAuth() public { + checkAuth(address(l2Proxy), "L2FarmProxy"); + } + + function testFile() public { + checkFileUint(address(l2Proxy), "L2FarmProxy", ["rewardThreshold"]); + } + + function testAuthModifiers() public virtual { + l2Proxy.deny(address(this)); + + checkModifier(address(l2Proxy), string(abi.encodePacked("L2FarmProxy", "/not-authorized")), [ + l2Proxy.recover.selector + ]); + } + + function testRecover() public { + address receiver = address(0x123); + rewardsToken.transfer(address(l2Proxy), 1 ether); + + assertEq(rewardsToken.balanceOf(receiver), 0); + assertEq(rewardsToken.balanceOf(address(l2Proxy)), 1 ether); + + l2Proxy.recover(address(rewardsToken), receiver, 1 ether); + + assertEq(rewardsToken.balanceOf(receiver), 1 ether); + assertEq(rewardsToken.balanceOf(address(l2Proxy)), 0); + } + + function testForwardReward() public { + l2Proxy.file("rewardThreshold", 100 ether); + rewardsToken.transfer(address(l2Proxy), 100 ether); + + vm.expectRevert("L2FarmProxy/reward-too-small"); + l2Proxy.forwardReward(); + + rewardsToken.transfer(address(l2Proxy), 1 ether); + assertEq(rewardsToken.balanceOf(farm), 0); + assertEq(rewardsToken.balanceOf(address(l2Proxy)), 101 ether); + + vm.expectEmit(true, true, true, true); + emit RewardAdded(101 ether); + l2Proxy.forwardReward(); + + assertEq(rewardsToken.balanceOf(farm), 101 ether); + assertEq(rewardsToken.balanceOf(address(l2Proxy)), 0); + } +} diff --git a/test/L2FarmProxySpell.t.sol b/test/L2FarmProxySpell.t.sol new file mode 100644 index 0000000..6db85af --- /dev/null +++ b/test/L2FarmProxySpell.t.sol @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Copyright (C) 2024 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +import "dss-test/DssTest.sol"; + +import { EtherForwarder } from "src/EtherForwarder.sol"; +import { L2FarmProxy } from "src/L2FarmProxy.sol"; +import { L2FarmProxySpell } from "deploy/L2FarmProxySpell.sol"; +import { FarmMock } from "test/mocks/FarmMock.sol"; +import { GemMock } from "test/mocks/GemMock.sol"; + +contract L2FarmProxySpellTest is DssTest { + + GemMock rewardsToken; + address stakingToken = address(444); + address l2Proxy; + address forwarder; + L2FarmProxySpell l2Spell; + address farm; + + event OwnerNominated(address newOwner); + event PauseChanged(bool isPaused); + event RewardsDurationUpdated(uint256 newDuration); + event RewardsDistributionUpdated(address newRewardsDistribution); + event Recovered(address token, uint256 amount); + + function setUp() public { + rewardsToken = new GemMock(1_000_000 ether); + farm = address(new FarmMock(address(rewardsToken), stakingToken)); + l2Proxy = address(new L2FarmProxy(farm)); + forwarder = address(new EtherForwarder(address(this))); + l2Spell = new L2FarmProxySpell(); + } + + function testL2ProxyFunctions() public { + bool success; + address usr = address(123); + + vm.expectEmit(true, true, true, true); + emit Rely(usr); + (success,) = address(l2Spell).delegatecall(abi.encodeCall(L2FarmProxySpell.rely, (l2Proxy, usr))); + assertTrue(success); + + vm.expectEmit(true, true, true, true); + emit Deny(usr); + (success,) = address(l2Spell).delegatecall(abi.encodeCall(L2FarmProxySpell.deny, (l2Proxy, usr))); + assertTrue(success); + + bytes32 what = "rewardThreshold"; + uint256 data = 456; + vm.expectEmit(true, true, true, true); + emit File(what, data); + (success,) = address(l2Spell).delegatecall(abi.encodeCall(L2FarmProxySpell.file, (l2Proxy, what, data))); + assertTrue(success); + + uint256 amount = 789 ether; + rewardsToken.transfer(l2Proxy, amount); + (success,) = address(l2Spell).delegatecall(abi.encodeCall(L2FarmProxySpell.recover, (l2Proxy, address(rewardsToken), usr, amount))); + assertTrue(success); + assertEq(rewardsToken.balanceOf(usr), amount); + } + + function testFarmFunctions() public { + bool success; + address usr = address(123); + + vm.expectEmit(true, true, true, true); + emit OwnerNominated(usr); + (success,) = address(l2Spell).delegatecall(abi.encodeCall(L2FarmProxySpell.nominateNewOwner, (farm, usr))); + assertTrue(success); + + vm.expectEmit(true, true, true, true); + emit PauseChanged(true); + (success,) = address(l2Spell).delegatecall(abi.encodeCall(L2FarmProxySpell.setPaused, (farm, true))); + assertTrue(success); + + uint256 amount = 456 ether; + vm.expectEmit(true, true, true, true); + emit Recovered(address(rewardsToken), amount); + (success,) = address(l2Spell).delegatecall(abi.encodeCall(L2FarmProxySpell.recoverERC20, (farm, address(rewardsToken), amount))); + assertTrue(success); + + vm.expectEmit(true, true, true, true); + emit RewardsDurationUpdated(amount); + (success,) = address(l2Spell).delegatecall(abi.encodeCall(L2FarmProxySpell.setRewardsDuration, (farm, amount))); + assertTrue(success); + + vm.expectEmit(true, true, true, true); + emit RewardsDistributionUpdated(usr); + (success,) = address(l2Spell).delegatecall(abi.encodeCall(L2FarmProxySpell.setRewardsDistribution, (farm, usr))); + assertTrue(success); + } + + // from https://ethereum.stackexchange.com/a/83577 + function _getRevertMsg(bytes memory _returnData) internal pure returns (string memory) { + if (_returnData.length < 68) return 'Transaction reverted silently'; + assembly { _returnData := add(_returnData, 0x04) } + return abi.decode(_returnData, (string)); + } + + function testInit() public { + bool success; + bytes memory response; + + (success, response) = address(l2Spell).delegatecall(abi.encodeCall(L2FarmProxySpell.init, ( + l2Proxy, + forwarder, + address(0xb4d), + stakingToken, + farm, + 0, + 7 days + ))); + assertFalse(success); + assertEq(_getRevertMsg(response), "L2FarmProxySpell/rewards-token-mismatch"); + + (success, response) = address(l2Spell).delegatecall(abi.encodeCall(L2FarmProxySpell.init, ( + l2Proxy, + forwarder, + address(rewardsToken), + stakingToken, + address(0xb4d), + 0, + 7 days + ))); + assertFalse(success); + assertEq(_getRevertMsg(response), "L2FarmProxySpell/farm-mismatch"); + + address badForwarder = address(new EtherForwarder(address(0xb4d))); + (success, response) = address(l2Spell).delegatecall(abi.encodeCall(L2FarmProxySpell.init, ( + l2Proxy, + badForwarder, + address(rewardsToken), + stakingToken, + farm, + 0, + 7 days + ))); + assertFalse(success); + assertEq(_getRevertMsg(response), "L2FarmProxySpell/forwarder-receiver-not-gov-relay"); + + (success, response) = address(l2Spell).delegatecall(abi.encodeCall(L2FarmProxySpell.init, ( + l2Proxy, + forwarder, + address(rewardsToken), + address(0xb4d), + farm, + 0, + 7 days + ))); + assertFalse(success); + assertEq(_getRevertMsg(response), "L2FarmProxySpell/farm-staking-token-mismatch"); + + address badFarm = address(new FarmMock(address(rewardsToken), address(rewardsToken))); + address badL2Proxy = address(new L2FarmProxy(badFarm)); + (success, response) = address(l2Spell).delegatecall(abi.encodeCall(L2FarmProxySpell.init, ( + badL2Proxy, + forwarder, + address(rewardsToken), + address(rewardsToken), + badFarm, + 0, + 7 days + ))); + assertFalse(success); + assertEq(_getRevertMsg(response), "L2FarmProxySpell/rewards-token-same-as-staking-token"); + + vm.expectEmit(true, true, true, true); + emit File("rewardThreshold", 888); + vm.expectEmit(true, true, true, true); + emit RewardsDistributionUpdated(l2Proxy); + vm.expectEmit(true, true, true, true); + emit RewardsDurationUpdated(7 days); + (success, response) = address(l2Spell).delegatecall(abi.encodeCall(L2FarmProxySpell.init, ( + l2Proxy, + forwarder, + address(rewardsToken), + stakingToken, + farm, + 888, + 7 days + ))); + assertTrue(success); + } +} diff --git a/test/mocks/DssVestMock.sol b/test/mocks/DssVestMock.sol new file mode 100644 index 0000000..affdb1c --- /dev/null +++ b/test/mocks/DssVestMock.sol @@ -0,0 +1,478 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// DssVestMock - Mock of DssVest, a token vesting contract +// +// Copyright (C) 2021 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +interface MintLike { + function mint(address, uint256) external; +} + +interface ChainlogLike { + function getAddress(bytes32) external view returns (address); +} + +interface DaiJoinLike { + function exit(address, uint256) external; +} + +interface VatLike { + function hope(address) external; + function suck(address, address, uint256) external; + function live() external view returns (uint256); +} + +interface TokenLike { + function transferFrom(address, address, uint256) external returns (bool); +} + +abstract contract DssVestMock { + // --- Data --- + mapping (address => uint256) public wards; + + struct Award { + address usr; // Vesting recipient + uint48 bgn; // Start of vesting period [timestamp] + uint48 clf; // The cliff date [timestamp] + uint48 fin; // End of vesting period [timestamp] + address mgr; // A manager address that can yank + uint8 res; // Restricted + uint128 tot; // Total reward amount + uint128 rxd; // Amount of vest claimed + } + mapping (uint256 => Award) public awards; + + uint256 public cap; // Maximum per-second issuance token rate + + uint256 public ids; // Total vestings + + uint256 internal locked; + + uint256 public constant TWENTY_YEARS = 20 * 365 days; + + // --- Events --- + event Rely(address indexed usr); + event Deny(address indexed usr); + + event File(bytes32 indexed what, uint256 data); + + event Init(uint256 indexed id, address indexed usr); + event Vest(uint256 indexed id, uint256 amt); + event Restrict(uint256 indexed id); + event Unrestrict(uint256 indexed id); + event Yank(uint256 indexed id, uint256 end); + event Move(uint256 indexed id, address indexed dst); + + // Getters to access only to the value desired + function usr(uint256 _id) external view returns (address) { + return awards[_id].usr; + } + + function bgn(uint256 _id) external view returns (uint256) { + return awards[_id].bgn; + } + + function clf(uint256 _id) external view returns (uint256) { + return awards[_id].clf; + } + + function fin(uint256 _id) external view returns (uint256) { + return awards[_id].fin; + } + + function mgr(uint256 _id) external view returns (address) { + return awards[_id].mgr; + } + + function res(uint256 _id) external view returns (uint256) { + return awards[_id].res; + } + + function tot(uint256 _id) external view returns (uint256) { + return awards[_id].tot; + } + + function rxd(uint256 _id) external view returns (uint256) { + return awards[_id].rxd; + } + + /** + @dev Base vesting logic contract constructor + */ + constructor() { + wards[msg.sender] = 1; + emit Rely(msg.sender); + } + + // --- Mutex --- + modifier lock { + require(locked == 0, "DssVest/system-locked"); + locked = 1; + _; + locked = 0; + } + + // --- Auth --- + modifier auth { + require(wards[msg.sender] == 1, "DssVest/not-authorized"); + _; + } + + function rely(address _usr) external auth { + wards[_usr] = 1; + emit Rely(_usr); + } + function deny(address _usr) external auth { + wards[_usr] = 0; + emit Deny(_usr); + } + + /** + @dev (Required) Set the per-second token issuance rate. + @param what The tag of the value to change (ex. bytes32("cap")) + @param data The value to update (ex. cap of 1000 tokens/yr == 1000*WAD/365 days) + */ + function file(bytes32 what, uint256 data) external lock auth { + if (what == "cap") cap = data; // The maximum amount of tokens that can be streamed per-second per vest + else revert("DssVest/file-unrecognized-param"); + emit File(what, data); + } + + function min(uint256 x, uint256 y) internal pure returns (uint256 z) { + z = x > y ? y : x; + } + function toUint48(uint256 x) internal pure returns (uint48 z) { + require((z = uint48(x)) == x, "DssVest/uint48-overflow"); + } + function toUint128(uint256 x) internal pure returns (uint128 z) { + require((z = uint128(x)) == x, "DssVest/uint128-overflow"); + } + + /** + @dev Govanance adds a vesting contract + @param _usr The recipient of the reward + @param _tot The total amount of the vest + @param _bgn The starting timestamp of the vest + @param _tau The duration of the vest (in seconds) + @param _eta The cliff duration in seconds (i.e. 1 years) + @param _mgr An optional manager for the contract. Can yank if vesting ends prematurely. + @return id The id of the vesting contract + */ + function create(address _usr, uint256 _tot, uint256 _bgn, uint256 _tau, uint256 _eta, address _mgr) external lock auth returns (uint256 id) { + require(_usr != address(0), "DssVest/invalid-user"); + require(_tot > 0, "DssVest/no-vest-total-amount"); + require(_bgn < block.timestamp + TWENTY_YEARS, "DssVest/bgn-too-far"); + require(_bgn > block.timestamp - TWENTY_YEARS, "DssVest/bgn-too-long-ago"); + require(_tau > 0, "DssVest/tau-zero"); + require(_tot / _tau <= cap, "DssVest/rate-too-high"); + require(_tau <= TWENTY_YEARS, "DssVest/tau-too-long"); + require(_eta <= _tau, "DssVest/eta-too-long"); + require(ids < type(uint256).max, "DssVest/ids-overflow"); + + unchecked { + id = ++ids; + } + awards[id] = Award({ + usr: _usr, + bgn: toUint48(_bgn), + clf: toUint48(_bgn + _eta), + fin: toUint48(_bgn + _tau), + tot: toUint128(_tot), + rxd: 0, + mgr: _mgr, + res: 0 + }); + emit Init(id, _usr); + } + + /** + @dev Anyone (or only owner of a vesting contract if restricted) calls this to claim all available rewards + @param _id The id of the vesting contract + */ + function vest(uint256 _id) external { + _vest(_id, type(uint256).max); + } + + /** + @dev Anyone (or only owner of a vesting contract if restricted) calls this to claim rewards + @param _id The id of the vesting contract + @param _maxAmt The maximum amount to vest + */ + function vest(uint256 _id, uint256 _maxAmt) external { + _vest(_id, _maxAmt); + } + + /** + @dev Anyone (or only owner of a vesting contract if restricted) calls this to claim rewards + @param _id The id of the vesting contract + @param _maxAmt The maximum amount to vest + */ + function _vest(uint256 _id, uint256 _maxAmt) internal lock { + Award memory _award = awards[_id]; + require(_award.usr != address(0), "DssVest/invalid-award"); + require(_award.res == 0 || _award.usr == msg.sender, "DssVest/only-user-can-claim"); + uint256 amt = unpaid(block.timestamp, _award.bgn, _award.clf, _award.fin, _award.tot, _award.rxd); + amt = min(amt, _maxAmt); + awards[_id].rxd = toUint128(_award.rxd + amt); + pay(_award.usr, amt); + emit Vest(_id, amt); + } + + /** + @dev amount of tokens accrued, not accounting for tokens paid + @param _id The id of the vesting contract + @return amt The accrued amount + */ + function accrued(uint256 _id) external view returns (uint256 amt) { + Award memory _award = awards[_id]; + require(_award.usr != address(0), "DssVest/invalid-award"); + amt = accrued(block.timestamp, _award.bgn, _award.fin, _award.tot); + } + + /** + @dev amount of tokens accrued, not accounting for tokens paid + @param _time The timestamp to perform the calculation + @param _bgn The start time of the contract + @param _fin The end time of the contract + @param _tot The total amount of the contract + @return amt The accrued amount + */ + function accrued(uint256 _time, uint48 _bgn, uint48 _fin, uint128 _tot) internal pure returns (uint256 amt) { + if (_time < _bgn) { + amt = 0; + } else if (_time >= _fin) { + amt = _tot; + } else { + amt = (_tot * (_time - _bgn)) / (_fin - _bgn); // 0 <= amt < _award.tot + } + } + + /** + @dev return the amount of vested, claimable GEM for a given ID + @param _id The id of the vesting contract + @return amt The claimable amount + */ + function unpaid(uint256 _id) external view returns (uint256 amt) { + Award memory _award = awards[_id]; + require(_award.usr != address(0), "DssVest/invalid-award"); + amt = unpaid(block.timestamp, _award.bgn, _award.clf, _award.fin, _award.tot, _award.rxd); + } + + /** + @dev amount of tokens accrued, not accounting for tokens paid + @param _time The timestamp to perform the calculation + @param _bgn The start time of the contract + @param _clf The timestamp of the cliff + @param _fin The end time of the contract + @param _tot The total amount of the contract + @param _rxd The number of gems received + @return amt The claimable amount + */ + function unpaid(uint256 _time, uint48 _bgn, uint48 _clf, uint48 _fin, uint128 _tot, uint128 _rxd) internal pure returns (uint256 amt) { + amt = _time < _clf ? 0 : accrued(_time, _bgn, _fin, _tot) - _rxd; + } + + /** + @dev Allows governance or the owner to restrict vesting to the owner only + @param _id The id of the vesting contract + */ + function restrict(uint256 _id) external lock { + address usr_ = awards[_id].usr; + require(usr_ != address(0), "DssVest/invalid-award"); + require(wards[msg.sender] == 1 || usr_ == msg.sender, "DssVest/not-authorized"); + awards[_id].res = 1; + emit Restrict(_id); + } + + /** + @dev Allows governance or the owner to enable permissionless vesting + @param _id The id of the vesting contract + */ + function unrestrict(uint256 _id) external lock { + address usr_ = awards[_id].usr; + require(usr_ != address(0), "DssVest/invalid-award"); + require(wards[msg.sender] == 1 || usr_ == msg.sender, "DssVest/not-authorized"); + awards[_id].res = 0; + emit Unrestrict(_id); + } + + /** + @dev Allows governance or the manager to remove a vesting contract immediately + @param _id The id of the vesting contract + */ + function yank(uint256 _id) external { + _yank(_id, block.timestamp); + } + + /** + @dev Allows governance or the manager to remove a vesting contract at a future time + @param _id The id of the vesting contract + @param _end A scheduled time to end the vest + */ + function yank(uint256 _id, uint256 _end) external { + _yank(_id, _end); + } + + /** + @dev Allows governance or the manager to end pre-maturely a vesting contract + @param _id The id of the vesting contract + @param _end A scheduled time to end the vest + */ + function _yank(uint256 _id, uint256 _end) internal lock { + require(wards[msg.sender] == 1 || awards[_id].mgr == msg.sender, "DssVest/not-authorized"); + Award memory _award = awards[_id]; + require(_award.usr != address(0), "DssVest/invalid-award"); + if (_end < block.timestamp) { + _end = block.timestamp; + } + if (_end < _award.fin) { + uint48 end = toUint48(_end); + awards[_id].fin = end; + if (end < _award.bgn) { + awards[_id].bgn = end; + awards[_id].clf = end; + awards[_id].tot = 0; + } else if (end < _award.clf) { + awards[_id].clf = end; + awards[_id].tot = 0; + } else { + awards[_id].tot = toUint128( + unpaid(_end, _award.bgn, _award.clf, _award.fin, _award.tot, _award.rxd) + _award.rxd + ); + } + } + + emit Yank(_id, _end); + } + + /** + @dev Allows owner to move a contract to a different address + @param _id The id of the vesting contract + @param _dst The address to send ownership of the contract to + */ + function move(uint256 _id, address _dst) external lock { + require(awards[_id].usr == msg.sender, "DssVest/only-user-can-move"); + require(_dst != address(0), "DssVest/zero-address-invalid"); + awards[_id].usr = _dst; + emit Move(_id, _dst); + } + + /** + @dev Return true if a contract is valid + @param _id The id of the vesting contract + @return isValid True for valid contract + */ + function valid(uint256 _id) external view returns (bool isValid) { + isValid = awards[_id].rxd < awards[_id].tot; + } + + /** + @dev Override this to implement payment logic. + @param _guy The payment target. + @param _amt The payment amount. [units are implementation-specific] + */ + function pay(address _guy, uint256 _amt) virtual internal; +} + +contract DssVestMintableMock is DssVestMock { + + MintLike public immutable gem; + + /** + @dev This contract must be authorized to 'mint' on the token + @param _gem The contract address of the mintable token + */ + constructor(address _gem) DssVestMock() { + require(_gem != address(0), "DssVestMintable/Invalid-token-address"); + gem = MintLike(_gem); + } + + /** + @dev Override pay to handle mint logic + @param _guy The recipient of the minted token + @param _amt The amount of token units to send to the _guy + */ + function pay(address _guy, uint256 _amt) override internal { + gem.mint(_guy, _amt); + } +} + +contract DssVestSuckableMock is DssVestMock { + + uint256 internal constant RAY = 10**27; + + ChainlogLike public immutable chainlog; + VatLike public immutable vat; + DaiJoinLike public immutable daiJoin; + + /** + @dev This contract must be authorized to 'suck' on the vat + @param _chainlog The contract address of the MCD chainlog + */ + constructor(address _chainlog) DssVestMock() { + require(_chainlog != address(0), "DssVestSuckable/Invalid-chainlog-address"); + ChainlogLike chainlog_ = chainlog = ChainlogLike(_chainlog); + VatLike vat_ = vat = VatLike(chainlog_.getAddress("MCD_VAT")); + DaiJoinLike daiJoin_ = daiJoin = DaiJoinLike(chainlog_.getAddress("MCD_JOIN_DAI")); + + vat_.hope(address(daiJoin_)); + } + + /** + @dev Override pay to handle suck logic + @param _guy The recipient of the ERC-20 Dai + @param _amt The amount of Dai to send to the _guy [WAD] + */ + function pay(address _guy, uint256 _amt) override internal { + require(vat.live() == 1, "DssVestSuckable/vat-not-live"); + vat.suck(chainlog.getAddress("MCD_VOW"), address(this), _amt * RAY); + daiJoin.exit(_guy, _amt); + } +} + +/* + Transferrable token DssVest. Can be used to enable streaming payments of + any arbitrary token from an address (i.e. CU multisig) to individual + contributors. +*/ +contract DssVestTransferrableMock is DssVestMock { + + address public immutable czar; + TokenLike public immutable gem; + + /** + @dev This contract must be approved for transfer of the gem on the czar + @param _czar The owner of the tokens to be distributed + @param _gem The token to be distributed + */ + constructor(address _czar, address _gem) DssVestMock() { + require(_czar != address(0), "DssVestTransferrable/Invalid-distributor-address"); + require(_gem != address(0), "DssVestTransferrable/Invalid-token-address"); + czar = _czar; + gem = TokenLike(_gem); + } + + /** + @dev Override pay to handle transfer logic + @param _guy The recipient of the ERC-20 Dai + @param _amt The amount of gem to send to the _guy (in native token units) + */ + function pay(address _guy, uint256 _amt) override internal { + require(gem.transferFrom(czar, _guy, _amt), "DssVestTransferrable/failed-transfer"); + } +} diff --git a/test/mocks/FarmMock.sol b/test/mocks/FarmMock.sol new file mode 100644 index 0000000..151c89e --- /dev/null +++ b/test/mocks/FarmMock.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Copyright (C) 2024 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +contract FarmMock { + address public immutable rewardsToken; + address public immutable stakingToken; + + event OwnerNominated(address newOwner); + event PauseChanged(bool isPaused); + event RewardAdded(uint256 rewards); + event RewardsDurationUpdated(uint256 newDuration); + event RewardsDistributionUpdated(address newRewardsDistribution); + event Recovered(address token, uint256 amount); + + constructor(address _rewardsToken, address _stakingToken) { + rewardsToken = _rewardsToken; + stakingToken = _stakingToken; + } + + function nominateNewOwner(address _owner) external { + emit OwnerNominated(_owner); + } + + function setPaused(bool _paused) external { + emit PauseChanged(_paused); + } + + function notifyRewardAmount(uint256 reward) external { + emit RewardAdded(reward); + } + + function recoverERC20(address tokenAddress, uint256 tokenAmount) external { + emit Recovered(tokenAddress, tokenAmount); + } + + function setRewardsDuration(uint256 _rewardsDuration) external { + emit RewardsDurationUpdated(_rewardsDuration); + } + + function setRewardsDistribution(address _rewardsDistribution) external { + emit RewardsDistributionUpdated(_rewardsDistribution); + } +} diff --git a/test/mocks/GemMock.sol b/test/mocks/GemMock.sol new file mode 100644 index 0000000..f5d2ed0 --- /dev/null +++ b/test/mocks/GemMock.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Copyright (C) 2024 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +contract GemMock { + mapping (address => uint256) public wards; + mapping (address => uint256) public balanceOf; + mapping (address => mapping (address => uint256)) public allowance; + + uint256 public totalSupply; + + constructor(uint256 initialSupply) { + wards[msg.sender] = 1; + + mint(msg.sender, initialSupply); + } + + modifier auth() { + require(wards[msg.sender] == 1, "Gem/not-authorized"); + _; + } + + function rely(address usr) external auth { wards[usr] = 1; } + function deny(address usr) external auth { wards[usr] = 0; } + + function approve(address spender, uint256 value) external returns (bool) { + allowance[msg.sender][spender] = value; + return true; + } + + function transfer(address to, uint256 value) external returns (bool) { + uint256 balance = balanceOf[msg.sender]; + require(balance >= value, "Gem/insufficient-balance"); + + unchecked { + balanceOf[msg.sender] = balance - value; + balanceOf[to] += value; + } + return true; + } + + function transferFrom(address from, address to, uint256 value) external returns (bool) { + uint256 balance = balanceOf[from]; + require(balance >= value, "Gem/insufficient-balance"); + + if (from != msg.sender) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= value, "Gem/insufficient-allowance"); + + unchecked { + allowance[from][msg.sender] = allowed - value; + } + } + } + + unchecked { + balanceOf[from] = balance - value; + balanceOf[to] += value; + } + return true; + } + + function mint(address to, uint256 value) public auth { + unchecked { + balanceOf[to] = balanceOf[to] + value; + } + totalSupply = totalSupply + value; + } + + function burn(address from, uint256 value) external { + uint256 balance = balanceOf[from]; + require(balance >= value, "Gem/insufficient-balance"); + + if (from != msg.sender) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= value, "Gem/insufficient-allowance"); + + unchecked { + allowance[from][msg.sender] = allowed - value; + } + } + } + + unchecked { + balanceOf[from] = balance - value; + totalSupply = totalSupply - value; + } + } +} diff --git a/test/mocks/InboxMock.sol b/test/mocks/InboxMock.sol new file mode 100644 index 0000000..0833e88 --- /dev/null +++ b/test/mocks/InboxMock.sol @@ -0,0 +1,14 @@ +// Copyright 2021-2022, Offchain Labs, Inc. +// For license information, see https://github.com/OffchainLabs/nitro-contracts/blob/main/LICENSE +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity ^0.8.21; + +contract InboxMock { + function calculateRetryableSubmissionFee( + uint256 dataLength, + uint256 baseFee + ) external pure returns (uint256 fee) { + fee = (1400 + 6 * dataLength) * (baseFee == 0 ? 30 gwei : baseFee); + } +} diff --git a/test/mocks/L1TokenGatewayMock.sol b/test/mocks/L1TokenGatewayMock.sol new file mode 100644 index 0000000..6181bdc --- /dev/null +++ b/test/mocks/L1TokenGatewayMock.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Copyright (C) 2024 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +interface TokenLike { + function transferFrom(address, address, uint256) external; +} + +contract L1TokenGatewayMock { + address public immutable inbox; + address public immutable escrow; + + constructor( + address _inbox, + address _escrow + ) { + inbox = _inbox; + escrow = _escrow; + } + + function outboundTransferCustomRefund( + address l1Token, + address /* refundTo */, + address /* to */, + uint256 amount, + uint256 /* maxGas */, + uint256 /* gasPriceBid */, + bytes calldata /* data */ + ) public payable returns (bytes memory res) { + TokenLike(l1Token).transferFrom(msg.sender, escrow, amount); + res = abi.encode(0); + } +}