Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions contracts/interfaces/IRescuable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/**
* @title IRescuable
* @dev Interface for rescuing funds from the contract.
*/
interface IRescuable {
/// @dev Native ETH transfer failed during rescue
error ETHTransferFailed();

/// @dev Rescues funds from the contract.
/// @param token The token to rescue, use `IERC20(address(0))` for native ETH.
/// @param amount The amount to rescue.
function rescueFunds(IERC20 token, uint256 amount) external;
}
35 changes: 35 additions & 0 deletions contracts/mixins/Rescuable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "../libraries/SafeERC20.sol";
import { IRescuable } from "../interfaces/IRescuable.sol";

/**
* @title Rescuable
* @dev Abstract contract for rescuing funds from the contract.
* Only the owner can rescue funds. Only native ETH and ERC20 tokens are supported.
* The token is transferred to the owner's address.
*/
abstract contract Rescuable is Ownable, IRescuable {
using SafeERC20 for IERC20;

/**
* @dev Sets the owner of the contract.
* @param owner Address of the owner.
*/
constructor(address owner) Ownable(owner) {}

/// @dev Rescues funds from the contract.
/// @param token The token to rescue, use `IERC20(address(0))` for native ETH.
/// @param amount The amount to rescue.
function rescueFunds(IERC20 token, uint256 amount) external virtual onlyOwner {
if(token == IERC20(address(0))) {
(bool success, ) = payable(owner()).call{ value: amount }("");
if (!success) revert ETHTransferFailed();
} else {
token.safeTransfer(owner(), amount);
}
}
}
18 changes: 18 additions & 0 deletions contracts/tests/mocks/NoReceiveOwnerMock.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

import { IRescuable } from "../../interfaces/IRescuable.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/// @dev Owner contract that cannot receive ETH, used to test ETHTransferFailed.
contract NoReceiveOwnerMock {
IRescuable private immutable _TARGET;

constructor(IRescuable target) {
_TARGET = target;
}

function rescueFunds(IERC20 token, uint256 amount) external {
_TARGET.rescueFunds(token, amount);
}
}
9 changes: 9 additions & 0 deletions contracts/tests/mocks/RescuableMock.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

import { Rescuable } from "../../mixins/Rescuable.sol";

contract RescuableMock is Rescuable {
constructor(address owner) Rescuable(owner) {}
receive() external payable {}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@1inch/solidity-utils",
"version": "6.9.5",
"version": "6.9.6",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
"exports": {
Expand Down
118 changes: 118 additions & 0 deletions test/contracts/Rescuable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { expect } from '../../src/expect';
import { ether } from '../../src/prelude';
import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers';
import { loadFixture } from '@nomicfoundation/hardhat-network-helpers';
import { ethers } from 'hardhat';
import type { RescuableMock } from '../../typechain-types/contracts/tests/mocks/RescuableMock';
import type { NoReceiveOwnerMock } from '../../typechain-types/contracts/tests/mocks/NoReceiveOwnerMock';

describe('Rescuable', function () {
let owner: SignerWithAddress;
let nonOwner: SignerWithAddress;

before(async function () {
[owner, nonOwner] = await ethers.getSigners();
});

async function deployRescuableMock() {
const RescuableMockFactory = await ethers.getContractFactory('RescuableMock');
const mock = await RescuableMockFactory.deploy(owner.address) as unknown as RescuableMock;

const TokenMock = await ethers.getContractFactory('TokenMock');
const token = await TokenMock.deploy('Test Token', 'TT');

return { mock, token };
}

describe('rescueFunds ERC20', function () {
it('should rescue ERC20 tokens to owner', async function () {
const { mock, token } = await loadFixture(deployRescuableMock);
const amount = ether('50');
await token.mint(mock, amount);

const ownerBalanceBefore = await token.balanceOf(owner.address);
await mock.rescueFunds(token, amount);
expect(await token.balanceOf(owner.address)).to.equal(ownerBalanceBefore + amount);
expect(await token.balanceOf(mock)).to.equal(0n);
});

it('should rescue partial ERC20 balance', async function () {
const { mock, token } = await loadFixture(deployRescuableMock);
const total = ether('100');
const rescue = ether('40');
await token.mint(mock, total);

await mock.rescueFunds(token, rescue);
expect(await token.balanceOf(mock)).to.equal(total - rescue);
});

it('should revert when called by non-owner', async function () {
const { mock, token } = await loadFixture(deployRescuableMock);
await expect(
mock.connect(nonOwner).rescueFunds(token, ether('1')),
).to.be.revertedWithCustomError(mock, 'OwnableUnauthorizedAccount');
});

it('should revert when token transfer returns false', async function () {
const { mock } = await loadFixture(deployRescuableMock);
const ERC20ReturnFalseMock = await ethers.getContractFactory('ERC20ReturnFalseMock');
const badToken = await ERC20ReturnFalseMock.deploy();

await expect(
mock.rescueFunds(badToken, ether('1')),
).to.be.revertedWithCustomError(mock, 'SafeTransferFailed');
});
});

describe('rescueFunds ETH', function () {
it('should rescue native ETH to owner', async function () {
const { mock } = await loadFixture(deployRescuableMock);
const amount = ether('1');
await owner.sendTransaction({ to: mock, value: amount });

const ownerBalanceBefore = await ethers.provider.getBalance(owner.address);
const tx = await mock.rescueFunds(ethers.ZeroAddress, amount);
const receipt = await tx.wait();
const gasCost = receipt!.gasUsed * receipt!.gasPrice;

expect(await ethers.provider.getBalance(owner.address)).to.equal(
ownerBalanceBefore + amount - gasCost,
);
expect(await ethers.provider.getBalance(mock)).to.equal(0n);
});

it('should rescue partial ETH balance', async function () {
const { mock } = await loadFixture(deployRescuableMock);
const total = ether('2');
const rescue = ether('1');
await owner.sendTransaction({ to: mock, value: total });

await mock.rescueFunds(ethers.ZeroAddress, rescue);
expect(await ethers.provider.getBalance(mock)).to.equal(total - rescue);
});

it('should revert when called by non-owner', async function () {
const { mock } = await loadFixture(deployRescuableMock);
await owner.sendTransaction({ to: mock, value: ether('1') });

await expect(
mock.connect(nonOwner).rescueFunds(ethers.ZeroAddress, ether('1')),
).to.be.revertedWithCustomError(mock, 'OwnableUnauthorizedAccount');
});

it('should revert when owner cannot receive ETH', async function () {
const { mock } = await loadFixture(deployRescuableMock);
const amount = ether('1');
await owner.sendTransaction({ to: mock, value: amount });

const NoReceiveOwnerMockFactory = await ethers.getContractFactory('NoReceiveOwnerMock');
const noReceiveOwner = await NoReceiveOwnerMockFactory.deploy(mock) as unknown as NoReceiveOwnerMock;

await mock.transferOwnership(noReceiveOwner);

await expect(
noReceiveOwner.rescueFunds(ethers.ZeroAddress, amount),
).to.be.revertedWithCustomError(mock, 'ETHTransferFailed');
});
});
});
Loading