diff --git a/script/check_storage_layout.sh b/script/check_storage_layout.sh index 5eab36a..64fc995 100755 --- a/script/check_storage_layout.sh +++ b/script/check_storage_layout.sh @@ -84,6 +84,7 @@ fi PINNED_FACETS=( InterestAdminFacet QueueForecastFacet + RescueFacet ) for name in "${PINNED_FACETS[@]}"; do diff --git a/script/diamond/WiseTelecomNodesDiamondSelectors.sol b/script/diamond/WiseTelecomNodesDiamondSelectors.sol index 64d7eec..01ac5ab 100644 --- a/script/diamond/WiseTelecomNodesDiamondSelectors.sol +++ b/script/diamond/WiseTelecomNodesDiamondSelectors.sol @@ -17,6 +17,7 @@ import {QueueJoinLeaveFacet} from "../../src/diamond/vault/facets/QueueJoinLeave import {QueueFulfillFacet} from "../../src/diamond/vault/facets/QueueFulfillFacet.sol"; import {QueueForecastFacet} from "../../src/diamond/vault/facets/QueueForecastFacet.sol"; import {InterestAdminFacet} from "../../src/diamond/vault/facets/InterestAdminFacet.sol"; +import {RescueFacet} from "../../src/diamond/vault/facets/RescueFacet.sol"; import {WiseTelecomNodesQueueUIHelper} from "../../src/diamond/vault/helpers/WiseTelecomNodesQueueUIHelper.sol"; import {WiseTelecomNodesQueueHelper} from "../../src/diamond/vault/helpers/WiseTelecomNodesQueueHelper.sol"; @@ -30,7 +31,7 @@ import {WiseTelecomNodesQueueHelper} from "../../src/diamond/vault/helpers/WiseT * queueAdmin=2, queueJoinLeave=5, queueFulfill=4, queueView=10 — * total 90. Post-launch additions (registered via the timelocked * selector proposals, not part of the genesis 90): queueForecast=1, - * interestAdmin=1. + * interestAdmin=1, rescue=1. */ library WiseTelecomNodesDiamondSelectors { @@ -133,6 +134,15 @@ library WiseTelecomNodesDiamondSelectors { sels[0] = InterestAdminFacet.setCashedInterest.selector; } + function rescueSelectors() + internal + pure + returns (bytes4[] memory sels) + { + sels = new bytes4[](1); + sels[0] = RescueFacet.rescueToken.selector; + } + function burnWiseSelectors() internal pure diff --git a/src/diamond/vault/WiseTelecomNodesDiamondErrors.sol b/src/diamond/vault/WiseTelecomNodesDiamondErrors.sol index ab6dbb5..ffd1acf 100644 --- a/src/diamond/vault/WiseTelecomNodesDiamondErrors.sol +++ b/src/diamond/vault/WiseTelecomNodesDiamondErrors.sol @@ -147,4 +147,6 @@ abstract contract WiseTelecomNodesDiamondErrors { error NegativeIncentiveNotAllowed(); error SameIncentive(); + + error ProtectedToken(); } diff --git a/src/diamond/vault/WiseTelecomNodesDiamondEvents.sol b/src/diamond/vault/WiseTelecomNodesDiamondEvents.sol index 72e0b8f..c0dcc24 100644 --- a/src/diamond/vault/WiseTelecomNodesDiamondEvents.sol +++ b/src/diamond/vault/WiseTelecomNodesDiamondEvents.sol @@ -200,6 +200,12 @@ abstract contract WiseTelecomNodesDiamondEvents { uint256 newAmount ); + event TokenRescued( + address indexed token, + address indexed to, + uint256 amount + ); + event SweeperSet( address indexed sweeper, bool allowed diff --git a/src/diamond/vault/facets/RescueFacet.sol b/src/diamond/vault/facets/RescueFacet.sol new file mode 100644 index 0000000..a8e0fd7 --- /dev/null +++ b/src/diamond/vault/facets/RescueFacet.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: -- WISE -- + +pragma solidity =0.8.36; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import {WiseTelecomNodesDiamondErrors} from "../WiseTelecomNodesDiamondErrors.sol"; +import {WiseTelecomNodesDiamondEvents} from "../WiseTelecomNodesDiamondEvents.sol"; + +import {NotMaster} from "../../shared/OwnableMaster.sol"; +import {OnlyDelegateCall} from "../../shared/DiamondErrors.sol"; + +/** + * @dev Master-only escape hatch for tokens stranded on the vault by + * direct wallet transfers. Strictly excluded: the underlying + * `USD_TOKEN`, whose vault balance backs interest claims and the + * sweep-buffer reservation (its surplus leaves only through + * `sweepOverhang` to the worker), and the vault's own share token. + * Every other balance is invisible to vault accounting and can be + * returned to its sender. + * + * No reentrancy guard: the single external call happens after all + * checks, the function writes no vault storage, and the caller is + * the master. + * + * DEPLOY-SLIM STORAGE MIRROR: instead of inheriting the full + * declaration chain, only the two slots this facet reads are + * pinned, padded to their exact positions in the deployed diamond + * layout (master 0, USD_TOKEN 8). The pinned entries are asserted + * label-for-label against the diamond's committed layout snapshot + * by script/check_storage_layout.sh, so any drift fails CI before + * it can ship. + */ +contract RescueFacet is + WiseTelecomNodesDiamondErrors, + WiseTelecomNodesDiamondEvents +{ + using SafeERC20 for IERC20; + + address internal master; + + uint256[7] private __gap1; + + IERC20 internal USD_TOKEN; + + address internal immutable _self; + + constructor() { + _self = address(this); + } + + modifier onlyDelegateCall() { + require( + address(this) != _self, + OnlyDelegateCall() + ); + _; + } + + modifier onlyMaster() { + require( + msg.sender == master, + NotMaster() + ); + _; + } + + function rescueToken( + address _token, + address _to, + uint256 _amount + ) + external + onlyDelegateCall + onlyMaster + { + require( + _token != address(USD_TOKEN) + && _token != address(this), + ProtectedToken() + ); + + require( + _to != address(0), + InvalidValue() + ); + + IERC20(_token).safeTransfer( + _to, + _amount + ); + + emit TokenRescued( + _token, + _to, + _amount + ); + } +} diff --git a/test/diamond/WiseTelecomNodesRescueFacet.t.sol b/test/diamond/WiseTelecomNodesRescueFacet.t.sol new file mode 100644 index 0000000..99dd932 --- /dev/null +++ b/test/diamond/WiseTelecomNodesRescueFacet.t.sol @@ -0,0 +1,349 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity =0.8.36; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {DiamondTestHarness} from "./utils/DiamondTestHarness.sol"; + +import {WiseTelecomNodesDiamond} from "../../src/diamond/vault/WiseTelecomNodesDiamond.sol"; +import {RescueFacet} from "../../src/diamond/vault/facets/RescueFacet.sol"; + +import {WiseTelecomNodesDiamondErrors} from "../../src/diamond/vault/WiseTelecomNodesDiamondErrors.sol"; +import {NotMaster} from "../../src/diamond/shared/OwnableMaster.sol"; +import {OnlyDelegateCall} from "../../src/diamond/shared/DiamondErrors.sol"; + +import {WiseTelecomNodesDiamondSelectors} from "../../script/diamond/WiseTelecomNodesDiamondSelectors.sol"; + +contract MockUSD is ERC20 { + + constructor() + ERC20("Mock USD", "MUSD") + {} + + function decimals() + public + pure + override + returns (uint8) + { + return 6; + } + + function mint( + address _to, + uint256 _amount + ) + external + { + _mint( + _to, + _amount + ); + } +} + +contract MockStray is ERC20 { + + constructor() + ERC20("Mock Stray", "STRAY") + {} + + function mint( + address _to, + uint256 _amount + ) + external + { + _mint( + _to, + _amount + ); + } +} + +contract MockNoReturnToken { + + mapping(address => uint256) public balanceOf; + + function mint( + address _to, + uint256 _amount + ) + external + { + balanceOf[_to] += _amount; + } + + function transfer( + address _to, + uint256 _amount + ) + external + { + require( + balanceOf[msg.sender] >= _amount, + "MockNoReturnToken: balance" + ); + + balanceOf[msg.sender] -= _amount; + balanceOf[_to] += _amount; + } +} + +/** + * @dev Exercises {RescueFacet}. The load-bearing properties: a + * stranded token leaves in full or in part exactly to the chosen + * recipient with the event emitted, non-returning USDT-style tokens + * work through SafeERC20, the underlying `USD_TOKEN` and the + * vault's own share token are unreachable, vault accounting is + * untouched by a rescue, and the master/recipient/delegatecall + * gates reject. + */ +contract WiseTelecomNodesRescueFacetTest is DiamondTestHarness { + + event TokenRescued( + address indexed token, + address indexed to, + uint256 amount + ); + + uint256 internal constant STRAY_AMOUNT = 100_224_341; + + address internal recipient = address(0xA1); + address internal stranger = address(0xBEEF); + + WiseTelecomNodesDiamond internal diamond; + RescueFacet internal rescueFacetInstance; + MockUSD internal usd; + MockStray internal stray; + MockNoReturnToken internal noReturnToken; + + function setUp() + public + { + usd = new MockUSD(); + + diamond = _newDiamond( + address(usd) + ); + + _wireAllFacets( + diamond + ); + + rescueFacetInstance = new RescueFacet(); + + _wireOne( + diamond, + address(rescueFacetInstance), + WiseTelecomNodesDiamondSelectors.rescueSelectors() + ); + + diamond.finalizeSetup(); + + stray = new MockStray(); + + stray.mint( + address(diamond), + STRAY_AMOUNT + ); + + noReturnToken = new MockNoReturnToken(); + + noReturnToken.mint( + address(diamond), + STRAY_AMOUNT + ); + } + + function _rescue( + address _token, + address _to, + uint256 _amount + ) + internal + { + RescueFacet(address(diamond)).rescueToken( + _token, + _to, + _amount + ); + } + + function test_masterRescuesFullStrayBalance() + public + { + uint256 supplyBefore = diamond.totalSupply(); + + vm.expectEmit( + true, + true, + false, + true, + address(diamond) + ); + + emit TokenRescued( + address(stray), + recipient, + STRAY_AMOUNT + ); + + _rescue( + address(stray), + recipient, + STRAY_AMOUNT + ); + + assertEq( + stray.balanceOf(recipient), + STRAY_AMOUNT + ); + + assertEq( + stray.balanceOf(address(diamond)), + 0 + ); + + assertEq( + diamond.totalSupply(), + supplyBefore + ); + } + + function test_partialRescueLeavesRemainder() + public + { + uint256 half = STRAY_AMOUNT / 2; + + _rescue( + address(stray), + recipient, + half + ); + + assertEq( + stray.balanceOf(recipient), + half + ); + + assertEq( + stray.balanceOf(address(diamond)), + STRAY_AMOUNT - half + ); + } + + function test_rescuesNonReturningToken() + public + { + _rescue( + address(noReturnToken), + recipient, + STRAY_AMOUNT + ); + + assertEq( + noReturnToken.balanceOf(recipient), + STRAY_AMOUNT + ); + + assertEq( + noReturnToken.balanceOf(address(diamond)), + 0 + ); + } + + function test_revertsForUnderlyingToken() + public + { + usd.mint( + address(diamond), + STRAY_AMOUNT + ); + + vm.expectRevert( + WiseTelecomNodesDiamondErrors.ProtectedToken.selector + ); + + _rescue( + address(usd), + recipient, + STRAY_AMOUNT + ); + } + + function test_revertsForVaultShareToken() + public + { + vm.expectRevert( + WiseTelecomNodesDiamondErrors.ProtectedToken.selector + ); + + _rescue( + address(diamond), + recipient, + 1 + ); + } + + function test_revertsForZeroRecipient() + public + { + vm.expectRevert( + WiseTelecomNodesDiamondErrors.InvalidValue.selector + ); + + _rescue( + address(stray), + address(0), + STRAY_AMOUNT + ); + } + + function test_revertsForStranger() + public + { + vm.prank( + stranger + ); + + vm.expectRevert( + NotMaster.selector + ); + + RescueFacet(address(diamond)).rescueToken( + address(stray), + stranger, + STRAY_AMOUNT + ); + } + + function test_revertsOnDirectFacetCall() + public + { + vm.expectRevert( + OnlyDelegateCall.selector + ); + + rescueFacetInstance.rescueToken( + address(stray), + recipient, + STRAY_AMOUNT + ); + } + + function test_revertsWhenAmountExceedsBalance() + public + { + vm.expectRevert( + bytes("ERC20: transfer amount exceeds balance") + ); + + _rescue( + address(stray), + recipient, + STRAY_AMOUNT + 1 + ); + } +}