diff --git a/contracts/contracts/beacon/BeaconConsolidation.sol b/contracts/contracts/beacon/BeaconConsolidation.sol index 9fbd561566..62d2b892d8 100644 --- a/contracts/contracts/beacon/BeaconConsolidation.sol +++ b/contracts/contracts/beacon/BeaconConsolidation.sol @@ -11,6 +11,9 @@ library BeaconConsolidation { internal returns (uint256 fee_) { + require(source.length == 48, "Invalid source byte length"); + require(target.length == 48, "Invalid target byte length"); + fee_ = fee(); // Call the Consolidation Request contract with the public keys of the source and target diff --git a/contracts/contracts/beacon/PartialWithdrawal.sol b/contracts/contracts/beacon/PartialWithdrawal.sol new file mode 100644 index 0000000000..ef6113bbf7 --- /dev/null +++ b/contracts/contracts/beacon/PartialWithdrawal.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +library PartialWithdrawal { + /// @notice The address where the withdrawal request is sent to + /// See https://eips.ethereum.org/EIPS/eip-7002 + address internal constant WITHDRAWAL_REQUEST_ADDRESS = + 0x00000961Ef480Eb55e80D19ad83579A64c007002; + + function request(bytes calldata validatorPubKey, uint64 amount) + internal + returns (uint256 fee_) + { + require(validatorPubKey.length == 48, "Invalid validator byte length"); + fee_ = fee(); + + // Call the Withdrawal Request contract with the validator public key + // and amount to be withdrawn packed together + + // This is a general purpose EL to CL request: + // https://eips.ethereum.org/EIPS/eip-7685 + (bool success, ) = WITHDRAWAL_REQUEST_ADDRESS.call{ value: fee_ }( + abi.encodePacked(validatorPubKey, amount) + ); + + require(success, "Withdrawal request failed"); + } + + function fee() internal view returns (uint256) { + // Get fee from the withdrawal request contract + (bool success, bytes memory result) = WITHDRAWAL_REQUEST_ADDRESS + .staticcall(""); + + require(success && result.length > 0, "Failed to get fee"); + return abi.decode(result, (uint256)); + } +} diff --git a/contracts/contracts/mocks/MockPartialWithdrawal.sol b/contracts/contracts/mocks/MockPartialWithdrawal.sol new file mode 100644 index 0000000000..fcf0d056c6 --- /dev/null +++ b/contracts/contracts/mocks/MockPartialWithdrawal.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { PartialWithdrawal } from "../beacon/PartialWithdrawal.sol"; + +contract MockPartialWithdrawal { + function fee() external view returns (uint256) { + return PartialWithdrawal.fee(); + } + + function request(bytes calldata validatorPubKey, uint64 amount) + external + returns (uint256 fee_) + { + return PartialWithdrawal.request(validatorPubKey, amount); + } +} diff --git a/contracts/contracts/mocks/beacon/ExecutionLayerConsolidation.sol b/contracts/contracts/mocks/beacon/ExecutionLayerConsolidation.sol new file mode 100644 index 0000000000..5c747b9f2b --- /dev/null +++ b/contracts/contracts/mocks/beacon/ExecutionLayerConsolidation.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { GeneralPurposeToConsensusLayerRequest } from "./GeneralPurposeToConsensusLayerRequest.sol"; + +contract ExecutionLayerConsolidation is GeneralPurposeToConsensusLayerRequest { + event ConsolidationRequestIssued(bytes sourceKey, bytes targetKey); + bytes public lastSource; + bytes public lastTarget; + + function handleRequest(bytes calldata data) internal override { + // parameters should consist of twice the 48 bytes for 2 public keys + require(data.length == 96, "Invalid Consolidation data"); + lastSource = data[:48]; + lastTarget = data[48:]; + + emit ConsolidationRequestIssued(lastSource, lastTarget); + } +} diff --git a/contracts/contracts/mocks/beacon/ExecutionLayerWithdrawal.sol b/contracts/contracts/mocks/beacon/ExecutionLayerWithdrawal.sol new file mode 100644 index 0000000000..2280c41f89 --- /dev/null +++ b/contracts/contracts/mocks/beacon/ExecutionLayerWithdrawal.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { GeneralPurposeToConsensusLayerRequest } from "./GeneralPurposeToConsensusLayerRequest.sol"; + +contract ExecutionLayerWithdrawal is GeneralPurposeToConsensusLayerRequest { + event WithdrawalRequestIssued(bytes publicKey, uint64 amount); + + bytes public lastPublicKey; + uint64 public lastAmount; + + function handleRequest(bytes calldata data) internal override { + // parameters should consist of 48 bytes for public key and 8 bytes for uint64 amount + require(data.length == 56, "Invalid Withdrawal data"); + lastPublicKey = data[:48]; + lastAmount = uint64(bytes8(data[48:])); + emit WithdrawalRequestIssued(lastPublicKey, lastAmount); + } +} diff --git a/contracts/contracts/mocks/beacon/GeneralPurposeToConsensusLayerRequest.sol b/contracts/contracts/mocks/beacon/GeneralPurposeToConsensusLayerRequest.sol new file mode 100644 index 0000000000..be117cb18d --- /dev/null +++ b/contracts/contracts/mocks/beacon/GeneralPurposeToConsensusLayerRequest.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +abstract contract GeneralPurposeToConsensusLayerRequest { + // solhint-disable no-complex-fallback + fallback() external payable { + // fee requested + if (msg.data.length == 0) { + uint256 fee = _fee(); + // solhint-disable-next-line no-inline-assembly + assembly { + // Return a uint256 value + mstore(0, fee) + return(0, 32) // Return 32 bytes from memory + } + } + + // else handle request + handleRequest(msg.data); + } + + /*************************************** + Abstract + ****************************************/ + + function _fee() internal virtual returns (uint256) { + return 1; + } + + function handleRequest(bytes calldata data) internal virtual; +} diff --git a/contracts/test/_fixture.js b/contracts/test/_fixture.js index 3039b7f6b4..97ff6f052c 100644 --- a/contracts/test/_fixture.js +++ b/contracts/test/_fixture.js @@ -15,6 +15,7 @@ const { fundAccounts, fundAccountsForOETHUnitTests, } = require("../utils/funding"); +const { deployWithConfirmation } = require("../utils/deploy"); const { replaceContractAt } = require("../utils/hardhat"); const { @@ -2524,18 +2525,30 @@ async function beaconChainFixture() { const { deploy } = deployments; const { governorAddr } = await getNamedAccounts(); - await deploy("MockBeaconRoots", { + const { beaconConsolidationReplaced, beaconWithdrawalReplaced } = + await enableExecutionLayerGeneralPurposeRequests(); + + await deploy("MockBeaconConsolidation", { from: governorAddr, }); - await deploy("MockBeaconConsolidation", { + await deploy("MockPartialWithdrawal", { from: governorAddr, }); + fixture.beaconConsolidationReplaced = beaconConsolidationReplaced; + fixture.beaconWithdrawalReplaced = beaconWithdrawalReplaced; + fixture.beaconRoots = await resolveContract("MockBeaconRoots"); fixture.beaconConsolidation = await resolveContract( "MockBeaconConsolidation" ); + fixture.partialWithdrawal = await resolveContract("MockPartialWithdrawal"); + + // fund the beacon communication contracts so they can pay the fee + await hardhatSetBalance(fixture.beaconConsolidation.address, "1"); + await hardhatSetBalance(fixture.partialWithdrawal.address, "1"); + fixture.beaconOracle = await resolveContract("BeaconOracle"); } else { fixture.beaconProofs = await resolveContract("MockBeaconProofs"); @@ -2544,6 +2557,102 @@ async function beaconChainFixture() { return fixture; } +/** + * Harhdat doesn't have a support for execution layer general purpose requests to the + * consensus layer. E.g. consolidation request and (partial) withdrawal request. + */ +async function enableExecutionLayerGeneralPurposeRequests() { + const executionLayerConsolidation = await deployWithConfirmation( + "ExecutionLayerConsolidation" + ); + const executionLayerWithdrawal = await deployWithConfirmation( + "ExecutionLayerWithdrawal" + ); + + await replaceContractAt( + addresses.mainnet.toConsensus.consolidation, + executionLayerConsolidation + ); + + await replaceContractAt( + addresses.mainnet.toConsensus.withdrawals, + executionLayerWithdrawal + ); + + const withdrawalAbi = `[ + { + "inputs": [], + "name": "lastAmount", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "lastPublicKey", + "outputs": [ + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "stateMutability": "view", + "type": "function" + } + ]`; + + const consolidationAbi = `[ + { + "inputs": [], + "name": "lastSource", + "outputs": [ + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "lastTarget", + "outputs": [ + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "stateMutability": "view", + "type": "function" + } + ]`; + + const beaconConsolidationReplaced = await ethers.getContractAt( + JSON.parse(consolidationAbi), + addresses.mainnet.toConsensus.consolidation + ); + + const beaconWithdrawalReplaced = await ethers.getContractAt( + JSON.parse(withdrawalAbi), + addresses.mainnet.toConsensus.withdrawals + ); + + return { + beaconConsolidationReplaced, + beaconWithdrawalReplaced, + }; +} + /** * A fixture is a setup function that is run only the first time it's invoked. On subsequent invocations, * Hardhat will reset the state of the network to what it was at the point after the fixture was initially executed. diff --git a/contracts/test/beacon/beaconConsolidation.mainnet.fork-test.js b/contracts/test/beacon/beaconConsolidation.mainnet.fork-test.js index 22909cef3e..fe218604fb 100644 --- a/contracts/test/beacon/beaconConsolidation.mainnet.fork-test.js +++ b/contracts/test/beacon/beaconConsolidation.mainnet.fork-test.js @@ -20,27 +20,16 @@ describe("ForkTest: Beacon Consolidation", function () { }); it("Should request consolidation of validators", async () => { - const { beaconConsolidation } = fixture; + const { beaconConsolidation, beaconConsolidationReplaced } = fixture; // These are two sweeping validators const source = "0xa31b5e5d655a06d849a36e5b03f1b9e647f911f38857c2a263973fba90f61b528173fb7a7cddd63dbe7e6604e7d61c87"; const target = "0xa258246e1217568a751670447879b7af5d6df585c59a15ebf0380f276069eadb11f30dea77cfb7357447dc24517be560"; - const fee = await beaconConsolidation.request(source, target); - - expect(fee).to.be.gt(0); - expect(fee).to.be.lt(10); - }); - - it("Example from mainnet", async () => { - const { beaconConsolidation } = fixture; - - const source = - "0x8893a64b63187b4a6dbe555e11729f95ad7665a9329dfcb5f6cd6f8f535551415caf87bede37aaf23478a149a6bc8d8a"; - const target = - "0xa5183f98e920a1cc4dab8714055f4a65a262ff1aa0c378d68d6594edd0438ed6c2173193a6b9e17360816684f91b3ffc"; - await beaconConsolidation.request(source, target); + + expect(await beaconConsolidationReplaced.lastSource()).to.equal(source); + expect(await beaconConsolidationReplaced.lastTarget()).to.equal(target); }); }); diff --git a/contracts/test/beacon/partialWithdrawal.mainnet.fork-test.js b/contracts/test/beacon/partialWithdrawal.mainnet.fork-test.js new file mode 100644 index 0000000000..f361596e91 --- /dev/null +++ b/contracts/test/beacon/partialWithdrawal.mainnet.fork-test.js @@ -0,0 +1,37 @@ +const { expect } = require("chai"); +const { createFixtureLoader, beaconChainFixture } = require("../_fixture"); +const { ousdUnits } = require("../helpers"); + +const loadFixture = createFixtureLoader(beaconChainFixture); + +describe("ForkTest: Partial Withdrawal", function () { + this.timeout(0); + + let fixture; + beforeEach(async () => { + fixture = await loadFixture(); + }); + + it("Should get consolidation fee", async () => { + const { partialWithdrawal } = fixture; + + const fee = await partialWithdrawal.fee(); + expect(fee).to.be.gt(0); + expect(fee).to.be.lt(10); + }); + + it("Should request a partial withdrawal", async () => { + const { partialWithdrawal, beaconWithdrawalReplaced } = fixture; + + const amount = ousdUnits("1"); + // These are two sweeping validators + const validatorPKey = + "0xa258246e1217568a751670447879b7af5d6df585c59a15ebf0380f276069eadb11f30dea77cfb7357447dc24517be560"; + await partialWithdrawal.request(validatorPKey, amount); + + expect(await beaconWithdrawalReplaced.lastPublicKey()).to.equal( + validatorPKey + ); + expect(await beaconWithdrawalReplaced.lastAmount()).to.equal(amount); + }); +}); diff --git a/contracts/utils/addresses.js b/contracts/utils/addresses.js index e04ffed82b..a0d53586fd 100644 --- a/contracts/utils/addresses.js +++ b/contracts/utils/addresses.js @@ -342,6 +342,13 @@ addresses.mainnet.passthrough.uniswap.OETH_OGN = addresses.mainnet.passthrough.uniswap.OETH_WETH = "0x216dEBBF25e5e67e6f5B2AD59c856Fc364478A6A"; +// General purpose execution to consensus layer communication +addresses.mainnet.toConsensus = {}; +addresses.mainnet.toConsensus.consolidation = + "0x0000BBdDc7CE488642fb579F8B00f3a590007251"; +addresses.mainnet.toConsensus.withdrawals = + "0x00000961Ef480Eb55e80D19ad83579A64c007002"; + // Arbitrum One addresses.arbitrumOne = {}; addresses.arbitrumOne.WOETHProxy = "0xD8724322f44E5c58D7A815F542036fb17DbbF839";