Skip to content

Commit

Permalink
feat: permissionless rescue (#43)
Browse files Browse the repository at this point in the history
* feat: add permissionless rescuable

* feat: add permissionless rescuable

* fix: assert actual error

* fix: add maxRescue

* fix: update inheritance
  • Loading branch information
sakulstra authored Sep 9, 2024
1 parent 6cbcb18 commit a842c36
Show file tree
Hide file tree
Showing 10 changed files with 276 additions and 35 deletions.
21 changes: 21 additions & 0 deletions src/contracts/utils/PermissionlessRescuable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.8;

import {IERC20} from '../oz-common/interfaces/IERC20.sol';
import {RescuableBase} from './RescuableBase.sol';
import {IPermissionlessRescuable} from './interfaces/IPermissionlessRescuable.sol';

abstract contract PermissionlessRescuable is RescuableBase, IPermissionlessRescuable {
/// @inheritdoc IPermissionlessRescuable
function whoShouldReceiveFunds() public view virtual returns (address);

/// @inheritdoc IPermissionlessRescuable
function emergencyTokenTransfer(address erc20Token, uint256 amount) external virtual {
_emergencyTokenTransfer(erc20Token, whoShouldReceiveFunds(), amount);
}

/// @inheritdoc IPermissionlessRescuable
function emergencyEtherTransfer(uint256 amount) external virtual {
_emergencyEtherTransfer(whoShouldReceiveFunds(), amount);
}
}
15 changes: 4 additions & 11 deletions src/contracts/utils/Rescuable.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,15 @@
pragma solidity ^0.8.8;

import {IERC20} from '../oz-common/interfaces/IERC20.sol';
import {SafeERC20} from '../oz-common/SafeERC20.sol';
import {RescuableBase} from './RescuableBase.sol';
import {IRescuable} from './interfaces/IRescuable.sol';

/**
* @title Rescuable
* @author BGD Labs
* @notice abstract contract with the methods to rescue tokens (ERC20 and native) from a contract
*/
abstract contract Rescuable is IRescuable {
using SafeERC20 for IERC20;

abstract contract Rescuable is RescuableBase, IRescuable {
/// @notice modifier that checks that caller is allowed address
modifier onlyRescueGuardian() {
require(msg.sender == whoCanRescue(), 'ONLY_RESCUE_GUARDIAN');
Expand All @@ -25,17 +23,12 @@ abstract contract Rescuable is IRescuable {
address to,
uint256 amount
) external virtual onlyRescueGuardian {
IERC20(erc20Token).safeTransfer(to, amount);

emit ERC20Rescued(msg.sender, erc20Token, to, amount);
_emergencyTokenTransfer(erc20Token, to, amount);
}

/// @inheritdoc IRescuable
function emergencyEtherTransfer(address to, uint256 amount) external virtual onlyRescueGuardian {
(bool success, ) = to.call{value: amount}(new bytes(0));
require(success, 'ETH_TRANSFER_FAIL');

emit NativeTokensRescued(msg.sender, to, amount);
_emergencyEtherTransfer(to, amount);
}

/// @inheritdoc IRescuable
Expand Down
32 changes: 32 additions & 0 deletions src/contracts/utils/RescuableBase.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.8;

import {IERC20} from '../oz-common/interfaces/IERC20.sol';
import {SafeERC20} from '../oz-common/SafeERC20.sol';
import {IRescuableBase} from './interfaces/IRescuableBase.sol';

abstract contract RescuableBase is IRescuableBase {
using SafeERC20 for IERC20;

/// @inheritdoc IRescuableBase
function maxRescue(address erc20Token) public view virtual returns (uint256);

function _emergencyTokenTransfer(
address erc20Token,
address to,
uint256 amount
) internal virtual {
uint256 max = maxRescue(erc20Token);
amount = max > amount ? amount : max;
IERC20(erc20Token).safeTransfer(to, amount);

emit ERC20Rescued(msg.sender, erc20Token, to, amount);
}

function _emergencyEtherTransfer(address to, uint256 amount) internal virtual {
(bool success, ) = to.call{value: amount}(new bytes(0));
require(success, 'ETH_TRANSFER_FAIL');

emit NativeTokensRescued(msg.sender, to, amount);
}
}
30 changes: 30 additions & 0 deletions src/contracts/utils/interfaces/IPermissionlessRescuable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.8;

import {IRescuableBase} from './IRescuableBase.sol';

/**
* @title IRescuable
* @author BGD Labs
* @notice interface containing the objects, events and methods definitions of the Rescuable contract
*/
interface IPermissionlessRescuable is IRescuableBase {
/**
* @notice method called to rescue tokens sent erroneously to the contract. Only callable by owner
* @param erc20Token address of the token to rescue
* @param amount of tokens to rescue
*/
function emergencyTokenTransfer(address erc20Token, uint256 amount) external;

/**
* @notice method called to rescue ether sent erroneously to the contract. Only callable by owner
* @param amount of eth to rescue
*/
function emergencyEtherTransfer(uint256 amount) external;

/**
* @notice method that defines the address that should receive the rescued tokens
* @return the receiver address
*/
function whoShouldReceiveFunds() external view returns (address);
}
26 changes: 3 additions & 23 deletions src/contracts/utils/interfaces/IRescuable.sol
Original file line number Diff line number Diff line change
@@ -1,34 +1,14 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.8;

import {IRescuableBase} from './IRescuableBase.sol';

/**
* @title IRescuable
* @author BGD Labs
* @notice interface containing the objects, events and methods definitions of the Rescuable contract
*/
interface IRescuable {
/**
* @notice emitted when erc20 tokens get rescued
* @param caller address that triggers the rescue
* @param token address of the rescued token
* @param to address that will receive the rescued tokens
* @param amount quantity of tokens rescued
*/
event ERC20Rescued(
address indexed caller,
address indexed token,
address indexed to,
uint256 amount
);

/**
* @notice emitted when native tokens get rescued
* @param caller address that triggers the rescue
* @param to address that will receive the rescued tokens
* @param amount quantity of tokens rescued
*/
event NativeTokensRescued(address indexed caller, address indexed to, uint256 amount);

interface IRescuable is IRescuableBase {
/**
* @notice method called to rescue tokens sent erroneously to the contract. Only callable by owner
* @param erc20Token address of the token to rescue
Expand Down
38 changes: 38 additions & 0 deletions src/contracts/utils/interfaces/IRescuableBase.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.8;

/**
* @title IRescuableBase
* @author BGD Labs
* @notice interface containing the objects, events and methods definitions of the RescuableBase contract
*/
interface IRescuableBase {
/**
* @notice emitted when erc20 tokens get rescued
* @param caller address that triggers the rescue
* @param token address of the rescued token
* @param to address that will receive the rescued tokens
* @param amount quantity of tokens rescued
*/
event ERC20Rescued(
address indexed caller,
address indexed token,
address indexed to,
uint256 amount
);

/**
* @notice emitted when native tokens get rescued
* @param caller address that triggers the rescue
* @param to address that will receive the rescued tokens
* @param amount quantity of tokens rescued
*/
event NativeTokensRescued(address indexed caller, address indexed to, uint256 amount);

/**
* @notice method that defined the maximum amount rescuable for any given asset.
* @dev there's currently no way to limit the rescuable "native asset", as we assume erc20s as intended underlying.
* @return the maximum amount of
*/
function maxRescue(address erc20Token) external view returns (uint256);
}
4 changes: 4 additions & 0 deletions src/mocks/ERC20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ contract ERC20 is Context, IERC20, IERC20Metadata {
return true;
}

function mint(address account, uint256 amount) external {
_mint(account, amount);
}

/**
* @dev Moves `amount` of tokens from `from` to `to`.
*
Expand Down
127 changes: 127 additions & 0 deletions test/PermissionlessRescuable.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import 'forge-std/Test.sol';
import {IERC20} from '../src/contracts/oz-common/interfaces/IERC20.sol';
import {Address} from '../src/contracts/oz-common/Address.sol';
import {ERC20} from '../src/mocks/ERC20.sol';
import {PermissionlessRescuable, IPermissionlessRescuable} from '../src/contracts/utils/PermissionlessRescuable.sol';
import {RescuableBase, IRescuableBase} from '../src/contracts/utils/RescuableBase.sol';

// Concrete implementation of PermissionlessRescuable for testing
contract TestPermissionlessRescuable is PermissionlessRescuable {
address public fundsReceiver;
address public restrictedErc20;

constructor(address _fundsReceiver, address _restrictedErc20) {
fundsReceiver = _fundsReceiver;
restrictedErc20 = _restrictedErc20;
}

function whoShouldReceiveFunds() public view override returns (address) {
return fundsReceiver;
}

/**
* Mock implementation forcing 10 wei leftover
*/
function maxRescue(
address erc20
) public view override(RescuableBase, IRescuableBase) returns (uint256) {
if (erc20 == restrictedErc20) {
uint256 balance = ERC20(erc20).balanceOf(address(this));
return balance > 10 ? balance - 10 : 0;
}
return type(uint256).max;
}

// Function to receive Ether
receive() external payable {}
}

contract PermissionlessRescuableTest is Test {
TestPermissionlessRescuable public rescuable;
ERC20 public mockToken;
ERC20 public restrictedMockToken;
address public fundsReceiver;

event ERC20Rescued(
address indexed caller,
address indexed token,
address indexed to,
uint256 amount
);
event NativeTokensRescued(address indexed caller, address indexed to, uint256 amount);

function setUp() public {
fundsReceiver = address(0x123);
mockToken = new ERC20('Test', 'TST');
restrictedMockToken = new ERC20('Test', 'TST');
rescuable = new TestPermissionlessRescuable(fundsReceiver, address(restrictedMockToken));
}

function test_whoShouldReceiveFunds() public {
assertEq(rescuable.whoShouldReceiveFunds(), fundsReceiver);
}

function test_emergencyTokenTransfer() public {
uint256 amount = 100;
mockToken.mint(address(rescuable), amount);

vm.expectEmit(true, true, true, true);
emit ERC20Rescued(address(this), address(mockToken), fundsReceiver, amount);

rescuable.emergencyTokenTransfer(address(mockToken), amount);

assertEq(mockToken.balanceOf(fundsReceiver), amount);
assertEq(mockToken.balanceOf(address(rescuable)), 0);
}

function test_emergencyTokenTransfer_withTransferRestriction() public {
uint256 amount = 100;

restrictedMockToken.mint(address(rescuable), amount);

vm.expectEmit(true, true, true, true);
emit ERC20Rescued(address(this), address(restrictedMockToken), fundsReceiver, amount - 10);

rescuable.emergencyTokenTransfer(address(restrictedMockToken), amount - 10);

assertEq(restrictedMockToken.balanceOf(fundsReceiver), amount - 10);
assertEq(restrictedMockToken.balanceOf(address(rescuable)), 10);

// we don't revert on zero to prevent griefing, so this will just pass but doing nothing
vm.expectEmit(true, true, true, true);
emit ERC20Rescued(address(this), address(restrictedMockToken), fundsReceiver, 0);

rescuable.emergencyTokenTransfer(address(restrictedMockToken), 10);
assertEq(restrictedMockToken.balanceOf(address(rescuable)), 10);
}

function test_emergencyEtherTransfer() public {
uint256 amount = 1 ether;
payable(address(rescuable)).transfer(amount);

vm.expectEmit(true, true, true, true);
emit NativeTokensRescued(address(this), fundsReceiver, amount);

rescuable.emergencyEtherTransfer(amount);

assertEq(address(rescuable).balance, 0);
assertEq(fundsReceiver.balance, amount);
}

function test_emergencyTokenTransferInsufficientBalance_shouldRevert() public {
uint256 amount = 100;
// Not minting any tokens to the contract
vm.expectRevert();
rescuable.emergencyTokenTransfer(address(mockToken), amount);
}

function test_emergencyEtherTransferInsufficientBalance_shouldRevert() public {
uint256 amount = 1 ether;
// Not sending any Ether to the contract
vm.expectRevert(bytes('ETH_TRANSFER_FAIL'));
rescuable.emergencyEtherTransfer(amount);
}
}
11 changes: 10 additions & 1 deletion test/Rescuable.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,25 @@ import {IERC20} from '../src/contracts/oz-common/interfaces/IERC20.sol';
import {Address} from '../src/contracts/oz-common/Address.sol';
import {ERC20} from '../src/mocks/ERC20.sol';
import {Rescuable, IRescuable} from '../src/contracts/utils/Rescuable.sol';
import {RescuableBase, IRescuableBase} from '../src/contracts/utils/RescuableBase.sol';

contract MockReceiverTokensContract is Rescuable {
address public immutable ALLOWED;
constructor (address allowedAddress) {

constructor(address allowedAddress) {
ALLOWED = allowedAddress;
}

function whoCanRescue() public view override returns (address) {
return ALLOWED;
}

function maxRescue(
address
) public pure override(RescuableBase, IRescuableBase) returns (uint256) {
return type(uint256).max;
}

receive() external payable {}
}

Expand Down
7 changes: 7 additions & 0 deletions test/Rescuable721.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'forge-std/Test.sol';
import {Address} from '../src/contracts/oz-common/Address.sol';
import {MockERC721, ERC721} from '../src/mocks/ERC721.sol';
import {Rescuable721} from '../src/contracts/utils/Rescuable721.sol';
import {RescuableBase, IRescuableBase} from '../src/contracts/utils/RescuableBase.sol';

contract MockReceiver721TokensContract is Rescuable721 {
address public immutable ALLOWED;
Expand All @@ -16,6 +17,12 @@ contract MockReceiver721TokensContract is Rescuable721 {
function whoCanRescue() public view override returns (address) {
return ALLOWED;
}

function maxRescue(
address
) public pure override(RescuableBase, IRescuableBase) returns (uint256) {
return type(uint256).max;
}
}

contract Rescue721Test is Test {
Expand Down

0 comments on commit a842c36

Please sign in to comment.