From c9692eb5f28b433e50b4ed2492e041d22038cfb3 Mon Sep 17 00:00:00 2001 From: ifelsedeveloper Date: Tue, 10 Mar 2026 09:56:35 +0000 Subject: [PATCH 1/2] feat: implement IRescuable interface and Rescuable mixin for fund recovery --- contracts/interfaces/IRescuable.sol | 18 +++ contracts/mixins/Rescuable.sol | 35 ++++++ contracts/tests/mocks/NoReceiveOwnerMock.sol | 18 +++ contracts/tests/mocks/RescuableMock.sol | 9 ++ test/contracts/Rescuable.test.ts | 118 +++++++++++++++++++ 5 files changed, 198 insertions(+) create mode 100644 contracts/interfaces/IRescuable.sol create mode 100644 contracts/mixins/Rescuable.sol create mode 100644 contracts/tests/mocks/NoReceiveOwnerMock.sol create mode 100644 contracts/tests/mocks/RescuableMock.sol create mode 100644 test/contracts/Rescuable.test.ts diff --git a/contracts/interfaces/IRescuable.sol b/contracts/interfaces/IRescuable.sol new file mode 100644 index 00000000..5289ef7e --- /dev/null +++ b/contracts/interfaces/IRescuable.sol @@ -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; +} diff --git a/contracts/mixins/Rescuable.sol b/contracts/mixins/Rescuable.sol new file mode 100644 index 00000000..216d049d --- /dev/null +++ b/contracts/mixins/Rescuable.sol @@ -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); + } + } +} diff --git a/contracts/tests/mocks/NoReceiveOwnerMock.sol b/contracts/tests/mocks/NoReceiveOwnerMock.sol new file mode 100644 index 00000000..a78117f4 --- /dev/null +++ b/contracts/tests/mocks/NoReceiveOwnerMock.sol @@ -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); + } +} diff --git a/contracts/tests/mocks/RescuableMock.sol b/contracts/tests/mocks/RescuableMock.sol new file mode 100644 index 00000000..a032bd81 --- /dev/null +++ b/contracts/tests/mocks/RescuableMock.sol @@ -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 {} +} diff --git a/test/contracts/Rescuable.test.ts b/test/contracts/Rescuable.test.ts new file mode 100644 index 00000000..7d595a26 --- /dev/null +++ b/test/contracts/Rescuable.test.ts @@ -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'); + }); + }); +}); From 7ab024c783cdddf70e6c8e9db8e50a182fc32f5f Mon Sep 17 00:00:00 2001 From: ifelsedeveloper Date: Tue, 10 Mar 2026 09:57:29 +0000 Subject: [PATCH 2/2] chore: bump version to 6.9.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 45b67577..cb70de95 100644 --- a/package.json +++ b/package.json @@ -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": {