From 0fbc84e221dd3f4950270d4857f7aa393548a309 Mon Sep 17 00:00:00 2001 From: vonMangoldt <61846574+vonMangoldt@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:40:25 -0400 Subject: [PATCH] Add RescueFacet for stranded-token recovery Master-only rescueToken(token, to, amount) returns tokens stranded on a vault by direct wallet transfers. The underlying USD_TOKEN (its vault balance backs interest claims and the sweep-buffer reservation; surplus exits only through sweepOverhang to the worker) and the vault's own share token are strictly excluded via a ProtectedToken error. Deploy-slim storage mirror pinning master (slot 0) and USD_TOKEN (slot 8), asserted by the pinned-subset phase of check_storage_layout.sh. No reentrancy guard: the single external call happens after all checks, the function writes no vault storage, and the caller is the master. Nine tests with full line, statement, branch and function coverage on the facet. --- script/check_storage_layout.sh | 1 + .../WiseTelecomNodesDiamondSelectors.sol | 12 +- .../vault/WiseTelecomNodesDiamondErrors.sol | 2 + .../vault/WiseTelecomNodesDiamondEvents.sol | 6 + src/diamond/vault/facets/RescueFacet.sol | 100 +++++ .../diamond/WiseTelecomNodesRescueFacet.t.sol | 349 ++++++++++++++++++ 6 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 src/diamond/vault/facets/RescueFacet.sol create mode 100644 test/diamond/WiseTelecomNodesRescueFacet.t.sol 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 + ); + } +}