From 7d2106a265a8d82f259b506c4c8fec42002546ef Mon Sep 17 00:00:00 2001 From: merkleplant <85061506+pmerkleplant@users.noreply.github.com> Date: Thu, 10 Oct 2024 13:27:36 +0200 Subject: [PATCH] script: Adds ETH rescue tooling for deactivated opScribes --- script/Scribe.s.sol | 4 +- script/ScribeOptimistic.s.sol | 81 ++++++++++- script/rescue/Rescuer.sol | 113 +++++++++++++++ test/rescue/RescuerTest.sol | 249 ++++++++++++++++++++++++++++++++++ 4 files changed, 443 insertions(+), 4 deletions(-) create mode 100644 script/rescue/Rescuer.sol create mode 100644 test/rescue/RescuerTest.sol diff --git a/script/Scribe.s.sol b/script/Scribe.s.sol index 32977ac..f6651a8 100644 --- a/script/Scribe.s.sol +++ b/script/Scribe.s.sol @@ -189,10 +189,10 @@ contract ScribeScript is Script { /// pokes with an already fully constructed payload. /// /// @dev Call via: - /// /// ```bash /// $ forge script \ - /// --private-key $PRIVATE_KEY \ + /// --keystore $KEYSTORE \ + /// --password $KEYSTORE_PASSWORD \ /// --broadcast \ /// --rpc-url $RPC_URL \ /// --sig $(cast calldata "pokeRaw(address,bytes)" $SCRIBE $PAYLOAD) \ diff --git a/script/ScribeOptimistic.s.sol b/script/ScribeOptimistic.s.sol index dae6ca9..bf5a7b0 100644 --- a/script/ScribeOptimistic.s.sol +++ b/script/ScribeOptimistic.s.sol @@ -15,10 +15,18 @@ import {LibSecp256k1} from "src/libs/LibSecp256k1.sol"; import {ScribeScript} from "./Scribe.s.sol"; +import {LibRandom} from "./libs/LibRandom.sol"; +import {LibFeed} from "./libs/LibFeed.sol"; + +import {Rescuer} from "./rescue/Rescuer.sol"; + /** * @title ScribeOptimistic Management Script */ contract ScribeOptimisticScript is ScribeScript { + using LibSecp256k1 for LibSecp256k1.Point; + using LibFeed for LibFeed.Feed; + /// @dev Deploys a new ScribeOptimistic instance with `initialAuthed` being /// the address initially auth'ed. Note that zero address is kissed /// directly after deployment. @@ -65,10 +73,10 @@ contract ScribeOptimisticScript is ScribeScript { /// opPokes with an already fully constructed payload. /// /// @dev Call via: - /// /// ```bash /// $ forge script \ - /// --private-key $PRIVATE_KEY \ + /// --keystore $KEYSTORE \ + /// --password $KEYSTORE_PASSWORD \ /// --broadcast \ /// --rpc-url $RPC_URL \ /// --sig $(cast calldata "opPokeRaw(address,bytes)" $SCRIBE $PAYLOAD) \ @@ -108,4 +116,73 @@ contract ScribeOptimisticScript is ScribeScript { IScribeOptimistic(self).opPoke(pokeData, schnorrData, ecdsaData); vm.stopBroadcast(); } + + /// @dev Rescues ETH held in deactivated `self`. + /// + /// @dev Call via: + /// ```bash + /// $ forge script \ + /// --keystore $KEYSTORE \ + /// --password $KEYSTORE_PASSWORD \ + /// --broadcast \ + /// --rpc-url $RPC_URL \ + /// --sig $(cast calldata "rescueETH(address,address)" $SCRIBE $RESCUER) \ + /// -vvvvv \ + /// script/dev/ScribeOptimistic.s.sol:ScribeOptimisticScript + /// ``` + function rescueETH(address self, address rescuer) public { + // Require self to be deactivated. + { + vm.prank(address(0)); + (bool ok, /*val*/ ) = IScribe(self).tryRead(); + require(!ok, "Instance not deactivated: read() does not fail"); + + require( + IScribe(self).feeds().length == 0, + "Instance not deactivated: Feeds still lifted" + ); + require( + IScribe(self).bar() == 255, + "Instance not deactivated: Bar not type(uint8).max" + ); + } + + // Ensure challenge reward is total balance. + uint challengeReward = IScribeOptimistic(self).challengeReward(); + uint total = self.balance; + if (challengeReward < total) { + IScribeOptimistic(self).setMaxChallengeReward(type(uint).max); + } + + // Create new random private key. + uint privKeySeed = LibRandom.readUint(); + uint privKey = _bound(privKeySeed, 1, LibSecp256k1.Q() - 1); + + // Create feed instance from private key. + LibFeed.Feed memory feed = LibFeed.newFeed(privKey); + + // Let feed sign feed registration message. + IScribe.ECDSAData memory registrationSig; + registrationSig = + feed.signECDSA(IScribe(self).feedRegistrationMessage()); + + // Construct pokeData and invalid Schnorr signature. + uint32 pokeDataAge = uint32(block.timestamp); + IScribe.PokeData memory pokeData = IScribe.PokeData(0, pokeDataAge); + IScribe.SchnorrData memory schnorrData = + IScribe.SchnorrData(bytes32(0), address(0), hex""); + + // Construct opPokeMessage. + bytes32 opPokeMessage = IScribeOptimistic(self).constructOpPokeMessage( + pokeData, schnorrData + ); + + // Let feed sign opPokeMessage. + IScribe.ECDSAData memory opPokeSig = feed.signECDSA(opPokeMessage); + + // Rescue ETH via rescuer contract. + Rescuer(payable(rescuer)).suck( + self, feed.pubKey, registrationSig, pokeDataAge, opPokeSig + ); + } } diff --git a/script/rescue/Rescuer.sol b/script/rescue/Rescuer.sol new file mode 100644 index 0000000..69e7ff2 --- /dev/null +++ b/script/rescue/Rescuer.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +import {IAuth} from "chronicle-std/auth/IAuth.sol"; +import {Auth} from "chronicle-std/auth/Auth.sol"; + +import {IScribe} from "src/IScribe.sol"; +import {IScribeOptimistic} from "src/IScribeOptimistic.sol"; + +import {LibSecp256k1} from "src/libs/LibSecp256k1.sol"; + +/** + * @title Rescuer + * + * @notice Contract to recover ETH from offboarded ScribeOptimistic instances + * + * @dev Deployment: + * ```bash + * $ forge create script/rescue/Rescuer.sol:Rescuer \ + * --constructor-args $INITIAL_AUTHED \ + * --keystore $KEYSTORE \ + * --password $KEYSTORE_PASSWORD \ + * --rpc-url $RPC_URL \ + * --verifier-url $ETHERSCAN_API_URL \ + * --etherscan-api-key $ETHERSCAN_API_KEY + * ``` + * + * @author Chronicle Labs, Inc + * @custom:security-contact security@chroniclelabs.org + */ +contract Rescuer is Auth { + using LibSecp256k1 for LibSecp256k1.Point; + + /// @notice Emitted when successfully recovered ETH funds. + /// @param caller The caller's address. + /// @param opScribe The ScribeOptimistic instance the ETH got recovered + /// from. + /// @param amount The amount of ETH recovered. + event Recovered( + address indexed caller, address indexed opScribe, uint amount + ); + + /// @notice Emitted when successfully withdrawed ETH from this contract. + /// @param caller The caller's address. + /// @param receiver The receiver + /// from. + /// @param amount The amount of ETH recovered. + event Withdrawed( + address indexed caller, address indexed receiver, uint amount + ); + + constructor(address initialAuthed) Auth(initialAuthed) {} + + receive() external payable {} + + /// @notice Withdraws `amount` ETH held in contract to `receiver`. + /// + /// @dev Only callable by auth'ed address. + function withdraw(address payable receiver, uint amount) external auth { + (bool ok,) = receiver.call{value: amount}(""); + require(ok); + + emit Withdrawed(msg.sender, receiver, amount); + } + + /// @notice Rescues ETH from ScribeOptimistic instance `opScribe`. + /// + /// @dev Note that `opScribe` MUST be deactivated. + /// @dev Note that validator key pair SHALL be only used once and generated + /// via a CSPRNG. + /// + /// @dev Only callable by auth'ed address. + function suck( + address opScribe, + LibSecp256k1.Point memory pubKey, + IScribe.ECDSAData memory registrationSig, + uint32 pokeDataAge, + IScribe.ECDSAData memory opPokeSig + ) external auth { + require(IAuth(opScribe).authed(address(this))); + + uint balanceBefore = address(this).balance; + + // Fail if instance has feeds lifted, ie is not deactivated. + require(IScribe(opScribe).feeds().length == 0); + + // Construct pokeData. + IScribe.PokeData memory pokeData = + IScribe.PokeData({val: uint128(0), age: pokeDataAge}); + + // Construct invalid Schnorr signature. + IScribe.SchnorrData memory schnorrSig = IScribe.SchnorrData({ + signature: bytes32(0), + commitment: address(0), + feedIds: hex"" + }); + + // Lift validator. + IScribe(opScribe).lift(pubKey, registrationSig); + + // Perform opPoke. + IScribeOptimistic(opScribe).opPoke(pokeData, schnorrSig, opPokeSig); + + // Perform opChallenge. + IScribeOptimistic(opScribe).opChallenge(schnorrSig); + + // Compute amount of ETH received as challenge reward. + uint amount = address(this).balance - balanceBefore; + + // Emit event. + emit Recovered(msg.sender, opScribe, amount); + } +} diff --git a/test/rescue/RescuerTest.sol b/test/rescue/RescuerTest.sol new file mode 100644 index 0000000..1e2cd84 --- /dev/null +++ b/test/rescue/RescuerTest.sol @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +import {Test} from "forge-std/Test.sol"; +import {console2} from "forge-std/console2.sol"; + +import {IAuth} from "chronicle-std/auth/IAuth.sol"; + +import {Rescuer} from "script/rescue/Rescuer.sol"; + +import {IScribe} from "src/IScribe.sol"; +import {IScribeOptimistic} from "src/IScribeOptimistic.sol"; +import {ScribeOptimistic} from "src/ScribeOptimistic.sol"; + +import {LibSecp256k1} from "src/libs/LibSecp256k1.sol"; + +import {LibFeed} from "script/libs/LibFeed.sol"; + +contract RescuerTest is Test { + using LibSecp256k1 for LibSecp256k1.Point; + using LibFeed for LibFeed.Feed; + + // Events copied from Rescuer. + event Recovered( + address indexed caller, address indexed opScribe, uint amount + ); + event Withdrawed( + address indexed caller, address indexed receiver, uint amount + ); + + Rescuer private rescuer; + IScribeOptimistic private opScribe; + + bytes32 internal FEED_REGISTRATION_MESSAGE; + + function setUp() public { + opScribe = new ScribeOptimistic(address(this), bytes32("TEST/TEST")); + + rescuer = new Rescuer(address(this)); + + // Note to auth rescuer on opScribe. + IAuth(address(opScribe)).rely(address(rescuer)); + + // Note to let opScribe have a non-zero ETH balance. + vm.deal(address(opScribe), 1 ether); + + // Cache constants. + FEED_REGISTRATION_MESSAGE = opScribe.feedRegistrationMessage(); + } + + // -- Test: Suck -- + + function testFuzz_suck(uint privKeySeed) public { + // Create new feed from privKeySeed. + LibFeed.Feed memory feed = + LibFeed.newFeed(_bound(privKeySeed, 1, LibSecp256k1.Q() - 1)); + + // Construct opPoke signature with invalid Schnorr signature. + uint32 pokeDataAge = uint32(block.timestamp); + IScribe.ECDSAData memory opPokeSig = + _constructOpPokeSig(feed, pokeDataAge); + + vm.expectEmit(); + emit Recovered(address(this), address(opScribe), 1 ether); + + rescuer.suck( + address(opScribe), + feed.pubKey, + feed.signECDSA(FEED_REGISTRATION_MESSAGE), + pokeDataAge, + opPokeSig + ); + + // Verify balances. + assertEq(address(opScribe).balance, 0); + assertEq(address(rescuer).balance, 1 ether); + + // Verify feed got kicked. + assertFalse(opScribe.feeds(feed.pubKey.toAddress())); + } + + function testFuzz_suck_FailsIf_RescuerNotAuthedOnOpScribe(uint privKeySeed) + public + { + // Create new feed from privKeySeed. + LibFeed.Feed memory feed = + LibFeed.newFeed(_bound(privKeySeed, 1, LibSecp256k1.Q() - 1)); + + // Construct opPoke signature with invalid Schnorr signature. + uint32 pokeDataAge = uint32(block.timestamp); + IScribe.ECDSAData memory opPokeSig = + _constructOpPokeSig(feed, pokeDataAge); + + // Deny rescuer on opScribe. + IAuth(address(opScribe)).deny(address(rescuer)); + + // Expect rescue to fail. + vm.expectRevert(); + rescuer.suck( + address(opScribe), + feed.pubKey, + feed.signECDSA(FEED_REGISTRATION_MESSAGE), + pokeDataAge, + opPokeSig + ); + } + + function testFuzz_suck_FailsIf_OpScribeNotDeactivated( + uint privKeySeed, + uint privKeyLiftedSeed + ) public { + // Create new feeds from seeds + LibFeed.Feed memory feed = + LibFeed.newFeed(_bound(privKeySeed, 1, LibSecp256k1.Q() - 1)); + LibFeed.Feed memory feedLifted = + LibFeed.newFeed(_bound(privKeyLiftedSeed, 1, LibSecp256k1.Q() - 1)); + + // Lift feedLifted. + opScribe.lift( + feedLifted.pubKey, + feedLifted.signECDSA(opScribe.feedRegistrationMessage()) + ); + + // Construct opPoke signature with invalid Schnorr signature. + uint32 pokeDataAge = uint32(block.timestamp); + IScribe.ECDSAData memory opPokeSig = + _constructOpPokeSig(feed, pokeDataAge); + + // Expect rescue to fail. + vm.expectRevert(); + rescuer.suck( + address(opScribe), + feed.pubKey, + feed.signECDSA(FEED_REGISTRATION_MESSAGE), + pokeDataAge, + opPokeSig + ); + } + + function test_suck_isAuthProtected() public { + vm.prank(address(0xbeef)); + vm.expectRevert( + abi.encodeWithSelector( + IAuth.NotAuthorized.selector, address(0xbeef) + ) + ); + rescuer.suck( + address(opScribe), + LibSecp256k1.ZERO_POINT(), + IScribe.ECDSAData(uint8(0), bytes32(0), bytes32(0)), + uint32(0), + IScribe.ECDSAData(uint8(0), bytes32(0), bytes32(0)) + ); + } + + // -- Test: Withdraw -- + + function testFuzz_withdraw_ToEOA( + address payable receiver, + uint balance, + uint withdrawal + ) public { + vm.assume(receiver.code.length == 0); + vm.assume(balance >= withdrawal); + + // Let rescuer have ETH balance. + vm.deal(address(rescuer), balance); + + vm.expectEmit(); + emit Withdrawed(address(this), receiver, withdrawal); + + rescuer.withdraw(receiver, withdrawal); + + assertEq(address(rescuer).balance, balance - withdrawal); + assertEq(receiver.balance, withdrawal); + } + + function test_withdraw_ToContract(uint balance, uint withdrawal) public { + vm.assume(balance >= withdrawal); + + // Let rescuer have ETH balance. + vm.deal(address(rescuer), balance); + + // Deploy ETH receiver. + ETHReceiver receiver = new ETHReceiver(); + + vm.expectEmit(); + emit Withdrawed(address(this), address(receiver), withdrawal); + + rescuer.withdraw(payable(address(receiver)), withdrawal); + + assertEq(address(rescuer).balance, balance - withdrawal); + assertEq(address(receiver).balance, withdrawal); + } + + function test_withdraw_FailsIf_ETHTransferFails( + uint balance, + uint withdrawal + ) public { + vm.assume(balance >= withdrawal); + + // Let rescuer have ETH balance. + vm.deal(address(rescuer), balance); + + // Deploy non ETH receiver. + NotETHReceiver receiver = new NotETHReceiver(); + + vm.expectRevert(); + rescuer.withdraw(payable(address(receiver)), withdrawal); + } + + function test_withdraw_isAuthProtected() public { + vm.prank(address(0xbeef)); + vm.expectRevert( + abi.encodeWithSelector( + IAuth.NotAuthorized.selector, address(0xbeef) + ) + ); + rescuer.withdraw(payable(address(this)), 0); + } + + // -- Helpers -- + + function _constructOpPokeSig(LibFeed.Feed memory feed, uint32 pokeDataAge) + internal + view + returns (IScribe.ECDSAData memory) + { + // Construct pokeData with zero val and given age. + IScribe.PokeData memory pokeData = IScribe.PokeData(0, pokeDataAge); + + // Construct invalid Schnorr signature. + IScribe.SchnorrData memory schnorrData = + IScribe.SchnorrData(bytes32(0), address(0), hex""); + + // Construct opPokeMessage. + bytes32 opPokeMessage = + opScribe.constructOpPokeMessage(pokeData, schnorrData); + + // Let feed sign opPokeMessage. + return feed.signECDSA(opPokeMessage); + } +} + +contract NotETHReceiver {} + +contract ETHReceiver { + receive() external payable {} +}