diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..762a296 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + strategy: + fail-fast: true + + name: Foundry project + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Show Forge version + run: | + forge --version + + - name: Run Forge fmt + run: | + forge fmt --check + id: fmt + + - name: Run Forge build + run: | + forge build --sizes + id: build + + - name: Run Forge tests + run: | + forge test -vvv + id: test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89815be --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Compiler files +cache/ +out/ +lib/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c5e79a8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "lib/openzeppelin-contracts-upgradeable"] + path = lib/openzeppelin-contracts-upgradeable + url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/README.md b/README.md new file mode 100644 index 0000000..05d8e55 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# Liquid Staking + +This repo contains the contracts and scripts needed to activate a validator that users can stake ZIL with. When delegating stake, users receive a non-rebasing **liquid staking token** (LST) that anyone can send to the validator's delegation contract later on to withdraw the staked ZIL plus the corresponding share of the validator rewards. + +Install Foundry (https://book.getfoundry.sh/getting-started/installation) and the OpenZeppelin contracts before proceeding with the deployment: +``` +forge install OpenZeppelin/openzeppelin-contracts-upgradeable --no-commit +forge install OpenZeppelin/openzeppelin-contracts --no-commit +``` + +## Contract Deployment +The delegation contract is used by delegators to stake and unstake ZIL with the respective validator. It acts as the validator node's control address and interacts with the `Deposit` system contract. `DelegationV1` is the initial implementation of the delegation contract is upgradeable: `DelegationV2` deploys a `NonRebasingLST` contract when it is initialized and `DelegationV3` adds the newest features. + +The delegation contract shall be deployed and upgraded by the account with the private key that was used to run the validator node and was used to generate its BLS keypair and peer id. Make sure the `PRIVATE_KEY` environment variable is set accordingly. + +To deploy `DelegationV1` run +``` +forge script script/deploy_Delegation.s.sol --rpc-url https://api.zq2-devnet.zilliqa.com --broadcast --legacy +``` +You will see an output like this: +``` + Signer is 0x15fc323DFE5D5DCfbeEdc25CEcbf57f676634d77 + Proxy deployed: 0x7A0b7e6D24eDe78260c9ddBD98e828B0e11A8EA2 + Implementation deployed: 0x7C623e01c5ce2e313C223ef2aEc1Ae5C6d12D9DD + Deployed version: 1 + Owner is 0x15fc323DFE5D5DCfbeEdc25CEcbf57f676634d77 +``` + +You will need the proxy address from the above output in all commands below. + +To upgrade the contract to `DelegationV2`, run +``` +forge script script/upgrade_Delegation.s.sol --rpc-url https://api.zq2-devnet.zilliqa.com --broadcast --legacy --sig "run(address payable)" 0x7A0b7e6D24eDe78260c9ddBD98e828B0e11A8EA2 +``` + +The output will look like this: +``` + Signer is 0x15fc323DFE5D5DCfbeEdc25CEcbf57f676634d77 + Upgrading from version: 1 + Owner is 0x15fc323DFE5D5DCfbeEdc25CEcbf57f676634d77 + New implementation deployed: 0x64Fa96a67910956141cc481a43f242C045c10165 + Upgraded to version: 2 +``` + +To upgrade the contract to `DelegationV3`, replace line 33 in `upgrade_Delegation.s.sol` with +```solidity +new DelegationV3() +``` +and run +``` +forge script script/upgrade_Delegation.s.sol --rpc-url https://api.zq2-devnet.zilliqa.com --broadcast --legacy --sig "run(address payable)" 0x7A0b7e6D24eDe78260c9ddBD98e828B0e11A8EA2 +``` +again. + +The output will look like this: +``` + Signer is 0x15fc323DFE5D5DCfbeEdc25CEcbf57f676634d77 + Upgrading from version: 2 + Owner is 0x15fc323DFE5D5DCfbeEdc25CEcbf57f676634d77 + New implementation deployed: 0x90A65311b6C7246FFD1F212C123cfE351a6d65A9 + Upgraded to version: 3 +``` + +## Validator Activation +Now you are ready to use the contract to activate your node as a validator with a deposit of e.g. 10 million ZIL. Run +``` +cast send --legacy --value 10000000ether --rpc-url https://api.zq2-devnet.zilliqa.com --private-key $PRIVATE_KEY \ +0x7a0b7e6d24ede78260c9ddbd98e828b0e11a8ea2 "deposit(bytes,bytes,bytes)" \ +0x92fbe50544dce63cfdcc88301d7412f0edea024c91ae5d6a04c7cd3819edfc1b9d75d9121080af12e00f054d221f876c \ +0x002408011220d5ed74b09dcbe84d3b32a56c01ab721cf82809848b6604535212a219d35c412f \ +0xb14832a866a49ddf8a3104f8ee379d29c136f29aeb8fccec9d7fb17180b99e8ed29bee2ada5ce390cb704bc6fd7f5ce814f914498376c4b8bc14841a57ae22279769ec8614e2673ba7f36edc5a4bf5733aa9d70af626279ee2b2cde939b4bd8a +``` +with the BLS public key, the peer id and the BLS signature of your node. Note that the peer id must be converted from base58 to hex. + +Make sure your node's account has the 10 million ZIL and your node is fully synced before you run the above command. + +Note that the reward address registered for your validator node will be the address of the delegation contract (the proxy contract to be more precise). + +## Staking and Unstaking +If the above transaction was successful and the node became a validator, it can accept delegations. In order to stake e.g. 200 ZIL, run +``` +forge script script/stake_Delegation.s.sol --rpc-url https://api.zq2-devnet.zilliqa.com --broadcast --legacy --sig "run(address payable)" 0x7A0b7e6D24eDe78260c9ddBD98e828B0e11A8EA2 --private-key 0x... +``` +with the private key of the delegator account. Make sure the account's balance can cover the transaction fees plus the 200 ZIL to be delegated. + +The output will look like this: +``` + Running version: 3 + Current stake: 10000000000000000000000000 + Current rewards: 110314207650273223687 + LST address: 0x9e5c257D1c6dF74EaA54e58CdccaCb924669dc83 + Owner balance: 10000000000000000000000000 + Staker balance: 0 + Staker balance: 199993793908430833324 +``` + +Note that the staker LST balance in the output will be different from the actual LST balance which you can query by running +``` +cast call 0x9e5c257D1c6dF74EaA54e58CdccaCb924669dc83 "balanceOf(address)(uint256)" 0xd819fFcE7A58b1E835c25617Db7b46a00888B013 --rpc-url https://api.zq2-devnet.zilliqa.com +``` +This is due to the fact that the above output was generated based on the local script execution before the transaction got submitted to the network. + +You can copy the LST address from the above output and add it to your wallet to transfer your liquid staking tokens to another account if you want to. + +Last but not least, to unstake, run +``` +forge script script/unstake_Delegation.s.sol --rpc-url https://api.zq2-devnet.zilliqa.com --broadcast --legacy --sig "run(address payable)" 0x7A0b7e6D24eDe78260c9ddBD98e828B0e11A8EA2 --private-key 0x... +``` +with the private key of an account that holds some LST. + +The output will look like this: +``` + Running version: 3 + Current stake: 10000000000000000000000000 + Current rewards: 331912568306010928520 + LST address: 0x9e5c257D1c6dF74EaA54e58CdccaCb924669dc83 + Owner balance: 10000000000000000000000000 + Staker balance: 199993784619390291653 + Staker balance: 0 +``` diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..8676616 --- /dev/null +++ b/foundry.toml @@ -0,0 +1,7 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +evm_version = 'shanghai' + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..5e817ed --- /dev/null +++ b/remappings.txt @@ -0,0 +1,2 @@ +@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ +@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ \ No newline at end of file diff --git a/script/Counter.s.sol b/script/Counter.s.sol new file mode 100644 index 0000000..cdc1fe9 --- /dev/null +++ b/script/Counter.s.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Script, console} from "forge-std/Script.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterScript is Script { + Counter public counter; + + function setUp() public {} + + function run() public { + vm.startBroadcast(); + + counter = new Counter(); + + vm.stopBroadcast(); + } +} diff --git a/script/deploy_Delegation.s.sol b/script/deploy_Delegation.s.sol new file mode 100644 index 0000000..833f0fe --- /dev/null +++ b/script/deploy_Delegation.s.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.26; + +import {Script} from "forge-std/Script.sol"; +import {Delegation} from "src/Delegation.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "forge-std/console.sol"; + +contract Deploy is Script { + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address owner = vm.addr(deployerPrivateKey); + console.log("Signer is %s", owner); + + vm.startBroadcast(deployerPrivateKey); + + address implementation = address( + //new Delegation{salt: "zilliqa"}() + new Delegation() + ); + + bytes memory initializerCall = abi.encodeWithSelector( + Delegation.initialize.selector, + owner + ); + + address payable proxy = payable( + //new ERC1967Proxy{salt: "zilliqa"}(implementation, initializerCall) + new ERC1967Proxy(implementation, initializerCall) + ); + + console.log( + "Proxy deployed: %s \r\n Implementation deployed: %s", + proxy, + implementation + ); + + Delegation delegation = Delegation( + proxy + ); + + delegation.stake(); + delegation.unstake(); + delegation.claim(); + + console.log("Deployed version: %s", + delegation.version() + ); + + console.log("Owner is %s", + delegation.owner() + ); + + vm.stopBroadcast(); + } +} diff --git a/script/deposit_Delegation.s.sol b/script/deposit_Delegation.s.sol new file mode 100644 index 0000000..9bfa664 --- /dev/null +++ b/script/deposit_Delegation.s.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.26; + +import {Script} from "forge-std/Script.sol"; +import {NonRebasingLST} from "src/NonRebasingLST.sol"; +import {DelegationV3} from "src/DelegationV3.sol"; +import "forge-std/console.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; + +contract Deposit is Script { + function run(address payable proxy) external { + + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address owner = vm.addr(deployerPrivateKey); + + //address payable proxy = payable(0x7A0b7e6D24eDe78260c9ddBD98e828B0e11A8EA2); + + DelegationV3 delegation = DelegationV3( + proxy + ); +/* + console.log("Running version: %s", + delegation.version() + ); +*/ + //TODO: output the arguments to use with cast send since forge script will fail when it tries to execute the script locally and can't call the BLS signature verification precompile + /*vm.broadcast(deployerPrivateKey); + + delegation.deposit{ + value: 10_000_000 ether + }( + bytes(hex"92fbe50544dce63cfdcc88301d7412f0edea024c91ae5d6a04c7cd3819edfc1b9d75d9121080af12e00f054d221f876c"), + bytes(hex"002408011220d5ed74b09dcbe84d3b32a56c01ab721cf82809848b6604535212a219d35c412f"), //"12D3KooWQDT1rcThrxoSmnCt9n35jrhy5wo4BHsM5JuVz8LstQpN" + bytes(hex"b14832a866a49ddf8a3104f8ee379d29c136f29aeb8fccec9d7fb17180b99e8ed29bee2ada5ce390cb704bc6fd7f5ce814f914498376c4b8bc14841a57ae22279769ec8614e2673ba7f36edc5a4bf5733aa9d70af626279ee2b2cde939b4bd8a") + ); + + console.log("Current stake: %s \r\n Current rewards: %s", + delegation.getStake(), + delegation.getRewards() + ); + */ + bytes memory input = abi.encodeWithSignature( + "deposit(bytes,bytes,bytes)", + bytes(hex"92fbe50544dce63cfdcc88301d7412f0edea024c91ae5d6a04c7cd3819edfc1b9d75d9121080af12e00f054d221f876c"), + bytes(hex"002408011220d5ed74b09dcbe84d3b32a56c01ab721cf82809848b6604535212a219d35c412f"), //"12D3KooWQDT1rcThrxoSmnCt9n35jrhy5wo4BHsM5JuVz8LstQpN" + bytes(hex"b14832a866a49ddf8a3104f8ee379d29c136f29aeb8fccec9d7fb17180b99e8ed29bee2ada5ce390cb704bc6fd7f5ce814f914498376c4b8bc14841a57ae22279769ec8614e2673ba7f36edc5a4bf5733aa9d70af626279ee2b2cde939b4bd8a") + ); + string memory output = 'cast send'; + output = string.concat(output, ' --legacy --value 10000000ether --rpc-url https://api.zq2-devnet.zilliqa.com --private-key '); + output = string.concat(output, Strings.toHexString(deployerPrivateKey)); + output = string.concat(output, ' '); + output = string.concat(output, Strings.toHexString(address(delegation))); + /*console.log("%s \\", output); + console.logBytes(input);*/ + output = string.concat(output, ' "deposit(bytes,bytes,bytes)"'); + output = string.concat(output, ' 0x92fbe50544dce63cfdcc88301d7412f0edea024c91ae5d6a04c7cd3819edfc1b9d75d9121080af12e00f054d221f876c'); + output = string.concat(output, ' 0x002408011220d5ed74b09dcbe84d3b32a56c01ab721cf82809848b6604535212a219d35c412f'); + output = string.concat(output, ' 0xb14832a866a49ddf8a3104f8ee379d29c136f29aeb8fccec9d7fb17180b99e8ed29bee2ada5ce390cb704bc6fd7f5ce814f914498376c4b8bc14841a57ae22279769ec8614e2673ba7f36edc5a4bf5733aa9d70af626279ee2b2cde939b4bd8a'); + console.log(output); + + // use this only for testing if deposit transaction not possible (e.g. no fully synced node available) + /*delegation.setup( + bytes(hex"b0447d886f8499bc0fd4aa21da63d71a0175ddd005d217a00c5304e1272e4a79a7df0ecb878a343582c9f2ca78c8c17f"), + bytes(hex"0024080112203f260505ee97570cbc034831097eddf177c4a49151dffb129abdc209329cc7e0") + ); + */ +/* + NonRebasingLST lst = NonRebasingLST(delegation.getLST()); + console.log("LST address: %s", + address(lst) + ); + + console.log("Owner LST balance: %s", + lst.balanceOf(owner) + ); +*/ + } +} \ No newline at end of file diff --git a/script/stake_Delegation.s.sol b/script/stake_Delegation.s.sol new file mode 100644 index 0000000..8910a33 --- /dev/null +++ b/script/stake_Delegation.s.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.26; + +import {Script} from "forge-std/Script.sol"; +import {NonRebasingLST} from "src/NonRebasingLST.sol"; +import {DelegationV3} from "src/DelegationV3.sol"; +import "forge-std/console.sol"; + +contract Stake is Script { + function run(address payable proxy) external { + + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address owner = vm.addr(deployerPrivateKey); + //console.log("Owner is %s", owner); + + //address staker = 0xd819fFcE7A58b1E835c25617Db7b46a00888B013; + address staker = msg.sender; + //address payable proxy = payable(0x7A0b7e6D24eDe78260c9ddBD98e828B0e11A8EA2); + + DelegationV3 delegation = DelegationV3( + proxy + ); + + console.log("Running version: %s", + delegation.version() + ); + + console.log("Current stake: %s \r\n Current rewards: %s", + delegation.getStake(), + delegation.getRewards() + ); + + NonRebasingLST lst = NonRebasingLST(delegation.getLST()); + console.log("LST address: %s", + address(lst) + ); + + console.log("Owner balance: %s", + lst.balanceOf(owner) + ); + + console.log("Staker balance: %s", + lst.balanceOf(staker) + ); + + //vm.broadcast(staker); + vm.broadcast(); + + delegation.stake{ + value: 200 ether + }(); + + console.log("Staker balance: %s", + lst.balanceOf(staker) + ); + } +} \ No newline at end of file diff --git a/script/unstake_Delegation.s.sol b/script/unstake_Delegation.s.sol new file mode 100644 index 0000000..7fbc5aa --- /dev/null +++ b/script/unstake_Delegation.s.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.26; + +import {Script} from "forge-std/Script.sol"; +import {NonRebasingLST} from "src/NonRebasingLST.sol"; +import {DelegationV3} from "src/DelegationV3.sol"; +import "forge-std/console.sol"; + +contract Unstake is Script { + function run(address payable proxy) external { + + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address owner = vm.addr(deployerPrivateKey); + //console.log("Owner is %s", owner); + + //address staker = 0xd819fFcE7A58b1E835c25617Db7b46a00888B013; + address staker = msg.sender; + //address payable proxy = payable(0x7A0b7e6D24eDe78260c9ddBD98e828B0e11A8EA2); + + DelegationV3 delegation = DelegationV3( + proxy + ); + + console.log("Running version: %s", + delegation.version() + ); + + console.log("Current stake: %s \r\n Current rewards: %s", + delegation.getStake(), + delegation.getRewards() + ); + + NonRebasingLST lst = NonRebasingLST(delegation.getLST()); + console.log("LST address: %s", + address(lst) + ); + + console.log("Owner balance: %s", + lst.balanceOf(owner) + ); + + uint256 stakerBalance = lst.balanceOf(staker); + console.log("Staker balance: %s", + stakerBalance + ); + + //vm.broadcast(staker); + vm.broadcast(); + + delegation.unstake( + stakerBalance + ); + + console.log("Staker balance: %s", + lst.balanceOf(staker) + ); + } +} \ No newline at end of file diff --git a/script/upgrade_Delegation.s.sol b/script/upgrade_Delegation.s.sol new file mode 100644 index 0000000..8f4ec9d --- /dev/null +++ b/script/upgrade_Delegation.s.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.26; + +import {Script} from "forge-std/Script.sol"; +import {Delegation} from "src/Delegation.sol"; +import {DelegationV2} from "src/DelegationV2.sol"; +import {DelegationV3} from "src/DelegationV3.sol"; +import "forge-std/console.sol"; + +contract Upgrade is Script { + function run(address payable proxy) external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address owner = vm.addr(deployerPrivateKey); + console.log("Signer is %s", owner); + + //address payable proxy = payable(0x7A0b7e6D24eDe78260c9ddBD98e828B0e11A8EA2); + + Delegation oldDelegation = Delegation( + proxy + ); + + console.log("Upgrading from version: %s", + oldDelegation.version() + ); + + console.log("Owner is %s", + oldDelegation.owner() + ); + + vm.startBroadcast(deployerPrivateKey); + + address payable newImplementation = payable( + new DelegationV3() + ); + + console.log("New implementation deployed: %s", + newImplementation + ); + + bytes memory reinitializerCall = abi.encodeWithSelector( + DelegationV2.reinitialize.selector + ); + + oldDelegation.upgradeToAndCall( + newImplementation, + reinitializerCall + ); + + DelegationV2 newDelegation = DelegationV2( + proxy + ); + + console.log("Upgraded to version: %s", + newDelegation.version() + ); + + vm.stopBroadcast(); + } +} \ No newline at end of file diff --git a/src/Delegation.sol b/src/Delegation.sol new file mode 100644 index 0000000..ce55d4c --- /dev/null +++ b/src/Delegation.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +// the contract is supposed to be deployed with the node's signer account +contract Delegation is Initializable, PausableUpgradeable, Ownable2StepUpgradeable, UUPSUpgradeable { + + /// @custom:storage-location erc7201:zilliqa.storage.Delegation + struct Storage { + bytes blsPubKey; + bytes peerId; + } + + // keccak256(abi.encode(uint256(keccak256("zilliqa.storage.Delegation")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant STORAGE_POSITION = 0x669e9cfa685336547bc6d91346afdd259f6cd8c0cb6d0b16603b5fa60cb48800; + + function _getStorage() private pure returns (Storage storage $) { + assembly { + $.slot := STORAGE_POSITION + } + } + + address public constant DEPOSIT_CONTRACT = 0x000000000000000000005a494C4445504F534954; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function version() public view returns(uint64) { + return _getInitializedVersion(); + } + + function initialize(address initialOwner) initializer public { + __Pausable_init(); + __Ownable_init(initialOwner); + __Ownable2Step_init(); + __UUPSUpgradeable_init(); + } + + function _authorizeUpgrade(address newImplementation) internal onlyOwner override {} + + // this is to receive rewards + receive() payable external { + } + + // called by the node's account that deployed this contract and is its owner + function deposit( + bytes calldata blsPubKey, + bytes calldata peerId, + bytes calldata signature + ) public payable onlyOwner { + Storage storage $ = _getStorage(); + $.blsPubKey = blsPubKey; + $.peerId = peerId; + } + + function stake() public payable {} + + function unstake() public {} + + function claim() public{} + +} diff --git a/src/DelegationV2.sol b/src/DelegationV2.sol new file mode 100644 index 0000000..59fd839 --- /dev/null +++ b/src/DelegationV2.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "src/NonRebasingLST.sol"; + +// the contract is supposed to be deployed with the node's signer account +// TODO: add events +contract DelegationV2 is Initializable, PausableUpgradeable, Ownable2StepUpgradeable, UUPSUpgradeable { + + /// @custom:storage-location erc7201:zilliqa.storage.Delegation + struct Storage { + bytes blsPubKey; + bytes peerId; + address lst; + } + + // keccak256(abi.encode(uint256(keccak256("zilliqa.storage.Delegation")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant STORAGE_POSITION = 0x4432bdf0e567007e5ad3c8ad839a7f885ef69723eaa659dd9f06e98a97274300; + + function _getStorage() private pure returns (Storage storage $) { + assembly { + $.slot := STORAGE_POSITION + } + } + + uint256 public constant MIN_DELEGATION = 100 ether; + address public constant DEPOSIT_CONTRACT = 0x000000000000000000005a494C4445504F534954; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function version() public view returns(uint64) { + return _getInitializedVersion(); + } + + function reinitialize() reinitializer(version() + 1) public { + Storage storage $ = _getStorage(); + $.lst = address(new NonRebasingLST(address(this))); + } + + function _authorizeUpgrade(address newImplementation) internal onlyOwner override {} + + event Staked(address indexed delegator, uint256 amount, uint256 shares); + event UnStaked(address indexed delegator, uint256 amount, uint256 shares); + + // only for test purposes + receive() payable external {} + + // called by the node's account that deployed this contract and is its owner + // with at least the minimum stake to request activation as a validator + function deposit( + bytes calldata blsPubKey, + bytes calldata peerId, + bytes calldata signature + ) public payable onlyOwner { + Storage storage $ = _getStorage(); + $.blsPubKey = blsPubKey; + $.peerId = peerId; + (bool success, bytes memory data) = DEPOSIT_CONTRACT.call{ + value: msg.value + }( + //abi.encodeWithSignature("deposit(bytes,bytes,bytes,address,address)", + //TODO: replace next line with the previous one once the signer address is implemented + abi.encodeWithSignature("deposit(bytes,bytes,bytes,address)", + blsPubKey, + peerId, + signature, + address(this) + //TODO: enable next line once the signer address is implemented + //owner() + ) + ); + NonRebasingLST($.lst).mint(owner(), msg.value); + require(success, "deposit failed"); + } + + function stake() public payable whenNotPaused { + require(msg.value >= MIN_DELEGATION, "delegated amount too low"); + //TODO: topup deposit by msg.value so that msg.value becomes part of getStake() + Storage storage $ = _getStorage(); + uint256 shares = NonRebasingLST($.lst).totalSupply() * msg.value / (getStake() + getRewards()); + NonRebasingLST($.lst).mint(msg.sender, shares); + emit Staked(msg.sender, msg.value, shares); + } + + function unstake(uint256 shares) public whenNotPaused { + Storage storage $ = _getStorage(); + NonRebasingLST($.lst).burn(msg.sender, shares); + uint256 amount = (getStake() + getRewards()) * shares / NonRebasingLST($.lst).totalSupply(); + //TODO: don't the transfer the amount, msg.sender can claim it after the unbonding period + msg.sender.call{ + value: amount + }(""); + emit UnStaked(msg.sender, amount, shares); + } + + function claim() public whenNotPaused { + } + + function restake() public onlyOwner{ + } + + function getRewards() public view returns(uint256) { + Storage storage $ = _getStorage(); + (bool success, bytes memory data) = DEPOSIT_CONTRACT.staticcall( + abi.encodeWithSignature("getRewardAddress(bytes)", $.blsPubKey) + ); + require(success, "could not retrieve reward address"); + address rewardAddress = abi.decode(data, (address)); + return rewardAddress.balance; + } + + function getStake() public view returns(uint256) { + Storage storage $ = _getStorage(); + (bool success, bytes memory data) = DEPOSIT_CONTRACT.staticcall( + abi.encodeWithSignature("getStake(bytes)", $.blsPubKey) + ); + require(success, "could not retrieve staked amount"); + return abi.decode(data, (uint256)); + } + + function getLST() public view returns(address) { + Storage storage $ = _getStorage(); + return $.lst; + } + +} \ No newline at end of file diff --git a/src/DelegationV3.sol b/src/DelegationV3.sol new file mode 100644 index 0000000..34cd344 --- /dev/null +++ b/src/DelegationV3.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "src/NonRebasingLST.sol"; + +// the contract is supposed to be deployed with the node's signer account +// TODO: add events +contract DelegationV3 is Initializable, PausableUpgradeable, Ownable2StepUpgradeable, UUPSUpgradeable { + + /// @custom:storage-location erc7201:zilliqa.storage.Delegation + struct Storage { + bytes blsPubKey; + bytes peerId; + address lst; + } + + // keccak256(abi.encode(uint256(keccak256("zilliqa.storage.Delegation")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant STORAGE_POSITION = 0x4432bdf0e567007e5ad3c8ad839a7f885ef69723eaa659dd9f06e98a97274300; + + function _getStorage() private pure returns (Storage storage $) { + assembly { + $.slot := STORAGE_POSITION + } + } + + uint256 public constant MIN_DELEGATION = 100 ether; + address public constant DEPOSIT_CONTRACT = 0x000000000000000000005a494C4445504F534954; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function version() public view returns(uint64) { + return _getInitializedVersion(); + } + + function reinitialize() reinitializer(version() + 1) public { + } + + function _authorizeUpgrade(address newImplementation) internal onlyOwner override {} + + event Staked(address indexed delegator, uint256 amount, uint256 shares); + event UnStaked(address indexed delegator, uint256 amount, uint256 shares); + + // currently not called as there is no transaction for issuing rewards + receive() payable external { + require (msg.sender == 0x0000000000000000000000000000000000000000, "rewards must be issues by zero address"); + // topup deposit by msg.value to restake the rewards + // or use them for instant stake withdrawals + } + + // called by the node's account that deployed this contract and is its owner + // with at least the minimum stake to request activation as a validator + function deposit( + bytes calldata blsPubKey, + bytes calldata peerId, + bytes calldata signature + ) public payable onlyOwner { + Storage storage $ = _getStorage(); + $.blsPubKey = blsPubKey; + $.peerId = peerId; + (bool success, bytes memory data) = DEPOSIT_CONTRACT.call{ + value: msg.value + }( + //abi.encodeWithSignature("deposit(bytes,bytes,bytes,address,address)", + //TODO: replace next line with the previous one once the signer address is implemented + abi.encodeWithSignature("deposit(bytes,bytes,bytes,address)", + blsPubKey, + peerId, + signature, + address(this) + //TODO: enable next line once the signer address is implemented + //owner() + ) + ); + NonRebasingLST($.lst).mint(owner(), msg.value); + require(success, "deposit failed"); + } + + function stake() public payable whenNotPaused { + require(msg.value >= MIN_DELEGATION, "delegated amount too low"); + //TODO: topup deposit by msg.value so that msg.value becomes part of getStake() + Storage storage $ = _getStorage(); + uint256 shares = NonRebasingLST($.lst).totalSupply() * msg.value / (getStake() + getRewards()); + NonRebasingLST($.lst).mint(msg.sender, shares); + emit Staked(msg.sender, msg.value, shares); + } + + function unstake(uint256 shares) public whenNotPaused { + Storage storage $ = _getStorage(); + NonRebasingLST($.lst).burn(msg.sender, shares); + uint256 amount = (getStake() + getRewards()) * shares / NonRebasingLST($.lst).totalSupply(); + //TODO: deduct the commission + //TODO: don't transfer the amount, msg.sender can claim it after the unbonding period + (bool success, bytes memory data) = msg.sender.call{ + value: amount + }(""); + require(success, "transfer of funds failed"); + emit UnStaked(msg.sender, amount, shares); + } + + function claim() public whenNotPaused { + // + } + + function restake() public onlyOwner{ + // + } + +/* function getRewards() public view returns(uint256){ + return 24391829365079365070369; + } +*/ + function getRewards() public view returns(uint256) { + Storage storage $ = _getStorage(); + (bool success, bytes memory data) = DEPOSIT_CONTRACT.staticcall( + abi.encodeWithSignature("getRewardAddress(bytes)", $.blsPubKey) + ); + require(success, "could not retrieve reward address"); + address rewardAddress = abi.decode(data, (address)); + return rewardAddress.balance; + } + +/* //TODO: replace with the below getStake2() function once stake() tops up the deposit + function getStake() public view returns(uint256) { + return getStake2() + address(this).balance; + } +*/ + function getStake() public view returns(uint256) { + Storage storage $ = _getStorage(); + (bool success, bytes memory data) = DEPOSIT_CONTRACT.staticcall( + abi.encodeWithSignature("getStake(bytes)", $.blsPubKey) + ); + require(success, "could not retrieve staked amount"); + return abi.decode(data, (uint256)); + } + + function getLST() public view returns(address) { + Storage storage $ = _getStorage(); + return $.lst; + } + + // only for testing purposes, will be removed later + function setup(bytes calldata blsPubKey, bytes calldata peerId) public onlyOwner { + Storage storage $ = _getStorage(); + $.blsPubKey = blsPubKey; + $.peerId = peerId; + owner().call{ + value: address(this).balance + }(""); + $.lst = address(new NonRebasingLST(address(this))); + NonRebasingLST($.lst).mint(owner(), getStake()); + } + +} \ No newline at end of file diff --git a/src/NonRebasingLST.sol b/src/NonRebasingLST.sol new file mode 100644 index 0000000..c9bd82a --- /dev/null +++ b/src/NonRebasingLST.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +contract NonRebasingLST is ERC20, Ownable { + constructor(address initialOwner) + ERC20("MyToken", "MTK") + Ownable(initialOwner) + {} + + function mint(address to, uint256 amount) public onlyOwner { + _mint(to, amount); + } + + function burn(address from, uint256 amount) public onlyOwner { + _burn(from, amount); + } +} + diff --git a/test/Counter.t.sol b/test/Counter.t.sol new file mode 100644 index 0000000..54b724f --- /dev/null +++ b/test/Counter.t.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test, console} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + counter.setNumber(0); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } + + function testFuzz_SetNumber(uint256 x) public { + counter.setNumber(x); + assertEq(counter.number(), x); + } +}