diff --git a/protocol/mcr/dlu/eth/contracts/README.md b/protocol/mcr/dlu/eth/contracts/README.md index 97eae2c6..62e7bf9f 100644 --- a/protocol/mcr/dlu/eth/contracts/README.md +++ b/protocol/mcr/dlu/eth/contracts/README.md @@ -1,4 +1,4 @@ -# MCR - L1 contract +# L1 contracts This directory contains the implementation of the MRC settlement smart contract. To test the contract, run: @@ -8,9 +8,12 @@ forge test There is a long-running test covering over 50 epochs. It will likely take a few seconds to run. -# Implementation -## Description +## Implementation + +### Description + For a given block height, MCR selects the earliest block commitment that matches the supermajority of stake for a given epoch by: + 1. Fixing the stake parameters for the epoch; all stake changes apply to the next epoch. 2. Tracking commitments for each block height until one exceeds the supermajority of stake. @@ -20,14 +23,14 @@ For a given block height, MCR selects the earliest block commitment that matches The stake is fixed for an epoch, so only commitments for a specific block height are considered, allowing for a straightforward proof. -**Commitment**. Let $v: C \to V$ map a commitment to its validator, where $C$ represent all possible commitments and $V$ is the set of validators. Since commitments are ordered by L1 in the L1-blocks, let $C'$ be an ordered subset of $C$ with $k$ elements (i.e. up to the $k$-th commitment). +**Commitment**. Let $v: C \to V$ map a commitment to its validator, where $C$ represent all possible commitments and $V$ is the set of validators. Since commitments are ordered by L1 in the L1-blocks, let $C'$ be an ordered subset of $C$ with $k$ elements (i.e. up to the $k$-th commitment). **Stake**. Let $s: V \to \mathbb{N}$ map a validator to their stake and $S(C',i) = \sum_{j = 1}^{i} s(v(c_j))$ the cumulative stake up to the $i$-th commitment. $S$ is non-decreasing as $S(C',i) = S(C',i - 1) + s(v(c_i))$. -We require that +We require that $$ S(C',i) > \frac{2}{3} TotalStake = \frac{2}{3} \times \sum_{u \in V} s(u), $$ -If $S(C', i)$ satisfies the condition, and $S(C',i-1)$ does not, then $c_i$ is returned by MCR. Due to the non-decreasing nature of $S$ with $i$, $c_i$ is the earliest commitment that can be returned. \ No newline at end of file +If $S(C', i)$ satisfies the condition, and $S(C',i-1)$ does not, then $c_i$ is returned by MCR. Due to the non-decreasing nature of $S$ with $i$, $c_i$ is the earliest commitment that can be returned. diff --git a/protocol/pcp/dlu/eth/contracts/.gitignore b/protocol/pcp/dlu/eth/contracts/.gitignore new file mode 100644 index 00000000..1f9b97e3 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/.gitignore @@ -0,0 +1,22 @@ +# Compiler files +cache/ +out/ +broadcast/ + +lib/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ +broadcast + +# Docs +docs/ + +# Dotenv file +.env + +.vscode/ + +node_modules/ \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/README.md b/protocol/pcp/dlu/eth/contracts/README.md new file mode 100644 index 00000000..ba5afe76 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/README.md @@ -0,0 +1,20 @@ +# L1 contracts + +This directory contains the implementation of the PCP settlement smart contract. To test the contract, run: + +## Installation + +```bash +chmod +x install-deps.sh +./install-deps.sh +``` + +## Testing + +After installing the dependencies, run + +```bash +forge test +``` + +There is a long-running test covering over 50 epochs. It will likely take a few seconds to run. diff --git a/protocol/pcp/dlu/eth/contracts/foundry.toml b/protocol/pcp/dlu/eth/contracts/foundry.toml new file mode 100644 index 00000000..2bc70ba5 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/foundry.toml @@ -0,0 +1,4 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] diff --git a/protocol/pcp/dlu/eth/contracts/install-deps.sh b/protocol/pcp/dlu/eth/contracts/install-deps.sh new file mode 100755 index 00000000..84e64aaf --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/install-deps.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Remove existing libs +rm -rf lib/ + +# Install all dependencies +forge install foundry-rs/forge-std --no-commit +forge install OpenZeppelin/openzeppelin-contracts --no-commit +forge install safe-global/safe-smart-account --no-commit +forge install transmissions11/solmate --no-commit +forge install OpenZeppelin/openzeppelin-contracts-upgradeable --no-commit \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/remappings.txt b/protocol/pcp/dlu/eth/contracts/remappings.txt new file mode 100644 index 00000000..d19816eb --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/remappings.txt @@ -0,0 +1,14 @@ +@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ +@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ +@createx/=lib/createx/src/ +ds-test/=lib/openzeppelin-contracts-upgradeable/lib/forge-std/lib/ds-test/src/ +erc4626-tests/=lib/openzeppelin-contracts-upgradeable/lib/erc4626-tests/ +forge-std/=lib/forge-std/src/ +murky/=lib/murky/ +openzeppelin-contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/ +openzeppelin-contracts/=lib/openzeppelin-contracts/ +openzeppelin-foundry-upgrades/=lib/openzeppelin-foundry-upgrades/src/ +openzeppelin/=lib/createx/lib/openzeppelin-contracts/contracts/ +@safe-smart-account/=lib/safe-smart-account/ +solady/=lib/createx/lib/solady/ +solidity-stringutils/=lib/openzeppelin-foundry-upgrades/lib/solidity-stringutils/ diff --git a/protocol/pcp/dlu/eth/contracts/script/CoreDeployer.s.sol b/protocol/pcp/dlu/eth/contracts/script/CoreDeployer.s.sol new file mode 100644 index 00000000..2e6004e1 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/CoreDeployer.s.sol @@ -0,0 +1,65 @@ +pragma solidity ^0.8.13; + +import "forge-std/Script.sol"; +import {MOVEToken} from "../src/token/MOVEToken.sol"; +import { Helper } from "./helpers/Helper.sol"; +import { PCPDeployer } from "./PCPDeployer.s.sol"; +import { MovementStakingDeployer } from "./MovementStakingDeployer.s.sol"; +import { StlMoveDeployer } from "./StlMoveDeployer.s.sol"; +import { MOVETokenDeployer } from "./MOVETokenDeployer.s.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; + +contract CoreDeployer is PCPDeployer, MovementStakingDeployer, StlMoveDeployer, MOVETokenDeployer { + + function run() external override(PCPDeployer, MovementStakingDeployer, StlMoveDeployer, MOVETokenDeployer) { + + // load config and deployments data + _loadExternalData(); + + uint256 signer = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(signer); + + // Deploy CREATE3Factory, Safes and Timelock if not deployed + _deployDependencies(); + + // Deploy or upgrade contracts conditionally + deployment.moveAdmin == ZERO && deployment.move == ZERO ? + _deployMove() : deployment.moveAdmin != ZERO && deployment.move != ZERO ? + // if move is already deployed, upgrade it + _upgradeMove() : revert("MOVE: both admin and proxy should be registered"); + + // requires move to be deployed + deployment.stakingAdmin == ZERO && deployment.staking == ZERO && deployment.move != ZERO ? + _deployStaking() : deployment.stakingAdmin != ZERO && deployment.staking != ZERO ? + // if staking is already deployed, upgrade it + _upgradeStaking() : revert("STAKING: both admin and proxy should be registered"); + + // requires move to be deployed + deployment.stlMoveAdmin == ZERO && deployment.stlMove == ZERO && deployment.move != ZERO ? + _deployStlMove() : deployment.stlMoveAdmin != ZERO && deployment.stlMove != ZERO ? + // if stlMove is already deployed, upgrade it + _upgradeStlMove() : revert("STL: both admin and proxy should be registered"); + + // requires staking and move to be deployed + deployment.pcpAdmin == ZERO && deployment.pcp == ZERO && deployment.move != ZERO && deployment.staking != ZERO ? + _deployPCP() : deployment.pcpAdmin != ZERO && deployment.pcp != ZERO ? + // if pcp is already deployed, upgrade it + _upgradePCP() : revert("PCP: both admin and proxy should be registered"); + + // Only write to file if chainid is not running a foundry local chain and if broadcasting + if (block.chainid == foundryChainId) { + _allowSameContract(); + _upgradeMove(); + _upgradeStaking(); + _upgradeStlMove(); + _upgradePCP(); + } else { + if (vm.isContext(VmSafe.ForgeContext.ScriptBroadcast)) { + _writeDeployments(); + } + } + + vm.stopBroadcast(); + } +} diff --git a/protocol/pcp/dlu/eth/contracts/script/DeployMOVETokenDev.s.sol b/protocol/pcp/dlu/eth/contracts/script/DeployMOVETokenDev.s.sol new file mode 100644 index 00000000..85a2911e --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/DeployMOVETokenDev.s.sol @@ -0,0 +1,29 @@ +pragma solidity ^0.8.19; + +import "forge-std/Script.sol"; +import "../src/token/MOVETokenDev.sol"; +import {IMintableToken, MintableToken} from "../src/token/base/MintableToken.sol"; +import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Helper} from "./helpers/Helper.sol"; + +contract DeployMOVETokenDev is Helper { + address public manager = 0x5A368EDEbF574162B84f8ECFE48e9De4f520E087; + uint256 public signer = vm.envUint("TEST_1"); + function run() external { + vm.startBroadcast(signer); + + MOVETokenDev moveTokenImplementation = new MOVETokenDev(); + TransparentUpgradeableProxy moveTokenProxy = new TransparentUpgradeableProxy( + address(moveTokenImplementation), + manager, + abi.encodeWithSignature("initialize(address)", manager) + ); + + console.log("Move Token Proxy: %s", address(moveTokenProxy)); + + vm.stopBroadcast(); + } +} diff --git a/protocol/pcp/dlu/eth/contracts/script/DeployPCP.s.sol b/protocol/pcp/dlu/eth/contracts/script/DeployPCP.s.sol new file mode 100644 index 00000000..bf65d394 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/DeployPCP.s.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Script.sol"; +import "../src/settlement/PCP.sol"; +import {IMintableToken, MintableToken} from "../src/token/base/MintableToken.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract DeployPCP is Script { + function run() public { + // Load Safe addresses from deployments.json + string memory root = vm.projectRoot(); + string memory path = string.concat(root, "/script/helpers/safe-deployments.json"); + string memory json = vm.readFile(path); + + address safe = abi.decode(vm.parseJson(json, ".Safe"), (address)); + address handler = abi.decode(vm.parseJson(json, ".FallbackHandler"), (address)); + address factory = abi.decode(vm.parseJson(json, ".SafeFactory"), (address)); + + vm.startBroadcast(); + + // Deploy PCP implementation and proxy + PCP pcpImplementation = new PCP(); + + // Get MOVE token and staking addresses from deployments + string memory deploymentsPath = string.concat(root, "/script/helpers/deployments.json"); + string memory deploymentsJson = vm.readFile(deploymentsPath); + address moveToken = abi.decode(vm.parseJson(deploymentsJson, ".3151908.move"), (address)); + address staking = abi.decode(vm.parseJson(deploymentsJson, ".3151908.staking"), (address)); + + // Initialize PCP with production settings + address[] memory custodians = new address[](1); + custodians[0] = moveToken; // The MOVE token is the custodian for rewards + + bytes memory pcpData = abi.encodeCall( + PCP.initialize, + ( + IMovementStaking(staking), // _stakingContract: address of staking contract + 0, // _lastPostconfirmedSuperBlockHeight: start from genesis + 5, // _leadingSuperBlockTolerance: max blocks ahead of last confirmed + 20 seconds, // _epochDuration: how long an epoch lasts + custodians, // _custodians: array with moveToken address for rewards + 10 seconds, // _postconfirmerDuration: how long a postconfirmer serves + moveToken // _moveTokenAddress: primary custodian for rewards in staking + ) + ); + address pcpProxy = address(new ERC1967Proxy(address(pcpImplementation), pcpData)); + + // Save PCP address to deployments + console.log("PCP implementation deployed to:", address(pcpImplementation)); + console.log("PCP proxy deployed to:", pcpProxy); + + vm.stopBroadcast(); + } +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/script/DeployPCPDev.s.sol b/protocol/pcp/dlu/eth/contracts/script/DeployPCPDev.s.sol new file mode 100644 index 00000000..ffeefdae --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/DeployPCPDev.s.sol @@ -0,0 +1,160 @@ +pragma solidity ^0.8.19; + +/** + * Development deployment script for the Multi-Commit-Rollup (PCP) system. + * This deploys a test environment with short epochs and quick postconfirmer rotations. + * Includes MOVE token for staking, Movement Staking for managing attesters, and PCP for cross-chain settlement. + */ + +import "forge-std/Script.sol"; +import "../src/token/MOVEToken.sol"; +import "../src/staking/MovementStaking.sol"; +import "../src/settlement/PCP.sol"; +import {IMintableToken, MintableToken} from "../src/token/base/MintableToken.sol"; +import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract DeployPCPDev is Script { + function run() external { + vm.startBroadcast(); + + console.log("hot: msg.sender: %s", msg.sender); + + // Deploy the implementation contracts that will be delegated to + MintableToken moveTokenImplementation = new MintableToken(); + MovementStaking stakingImplementation = new MovementStaking(); + PCP pcpImplementation = new PCP(); + + // Deploy MOVE token behind proxy and initialize with name and symbol + bytes memory moveTokenData = abi.encodeCall( + MintableToken.initialize, + ( + "Move Token", // name: Token name for display + "MOVE" // symbol: Token symbol for markets/exchanges + ) + ); + address moveTokenProxy = address( + new ERC1967Proxy(address(moveTokenImplementation), moveTokenData) + ); + + // Deploy staking contract behind proxy, using MOVE token for rewards + bytes memory movementStakingData = abi.encodeCall( + MovementStaking.initialize, + IMintableToken(address(moveTokenProxy)) // moveToken: Token used for staking and rewards + ); + address movementStakingProxy = address( + new ERC1967Proxy(address(stakingImplementation), movementStakingData) + ); + + // Set up PCP with MOVE token as the only custodian for rewards + address[] memory custodians = new address[](1); + custodians[0] = address(moveTokenProxy); + + // Deploy PCP behind proxy with test configuration + bytes memory pcpData = abi.encodeCall( + PCP.initialize, + ( + IMovementStaking(address(movementStakingProxy)), // stakingContract: Contract managing attesters' stakes + 0, // lastPostconfirmedSuperBlockHeight: Start from genesis + 5, // leadingSuperBlockTolerance: Max blocks ahead of last confirmed + 10 seconds, // epochDuration: How long each epoch lasts (short for testing) + custodians, // custodians: Array of tokens used for rewards [MOVE] + 5 seconds, // postconfirmerDuration: How long postconfirmer serves + address(moveTokenProxy) // moveTokenAddress: Primary token for staking rewards + ) + ); + address pcpProxy = address(new ERC1967Proxy(address(pcpImplementation), pcpData)); + + // Set up roles and permissions + PCP pcp = PCP(pcpProxy); + pcp.grantCommitmentAdmin(msg.sender); + + // Log the deployed addresses + console.log("Move Token Proxy: %s", moveTokenProxy); + console.log("PCP Proxy: %s", pcpProxy); + console.log("PCP custodian: %s", MovementStaking(movementStakingProxy).epochDurationByDomain(pcpProxy)); + + // Log initial state + console.log("\n=== Initial Setup ==="); + console.log("Deployer address: %s", msg.sender); + console.log("Move Token Proxy: %s", moveTokenProxy); + console.log("PCP Proxy: %s", pcpProxy); + console.log("Staking Proxy: %s", movementStakingProxy); + + // Set up initial token distribution and permissions + MintableToken moveToken = MintableToken(moveTokenProxy); + + // Log roles before minting + console.log("\n=== Roles ==="); + console.log("Deployer has minter role: %s", moveToken.hasMinterRole(msg.sender)); + console.log("Staking has minter role: %s", moveToken.hasMinterRole(address(movementStakingProxy))); + + // Log balances before minting + console.log("\n=== Balances Before Mint ==="); + // log if the deployer has enough Eth to pay for the deployment + uint256 ethbalance = address(msg.sender).balance; + console.log("Deployer has balance (ETH): %s", ethbalance); + console.log("Deployer balance (MOVE): %s", moveToken.balanceOf(msg.sender)); + console.log("Staking balance (MOVE): %s", moveToken.balanceOf(address(movementStakingProxy))); + + // Mint and grant roles + moveToken.mint(msg.sender, 100000 ether); // Mint initial tokens to deployer + moveToken.grantMinterRole(msg.sender); // Allow deployer to mint + moveToken.grantMinterRole(address(movementStakingProxy)); // Allow staking to mint rewards + + // Log final state + console.log("\n=== Final State ==="); + console.log("Deployer balance after mint (MOVE): %s", moveToken.balanceOf(msg.sender)); + console.log("Deployer balance after mint (ETH): %s", address(msg.sender).balance); + console.log("Deployer has spend (ETH): %s", ethbalance - address(msg.sender).balance); + console.log("Deployer has minter role: %s", moveToken.hasMinterRole(msg.sender)); + console.log("Staking has minter role: %s", moveToken.hasMinterRole(address(movementStakingProxy))); + + // Verify deployment + console.log("\n=== Verifying Deployment ==="); + + // Verify PCP configuration + console.log("PCP Configuration:"); + MovementStaking staking = MovementStaking(movementStakingProxy); + uint256 epochDuration = staking.getEpochDuration(pcpProxy); + uint256 postconfirmerDuration = pcp.getPostconfirmerDuration(); + console.log("- Epoch Duration: %s seconds", epochDuration); + console.log("- Postconfirmer Duration: %s seconds", postconfirmerDuration); + require(epochDuration == 10, "Incorrect epoch duration"); + require(postconfirmerDuration == 5, "Incorrect postconfirmer duration"); + + // Verify Staking configuration + console.log("\nStaking Configuration:"); + uint256 stakingEpochDuration = MovementStaking(movementStakingProxy).epochDurationByDomain(pcpProxy); + console.log("- Epoch Duration for PCP domain: %s seconds", stakingEpochDuration); + require(stakingEpochDuration == 10, "Incorrect staking epoch duration"); + + // Some simple sanity checks + console.log("\ngetAcceptingEpoch(pcpProxy): %s", staking.getAcceptingEpoch(pcpProxy)); + console.log("\ngetLastPostconfirmedSuperBlockHeight(): %s", pcp.getLastPostconfirmedSuperBlockHeight()); + console.log("\nList of active attesters:"); + address[] memory stakedAttesters = staking.getStakedAttestersForAcceptingEpoch(pcpProxy); + if (stakedAttesters.length > 0) { + for (uint256 i = 0; i < stakedAttesters.length; i++) { + console.log("- Attester %s: %s", i, stakedAttesters[i]); + } + } else { + console.log("No attesters staked"); + } + console.log("\nPostconfirmer: %s", pcp.getPostconfirmer()); + + // Verify token setup + console.log("\nToken Configuration:"); + uint256 deployerBalance = moveToken.balanceOf(msg.sender); + console.log("- Deployer Balance: %s", deployerBalance); + require(deployerBalance == 100000 ether, "Incorrect deployer balance"); + require(moveToken.hasMinterRole(msg.sender), "Deployer missing minter role"); + require(moveToken.hasMinterRole(address(movementStakingProxy)), "Staking missing minter role"); + + console.log("\nDeployment verified successfully!"); + + vm.stopBroadcast(); + } +} diff --git a/protocol/pcp/dlu/eth/contracts/script/MOVETokenDeployer.s.sol b/protocol/pcp/dlu/eth/contracts/script/MOVETokenDeployer.s.sol new file mode 100644 index 00000000..14cd23e9 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/MOVETokenDeployer.s.sol @@ -0,0 +1,95 @@ +pragma solidity ^0.8.13; + +import "forge-std/Script.sol"; +import {MOVEToken} from "../src/token/MOVEToken.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import { Helper, ProxyAdmin } from "./helpers/Helper.sol"; +import {ICREATE3Factory} from "./helpers/Create3/ICREATE3Factory.sol"; + +// Script intended to be used for deploying the MOVE token from an EOA +// Utilizies existing safes and sets them as proposers and executors. +// The MOVEToken contract takes in the Movement Foundation address and sets it as its own admin for future upgrades. +// The whole supply is minted to the Movement Foundation Safe. +// The script also verifies that the token has the correct balances, decimals and permissions. +contract MOVETokenDeployer is Helper { + // COMMANDS + // mainnet + // forge script MOVETokenDeployer --fork-url https://eth.llamarpc.com --verify --etherscan-api-key ETHERSCAN_API_KEY + // testnet + // forge script MOVETokenDeployer --fork-url https://eth-sepolia.api.onfinality.io/public + // Safes should be already deployed + bytes32 public salt = 0x0; + bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00; + + function run() external virtual { + + // load config and deployments data + _loadExternalData(); + + uint256 signer = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(signer); + + // Deploy CREATE3Factory, Safes and Timelock if not deployed + _deployDependencies(); + + deployment.moveAdmin == ZERO && deployment.move == ZERO ? + _deployMove() : deployment.moveAdmin != ZERO && deployment.move != ZERO ? + // if move is already deployed, upgrade it + _upgradeMove() : revert("MOVE: both admin and proxy should be registered"); + + require(MOVEToken(deployment.move).balanceOf(address(deployment.movementAnchorage)) == 999999998000000000, "Movement Anchorage Safe balance is wrong"); + require(MOVEToken(deployment.move).decimals() == 8, "Decimals are expected to be 8"); + require(MOVEToken(deployment.move).totalSupply() == 1000000000000000000,"Total supply is wrong"); + require(MOVEToken(deployment.move).hasRole(DEFAULT_ADMIN_ROLE, address(deployment.movementFoundationSafe)),"Movement Foundation expected to have token admin role"); + require(!MOVEToken(deployment.move).hasRole(DEFAULT_ADMIN_ROLE, address(deployment.movementLabsSafe)),"Movement Labs not expected to have token admin role"); + require(!MOVEToken(deployment.move).hasRole(DEFAULT_ADMIN_ROLE, address(timelock)),"Timelock not expected to have token admin role"); + vm.stopBroadcast(); + + if (vm.isContext(VmSafe.ForgeContext.ScriptBroadcast)) { + _writeDeployments(); + } + } + + // •☽────✧˖°˖DANGER ZONE˖°˖✧────☾• +// Modifications to the following functions have to be throughly tested + + function _deployMove() internal { + console.log("MOVE: deploying"); + MOVEToken moveImplementation = new MOVEToken(); + // genetares bytecode for CREATE3 deployment + bytes memory bytecode = abi.encodePacked( + type(TransparentUpgradeableProxy).creationCode, + abi.encode(address(moveImplementation), address(timelock), abi.encodeWithSignature(moveSignature, deployment.movementFoundationSafe, deployment.movementAnchorage)) + ); + vm.recordLogs(); + // deploys the MOVE token proxy using CREATE3 + moveProxy = TransparentUpgradeableProxy(payable(ICREATE3Factory(create3).deploy(salt, bytecode))); + console.log("MOVEToken deployment records:"); + console.log("proxy", address(moveProxy)); + deployment.move = address(moveProxy); + deployment.moveAdmin = _storeAdminDeployment(); + } + + function _upgradeMove() internal { + console.log("MOVE: upgrading"); + MOVEToken newMoveImplementation = new MOVEToken(); + _checkBytecodeDifference(address(newMoveImplementation), deployment.move); + // Prepare the data for the upgrade + bytes memory data = abi.encodeWithSignature( + "schedule(address,uint256,bytes,bytes32,bytes32,uint256)", + address(deployment.moveAdmin), + 0, + abi.encodeWithSignature( + "upgradeAndCall(address,address,bytes)", + address(deployment.move), + address(newMoveImplementation), + "" + ), + bytes32(0), + bytes32(0), + config.minDelay + ); + + _proposeUpgrade(data, "movetoken.json"); + } +} diff --git a/protocol/pcp/dlu/eth/contracts/script/MovementStakingDeployer.s.sol b/protocol/pcp/dlu/eth/contracts/script/MovementStakingDeployer.s.sol new file mode 100644 index 00000000..b487be23 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/MovementStakingDeployer.s.sol @@ -0,0 +1,76 @@ +pragma solidity ^0.8.13; + +import "forge-std/Script.sol"; +import {MovementStaking} from "../src/staking/MovementStaking.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; +import { Helper } from "./helpers/Helper.sol"; + +contract MovementStakingDeployer is Helper { + + function run() external virtual { + + // load config and deployments data + _loadExternalData(); + + uint256 signer = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(signer); + + // Deploy CREATE3Factory, Safes and Timelock if not deployed + _deployDependencies(); + + deployment.stakingAdmin == ZERO && deployment.staking == ZERO && deployment.move != ZERO ? + _deployStaking() : deployment.stakingAdmin != ZERO && deployment.staking != ZERO ? + _upgradeStaking() : revert("STAKING: both admin and proxy should be registered"); + + vm.stopBroadcast(); + + // Only write to file if chainid is not running a foundry local chain + if (vm.isContext(VmSafe.ForgeContext.ScriptBroadcast)) { + _writeDeployments(); + } + } + + // •☽────✧˖°˖DANGER ZONE˖°˖✧────☾• +// Modifications to the following functions have to be throughly tested + + function _deployStaking() internal { + console.log("STAKING: deploying"); + MovementStaking stakingImplementation = new MovementStaking(); + vm.recordLogs(); + stakingProxy = new TransparentUpgradeableProxy( + address(stakingImplementation), + address(timelock), + abi.encodeWithSignature(stakingSignature, address(moveProxy)) + ); + console.log("STAKING deployment records:"); + console.log("proxy", address(stakingProxy)); + deployment.staking = address(stakingProxy); + deployment.stakingAdmin = _storeAdminDeployment(); + } + + function _upgradeStaking() internal { + console.log("STAKING: upgrading"); + MovementStaking newStakingImplementation = new MovementStaking(); + _checkBytecodeDifference(address(newStakingImplementation), deployment.staking); + // Prepare the data for the upgrade + bytes memory data = abi.encodeWithSignature( + "schedule(address,uint256,bytes,bytes32,bytes32,uint256)", + address(deployment.stakingAdmin), + 0, + abi.encodeWithSignature( + "upgradeAndCall(address,address,bytes)", + address(stakingProxy), + address(newStakingImplementation), + "" + ), + bytes32(0), + bytes32(0), + config.minDelay + ); + + _proposeUpgrade(data, "staking.json"); +} + + +} diff --git a/protocol/pcp/dlu/eth/contracts/script/MultisigMOVETokenDeployer.s.sol b/protocol/pcp/dlu/eth/contracts/script/MultisigMOVETokenDeployer.s.sol new file mode 100644 index 00000000..2261e22f --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/MultisigMOVETokenDeployer.s.sol @@ -0,0 +1,158 @@ +pragma solidity ^0.8.13; + +import "forge-std/Script.sol"; +import {MOVEToken} from "../src/token/MOVEToken.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {Helper, Safe} from "./helpers/Helper.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {ICREATE3Factory} from "./helpers/Create3/ICREATE3Factory.sol"; +import {Enum} from "@safe-smart-account/contracts/common/Enum.sol"; +import {stdJson} from "forge-std/StdJson.sol"; + +// Script intended to be used for deploying the MOVE token from an EOA +// Utilizies existing safes and sets them as proposers and executors. +// The MOVEToken contract takes in the Movement Foundation address and sets it as its own admin for future upgrades. +// The whole supply is minted to the Movement Foundation Safe. +// The script also verifies that the token has the correct balances, decimals and permissions. +contract MultisigMOVETokenDeployer is Helper { + using stdJson for string; + // COMMANDS + // mainnet + // forge script MultisigMOVETokenDeployer --fork-url https://eth.llamarpc.com --verify --etherscan-api-key ETHERSCAN_API_KEY + // testnet + // forge script MultisigMOVETokenDeployer --fork-url https://eth-sepolia.api.onfinality.io/public + // Safes should be already deployed + + bytes32 public salt = 0x6c0000000000000000000000018eddf77afc0a5c6d05a564a44fe37b068922c3; + bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00; + + function run() external virtual { + // load config and deployments data + _loadExternalData(); + + uint256 signer = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(signer); + + // Deploy CREATE3Factory, Safes and Timelock if not deployed + _deployDependencies(); + + // This deployer solely deploys a timelock and an implementation, it leaves to multisig to execute the deployment + // of the actual token. + _proposeMultisigMove(); + + vm.stopBroadcast(); + + if (vm.isContext(VmSafe.ForgeContext.ScriptBroadcast)) { + _writeDeployments(); + } + } + + // •☽────✧˖°˖DANGER ZONE˖°˖✧────☾• + // Modifications to the following functions have to be throughly tested + + function _proposeMultisigMove() internal { + console.log("MOVE: deploying"); + MOVEToken moveImplementation = new MOVEToken(); + // genetares bytecode for CREATE3 deployment + bytes memory create3Bytecode = abi.encodePacked( + type(TransparentUpgradeableProxy).creationCode, + abi.encode( + address(moveImplementation), + address(timelock), + abi.encodeWithSignature(moveSignature, deployment.movementFoundationSafe, deployment.movementAnchorage) + ) + ); + + deployment.move = create3.getDeployed(deployment.movementDeployerSafe, salt); + console.log("MOVE: deployment address", deployment.move); + + // check if the deployment address starts with 0x3073 so we can be sure CREATE3 deployed successfully + // this is a safety check to prevent deploying to an incorrect address + // starting and ending with 3073 is a deterministic address that can be reproduced on other networks and brands the token address + // users have an extra layer of security by easily identifying the address + require(_startsWith3073(deployment.move), "MOVE: deployment address does not start with 0x3073"); + + // create bytecode the MOVE token proxy using CREATE3 + bytes memory bytecode = abi.encodeWithSignature("deploy(bytes32,bytes)", salt, create3Bytecode); + + // NOTE: digest can be used if immediately signing and executing the transaction + // bytes32 digest = Safe(payable(deployment.movementFoundationSafe)).getTransactionHash( + // address(create3), 0, bytecode, Enum.Operation.Call, 0, 0, 0, ZERO, payable(ZERO), 0 + // ); + + string memory json = "safeCall"; + // Serialize the relevant fields into JSON format + json.serialize("to", address(create3)); + string memory zero = "0"; + json.serialize("value", zero); + json.serialize("data", bytecode); + string memory operation = "OperationType.Call"; + json.serialize("chainId", chainId); + json.serialize("safeAddress", deployment.movementDeployerSafe); + string memory serializedData = json.serialize("operation", operation); + // Log the serialized JSON for debugging + console.log("json |start|", serializedData, "|end|"); + // Write the serialized data to a file + if (vm.isContext(VmSafe.ForgeContext.ScriptBroadcast)) { + vm.writeFile(string.concat(root, upgradePath, "deploymove.json"), serializedData); + } + } + + function _deployMultisigMove() internal { + console.log("MOVE: deploying"); + MOVEToken moveImplementation = new MOVEToken(); + // genetares bytecode for CREATE3 deployment + bytes memory create3Bytecode = abi.encodePacked( + type(TransparentUpgradeableProxy).creationCode, + abi.encode( + address(moveImplementation), + address(timelock), + abi.encodeWithSignature(moveSignature, deployment.movementFoundationSafe, deployment.movementAnchorage) + ) + ); + vm.recordLogs(); + // craete bytecode the MOVE token proxy using CREATE3 + bytes memory bytecode = abi.encodeWithSignature("deploy(bytes32,bytes)", salt, create3Bytecode); + bytes32 digest = Safe(payable(deployment.movementDeployerSafe)).getTransactionHash( + address(create3), 0, bytecode, Enum.Operation.Call, 0, 0, 0, ZERO, payable(ZERO), 0 + ); + + // three signers for the deployment (this is mocked and only works in foundry chain) + uint256[] memory signers = new uint256[](3); + signers[0] = vm.envUint("PRIVATE_KEY"); + signers[1] = 1; + signers[2] = 2; + + bytes memory signatures = _generateSignatures(signers, digest); + + Safe(payable(deployment.movementFoundationSafe)).execTransaction( + address(create3), 0, bytecode, Enum.Operation.Call, 0, 0, 0, ZERO, payable(ZERO), signatures + ); + // moveProxy = + console.log("MOVEToken deployment records:"); + Vm.Log[] memory logs = vm.getRecordedLogs(); + deployment.move = logs[0].emitter; + deployment.moveAdmin = logs[logs.length - 3].emitter; + console.log("proxy", deployment.move); + console.log("admin", deployment.moveAdmin); + } + + // MULTISIG WILL NEVER BE USED WITHIN THE CONTRACT PIPELINE + function _upgradeMultisigMove() internal { + console.log("MOVE: upgrading"); + MOVEToken newMoveImplementation = new MOVEToken(); + timelock.schedule( + deployment.moveAdmin, + 0, + abi.encodeWithSignature( + "upgradeAndCall(address,address,bytes)", + deployment.move, + address(newMoveImplementation), + abi.encodeWithSignature("initialize(address)", deployment.movementFoundationSafe) + ), + bytes32(0), + bytes32(0), + config.minDelay + ); + } +} diff --git a/protocol/pcp/dlu/eth/contracts/script/PCPDeployer.s.sol b/protocol/pcp/dlu/eth/contracts/script/PCPDeployer.s.sol new file mode 100644 index 00000000..7d037e16 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/PCPDeployer.s.sol @@ -0,0 +1,80 @@ +pragma solidity ^0.8.13; + +import "forge-std/Script.sol"; +import {PCP} from "../src/settlement/PCP.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; +import { Helper } from "./helpers/Helper.sol"; + +contract PCPDeployer is Helper { + + function run() external virtual { + + // load config and deployments data + _loadExternalData(); + + uint256 signer = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(signer); + + // Deploy CREATE3Factory, Safes and Timelock if not deployed + _deployDependencies(); + + deployment.pcpAdmin == ZERO && deployment.pcp == ZERO && deployment.move != ZERO && deployment.staking != ZERO ? + _deployPCP() : deployment.pcpAdmin != ZERO && deployment.pcp != ZERO ? + _upgradePCP() : revert("PCP: both admin and proxy should be registered"); + + vm.stopBroadcast(); + + // Only write to file if chainid is not running a foundry local chain + if (vm.isContext(VmSafe.ForgeContext.ScriptBroadcast)) { + _writeDeployments(); + } + } + + // •☽────✧˖°˖DANGER ZONE˖°˖✧────☾• +// Modifications to the following functions have to be throughly tested + + function _deployPCP() internal { + console.log("PCP: deploying"); + PCP pcpImplementation = new PCP(); + vm.recordLogs(); + pcpProxy = new TransparentUpgradeableProxy( + address(pcpImplementation), + address(timelock), + abi.encodeWithSignature( + pcpSignature, + address(stakingProxy), + 128, + 100 ether, + 100 ether, + config.signersLabs + ) + ); + console.log("PCP deployment records:"); + console.log("proxy", address(pcpProxy)); + deployment.pcp = address(pcpProxy); + deployment.pcpAdmin = _storeAdminDeployment(); + } + + function _upgradePCP() internal { + console.log("PCP: upgrading"); + PCP newPCPImplementation = new PCP(); + _checkBytecodeDifference(address(newPCPImplementation), deployment.pcp); + bytes memory data = abi.encodeWithSignature( + "schedule(address,uint256,bytes,bytes32,bytes32,uint256)", + address(deployment.pcpAdmin), + 0, + abi.encodeWithSignature( + "upgradeAndCall(address,address,bytes)", + address(pcpProxy), + address(newPCPImplementation), + "" + ), + bytes32(0), + bytes32(0), + config.minDelay + ); + _proposeUpgrade(data, "pcp.json"); + } + +} diff --git a/protocol/pcp/dlu/eth/contracts/script/StlMoveDeployer.s.sol b/protocol/pcp/dlu/eth/contracts/script/StlMoveDeployer.s.sol new file mode 100644 index 00000000..2c037951 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/StlMoveDeployer.s.sol @@ -0,0 +1,74 @@ +pragma solidity ^0.8.13; + +import "forge-std/Script.sol"; +import {stlMoveToken} from "../src/token/stlMoveToken.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; +import { Helper } from "./helpers/Helper.sol"; + +contract StlMoveDeployer is Helper { + + function run() external virtual { + + // load config and deployments data + _loadExternalData(); + + uint256 signer = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(signer); + + // Deploy CREATE3Factory, Safes and Timelock if not deployed + _deployDependencies(); + + deployment.stlMoveAdmin == ZERO && deployment.stlMove == ZERO && deployment.move != ZERO ? + _deployStlMove() : deployment.stlMoveAdmin != ZERO && deployment.stlMove != ZERO ? + _upgradeStlMove() : revert("STL: both admin and proxy should be registered"); + + vm.stopBroadcast(); + + // Only write to file if chainid is not running a foundry local chain + if (vm.isContext(VmSafe.ForgeContext.ScriptBroadcast)) { + _writeDeployments(); + } + } + + // •☽────✧˖°˖DANGER ZONE˖°˖✧────☾• +// Modifications to the following functions have to be throughly tested + + function _deployStlMove() internal { + console.log("STL: deploying"); + stlMoveToken stlMoveImplementation = new stlMoveToken(); + vm.recordLogs(); + stlMoveProxy = new TransparentUpgradeableProxy( + address(stlMoveImplementation), + address(timelock), + abi.encodeWithSignature(stlMoveSignature, "STL Move Token", "STL", address(moveProxy)) + ); + console.log("STL deployment records:"); + console.log("proxy", address(stlMoveProxy)); + deployment.stlMove = address(stlMoveProxy); + deployment.stlMoveAdmin = _storeAdminDeployment(); + } + + function _upgradeStlMove() internal { + console.log("STL: upgrading"); + stlMoveToken newStlMoveImplementation = new stlMoveToken(); + _checkBytecodeDifference(address(newStlMoveImplementation), deployment.stlMove); + // Prepare the data for the upgrade + bytes memory data = abi.encodeWithSignature( + "schedule(address,uint256,bytes,bytes32,bytes32,uint256)", + address(deployment.stlMoveAdmin), + 0, + abi.encodeWithSignature( + "upgradeAndCall(address,address,bytes)", + address(stlMoveProxy), + address(newStlMoveImplementation), + "" + ), + bytes32(0), + bytes32(0), + config.minDelay + ); + + _proposeUpgrade(data, "stlmove.json"); + } +} diff --git a/protocol/pcp/dlu/eth/contracts/script/VerifyPCPDev.s.sol b/protocol/pcp/dlu/eth/contracts/script/VerifyPCPDev.s.sol new file mode 100644 index 00000000..befa26fc --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/VerifyPCPDev.s.sol @@ -0,0 +1,49 @@ +pragma solidity ^0.8.19; + +/** + * Verification script for the PCP deployment. + * Checks token balances, permissions, staking functionality, and basic PCP operations. + * Run this after DeployPCPDev.s.sol to verify the system is working correctly. + */ + +import "forge-std/Script.sol"; +import "../src/token/MOVEToken.sol"; +import "../src/staking/MovementStaking.sol"; +import "../src/settlement/PCP.sol"; +import {IMintableToken, MintableToken} from "../src/token/base/MintableToken.sol"; +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; + +contract VerifyPCPDev is Script { + function run() external { + // Read deployment addresses + string memory json = vm.readFile("deployment.json"); + address moveTokenProxy = vm.parseJsonAddress(json, ".moveToken"); + address pcpProxy = vm.parseJsonAddress(json, ".pcp"); + address stakingProxy = vm.parseJsonAddress(json, ".staking"); + address deployer = vm.parseJsonAddress(json, ".deployer"); + + // Contract instances + MintableToken moveToken = MintableToken(moveTokenProxy); + PCP pcp = PCP(pcpProxy); + MovementStaking staking = MovementStaking(stakingProxy); + + console.log("\n=== Verifying PCP Configuration ==="); + console.log("Epoch duration: %s seconds", pcp.getEpochDuration()); + console.log("Postconfirmer duration: %s seconds", pcp.getPostconfirmerDuration()); + + console.log("\n=== Verifying Staking Setup ==="); + console.log("PCP epoch duration in staking: %s", staking.epochDurationByDomain(pcpProxy)); + // console.log("MOVE token in staking: %s", address(staking.moveToken())); + + console.log("\n=== Verifying Token Setup ==="); + console.log("Deployer balance: %s", moveToken.balanceOf(deployer)); + console.log("Deployer has minter role: %s", moveToken.hasMinterRole(deployer)); + console.log("Staking has minter role: %s", moveToken.hasMinterRole(stakingProxy)); + + vm.startBroadcast(); + + console.log("\n=== Verification Complete ==="); + + vm.stopBroadcast(); + } +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/script/deploy-safe.sh b/protocol/pcp/dlu/eth/contracts/script/deploy-safe.sh new file mode 100755 index 00000000..b2bd954b --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/deploy-safe.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +# Require RPC URL and private key as parameters +if [ -z "$1" ] || [ -z "$2" ]; then + echo "Error: Both RPC URL and private key are required" + echo "Usage: $0 " + echo "Example: $0 http://127.0.0.1:60743 0123456789abcdef... (private key without 0x prefix)" + exit 1 +fi + +RPC_URL=$1 +PRIVATE_KEY=$2 +echo "Using RPC URL: $RPC_URL" + +# Get the address from the private key +ADDRESS=$(cast wallet address --private-key $PRIVATE_KEY) +echo "Using address: $ADDRESS" + +# Check balance +BALANCE=$(cast balance $ADDRESS --rpc-url $RPC_URL) +echo "Current balance: $BALANCE ETH" + +if [ "$BALANCE" = "0" ]; then + echo "Error: Account has no funds. Please fund it first using:" + echo "cast send --private-key $ADDRESS --value 1ether --rpc-url $RPC_URL" + exit 1 +fi + +# Create a file to store the addresses +DEPLOYMENT_FILE="script/helpers/safe-deployments.json" +echo "{}" > $DEPLOYMENT_FILE + +# Deploy Safe singleton +echo "Deploying Safe singleton..." +SAFE_OUTPUT=$(forge create lib/safe-contracts/contracts/Safe.sol:Safe \ + --rpc-url $RPC_URL \ + --private-key $PRIVATE_KEY \ + --gas-price 100000000000 \ + --legacy) +SAFE_ADDRESS=$(echo "$SAFE_OUTPUT" | grep "Deployed to:" | awk '{print $3}') +echo "Safe deployed to: $SAFE_ADDRESS" + +# Deploy Fallback Handler +echo "Deploying Fallback Handler..." +HANDLER_OUTPUT=$(forge create lib/safe-contracts/contracts/handler/CompatibilityFallbackHandler.sol:CompatibilityFallbackHandler \ + --rpc-url $RPC_URL \ + --private-key $PRIVATE_KEY \ + --gas-price 100000000000 \ + --legacy) +HANDLER_ADDRESS=$(echo "$HANDLER_OUTPUT" | grep "Deployed to:" | awk '{print $3}') +echo "Fallback Handler deployed to: $HANDLER_ADDRESS" + +# Deploy Safe Factory +echo "Deploying Safe Factory..." +FACTORY_OUTPUT=$(forge create lib/safe-contracts/contracts/proxies/SafeProxyFactory.sol:SafeProxyFactory \ + --rpc-url $RPC_URL \ + --private-key $PRIVATE_KEY \ + --gas-price 100000000000 \ + --legacy) +FACTORY_ADDRESS=$(echo "$FACTORY_OUTPUT" | grep "Deployed to:" | awk '{print $3}') +echo "Safe Factory deployed to: $FACTORY_ADDRESS" + +# Save addresses to JSON file +cat > $DEPLOYMENT_FILE << EOF +{ + "Safe": "$SAFE_ADDRESS", + "FallbackHandler": "$HANDLER_ADDRESS", + "SafeFactory": "$FACTORY_ADDRESS" +} +EOF + +echo "Deployment addresses saved to $DEPLOYMENT_FILE" \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/script/helpers/Create3/CREATE3Factory.sol b/protocol/pcp/dlu/eth/contracts/script/helpers/Create3/CREATE3Factory.sol new file mode 100644 index 00000000..9b501508 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/helpers/Create3/CREATE3Factory.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.13; + +import {CREATE3} from "solmate/utils/CREATE3.sol"; + +import {ICREATE3Factory} from "./ICREATE3Factory.sol"; + +/// @title Factory for deploying contracts to deterministic addresses via CREATE3 +/// @author zefram.eth +/// @notice Enables deploying contracts using CREATE3. Each deployer (msg.sender) has +/// its own namespace for deployed addresses. +contract CREATE3Factory is ICREATE3Factory { + /// @inheritdoc ICREATE3Factory + function deploy(bytes32 salt, bytes memory creationCode) + external + payable + override + returns (address deployed) + { + // hash salt with the deployer address to give each deployer its own namespace + salt = keccak256(abi.encodePacked(msg.sender, salt)); + return CREATE3.deploy(salt, creationCode, msg.value); + } + + /// @inheritdoc ICREATE3Factory + function getDeployed(address deployer, bytes32 salt) + external + view + override + returns (address deployed) + { + // https://github.com/ethereum/EIPs/pull/3171 + // hash salt with the deployer address to give each deployer its own namespace + salt = keccak256(abi.encodePacked(deployer, salt)); + return CREATE3.getDeployed(salt); + } +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/script/helpers/Create3/ICREATE3Factory.sol b/protocol/pcp/dlu/eth/contracts/script/helpers/Create3/ICREATE3Factory.sol new file mode 100644 index 00000000..a1b0063f --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/helpers/Create3/ICREATE3Factory.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity >=0.6.0; + +/// @title Factory for deploying contracts to deterministic addresses via CREATE3 +/// @author zefram.eth +/// @notice Enables deploying contracts using CREATE3. Each deployer (msg.sender) has +/// its own namespace for deployed addresses. +interface ICREATE3Factory { + /// @notice Deploys a contract using CREATE3 + /// @dev The provided salt is hashed together with msg.sender to generate the final salt + /// @param salt The deployer-specific salt for determining the deployed contract's address + /// @param creationCode The creation code of the contract to deploy + /// @return deployed The address of the deployed contract + function deploy(bytes32 salt, bytes memory creationCode) + external + payable + returns (address deployed); + + /// @notice Predicts the address of a deployed contract + /// @dev The provided salt is hashed together with the deployer address to generate the final salt + /// @param deployer The deployer account that will call deploy() + /// @param salt The deployer-specific salt for determining the deployed contract's address + /// @return deployed The address of the contract that will be deployed + function getDeployed(address deployer, bytes32 salt) + external + view + returns (address deployed); +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/script/helpers/Helper.sol b/protocol/pcp/dlu/eth/contracts/script/helpers/Helper.sol new file mode 100644 index 00000000..86469fc9 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/helpers/Helper.sol @@ -0,0 +1,382 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Script.sol"; +import {stdJson} from "forge-std/StdJson.sol"; +import { + TransparentUpgradeableProxy, + ERC1967Utils +} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; +import {SafeProxyFactory} from "@safe-smart-account/contracts/proxies/SafeProxyFactory.sol"; +import {CompatibilityFallbackHandler} from "@safe-smart-account/contracts/handler/CompatibilityFallbackHandler.sol"; +import {SafeProxy} from "@safe-smart-account/contracts/proxies/SafeProxy.sol"; +import {Safe} from "@safe-smart-account/contracts/Safe.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {CREATE3Factory} from "./Create3/CREATE3Factory.sol"; + +contract Helper is Script { + using stdJson for string; + + TransparentUpgradeableProxy public moveProxy; + TransparentUpgradeableProxy public stlMoveProxy; + TransparentUpgradeableProxy public stakingProxy; + TransparentUpgradeableProxy public pcpProxy; + TimelockController public timelock; + // CREATE3 exists across all major chains, we only enforce it on the same address if not deployed yet + CREATE3Factory public create3 = CREATE3Factory(0x2Dfcc7415D89af828cbef005F0d072D8b3F23183); + string public pcpSignature = "initialize(address,uint256,uint256,uint256,address[])"; + string public stakingSignature = "initialize(address)"; + string public stlMoveSignature = "initialize(string,string,address)"; + string public moveSignature = "initialize(address,address)"; + string public safeSetupSignature = "setup(address[],uint256,address,bytes,address,address,uint256,address)"; + string public root = vm.projectRoot(); + string public deploymentsPath = "/script/helpers/deployments.json"; + string public upgradePath = "/script/helpers/upgrade/"; + string public configPath = "/script/helpers/config.json"; + address public ZERO = 0x0000000000000000000000000000000000000000; + string public chainId = _uint2str(block.chainid); + uint256 public foundryChainId = 31337; + string public storageJson; + bool public allowsSameContract; + + ConfigData public config; + + struct ConfigData { + uint256 minDelay; + address[] signersDeployer; + address[] signersFoundation; + address[] signersLabs; + uint256 thresholdDeployer; + uint256 thresholdFoundation; + uint256 thresholdLabs; + } + + Deployment public deployment; + + struct Deployment { + address pcp; + address pcpAdmin; + address move; + address moveAdmin; + address movementAnchorage; + address movementDeployerSafe; + address movementFoundationSafe; + address movementLabsSafe; + address staking; + address stakingAdmin; + address stlMove; + address stlMoveAdmin; + address timelock; + } + + function _loadConfig() internal { + string memory path = string.concat(root, configPath); + string memory json = vm.readFile(path); + bytes memory rawConfigData = json.parseRaw(string(abi.encodePacked("."))); + config = abi.decode(rawConfigData, (ConfigData)); + + if (config.signersLabs[0] == ZERO) { + config.signersLabs[0] = vm.addr(vm.envUint("PRIVATE_KEY")); + // populate multisigs with signers + for (uint256 i = 1; i < config.signersLabs.length; i++) { + if (config.signersLabs[i] == ZERO) { + config.signersLabs[i] = vm.addr(i); + } + } + } + if (config.signersFoundation[0] == ZERO) { + config.signersFoundation[0] = vm.addr(vm.envUint("PRIVATE_KEY")); + // populate multisigs with signers + for (uint256 i = 1; i < config.signersFoundation.length; i++) { + if (config.signersFoundation[i] == ZERO) { + config.signersFoundation[i] = vm.addr(i); + } + } + } + } + + function _loadDeployments() internal { + // load deployments + // Inspo https://github.com/traderjoe-xyz/joe-v2/blob/main/script/deploy-core.s.sol + string memory path = string.concat(root, deploymentsPath); + string memory json = vm.readFile(path); + bytes memory rawDeploymentData = json.parseRaw(string(abi.encodePacked(".", chainId))); + deployment = abi.decode(rawDeploymentData, (Deployment)); + storageJson = json; + } + + function _loadExternalData() internal { + _loadConfig(); + _loadDeployments(); + } + + function _deploySafes() internal { + console.log("Deploying Safes"); + if (deployment.movementLabsSafe == ZERO && block.chainid != foundryChainId) { + // use canonical v1.4.1 safe factory address 0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67 if: + // - chainid is not foundry + // - safe is not deployed + SafeProxyFactory safeFactory = SafeProxyFactory(0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67); + deployment.movementDeployerSafe = _deploySafe( + safeFactory, + 0x41675C099F32341bf84BFc5382aF534df5C7461a, + 0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99, + config.signersDeployer, + config.thresholdDeployer + ); + deployment.movementLabsSafe = _deploySafe( + safeFactory, + 0x41675C099F32341bf84BFc5382aF534df5C7461a, + 0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99, + config.signersLabs, + config.thresholdLabs + ); + deployment.movementFoundationSafe = _deploySafe( + safeFactory, + 0x41675C099F32341bf84BFc5382aF534df5C7461a, + 0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99, + config.signersFoundation, + config.thresholdFoundation + ); + } else { + if (block.chainid == foundryChainId) { + SafeProxyFactory safeFactory = new SafeProxyFactory(); + Safe safeSingleton = new Safe(); + CompatibilityFallbackHandler fallbackHandler = new CompatibilityFallbackHandler(); + deployment.movementDeployerSafe = _deploySafe( + safeFactory, + address(safeSingleton), + address(fallbackHandler), + config.signersDeployer, + config.thresholdDeployer + ); + deployment.movementLabsSafe = _deploySafe( + safeFactory, + address(safeSingleton), + address(fallbackHandler), + config.signersLabs, + config.thresholdLabs + ); + deployment.movementFoundationSafe = _deploySafe( + safeFactory, + address(safeSingleton), + address(fallbackHandler), + config.signersFoundation, + config.thresholdFoundation + ); + // repeats foundation signers + deployment.movementAnchorage = _deploySafe( + safeFactory, + address(safeSingleton), + address(fallbackHandler), + config.signersFoundation, + config.thresholdLabs + ); + } + } + console.log("Safe addresses:"); + console.log("Deployer:", address(deployment.movementDeployerSafe)); + console.log("Labs:", address(deployment.movementLabsSafe)); + console.log("Foundation:", address(deployment.movementFoundationSafe)); + } + + function _deploySafe( + SafeProxyFactory safeFactory, + address safeSingleton, + address fallbackHandler, + address[] memory signers, + uint256 threshold + ) internal returns (address safe) { + safe = payable( + address( + safeFactory.createProxyWithNonce( + safeSingleton, + abi.encodeWithSignature( + safeSetupSignature, signers, threshold, ZERO, "0x", fallbackHandler, ZERO, 0, payable(ZERO) + ), + 0 + ) + ) + ); + } + + function _deployTimelock() internal { + if (deployment.timelock == ZERO) { + timelock = new TimelockController(config.minDelay, config.signersLabs, config.signersFoundation, ZERO); + deployment.timelock = address(timelock); + } + } + + function _deployCreate3() internal { + if (address(create3).code.length == 0) { + console.log("CREATE3: deploying"); + create3 = new CREATE3Factory(); + } + } + + function _deployDependencies() internal { + _deployCreate3(); + _deploySafes(); + _deployTimelock(); + } + + function _storeAdminDeployment() internal returns (address admin) { + Vm.Log[] memory logs = vm.getRecordedLogs(); + admin = logs[logs.length - 2].emitter; + console.log("admin", admin); + } + + function _writeDeployments() internal { + string memory path = string.concat(root, deploymentsPath); + string memory json = storageJson; + string memory base = "new"; + string memory newChainData = _serializer(json, deployment); + // take values from storageJson that were not updated (e.g. 3771) and serialize them + // since transaction reverts if writeDeployments does not contain all chain data, + // we need to serialize chain data for all valid chains besides the current one + uint256[] memory validChains = new uint256[](4); + validChains[0] = 1; // ethereum + validChains[1] = 11155111; // sepolia + validChains[2] = 17000; // holesky + validChains[3] = 31337; // foundry + for (uint256 i = 0; i < validChains.length; i++) { + if (validChains[i] != block.chainid) { + _serializeChainData(base, storageJson, validChains[i]); + } + } + // new chain data + string memory data = base.serialize(chainId, newChainData); + vm.writeFile(path, data); + } + + function _serializeChainData(string memory base, string storage sJson, uint256 chain) internal { + bytes memory rawDeploymentData = sJson.parseRaw(string(abi.encodePacked(".", _uint2str(chain)))); + Deployment memory deploymentData = abi.decode(rawDeploymentData, (Deployment)); + string memory json = _uint2str(chain); + string memory chainData = _serializer(json, deploymentData); + base.serialize(_uint2str(chain), chainData); + } + + function _serializer(string memory json, Deployment memory memoryDeployment) internal returns (string memory) { + json.serialize("pcp", memoryDeployment.pcp); + json.serialize("pcpAdmin", memoryDeployment.pcpAdmin); + json.serialize("move", memoryDeployment.move); + json.serialize("moveAdmin", memoryDeployment.moveAdmin); + json.serialize("movementAnchorage", memoryDeployment.movementAnchorage); + json.serialize("movementDeployerSafe", memoryDeployment.movementDeployerSafe); + json.serialize("movementFoundationSafe", memoryDeployment.movementFoundationSafe); + json.serialize("movementLabsSafe", memoryDeployment.movementLabsSafe); + json.serialize("staking", memoryDeployment.staking); + json.serialize("stakingAdmin", memoryDeployment.stakingAdmin); + json.serialize("stlMove", memoryDeployment.stlMove); + json.serialize("stlMoveAdmin", memoryDeployment.stlMoveAdmin); + return json.serialize("timelock", memoryDeployment.timelock); + } + + function _proposeUpgrade(bytes memory data, string memory fileName) internal { + string memory json = "safeCall"; + // Serialize the relevant fields into JSON format + json.serialize("to", address(timelock)); + string memory zero = "0"; + json.serialize("value", zero); + json.serialize("data", data); + string memory operation = "OperationType.Call"; + json.serialize("chainId", chainId); + json.serialize("safeAddress", deployment.movementLabsSafe); + string memory serializedData = json.serialize("operation", operation); + // Log the serialized JSON for debugging + console.log("json |start|", serializedData, "|end|"); + // Write the serialized data to a file + if (vm.isContext(VmSafe.ForgeContext.ScriptBroadcast)) { + vm.writeFile(string.concat(root, upgradePath, fileName), serializedData); + } + } + + // string to address + function s2a(bytes memory str) public returns (address addr) { + bytes32 data = keccak256(str); + assembly { + addr := data + } + } + + function _generateSignatures(uint256[] memory privKeys, bytes32 digest) + internal + returns (bytes memory signatures) + { + require(vm.addr(privKeys[0]) == vm.addr(vm.envUint("PRIVATE_KEY")), "First signer must be the sender"); + _sortByAddress(privKeys); + for (uint256 i = 0; i < privKeys.length; i++) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privKeys[i], digest); + signatures = abi.encodePacked(signatures, r, s, v); + } + } + + function _sortByAddress(uint256[] memory privKeys) internal { + for (uint256 i = 0; i < privKeys.length - 1; i++) { + for (uint256 j = 0; j < privKeys.length - i - 1; j++) { + if (vm.addr(privKeys[j]) > vm.addr(privKeys[j + 1])) { + (privKeys[j], privKeys[j + 1]) = (privKeys[j + 1], privKeys[j]); + } + } + } + } + + function _uint2str(uint256 _i) internal pure returns (string memory _uintAsString) { + if (_i == 0) { + return "0"; + } + uint256 j = _i; + uint256 len; + while (j != 0) { + len++; + j /= 10; + } + bytes memory bstr = new bytes(len); + uint256 k = len; + while (_i != 0) { + k = k - 1; + uint8 temp = (48 + uint8(_i - _i / 10 * 10)); + bytes1 b1 = bytes1(temp); + bstr[k] = b1; + _i /= 10; + } + return string(bstr); + } + + function _startsWith3073(address addr) internal pure returns (bool) { + bytes20 addrBytes = bytes20(addr); + return (uint16(uint8(addrBytes[0])) << 8 | uint8(addrBytes[1])) == 0x3073; + } + + function _getBytecode(address _addr) internal view returns (bytes memory code) { + assembly { + let size := extcodesize(_addr) + code := mload(0x40) + mstore(0x40, add(code, add(size, 0x20))) + mstore(code, size) + extcodecopy(_addr, add(code, 0x20), 0, size) + } + } + + function _getImplementation(address proxy) internal view returns (address implementation) { + bytes32 IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + implementation = address(uint160(uint256(vm.load(proxy, IMPLEMENTATION_SLOT)))); + } + + function _checkBytecodeDifference(address newImplementation, address proxy) internal { + if (allowsSameContract) { + return; + } + address currentImplementation = _getImplementation(proxy); + bytes memory newCode = _getBytecode(newImplementation); + bytes memory currentCode = _getBytecode(currentImplementation); + require(keccak256(newCode) != keccak256(currentCode), "Helper: New implementation is the same as the current one"); + } + + function _allowSameContract() internal { + allowsSameContract = true; + } +} diff --git a/protocol/pcp/dlu/eth/contracts/script/helpers/config.json b/protocol/pcp/dlu/eth/contracts/script/helpers/config.json new file mode 100644 index 00000000..40ee71ba --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/helpers/config.json @@ -0,0 +1,9 @@ +{ + "minDelay": 0, + "signersLabs": ["0x8943545177806ED17B9F23F0a21ee5948eCaa776"], + "signersFoundation": ["0x8943545177806ED17B9F23F0a21ee5948eCaa776"], + "signersDeployer": ["0x8943545177806ED17B9F23F0a21ee5948eCaa776"], + "thresholdLabs": 1, + "thresholdFoundation": 1, + "thresholdDeployer": 1 +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/script/helpers/configOLD.json b/protocol/pcp/dlu/eth/contracts/script/helpers/configOLD.json new file mode 100644 index 00000000..7e8a2d73 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/helpers/configOLD.json @@ -0,0 +1,24 @@ +{ + "minDelay": "172800", + "signersDeployer": [ + "0xB2105464215716e1445367BEA5668F581eF7d063", + "0x3eB69Ef2DbEDD5d58AA5E074131Cd22D5e87Ff53" + ], + "signersFoundation": [ + "0x2801A777E451094c22abCF2c1bBfd0a9c1756831", + "0xCd87972D73C3eAC3b12dA31c089cE53DcC066812", + "0x5505070Cf73f8c92Cf79a23C53Be15F55Fb70923", + "0x9d7963F30C27d54e19faA679EF2F5Af105Ac1D75", + "0x89A4e685644E5fa0F137BBabc35b7A45a20a90bE" + ], + "signersLabs": [ + "0x49F86Aee2C2187870ece0e64570D0048EaF4C751", + "0xaFf3deeb13bD2B480751189808C16e9809EeBcce", + "0x12Cbb2C9F072E955b6B95ad46213aAa984A4434D", + "0xB2105464215716e1445367BEA5668F581eF7d063", + "0x0eEd12Ca165A962cd12420DfB38407637bcA4267" + ], + "thresholdDeployer": 1, + "thresholdFoundation": 3, + "thresholdLabs": 4 +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/script/helpers/deployments.json b/protocol/pcp/dlu/eth/contracts/script/helpers/deployments.json new file mode 100644 index 00000000..b2881cf2 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/helpers/deployments.json @@ -0,0 +1,77 @@ +{ + "1": { + "pcp": "0x0000000000000000000000000000000000000000", + "pcpAdmin": "0x0000000000000000000000000000000000000000", + "move": "0x3073f7aAA4DB83f95e9FFf17424F71D4751a3073", + "moveAdmin": "0x8365AA031806A1ac2b31a5d3b8323020FC85DfEc", + "movementAnchorage": "0xe3e86E126fcCd071Af39a0899734Ca5C8E5F4F25", + "movementDeployerSafe": "0x7aE744e3b2816F660054EAbd1a1C4935DA34Ae28", + "movementFoundationSafe": "0x074C155f09cE5fC3B65b4a9Bbb01739459C7AD63", + "movementLabsSafe": "0xd7E22951DE7aF453aAc5400d6E072E3b63BeB7E2", + "staking": "0x0000000000000000000000000000000000000000", + "stakingAdmin": "0x0000000000000000000000000000000000000000", + "stlMove": "0x0000000000000000000000000000000000000000", + "stlMoveAdmin": "0x0000000000000000000000000000000000000000", + "timelock": "0xA649f6335828f070dDDd7A8c4F5bef2b6FF7Bd51" + }, + "11155111": { + "pcp": "0x0000000000000000000000000000000000000000", + "pcpAdmin": "0x0000000000000000000000000000000000000000", + "move": "0x0000000000000000000000000000000000000000", + "moveAdmin": "0x0000000000000000000000000000000000000000", + "movementAnchorage": "0x0000000000000000000000000000000000000000", + "movementDeployerSafe": "0xDfBe79c22944b25beDF690Af3FC7CC9289E946f1", + "movementFoundationSafe": "0x00db70A9e12537495C359581b7b3Bc3a69379A00", + "movementLabsSafe": "0x493516F6dB02c9b7f649E650c5de244646022Aa0", + "staking": "0x0000000000000000000000000000000000000000", + "stakingAdmin": "0x0000000000000000000000000000000000000000", + "stlMove": "0x0000000000000000000000000000000000000000", + "stlMoveAdmin": "0x0000000000000000000000000000000000000000", + "timelock": "0xC5B4Ca6E12144dE0e8e666F738A289476bebBc02" + }, + "17000": { + "pcp": "0x0000000000000000000000000000000000000000", + "pcpAdmin": "0x0000000000000000000000000000000000000000", + "move": "0x0000000000000000000000000000000000000000", + "moveAdmin": "0x0000000000000000000000000000000000000000", + "movementAnchorage": "0x0000000000000000000000000000000000000000", + "movementDeployerSafe": "0x0000000000000000000000000000000000000000", + "movementFoundationSafe": "0x0000000000000000000000000000000000000000", + "movementLabsSafe": "0x0000000000000000000000000000000000000000", + "staking": "0x0000000000000000000000000000000000000000", + "stakingAdmin": "0x0000000000000000000000000000000000000000", + "stlMove": "0x0000000000000000000000000000000000000000", + "stlMoveAdmin": "0x0000000000000000000000000000000000000000", + "timelock": "0x0000000000000000000000000000000000000000" + }, + "31337": { + "pcp": "0x0000000000000000000000000000000000000000", + "pcpAdmin": "0x0000000000000000000000000000000000000000", + "move": "0x703848F4c85f18e3acd8196c8eC91eb0b7Bd0797", + "moveAdmin": "0x0000000000000000000000000000000000000000", + "movementAnchorage": "0x0000000000000000000000000000000000000000", + "movementDeployerSafe": "0x0000000000000000000000000000000000000000", + "movementFoundationSafe": "0x0000000000000000000000000000000000000000", + "movementLabsSafe": "0x0000000000000000000000000000000000000000", + "staking": "0x422A3492e218383753D8006C7Bfa97815B44373F", + "stakingAdmin": "0x0000000000000000000000000000000000000000", + "stlMove": "0x0000000000000000000000000000000000000000", + "stlMoveAdmin": "0x0000000000000000000000000000000000000000", + "timelock": "0x0000000000000000000000000000000000000000" + }, + "3151908": { + "pcp": "0x0000000000000000000000000000000000000000", + "pcpAdmin": "0x0000000000000000000000000000000000000000", + "move": "0x703848F4c85f18e3acd8196c8eC91eb0b7Bd0797", + "moveAdmin": "0x0000000000000000000000000000000000000000", + "movementAnchorage": "0x0000000000000000000000000000000000000000", + "movementDeployerSafe": "0x0000000000000000000000000000000000000000", + "movementFoundationSafe": "0x0000000000000000000000000000000000000000", + "movementLabsSafe": "0x0000000000000000000000000000000000000000", + "staking": "0x422A3492e218383753D8006C7Bfa97815B44373F", + "stakingAdmin": "0x0000000000000000000000000000000000000000", + "stlMove": "0x0000000000000000000000000000000000000000", + "stlMoveAdmin": "0x0000000000000000000000000000000000000000", + "timelock": "0x0000000000000000000000000000000000000000" + } +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/script/helpers/deploymentsOLD.json b/protocol/pcp/dlu/eth/contracts/script/helpers/deploymentsOLD.json new file mode 100644 index 00000000..3d14bed3 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/helpers/deploymentsOLD.json @@ -0,0 +1,62 @@ +{ + "1": { + "pcp": "0x0000000000000000000000000000000000000000", + "pcpAdmin": "0x0000000000000000000000000000000000000000", + "move": "0x3073f7aAA4DB83f95e9FFf17424F71D4751a3073", + "moveAdmin": "0x8365AA031806A1ac2b31a5d3b8323020FC85DfEc", + "movementAnchorage": "0xe3e86E126fcCd071Af39a0899734Ca5C8E5F4F25", + "movementDeployerSafe": "0x7aE744e3b2816F660054EAbd1a1C4935DA34Ae28", + "movementFoundationSafe": "0x074C155f09cE5fC3B65b4a9Bbb01739459C7AD63", + "movementLabsSafe": "0xd7E22951DE7aF453aAc5400d6E072E3b63BeB7E2", + "staking": "0x0000000000000000000000000000000000000000", + "stakingAdmin": "0x0000000000000000000000000000000000000000", + "stlMove": "0x0000000000000000000000000000000000000000", + "stlMoveAdmin": "0x0000000000000000000000000000000000000000", + "timelock": "0xA649f6335828f070dDDd7A8c4F5bef2b6FF7Bd51" + }, + "11155111": { + "pcp": "0x0000000000000000000000000000000000000000", + "pcpAdmin": "0x0000000000000000000000000000000000000000", + "move": "0x0000000000000000000000000000000000000000", + "moveAdmin": "0x0000000000000000000000000000000000000000", + "movementAnchorage": "0x0000000000000000000000000000000000000000", + "movementDeployerSafe": "0xDfBe79c22944b25beDF690Af3FC7CC9289E946f1", + "movementFoundationSafe": "0x00db70A9e12537495C359581b7b3Bc3a69379A00", + "movementLabsSafe": "0x493516F6dB02c9b7f649E650c5de244646022Aa0", + "staking": "0x0000000000000000000000000000000000000000", + "stakingAdmin": "0x0000000000000000000000000000000000000000", + "stlMove": "0x0000000000000000000000000000000000000000", + "stlMoveAdmin": "0x0000000000000000000000000000000000000000", + "timelock": "0xC5B4Ca6E12144dE0e8e666F738A289476bebBc02" + }, + "17000": { + "pcp": "0x0000000000000000000000000000000000000000", + "pcpAdmin": "0x0000000000000000000000000000000000000000", + "move": "0x0000000000000000000000000000000000000000", + "moveAdmin": "0x0000000000000000000000000000000000000000", + "movementAnchorage": "0x0000000000000000000000000000000000000000", + "movementDeployerSafe": "0x0000000000000000000000000000000000000000", + "movementFoundationSafe": "0x0000000000000000000000000000000000000000", + "movementLabsSafe": "0x0000000000000000000000000000000000000000", + "staking": "0x0000000000000000000000000000000000000000", + "stakingAdmin": "0x0000000000000000000000000000000000000000", + "stlMove": "0x0000000000000000000000000000000000000000", + "stlMoveAdmin": "0x0000000000000000000000000000000000000000", + "timelock": "0x0000000000000000000000000000000000000000" + }, + "31337": { + "pcp": "0x0000000000000000000000000000000000000000", + "pcpAdmin": "0x0000000000000000000000000000000000000000", + "move": "0x0000000000000000000000000000000000000000", + "moveAdmin": "0x0000000000000000000000000000000000000000", + "movementAnchorage": "0x0000000000000000000000000000000000000000", + "movementDeployerSafe": "0x0000000000000000000000000000000000000000", + "movementFoundationSafe": "0x0000000000000000000000000000000000000000", + "movementLabsSafe": "0x0000000000000000000000000000000000000000", + "staking": "0x0000000000000000000000000000000000000000", + "stakingAdmin": "0x0000000000000000000000000000000000000000", + "stlMove": "0x0000000000000000000000000000000000000000", + "stlMoveAdmin": "0x0000000000000000000000000000000000000000", + "timelock": "0x0000000000000000000000000000000000000000" + } +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/script/helpers/safe-deployments.json b/protocol/pcp/dlu/eth/contracts/script/helpers/safe-deployments.json new file mode 100644 index 00000000..9fcc4fbc --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/helpers/safe-deployments.json @@ -0,0 +1,5 @@ +{ + "Safe": "0xb4B46bdAA835F8E4b4d8e208B6559cD267851051", + "FallbackHandler": "0x17435ccE3d1B4fA2e5f8A08eD921D57C6762A180", + "SafeFactory": "0x703848F4c85f18e3acd8196c8eC91eb0b7Bd0797" +} diff --git a/protocol/pcp/dlu/eth/contracts/script/helpers/upgrade/deploymove.json b/protocol/pcp/dlu/eth/contracts/script/helpers/upgrade/deploymove.json new file mode 100644 index 00000000..c39345ec --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/helpers/upgrade/deploymove.json @@ -0,0 +1,8 @@ +{ + "chainId": "1", + "data": "0xcdcb760a6c0000000000000000000000018eddf77afc0a5c6d05a564a44fe37b068922c300000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000eb260a0604052604051610dd2380380610dd28339810160408190526100229161036a565b828161002e828261008c565b50508160405161003d9061032e565b6001600160a01b039091168152602001604051809103905ff080158015610066573d5f803e3d5ffd5b506001600160a01b031660805261008461007f60805190565b6100ea565b505050610451565b61009582610157565b6040516001600160a01b038316907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b905f90a28051156100de576100d982826101d5565b505050565b6100e6610248565b5050565b7f7e644d79422f17c01e4894b5f4f588d331ebfa28653d42ae832dc59e38c9798f6101295f80516020610db2833981519152546001600160a01b031690565b604080516001600160a01b03928316815291841660208301520160405180910390a161015481610269565b50565b806001600160a01b03163b5f0361019157604051634c9c8ce360e01b81526001600160a01b03821660048201526024015b60405180910390fd5b807f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc5b80546001600160a01b0319166001600160a01b039290921691909117905550565b60605f80846001600160a01b0316846040516101f1919061043b565b5f60405180830381855af49150503d805f8114610229576040519150601f19603f3d011682016040523d82523d5f602084013e61022e565b606091505b50909250905061023f8583836102a6565b95945050505050565b34156102675760405163b398979f60e01b815260040160405180910390fd5b565b6001600160a01b03811661029257604051633173bdd160e11b81525f6004820152602401610188565b805f80516020610db28339815191526101b4565b6060826102bb576102b682610305565b6102fe565b81511580156102d257506001600160a01b0384163b155b156102fb57604051639996b31560e01b81526001600160a01b0385166004820152602401610188565b50805b9392505050565b8051156103155780518082602001fd5b604051630a12f52160e11b815260040160405180910390fd5b6104ef806108c383390190565b80516001600160a01b0381168114610351575f80fd5b919050565b634e487b7160e01b5f52604160045260245ffd5b5f805f6060848603121561037c575f80fd5b6103858461033b565b92506103936020850161033b565b60408501519092506001600160401b038111156103ae575f80fd5b8401601f810186136103be575f80fd5b80516001600160401b038111156103d7576103d7610356565b604051601f8201601f19908116603f011681016001600160401b038111828210171561040557610405610356565b60405281815282820160200188101561041c575f80fd5b8160208401602083015e5f602083830101528093505050509250925092565b5f82518060208501845e5f920191825250919050565b60805161045b6104685f395f6010015261045b5ff3fe608060405261000c61000e565b005b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b0316330361007a575f356001600160e01b03191663278f794360e11b14610070576040516334ad5dbb60e21b815260040160405180910390fd5b610078610082565b565b6100786100b0565b5f806100913660048184610303565b81019061009e919061033e565b915091506100ac82826100c0565b5050565b6100786100bb61011a565b610151565b6100c98261016f565b6040516001600160a01b038316907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b905f90a28051156101125761010d82826101ea565b505050565b6100ac61025c565b5f61014c7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc546001600160a01b031690565b905090565b365f80375f80365f845af43d5f803e80801561016b573d5ff35b3d5ffd5b806001600160a01b03163b5f036101a957604051634c9c8ce360e01b81526001600160a01b03821660048201526024015b60405180910390fd5b7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc80546001600160a01b0319166001600160a01b0392909216919091179055565b60605f80846001600160a01b031684604051610206919061040f565b5f60405180830381855af49150503d805f811461023e576040519150601f19603f3d011682016040523d82523d5f602084013e610243565b606091505b509150915061025385838361027b565b95945050505050565b34156100785760405163b398979f60e01b815260040160405180910390fd5b6060826102905761028b826102da565b6102d3565b81511580156102a757506001600160a01b0384163b155b156102d057604051639996b31560e01b81526001600160a01b03851660048201526024016101a0565b50805b9392505050565b8051156102ea5780518082602001fd5b604051630a12f52160e11b815260040160405180910390fd5b5f8085851115610311575f80fd5b8386111561031d575f80fd5b5050820193919092039150565b634e487b7160e01b5f52604160045260245ffd5b5f806040838503121561034f575f80fd5b82356001600160a01b0381168114610365575f80fd5b9150602083013567ffffffffffffffff811115610380575f80fd5b8301601f81018513610390575f80fd5b803567ffffffffffffffff8111156103aa576103aa61032a565b604051601f8201601f19908116603f0116810167ffffffffffffffff811182821017156103d9576103d961032a565b6040528181528282016020018710156103f0575f80fd5b816020840160208301375f602083830101528093505050509250929050565b5f82518060208501845e5f92019182525091905056fea26469706673582212207316aab519176ee304ec10e6d1292c57367ad6dcdd543d000aca2331bc7121f664736f6c634300081a0033608060405234801561000f575f80fd5b506040516104ef3803806104ef83398101604081905261002e916100bb565b806001600160a01b03811661005c57604051631e4fbdf760e01b81525f600482015260240160405180910390fd5b6100658161006c565b50506100e8565b5f80546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b5f602082840312156100cb575f80fd5b81516001600160a01b03811681146100e1575f80fd5b9392505050565b6103fa806100f55f395ff3fe608060405260043610610049575f3560e01c8063715018a61461004d5780638da5cb5b146100635780639623609d1461008e578063ad3cb1cc146100a1578063f2fde38b146100de575b5f80fd5b348015610058575f80fd5b506100616100fd565b005b34801561006e575f80fd5b505f546040516001600160a01b0390911681526020015b60405180910390f35b61006161009c366004610260565b610110565b3480156100ac575f80fd5b506100d1604051806040016040528060058152602001640352e302e360dc1b81525081565b6040516100859190610365565b3480156100e9575f80fd5b506100616100f836600461037e565b61017b565b6101056101bd565b61010e5f6101e9565b565b6101186101bd565b60405163278f794360e11b81526001600160a01b03841690634f1ef2869034906101489086908690600401610399565b5f604051808303818588803b15801561015f575f80fd5b505af1158015610171573d5f803e3d5ffd5b5050505050505050565b6101836101bd565b6001600160a01b0381166101b157604051631e4fbdf760e01b81525f60048201526024015b60405180910390fd5b6101ba816101e9565b50565b5f546001600160a01b0316331461010e5760405163118cdaa760e01b81523360048201526024016101a8565b5f80546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b6001600160a01b03811681146101ba575f80fd5b634e487b7160e01b5f52604160045260245ffd5b5f805f60608486031215610272575f80fd5b833561027d81610238565b9250602084013561028d81610238565b9150604084013567ffffffffffffffff8111156102a8575f80fd5b8401601f810186136102b8575f80fd5b803567ffffffffffffffff8111156102d2576102d261024c565b604051601f8201601f19908116603f0116810167ffffffffffffffff811182821017156103015761030161024c565b604052818152828201602001881015610318575f80fd5b816020840160208301375f602083830101528093505050509250925092565b5f81518084528060208401602086015e5f602082860101526020601f19601f83011685010191505092915050565b602081525f6103776020830184610337565b9392505050565b5f6020828403121561038e575f80fd5b813561037781610238565b6001600160a01b03831681526040602082018190525f906103bc90830184610337565b94935050505056fea2646970667358221220def1ca9b5fe53ae7582cac45dc9f62f92e0f0d18509d044fe0ed34cd71f1407864736f6c634300081a0033b53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d61030000000000000000000000001e1bf2adf28e2e0549ad2474f04f3e1b0de77e9c000000000000000000000000a649f6335828f070dddd7a8c4f5bef2b6ff7bd5100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044485cc955000000000000000000000000074c155f09ce5fc3b65b4a9bbb01739459c7ad63000000000000000000000000e3e86e126fccd071af39a0899734ca5c8e5f4f25000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "operation": "OperationType.Call", + "safeAddress": "0x7aE744e3b2816F660054EAbd1a1C4935DA34Ae28", + "to": "0x2Dfcc7415D89af828cbef005F0d072D8b3F23183", + "value": "0" +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/script/helpers/upgrade/mcr.json b/protocol/pcp/dlu/eth/contracts/script/helpers/upgrade/mcr.json new file mode 100644 index 00000000..77c5442b --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/helpers/upgrade/mcr.json @@ -0,0 +1,8 @@ +{ + "chainId": "31337", + "data": "0x01d5062a000000000000000000000000774f4a713148fa4120d60d2552ae55ecbe1eb6d1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000849623609d0000000000000000000000004d5a88b35d7dd11d63ac85a3b9ff7ff2ec4f5d7a000000000000000000000000061447e81544e21ac6920e82f0fd9cca31c704e50000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "operation": "OperationType.Call", + "safeAddress": "0x55e1A8f7f5bFa23226115198f5Bc5Ba75D9257E4", + "to": "0x792123fcDe5d62d8316A665962f72F9722eA15Dd", + "value": "0" +} diff --git a/protocol/pcp/dlu/eth/contracts/script/helpers/upgrade/staking.json b/protocol/pcp/dlu/eth/contracts/script/helpers/upgrade/staking.json new file mode 100644 index 00000000..bd1051e4 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/helpers/upgrade/staking.json @@ -0,0 +1 @@ +{"chainId":"31337","data":"0x01d5062a0000000000000000000000002dbd88997c031fd7230639630889e843248e0e0b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000849623609d000000000000000000000000255c57eb74ec1c20803e7562f77258d9020b387500000000000000000000000095fde926e53e388151b8694071a7ade283facc7d0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","operation":"OperationType.Call","safeAddress":"0x55e1A8f7f5bFa23226115198f5Bc5Ba75D9257E4","to":"0x792123fcDe5d62d8316A665962f72F9722eA15Dd","value":"0"} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/script/helpers/upgrade/stlmove.json b/protocol/pcp/dlu/eth/contracts/script/helpers/upgrade/stlmove.json new file mode 100644 index 00000000..b80db95c --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/helpers/upgrade/stlmove.json @@ -0,0 +1 @@ +{"chainId":"31337","data":"0x01d5062a00000000000000000000000018b30fba961c78c46cab2a990f7fcc1fc23bc6f9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000849623609d000000000000000000000000e87a1e3fab0bd7d89d3a734ee562422d87cfeddb000000000000000000000000a3fbe451ef7fb2326a7a2f4406e1b83a67af5ad40000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","operation":"OperationType.Call","safeAddress":"0x55e1A8f7f5bFa23226115198f5Bc5Ba75D9257E4","to":"0x792123fcDe5d62d8316A665962f72F9722eA15Dd","value":"0"} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/script/install-deps.sh b/protocol/pcp/dlu/eth/contracts/script/install-deps.sh new file mode 100755 index 00000000..053a8e57 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/install-deps.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +echo "🧹 Cleaning up existing dependencies..." +rm -rf lib/openzeppelin-contracts +rm -rf lib/openzeppelin-contracts-upgradeable +rm -rf lib/safe-contracts +rm -rf lib/forge-std + +echo "📦 Installing core dependencies..." +forge install foundry-rs/forge-std --no-git +forge install OpenZeppelin/openzeppelin-contracts --no-git +forge install OpenZeppelin/openzeppelin-contracts-upgradeable --no-git + +echo "🔒 Installing Safe contracts with submodules..." +forge install safe-global/safe-contracts@v1.4.1 --no-git --recurse-submodules + +echo "🔨 Verifying installation..." +forge build + +echo "✅ Dependencies installed successfully!" \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/script/verify-mcr.sh b/protocol/pcp/dlu/eth/contracts/script/verify-mcr.sh new file mode 100755 index 00000000..f67de637 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/script/verify-mcr.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +# Check if RPC URL is provided +if [ -z "$1" ]; then + echo "Usage: $0 " + echo "Example: $0 http://127.0.0.1:50955" + exit 1 +fi + +RPC_URL=$1 + +# Read the deployment addresses from the latest broadcast +LATEST_BROADCAST=$(ls -t broadcast/DeployPCPDev.s.sol/*/run-latest.json | head -n1) +# Get the proxy addresses (they're created after the implementations) +MOVE_TOKEN=$(cat $LATEST_BROADCAST | jq -r '.transactions[] | select(.contractName=="ERC1967Proxy") | .contractAddress' | sed -n '1p') +STAKING_PROXY=$(cat $LATEST_BROADCAST | jq -r '.transactions[] | select(.contractName=="ERC1967Proxy") | .contractAddress' | sed -n '2p') +PCP_PROXY=$(cat $LATEST_BROADCAST | jq -r '.transactions[] | select(.contractName=="ERC1967Proxy") | .contractAddress' | sed -n '3p') +DEPLOYER=$(cat $LATEST_BROADCAST | jq -r '.transactions[0].from') +PRIVATE_KEY="39725efee3fb28614de3bacaffe4cc4bd8c436257e2c8bb887c4b5c4be45e76d" + +echo "=== Verifying PCP Deployment ===" +echo "MOVE Token: $MOVE_TOKEN" +echo "PCP Proxy: $PCP_PROXY" +echo "Staking Proxy: $STAKING_PROXY" +echo "Deployer: $DEPLOYER"./ + +echo -e "\n=== Checking PCP Configuration ===" +echo "Epoch Duration:" +cast call --rpc-url $RPC_URL $PCP_PROXY "getEpochDuration()(uint256)" +echo "Postconfirmer Duration:" +cast call --rpc-url $RPC_URL $PCP_PROXY "getPostconfirmerDuration()(uint256)" + +echo -e "\n=== Checking Staking Setup ===" +echo "Epoch Duration for PCP domain:" +cast call --rpc-url $RPC_URL $STAKING_PROXY "epochDurationByDomain(address)(uint256)" $PCP_PROXY + +echo -e "\n=== Checking Token Setup ===" +echo "Deployer Balance:" +cast call --rpc-url $RPC_URL $MOVE_TOKEN "balanceOf(address)(uint256)" $DEPLOYER +echo "Staking Contract has Minter Role:" +cast call --rpc-url $RPC_URL $MOVE_TOKEN "hasMinterRole(address)(bool)" $STAKING_PROXY + +echo -e "\n=== Testing Staking ===" +echo "Approving tokens for staking..." +cast send --rpc-url $RPC_URL $MOVE_TOKEN "approve(address,uint256)" $STAKING_PROXY 1000ether --private-key $PRIVATE_KEY +echo "Staking tokens..." +cast send --rpc-url $RPC_URL $STAKING_PROXY "stake(address,uint256)" $PCP_PROXY 1000ether --private-key $PRIVATE_KEY + +echo -e "\n=== Testing PCP Attestation ===" +echo "Submitting test attestation..." +cast send --rpc-url $RPC_URL $PCP_PROXY "attest(uint256,bytes32)" 1 0x1234567890123456789012345678901234567890123456789012345678901234 --private-key $PRIVATE_KEY + +echo -e "\n=== Verification Complete ===" \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/src/settlement/PCP.sol b/protocol/pcp/dlu/eth/contracts/src/settlement/PCP.sol new file mode 100644 index 00000000..4654f0f2 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/settlement/PCP.sol @@ -0,0 +1,681 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {MovementStaking, IMovementStaking} from "../staking/MovementStaking.sol"; +import {PCPStorage} from "./PCPStorage.sol"; +import {BaseSettlement} from "./settlement/BaseSettlement.sol"; +import {IPCP} from "./interfaces/IPCP.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +contract PCP is Initializable, BaseSettlement, PCPStorage, IPCP { + + // A role for setting commitments + bytes32 public constant COMMITMENT_ADMIN = keccak256("COMMITMENT_ADMIN"); + + // Trusted attesters admin + bytes32 public constant TRUSTED_ATTESTER = keccak256("TRUSTED_ATTESTER"); + + /// @notice Error thrown when postconfirmer term is greater than 256 blocks + error PostconfirmerDurationTooLong(); + + /// @notice Error thrown when postconfirmer term is too large for epoch duration + error PostconfirmerDurationTooLongForEpoch(); + + /// @notice Error thrown when minimum commitment age is greater than epoch duration + error minCommitmentAgeForPostconfirmationTooLong(); + + /// @notice Error thrown when maximum postconfirmer non-reactivity time is greater than epoch duration + error postconfirmerPrivilegeDurationTooLong(); + + // ---------------------------------------------------------------- + // -------- 1. Role & Permission Management ----------------------- + // ---------------------------------------------------------------- + + function grantCommitmentAdmin(address account) public { + require( + hasRole(DEFAULT_ADMIN_ROLE, msg.sender), + "ADD_COMMITMENT_ADMIN_IS_ADMIN_ONLY" + ); + grantRole(COMMITMENT_ADMIN, account); + } + + function batchGrantCommitmentAdmin(address[] memory accounts) public { + require( + hasRole(DEFAULT_ADMIN_ROLE, msg.sender), + "ADD_COMMITMENT_ADMIN_IS_ADMIN_ONLY" + ); + for (uint256 i = 0; i < accounts.length; i++) { + grantRole(COMMITMENT_ADMIN, accounts[i]); + } + } + + function grantTrustedAttester(address attester) public onlyRole(COMMITMENT_ADMIN) { + grantRole(TRUSTED_ATTESTER, attester); + } + + function batchGrantTrustedAttester(address[] memory attesters) public onlyRole(COMMITMENT_ADMIN) { + for (uint256 i = 0; i < attesters.length; i++) { + grantRole(TRUSTED_ATTESTER, attesters[i]); + } + } + + // ---------------------------------------------------------------- + // -------- 2. Contract Initialization & Configuration ------------ + // ---------------------------------------------------------------- + + function initialize( + IMovementStaking _stakingContract, + uint256 _lastPostconfirmedSuperBlockHeight, + uint256 _leadingSuperBlockTolerance, + uint256 _epochDuration, // in time units + address[] memory _custodians, + uint256 _postconfirmerDuration, // in time units + address _moveTokenAddress // the primary custodian for rewards in the staking contract + ) public initializer { + __BaseSettlement_init_unchained(); + stakingContract = _stakingContract; + leadingSuperBlockTolerance = _leadingSuperBlockTolerance; + lastPostconfirmedSuperBlockHeight = _lastPostconfirmedSuperBlockHeight; + stakingContract.registerDomain(_epochDuration, _custodians); + grantCommitmentAdmin(msg.sender); + grantTrustedAttester(msg.sender); + postconfirmerDuration = _postconfirmerDuration; + moveTokenAddress = _moveTokenAddress; + + // Set default values to 1/10th of epoch duration + // NOTE since epochduration divided by 10 may not be an exact integer, the start and end of these windows may drift within an epoch over time. + // NOTE Consequently to remain on the safe side, these values should remain a small fraction of the epoch duration. + // NOTE If they are small at most only the last fraction within an epoch will behave differently. + // TODO Examine the effects of the above. + minCommitmentAgeForPostconfirmation = _epochDuration / 10; + postconfirmerPrivilegeDuration = _epochDuration / 10; + rewardPerAttestationPoint = 1; + rewardPerPostconfirmationPoint = 1; + } + + /// @notice Accepts the genesis ceremony. + function acceptGenesisCeremony() public { + require(hasRole(DEFAULT_ADMIN_ROLE, msg.sender), "ACCEPT_GENESIS_CEREMONY_IS_ADMIN_ONLY"); + stakingContract.acceptGenesisCeremony(); + } + + /// @notice Sets the postconfirmer term duration, must be less than epoch duration + /// @param _postconfirmerDuration New postconfirmer term duration in time units + function setPostconfirmerDuration(uint256 _postconfirmerDuration) public onlyRole(COMMITMENT_ADMIN) { + // Ensure postconfirmer term is sufficiently small compared to epoch duration + uint256 epochDuration = stakingContract.getEpochDuration(address(this)); + + // TODO If we would use block heights instead of timestamps we could handle everything much smoother. + if (2 * _postconfirmerDuration >= epochDuration ) { + revert PostconfirmerDurationTooLongForEpoch(); + } + postconfirmerDuration = _postconfirmerDuration; + } + + function getPostconfirmerDuration() public view returns (uint256) { + return postconfirmerDuration; + } + + /// @notice Sets the maximum time the postconfirmer can be non-reactive to an honest superBlock commitment + /// @param _postconfirmerPrivilegeDuration maximum time the postconfirmer is permitted to be non-reactive to an honest superBlock commitment + function setPostconfirmerPrivilegeDuration(uint256 _postconfirmerPrivilegeDuration) public onlyRole(COMMITMENT_ADMIN) { + // Ensure max privilege time is not too large + if (_postconfirmerPrivilegeDuration >= stakingContract.getEpochDuration(address(this)) - getMinCommitmentAgeForPostconfirmation()) { + revert postconfirmerPrivilegeDurationTooLong(); + } + postconfirmerPrivilegeDuration = _postconfirmerPrivilegeDuration; + } + + /// @notice Gets the maximum time the postconfirmer can be non-reactive to an honest superBlock commitment + /// @return The maximum time the postconfirmer can be non-reactive to an honest superBlock commitment + function getPostconfirmerPrivilegeDuration() public view returns (uint256) { + return postconfirmerPrivilegeDuration; + } + + /// @notice Sets the minimum time that must pass before a commitment can be postconfirmed + /// @param _minCommitmentAgeForPostconfirmation New minimum commitment age + // TODO we also require a check when setting the epoch length that it is larger than the min commitment age + // TODO we need to set these values such that it works for postconfirmer Term and postconfirmerPrivilegeDuration, etc... there are many constraints here. + function setMinCommitmentAgeForPostconfirmation(uint256 _minCommitmentAgeForPostconfirmation) public onlyRole(COMMITMENT_ADMIN) { + // Ensure min age is less than epoch duration to allow postconfirmation within same epoch + if (_minCommitmentAgeForPostconfirmation >= stakingContract.getEpochDuration(address(this)) - getPostconfirmerPrivilegeDuration()) { + revert minCommitmentAgeForPostconfirmationTooLong(); + } + minCommitmentAgeForPostconfirmation = _minCommitmentAgeForPostconfirmation; + } + + function getMinCommitmentAgeForPostconfirmation() public view returns (uint256) { + return minCommitmentAgeForPostconfirmation; + } + + function setOpenAttestationEnabled(bool enabled) public onlyRole(COMMITMENT_ADMIN) { + openAttestationEnabled = enabled; + } + + // ---------------------------------------------------------------- + // -------- 3. Epoch & Time Management --------------------------- + // ---------------------------------------------------------------- + + /// @notice Gets the epoch duration + function getEpochDuration() public view returns (uint256) { + return stakingContract.getEpochDuration(address(this)); + } + + /// @notice Gets the time at which the current epoch started + function getEpochStartTime() public view returns (uint256) { + uint256 currentTime = block.timestamp; + return currentTime - (currentTime % stakingContract.getEpochDuration(address(this))); + } + + // gets the present epoch + function getPresentEpoch() public view returns (uint256) { + return stakingContract.getEpochByL1BlockTime(address(this)); + } + + // gets the accepting epoch + function getAcceptingEpoch() public view returns (uint256) { + return stakingContract.getAcceptingEpoch(address(this)); + } + + // gets the next accepting epoch (unless we are at genesis) + function getNextAcceptingEpochWithException() public view returns (uint256) { + return stakingContract.getNextAcceptingEpochWithException(address(this)); + } + + /// @notice Gets the time at which the current postconfirmer's term started + function getPostconfirmerStartTime() public view returns (uint256) { + uint256 currentTime = block.timestamp; + // We reset the times to match the start of epochs. This ensures every epoch has the same setup. + uint256 currentTimeCorrected = currentTime % stakingContract.getEpochDuration(address(this)); + return currentTimeCorrected - (currentTimeCorrected % postconfirmerDuration); + } + + /// @notice Determines the postconfirmer in the accepting epoch using L1 block hash as a source of randomness + // At the border between epochs this is not ideal as getPostconfirmer works on blocks and epochs works with time. + // Thus we must consider the edge cases where the postconfirmer is only active for a short time. + function getPostconfirmer() public view returns (address) { + // TODO unless we swap with everything, including epochs, we must use block.timestamp. + // However, to get easy access to L1 randomness we need to know the block at which the postconfirmer started, which is expensive (unless we count in blocks instead of time) + // TODO as long as we do not swap to block.number, the randomness below is precictable. + uint256 randSeed1 = getPostconfirmerStartTime(); + uint256 randSeed2 = getEpochStartTime(); + address[] memory attesters = stakingContract.getStakedAttestersForAcceptingEpoch(address(this)); + if (attesters.length == 0) { + return address(0); + } + uint256 postconfirmerIndex = uint256(keccak256(abi.encodePacked(randSeed1, randSeed2))) % attesters.length; // randomize the order of the attesters + return attesters[postconfirmerIndex]; + } + + /// @notice Sets the accepting epoch to a new value (must be higher than current) + /// @param newEpoch The new accepting epoch value + function setAcceptingEpoch(uint256 newEpoch) external onlyRole(COMMITMENT_ADMIN) { + // the domain which is the pcp contract must make the call to set the accepting epoch + stakingContract.setAcceptingEpoch(address(this), newEpoch); + } + + // ---------------------------------------------------------------- + // -------- 4. Commitment Management ---------- + // ---------------------------------------------------------------- + + // creates a commitment + function createSuperBlockCommitment( + uint256 height, + bytes32 commitment, + bytes32 blockId + ) public pure returns (SuperBlockCommitment memory) { + return SuperBlockCommitment(height, commitment, blockId); + } + + /// @dev submits a superBlock commitment for an attester. + function submitSuperBlockCommitmentForAttester( + address attester, + SuperBlockCommitment memory superBlockCommitment + ) internal { + // Attester has already committed to a superBlock at this height + if (commitments[superBlockCommitment.height][attester].height != 0) + revert AttesterAlreadyCommitted(); + + // note: do no uncomment the below, we want to allow this in case we have lagging attesters + // Attester has committed to an already postconfirmed superBlock + // if ( lastPostconfirmedSuperBlockHeight > superBlockCommitment.height) revert AlreadyAcceptedSuperBlock(); + // Attester has committed to a superBlock too far ahead of the last postconfirmed superBlock + if (lastPostconfirmedSuperBlockHeight + leadingSuperBlockTolerance < superBlockCommitment.height) { + revert AttesterAlreadyCommitted(); + } + + // assign the superBlock height to the present epoch if it hasn't been assigned yet + // since any attester can submit a comittment for a superBlock height, the epoch assignment could differ + // from when the superBlock gets actually postconfirmed. This is limited by by leadingSuperBlockTolerance + if (superBlockHeightAssignedEpoch[superBlockCommitment.height] == 0) { + superBlockHeightAssignedEpoch[superBlockCommitment.height] = getPresentEpoch(); + } + + // register the attester's commitment + commitments[superBlockCommitment.height][attester] = superBlockCommitment; + + // Record first seen timestamp if not already set + TrySetCommitmentFirstSeenAt(superBlockCommitment.height, superBlockCommitment.commitment, block.timestamp); + + // increment the commitment count by stake + uint256 attesterStakeForAcceptingEpoch = getAttesterStakeForAcceptingEpoch(attester); + commitmentStake[superBlockCommitment.height][superBlockCommitment.commitment] += attesterStakeForAcceptingEpoch; + + emit SuperBlockCommitmentSubmitted( + superBlockCommitment.blockId, + superBlockCommitment.commitment, + attesterStakeForAcceptingEpoch + ); + } + function submitSuperBlockCommitment(SuperBlockCommitment memory superBlockCommitment) external { + require( + openAttestationEnabled || hasRole(TRUSTED_ATTESTER, msg.sender), + "UNAUTHORIZED_SUPERBLOCK_COMMITMENT" + ); + submitSuperBlockCommitmentForAttester(msg.sender, superBlockCommitment); + } + + function submitBatchSuperBlockCommitment(SuperBlockCommitment[] memory superBlockCommitments) public { + require( + openAttestationEnabled || hasRole(TRUSTED_ATTESTER, msg.sender), + "UNAUTHORIZED_SUPERBLOCK_COMMITMENT" + ); + for (uint256 i = 0; i < superBlockCommitments.length; i++) { + submitSuperBlockCommitmentForAttester(msg.sender, superBlockCommitments[i]); + } + } + function getValidatorCommitmentAtSuperBlockHeight( + uint256 height, + address attester + ) public view returns (SuperBlockCommitment memory) { + return commitments[height][attester]; + } + + // gets the max tolerable superBlock height + function getMaxTolerableSuperBlockHeight() public view returns (uint256) { + return lastPostconfirmedSuperBlockHeight + leadingSuperBlockTolerance; + } + /// @notice Gets the commitment submitted by an attester for a given height + function getCommitmentByAttester(uint256 height, address attester) public view returns (SuperBlockCommitment memory) { + return commitments[height][attester]; + } + + /// @notice Gets the epoch assigned to a superblock height + function getSuperBlockHeightAssignedEpoch(uint256 height) public view returns (uint256) { + return superBlockHeightAssignedEpoch[height]; + } + + // TODO use this to limit the postconfirmations on new commits ( we need to give time to attesters to submit their commitments ) + /// @notice get the timestamp when a commitment was first seen + function getCommitmentFirstSeenAt(SuperBlockCommitment memory superBlockCommitment) public view returns (uint256) { + return commitmentFirstSeenAt[superBlockCommitment.height][superBlockCommitment.commitment]; + } + + /// @notice Sets the timestamp when a commitment was first seen + function TrySetCommitmentFirstSeenAt(uint256 height, bytes32 commitment, uint256 timestamp) internal { + if (commitmentFirstSeenAt[height][commitment] != 0) { + // do not set if already set + return; + } else if (timestamp == 0) { + // no need to set if timestamp is 0. This if may be redundant though. + return; + } + commitmentFirstSeenAt[height][commitment] = timestamp; + } + + // ---------------------------------------------------------------- + // -------- 5. Postconfirmation and Rollover Management ---------- + // ---------------------------------------------------------------- + + /// @notice Gets the height of the last postconfirmed superblock + function getLastPostconfirmedSuperBlockHeight() public view returns (uint256) { + return lastPostconfirmedSuperBlockHeight; + } + + function postconfirmSuperBlocksAndRollover() public { + postconfirmAndRolloverWithAttester(msg.sender); + } + + /// @notice The current postconfirmer can postconfirm a superBlock height, given there is a supermajority of stake on a commitment + /// @notice If the current postconfirmer is live, we should not accept postconfirmations from voluntary attesters + // TODO: this will be improved, such that voluntary attesters can postconfirm but will not be rewarded before the liveness period has ended + /// @notice If the current postconfirmer is not live, we should accept postconfirmations from any attester + // TODO: this will be improved, such that the first voluntary attester to do sowill be rewarded + function postconfirmAndRolloverWithAttester(address /* attester */) internal { + + // keep ticking through postconfirmations and rollovers as long as the postconfirmer is permitted to do + // ! rewards need to be + // ! - at least the cost for gas cost of postconfirmation + // ! - reward the postconfirmer well to incentivize postconfirmation at every height + while (attemptPostconfirmOrRollover(lastPostconfirmedSuperBlockHeight + 1)) { + } + } + + // Sets the postconfirmed commitment at a given superBlock height + function setPostconfirmedCommitmentAtBlockHeight(SuperBlockCommitment memory superBlockCommitment) public { + require( + hasRole(COMMITMENT_ADMIN, msg.sender), + "SET_LAST_POSTCONFIRMED_COMMITMENT_AT_HEIGHT_IS_COMMITMENT_ADMIN_ONLY" + ); + versionedPostconfirmedSuperBlocks[postconfirmedSuperBlocksVersion][superBlockCommitment.height] = superBlockCommitment; + } + + // Forces the latest attestation by setting the superBlock height + // Note: this only safe when we are running with a single validator as it does not zero out follow-on commitments. + function forceLatestCommitment(SuperBlockCommitment memory superBlockCommitment) public { + require( + hasRole(COMMITMENT_ADMIN, msg.sender), + "FORCE_LATEST_COMMITMENT_IS_COMMITMENT_ADMIN_ONLY" + ); + setPostconfirmedCommitmentAtBlockHeight(superBlockCommitment); + } + + function getPostconfirmedCommitment(uint256 height) public view returns (SuperBlockCommitment memory) { + return versionedPostconfirmedSuperBlocks[postconfirmedSuperBlocksVersion][height]; + } + /// @dev Postconfirms a superBlock commitment. + /// @dev This function and attemptPostconfirmOrRollover() could call each other recursively, so we must ensure it's safe from re-entrancy + function _postconfirmSuperBlockCommitment(SuperBlockCommitment memory superBlockCommitment, address attester) internal { + uint256 currentAcceptingEpoch = getAcceptingEpoch(); + + // get the epoch for the superBlock commitment + // SuperBlock commitment is not in the current epoch, it cannot be postconfirmed. + // TODO: double check liveness conditions for the following critera + if (superBlockHeightAssignedEpoch[superBlockCommitment.height] != currentAcceptingEpoch) { + revert UnacceptableSuperBlockCommitment(); + } + + // ensure that the lastPostconfirmedSuperBlockHeight is exactly the superBlock height - 1 + if (lastPostconfirmedSuperBlockHeight != superBlockCommitment.height - 1) { + revert UnacceptableSuperBlockCommitment(); + } + + // Record reward points for all attesters who committed to the winning commitment + address[] memory attesters = getStakedAttestersForAcceptingEpoch(); + for (uint256 i = 0; i < attesters.length; i++) { + if (commitments[superBlockCommitment.height][attesters[i]].commitment == superBlockCommitment.commitment) { + attesterRewardPoints[currentAcceptingEpoch][attesters[i]]++; + } + } + + // Award points to postconfirmer + if (!isWithinPostconfirmerPrivilegeDuration(superBlockCommitment)) { + // if we are outside the privilege window, for the postconfirmer reward anyone who postconfirms + postconfirmerRewardPoints[currentAcceptingEpoch][attester] += 1; + } else { + // if we are within the privilege window, only award points to the postconfirmer + // TODO optimization: even if the height has been volunteer postconfirmed we need to allow that that postconfirmer gets rewards, + // TODO otherwise weak postconfirmers may could get played (rich volunteer postconfirmers pay the fees and poor postconfirmers never get any reward) + // TODO but check if this is really required game theoretically. + if (getPostconfirmer() == attester) { + postconfirmerRewardPoints[currentAcceptingEpoch][attester] += 1; + } + } + + versionedPostconfirmedSuperBlocks[postconfirmedSuperBlocksVersion][superBlockCommitment.height] = superBlockCommitment; + lastPostconfirmedSuperBlockHeight = superBlockCommitment.height; + postconfirmedBy[superBlockCommitment.height] = attester; + postconfirmedAtL1BlockHeight[superBlockCommitment.height] = block.number; + postconfirmedAtL1BlockTimestamp[superBlockCommitment.height] = block.timestamp; + + // emit the superBlock postconfirmed event + emit SuperBlockPostconfirmed( + superBlockCommitment.blockId, + superBlockCommitment.commitment, + superBlockCommitment.height + ); + } + + /// @dev nonReentrant because there is no need to reenter this function. It should be called iteratively. + /// @dev Marked on the internal method to simplify risks from complex calling patterns. This also calls an external contract. + function rollOverEpoch() internal { + // Get all attesters who earned points in the current epoch + uint256 acceptingEpoch = getAcceptingEpoch(); + address[] memory attesters = getStakedAttestersForAcceptingEpoch(); + + // reward + for (uint256 i = 0; i < attesters.length; i++) { + if (attesterRewardPoints[acceptingEpoch][attesters[i]] > 0) { + // TODO: make this configurable and set it on instance creation + uint256 reward = attesterRewardPoints[acceptingEpoch][attesters[i]] * rewardPerAttestationPoint * getAttesterStakeForAcceptingEpoch(attesters[i]); + // the staking contract is the custodian + // rewards are currently paid out from the pcp domain + stakingContract.rewardFromDomain(attesters[i], reward, moveTokenAddress); + // TODO : check if we really have to keep attesterRewardPoints per epoch, or whether we could simply delete the points here for a given attester. + } + + // Add postconfirmation rewards + if (postconfirmerRewardPoints[acceptingEpoch][attesters[i]] > 0) { + uint256 reward = postconfirmerRewardPoints[acceptingEpoch][attesters[i]] * rewardPerPostconfirmationPoint * getAttesterStakeForAcceptingEpoch(attesters[i]); + stakingContract.rewardFromDomain(attesters[i], reward, moveTokenAddress); + // TODO : check if we really have to keep postconfirmerRewardPoints per epoch, or whether we could simply delete the points here for a given postconfirmer. + // TODO also the postconfirmer list is super short. typically for a given height only the postconfirmer and at most the postconfirmer and a volunteer postconfirmer. + // TODO So this can be heavily optimized. + } + } + + stakingContract.rollOverEpoch(); + } + + /// @notice Checks, for a given superBlock commitment, if the current L1 block time is within the postconfirmer's privilege window + /// @dev The postconfirmer's privilege window is the time period when only the postconfirmer will get rewarded for postconfirmation + function isWithinPostconfirmerPrivilegeDuration(SuperBlockCommitment memory superBlockCommitment) public view returns (bool) { + if (getCommitmentFirstSeenAt(superBlockCommitment) == 0) { + return false; + } + // based on the first timestamp for the commitment we can determine if the postconfirmer has been live sufficiently recently + // use getCommitmentFirstSeenAt, and the mappings + if (getCommitmentFirstSeenAt(superBlockCommitment) + + getMinCommitmentAgeForPostconfirmation() + + getPostconfirmerPrivilegeDuration() + < block.timestamp) { + return false; + } + return true; + } + + /// @dev it is possible if the accepting epoch is behind the presentEpoch that heights dont obtain enough votes in the assigned epoch. + /// @dev Moreover, due to the leadingBlockTolerance, the assigned epoch for a height could be ahead of the actual epoch. + /// @dev solution is to move to the next epoch and count votes there + function attemptPostconfirmOrRollover(uint256 superBlockHeight) internal returns (bool) { + uint256 superBlockEpoch = superBlockHeightAssignedEpoch[superBlockHeight]; + if (getLastPostconfirmedSuperBlockHeight() == 0) { + // if there is no postconfirmed superblock we are at genesis + } else { + // ensure that the superBlock height is equal or above the lastPostconfirmedSuperBlockHeight + uint256 previousSuperBlockEpoch = superBlockHeightAssignedEpoch[superBlockHeight-1]; + if (superBlockEpoch < previousSuperBlockEpoch ) { + address[] memory stakedAttesters = getStakedAttestersForAcceptingEpoch(); + // if there is at least one commitment at this superBlock height, we need to update once + for (uint256 i = 0; i < stakedAttesters.length; i++) { + if (commitments[superBlockHeight][stakedAttesters[i]].height != 0) { + superBlockHeightAssignedEpoch[superBlockHeight] = previousSuperBlockEpoch; + break; + } + } + superBlockEpoch = previousSuperBlockEpoch; + } + } + + // if the accepting epoch is far behind the superBlockEpoch (which is determined by commitments measured in L1 block time), then the protocol was not live for a while + // We keep rolling over the epoch (i.e. update stakes) until we catch up with the present epoch + while (getAcceptingEpoch() < superBlockEpoch) { + // TODO only permit rollover after some liveness criteria for the postconfirmer, as this is related to the reward model (rollovers should be rewarded) + rollOverEpoch(); + } + + // TODO only permit postconfirmation after some liveness criteria for the postconfirmer, as this is related to the reward model (postconfirmation should be rewarded) + + uint256 supermajority = (2 * getTotalStake(superBlockEpoch)) / 3 + 1; + address[] memory attesters = getStakedAttestersForAcceptingEpoch(); + + // iterate over the attester set + // TODO: randomize the order in which we check the attesters, which helps against spam of commitments. + // TODO: it may be more elegant to go through the commitments rather than the attesters.. + bool successfulPostconfirmation = false; + for (uint256 i = 0; i < attesters.length; i++) { + address attester = attesters[i]; + SuperBlockCommitment memory superBlockCommitment = commitments[superBlockHeight][attester]; + // check if the commitment has committed to the correct superBlock height + // TODO: possibly this is not needed and we can remove the height from the commitment? + if (superBlockCommitment.height != superBlockHeight) continue; + + // check the total stake on the commitment + uint256 totalStakeOnCommitment = commitmentStake[superBlockCommitment.height][superBlockCommitment.commitment]; + + if (totalStakeOnCommitment >= supermajority) { + // Check if enough time has passed since commitment was first seen + // if not enough time has passed, then no postconfirmation at this height can yet happen + uint256 firstSeen = getCommitmentFirstSeenAt(superBlockCommitment); + // we should jump out of the for loop entirely + if (block.timestamp < firstSeen + minCommitmentAgeForPostconfirmation) break; + + _postconfirmSuperBlockCommitment(superBlockCommitment, msg.sender); + successfulPostconfirmation = true; + + // TODO: for rewards we have to run through all the attesters, as we need to acknowledge that they get rewards. + + // TODO: if the attester is the current postconfirmer, we need to record that the postconfirmer has shown liveness. + // TODO: this liveness needs to be discoverable by isWithinPostconfirmerPrivilegeDuration() + + return true; + } + } + // if there was no supermajority for any commitment at that height it means that the attesters were not sufficiently live + // we rollover the epoch to give the next attesters a chance + if (!successfulPostconfirmation && getPresentEpoch() > getAcceptingEpoch()) { + rollOverEpoch(); + return true; // we have to retry the postconfirmation at the next epoch again + } + return false; + } + + // ---------------------------------------------------------------- + // -------- 6. Stake, Rewards & Slashing Mechanisms -------------- + // ---------------------------------------------------------------- + + /// @notice Gets the stake for a given tuple (custodian, attester) at a given epoch + function getStake( + uint256 epoch, + address custodian, + address attester + ) public view returns (uint256) { + return + stakingContract.getStake( + address(this), + epoch, + custodian, + attester + ); + } + + /// @notice Gets the stake for a given tuple (custodian, attester) at the accepting epoch + function getStakeForAcceptingEpoch( + address custodian, + address attester + ) public view returns (uint256) { + return getStake(getAcceptingEpoch(), custodian, attester); + } + + /// @notice Gets the stake for a given attester at a given epoch + // TODO: memorize this (<-- ? as in create a mapping?) + function getAttesterStake( + uint256 epoch, + address attester + ) public view returns (uint256) { + address[] memory custodians = stakingContract.getRegisteredCustodians( + address(this) + ); + uint256 totalStake = 0; + for (uint256 i = 0; i < custodians.length; i++) { + // for now, each custodian has weight of 1 + totalStake += getStake(epoch, custodians[i], attester); + } + return totalStake; + } + + /// @notice Gets the stake for a given attester at the accepting epoch + function getAttesterStakeForAcceptingEpoch( + address attester + ) public view returns (uint256) { + return getAttesterStake(getAcceptingEpoch(), attester); + } + + /// @notice Gets the stake for a given custodian for a given epoch + function getCustodianStake( + uint256 epoch, + address custodian + ) public view returns (uint256) { + return + stakingContract.getCustodianStake( + address(this), // domain + epoch, + custodian + ); + } + + function getTotalStake( + uint256 epoch + ) public view returns (uint256) { + // we can either use the attesterStake or the custodianStake + // the sums of attesterStake and custodianStake should equal the same value + address[] memory custodians = stakingContract.getRegisteredCustodians( + address(this) + ); + uint256 totalStake = 0; + for (uint256 i = 0; i < custodians.length; i++) { + // for now, each custodian has weight of 1 + totalStake += getCustodianStake(epoch, custodians[i]); + } + return totalStake; + } + + // gets the total stake for the current epoch for a given custodian + function getCustodianStakeForAcceptingEpoch( + address custodian + ) public view returns (uint256) { + return getCustodianStake(getAcceptingEpoch(), custodian); + } + + function getTotalStakeForAcceptingEpoch() + public + view + returns (uint256) + { + return getTotalStake(getAcceptingEpoch()); + } + + function setRewardPerAttestationPoint(uint256 rewardPerPoint) public onlyRole(COMMITMENT_ADMIN) { + rewardPerAttestationPoint = rewardPerPoint; + } + + function setRewardPerPostconfirmationPoint(uint256 rewardPerPoint) public onlyRole(COMMITMENT_ADMIN) { + rewardPerPostconfirmationPoint = rewardPerPoint; + } + + /// @notice Gets the reward points for an attester in a given epoch + function getAttesterRewardPoints(uint256 epoch, address attester) public view returns (uint256) { + return attesterRewardPoints[epoch][attester]; + } + + /// @notice Gets the reward points for a postconfirmer in a given epoch + function getPostconfirmerRewardPoints(uint256 epoch, address postconfirmer) public view returns (uint256) { + return postconfirmerRewardPoints[epoch][postconfirmer]; + } + + /// @notice Gets the attesters who have stake in the current accepting epoch + function getStakedAttestersForAcceptingEpoch() public view returns (address[] memory) { + return stakingContract.getStakedAttestersForAcceptingEpoch(address(this)); + } + + function isCommitted(uint256 height) external view returns (bool) { + return commitments[height][msg.sender].height != 0; + } + + function isPostconfirmed(uint256 height) external view returns (bool) { + return versionedPostconfirmedSuperBlocks[postconfirmedSuperBlocksVersion][height].height != 0; + } + +} diff --git a/protocol/pcp/dlu/eth/contracts/src/settlement/PCPStorage.sol b/protocol/pcp/dlu/eth/contracts/src/settlement/PCPStorage.sol new file mode 100644 index 00000000..2da15de1 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/settlement/PCPStorage.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {MovementStaking, IMovementStaking} from "../staking/MovementStaking.sol"; + +contract PCPStorage { + + IMovementStaking public stakingContract; + + // The MOVE token address, which is the primary custodian for rewards in the staking contract + address public moveTokenAddress; + + // the number of superBlocks that can be submitted ahead of the lastPostconfirmedSuperBlockHeight + // this allows for things like batching to take place without some attesters locking down the attester set by pushing too far ahead + // ? this could be replaced by a 2/3 stake vote on the superBlock height to epoch assignment + // ? however, this protocol becomes more complex as you to take steps to ensure that... + // ? 1. superBlock heights have a non-decreasing mapping to epochs + // ? 2. Votes get accumulated reasonable near the end of the epoch (i.e., your vote is cast for the epoch you vote fore and the next) + // ? if howevever, you simply allow a race with the tolerance below, both of these are satisfied without the added complexity + // TODO the above explanation is not clear and needs to be rephrased or further explained + // TODO unless this is clarified or becomes relevant in the future, this comment should be removed + uint256 public leadingSuperBlockTolerance; + + // track the last postconfirmed superBlock height, so that we can require superBlocks are submitted in order and handle staking effectively + uint256 public lastPostconfirmedSuperBlockHeight; + + /// Postconfirmer term time in seconds. The postconfirmer remains the same for postconfirmerDuration period. + // The Postconfirmer term can be minimal, but it should not be too small as the postconfirmer should have some time + // to prepare and post L1-transactions that will start the validation of attestations. + uint256 public postconfirmerDuration; + + /// @notice Minimum time that must pass before a commitment can be postconfirmed + uint256 public minCommitmentAgeForPostconfirmation; + + /// @notice Max time the postconfirmer can be non-reactive to an honest superBlock commitment + uint256 public postconfirmerPrivilegeDuration; + + // TODO i added these param descriptions. are these correct? + /// Struct to store block commitment details + /// @param height The height of the block + /// @param commitment The hash of the committment + /// @param blockId The unique identifier of the block (hash of the block) + struct SuperBlockCommitment { + // currently, to simplify the api, we'll say 0 is uncommitted all other numbers are legitimate heights + uint256 height; + bytes32 commitment; + bytes32 blockId; + } + + // map each superBlock height to an epoch + mapping(uint256 superBlockHeight => uint256 epoch) public superBlockHeightAssignedEpoch; + + // track each commitment from each attester for each superBlock height + mapping(uint256 superBlockHeight => mapping(address attester => SuperBlockCommitment)) public commitments; + + // track the total stake accumulate for each commitment for each superBlock height + mapping(uint256 superBlockHeight => mapping(bytes32 commitement => uint256 stake)) public commitmentStake; + + // track when each commitment was first seen for each superBlock height + mapping(uint256 superBlockHeight => mapping(bytes32 commitment => uint256 timestamp)) public commitmentFirstSeenAt; + + // Track which attester postconfirmed a given superBlock height + mapping(uint256 superBlockHeight => address attester) public postconfirmedBy; + + // Track if postconfirmer postconfirmed a given superBlock height + // TODO this may be redundant due to one of the mappings below + mapping(uint256 superBlockHeight => bool) public postconfirmedByPostconfirmer; + + // Track the L1Block height when a superBlock height was postconfirmed + mapping(uint256 superBlockHeight => uint256 L1BlockHeight) public postconfirmedAtL1BlockHeight; + + // TODO: either the L1Block timestamp or L1Block height should be tracked, both are not needed, but keep both until we know which one is not needed + // Track the L1Block timestamp when a superBlock height was postconfirmed + mapping(uint256 superBlockHeight => uint256 L1BlockTimestamp) public postconfirmedAtL1BlockTimestamp; + + // Track the L1Block height when a superBlock height was postconfirmed by the postconfirmer + mapping(uint256 superBlockHeight => uint256 L1BlockHeight) public postconfirmedAtL1BlockHeightByPostconfirmer; + + // map superBlock height to postconfirmed superBlock hash + mapping(uint256 superBlockHeight => SuperBlockCommitment) public postconfirmedSuperBlocks; + + // whether we allow open attestation + bool public openAttestationEnabled; + + // versioned scheme for postconfirmed superBlocks + mapping(uint256 => mapping(uint256 superBlockHeight => SuperBlockCommitment)) public versionedPostconfirmedSuperBlocks; + uint256 public postconfirmedSuperBlocksVersion; + + // track reward points for attesters + mapping(uint256 epoch => mapping(address attester => uint256 points)) public attesterRewardPoints; + + // track reward points for postconfirmers + mapping(uint256 epoch => mapping(address postconfirmer => uint256 points)) public postconfirmerRewardPoints; + + // track the reward per point for attesters + uint256 public rewardPerAttestationPoint; + + // track the reward per point for postconfirmers + uint256 public rewardPerPostconfirmationPoint; + + uint256[45] internal __gap; // Reduced by 1 for new mapping + +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/src/settlement/interfaces/IPCP.sol b/protocol/pcp/dlu/eth/contracts/src/settlement/interfaces/IPCP.sol new file mode 100644 index 00000000..be5c703b --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/settlement/interfaces/IPCP.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {PCPStorage} from "../PCPStorage.sol"; + +interface IPCP { + + event SuperBlockPostconfirmed( + bytes32 indexed blockHash, + bytes32 stateCommitment, + uint256 height + ); + event SuperBlockCommitmentSubmitted( + bytes32 indexed blockHash, + bytes32 stateCommitment, + uint256 attesterStake + ); + error UnacceptableSuperBlockCommitment(); + error AttesterAlreadyCommitted(); + + /// @notice Gets the epoch duration + function getEpochDuration() external view returns (uint256); + + /// @notice Gets the postconfirmer duration + function getPostconfirmerDuration() external view returns (uint256); + + /// @notice Gets the postconfirmer + function getPostconfirmer() external view returns (address); + + /// @notice submit a superblock commitment + function submitSuperBlockCommitment(PCPStorage.SuperBlockCommitment memory commitment) external; + + /// @notice get the last postconfirmed superblock height + function getLastPostconfirmedSuperBlockHeight() external view returns (uint256); + + /// @notice get the accepting epoch + function getAcceptingEpoch() external view returns (uint256); + + /// @notice get the present epoch + function getPresentEpoch() external view returns (uint256); + + /// @notice get the postconfirmed commitment for a given height + function getPostconfirmedCommitment(uint256 height) external view returns (PCPStorage.SuperBlockCommitment memory); + + /// @notice postconfirm superblocks and rollover + function postconfirmSuperBlocksAndRollover() external; + + /// @notice Sets the accepting epoch to a new value (must be higher than current) + function setAcceptingEpoch(uint256 newEpoch) external; + + /// @notice The role that allows attesters to submit commitments + function TRUSTED_ATTESTER() external pure returns (bytes32); + + /// @notice The role that allows the commitment admin to set the accepting epoch + function COMMITMENT_ADMIN() external pure returns (bytes32); +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/src/settlement/settlement/BaseSettlement.sol b/protocol/pcp/dlu/eth/contracts/src/settlement/settlement/BaseSettlement.sol new file mode 100644 index 00000000..867ee5bb --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/settlement/settlement/BaseSettlement.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; + +contract BaseSettlement is + Initializable, + AccessControlUpgradeable, + UUPSUpgradeable +{ + /** + * @dev Initialize the contract + */ + function initialize() public virtual initializer { + __BaseSettlement_init(); + } + + function __BaseSettlement_init() internal onlyInitializing { + __BaseSettlement_init_unchained(); + } + + function __BaseSettlement_init_unchained() internal onlyInitializing { + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + } + + /** + * @dev Authorize an upgrade + * @param newImplementation The address of the new implementation + */ + function _authorizeUpgrade( + address newImplementation + ) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} +} diff --git a/protocol/pcp/dlu/eth/contracts/src/staking/MovementStaking.sol b/protocol/pcp/dlu/eth/contracts/src/staking/MovementStaking.sol new file mode 100644 index 00000000..475db281 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/staking/MovementStaking.sol @@ -0,0 +1,716 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; +import {BaseStaking} from "./base/BaseStaking.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {ICustodianToken} from "../token/custodian/CustodianToken.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {MovementStakingStorage, EnumerableSet} from "./MovementStakingStorage.sol"; +import {IMovementStaking} from "./interfaces/IMovementStaking.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +// TODO Error: "Contract "MovementStaking" should be marked as abstract.(3656)" +contract MovementStaking is + MovementStakingStorage, + IMovementStaking, + BaseStaking, + ReentrancyGuard +{ + using EnumerableSet for EnumerableSet.AddressSet; + + /// @notice Error thrown when trying to get epoch but duration not set + error EpochDurationNotSet(); + + function initialize(IERC20 _token) public initializer { + __BaseStaking_init_unchained(); + token = _token; + } + + /// @notice Registers a domain and sets the epoch duration + function registerDomain( + uint256 epochDuration, + address[] calldata custodians + ) external nonReentrant { + address domain = msg.sender; + epochDurationByDomain[domain] = epochDuration; + + for (uint256 i = 0; i < custodians.length; i++) { + registeredCustodiansByDomain[domain].add(custodians[i]); + } + } + + /// @notice Gets all custodians who are registered for the given domain + function getRegisteredCustodians( + address domain + ) public view returns (address[] memory) { + // todo: we probably want to figure out a better API which still allows domains to interpret custodians as they see fit + address[] memory custodians = new address[]( + registeredCustodiansByDomain[domain].length() + ); + for (uint256 i = 0; i < registeredCustodiansByDomain[domain].length(); i++) { + custodians[i] = registeredCustodiansByDomain[domain].at(i); + } + return custodians; + } + + /// @notice Gets all attesters who are registered for the given domain + function getRegisteredAttesters( + address domain + ) public view returns (address[] memory) { + address[] memory attesters = new address[]( + registeredAttestersByDomain[domain].length() + ); + for (uint256 i = 0; i < registeredAttestersByDomain[domain].length(); i++) { + attesters[i] = registeredAttestersByDomain[domain].at(i); + } + return attesters; + } + + /// @notice Gets all attesters who have stake in the current accepting epoch + function getStakedAttestersForAcceptingEpoch( + address domain + ) public view returns (address[] memory) { + // First get all registered attesters + uint256 totalAttesters = registeredAttestersByDomain[domain].length(); + + // Count attesters with stake + uint256 activeAttesterCount = 0; + for (uint256 i = 0; i < totalAttesters; i++) { + address attester = registeredAttestersByDomain[domain].at(i); + if (getAttesterStakeForAcceptingEpoch(domain, attester) > 0) { + activeAttesterCount++; + } + } + + // Create array of active attesters + address[] memory activeAttesters = new address[](activeAttesterCount); + uint256 activeIndex = 0; + for (uint256 i = 0; i < totalAttesters; i++) { + address attester = registeredAttestersByDomain[domain].at(i); + if (getAttesterStakeForAcceptingEpoch(domain, attester) > 0) { + activeAttesters[activeIndex] = attester; + activeIndex++; + } + } + + return activeAttesters; + } + + /// @notice Gets the epoch duration for the given domain + function getEpochDuration(address domain) public view returns (uint256) { + return epochDurationByDomain[domain]; + } + + /// @notice Sets the accepting epoch for a given domain + /// @param domain The domain address + /// @param newEpoch The new accepting epoch value + function setAcceptingEpoch(address domain, uint256 newEpoch) external { + require(newEpoch <= getEpochByL1BlockTime(address(domain)), "NEW_EPOCH_MUST_BE_LESS_THAN_PRESENT_EPOCH"); + require(newEpoch > getAcceptingEpoch(domain), "NEW_EPOCH_MUST_BE_HIGHER_THAN_CURRENT_EPOCH"); + require(msg.sender == domain, "UNAUTHORIZED"); + currentAcceptingEpochByDomain[domain] = newEpoch; + } + + function acceptGenesisCeremony() public nonReentrant { + address domain = msg.sender; + + if (domainGenesisAccepted[domain]) revert GenesisAlreadyAccepted(); + domainGenesisAccepted[domain] = true; + + assert(epochDurationByDomain[domain] > 0); + + // roll over from 0 (genesis) to current epoch by L1Block time + currentAcceptingEpochByDomain[domain] = getEpochByL1BlockTime(domain); + + for (uint256 i = 0; i < registeredAttestersByDomain[domain].length(); i++) { + address attester = registeredAttestersByDomain[domain].at(i); + + for (uint256 j = 0; j < registeredCustodiansByDomain[domain].length(); j++) { + address custodian = registeredCustodiansByDomain[domain].at(j); + + // get the genesis stake for the attester + uint256 attesterStake = getStake( + domain, + 0, + custodian, + attester + ); + + // roll over the genesis stake to the current epoch + // except if the current epoch is 0, because we are already in the first epoch + if (getAcceptingEpoch(domain) > 0) { + if (getAcceptingEpoch(domain) > 0) { + _addStake( + domain, + getAcceptingEpoch(domain), + custodian, + attester, + attesterStake + ); + } + } + } + } + } + + function _addStake( + address domain, + uint256 epoch, + address custodian, + address attester, + uint256 amount + ) internal { + stakesByDomainEpochCustodianAttester[domain][epoch][custodian][attester] += amount; + stakesByDomainEpochCustodian[domain][epoch][custodian] += amount; + } + + function _removeStake( + address domain, + uint256 epoch, + address custodian, + address attester, + uint256 amount + ) internal { + stakesByDomainEpochCustodianAttester[domain][epoch][custodian][attester] -= amount; + stakesByDomainEpochCustodian[domain][epoch][custodian] -= amount; + } + + function _addUnstake( + address domain, + uint256 epoch, + address custodian, + address attester, + uint256 amount + ) internal { + unstakesByDomainEpochCustodianAttester[domain][epoch][custodian][attester] += amount; + } + + function _removeUnstake( + address domain, + uint256 epoch, + address custodian, + address attester, + uint256 amount + ) internal { + unstakesByDomainEpochCustodianAttester[domain][epoch][custodian][attester] -= amount; + } + + function _setUnstake( + address domain, + uint256 epoch, + address custodian, + address attester, + uint256 amount + ) internal { + unstakesByDomainEpochCustodianAttester[domain][epoch][custodian][attester] = amount; + } + + // gets the would be epoch for the current L1-block time. + // TODO: for liveness of the protocol it should be possible that newer epochs can accept L2-block-batches that are before the current epoch (IF the previous epoch has stopped being live) + function getEpochByL1BlockTime(address domain) public view returns (uint256) { + if (epochDurationByDomain[domain] == 0) revert EpochDurationNotSet(); + return block.timestamp / epochDurationByDomain[domain]; + } + + // gets the current epoch up to which superBlocks have been accepted + function getAcceptingEpoch(address domain) public view returns (uint256) { + return currentAcceptingEpochByDomain[domain]; + } + + /// @notice Gets the next accepting epoch number + /// @dev Special handling for genesis state (epoch 0): + /// @dev If getAcceptingEpoch(domain) == 0, returns 0 to stay in genesis until ceremony completes + function getNextAcceptingEpochWithException(address domain) public view returns (uint256) { + return getAcceptingEpoch(domain) == 0 ? 0 : getAcceptingEpoch(domain) + 1; + } + + /// @notice Gets the next present epoch number + /// @dev Special handling for genesis state (accepting epoch 0): + /// @dev If getAcceptingEpoch(domain) == 0, returns 0 to stay in genesis until ceremony completes + function getNextPresentEpochWithException(address domain) public view returns (uint256) { + return getAcceptingEpoch(domain) == 0 ? 0 : getEpochByL1BlockTime(domain) + 1; + } + + /// @dev gets the stake for a given epoch for a given {attester,custodian} tuple + function getStake( + address domain, + uint256 epoch, + address custodian, + address attester + ) public view returns (uint256) { + return stakesByDomainEpochCustodianAttester[domain][epoch][custodian][attester]; + } + + /// @dev gets the stake for the accepting epoch for a given {attester,custodian} tuple + function getStakeForAcceptingEpoch( + address domain, + address custodian, + address attester + ) public view returns (uint256) { + return + getStake( + domain, + getAcceptingEpoch(domain), + custodian, + attester + ); + } + + /// @dev gets the unstake for a given epoch for a given {attester,custodian} tuple + + function getUnstake( + address domain, + uint256 epoch, + address custodian, + address attester + ) public view returns (uint256) { + return unstakesByDomainEpochCustodianAttester[domain][epoch][custodian][attester]; + } + + /// @dev gets the unstake for the accepting epoch for a given {attester,custodian} tuple + function getUnstakeForAcceptingEpoch( + address domain, + address custodian, + address attester + ) public view returns (uint256) { + return + getUnstake( + domain, + getAcceptingEpoch(domain), + custodian, + attester + ); + } + + /// @dev gets the total stake for a given epoch for a given custodian + function getCustodianStake( + address domain, + uint256 epoch, + address custodian + ) public view returns (uint256) { + return stakesByDomainEpochCustodian[domain][epoch][custodian]; + } + + /// @dev gets the total stake for the accepting epoch for a given custodian + function getCustodianStakeForAcceptingEpoch( + address domain, + address custodian + ) public view returns (uint256) { + return + getCustodianStake(domain, getAcceptingEpoch(domain), custodian); + } + + function getAttesterStake(address domain, uint256 epoch, address attester) public view returns (uint256) { + uint256 attesterStake = 0; + for (uint256 i = 0; i < registeredCustodiansByDomain[domain].length(); i++) { + attesterStake += getStake(domain, epoch, registeredCustodiansByDomain[domain].at(i), attester); + } + return attesterStake; + } + + function getAttesterStakeForAcceptingEpoch(address domain, address attester) public view returns (uint256) { + return getAttesterStake(domain, getAcceptingEpoch(domain), attester); + } + + /// @notice Stakes for the next epoch + function stake( + address domain, + IERC20 custodian, + uint256 amount + ) external onlyRole(WHITELIST_ROLE) nonReentrant { + // add the attester to the list of attesters + registeredAttestersByDomain[domain].add(msg.sender); + + // add the custodian to the list of custodians + // registeredCustodiansByDomain[domain].add(address(custodian)); // Note: we don't want this to take place by default as it opens up an opportunity for a gas attack by generating a large number of custodians for the domain contract to track + + // check the balance of the token before transfer + uint256 balanceBefore = token.balanceOf(address(this)); + + // transfer the stake to the staking contract + // if the transfer is not using a custodian, the custodian is the token itself + // hence this works + // ! In general with this pattern, the custodian must be careful about not over-approving the token. + custodian.transferFrom(msg.sender, address(this), amount); + + // require that the balance of the actual token has increased by the amount + if (token.balanceOf(address(this)) != balanceBefore + amount) + revert CustodianTransferAmountMismatch(); + + // set the attester to stake for the next accepting epoch + _addStake( + domain, + // TODO should this not be getNextAcceptingEpochWithException(domain)? + // getNextPresentEpochWithException(domain), + getNextAcceptingEpochWithException(domain), + address(custodian), + msg.sender, + amount + ); + + // Let the world know that the attester has staked + emit AttesterStaked( + domain, + getNextAcceptingEpochWithException(domain), + address(custodian), + msg.sender, + amount + ); + } + + // unstakes an amount for the next epoch + function unstake( + address domain, + address custodian, + uint256 amount + ) external onlyRole(WHITELIST_ROLE) nonReentrant { + // indicate that we are going to unstake this amount in the next epoch + // ! this doesn't actually happen until we roll over the epoch + // note: by tracking in the next epoch we need to make sure when we roll over an epoch we check the amount rolled over from stake by the unstake in the next epoch + _addUnstake( + domain, + // TODO should this not be getNextAcceptingEpochWithException(domain)? + // getNextPresentEpochWithException(domain), + getNextAcceptingEpochWithException(domain), + custodian, + msg.sender, + amount + ); + + emit AttesterUnstaked( + domain, + getNextAcceptingEpochWithException(domain), + custodian, + msg.sender, + amount + ); + } + + // rolls over the stake and unstake for a given attester + function _rollOverAttester( + address domain, + uint256 epochNumber, + address custodian, + address attester + ) internal { + // the amount of stake rolled over is stake[currentAcceptingEpoch] - unstake[nextEpoch] + uint256 stakeAmount = getStake( + domain, + epochNumber, + custodian, + attester + ); + uint256 unstakeAmount = getUnstake( + domain, + epochNumber + 1, + custodian, + attester + ); + if (unstakeAmount > stakeAmount) { + unstakeAmount = stakeAmount; + } + uint256 remainder = stakeAmount - unstakeAmount; + + _addStake(domain, epochNumber + 1, custodian, attester, remainder); + + // the unstake is paid out from the staking contract (all stakes are collected in the staking contract) + // note: this is the only place this takes place + // there's not risk of double payout, so long as rollOverattester is only called once per epoch + // this should be guaranteed by the implementation, but we may want to create a withdrawal mapping to ensure this + if (unstakeAmount > 0) { + _payAttesterFromContractDirectly(address(this), attester, custodian, unstakeAmount); + } + + emit AttesterEpochRolledOver( + attester, + epochNumber, + custodian, + stakeAmount, + unstakeAmount + ); + } + + function _rollOverEpoch(address domain, uint256 epochNumber) internal { + // iterate over the attester set + // * complexity here can be reduced by actually mapping attesters to their token and custodian + for (uint256 i = 0; i < registeredAttestersByDomain[domain].length(); i++) { + address attester = registeredAttestersByDomain[domain].at(i); + + for (uint256 j = 0; j < registeredCustodiansByDomain[domain].length(); j++) { + address custodian = registeredCustodiansByDomain[domain].at(j); + + _rollOverAttester(domain, epochNumber, custodian, attester); + } + } + + // increment the current epoch + currentAcceptingEpochByDomain[domain] = epochNumber + 1; + + emit EpochRolledOver(domain, epochNumber); + } + + function rollOverEpoch() external { + _rollOverEpoch(msg.sender, getAcceptingEpoch(msg.sender)); + } + + /** + * @dev Slash an attester's stake + * @param domain The domain of the attester + * @param epoch The epoch in which the slash is attempted + * @param custodian The custodian of the token + * @param attester The attester to slash + * @param amount The amount to slash + */ + function _slashStake( + address domain, + uint256 epoch, + address custodian, + address attester, + uint256 amount + ) internal { + // stake slash will always target this epoch + uint256 targetEpoch = epoch; + uint256 stakeForEpoch = getStake( + domain, + targetEpoch, + custodian, + attester + ); + + // deduct the amount from the attester's stake, account for underflow + if (stakeForEpoch < amount) { + _removeStake( + domain, + targetEpoch, + custodian, + attester, + stakeForEpoch + ); + } else { + _removeStake(domain, targetEpoch, custodian, attester, amount); + } + } + + /** + * @dev Slash an attester's unstake + * @param domain The domain of the attester + * @param epoch The epoch in which the slash is attempted, i.e., epoch - 1 of the epoch where the unstake will be removed + * @param custodian The custodian of the token + * @param attester The attester to slash + */ + function _slashUnstake( + address domain, + uint256 epoch, + address custodian, + address attester + ) internal { + // unstake slash will always target the next epoch + uint256 stakeForEpoch = getStake( + domain, + epoch, + custodian, + attester + ); + uint256 targetEpoch = epoch + 1; + uint256 unstakeForEpoch = getUnstake( + domain, + targetEpoch, + custodian, + attester + ); + + if (unstakeForEpoch > stakeForEpoch) { + // if you are trying to unstake more than is staked + + // set the unstake to the maximum possible amount + _setUnstake( + domain, + targetEpoch, + custodian, + attester, + stakeForEpoch + ); + } + } + + function slash( + address[] calldata custodians, + address[] calldata attesters, + uint256[] calldata amounts, + uint256[] calldata refundAmounts + ) public nonReentrant { + for (uint256 i = 0; i < attesters.length; i++) { + // issue a refund that is the min of the stake balance, the amount to be slashed, and the refund amount + // this is to prevent a Domain from trying to have this contract pay out more than has been staked + uint256 refundAmount = Math.min( + getStake( + msg.sender, + getAcceptingEpoch(attesters[i]), + custodians[i], + attesters[i] + ), + Math.min(amounts[i], refundAmounts[i]) + ); + _payAttesterWithSelector( + address(this), // this contract is paying the attester, it should always have enough balance + attesters[i], + custodians[i], + refundAmount + ); + + // slash both stake and unstake so that the weight of the attester is reduced and they can't withdraw the unstake at the next epoch + _slashStake( + msg.sender, + getAcceptingEpoch(msg.sender), + custodians[i], + attesters[i], + amounts[i] + ); + + _slashUnstake( + msg.sender, + getAcceptingEpoch(msg.sender), + custodians[i], + attesters[i] + ); + } + } + + /// @notice Routes attester payment to appropriate function based on conditions + /// @param from The address initiating the payment (this contract or external) + /// @param attester The address receiving the payment + /// @param custodian The custodian token address (or base token if direct payment) + /// @param amount The amount to pay + function _payAttesterWithSelector( + address from, + address attester, + address custodian, + uint256 amount + ) internal { + if (from == address(this)) { + // this contract is paying the attester + if (address(token) == custodian) { + // if there isn't a custodian, just transfer the base token + _payAttesterFromContractDirectly(from, attester, custodian, amount); + } else { + // approve the custodian to spend the base token and purchase custodial token + _payAttesterFromContractViaCustodian(from, attester, custodian, amount); + } + } else { + // This can be used by the domain to pay the attester, but it's just as convenient for the domain to reward the attester directly. + // This is, currently, there is no added benefit of issuing a reward through this contract--other than Riccardian clarity. + + // somebody else is trying to pay the attester, e.g., the domain + if (address(token) == custodian) { + // if there isn't a custodian, transfer from the sender + _payAttesterFromExternalDirectly(from, attester, custodian, amount); + } else { + // purchase the custodial token for the attester from sender + _payAttesterFromExternalViaCustodian(from, attester, custodian, amount); + } + } + } + + /// @notice Contract pays attester directly with base token + // if there isn't a custodian, just transfer the base token + function _payAttesterFromContractDirectly(address from, address attester, address custodian, uint256 amount) internal { + require(from == address(this), "Only contract can call directly 1"); + require(address(token) == custodian, "Must use base token"); + token.transfer(attester, amount); + } + + /// @notice Contract pays attester through custodian token + function _payAttesterFromContractViaCustodian(address from, address attester, address custodian, uint256 amount) internal { + require(from == address(this), "Only contract can call directly 2"); + require(address(token) != custodian, "Must use custodian token"); + token.approve(custodian, amount); + ICustodianToken(custodian).buyCustodialToken(attester, amount); + } + + /// @notice External account pays attester directly with base token + // somebody else is trying to pay the attester, e.g., the domain + // This can be used by the domain to pay the attester, but it's just as convenient for the domain to reward the attester directly. + // This is, currently, there is no added benefit of issuing a reward through this contract--other than Riccardian clarity. + function _payAttesterFromExternalDirectly(address from, address attester, address custodian, uint256 amount) internal { + require(msg.sender != address(this), "Only external calls"); + require(address(token) == custodian, "Must use base token"); + token.transferFrom(from, attester, amount); + } + + /// @notice External account pays attester through custodian token + function _payAttesterFromExternalViaCustodian(address from, address attester, address custodian, uint256 amount) internal { + require(msg.sender != address(this), "Only external calls"); + require(address(token) != custodian, "Must use custodian token"); + ICustodianToken(custodian).buyCustodialTokenFrom(from, attester, amount); + } + + /// @notice Domain rewards an attester + /// @param attester The attester to reward + /// @param amount The amount to reward + /// @param custodian The custodian of the token from which to reward the attester, here it is the domain + function rewardFromDomain( + address attester, + uint256 amount, + address custodian // here it is the domain + ) public nonReentrant { + _payAttesterFromExternalDirectly(msg.sender, attester, custodian, amount); + } + + /// @notice An array of custodians reward an array of attesters + /// @param attesters The attesters to reward + /// @param amounts The amounts to reward + /// @param custodians The custodians of the token from which to reward the attesters + function rewardArray( + address[] calldata attesters, + uint256[] calldata amounts, + address[] calldata custodians + ) public nonReentrant { + // note: you may want to apply this directly to the attester's stake if the Domain sets an automatic restake policy + for (uint256 i = 0; i < attesters.length; i++) { + _payAttesterFromExternalDirectly(msg.sender, attesters[i], custodians[i], amounts[i]); + } + } + + + + /// @notice Whitelist an address to be used as an attester or custodian. + /// @notice Whitelisting means that the address is allowed to stake and unstake + function whitelistAddress( + address addr + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + grantRole(WHITELIST_ROLE, addr); + } + + function removeAddressFromWhitelist( + address addr + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + revokeRole(WHITELIST_ROLE, addr); + } + + /// @notice Computes total stake across all custodians and attesters for an epoch + function computeAllStake( + address domain, + uint256 epoch + ) public view returns (uint256) { + address[] memory custodians = getRegisteredCustodians(domain); + address[] memory attesters = getRegisteredAttesters(domain); + uint256 totalStake = 0; + + for (uint256 i = 0; i < custodians.length; i++) { + for (uint256 j = 0; j < attesters.length; j++) { + totalStake += getStake(domain, epoch, custodians[i], attesters[j]); + } + } + return totalStake; + } + + /// @notice Computes total stake across all custodians and attesters for the current accepting epoch + /// @param domain The domain to compute total stake for + function computeAllStakeForAcceptingEpoch( + address domain + ) public view returns (uint256) { + return computeAllStake(domain, getAcceptingEpoch(domain)); + } + +} diff --git a/protocol/pcp/dlu/eth/contracts/src/staking/MovementStakingStorage.sol b/protocol/pcp/dlu/eth/contracts/src/staking/MovementStakingStorage.sol new file mode 100644 index 00000000..7e84282e --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/staking/MovementStakingStorage.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +contract MovementStakingStorage { + + using SafeERC20 for IERC20; + using EnumerableSet for EnumerableSet.AddressSet; + + // the token used for staking + IERC20 public token; + + /// @dev the duration of each epoch in seconds. + /// The stakes are organized into epochs, and where epochs are measured in L1-block timestamps. + mapping(address domain => uint256 epochDuration) public epochDurationByDomain; + /// @dev the current epoch for each domain. Commitments are submitted only for the current epoch + /// and validators may not submit commitments to epochs that are far in the past. + /// Hence, we need to treat each epoch separately. + mapping(address domain => uint256 currentAcceptingEpoch) public currentAcceptingEpochByDomain; + // Track registered attesters for each domain + mapping(address domain => EnumerableSet.AddressSet attester) internal registeredAttestersByDomain; + mapping(address domain => EnumerableSet.AddressSet custodian) internal registeredCustodiansByDomain; + + // preserved records of stake by address per epoch + /// @dev this is a mapping of domain => epoch => custodian => attester => stake + mapping(address domain => + mapping(uint256 epoch => + mapping(address custodian => + mapping(address attester => uint256 stake)))) public stakesByDomainEpochCustodianAttester; + + // preserved records of unstake by address per epoch + /// @dev this is a mapping of domain => epoch => custodian => attester => unstake + mapping(address domain => + mapping(uint256 epoch => + mapping(address custodian => + mapping(address attester => uint256 stake)))) public unstakesByDomainEpochCustodianAttester; + + // track the total stake of the epoch (computed at rollover) + /// @dev this is a mapping of domain => epoch => custodian => stake + mapping(address domain => + mapping(uint256 epoch => + mapping(address custodian => uint256 stake))) public stakesByDomainEpochCustodian; + + mapping(address domain => bool) public domainGenesisAccepted; + + // the whitelist role needed to stake/unstake + bytes32 public constant WHITELIST_ROLE = keccak256("WHITELIST_ROLE"); +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/src/staking/base/BaseStaking.sol b/protocol/pcp/dlu/eth/contracts/src/staking/base/BaseStaking.sol new file mode 100644 index 00000000..beae0e53 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/staking/base/BaseStaking.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; + +contract BaseStaking is Initializable, AccessControlUpgradeable, UUPSUpgradeable { + + /** + * @dev Initialize the contract + */ + function initialize() public virtual initializer { + __BaseStaking_init(); + } + + function __BaseStaking_init() internal onlyInitializing { + __BaseStaking_init_unchained(); + } + + function __BaseStaking_init_unchained() internal onlyInitializing { + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + } + + /** + * @dev Authorize an upgrade + * @param newImplementation The address of the new implementation + */ + function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} + +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/src/staking/interfaces/IMovementStaking.sol b/protocol/pcp/dlu/eth/contracts/src/staking/interfaces/IMovementStaking.sol new file mode 100644 index 00000000..f4479e2e --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/staking/interfaces/IMovementStaking.sol @@ -0,0 +1,110 @@ +pragma solidity ^0.8.13; + +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; + +// canonical order: domain, epoch, custodian, attester, stake =? decas +interface IMovementStaking { + function registerDomain( + uint256 epochDuration, + address[] calldata custodians + ) external; + function acceptGenesisCeremony() external; + function getEpochByL1BlockTime(address) external view returns (uint256); + function getAcceptingEpoch(address) external view returns (uint256); + function getNextAcceptingEpochWithException(address) external view returns (uint256); + function getNextPresentEpochWithException(address) external view returns (uint256); + function getStake( + address domain, + uint256 epoch, + address custodian, + address attester + ) external view returns (uint256); + function getStakeForAcceptingEpoch( + address domain, + address custodian, + address attester + ) external view returns (uint256); + function getUnstake( + address domain, + uint256 epoch, + address custodian, + address attester + ) external view returns (uint256); + function getUnstakeForAcceptingEpoch( + address domain, + address custodian, + address attester + ) external view returns (uint256); + function getCustodianStake( + address domain, + uint256 epoch, + address custodian + ) external view returns (uint256); + function getCustodianStakeForAcceptingEpoch( + address domain, + address custodian + ) external view returns (uint256); + function stake(address domain, IERC20 custodian, uint256 amount) external; + function unstake( + address domain, + address custodian, + uint256 amount + ) external; + function getRegisteredCustodians( + address domain + ) external view returns (address[] memory); + function getRegisteredAttesters( + address domain + ) external view returns (address[] memory); + function rollOverEpoch() external; + function slash( + address[] calldata custodians, + address[] calldata attesters, + uint256[] calldata amounts, + uint256[] calldata refundAmounts + ) external; + + function whitelistAddress(address addr) external; + function removeAddressFromWhitelist(address addr) external; + + event AttesterStaked( + address indexed domain, + uint256 indexed epoch, + address indexed custodian, + address attester, + uint256 stake + ); + + event AttesterUnstaked( + address indexed domain, + uint256 indexed epoch, + address indexed custodian, + address attester, + uint256 stake + ); + + event AttesterEpochRolledOver( + address indexed attester, + uint256 indexed epoch, + address indexed custodian, + uint256 stake, + uint256 unstake + ); + + event EpochRolledOver(address indexed domain, uint256 epoch); + + error StakeExceedsGenesisStake(); + error CustodianTransferAmountMismatch(); + error GenesisAlreadyAccepted(); + + function getStakedAttestersForAcceptingEpoch(address domain) external view returns (address[] memory); + function computeAllStakeForAcceptingEpoch(address attester) external view returns (uint256); + function getAttesterStakeForAcceptingEpoch(address domain, address attester) external view returns (uint256); + + function rewardFromDomain(address attester, uint256 amount, address custodian) external; + function rewardArray(address[] calldata attesters, uint256[] calldata amounts, address[] calldata custodians) external; + + function getEpochDuration(address domain) external view returns (uint256); + + function setAcceptingEpoch(address domain, uint256 newEpoch) external; +} diff --git a/protocol/pcp/dlu/eth/contracts/src/token/MOVEToken.sol b/protocol/pcp/dlu/eth/contracts/src/token/MOVEToken.sol new file mode 100644 index 00000000..955b118c --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/token/MOVEToken.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ERC20PermitUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; + +contract MOVEToken is ERC20PermitUpgradeable, AccessControlUpgradeable { + + /** + * @dev Disables potential implementation exploit + */ + constructor() {_disableInitializers();} + + /** + * @dev Initializes the contract with initial parameters. + * @param _owner The address of the owner who receives default admin role. + * @param _custody The address of the custody account. + * @notice The ERC20 token is named "Movement" with symbol "MOVE". + * @notice EIP712 domain version is set to "1" for signatures. + * @notice The owner is granted the `DEFAULT_ADMIN_ROLE`. + * @notice 10 billion MOVE tokens are minted to the owner's address. + */ + function initialize(address _owner, address _custody) public initializer { + require(_owner != address(0) && _custody != address(0)); + __ERC20_init("Movement", "MOVE"); + __EIP712_init_unchained("Movement", "1"); + _grantRole(DEFAULT_ADMIN_ROLE, _owner); + _mint(_custody, 10000000000 * 10 ** decimals()); + } + + /** + * @dev Returns the number of decimals + * @notice decimals is set to 8, following the Movement network standard decimals + */ + function decimals() public pure override returns (uint8) { + return 8; + } +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/src/token/MOVETokenDev.sol b/protocol/pcp/dlu/eth/contracts/src/token/MOVETokenDev.sol new file mode 100644 index 00000000..f5c20413 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/token/MOVETokenDev.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "./base/MintableToken.sol"; + +contract MOVETokenDev is MintableToken { + + /** + * @dev Initialize the contract + */ + function initialize(address manager) public initializer { + __MintableToken_init("Movement", "MOVE"); + _mint(manager, 10000000000 * 10 ** decimals()); + _grantRole(MINTER_ADMIN_ROLE, manager); + _grantRole(MINTER_ROLE, manager); + } + + function grantRoles(address account) public onlyRole(DEFAULT_ADMIN_ROLE) { + _grantRole(MINTER_ADMIN_ROLE, account); + _grantRole(MINTER_ROLE, account); + + } + + function decimals() public pure override returns (uint8) { + return 8; + } +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/src/token/MOVETokenV1.sol b/protocol/pcp/dlu/eth/contracts/src/token/MOVETokenV1.sol new file mode 100644 index 00000000..955b118c --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/token/MOVETokenV1.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ERC20PermitUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; + +contract MOVEToken is ERC20PermitUpgradeable, AccessControlUpgradeable { + + /** + * @dev Disables potential implementation exploit + */ + constructor() {_disableInitializers();} + + /** + * @dev Initializes the contract with initial parameters. + * @param _owner The address of the owner who receives default admin role. + * @param _custody The address of the custody account. + * @notice The ERC20 token is named "Movement" with symbol "MOVE". + * @notice EIP712 domain version is set to "1" for signatures. + * @notice The owner is granted the `DEFAULT_ADMIN_ROLE`. + * @notice 10 billion MOVE tokens are minted to the owner's address. + */ + function initialize(address _owner, address _custody) public initializer { + require(_owner != address(0) && _custody != address(0)); + __ERC20_init("Movement", "MOVE"); + __EIP712_init_unchained("Movement", "1"); + _grantRole(DEFAULT_ADMIN_ROLE, _owner); + _mint(_custody, 10000000000 * 10 ** decimals()); + } + + /** + * @dev Returns the number of decimals + * @notice decimals is set to 8, following the Movement network standard decimals + */ + function decimals() public pure override returns (uint8) { + return 8; + } +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/src/token/base/BaseToken.sol b/protocol/pcp/dlu/eth/contracts/src/token/base/BaseToken.sol new file mode 100644 index 00000000..eb8a802a --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/token/base/BaseToken.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; + +contract BaseToken is Initializable, ERC20Upgradeable, AccessControlUpgradeable, UUPSUpgradeable { + /** + * @dev Initialize the contract + * @param name The name of the token + * @param symbol The symbol of the token + */ + function initialize(string memory name, string memory symbol) public virtual initializer { + __BaseToken_init(name, symbol); + } + /** + * @dev Initialize the contract + */ + + function __BaseToken_init(string memory name, string memory symbol) internal onlyInitializing { + __ERC20_init_unchained(name, symbol); + __BaseToken_init_unchained(); + } + + function __BaseToken_init_unchained() internal onlyInitializing { + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + } + + /** + * @dev Authorize an upgrade + * @param newImplementation The address of the new implementation + */ + function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} +} diff --git a/protocol/pcp/dlu/eth/contracts/src/token/base/MintableToken.sol b/protocol/pcp/dlu/eth/contracts/src/token/base/MintableToken.sol new file mode 100644 index 00000000..4d78e27a --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/token/base/MintableToken.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {BaseToken} from "./BaseToken.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; + +interface IMintableToken is IERC20 { + function mint(address to, uint256 amount) external; + function grantMinterRole(address account) external; + function revokeMinterRole(address account) external; +} + +contract MintableToken is IMintableToken, BaseToken { + using SafeERC20 for IERC20; + + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + bytes32 public constant MINTER_ADMIN_ROLE = keccak256("MINTER_ADMIN_ROLE"); + + /** + * @dev Initialize the contract + * @param name The name of the token + * @param symbol The symbol of the token + */ + function initialize( + string memory name, + string memory symbol + ) public virtual override initializer { + __MintableToken_init(name, symbol); + } + + function __MintableToken_init( + string memory name, + string memory symbol + ) internal onlyInitializing { + __ERC20_init_unchained(name, symbol); + __BaseToken_init_unchained(); + __MintableToken_init_unchained(); + } + + function __MintableToken_init_unchained() internal onlyInitializing { + _grantRole(MINTER_ADMIN_ROLE, msg.sender); + _grantRole(MINTER_ROLE, msg.sender); + } + + /** + * @dev Set minter role + * @param account The address to set minter role + */ + function grantMinterRole( + address account + ) public onlyRole(MINTER_ADMIN_ROLE) { + _grantRole(MINTER_ROLE, account); + } + + /** + * @dev Check if an account has minter role + * @param account The address to check + * @return True if the account has minter role + */ + function hasMinterRole( + address account + ) public view returns (bool) { + return hasRole(MINTER_ROLE, account); + } + + /** + * @dev Revoke minter admin role + * @param account The address to revoke minter admin role from + */ + function revokeMinterAdminRole( + address account + ) public onlyRole(MINTER_ADMIN_ROLE) { + _revokeRole(MINTER_ADMIN_ROLE, account); + } + + /** + * @dev Revoke minter role + * @param account The address to revoke minter role from + */ + function revokeMinterRole( + address account + ) public onlyRole(MINTER_ADMIN_ROLE) { + _revokeRole(MINTER_ROLE, account); + } + + /** + * @dev Mint new tokens + * @param to The address to mint tokens to + * @param amount The amount of tokens to mint + */ + function mint( + address to, + uint256 amount + ) public virtual onlyRole(MINTER_ROLE) { + _mint(to, amount); + } +} diff --git a/protocol/pcp/dlu/eth/contracts/src/token/base/WrappedToken.sol b/protocol/pcp/dlu/eth/contracts/src/token/base/WrappedToken.sol new file mode 100644 index 00000000..96e0ae5e --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/token/base/WrappedToken.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import "./MintableToken.sol"; +import "./WrappedTokenStorage.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; + +contract WrappedToken is WrappedTokenStorage, MintableToken { + using SafeERC20 for IERC20; + + /** + * @dev Initialize the contract + * @param name The name of the token + * @param symbol The symbol of the token + * @param _underlyingToken The underlying token to wrap + */ + function initialize( + string memory name, + string memory symbol, + IMintableToken _underlyingToken + ) public virtual initializer { + __WrappedToken_init(name, symbol, _underlyingToken); + } + + /** + * @dev Initialize the contract + * @param _underlyingToken The underlying token to wrap + */ + function __WrappedToken_init( + string memory name, + string memory symbol, + IMintableToken _underlyingToken + ) internal onlyInitializing { + __ERC20_init_unchained(name, symbol); + __BaseToken_init_unchained(); + __MintableToken_init_unchained(); + __WrappedToken_init_unchained(_underlyingToken); + } + + /** + * @dev Initialize the contract unchained avoiding reinitialization + * @param _underlyingToken The underlying token to wrap + */ + function __WrappedToken_init_unchained( + IMintableToken _underlyingToken + ) internal onlyInitializing { + underlyingToken = _underlyingToken; + } + + /** + * @dev Mint new tokens + * @param account The address to mint tokens to + * @param amount The amount of tokens to mint + */ + function mint(address account, uint256 amount) public virtual override { + super.mint(account, amount); + underlyingToken.mint(address(this), amount); + } +} diff --git a/protocol/pcp/dlu/eth/contracts/src/token/base/WrappedTokenStorage.sol b/protocol/pcp/dlu/eth/contracts/src/token/base/WrappedTokenStorage.sol new file mode 100644 index 00000000..f3a8dc8a --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/token/base/WrappedTokenStorage.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import "./MintableToken.sol"; + +contract WrappedTokenStorage { + using SafeERC20 for IERC20; + + IMintableToken public underlyingToken; + + uint256[50] internal __gap; +} diff --git a/protocol/pcp/dlu/eth/contracts/src/token/custodian/CustodianToken.sol b/protocol/pcp/dlu/eth/contracts/src/token/custodian/CustodianToken.sol new file mode 100644 index 00000000..8609d28e --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/token/custodian/CustodianToken.sol @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {IMintableToken} from "../base/MintableToken.sol"; +import {WrappedToken} from "../base/WrappedToken.sol"; + +interface ICustodianToken is IERC20 { + function grantTransferSinkRole(address account) external; + function revokeTransferSinkRole(address account) external; + + function grantBuyerRole(address account) external; + function revokeBuyerRole(address account) external; + + function buyCustodialToken(address account, uint256 amount) external; + function buyCustodialTokenFrom( + address buyer, + address account, + uint256 amount + ) external; +} + +contract CustodianToken is ICustodianToken, WrappedToken { + using SafeERC20 for IERC20; + + bytes32 public constant TRANSFER_SINK_ROLE = + keccak256("TRANSFER_SINK_ROLE"); + bytes32 public constant TRANSFER_SINK_ADMIN_ROLE = + keccak256("TRANSFER_SINK_ADMIN_ROLE"); + + bytes32 public constant BUYER_ROLE = keccak256("BUYER_ROLE"); + bytes32 public constant BUYER_ADMIN_ROLE = keccak256("BUYER_ADMIN_ROLE"); + + error RestrictedToTransferSinkRole(); + error RestrictedToBuyerRole(); + + /** + * @dev Initialize the contract + * @param name The name of the token + * @param symbol The symbol of the token + * @param _underlyingToken The underlying token to wrap + */ + function initialize( + string memory name, + string memory symbol, + IMintableToken _underlyingToken + ) public virtual override initializer { + __CustodianToken_init(name, symbol, _underlyingToken); + } + + function __CustodianToken_init( + string memory name, + string memory symbol, + IMintableToken _underlyingToken + ) internal onlyInitializing { + __ERC20_init_unchained(name, symbol); + __BaseToken_init_unchained(); + __MintableToken_init_unchained(); + __WrappedToken_init_unchained(_underlyingToken); + __CustodianToken_init_unchained(); + } + + function __CustodianToken_init_unchained() internal onlyInitializing { + _grantRole(TRANSFER_SINK_ADMIN_ROLE, msg.sender); + _grantRole(TRANSFER_SINK_ROLE, msg.sender); + _grantRole(BUYER_ADMIN_ROLE, msg.sender); + _grantRole(BUYER_ROLE, msg.sender); + } + + function grantTransferSinkRole( + address account + ) public onlyRole(TRANSFER_SINK_ADMIN_ROLE) { + _grantRole(TRANSFER_SINK_ROLE, account); + } + + function revokeTransferSinkRole( + address account + ) public onlyRole(TRANSFER_SINK_ADMIN_ROLE) { + _revokeRole(TRANSFER_SINK_ROLE, account); + } + + /** + * @dev Approve tokens + * @param spender The address to approve tokens for + * @param amount The amount of tokens to approve + * @return A boolean indicating whether the approval was successful + */ + function approve( + address spender, + uint256 amount + ) public virtual override(IERC20, ERC20Upgradeable) returns (bool) { + // require the spender is a transfer sink + if (!hasRole(TRANSFER_SINK_ROLE, spender)) + revert RestrictedToTransferSinkRole(); + + return underlyingToken.approve(spender, amount); + } + + /** + * @dev Transfer tokens from + * @param from The address to transfer tokens from + * @param to The address to transfer tokens to + * @param amount The amount of tokens to transfer + * @return A boolean indicating whether the transfer was successful + */ + function transferFrom( + address from, + address to, + uint256 amount + ) public virtual override(IERC20, ERC20Upgradeable) returns (bool) { + // require the destination is a transfer sink + if (!hasRole(TRANSFER_SINK_ROLE, to)) + revert RestrictedToTransferSinkRole(); + + // burn the tokens from the sender + super.transferFrom(from, address(this), amount); + + // also perform a safe transfer from this contract to the recipient + return underlyingToken.transfer(to, amount); + } + + /** + * @dev Transfer tokens + * @param to The address to transfer tokens to + * @param amount The amount of tokens to transfer + * @return A boolean indicating whether the transfer was successful + */ + function transfer( + address to, + uint256 amount + ) public virtual override(IERC20, ERC20Upgradeable) returns (bool) { + // require the destination is a transfer sink + if (!hasRole(TRANSFER_SINK_ROLE, to)) + revert RestrictedToTransferSinkRole(); + + // burn the tokens from the sender + super.transfer(address(this), amount); + + // also perform a safe transfer from this contract to the recipient + return underlyingToken.transfer(to, amount); + } + + function grantBuyerRole(address account) public onlyRole(BUYER_ADMIN_ROLE) { + _grantRole(BUYER_ROLE, account); + } + + function revokeBuyerRole( + address account + ) public onlyRole(BUYER_ADMIN_ROLE) { + _revokeRole(BUYER_ROLE, account); + } + + function buyCustodialToken( + address account, + uint256 amount + ) public override { + buyCustodialTokenFrom(msg.sender, account, amount); + } + + function buyCustodialTokenFrom( + address buyer, + address account, + uint256 amount + ) public override { + // todo: this might need to check msg.sender instead or in addition to buyer + if (!hasRole(BUYER_ROLE, buyer)) revert RestrictedToBuyerRole(); + + // transfer the approved value from the buyer to this contract + underlyingToken.transferFrom(buyer, address(this), amount); + + // mint the custodial token for the buyer at their desired address + // ! maybe this should also be managed through the minter role, so the buyer would have to be buyer and minter + super._mint(account, amount); + } +} diff --git a/protocol/pcp/dlu/eth/contracts/src/token/faucet/MOVEFaucet.sol b/protocol/pcp/dlu/eth/contracts/src/token/faucet/MOVEFaucet.sol new file mode 100644 index 00000000..edd7b52b --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/token/faucet/MOVEFaucet.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +interface IERC20 { + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 value) external returns (bool); + function decimals() external view returns (uint8); +} + +contract MOVEFaucet { + + IERC20 public move; + uint256 public rateLimit = 1 days; + uint256 public amount = 10; + uint256 public maxBalance = 1; + address public owner; + mapping(address => uint256) public lastFaucetClaim; + + constructor(IERC20 _move) { + move = _move; + owner = msg.sender; + } + + function faucet() external payable { + require(msg.value == 10 ** 17, "MOVEFaucet: eth invalid amount"); + require(move.balanceOf(msg.sender) < maxBalance * 10 ** move.decimals(), "MOVEFaucet: balance must be less than determine amount of MOVE"); + require(block.timestamp - lastFaucetClaim[msg.sender] >= rateLimit, "MOVEFaucet: rate limit exceeded"); + lastFaucetClaim[msg.sender] = block.timestamp; + require(move.transfer(msg.sender, amount * 10 ** move.decimals()), "MOVEFaucet: transfer failed"); + } + + function setConfig(uint256 _rateLimit, uint256 _amount, uint256 _maxBalance, address _owner) external { + require(msg.sender == owner, "MOVEFaucet: only owner can set config"); + rateLimit = _rateLimit; + amount = _amount; + maxBalance = _maxBalance; + owner = _owner; + + } + + function withdraw() external { + require(msg.sender == owner, "MOVEFaucet: only owner can retrieve funds"); + (bool status,) = owner.call{value: address(this).balance}(""); + require(status == true, "error during transaction"); + } +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/src/token/locked/LockedToken.sol b/protocol/pcp/dlu/eth/contracts/src/token/locked/LockedToken.sol new file mode 100644 index 00000000..2ae5ba97 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/token/locked/LockedToken.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {WrappedToken} from "../base/WrappedToken.sol"; +import {IMintableToken} from "../base/MintableToken.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {LockedTokenStorage} from "./LockedTokenStorage.sol"; + +contract LockedToken is WrappedToken, LockedTokenStorage { + /** + * @dev Initialize the contract + * @param name The name of the token + * @param symbol The symbol of the token + * @param _underlyingToken The underlying token to wrap + */ + function initialize( + string memory name, + string memory symbol, + IMintableToken _underlyingToken + ) public virtual override initializer { + __LockedToken_init(name, symbol, _underlyingToken); + } + + function __LockedToken_init( + string memory name, + string memory symbol, + IMintableToken _underlyingToken + ) internal onlyInitializing { + __ERC20_init_unchained(name, symbol); + __BaseToken_init_unchained(); + __MintableToken_init_unchained(); + __WrappedToken_init_unchained(_underlyingToken); + __LockedToken_init_unchained(); + } + + function __LockedToken_init_unchained() internal onlyInitializing { + _grantRole(MINT_LOCKER_ADMIN_ROLE, msg.sender); + _grantRole(MINT_LOCKER_ROLE, msg.sender); + } + + /** + * @dev Mint and lock tokens + * @param addresses The addresses to mint and lock tokens for + * @param mintAmounts The amounts to mint. + * @param lockAmounts The amount up to which the user is allowed to be unlock, respective of balance + * @param lockTimes The times to lock the tokens for + */ + function mintAndLock( + address[] calldata addresses, + uint256[] calldata mintAmounts, + uint256[] calldata lockAmounts, + uint256[] calldata lockTimes + ) external onlyRole(MINT_LOCKER_ROLE) { + if (addresses.length != mintAmounts.length) + revert AddressesAndMintLengthMismatch(); + if (addresses.length != lockAmounts.length) + revert AddressesAndLockLengthMismatch(); + if (addresses.length != lockTimes.length) + revert AddressesAndTimeLengthMismatch(); + + for (uint256 i = 0; i < addresses.length; i++) { + underlyingToken.mint(address(this), mintAmounts[i]); + _mint(addresses[i], mintAmounts[i]); + _lock(addresses[i], lockAmounts[i], lockTimes[i]); + } + } + + /** + * @dev Lock tokens + * @param account The address to lock tokens for + * @param amount The amount of tokens to lock + * @param lockTime The time to lock the tokens for + */ + function _lock(address account, uint256 amount, uint256 lockTime) internal { + locks[account].push(Lock(amount, lockTime)); + } + + /** + * @dev Release unlocked tokens + */ + function release() external { + uint256 totalUnlocked = 0; + Lock[] storage userLocks = locks[msg.sender]; + for (uint256 i; i < userLocks.length;) { + if (block.timestamp > userLocks[i].releaseTime) { + // compute the max possible amount to withdraw + uint256 amount = Math.min( + userLocks[i].amount, + balanceOf(msg.sender) + ); + + // burn the amount so that the user can't overdraw + _transfer(msg.sender, address(this), amount); + + // add to the total unlocked amount + totalUnlocked += amount; + + // deduct the amount from the lock + userLocks[i].amount -= amount; + + // if the amount on the lock is now 0, remove the lock + if (userLocks[i].amount == 0) { + userLocks[i] = userLocks[userLocks.length - 1]; + userLocks.pop(); + continue; + } + } + i++; + } + + // transfer the underlying token + underlyingToken.transfer(msg.sender, totalUnlocked); + } + + /** + * @dev Get the total locked balance of an account + * @param account The address to get the total locked balance of + * @return The total locked balance of the account + */ + function balanceOfLocked(address account) external view returns (uint256) { + uint256 totalLocked = 0; + Lock[] memory userLocks = locks[account]; + for (uint256 i = 0; i < userLocks.length; i++) { + totalLocked += userLocks[i].amount; + } + return totalLocked; + } +} diff --git a/protocol/pcp/dlu/eth/contracts/src/token/locked/LockedTokenStorage.sol b/protocol/pcp/dlu/eth/contracts/src/token/locked/LockedTokenStorage.sol new file mode 100644 index 00000000..d361612b --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/token/locked/LockedTokenStorage.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; +import {LockedTokenStorage} from "./LockedTokenStorage.sol"; + +contract LockedTokenStorage { + bytes32 public constant MINT_LOCKER_ROLE = keccak256("MINT_LOCKER_ROLE"); + bytes32 public constant MINT_LOCKER_ADMIN_ROLE = + keccak256("MINT_LOCKER_ADMIN_ROLE"); + + struct Lock { + uint256 amount; + uint256 releaseTime; + } + mapping(address => Lock[]) public locks; + + error AddressesAndMintLengthMismatch(); + error AddressesAndLockLengthMismatch(); + error AddressesAndTimeLengthMismatch(); +} diff --git a/protocol/pcp/dlu/eth/contracts/src/token/stlMoveToken.sol b/protocol/pcp/dlu/eth/contracts/src/token/stlMoveToken.sol new file mode 100644 index 00000000..0a819c5e --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/src/token/stlMoveToken.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {LockedToken} from "./locked/LockedToken.sol"; +import {CustodianToken} from "./custodian/CustodianToken.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import {IMintableToken} from "./base/MintableToken.sol"; +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; + +contract stlMoveToken is LockedToken, CustodianToken { + using SafeERC20 for IERC20; + + /** + * @dev Initialize the contract + * @param _underlyingToken The underlying token to wrap + */ + function initialize(IMintableToken _underlyingToken) public { + initialize("Stakable Locked Move Token", "stlMOVE", _underlyingToken); + } + + function initialize(string memory name, string memory symbol, IMintableToken _underlyingToken) + public + override(CustodianToken, LockedToken) + initializer + { + __ERC20_init_unchained(name, symbol); + __BaseToken_init_unchained(); + __MintableToken_init_unchained(); + __WrappedToken_init_unchained(_underlyingToken); + __LockedToken_init_unchained(); + __CustodianToken_init_unchained(); + } + + function transfer(address to, uint256 amount) + public + override(CustodianToken, ERC20Upgradeable, IERC20) + returns (bool) + { + return CustodianToken.transfer(to, amount); + } + + function transferFrom(address from, address to, uint256 amount) + public + override(CustodianToken, ERC20Upgradeable, IERC20) + returns (bool) + { + return CustodianToken.transferFrom(from, to, amount); + } + + function approve(address spender, uint256 amount) + public + override(CustodianToken, ERC20Upgradeable, IERC20) + returns (bool) + { + return CustodianToken.approve(spender, amount); + } +} + +// Flow for staking +// StakingContract: signer call stake +// StakingContract: signer approves StakingContract to spend their stlkMOVE tokens. +// StakingContract: calls transferFrom on stlkMOVE to move both stlkMOVE and MOVE tokens to the staking contract +// StakingContract: staking contract confirms it received the tokens and records balance for the signer with the custodian + +// Flow for unstaking +// StakingContract: signer calls unstake with the custodian +// StakingContract: staking contract transfers stlkMOVE and MOVE tokens back to the custodian via calling transfer on the stlkMOVE contract +// StakingContract: staking contract confirms it transferred the tokens back to the custodian and updates the signer's balance to 0 diff --git a/protocol/pcp/dlu/eth/contracts/test/Deployer.t.sol b/protocol/pcp/dlu/eth/contracts/test/Deployer.t.sol new file mode 100644 index 00000000..f66c39aa --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/test/Deployer.t.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../../src/token/MOVEToken.sol"; + +contract DeployerTest is Test { + + function setUp() public { + // Set the sender address + } +} diff --git a/protocol/pcp/dlu/eth/contracts/test/settlement/PCP.t.sol b/protocol/pcp/dlu/eth/contracts/test/settlement/PCP.t.sol new file mode 100644 index 00000000..0e3ddd8a --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/test/settlement/PCP.t.sol @@ -0,0 +1,1084 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../../src/staking/MovementStaking.sol"; +import "../../src/token/MOVETokenDev.sol"; +import "../../src/settlement/PCP.sol"; +import "../../src/settlement/PCPStorage.sol"; +import "../../src/settlement/interfaces/IPCP.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; + +contract PCPTest is Test { + MOVETokenDev public moveToken; + MovementStaking public staking; + PCP public pcp; + ProxyAdmin public admin; + string public moveSignature = "initialize(address)"; + string public stakingSignature = "initialize(address)"; + string public pcpSignature = "initialize(address,uint256,uint256,uint256,address[],uint256,address)"; + uint256 epochDuration = 7200 seconds; + uint256 postconfirmerDuration = epochDuration/4; + bytes32 honestCommitmentTemplate = keccak256(abi.encodePacked(uint256(1), uint256(2), uint256(3))); + bytes32 honestBlockIdTemplate = keccak256(abi.encodePacked(uint256(1), uint256(2), uint256(3))); + bytes32 dishonestCommitmentTemplate = keccak256(abi.encodePacked(uint256(3), uint256(2), uint256(1))); + bytes32 dishonestBlockIdTemplate = keccak256(abi.encodePacked(uint256(3), uint256(2), uint256(1))); + + // make an honest commitment + function makeHonestCommitment(uint256 height) internal view returns (PCPStorage.SuperBlockCommitment memory) { + return PCPStorage.SuperBlockCommitment({ + height: height, + commitment: honestCommitmentTemplate, + blockId: honestBlockIdTemplate + }); + } + + // make a dishonest commitment + function makeDishonestCommitment(uint256 height) internal view returns (PCPStorage.SuperBlockCommitment memory) { + return PCPStorage.SuperBlockCommitment({ + height: height, + commitment: dishonestCommitmentTemplate, + blockId: dishonestBlockIdTemplate + }); + } + + + // ---------------------------------------------------------------- + // -------- Helper functions -------------------------------------- + // ---------------------------------------------------------------- + + function setUp() public { + MOVETokenDev moveTokenImplementation = new MOVETokenDev(); + MovementStaking stakingImplementation = new MovementStaking(); + PCP pcpImplementation = new PCP(); + + // Contract PCPTest is the admin + admin = new ProxyAdmin(address(this)); + + // Deploy proxies + bytes memory initData = abi.encodeWithSignature(moveSignature, address(this)); + TransparentUpgradeableProxy moveProxy = new TransparentUpgradeableProxy( + address(moveTokenImplementation), + address(admin), + initData + ); + // Set up the moveToken variable to interact with the proxy + moveToken = MOVETokenDev(address(moveProxy)); + + bytes memory stakingInitData = abi.encodeWithSignature(stakingSignature, IMintableToken(address(moveProxy))); + TransparentUpgradeableProxy stakingProxy = new TransparentUpgradeableProxy( + address(stakingImplementation), + address(admin), + stakingInitData + ); + // Set up the staking variable to interact with the proxy + staking = MovementStaking(address(stakingProxy)); + + address[] memory custodians = new address[](1); + // TODO while this works it is hard to access that this is the moveToken. We should not rely on the custodian array + custodians[0] = address(moveProxy); + + bytes memory pcpInitData = abi.encodeWithSignature( + pcpSignature, + stakingProxy, // _stakingContract, address of staking contract + 0, // _lastPostconfirmedSuperBlockHeight, start from genesis + 5, // _leadingSuperBlockTolerance, max blocks ahead of last confirmed + epochDuration, // _epochDuration, how long an epoch lasts, constant stakes in that time + custodians, // _custodians, array with moveProxy address + postconfirmerDuration, // _postconfirmerDuration, how long an postconfirmer serves + // TODO can we replace the following line with the moveToken address? + address(moveProxy) // _moveTokenAddress, the primary custodian for rewards in the staking contract + ); + TransparentUpgradeableProxy pcpProxy = new TransparentUpgradeableProxy( + address(pcpImplementation), + address(admin), + pcpInitData + ); + + pcp = PCP(address(pcpProxy)); + pcp.setOpenAttestationEnabled(true); + + assertEq(staking.getEpochDuration(address(pcp)), epochDuration, "Epoch duration not set correctly"); + // set the min commitment age for postconfirmation to 0 to make the tests easier + pcp.setMinCommitmentAgeForPostconfirmation(0); + assertEq(pcp.getMinCommitmentAgeForPostconfirmation(), 0, "The default min commitment age for tests is set to 0"); + // set the max postconfirmer non-reactivity time to 0 to make the tests easier + pcp.setPostconfirmerPrivilegeDuration(0); + assertEq(pcp.getPostconfirmerPrivilegeDuration(), 0, "The default max postconfirmer non-reactivity time for tests is set to 0"); + } + + // Helper function to setup genesis with 1 attester and their stake + function setupGenesisWithOneAttester(uint256 stakeAmount) internal returns (address attester) { + moveToken.mint(address(pcp), stakeAmount*100); // PCP needs tokens to pay rewards + // PCP needs to approve staking contract to spend its tokens + vm.prank(address(pcp)); + moveToken.approve(address(staking), type(uint256).max); + + attester = payable(vm.addr(1)); + staking.whitelistAddress(attester); + moveToken.mint(attester, stakeAmount); + vm.prank(attester); + moveToken.approve(address(staking), stakeAmount); + vm.prank(attester); + staking.stake(address(pcp), moveToken, stakeAmount); + assertEq(pcp.getStakeForAcceptingEpoch(address(moveToken), attester), stakeAmount); + assertEq(pcp.getTotalStakeForAcceptingEpoch(), stakeAmount); + + // TODO check why the registering did not work in the setup function + // setup the epoch duration + address[] memory custodians = new address[](1); + custodians[0] = address(moveToken); + staking.registerDomain(epochDuration, custodians); + + // TODO this seems odd that we need to do this here.. check for correctnes of this approach + pcp.grantRole(pcp.DEFAULT_ADMIN_ROLE(), address(pcp)); + + // attempt genesis when L1 chain has already advanced into the future + // vm.warp(3*epochDuration); + + // End genesis ceremony + vm.prank(address(pcp)); + pcp.acceptGenesisCeremony(); + + // Verify stakes + assertEq(pcp.getStakeForAcceptingEpoch(address(moveToken), attester), stakeAmount, "Alice's stake not correct"); + assertEq(pcp.getTotalStakeForAcceptingEpoch(), stakeAmount, "Total stake not correct"); + } + + + // Helper function to setup genesis with 3 attesters and their stakes + function setupGenesisWithThreeAttesters( + uint256 aliceStakeAmount, + uint256 bobStakeAmount, + uint256 carolStakeAmount + ) internal returns (address alice, address bob, address carol) { + uint256 totalStakeAmount = aliceStakeAmount + bobStakeAmount + carolStakeAmount; + + moveToken.mint(address(pcp), totalStakeAmount*100); // PCP needs tokens to pay rewards + // PCP needs to approve staking contract to spend its tokens + vm.prank(address(pcp)); + moveToken.approve(address(staking), type(uint256).max); + + // Create attesters + alice = payable(vm.addr(1)); + bob = payable(vm.addr(2)); + carol = payable(vm.addr(3)); + address[] memory attesters = new address[](3); + attesters[0] = alice; + attesters[1] = bob; + attesters[2] = carol; + + // Setup attesters + for (uint i = 0; i < attesters.length; i++) { + staking.whitelistAddress(attesters[i]); + moveToken.mint(attesters[i], totalStakeAmount); // we mint the total stake amount for each attester, just so we have some buffer + vm.prank(attesters[i]); + moveToken.approve(address(staking), totalStakeAmount); + } + + // Stake + vm.prank(alice); + staking.stake(address(pcp), moveToken, aliceStakeAmount); + vm.prank(bob); + staking.stake(address(pcp), moveToken, bobStakeAmount); + vm.prank(carol); + staking.stake(address(pcp), moveToken, carolStakeAmount); + + // Verify stakes + assertEq(pcp.getStakeForAcceptingEpoch(address(moveToken), alice), aliceStakeAmount, "Alice's stake not correct"); + assertEq(pcp.getStakeForAcceptingEpoch(address(moveToken), bob), bobStakeAmount, "Bob's stake not correct"); + assertEq(pcp.getStakeForAcceptingEpoch(address(moveToken), carol), carolStakeAmount, "Carol's stake not correct"); + assertEq(pcp.getTotalStakeForAcceptingEpoch(), totalStakeAmount, "Total stake not correct"); + + // TODO check why the registering did not work in the setup function + // setup the epoch duration + address[] memory custodians = new address[](1); + custodians[0] = address(moveToken); + staking.registerDomain(epochDuration, custodians); + + // TODO this seems odd that we need to do this here.. check for correctnes of this approach + pcp.grantRole(pcp.DEFAULT_ADMIN_ROLE(), address(pcp)); + + // attempt genesis when L1 chain has already advanced into the future + // vm.warp(3*epochDuration); + + // End genesis ceremony + vm.prank(address(pcp)); + pcp.acceptGenesisCeremony(); + + // Verify stakes + assertEq(pcp.getStakeForAcceptingEpoch(address(moveToken), alice), aliceStakeAmount, "Alice's stake not correct"); + assertEq(pcp.getStakeForAcceptingEpoch(address(moveToken), bob), bobStakeAmount, "Bob's stake not correct"); + assertEq(pcp.getStakeForAcceptingEpoch(address(moveToken), carol), carolStakeAmount, "Carol's stake not correct"); + assertEq(pcp.getTotalStakeForAcceptingEpoch(), totalStakeAmount, "Total stake not correct"); + } + + /// @notice Helper function to setup a new signer with staking + /// @param seed used to generate signer address + /// @param stakeAmount Amount of tokens to stake + /// @return newStakedAttester Address of the newly setup signer + function newStakedAttester(uint256 seed, uint256 stakeAmount) internal returns (address) { + address payable newAttester = payable(vm.addr(seed)); + staking.whitelistAddress(newAttester); + moveToken.mint(newAttester, stakeAmount * 3); // Mint 3x for flexibility + vm.prank(newAttester); + moveToken.approve(address(staking), stakeAmount); + vm.prank(newAttester); + staking.stake(address(pcp), moveToken, stakeAmount); + assert(pcp.getStakeForAcceptingEpoch(address(moveToken), newAttester) == stakeAmount); + + return newAttester; + } + + // we need this function to print the commitment in a readable format, e.g. for logging purposes + function commitmentToHexString(bytes32 commitment) public pure returns (string memory) { + bytes memory alphabet = "0123456789abcdef"; + bytes memory str = new bytes(2 + 32 * 2); + str[0] = "0"; + str[1] = "x"; + for (uint i = 0; i < 32; i++) { + str[2+i*2] = alphabet[uint8(commitment[i] >> 4)]; + str[2+i*2+1] = alphabet[uint8(commitment[i] & 0x0f)]; + } + return string(str); + } + + // this function checks if the honest attesters have a supermajority of the stake + function logStakeInfo(address[] memory _honestAttesters, address[] memory _dishonestAttesters) internal view returns (bool) { + // calculate the honest attesters stake + uint256 honestStake = 0; + for (uint256 k = 0; k < _honestAttesters.length; k++) { + honestStake += pcp.getStakeForAcceptingEpoch(address(moveToken), _honestAttesters[k]); + } + + // calculate the dishonest attesters stake + uint256 dishonestStake = 0; + for (uint256 k = 0; k < _dishonestAttesters.length; k++) { + dishonestStake += pcp.getStakeForAcceptingEpoch(address(moveToken), _dishonestAttesters[k]); + } + + uint256 supermajorityStake = 2 * (honestStake + dishonestStake) / 3 + 1; + return honestStake >= supermajorityStake; + } + + // remove an attester from the attesters array + function removeAttester(address attester, address[] storage attesters, uint256 attesterStake) internal { + vm.prank(attester); + staking.unstake(address(pcp), address(moveToken), attesterStake); + + // Find and remove attester from array using swap and pop + for (uint i = 0; i < attesters.length; i++) { + if (attesters[i] == attester) { + attesters[i] = attesters[attesters.length - 1]; + attesters.pop(); + break; + } + } + } + + // ---------------------------------------------------------------- + // -------- General tests ---------------------------------------- + // ---------------------------------------------------------------- + + function testCannotInitializeTwice() public { + address[] memory custodians = new address[](1); + custodians[0] = address(moveToken); + // Attempt to initialize again should fail + vm.expectRevert(bytes4(0xf92ee8a9)); + pcp.initialize(staking, 0, 5, 10 seconds, custodians,120 seconds, address(moveToken)); + } + + function testSetAcceptingEpochOnlyDomain() public { + address alice = setupGenesisWithOneAttester(1000); + vm.warp(pcp.getEpochDuration()*2); + + // Try to set accepting epoch from a non-domain address + vm.prank(alice); + assertEq(pcp.hasRole(pcp.COMMITMENT_ADMIN(), alice), false); + vm.prank(alice); + vm.expectRevert("UNAUTHORIZED"); + staking.setAcceptingEpoch(address(pcp), 1); + console.log("Unauthorized attempt failed as expected"); + + // Ensure the PCP contract has the COMMITMENT_ADMIN role + uint256 presentEpoch = pcp.getPresentEpoch(); + assertEq(pcp.hasRole(pcp.COMMITMENT_ADMIN(), address(this)), true); + pcp.grantRole(pcp.COMMITMENT_ADMIN(), address(this)); + // check that pcp has the COMMITMENT_ADMIN role + assertEq(pcp.hasRole(pcp.COMMITMENT_ADMIN(), address(this)), true); + pcp.setAcceptingEpoch(presentEpoch - 1); + assertEq(staking.getAcceptingEpoch(address(pcp)), presentEpoch - 1); + } + + /// @notice Test that an attester cannot submit multiple commitments for the same height + function testAttesterCannotCommitTwice() public { + // three well-funded signers + (, , address carol) = setupGenesisWithThreeAttesters(1, 1, 1); + + // carol will be dishonest + vm.prank(carol); + pcp.submitSuperBlockCommitment(makeDishonestCommitment(1)); + + // carol will try to sign again + vm.prank(carol); + vm.expectRevert(IPCP.AttesterAlreadyCommitted.selector); + pcp.submitSuperBlockCommitment(makeDishonestCommitment(1)); + } + + /// @notice Test that honest supermajority succeeds despite dishonest attesters + function testHonestSupermajoritySucceeds() public { + // Setup with alice+bob having supermajority (67%) + (address alice, address bob, address carol) = setupGenesisWithThreeAttesters(2, 1, 1); + + // Dishonest carol submits first + vm.prank(carol); + pcp.submitSuperBlockCommitment(makeDishonestCommitment(1)); + + // Honest majority submits + vm.prank(alice); + pcp.submitSuperBlockCommitment(makeHonestCommitment(1)); + vm.prank(bob); + pcp.submitSuperBlockCommitment(makeHonestCommitment(1)); + + // Trigger postconfirmation with majority + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + + // Verify honest commitment was postconfirmed + PCPStorage.SuperBlockCommitment memory retrievedCommitment = pcp.getPostconfirmedCommitment(1); + assertEq(retrievedCommitment.commitment, honestCommitmentTemplate); + assertEq(retrievedCommitment.blockId, honestBlockIdTemplate); + assertEq(retrievedCommitment.height, 1); + } + + + /// @notice Test that no postconfirmation happens when stakes are equal + function testNoPostconfirmationWithEqualStakes() public { + // Setup with equal stakes (no possible supermajority) + (address alice, address bob, address carol) = setupGenesisWithThreeAttesters(1, 1, 1); + + // Honnest commitments + vm.prank(alice); + pcp.submitSuperBlockCommitment(makeHonestCommitment(1)); + vm.prank(bob); + pcp.submitSuperBlockCommitment(makeHonestCommitment(1)); + // Dishonest commitment + vm.prank(carol); + pcp.submitSuperBlockCommitment(makeDishonestCommitment(1)); + + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + assertEq(pcp.getLastPostconfirmedSuperBlockHeight(), 0, "Height should not advance - Alice"); + // Verify no commitment was postconfirmed + PCPStorage.SuperBlockCommitment memory retrievedCommitment = pcp.getPostconfirmedCommitment(1); + assertEq(retrievedCommitment.height, 0, "No commitment should be postconfirmed"); + assertEq(retrievedCommitment.commitment, bytes32(0), "No commitment should be postconfirmed"); + } + + /// @notice Test that rollover handling works with dishonesty + function testRolloverHandlingWithDishonesty() public { + uint256 L1BlockTimeStart = 30 * epochDuration; // TODO why though? + vm.warp(L1BlockTimeStart); + + (address alice, address bob, address carol) = setupGenesisWithThreeAttesters(2, 1, 1); + + // dishonest carol + vm.prank(carol); + pcp.submitSuperBlockCommitment(makeDishonestCommitment(1)); + + // honest majority + vm.prank(alice); + pcp.submitSuperBlockCommitment(makeHonestCommitment(1)); + vm.prank(bob); + pcp.submitSuperBlockCommitment(makeHonestCommitment(1)); + + // now we move to next epoch + vm.warp(L1BlockTimeStart + epochDuration); + + // postconfirm and rollover + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + + // check that roll over happened + assertEq(pcp.getAcceptingEpoch(), pcp.getPresentEpoch()); + assertEq(pcp.getStakeForAcceptingEpoch(address(moveToken), alice), 2); + assertEq(pcp.getStakeForAcceptingEpoch(address(moveToken), bob), 1); + assertEq(pcp.getStakeForAcceptingEpoch(address(moveToken), carol), 1); + PCPStorage.SuperBlockCommitment memory retrievedCommitment = pcp.getPostconfirmedCommitment(1); + assert(retrievedCommitment.commitment == honestCommitmentTemplate); + assert(retrievedCommitment.blockId == honestBlockIdTemplate); + assert(retrievedCommitment.height == 1); + } + + // State variable (at contract level) + // dynamic array defined as state variable to permit to use push + address[] honestAttesters = new address[](0); + address[] dishonestAttesters = new address[](0); + + /// @notice Tests the PCP system's resilience with changing Attester sets by: + /// 1. Starting with honest majority (2/3 honest, 1/3 dishonest) + /// 2. Adding new attester periodically + /// 3. Removing attester periodically + /// 4. Verifying honest commitments prevail over 50 reorganizations + // TODO i am not convinced we need such a complicated unit test here. Consider what this is trying to achieve and break it up. + function testChangingAttesterSet() public { + // TODO explain why we need to pause gas metering here + vm.pauseGasMetering(); + uint256 attesterStake = 1; + uint256 L1BlockTimeStart = 30 * epochDuration; // TODO why though? + uint256 L1BlockTime = L1BlockTimeStart; + vm.warp(L1BlockTime); + uint256 changingAttesterSetEvents = 10; // number of times we change the attester set + uint256 commitmentHeights = 1; // number of commitments after each change event + + // alice needs to have attesterStake + 1 so we reach supermajority + (address alice, address bob, address carol) = setupGenesisWithThreeAttesters(attesterStake+1, attesterStake, attesterStake); + moveToken.mint(address(pcp), 100); // PCP needs tokens to pay rewards + + // honest attesters + honestAttesters.push(alice); + honestAttesters.push(bob); + + // dishonest attesters + dishonestAttesters.push(carol); + + for (uint256 i = 0; i < changingAttesterSetEvents; i++) { + for (uint256 j = 0; j < commitmentHeights; j++) { + uint256 superBlockHeightNow = i * commitmentHeights + j + 1; + + L1BlockTime += epochDuration; + vm.warp(L1BlockTime); + // alice triggers rollover + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + + // get the assigned epoch for the superblock height + // commit roughly half of dishones attesters + PCPStorage.SuperBlockCommitment memory dishonestCommitment = makeDishonestCommitment(superBlockHeightNow); + for (uint256 k = 0; k < dishonestAttesters.length / 2; k++) { + vm.prank(dishonestAttesters[k]); + pcp.submitSuperBlockCommitment(dishonestCommitment); + } + + // commit honestly + PCPStorage.SuperBlockCommitment memory honestCommitment = makeHonestCommitment(superBlockHeightNow); + for (uint256 k = 0; k < honestAttesters.length; k++) { + vm.prank(honestAttesters[k]); + pcp.submitSuperBlockCommitment(honestCommitment); + } + + // TODO: The following does not serve any purpose, as enough attesters are already committed + // commit dishonestly the rest + // for (uint256 k = dishonestAttesters.length / 2; k < dishonestAttesters.length; k++) { + // vm.prank(dishonestAttesters[k]); + // pcp.submitSuperBlockCommitment(dishonestCommitment); + // } + + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + + PCPStorage.SuperBlockCommitment memory retrievedCommitment = pcp.getPostconfirmedCommitment(superBlockHeightNow); + assert(retrievedCommitment.commitment == honestCommitment.commitment); + assert(retrievedCommitment.blockId == honestCommitment.blockId); + assert(retrievedCommitment.height == superBlockHeightNow); + + } + + uint256 honestStakedAttesterLength = honestAttesters.length; + uint256 dishonestStakedAttesterLength = dishonestAttesters.length; + + // TODO replace the below with this function call + // address newAttester = newStakedAttester(4 + i, attesterStake); // TODO why 4 not 3? + + // add a new attester + address payable newAttester = payable(vm.addr(4 + i)); + + staking.whitelistAddress(newAttester); + moveToken.mint(newAttester, 3*attesterStake); + vm.prank(newAttester); + moveToken.approve(address(staking), attesterStake); + vm.prank(newAttester); + staking.stake(address(pcp), moveToken, attesterStake); + + L1BlockTime += epochDuration; + vm.warp(L1BlockTime); + + // Force rollover by having alice (who has majority stake) call postconfirmSuperBlocksAndRollover + vm.prank(alice); // alice has attesterStake+1 from setup + pcp.postconfirmSuperBlocksAndRollover(); + // confirm that the new attester has stake + assert(pcp.getStakeForAcceptingEpoch(address(moveToken), newAttester) == attesterStake); + + // push every third signer to dishonest attesters. If pushed earlier we fail a super majority test. + if (i % 3 == 2) { + dishonestAttesters.push(newAttester); + assert(dishonestAttesters.length == dishonestStakedAttesterLength + 1); + } else { + honestAttesters.push(newAttester); + assert(honestAttesters.length == honestStakedAttesterLength + 1); + } + + // TODO explain here why we do the following + if (i % 5 == 4) { + // removeAttester(dishonestAttesters[0], dishonestAttesters, attesterStake); + } + // TODO only having this but not the above is a more complex interesting scenario that would fail the line as we rollover in the postconfirmation: + // assert(retrievedCommitment.commitment == honestCommitment.commitment); (above) + // this is interesting but it requires moving this upwards in the code and maybe not applying both + if (i % 8 == 7) { + // remove an honest attester + // removeAttester(honestAttesters[0], honestAttesters, attesterStake); + } + + assert(logStakeInfo(honestAttesters, dishonestAttesters)); + + // L1BlockTime += 5; + // vm.warp(L1BlockTime); + // assert the time here + assertEq(L1BlockTime, L1BlockTimeStart + (i+1) * (commitmentHeights + 1) * epochDuration); + } + assertEq(pcp.getLastPostconfirmedSuperBlockHeight(), changingAttesterSetEvents * commitmentHeights); + } + + function testForcedAttestation() public { + vm.pauseGasMetering(); + + uint256 blockTime = 300; + vm.warp(blockTime); + + // default signer should be able to force commitment + PCPStorage.SuperBlockCommitment memory forcedCommitment = makeDishonestCommitment(1); + pcp.forceLatestCommitment(forcedCommitment); + + // get the latest commitment + PCPStorage.SuperBlockCommitment memory retrievedCommitment = pcp.getPostconfirmedCommitment(1); + assertEq(retrievedCommitment.blockId, forcedCommitment.blockId); + assertEq(retrievedCommitment.commitment, forcedCommitment.commitment); + assertEq(retrievedCommitment.height, forcedCommitment.height); + + // create an unauthorized signer + address payable alice = payable(vm.addr(1)); + + // try to force a different commitment with unauthorized user + PCPStorage.SuperBlockCommitment memory badForcedCommitment = makeHonestCommitment(1); + + // Alice should not have COMMITMENT_ADMIN role + assertEq(pcp.hasRole(pcp.COMMITMENT_ADMIN(), alice), false); + + vm.prank(alice); + vm.expectRevert("FORCE_LATEST_COMMITMENT_IS_COMMITMENT_ADMIN_ONLY"); + pcp.forceLatestCommitment(badForcedCommitment); + } + + /// @notice Test that a confirmation and postconfirmation by single attester works + function testSimplePostconfirmation() public { + // Setup - single attester + address payable alice = payable(vm.addr(1)); + staking.whitelistAddress(alice); + moveToken.mint(alice, 100); + + // Stake + vm.prank(alice); + moveToken.approve(address(staking), 100); + vm.prank(alice); + staking.stake(address(pcp), moveToken, 100); + + // End genesis ceremony + // vm.prank(address(pcp)); // TODO is this needed? + pcp.acceptGenesisCeremony(); + + // confirm current superblock height + uint256 currentHeight = pcp.getLastPostconfirmedSuperBlockHeight(); + + // Create and submit commitment + uint256 targetHeight = 1; + PCPStorage.SuperBlockCommitment memory commitment = PCPStorage.SuperBlockCommitment({ + height: targetHeight, + commitment: keccak256(abi.encodePacked(uint256(1))), + blockId: keccak256(abi.encodePacked(uint256(1))) + }); + + // Submit commitment + vm.prank(alice); + pcp.submitSuperBlockCommitment(commitment); + + // Verify commitment was stored + PCPStorage.SuperBlockCommitment memory stored = pcp.getCommitmentByAttester(targetHeight, alice); + assert(stored.commitment == commitment.commitment); + + // Attempt postconfirmation + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + + // Verify postconfirmation worked + PCPStorage.SuperBlockCommitment memory postconfirmed = pcp.getPostconfirmedCommitment(targetHeight); + assert(postconfirmed.commitment == commitment.commitment); + + // confirm current superblock height + uint256 currentHeightNew = pcp.getLastPostconfirmedSuperBlockHeight(); + assertEq(currentHeightNew, currentHeight + 1); + + } + + + /// @notice Test that a confirmation and postconfirmation by single attester works if they have majority stake + function testPostconfirmationWithMajorityStake() public { + // Setup with alice having majority + (address alice, address bob, ) = setupGenesisWithThreeAttesters(34, 33, 33); + + // Create commitment for height 1 + uint256 targetHeight = 1; + + PCPStorage.SuperBlockCommitment memory commitment = makeHonestCommitment(targetHeight); + + // Submit commitments + vm.prank(alice); + pcp.submitSuperBlockCommitment(commitment); + vm.prank(bob); + pcp.submitSuperBlockCommitment(commitment); + + // Verify commitments were stored + PCPStorage.SuperBlockCommitment memory aliceCommitment = pcp.getCommitmentByAttester(targetHeight, alice); + PCPStorage.SuperBlockCommitment memory bobCommitment = pcp.getCommitmentByAttester(targetHeight, bob); + assert(aliceCommitment.commitment == commitment.commitment); + assert(bobCommitment.commitment == commitment.commitment); + + // Verify postconfirmer state + assert(pcp.isWithinPostconfirmerPrivilegeDuration(commitment)); + assertEq(pcp.getSuperBlockHeightAssignedEpoch(targetHeight), pcp.getAcceptingEpoch()); + + // Attempt postconfirmation + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + + // Verify postconfirmation + PCPStorage.SuperBlockCommitment memory postconfirmed = pcp.getPostconfirmedCommitment(targetHeight); + assert(postconfirmed.commitment == commitment.commitment); + assertEq(pcp.getLastPostconfirmedSuperBlockHeight(), targetHeight); + } + + /// @notice Test that a confirmation and postconfirmation by single attester fails if they have majority stake + function testPostconfirmationWithoutMajorityStake() public { + // Setup with no one having majority + (address alice, address bob, ) = setupGenesisWithThreeAttesters(33, 33, 34); + + // Create commitment for height 1 + uint256 targetHeight = 1; + + PCPStorage.SuperBlockCommitment memory commitment = makeHonestCommitment(targetHeight); + + // Submit commitments + vm.prank(alice); + pcp.submitSuperBlockCommitment(commitment); + vm.prank(bob); + pcp.submitSuperBlockCommitment(commitment); + + // Verify commitments were stored + PCPStorage.SuperBlockCommitment memory aliceCommitment = pcp.getCommitmentByAttester(targetHeight, alice); + PCPStorage.SuperBlockCommitment memory bobCommitment = pcp.getCommitmentByAttester(targetHeight, bob); + assert(aliceCommitment.commitment == commitment.commitment); + assert(bobCommitment.commitment == commitment.commitment); + + // Verify postconfirmer state + assert(pcp.isWithinPostconfirmerPrivilegeDuration(commitment)); + assertEq(pcp.getSuperBlockHeightAssignedEpoch(targetHeight), pcp.getAcceptingEpoch()); + + // Attempt postconfirmation - this should fail because there's no supermajority + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + + // Verify height hasn't changed (postconfirmation didn't succeed) + assertEq(pcp.getLastPostconfirmedSuperBlockHeight(), 0); + } + + /// @notice Test that stake activation and postconfirmation works away from the Genesis. + /// TODO at genesis this behaves different and we should test this, specifically. unstake and stake are directly applied to epoch 0 until it is rolled over + function testStakeActivationAndPostconfirmation() public { + // Setup initial attesters with equal stakes, but Carol hasn't staked yet + (address alice, address bob, address carol) = setupGenesisWithThreeAttesters(1, 1, 0); + + // Create commitment for height 1 by the only stable attester + PCPStorage.SuperBlockCommitment memory commitment = makeHonestCommitment(1); + vm.prank(bob); + pcp.submitSuperBlockCommitment(commitment); + + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + assertEq(pcp.getLastPostconfirmedSuperBlockHeight(), 0, "Last postconfirmed superblock height should be 0, as no supermajority was reached (2/3 < threshold)"); + assertEq(pcp.getAcceptingEpoch(),0, "Accepting epoch should be 0"); + + vm.warp(epochDuration); + assertEq(pcp.getPresentEpoch(),1, "Present epoch should be 1"); + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + assertEq(pcp.getAcceptingEpoch(),1, "Accepting epoch should be 1"); + + vm.prank(carol); + staking.stake(address(pcp), moveToken, 1); + assertEq(pcp.getStakeForAcceptingEpoch(address(moveToken), carol), 0, "Carol's stake is still 0."); + // Alice unstakes so her commitment is not counted in the next accepting epoch + vm.prank(alice); + staking.unstake(address(pcp), address(moveToken), 1); + assertEq(pcp.getStakeForAcceptingEpoch(address(moveToken), alice), 1, "Alice's stake should still be 1"); + assertEq(staking.getUnstake(address(pcp), 2, address(moveToken), alice), 1, "Alice's unstake in epoch 2 should be 1"); + + // Warp to next epoch + vm.warp(2*epochDuration); + assertEq(pcp.getPresentEpoch(), 2, "Present epoch should be 2"); + assertEq(pcp.getAcceptingEpoch(), 1, "Accepting epoch should be 1"); + + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + assertEq(pcp.getAcceptingEpoch(), 2, "Accepting epoch should be 2"); + + assertEq(pcp.getStakeForAcceptingEpoch(address(moveToken), carol), 1, "Carol's stake should already be active"); + assertEq(pcp.getStakeForAcceptingEpoch(address(moveToken), alice), 0, "Alice's stake should be 0"); + assertEq(moveToken.balanceOf(alice), 2, "Alice's balance should be 2"); + + // Carol commits to height 1 + vm.prank(carol); + pcp.submitSuperBlockCommitment(commitment); + + // perform postconfirmation + vm.prank(carol); + pcp.postconfirmSuperBlocksAndRollover(); + assertEq(pcp.getLastPostconfirmedSuperBlockHeight(), 1, "Last postconfirmed superblock height should be 1, as supermajority was reached (2/2 > threshold)"); + } + + function testSetMinCommitmentAge() public { + // Set min commitment age to a too long value + vm.expectRevert(PCP.minCommitmentAgeForPostconfirmationTooLong.selector); + pcp.setMinCommitmentAgeForPostconfirmation(epochDuration); + + // Set min commitment age to 1/10 of epochDuration + uint256 minAge = epochDuration/10; + pcp.setMinCommitmentAgeForPostconfirmation(minAge); + assertEq(pcp.minCommitmentAgeForPostconfirmation(), minAge, "Min commitment age should be updated to 1/10 of epochDuration"); + } + + function testMinCommitmentAge() public { + // Setup with Alice having supermajority stake + address alice = setupGenesisWithOneAttester(1); + assertEq(pcp.getMinCommitmentAgeForPostconfirmation(), 0, "The unset min commitment age should be 0"); + uint256 minAge = 1 minutes; + pcp.setMinCommitmentAgeForPostconfirmation(minAge); + assertEq(pcp.getMinCommitmentAgeForPostconfirmation(), minAge, "Min commitment age should be updated to 1 minutes"); + + vm.prank(alice); + pcp.submitSuperBlockCommitment(makeHonestCommitment(1)); + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + assertEq(pcp.getLastPostconfirmedSuperBlockHeight(), 0, "Immediate postconfirmation should fail."); + + vm.warp(block.timestamp + minAge); // note that time starts at 1, not 0 + // Now postconfirmation should succeed + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + assertEq(pcp.getLastPostconfirmedSuperBlockHeight(), 1); + } + + + // ---------------------------------------------------------------- + // -------- Postconfirmer tests -------------------------------------- + // ---------------------------------------------------------------- + + /// @notice Test that getPostconfirmerStartTime correctly calculates term start times + function testPostconfirmerStartTime() public { + // Test at block 0 + assertEq(block.timestamp, 1, "Current time should be 1"); // TODO why is it 1? and not 0? + assertEq(postconfirmerDuration, pcp.getPostconfirmerDuration(), "Postconfirmer term should be correctly set"); + assertEq(pcp.getPostconfirmerStartTime(), 0, "Postconfirmer term should start at (1) time 0"); + + // Test at half an postconfirmer term + vm.warp(postconfirmerDuration-1); + assertEq(pcp.getPostconfirmerStartTime(), 0, "Postconfirmer term should start at (2) time 0"); + + // Test at an postconfirmer term boundary + vm.warp(postconfirmerDuration); + assertEq(pcp.getPostconfirmerStartTime(), postconfirmerDuration, "Postconfirmer term should start at (3) time postconfirmerDuration"); + + // Test at an postconfirmer term boundary + vm.warp(postconfirmerDuration+1); + assertEq(pcp.getPostconfirmerStartTime(), postconfirmerDuration, "Postconfirmer term should start at (4) time postconfirmerDuration"); + + // Test at 1.5 postconfirmer terms + vm.warp(2 * postconfirmerDuration ); + assertEq(pcp.getPostconfirmerStartTime(), 2 * postconfirmerDuration, "Postconfirmer term should start at (5) time 2 * postconfirmerDuration"); + } + + /// @notice Test setting postconfirmer duration with validation + function testSetPostconfirmerDuration() public { + // Check the epoch duration is set correctly + assertEq(epochDuration, staking.getEpochDuration(address(pcp))); + // Test valid duration (less than half epoch duration) + uint256 validDuration = epochDuration / 2 - 1; + pcp.setPostconfirmerDuration(validDuration); + assertEq(pcp.getPostconfirmerDuration(), validDuration, "Duration should be updated to valid value"); + + // Test duration too long compared to epoch (>= epochDuration/2) + uint256 invalidDuration = epochDuration / 2; + vm.expectRevert(PCP.PostconfirmerDurationTooLongForEpoch.selector); + pcp.setPostconfirmerDuration(invalidDuration); + assertEq(pcp.getPostconfirmerDuration(), validDuration, "Duration should remain at previous valid value"); + + // Test duration equal to epoch duration (should fail) + vm.expectRevert(PCP.PostconfirmerDurationTooLongForEpoch.selector); + pcp.setPostconfirmerDuration(epochDuration); + assertEq(pcp.getPostconfirmerDuration(), validDuration, "Duration should remain at previous valid value"); + } + + /// @notice Test that getPostconfirmer correctly selects an postconfirmer based on block hash + function testGetPostconfirmer() public { + // Setup with three attesters with equal stakes + (, address bob, address carol) = setupGenesisWithThreeAttesters(1, 1, 1); + uint256 myPostconfirmerDuration = 13; + pcp.setPostconfirmerDuration(myPostconfirmerDuration); + assertEq(myPostconfirmerDuration,pcp.getPostconfirmerDuration(),"Postconfirmer duration not set correctly"); + + address initialPostconfirmer = pcp.getPostconfirmer(); + assertEq(initialPostconfirmer, bob, "Postconfirmer should be bob"); + + vm.warp(myPostconfirmerDuration-1); + assertEq(pcp.getPostconfirmer(), initialPostconfirmer, "Postconfirmer should not change within term"); + + // Move two postconfirmer terms (moving one resulted still in bob as postconfirmer with current randomness) + vm.warp(2*myPostconfirmerDuration); + address newPostconfirmer = pcp.getPostconfirmer(); + assertEq(pcp.getPostconfirmerStartTime(),2*myPostconfirmerDuration,"Postconfirmer start time should be myPostconfirmerDuration"); + assertEq(newPostconfirmer, carol, "New postconfirmer should be Carol"); + } + + + // ---------------------------------------------------------------- + // -------- Attester reward tests -------------------------------------- + // ---------------------------------------------------------------- + + function testAttesterRewardPoints() public { + // Setup with Alice having supermajority-enabling stake + (address alice, address bob, address carol) = setupGenesisWithThreeAttesters(2, 1, 1); + uint256 aliceInitialBalance = moveToken.balanceOf(alice); + uint256 bobInitialBalance = moveToken.balanceOf(bob); + uint256 carolInitialBalance = moveToken.balanceOf(carol); + pcp.setRewardPerPostconfirmationPoint(0); + + // Exit genesis epoch + vm.warp(epochDuration); + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + assertEq(pcp.getAcceptingEpoch(), 1, "Should have exited genesis"); + + // Submit commitments for height 1 honestly (Alice and Bob > 2/3) + vm.prank(alice); + pcp.submitSuperBlockCommitment(makeHonestCommitment(1)); + vm.prank(bob); + pcp.submitSuperBlockCommitment(makeHonestCommitment(1)); + vm.prank(carol); + pcp.submitSuperBlockCommitment(makeDishonestCommitment(1)); + + // Check initial reward points + assertEq(pcp.getAttesterRewardPoints(pcp.getAcceptingEpoch(), alice), 0, "Alice should have no points yet"); + assertEq(pcp.getAttesterRewardPoints(pcp.getAcceptingEpoch(), bob), 0, "Bob should have no points yet"); + assertEq(pcp.getAttesterRewardPoints(pcp.getAcceptingEpoch(), carol), 0, "Carol should have no points yet"); + + // Trigger postconfirmation + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + + // New reward points + assertEq(pcp.getAttesterRewardPoints(pcp.getAcceptingEpoch(), alice), 1, "Alice should have 1 points"); + assertEq(pcp.getAttesterRewardPoints(pcp.getAcceptingEpoch(), bob), 1, "Bob should have 1 point"); + assertEq(pcp.getAttesterRewardPoints(pcp.getAcceptingEpoch(), carol), 0, "Carol should have 0 point"); + + // Alice and Carol commit to height 2 honestly (Alice + Carol > 2/3) + vm.prank(alice); + pcp.submitSuperBlockCommitment(makeHonestCommitment(2)); + vm.prank(bob); + pcp.submitSuperBlockCommitment(makeDishonestCommitment(2)); + vm.prank(carol); + pcp.submitSuperBlockCommitment(makeHonestCommitment(2)); + + // Trigger postconfirmation, reward distribution by rolling over to next epoch + vm.warp(2*epochDuration); + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + assertEq(pcp.getAcceptingEpoch(), 2, "Should be in epoch 2"); + + // Verify rewards were distributed and points were cleared + assertEq(pcp.attesterRewardPoints(pcp.getAcceptingEpoch(), alice), 0, "Alice's points should be cleared"); + assertEq(pcp.attesterRewardPoints(pcp.getAcceptingEpoch(), bob), 0, "Bob's points should be cleared"); + assertEq(pcp.attesterRewardPoints(pcp.getAcceptingEpoch(), carol), 0, "Carol's points should be cleared"); + assertEq(moveToken.balanceOf(alice), aliceInitialBalance + pcp.getStakeForAcceptingEpoch(address(moveToken), alice) * 2, "Alice reward not correct."); + assertEq(moveToken.balanceOf(bob), bobInitialBalance + pcp.getStakeForAcceptingEpoch(address(moveToken), bob), "Bob reward not correct."); + assertEq(moveToken.balanceOf(carol), carolInitialBalance + pcp.getStakeForAcceptingEpoch(address(moveToken), carol), "Carol reward not correct."); + } + + /// @notice Test that postconfirmation rewards are distributed correctly when the postconfirmer is live + function testPostconfirmationRewardsLivePostconfirmer() public { + uint256 stake = 7; + // alice has supermajority stake + uint256 aliceStake = 3*stake; + uint256 bobStake = stake; + (address alice, address bob, ) = setupGenesisWithThreeAttesters(aliceStake, bobStake, 0); + uint256 aliceInitialBalance = moveToken.balanceOf(alice); + uint256 bobInitialBalance = moveToken.balanceOf(bob); + // set the max postconfirmer non-reactivity time to 1/4 epochDuration + pcp.setPostconfirmerPrivilegeDuration(epochDuration/4); + + vm.prank(alice); + pcp.submitSuperBlockCommitment(makeHonestCommitment(1)); + // check that the first seen timestamp is set + assertGt(pcp.getCommitmentFirstSeenAt(makeHonestCommitment(1)), 0, "Commitment first seen at should be set"); + + assertEq(pcp.getPostconfirmer(), bob, "Bob should be the postconfirmer but its not"); + assertEq(pcp.isWithinPostconfirmerPrivilegeDuration(makeHonestCommitment(1)), true, "Postconfirmer should be live"); + + // postconfirmer postconfirms while postconfirmer is live + vm.prank(bob); + pcp.postconfirmSuperBlocksAndRollover(); + assertEq(pcp.getAcceptingEpoch(), 0, "Should be in epoch 0"); + assertEq(pcp.getLastPostconfirmedSuperBlockHeight(), 1, "Last postconfirmed superblock height should be 1"); + assertEq(pcp.getAttesterRewardPoints(pcp.getAcceptingEpoch(), alice), 1, "Alice should have 1 attester points"); + assertEq(pcp.getPostconfirmerRewardPoints(pcp.getAcceptingEpoch(), bob), 1, "Bob should have 1 postconfirmer points"); + assertEq(moveToken.balanceOf(alice), aliceInitialBalance, "Alice should have not received any rewards yet"); + assertEq(moveToken.balanceOf(bob), bobInitialBalance, "Bob should not have received any rewards yet"); + + // warp to next epoch + vm.warp(epochDuration); + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + assertEq(pcp.getLastPostconfirmedSuperBlockHeight(), 1); + assertEq(pcp.getAcceptingEpoch(), 1, "Should be in epoch 1"); + + // Verify rewards: + assertEq(moveToken.balanceOf(alice), aliceInitialBalance + aliceStake, "Alice should have received the rewards"); + assertEq(moveToken.balanceOf(bob), bobInitialBalance + bobStake, "Bob should have received the rewards"); + } + + /// @notice Test that volunteer postconfirmation rewards are not distributed to volunteer postconfirmer when postconfirmer is live + // TODO once the postconfirmer can get postconfirm points within the postconfirmer privilege window, whether or not the height has previously been postconfirmed, this test should be updated + function testVolunteerPostconfirmationRewardsLivePostconfirmer() public { + uint256 aliceStake = 9; + // alice has supermajority stake + (address alice, address bob, ) = setupGenesisWithThreeAttesters(aliceStake, 0, 0); + uint256 aliceInitialBalance = moveToken.balanceOf(alice); + uint256 bobInitialBalance = moveToken.balanceOf(bob); + + vm.prank(alice); + pcp.submitSuperBlockCommitment(makeHonestCommitment(1)); + + assertEq(pcp.getPostconfirmer(), alice, "Alice should be the postconfirmer since it is the only staked attester."); + assertEq(pcp.isWithinPostconfirmerPrivilegeDuration(makeHonestCommitment(1)), true, "Postconfirmer should be live"); + + // volunteer postconfirmer postconfirms while postconfirmer is live + vm.prank(bob); + pcp.postconfirmSuperBlocksAndRollover(); + assertEq(pcp.getLastPostconfirmedSuperBlockHeight(), 1); + + // bob should not get any postconfirmer rewards + assertEq(moveToken.balanceOf(bob), bobInitialBalance, "Bob should not have received any rewards"); + assertEq(pcp.getPostconfirmerRewardPoints(pcp.getAcceptingEpoch(), bob), 0, "Bob should have 0 postconfirmer points"); + assertEq(pcp.getAttesterRewardPoints(pcp.getAcceptingEpoch(), alice), 1, "Alice should have 1 attester points"); + assertEq(pcp.getPostconfirmerRewardPoints(pcp.getAcceptingEpoch(), alice), 0, "Alice should have 0 postconfirmer points"); + + vm.warp(epochDuration); + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + assertEq(pcp.getAcceptingEpoch(), 1, "Should be in epoch 1"); + + // alice should get the postconfirmer rewards + assertEq(moveToken.balanceOf(bob), bobInitialBalance, "Bob should not have received any rewards"); + assertEq(moveToken.balanceOf(alice), aliceInitialBalance + aliceStake, "Alice should have received the attester rewards"); + } + + /// @notice Test that postconfirmation rewards are distributed to volunteer postconfirmer when postconfirmer is not live + // TODO this test should probably be merged with the above test + function testVolunteerPostconfirmationRewardsNotLivePostconfirmer() public { + // alice has supermajority stake + uint256 stake =13; + uint256 aliceStake = 3*stake; + uint256 bobStake = stake; + (address alice, address bob, ) = setupGenesisWithThreeAttesters(aliceStake, bobStake, 0); + uint256 aliceInitialBalance = moveToken.balanceOf(alice); + uint256 bobInitialBalance = moveToken.balanceOf(bob); + uint256 thisPostconfirmerDuration = pcp.getPostconfirmerDuration(); + + // set the time windows + assertEq(pcp.getMinCommitmentAgeForPostconfirmation(), 0, "Min commitment age should be 0"); + uint256 thisPostconfirmerPriviledgeWindow = epochDuration/100; + pcp.setPostconfirmerPrivilegeDuration(thisPostconfirmerPriviledgeWindow); + assertEq(pcp.getPostconfirmerPrivilegeDuration(), thisPostconfirmerPriviledgeWindow, "Max postconfirmer non-reactivity time should be 1/100 epochDuration"); + assertGt(thisPostconfirmerDuration, thisPostconfirmerPriviledgeWindow, "Postconfirmer term should be greater than thisPostconfirmerPriviledgeWindow"); + + vm.prank(alice); + pcp.submitSuperBlockCommitment(makeHonestCommitment(1)); + + assertEq(pcp.getPostconfirmer(), bob, "bob should be the postconfirmer"); + assertEq(pcp.isWithinPostconfirmerPrivilegeDuration(makeHonestCommitment(1)), true, "Postconfirmer should be live"); + + // warp out of postconfirmer privilege window + vm.warp(block.timestamp + thisPostconfirmerPriviledgeWindow + 1 ); // TODO check why + 1 is needed + assertEq(pcp.isWithinPostconfirmerPrivilegeDuration(makeHonestCommitment(1)), false, "Postconfirmer should not be live"); + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + assertEq(pcp.getAcceptingEpoch(), 0, "Should be in epoch 0"); + assertEq(pcp.getLastPostconfirmedSuperBlockHeight(), 1, "Last postconfirmed superblock height should be 1"); + assertEq(pcp.getAttesterRewardPoints(pcp.getAcceptingEpoch(), alice), 1, "Alice should have 1 attester points"); + assertEq(pcp.getPostconfirmerRewardPoints(pcp.getAcceptingEpoch(), alice), 1, "Alice should have 1 postconfirmer points"); + + // warp to next epoch + vm.warp(epochDuration); + vm.prank(bob); + pcp.postconfirmSuperBlocksAndRollover(); + assertEq(pcp.getAcceptingEpoch(), 1, "Should be in epoch 1"); + + assertEq(moveToken.balanceOf(alice), aliceInitialBalance + aliceStake + aliceStake, "Alice should have received the attester and postconfirmer rewards"); + assertEq(moveToken.balanceOf(bob), bobInitialBalance, "Bob should have received no rewards"); + } + + // ---------------------------------------------------------------- + // -------- Postconfirmer reward tests -------------------------------------- + // ---------------------------------------------------------------- + + + // An postconfirmer that is in place for postconfirmerDuration time should be replaced by a new postconfirmer after their term ended. + // TODO reward logic is not yet implemented + function testPostconfirmerRewards() public { + (address alice, address bob, ) = setupGenesisWithThreeAttesters(1, 1, 0); + assertEq(pcp.getPostconfirmer(), bob, "Bob should be the postconfirmer"); + + // make superBlock commitments + PCPStorage.SuperBlockCommitment memory initCommitment = makeHonestCommitment(1); + vm.prank(alice); + pcp.submitSuperBlockCommitment(initCommitment); + vm.prank(bob); + pcp.submitSuperBlockCommitment(initCommitment); + + // bob postconfirms and gets a reward + vm.prank(bob); + pcp.postconfirmSuperBlocksAndRollover(); + assertEq(pcp.getLastPostconfirmedSuperBlockHeight(), 1); + + // make second superblock commitment + PCPStorage.SuperBlockCommitment memory secondCommitment = makeHonestCommitment(2); + vm.prank(alice); + pcp.submitSuperBlockCommitment(secondCommitment); + vm.prank(bob); + pcp.submitSuperBlockCommitment(secondCommitment); + + // alice can postconfirm, but does not get the reward + // TODO check that bob did not get the reward + vm.prank(alice); + pcp.postconfirmSuperBlocksAndRollover(); + assertEq(pcp.getLastPostconfirmedSuperBlockHeight(), 2); + + // bob tries to postconfirm, but already done by alice + // TODO: bob should still get the reward + vm.prank(bob); + pcp.postconfirmSuperBlocksAndRollover(); + assertEq(pcp.getLastPostconfirmedSuperBlockHeight(), 2); + } + + +} diff --git a/protocol/pcp/dlu/eth/contracts/test/staking/MovementStaking.t.sol b/protocol/pcp/dlu/eth/contracts/test/staking/MovementStaking.t.sol new file mode 100644 index 00000000..bba4caea --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/test/staking/MovementStaking.t.sol @@ -0,0 +1,441 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../../src/staking/MovementStaking.sol"; +import "../../src/token/MOVETokenDev.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +contract MovementStakingTest is Test { + bytes32 public constant WHITELIST_ROLE = keccak256("WHITELIST_ROLE"); + address public multisig = address(this); + MOVETokenDev public moveToken; + MovementStaking public staking; + + function setUp() public { + MOVETokenDev moveTokenImpl = new MOVETokenDev(); + TransparentUpgradeableProxy moveProxy = new TransparentUpgradeableProxy( + address(moveTokenImpl), + address(this), + abi.encodeWithSignature("initialize(address)", multisig) + ); + + MovementStaking stakingImpl = new MovementStaking(); + TransparentUpgradeableProxy stakingProxy = new TransparentUpgradeableProxy( + address(stakingImpl), + address(this), + abi.encodeWithSignature("initialize(address)", address(moveProxy)) + ); + moveToken = MOVETokenDev(address(moveProxy)); + staking = MovementStaking(address(stakingProxy)); + } + + function testCannotInitializeTwice() public { + // Attempt to initialize again should fail + vm.expectRevert(bytes4(0xf92ee8a9)); + staking.initialize(moveToken); + } + + function testRegister() public { + + // Register a new domain + address payable domain = payable(vm.addr(1)); + address[] memory custodians = new address[](1); + custodians[0] = address(moveToken); + vm.prank(domain); + staking.registerDomain(1 seconds, custodians); + + assertEq(staking.getAcceptingEpoch(domain), 0); + } + + function testWhitelist() public { + + // Our whitelister + address whitelister = vm.addr(1); + // Whitelist them + staking.whitelistAddress(whitelister); + assertEq(staking.hasRole(WHITELIST_ROLE, whitelister), true); + // Remove them from the whitelist + staking.removeAddressFromWhitelist(whitelister); + assertEq(staking.hasRole(WHITELIST_ROLE, whitelister), false); + // As a whitelister let's see if I can whitelist myself + vm.prank(whitelister); + vm.expectRevert(); + staking.whitelistAddress(whitelister); + } + + function testSimpleStaker() public { + // Register a new staker + address payable domain = payable(vm.addr(1)); + + address[] memory custodians = new address[](1); + custodians[0] = address(moveToken); + + vm.prank(domain); + staking.registerDomain(3600 seconds, custodians); + assertEq(staking.getEpochDuration(domain), 3600 seconds, "Epoch duration not set correctly"); + + // stake at the domain + address payable staker = payable(vm.addr(2)); + + staking.whitelistAddress(staker); + moveToken.mint(staker, 100); + + vm.prank(staker); + moveToken.approve(address(staking), 100); + + vm.prank(staker); + staking.stake(domain, moveToken, 100); + + assertEq(moveToken.balanceOf(staker), 0); + assertEq(staking.getStake(domain, 0, address(moveToken), staker), 100); + } + + function testSimpleGenesisCeremony() public { + // Register a new staker + address payable domain = payable(vm.addr(1)); + address[] memory custodians = new address[](1); + custodians[0] = address(moveToken); + vm.prank(domain); + staking.registerDomain(1 seconds, custodians); + assertEq(staking.getEpochDuration(domain), 1 seconds, "Epoch duration not set correctly"); + + // genesis ceremony + address payable staker = payable(vm.addr(2)); + staking.whitelistAddress(staker); + moveToken.mint(staker, 100); + vm.prank(staker); + moveToken.approve(address(staking), 100); + vm.prank(staker); + staking.stake(domain, moveToken, 100); + vm.prank(domain); + staking.acceptGenesisCeremony(); + + assertNotEq(staking.currentAcceptingEpochByDomain(domain), 0); + assertEq(staking.getStakeForAcceptingEpoch(domain, address(moveToken), staker), 100); + + vm.expectRevert(IMovementStaking.GenesisAlreadyAccepted.selector); + vm.prank(domain); + staking.acceptGenesisCeremony(); + } + + function testSimpleRolloverEpoch() public { + + + // Register a new staker + address payable domain = payable(vm.addr(1)); + address[] memory custodians = new address[](1); + custodians[0] = address(moveToken); + vm.prank(domain); + staking.registerDomain(1 seconds, custodians); + + // genesis ceremony + address payable staker = payable(vm.addr(2)); + staking.whitelistAddress(staker); + moveToken.mint(staker, 100); + staking.whitelistAddress(staker); + vm.prank(staker); + moveToken.approve(address(staking), 100); + vm.prank(staker); + staking.stake(domain, moveToken, 100); + vm.prank(domain); + staking.acceptGenesisCeremony(); + + // rollover epoch + for (uint256 i = 0; i < 10; i++) { + vm.warp((i + 1) * 1 seconds); + uint256 epochBefore = staking.getAcceptingEpoch(domain); + vm.prank(domain); + staking.rollOverEpoch(); + uint256 epochAfter = staking.getAcceptingEpoch(domain); + assertEq(epochAfter, epochBefore + 1); + assertEq(staking.getStakeForAcceptingEpoch(domain, address(moveToken), staker), 100); + } + } + + function testUnstakeRolloverEpoch() public { + + + // Register a new staker + address payable domain = payable(vm.addr(1)); + address[] memory custodians = new address[](1); + custodians[0] = address(moveToken); + vm.prank(domain); + staking.registerDomain(1 seconds, custodians); + + // genesis ceremony + address payable staker = payable(vm.addr(2)); + staking.whitelistAddress(staker); + moveToken.mint(staker, 100); + vm.prank(staker); + moveToken.approve(address(staking), 100); + vm.prank(staker); + staking.stake(domain, moveToken, 100); + vm.prank(domain); + staking.acceptGenesisCeremony(); + + for (uint256 i = 0; i < 10; i++) { + vm.warp((i + 1) * 1 seconds); + uint256 epochBefore = staking.getAcceptingEpoch(domain); + + // unstake + vm.prank(staker); + staking.unstake(domain, address(moveToken), 10); + assertEq(staking.getStakeForAcceptingEpoch(domain, address(moveToken), staker), 100 - (i * 10)); + assertEq(moveToken.balanceOf(staker), i * 10); + + // roll over + vm.prank(domain); + staking.rollOverEpoch(); + uint256 epochAfter = staking.getAcceptingEpoch(domain); + assertEq(epochAfter, epochBefore + 1); + } + } + + function testUnstakeAndStakeRolloverEpoch() public { + + + // Register a new staker + address payable domain = payable(vm.addr(1)); + address[] memory custodians = new address[](1); + custodians[0] = address(moveToken); + vm.prank(domain); + staking.registerDomain(1 seconds, custodians); + + // genesis ceremony + address payable staker = payable(vm.addr(2)); + staking.whitelistAddress(staker); + moveToken.mint(staker, 150); + vm.prank(staker); + moveToken.approve(address(staking), 100); + vm.prank(staker); + staking.stake(domain, moveToken, 100); + vm.prank(domain); + staking.acceptGenesisCeremony(); + + for (uint256 i = 0; i < 10; i++) { + vm.warp((i + 1) * 1 seconds); + uint256 epochBefore = staking.getAcceptingEpoch(domain); + + // unstake + vm.prank(staker); + staking.unstake(domain, address(moveToken), 10); + + // stake + vm.prank(staker); + moveToken.approve(address(staking), 5); + vm.prank(staker); + staking.stake(domain, moveToken, 5); + + // check stake + assertEq(staking.getStakeForAcceptingEpoch(domain, address(moveToken), staker), (100 - (i * 10)) + (i * 5)); + assertEq(moveToken.balanceOf(staker), (50 - (i + 1) * 5) + (i * 10)); + + // roll over + vm.prank(domain); + staking.rollOverEpoch(); + uint256 epochAfter = staking.getAcceptingEpoch(domain); + assertEq(epochAfter, epochBefore + 1); + } + } + + function testUnstakeStakeAndSlashRolloverEpoch() public { + + + // Register a new staker + address payable domain = payable(vm.addr(1)); + address[] memory custodians = new address[](1); + custodians[0] = address(moveToken); + vm.prank(domain); + staking.registerDomain(1 seconds, custodians); + + // genesis ceremony + address payable staker = payable(vm.addr(2)); + staking.whitelistAddress(staker); + moveToken.mint(staker, 150); + vm.prank(staker); + moveToken.approve(address(staking), 100); + vm.prank(staker); + staking.stake(domain, moveToken, 100); + vm.prank(domain); + staking.acceptGenesisCeremony(); + + for (uint256 i = 0; i < 5; i++) { + vm.warp((i + 1) * 1 seconds); + uint256 epochBefore = staking.getAcceptingEpoch(domain); + + // unstake + vm.prank(staker); + staking.unstake(domain, address(moveToken), 10); + + // stake + vm.prank(staker); + moveToken.approve(address(staking), 5); + vm.prank(staker); + staking.stake(domain, moveToken, 5); + + // check stake + assertEq( + staking.getStakeForAcceptingEpoch(domain, address(moveToken), staker), (100 - (i * 10)) + (i * 5) - (i * 1) + ); + assertEq(moveToken.balanceOf(staker), (50 - (i + 1) * 5) + (i * 10)); + + // slash + vm.prank(domain); + address[] memory custodians1 = new address[](1); + custodians1[0] = address(moveToken); + address[] memory attesters1 = new address[](1); + attesters1[0] = staker; + uint256[] memory amounts1 = new uint256[](1); + amounts1[0] = 1; + uint256[] memory refundAmounts1 = new uint256[](1); + refundAmounts1[0] = 0; + staking.slash(custodians1, attesters1, amounts1, refundAmounts1); + + // slash immediately takes effect + assertEq( + staking.getStakeForAcceptingEpoch(domain, address(moveToken), staker), + (100 - (i * 10)) + (i * 5) - ((i + 1) * 1) + ); + + // roll over + vm.prank(domain); + staking.rollOverEpoch(); + uint256 epochAfter = staking.getAcceptingEpoch(domain); + assertEq(epochAfter, epochBefore + 1); + } + } + + function testHalbornReward() public { + + // Register a domain + address payable domain = payable(vm.addr(1)); + address[] memory custodians = new address[](1); + custodians[0] = address(moveToken); + vm.prank(domain); + staking.registerDomain(1 seconds, custodians); + + // Alice stakes 1000 tokens + address payable alice = payable(vm.addr(2)); + staking.whitelistAddress(alice); + moveToken.mint(alice, 1000); + vm.prank(alice); + moveToken.approve(address(staking), 1000); + vm.prank(alice); + staking.stake(domain, moveToken, 1000); + + // Bob stakes 100 tokens + address payable bob = payable(vm.addr(3)); + staking.whitelistAddress(bob); + moveToken.mint(bob, 100); + vm.prank(bob); + moveToken.approve(address(staking), 100); + vm.prank(bob); + staking.stake(domain, moveToken, 100); + + // Assertions on stakes and balances + assertEq(moveToken.balanceOf(alice), 0); + assertEq(moveToken.balanceOf(bob), 0); + assertEq(moveToken.balanceOf(address(staking)), 1100); + assertEq(staking.getCustodianStake(domain, 0, address(moveToken)), 1100); + assertEq(staking.getStake(domain, 0, address(moveToken), alice), 1000); + assertEq(staking.getStake(domain, 0, address(moveToken), bob), 100); + + // Charlie calls reward with himself only to steal tokens + address charlie = vm.addr(4); + address[] memory attesters = new address[](1); + attesters[0] = charlie; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 1000; + vm.prank(charlie); + vm.expectRevert( + abi.encodeWithSignature( + "ERC20InsufficientAllowance(address,uint256,uint256)", + address(staking), // should be called by the staking contract + 0, + 1000 + ) + ); + staking.rewardArray(attesters, amounts, custodians); + } + + function testRewardSingleAttester() public { + // Register a domain + address domain = address(this); + address[] memory custodians = new address[](1); + custodians[0] = address(moveToken); + staking.registerDomain(7200 seconds, custodians); + // Setup domain to pay rewards + // moveToken.mint(domain, 100); // Domain has already funds + moveToken.approve(address(staking), 100); // Domain approves staking + + + // Alice stakes 1000 tokens + address payable alice = payable(vm.addr(2)); + staking.whitelistAddress(alice); + moveToken.mint(alice, 1000); + vm.prank(alice); + moveToken.approve(address(staking), 1000); + vm.prank(alice); + staking.stake(domain, moveToken, 1000); + + // Assertions on stakes and balances + assertEq(moveToken.balanceOf(alice), 0, "Alice should have 0 tokens"); + assertEq(moveToken.balanceOf(address(staking)), 1000, "Staking contract should have 1000 tokens"); + assertEq(staking.getCustodianStake(domain, 0, address(moveToken)), 1000, "Custodian stake should be 1000"); + assertEq(staking.getStake(domain, 0, address(moveToken), alice), 1000, "Alice stake should be 1000"); + + // Reward alice + console.log("This is moveToken:", address(moveToken)); + console.log("This is staking:", address(staking)); + console.log("This is alice:", alice); + console.log("This is domain:", domain); + vm.prank(domain); // Domain calls reward + staking.rewardFromDomain(alice, 100, address(moveToken)); + + // Verify reward was received + assertEq(moveToken.balanceOf(alice), 100, "Alice should have received 100 tokens"); + } + + function testSetAcceptingEpoch() public { + // Setup + address domain = makeAddr("domain"); + vm.startPrank(domain); + + // Register domain + address[] memory custodians = new address[](1); + custodians[0] = address(moveToken); + staking.registerDomain(10, custodians); + staking.acceptGenesisCeremony(); + + // Test setting accepting epoch + uint256 currentEpoch = staking.getAcceptingEpoch(domain); + uint256 epochDuration = staking.getEpochDuration(domain); + vm.warp(block.timestamp + 10 * epochDuration); + uint256 presentEpoch = staking.getEpochByL1BlockTime(domain); + + // Should succeed when newEpoch is between current and present + uint256 newEpoch = currentEpoch + 1; + vm.assume(newEpoch <= presentEpoch); + staking.setAcceptingEpoch(domain, newEpoch); + assertEq(staking.getAcceptingEpoch(domain), newEpoch); + + // Should fail when caller is not domain + vm.stopPrank(); + vm.expectRevert("UNAUTHORIZED"); + staking.setAcceptingEpoch(domain, newEpoch + 1); + + // Should fail when newEpoch > present epoch + vm.startPrank(domain); + vm.expectRevert("NEW_EPOCH_MUST_BE_LESS_THAN_PRESENT_EPOCH"); + staking.setAcceptingEpoch(domain, presentEpoch + 1); + + // Should fail when newEpoch <= current epoch + vm.expectRevert("NEW_EPOCH_MUST_BE_HIGHER_THAN_CURRENT_EPOCH"); + staking.setAcceptingEpoch(domain, newEpoch); + } + +} + + + diff --git a/protocol/pcp/dlu/eth/contracts/test/staking/base/BaseStaking.t.sol b/protocol/pcp/dlu/eth/contracts/test/staking/base/BaseStaking.t.sol new file mode 100644 index 00000000..b1a32cdc --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/test/staking/base/BaseStaking.t.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../../../src/staking/base/BaseStaking.sol"; + +contract BaseStakingTest is Test { + + function testInitialize() public { + + BaseStaking staking = new BaseStaking(); + staking.initialize(); + + } + + function testCannotInitializeTwice() public { + + BaseStaking staking = new BaseStaking(); + staking.initialize(); + + // Attempt to initialize again should fail + vm.expectRevert(bytes4(0xf92ee8a9)); + staking.initialize(); + + } +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/test/token/Faucet.t.sol b/protocol/pcp/dlu/eth/contracts/test/token/Faucet.t.sol new file mode 100644 index 00000000..b56ff324 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/test/token/Faucet.t.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {MOVEFaucet, IERC20} from '../../src/token/faucet/MOVEFaucet.sol'; +import {MOVETokenDev} from '../../src/token/MOVETokenDev.sol'; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +contract MOVEFaucetTest is Test { + MOVEFaucet public faucet; + MOVETokenDev public token; + + receive() external payable {} + fallback() external payable {} + + function setUp() public { + MOVETokenDev tokenImpl = new MOVETokenDev(); + TransparentUpgradeableProxy tokenProxy = new TransparentUpgradeableProxy(address(tokenImpl), address(this), abi.encodeWithSignature("initialize(address)", address(this))); + token = MOVETokenDev(address(tokenProxy)); + faucet = new MOVEFaucet(IERC20(address(token))); + } + + function testFaucet() public { + vm.warp(1 days); + + token.balanceOf(address(this)); + + token.transfer(address(faucet), 20 * 10 ** token.decimals()); + + vm.deal(address(0x1337), 2* 10**17); + + vm.startPrank(address(0x1337)); + vm.expectRevert("MOVEFaucet: eth invalid amount"); + faucet.faucet{value: 10**16}(); + + faucet.faucet{value: 10**17}(); + assertEq(token.balanceOf(address(0x1337)), 10 * 10 ** token.decimals()); + + vm.expectRevert("MOVEFaucet: balance must be less than determine amount of MOVE"); + faucet.faucet{value: 10**17}(); + + token.transfer(address(0xdead), token.balanceOf(address(0x1337))); + + vm.expectRevert("MOVEFaucet: rate limit exceeded"); + faucet.faucet{value: 10**17}(); + + vm.warp(block.timestamp + 1 days); + faucet.faucet{value: 10**17}(); + vm.stopPrank(); + vm.prank(address(this)); + uint256 balance = address(this).balance; + faucet.withdraw(); + assertEq(address(faucet).balance, 0); + assertEq(address(this).balance, balance + 2*10**17); + } + + +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/test/token/MOVEToken.t.sol b/protocol/pcp/dlu/eth/contracts/test/token/MOVEToken.t.sol new file mode 100644 index 00000000..f958ee7c --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/test/token/MOVEToken.t.sol @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "forge-std/console2.sol"; +import {MOVEToken} from "../../src/token/MOVEToken.sol"; +import {MOVETokenDev} from "../../src/token/MOVETokenDev.sol"; +import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {CompatibilityFallbackHandler} from "@safe-smart-account/contracts/handler/CompatibilityFallbackHandler.sol"; +import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; +import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; + +function string2Address(bytes memory str) pure returns (address addr) { + bytes32 data = keccak256(str); + assembly { + mstore(0, data) + addr := mload(0) + } +} + +contract MOVETokenTest is Test { + MOVEToken public token; + TransparentUpgradeableProxy public tokenProxy; + ProxyAdmin public admin; + MOVEToken public moveTokenImplementation; + MOVETokenDev public moveTokenImplementation2; + TimelockController public timelock; + string public moveSignature = "initialize(address,address)"; + address public multisig = address(0x00db70A9e12537495C359581b7b3Bc3a69379A00); + address public anchorage = address(0xabc); + bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00; + + function setUp() public { + moveTokenImplementation = new MOVEToken(); + moveTokenImplementation2 = new MOVETokenDev(); + + uint256 minDelay = 1 days; + address[] memory proposers = new address[](5); + address[] memory executors = new address[](1); + + proposers[0] = string2Address("Andy"); + proposers[1] = string2Address("Bob"); + proposers[2] = string2Address("Charlie"); + proposers[3] = string2Address("David"); + proposers[4] = string2Address("Eve"); + executors[0] = multisig; + + timelock = new TimelockController(minDelay, proposers, executors, address(0x0)); + + vm.recordLogs(); + // Deploy proxy + tokenProxy = new TransparentUpgradeableProxy( + address(moveTokenImplementation), + address(timelock), + abi.encodeWithSignature(moveSignature, multisig, anchorage) + ); + Vm.Log[] memory entries = vm.getRecordedLogs(); + + admin = ProxyAdmin(entries[entries.length - 2].emitter); + + token = MOVEToken(address(tokenProxy)); + } + + function testCannotInitializeTwice() public { + // Initialize the contract + vm.expectRevert(bytes4(0xf92ee8a9)); + token.initialize(multisig, anchorage); + } + + function testDecimals() public view { + assertEq(token.decimals(), 8); + } + + function testTotalSupply() public view { + assertEq(token.totalSupply(), 10000000000 * 10 ** 8); + } + + function testMultisigBalance() public view { + assertEq(token.balanceOf(anchorage), 10000000000 * 10 ** 8); + } + + /// @notice Fuzzing test to verify admin role permissions + /// @param other Any address to test against + function testAdminRoleFuzz(address other) public { + // Verify multisig has admin role (primary admin) + assertEq(token.hasRole(DEFAULT_ADMIN_ROLE, multisig), true); + + // Verify other addresses only have admin if they are the multisig + assertEq(token.hasRole(DEFAULT_ADMIN_ROLE, other), other == multisig); + + // Verify custody address (anchorage) does not have admin role + assertEq(token.hasRole(DEFAULT_ADMIN_ROLE, anchorage), false); + + // Test role granting permissions (skip multisig since it should succeed) + vm.assume(other != multisig); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), DEFAULT_ADMIN_ROLE) + ); + token.grantRole(DEFAULT_ADMIN_ROLE, other); + } + + function testUpgradeFromTimelock() public { + assertEq(admin.owner(), address(timelock)); + + vm.prank(string2Address("Andy")); + timelock.schedule( + address(admin), + 0, + abi.encodeWithSignature( + "upgradeAndCall(address,address,bytes)", + address(tokenProxy), + address(moveTokenImplementation2), + "" + ), + bytes32(0), + bytes32(0), + block.timestamp + 1 days + ); + + vm.warp(block.timestamp + 1 days + 1); + + vm.prank(multisig); + timelock.execute( + address(admin), + 0, + abi.encodeWithSignature( + "upgradeAndCall(address,address,bytes)", + address(tokenProxy), + address(moveTokenImplementation2), + "" + ), + bytes32(0), + bytes32(0) + ); + + // Check the token details + assertEq(token.decimals(), 8); + assertEq(token.totalSupply(), 10000000000 * 10 ** 8); + assertEq(token.balanceOf(anchorage), 10000000000 * 10 ** 8); + } + + function testTransferToNewTimelock() public { + assertEq(admin.owner(), address(timelock)); + + uint256 minDelay = 1 days; + address[] memory proposers = new address[](5); + address[] memory executors = new address[](1); + + // Andy has been compromised, Albert will be the new proposer + // we need to transfer the proxyAdmin ownership to a new timelock + proposers[0] = string2Address("Albert"); + proposers[1] = string2Address("Bob"); + proposers[2] = string2Address("Charlie"); + proposers[3] = string2Address("David"); + proposers[4] = string2Address("Eve"); + + executors[0] = multisig; + + TimelockController newTimelock = new TimelockController(minDelay, proposers, executors, address(0x0)); + vm.prank(string2Address("Bob")); + timelock.schedule( + address(admin), + 0, + abi.encodeWithSignature("transferOwnership(address)", address(newTimelock)), + bytes32(0), + bytes32(0), + block.timestamp + 1 days + ); + + vm.warp(block.timestamp + 1 days + 1); + vm.prank(multisig); + timelock.execute( + address(admin), + 0, + abi.encodeWithSignature("transferOwnership(address)", address(newTimelock)), + bytes32(0), + bytes32(0) + ); + + assertEq(admin.owner(), address(newTimelock)); + } + + function testGrants() public { + testUpgradeFromTimelock(); + + vm.prank(multisig); + MOVETokenDev(address(token)).grantRoles(multisig); + + // Check the token details + assertEq(MOVETokenDev(address(token)).hasRole(MOVETokenDev(address(token)).MINTER_ROLE(), multisig), true); + } + + function testMint() public { + testUpgradeFromTimelock(); + + vm.prank(multisig); + MOVETokenDev(address(token)).grantRoles(multisig); + uint256 intialBalance = MOVETokenDev(address(token)).balanceOf(address(0x1337)); + // Mint tokens + vm.prank(multisig); + MOVETokenDev(address(token)).mint(address(0x1337), 100); + + // Check the token details + assertEq(MOVETokenDev(address(token)).balanceOf(address(0x1337)), intialBalance + 100); + } + + function testRevokeMinterRole() public { + testUpgradeFromTimelock(); + + vm.prank(multisig); + MOVETokenDev(address(token)).grantRoles(multisig); + + assertEq(MOVETokenDev(address(token)).hasRole(MOVETokenDev(address(token)).MINTER_ROLE(), multisig), true); + + vm.startPrank(multisig); + MOVETokenDev(address(token)).mint(address(0x1337), 100); + // Revoke minter role + MOVETokenDev(address(token)).revokeMinterRole(multisig); + + // Check the token details + assertEq(MOVETokenDev(address(token)).hasRole(MOVETokenDev(address(token)).MINTER_ROLE(), multisig), false); + + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + multisig, + MOVETokenDev(address(token)).MINTER_ROLE() + ) + ); + MOVETokenDev(address(token)).mint(address(0x1337), 100); + vm.stopPrank(); + } + + function testGrantRevokeMinterAdminRole() public { + testUpgradeFromTimelock(); + vm.prank(multisig); + MOVETokenDev(address(token)).grantRoles(multisig); + assertEq(MOVETokenDev(address(token)).hasRole(MOVETokenDev(address(token)).MINTER_ROLE(), multisig), true); + vm.startPrank(multisig); + + MOVETokenDev(address(token)).mint(address(0x1337), 100); + // Revoke minter role + MOVETokenDev(address(token)).revokeMinterRole(multisig); + + // Check the token details + assertEq(MOVETokenDev(address(token)).hasRole(MOVETokenDev(address(token)).MINTER_ROLE(), multisig), false); + + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + multisig, + MOVETokenDev(address(token)).MINTER_ROLE() + ) + ); + MOVETokenDev(address(token)).mint(address(0x1337), 100); + + assertEq( + MOVETokenDev(address(token)).hasRole(MOVETokenDev(address(token)).MINTER_ROLE(), address(0x1337)), false + ); + // Grant minter role + MOVETokenDev(address(token)).grantMinterRole(address(0x1337)); + vm.stopPrank(); + vm.prank(address(0x1337)); + MOVETokenDev(address(token)).mint(address(0x1337), 100); + + // Check the token details + assertEq( + MOVETokenDev(address(token)).hasRole(MOVETokenDev(address(token)).MINTER_ROLE(), address(0x1337)), true + ); + + // Revoke minter role + vm.prank(multisig); + MOVETokenDev(address(token)).revokeMinterRole(address(0x1337)); + + assertEq( + MOVETokenDev(address(token)).hasRole(MOVETokenDev(address(token)).MINTER_ROLE(), address(0x1337)), false + ); + + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + address(0x1337), + MOVETokenDev(address(token)).MINTER_ROLE() + ) + ); + vm.prank(address(0x1337)); + MOVETokenDev(address(token)).mint(address(0x1337), 100); + + assertEq(MOVETokenDev(address(token)).hasRole(MOVETokenDev(address(token)).MINTER_ADMIN_ROLE(), multisig), true); + // Revoke minter admin role + vm.startPrank(multisig); + MOVETokenDev(address(token)).revokeMinterAdminRole(multisig); + + assertEq( + MOVETokenDev(address(token)).hasRole(MOVETokenDev(address(token)).MINTER_ADMIN_ROLE(), multisig), false + ); + + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + multisig, + MOVETokenDev(address(token)).MINTER_ADMIN_ROLE() + ) + ); + MOVETokenDev(address(token)).grantMinterRole(multisig); + + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + multisig, + MOVETokenDev(address(token)).MINTER_ROLE() + ) + ); + MOVETokenDev(address(token)).mint(address(0x1337), 100); + vm.stopPrank(); + } +} diff --git a/protocol/pcp/dlu/eth/contracts/test/token/MOVETokenV2.t.sol b/protocol/pcp/dlu/eth/contracts/test/token/MOVETokenV2.t.sol new file mode 100644 index 00000000..f333e39a --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/test/token/MOVETokenV2.t.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../../src/token/MOVETokenDev.sol"; +import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {console} from "forge-std/console.sol"; +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; + +contract MOVETokenDevTest is Test { + MOVETokenDev public token; + ProxyAdmin public admin; + string public moveSignature = "initialize(address)"; + address public multisig = 0x00db70A9e12537495C359581b7b3Bc3a69379A00; + bytes32 public MINTER_ROLE; + bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00; + + function setUp() public { + MOVETokenDev moveTokenImplementation = new MOVETokenDev(); + + // Deploy proxies + TransparentUpgradeableProxy moveProxy = new TransparentUpgradeableProxy( + address(moveTokenImplementation), address(multisig), abi.encodeWithSignature(moveSignature, multisig) + ); + token = MOVETokenDev(address(moveProxy)); + MINTER_ROLE = token.MINTER_ROLE(); + } + + function testCannotInitializeTwice() public { + vm.startPrank(multisig); + // Initialize the contract + vm.expectRevert(); + token.initialize(multisig); + vm.stopPrank(); + } + + function testGrants() public view { + // Check the token details + assertEq(token.hasRole(MINTER_ROLE, multisig), true); + } + + function testMint() public { + vm.startPrank(multisig); + uint256 intialBalance = token.balanceOf(address(0x1337)); + // Mint tokens + token.mint(address(0x1337), 100); + + // Check the token details + assertEq(token.balanceOf(address(0x1337)), intialBalance + 100); + vm.stopPrank(); + } + + function testRevokeMinterRole() public { + vm.startPrank(multisig); + assertEq(token.hasRole(MINTER_ROLE, multisig), true); + + token.mint(address(0x1337), 100); + // Revoke minter role + token.revokeMinterRole(multisig); + + // Check the token details + assertEq(token.hasRole(MINTER_ROLE, multisig), false); + + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, multisig, MINTER_ROLE) + ); + token.mint(address(0x1337), 100); + vm.stopPrank(); + } + + function testGrantRevokeMinterAdminRole() public { + vm.startPrank(multisig); + assertEq(token.hasRole(MINTER_ROLE, multisig), true); + + token.mint(address(0x1337), 100); + // Revoke minter role + token.revokeMinterRole(multisig); + + // Check the token details + assertEq(token.hasRole(MINTER_ROLE, multisig), false); + + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, multisig, MINTER_ROLE) + ); + token.mint(address(0x1337), 100); + + assertEq(token.hasRole(MINTER_ROLE, address(0x1337)), false); + // Grant minter role + token.grantMinterRole(address(0x1337)); + vm.stopPrank(); + vm.prank(address(0x1337)); + token.mint(address(0x1337), 100); + + // Check the token details + assertEq(token.hasRole(MINTER_ROLE, address(0x1337)), true); + vm.startPrank(multisig); + // Revoke minter role + token.revokeMinterRole(address(0x1337)); + + assertEq(token.hasRole(MINTER_ROLE, address(0x1337)), false); + vm.stopPrank(); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, address(0x1337), MINTER_ROLE + ) + ); + vm.prank(address(0x1337)); + token.mint(address(0x1337), 100); + vm.startPrank(multisig); + assertEq(token.hasRole(token.MINTER_ADMIN_ROLE(), multisig), true); + // Revoke minter admin role + token.revokeMinterAdminRole(multisig); + + assertEq(token.hasRole(token.MINTER_ADMIN_ROLE(), multisig), false); + + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, multisig, token.MINTER_ADMIN_ROLE() + ) + ); + token.grantMinterRole(multisig); + + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, multisig, MINTER_ROLE) + ); + token.mint(address(0x1337), 100); + vm.stopPrank(); + } + + // Tests that non-admin accounts cannot grant roles by checking for the expected revert + function testCannotGrantRoleFuzz(address messenger, address receiver) public { + // repeat with new test if messenger is multisig or 0 + vm.assume(messenger != multisig); + vm.assume(messenger != address(0)); + console.log("............................"); // TODO : if the console logs are modified, the test fail sometimes, why? + console.log("messenger", messenger); + console.log("multisig", multisig); + + // impersonate the messenger address for all subsequent calls + vm.startPrank(messenger); + + // Expect the call to revert with AccessControlUnauthorizedAccount error + // - messenger: the account trying to grant the role + // - DEFAULT_ADMIN_ROLE (0x00): the role needed to grant any role + console.log("... messenger", messenger); + console.logBytes32(DEFAULT_ADMIN_ROLE); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, messenger, DEFAULT_ADMIN_ROLE) + ); + + // Attempt to grant MINTER_ROLE to receiver address + // This should fail since messenger doesn't have DEFAULT_ADMIN_ROLE + try token.grantRole(MINTER_ROLE, receiver) { + fail(); + } catch Error(string memory reason) { + console.log("Revert reason:", reason); + } catch (bytes memory returnData) { + console.logBytes(returnData); + } + + vm.stopPrank(); + } + +} diff --git a/protocol/pcp/dlu/eth/contracts/test/token/base/BaseToken.t.sol b/protocol/pcp/dlu/eth/contracts/test/token/base/BaseToken.t.sol new file mode 100644 index 00000000..c6e90fa0 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/test/token/base/BaseToken.t.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../../../src/token/base/BaseToken.sol"; + +contract BaseTokenTest is Test { + function testInitialize() public { + BaseToken token = new BaseToken(); + + // Call the initialize function + token.initialize("Base Token", "BASE"); + + // Check the token details + assertEq(token.name(), "Base Token"); + assertEq(token.symbol(), "BASE"); + } + + function testCannotInitializeTwice() public { + BaseToken token = new BaseToken(); + + // Initialize the contract + token.initialize("Base Token", "BASE"); + + // Attempt to initialize again should fail + vm.expectRevert(bytes4(0xf92ee8a9)); + token.initialize("Base Token", "BASE"); + } +} diff --git a/protocol/pcp/dlu/eth/contracts/test/token/base/MintableToken.t.sol b/protocol/pcp/dlu/eth/contracts/test/token/base/MintableToken.t.sol new file mode 100644 index 00000000..d7716063 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/test/token/base/MintableToken.t.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../../../src/token/base/MintableToken.sol"; + +contract MintableTokenTest is Test { + + function testInitialize() public { + + MintableToken token = new MintableToken(); + + // Call the initialize function + token.initialize("Base Token", "BASE"); + + // Check the token details + assertEq(token.name(), "Base Token"); + assertEq(token.symbol(), "BASE"); + } + + function testCannotInitializeTwice() public { + + MintableToken token = new MintableToken(); + + // Initialize the contract + token.initialize("Base Token", "BASE"); + + // Attempt to initialize again should fail + vm.expectRevert(bytes4(0xf92ee8a9)); + token.initialize("Base Token", "BASE"); + } +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/test/token/base/WrappedToken.t.sol b/protocol/pcp/dlu/eth/contracts/test/token/base/WrappedToken.t.sol new file mode 100644 index 00000000..7508f080 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/test/token/base/WrappedToken.t.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../../../src/token/base/MintableToken.sol"; +import "../../../src/token/base/WrappedToken.sol"; +// import base access control instead of upgradeable access control + + +contract WrappedTokenTest is Test { + + function testInitialize() public { + + MintableToken underlyingToken = new MintableToken(); + underlyingToken.initialize("Underlying Token", "UNDERLYING"); + + WrappedToken token = new WrappedToken(); + token.initialize("Base Token", "BASE", underlyingToken); + + // Check the token details + assertEq(token.name(), "Base Token"); + assertEq(token.symbol(), "BASE"); + + } + + function testCannotInitializeTwice() public { + + MintableToken underlyingToken = new MintableToken(); + underlyingToken.initialize("Underlying Token", "UNDERLYING"); + + WrappedToken token = new WrappedToken(); + token.initialize("Base Token", "BASE", underlyingToken); + + // Attempt to initialize again should fail + vm.expectRevert(bytes4(0xf92ee8a9)); + token.initialize("Base Token", "BASE", underlyingToken); + + } + + function testGrants() public { + + MintableToken underlyingToken = new MintableToken(); + underlyingToken.initialize("Underlying Token", "UNDERLYING"); + + WrappedToken token = new WrappedToken(); + token.initialize("Base Token", "BASE", underlyingToken); + + underlyingToken.grantMinterRole(address(token)); + assert(underlyingToken.hasRole(underlyingToken.MINTER_ROLE(), address(token))); + + // valid minting succeeds + vm.prank(address(token)); + underlyingToken.mint(address(this), 100); + assert(underlyingToken.balanceOf(address(this)) == 100); + + // invalid minting fails + address payable signer = payable(vm.addr(1)); + vm.prank(signer); + vm.expectRevert(); // todo: catch type + underlyingToken.mint(signer, 100); + + } + +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/test/token/custodian/CustodianToken.t.sol b/protocol/pcp/dlu/eth/contracts/test/token/custodian/CustodianToken.t.sol new file mode 100644 index 00000000..c9f01569 --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/test/token/custodian/CustodianToken.t.sol @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../../../src/token/base/MintableToken.sol"; +import "../../../src/token/custodian/CustodianToken.sol"; +// import base access control instead of upgradeable access control + +contract CustodianTokenTest is Test { + function testInitialize() public { + MintableToken underlyingToken = new MintableToken(); + underlyingToken.initialize("Underlying Token", "UNDERLYING"); + + CustodianToken token = new CustodianToken(); + token.initialize("Custodian Token", "CUSTODIAN", underlyingToken); + + // Check the token details + assertEq(token.name(), "Custodian Token"); + assertEq(token.symbol(), "CUSTODIAN"); + } + + function testCannotInitializeTwice() public { + MintableToken underlyingToken = new MintableToken(); + underlyingToken.initialize("Underlying Token", "UNDERLYING"); + + CustodianToken token = new CustodianToken(); + token.initialize("Custodian Token", "CUSTODIAN", underlyingToken); + + // Attempt to initialize again should fail + vm.expectRevert(bytes4(0xf92ee8a9)); + token.initialize("Custodian Token", "CUSTODIAN", underlyingToken); + } + + function testGrants() public { + MintableToken underlyingToken = new MintableToken(); + underlyingToken.initialize("Underlying Token", "UNDERLYING"); + + CustodianToken token = new CustodianToken(); + token.initialize("Custodian Token", "CUSTODIAN", underlyingToken); + + underlyingToken.grantMinterRole(address(token)); + assert( + underlyingToken.hasRole( + underlyingToken.MINTER_ROLE(), + address(token) + ) + ); + + // valid minting succeeds + vm.prank(address(token)); + underlyingToken.mint(address(this), 100); + assert(underlyingToken.balanceOf(address(this)) == 100); + + // invalid minting fails + address payable signer = payable(vm.addr(1)); + vm.prank(signer); + vm.expectRevert(); // todo: catch type + underlyingToken.mint(signer, 100); + } + + function testCustodianMint() public { + MintableToken underlyingToken = new MintableToken(); + underlyingToken.initialize("Underlying Token", "UNDERLYING"); + + CustodianToken token = new CustodianToken(); + token.initialize("Custodian Token", "CUSTODIAN", underlyingToken); + + underlyingToken.grantMinterRole(address(token)); + assert( + underlyingToken.hasRole( + underlyingToken.MINTER_ROLE(), + address(token) + ) + ); + + // valid minting succeeds + token.mint(address(this), 100); + assert(token.balanceOf(address(this)) == 100); + assert(underlyingToken.balanceOf(address(token)) == 100); + + // valid minting is incremental + address payable signer = payable(vm.addr(1)); + token.mint(signer, 100); + assert(token.balanceOf(signer) == 100); + assert(underlyingToken.balanceOf(address(token)) == 200); + + // signers with the minter role can call through the custodian + token.grantMinterRole(signer); + vm.prank(signer); + token.mint(signer, 100); + assert(token.balanceOf(signer) == 200); + assert(underlyingToken.balanceOf(address(token)) == 300); + + // signers without the minter role cannot call through the custodian + token.revokeMinterRole(signer); + vm.prank(signer); + vm.expectRevert(); // todo: catch type + token.mint(signer, 100); + } + + function testCustodianTransferToValidSink() public { + MintableToken underlyingToken = new MintableToken(); + underlyingToken.initialize("Underlying Token", "UNDERLYING"); + + CustodianToken token = new CustodianToken(); + token.initialize("Custodian Token", "CUSTODIAN", underlyingToken); + + underlyingToken.grantMinterRole(address(token)); + assert( + underlyingToken.hasRole( + underlyingToken.MINTER_ROLE(), + address(token) + ) + ); + + // signers + address payable validSink = payable(vm.addr(2)); + token.grantTransferSinkRole(validSink); + address payable alice = payable(vm.addr(5)); + + // transfer to valid sink succeeds + token.mint(alice, 100); + vm.prank(alice); + token.transfer(validSink, 100); + assert(token.balanceOf(alice) == 0); + assert(underlyingToken.balanceOf(validSink) == 100); + } + + function testCustodianTransferToInvalidSink() public { + MintableToken underlyingToken = new MintableToken(); + underlyingToken.initialize("Underlying Token", "UNDERLYING"); + + CustodianToken token = new CustodianToken(); + token.initialize("Custodian Token", "CUSTODIAN", underlyingToken); + + underlyingToken.grantMinterRole(address(token)); + assert( + underlyingToken.hasRole( + underlyingToken.MINTER_ROLE(), + address(token) + ) + ); + + // signers + address payable invalidSink = payable(vm.addr(2)); + address payable alice = payable(vm.addr(5)); + + // transfer to invalid sink fails + token.mint(alice, 100); + vm.prank(alice); + vm.expectRevert(); // todo: catch type + token.transfer(invalidSink, 100); + } + + function testCustodianBuyValidSource() public { + MintableToken underlyingToken = new MintableToken(); + underlyingToken.initialize("Underlying Token", "UNDERLYING"); + + CustodianToken token = new CustodianToken(); + token.initialize("Custodian Token", "CUSTODIAN", underlyingToken); + + underlyingToken.grantMinterRole(address(token)); + assert( + underlyingToken.hasRole( + underlyingToken.MINTER_ROLE(), + address(token) + ) + ); + + // signers + address payable validSource = payable(vm.addr(2)); + token.grantBuyerRole(validSource); + address payable alice = payable(vm.addr(5)); + + // fund the valid source in the underlying token + underlyingToken.mint(validSource, 100); + + // approve the custodian to spend the underlying token + vm.prank(validSource); + underlyingToken.approve(address(token), 100); + + // buy from valid source succeeds + vm.prank(validSource); + token.buyCustodialToken(alice, 100); + assert(token.balanceOf(alice) == 100); + assert(underlyingToken.balanceOf(address(token)) == 100); + assert(underlyingToken.balanceOf(validSource) == 0); + } + + function testCustodianBuyInvalidSource() public { + MintableToken underlyingToken = new MintableToken(); + underlyingToken.initialize("Underlying Token", "UNDERLYING"); + + CustodianToken token = new CustodianToken(); + token.initialize("Custodian Token", "CUSTODIAN", underlyingToken); + + underlyingToken.grantMinterRole(address(token)); + assert( + underlyingToken.hasRole( + underlyingToken.MINTER_ROLE(), + address(token) + ) + ); + + // signers + address payable invalidSource = payable(vm.addr(2)); + address payable alice = payable(vm.addr(5)); + + // fund the valid source in the underlying token + underlyingToken.mint(invalidSource, 100); + + // approve the custodian to spend the underlying token + vm.prank(invalidSource); + underlyingToken.approve(address(token), 100); + + // buy from valid source succeeds + vm.prank(invalidSource); + vm.expectRevert(); // todo: catch type + token.buyCustodialToken(alice, 100); + } +} diff --git a/protocol/pcp/dlu/eth/contracts/test/token/locked/LockedToken.t.sol b/protocol/pcp/dlu/eth/contracts/test/token/locked/LockedToken.t.sol new file mode 100644 index 00000000..bf8c2ced --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/test/token/locked/LockedToken.t.sol @@ -0,0 +1,340 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../../../src/token/base/MintableToken.sol"; +import "../../../src/token/locked/LockedToken.sol"; +// import base access control instead of upgradeable access control + +contract LockedTokenTest is Test { + + function testInitialize() public { + + MintableToken underlyingToken = new MintableToken(); + underlyingToken.initialize("Underlying Token", "UNDERLYING"); + + LockedToken token = new LockedToken(); + token.initialize("Locked Token", "LOCKED", underlyingToken); + + // Check the token details + assertEq(token.name(), "Locked Token"); + assertEq(token.symbol(), "LOCKED"); + + } + + function testBasicLock() public { + + MintableToken underlyingToken = new MintableToken(); + underlyingToken.initialize("Underlying Token", "UNDERLYING"); + + LockedToken token = new LockedToken(); + token.initialize("Locked Token", "LOCKED", underlyingToken); + + underlyingToken.grantMinterRole(address(token)); + assert(underlyingToken.hasRole(underlyingToken.MINTER_ROLE(), address(token))); + + // signers + address payable alice = payable(vm.addr(1)); + + // mint locked tokens + address[] memory addresses = new address[](1); + addresses[0] = alice; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 100; + uint256[] memory locks = new uint256[](1); + locks[0] = block.timestamp + 100; + token.mintAndLock( + addresses, + amounts, + amounts, // in this test case, we are not adding separate lock amounts + locks + ); + assert(token.balanceOf(alice) == 100); + assert(underlyingToken.balanceOf(address(token)) == 100); + assert(underlyingToken.balanceOf(alice) == 0); + + vm.warp(block.timestamp + 1); + // cannot release locked tokens + vm.prank(alice); + token.release(); + assert(token.balanceOf(alice) == 100); + assert(underlyingToken.balanceOf(address(token)) == 100); + assert(underlyingToken.balanceOf(alice) == 0); + + // tick forward + vm.warp(block.timestamp + 101); + + // release locked tokens + vm.prank(alice); + token.release(); + assert(token.balanceOf(alice) == 0); + assert(underlyingToken.balanceOf(address(token)) == 0); + assert(underlyingToken.balanceOf(alice) == 100); + + } + + function testLockWithEarnings() public { + + MintableToken underlyingToken = new MintableToken(); + underlyingToken.initialize("Underlying Token", "UNDERLYING"); + + LockedToken token = new LockedToken(); + token.initialize("Locked Token", "LOCKED", underlyingToken); + + underlyingToken.grantMinterRole(address(token)); + + // signers + address payable alice = payable(vm.addr(1)); + + // mint locked tokens + address[] memory addresses = new address[](1); + addresses[0] = alice; + uint256[] memory mintAmounts = new uint256[](1); + mintAmounts[0] = 100; + uint256[] memory lockAmounts = new uint256[](1); + lockAmounts[0] = 150; + uint256[] memory locks = new uint256[](1); + locks[0] = block.timestamp + 100; + token.mintAndLock( + addresses, + mintAmounts, + lockAmounts, + locks + ); + assert(token.balanceOf(alice) == 100); + assert(underlyingToken.balanceOf(address(token)) == 100); + assert(underlyingToken.balanceOf(alice) == 0); + + // cannot release locked tokens + vm.warp(block.timestamp + 1); + vm.prank(alice); + token.release(); + assert(token.balanceOf(alice) == 100); + assert(underlyingToken.balanceOf(address(token)) == 100); + assert(underlyingToken.balanceOf(alice) == 0); + + // alice earns on locked tokens + token.mint(alice, 50); + assert(token.balanceOf(alice) == 150); + + // tick forward + vm.warp(block.timestamp + 101); + + // release locked tokens + vm.prank(alice); + token.release(); + assert(token.balanceOf(alice) == 0); + assert(underlyingToken.balanceOf(address(token)) == 0); + assert(underlyingToken.balanceOf(alice) == 150); + } + + function testLockMultiple() public { + + MintableToken underlyingToken = new MintableToken(); + underlyingToken.initialize("Underlying Token", "UNDERLYING"); + + LockedToken token = new LockedToken(); + token.initialize("Locked Token", "LOCKED", underlyingToken); + + underlyingToken.grantMinterRole(address(token)); + + // signers + address payable alice = payable(vm.addr(1)); + + // mint locked tokens + address[] memory addresses = new address[](3); + addresses[0] = alice; + addresses[1] = alice; + addresses[2] = alice; + uint256[] memory mintAmounts = new uint256[](3); + mintAmounts[0] = 100; + mintAmounts[1] = 50; + mintAmounts[2] = 25; + uint256[] memory lockAmounts = new uint256[](3); + lockAmounts[0] = 100; + lockAmounts[1] = 50; + lockAmounts[2] = 25; + uint256[] memory locks = new uint256[](3); + locks[0] = block.timestamp + 100; + locks[1] = block.timestamp + 200; + locks[2] = block.timestamp + 300; + token.mintAndLock( + addresses, + mintAmounts, + lockAmounts, + locks + ); + assert(token.balanceOf(alice) == 175); + assert(underlyingToken.balanceOf(address(token)) == 175); + assert(underlyingToken.balanceOf(alice) == 0); + + // cannot release locked tokens + vm.warp(block.timestamp + 1); + vm.prank(alice); + token.release(); + assert(token.balanceOf(alice) == 175); + assert(underlyingToken.balanceOf(address(token)) == 175); + assert(underlyingToken.balanceOf(alice) == 0); + + // tick forward + vm.warp(block.timestamp + 301); + + // release locked tokens + vm.prank(alice); + token.release(); + assert(token.balanceOf(alice) == 0); + assert(underlyingToken.balanceOf(address(token)) == 0); + assert(underlyingToken.balanceOf(alice) == 175); + } + + function testLockMultiplePrematureClaim() public { + + MintableToken underlyingToken = new MintableToken(); + underlyingToken.initialize("Underlying Token", "UNDERLYING"); + + LockedToken token = new LockedToken(); + token.initialize("Locked Token", "LOCKED", underlyingToken); + + underlyingToken.grantMinterRole(address(token)); + + // signers + address payable alice = payable(vm.addr(1)); + + // mint locked tokens + address[] memory addresses = new address[](3); + addresses[0] = alice; + addresses[1] = alice; + addresses[2] = alice; + uint256[] memory mintAmounts = new uint256[](3); + mintAmounts[0] = 100; + mintAmounts[1] = 50; + mintAmounts[2] = 25; + uint256[] memory lockAmounts = new uint256[](3); + lockAmounts[0] = 100; + lockAmounts[1] = 50; + lockAmounts[2] = 25; + uint256[] memory locks = new uint256[](3); + locks[0] = block.timestamp + 100; + locks[1] = block.timestamp + 200; + locks[2] = block.timestamp + 400; + token.mintAndLock( + addresses, + mintAmounts, + lockAmounts, + locks + ); + assert(token.balanceOf(alice) == 175); + assert(underlyingToken.balanceOf(address(token)) == 175); + assert(underlyingToken.balanceOf(alice) == 0); + + // cannot release locked tokens + vm.warp(block.timestamp + 1); + vm.prank(alice); + token.release(); + assert(token.balanceOf(alice) == 175); + assert(underlyingToken.balanceOf(address(token)) == 175); + assert(underlyingToken.balanceOf(alice) == 0); + + // tick forward + vm.warp(block.timestamp + 301); + + // release locked tokens + vm.prank(alice); + token.release(); + assert(token.balanceOf(alice) == 25); + assert(underlyingToken.balanceOf(address(token)) == 25); + assert(underlyingToken.balanceOf(alice) == 150); + // two releases occurred, alice lock index 0 should still be present + (uint256 lock1,) = token.locks(alice, 0); + assert(lock1 == 25); + + // tick forward + vm.warp(block.timestamp + 101); + vm.prank(alice); + token.release(); + assert(token.balanceOf(alice) == 0); + assert(underlyingToken.balanceOf(address(token)) == 0); + assert(underlyingToken.balanceOf(alice) == 175); + // Verify that all locks are released by checking that accessing any lock reverts + vm.expectRevert(); + token.locks(alice, 0); // Should revert since no locks should exist + } + + + function testTransferLockedAsset() public { + + MintableToken underlyingToken = new MintableToken(); + underlyingToken.initialize("Underlying Token", "UNDERLYING"); + + LockedToken token = new LockedToken(); + token.initialize("Locked Token", "LOCKED", underlyingToken); + + underlyingToken.grantMinterRole(address(token)); + + // signers + address payable alice = payable(vm.addr(1)); + + // mint locked tokens + address[] memory addresses = new address[](3); + addresses[0] = alice; + addresses[1] = alice; + addresses[2] = alice; + uint256[] memory mintAmounts = new uint256[](3); + mintAmounts[0] = 100; + mintAmounts[1] = 50; + mintAmounts[2] = 25; + uint256[] memory lockAmounts = new uint256[](3); + lockAmounts[0] = 100; + lockAmounts[1] = 50; + lockAmounts[2] = 25; + uint256[] memory locks = new uint256[](3); + locks[0] = block.timestamp + 100; + locks[1] = block.timestamp + 200; + locks[2] = block.timestamp + 400; + token.mintAndLock( + addresses, + mintAmounts, + lockAmounts, + locks + ); + assert(token.balanceOf(alice) == 175); + assert(underlyingToken.balanceOf(address(token)) == 175); + assert(underlyingToken.balanceOf(alice) == 0); + + // cannot release locked tokens + vm.warp(block.timestamp + 1); + vm.prank(alice); + token.release(); + assert(token.balanceOf(alice) == 175); + assert(underlyingToken.balanceOf(address(token)) == 175); + assert(underlyingToken.balanceOf(alice) == 0); + + // tick forward + vm.warp(block.timestamp + 301); + + // release locked tokens + vm.prank(alice); + token.release(); + assert(token.balanceOf(alice) == 25); + assert(underlyingToken.balanceOf(address(token)) == 25); + assert(underlyingToken.balanceOf(alice) == 150); + // two releases occurred, alice lock index 0 should still be present + (uint256 lock1,) = token.locks(alice, 0); + assert(lock1 == 25); + + vm.prank(alice); + token.transfer(address(0x1337), 20); + // tick forward + vm.warp(block.timestamp + 101); + vm.prank(alice); + token.release(); + assert(token.balanceOf(alice) == 0); + assert(underlyingToken.balanceOf(address(token)) == 20); + assert(underlyingToken.balanceOf(alice) == 155); + // call should revert with no locks existent + (uint256 lock2,) = token.locks(alice, 0); + assert(lock2 == 20); + } + + +} \ No newline at end of file diff --git a/protocol/pcp/dlu/eth/contracts/test/token/stlMoveToken.t.sol b/protocol/pcp/dlu/eth/contracts/test/token/stlMoveToken.t.sol new file mode 100644 index 00000000..26e0797d --- /dev/null +++ b/protocol/pcp/dlu/eth/contracts/test/token/stlMoveToken.t.sol @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../../src/token/stlMoveToken.sol"; +import "../../src/token/MOVETokenDev.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +contract stlMoveTokenTest is Test { + address public multisig = address(this); + MOVETokenDev public underlyingToken; + stlMoveToken public token; + + function setUp() public { + MOVETokenDev underlyingTokenImpl = new MOVETokenDev(); + TransparentUpgradeableProxy underlyingTokenProxy = new TransparentUpgradeableProxy( + address(underlyingTokenImpl), + address(this), + abi.encodeWithSignature("initialize(address)", multisig) + ); + + + stlMoveToken tokenImpl = new stlMoveToken(); + TransparentUpgradeableProxy tokenProxy = new TransparentUpgradeableProxy( + address(tokenImpl), + address(this), + abi.encodeWithSignature("initialize(address)", address(underlyingTokenProxy)) + ); + underlyingToken = MOVETokenDev(address(underlyingTokenProxy)); + token = stlMoveToken(address(tokenProxy)); + + // Check the token details + assertEq(token.name(), "Stakable Locked Move Token"); + assertEq(token.symbol(), "stlMOVE"); + } + + function testCannotInitializeTwice() public { + + + // Expect reversion + vm.expectRevert(bytes4(0xf92ee8a9)); + token.initialize(underlyingToken); + } + + function testSimulateStaking() public { + + vm.prank(multisig); + underlyingToken.grantMinterRole(address(token)); + assert(underlyingToken.hasRole(underlyingToken.MINTER_ROLE(), address(token))); + + // signers + address payable alice = payable(vm.addr(1)); + address payable bob = payable(vm.addr(2)); + address payable carol = payable(vm.addr(3)); + address payable dave = payable(vm.addr(4)); + + // mint locked tokens + address[] memory addresses = new address[](6); + addresses[0] = alice; + addresses[1] = bob; + addresses[2] = carol; + addresses[3] = dave; + addresses[4] = alice; + addresses[5] = bob; + uint256[] memory mintAmounts = new uint256[](6); + mintAmounts[0] = 100; + mintAmounts[1] = 100; + mintAmounts[2] = 100; + mintAmounts[3] = 100; + mintAmounts[4] = 0; + mintAmounts[5] = 0; + uint256[] memory lockAmounts = new uint256[](6); + lockAmounts[0] = 100; + lockAmounts[1] = 100; + lockAmounts[2] = 100; + lockAmounts[3] = 100; + lockAmounts[4] = UINT256_MAX; + lockAmounts[5] = UINT256_MAX; + uint256[] memory locks = new uint256[](6); + locks[0] = block.timestamp + 100; + locks[1] = block.timestamp + 100; + locks[2] = block.timestamp + 100; + locks[3] = block.timestamp + 100; + locks[4] = block.timestamp + 200; + locks[5] = block.timestamp + 200; + token.mintAndLock(addresses, mintAmounts, lockAmounts, locks); + assertEq(token.balanceOf(alice), 100); + assertEq(token.balanceOf(bob), 100); + assertEq(token.balanceOf(carol), 100); + assertEq(token.balanceOf(dave), 100); + assertEq(underlyingToken.balanceOf(address(token)), 400); + assertEq(underlyingToken.balanceOf(alice), 0); + assertEq(underlyingToken.balanceOf(bob), 0); + assertEq(underlyingToken.balanceOf(carol), 0); + assertEq(underlyingToken.balanceOf(dave), 0); + + vm.warp(block.timestamp + 1); + // cannot release locked tokens + vm.prank(alice); + token.release(); + assertEq(token.balanceOf(alice), 100); + assertEq(underlyingToken.balanceOf(address(token)), 400); + assertEq(underlyingToken.balanceOf(alice), 0); + vm.prank(bob); + token.release(); + assertEq(token.balanceOf(bob), 100); + assertEq(underlyingToken.balanceOf(address(token)), 400); + assertEq(underlyingToken.balanceOf(bob), 0); + vm.prank(carol); + token.release(); + assertEq(token.balanceOf(carol), 100); + assertEq(underlyingToken.balanceOf(address(token)), 400); + assertEq(underlyingToken.balanceOf(carol), 0); + vm.prank(dave); + token.release(); + assertEq(token.balanceOf(dave), 100); + assertEq(underlyingToken.balanceOf(address(token)), 400); + assertEq(underlyingToken.balanceOf(dave), 0); + + // add a transfer sink to represent a staking pool + address payable stakingPool = payable(vm.addr(5)); + token.grantTransferSinkRole(stakingPool); + token.grantBuyerRole(stakingPool); + + // mint some funds on the underlying token for the staking pool to reward stakers + underlyingToken.mint(stakingPool, 100); + + // use to custodian to stake the locked tokens + vm.prank(alice); + token.transfer(stakingPool, 100); + assertEq(token.balanceOf(alice), 0); + assertEq(underlyingToken.balanceOf(stakingPool), 200); + assertEq(underlyingToken.balanceOf(address(token)), 300); + vm.prank(bob); + token.transfer(stakingPool, 100); + assertEq(token.balanceOf(bob), 0); + assertEq(underlyingToken.balanceOf(stakingPool), 300); + assertEq(underlyingToken.balanceOf(address(token)), 200); + vm.prank(carol); + token.transfer(stakingPool, 100); + assertEq(token.balanceOf(carol), 0); + assertEq(underlyingToken.balanceOf(stakingPool), 400); + assertEq(underlyingToken.balanceOf(address(token)), 100); + // ! dave does not stake + + // alice gets reward and cashes out through the custodian, but cannot withdraw + vm.prank(stakingPool); + underlyingToken.approve(address(token), 110); + vm.prank(stakingPool); + token.buyCustodialToken(alice, 110); + assertEq(token.balanceOf(alice), 110); + assertEq(underlyingToken.balanceOf(stakingPool), 290); + assertEq(underlyingToken.balanceOf(address(token)), 210); + vm.prank(alice); + token.release(); + assertEq(token.balanceOf(alice), 110); + assertEq(underlyingToken.balanceOf(alice), 0); + assertEq(underlyingToken.balanceOf(address(token)), 210); + + // bob does not get a reward but cashes out through the custodian + vm.prank(stakingPool); + underlyingToken.approve(address(token), 100); + vm.prank(stakingPool); + token.buyCustodialToken(bob, 100); + assertEq(token.balanceOf(bob), 100); + assertEq(underlyingToken.balanceOf(stakingPool), 190); + assertEq(underlyingToken.balanceOf(address(token)), 310); + vm.prank(bob); + token.release(); + assertEq(token.balanceOf(bob), 100); + assertEq(underlyingToken.balanceOf(bob), 0); + assertEq(underlyingToken.balanceOf(address(token)), 310); + + // time passes + vm.warp(block.timestamp + 101); + + // alice withdraws as much as she can + vm.prank(alice); + token.release(); + assertEq(token.balanceOf(alice), 10); + assertEq(underlyingToken.balanceOf(alice), 100); + assertEq(underlyingToken.balanceOf(address(token)), 210); + + // bob withdraws as much as he can + vm.prank(bob); + token.release(); + assertEq(token.balanceOf(bob), 0); + assertEq(underlyingToken.balanceOf(bob), 100); + assertEq(underlyingToken.balanceOf(address(token)), 110); + + // carol withdraws as much as she can, but it she doesn't have any because here funds are still staked + vm.prank(carol); + token.release(); + assertEq(token.balanceOf(carol), 0); + assertEq(underlyingToken.balanceOf(carol), 0); + assertEq(underlyingToken.balanceOf(address(token)), 110); + + // carol gets reward and cashes out through the custodian + vm.prank(stakingPool); + underlyingToken.approve(address(token), 110); + vm.prank(stakingPool); + token.buyCustodialToken(carol, 110); + assertEq(token.balanceOf(carol), 110); + assertEq(underlyingToken.balanceOf(stakingPool), 80); // spent 20 in total on rewards + assertEq(underlyingToken.balanceOf(address(token)), 220); + + // carol withdraws as much as she can + vm.prank(carol); + token.release(); + assertEq(token.balanceOf(carol), 10); + assertEq(underlyingToken.balanceOf(carol), 100); + assertEq(underlyingToken.balanceOf(address(token)), 120); + + // dave withdraws as much as he can + vm.prank(dave); + token.release(); + assertEq(token.balanceOf(dave), 0); + assertEq(underlyingToken.balanceOf(dave), 100); + assertEq(underlyingToken.balanceOf(address(token)), 20); + + // time passes + vm.warp(block.timestamp + 101); + + // alice withdraws as much as she can; she can withdraw her rewards + vm.prank(alice); + token.release(); + assertEq(token.balanceOf(alice), 0); + assertEq(underlyingToken.balanceOf(alice), 110); + assertEq(underlyingToken.balanceOf(address(token)), 10); + + // bob withdraws as much as he can; he can withdraw his rewards, but doesn't have any + vm.prank(bob); + token.release(); + assertEq(token.balanceOf(bob), 0); + assertEq(underlyingToken.balanceOf(bob), 100); + assertEq(underlyingToken.balanceOf(address(token)), 10); + + // carol withdraws as much as she can; she can't withdraw her rewards + vm.prank(carol); + token.release(); + assertEq(token.balanceOf(carol), 10); + assertEq(underlyingToken.balanceOf(carol), 100); + assertEq(underlyingToken.balanceOf(address(token)), 10); + } +}