Skip to content

Commit 94b19e1

Browse files
committed
feat: support arbitary calls to support recovery of non erc20 tokens
1 parent eb6d9ad commit 94b19e1

File tree

6 files changed

+101
-50
lines changed

6 files changed

+101
-50
lines changed

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ CELO_RPC_URL=https://forno.celo.org
55
# Deployer address (Celo: Deployer 1)
66
DEPLOYER=0xE23a4c6615669526Ab58E9c37088bee4eD2b2dEE
77

8-
# Address authorized to call recover functions (so deployer doesn't burn nonces)
8+
# Owner of deployed recovery contracts (must differ from DEPLOYER to avoid burning nonces)
99
RECOVERER=0x0000000000000000000000000000000000000000
1010

1111
# Deploy recovery contracts up to (and including) this nonce

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
- **No contract on Celo** → burns the nonce with a cheap self-transfer (21k gas)
77
3. The `RecoveryContract` at the target nonce lands at the exact same address, allowing fund recovery
88

9-
The `RecoveryContract` supports recovering both native ETH and any ERC20 token. It uses OpenZeppelin's `Ownable` and adds an immutable `recoverer` role — a separate address authorized to call recovery functions so the deployer doesn't burn future nonces.
9+
The `RecoveryContract` supports recovering ETH, ERC20, ERC721, ERC1155, and arbitrary calls. It uses OpenZeppelin's `Ownable` with the owner set to a `RECOVERER` address (must differ from the deployer, so the deployer doesn't burn future nonces by interacting with the contracts).
1010

1111
## Setup
1212

@@ -24,7 +24,7 @@ Edit `.env`:
2424
ETH_RPC_URL=https://ethereum-rpc.publicnode.com
2525
CELO_RPC_URL=https://forno.celo.org
2626
DEPLOYER=0xE23a4c6615669526Ab58E9c37088bee4eD2b2dEE
27-
RECOVERER=0x... # set this to the address that will call recover functions
27+
RECOVERER=0x... # owner of deployed recovery contracts (must differ from DEPLOYER)
2828
MAX_NONCE=68
2929
```
3030

@@ -80,7 +80,7 @@ After deployment, verify the source code so the recovery functions are callable
8080
```bash
8181
forge verify-contract <CONTRACT_ADDRESS> \
8282
src/RecoveryContract.sol:RecoveryContract \
83-
--constructor-args $(cast abi-encode "constructor(address,address)" $DEPLOYER $RECOVERER) \
83+
--constructor-args $(cast abi-encode "constructor(address)" $RECOVERER) \
8484
--etherscan-api-key $ETHERSCAN_API_KEY \
8585
--chain 1
8686
```

script/DeployRecovery.s.sol

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ contract DeployRecovery is Script {
7777

7878
require(startNonce <= maxNonce, "Deployer nonce already past max nonce");
7979
require(recoverer != address(0), "Set RECOVERER in .env before deployment");
80+
require(
81+
recoverer != deployer,
82+
"RECOVERER must differ from DEPLOYER - calling recovery functions from the deployer burns future nonces"
83+
);
8084

8185
// Query Celo to find which nonces have contracts
8286
bool[] memory isContractNonce = _discoverContractNonces(celoRpc, deployer, maxNonce);
@@ -95,7 +99,7 @@ contract DeployRecovery is Script {
9599
address expectedAddr = vm.computeCreateAddress(deployer, nonce);
96100

97101
if (isContractNonce[nonce]) {
98-
RecoveryContract rc = new RecoveryContract(deployer, recoverer);
102+
RecoveryContract rc = new RecoveryContract(recoverer);
99103
console.log("DEPLOYED nonce", nonce, "->", address(rc));
100104
require(address(rc) == expectedAddr, "Address mismatch!");
101105
deployed++;

src/RecoveryContract.sol

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,41 +8,37 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol
88
/// @title RecoveryContract
99
/// @notice Minimal contract deployed to recover funds accidentally sent to
1010
/// CREATE-derived addresses on the wrong chain.
11+
/// Owner is set to the recoverer address so the deployer doesn't
12+
/// burn future nonces by interacting with the contract.
1113
contract RecoveryContract is Ownable {
1214
using SafeERC20 for IERC20;
1315

14-
address public immutable recoverer;
15-
16-
error NotAuthorized();
1716
error TransferFailed();
17+
error ExecutionFailed(bytes returnData);
1818

19-
modifier onlyAuthorized() {
20-
if (msg.sender != owner() && msg.sender != recoverer) revert NotAuthorized();
21-
_;
22-
}
23-
24-
/// @param _owner The deployer / primary owner.
25-
/// @param _recoverer A separate address authorized to recover funds,
26-
/// so the deployer doesn't need to send txs (burning future nonces).
27-
constructor(address _owner, address _recoverer) Ownable(_owner) {
28-
recoverer = _recoverer;
29-
}
19+
/// @param _recoverer The address that owns this contract and can recover funds.
20+
constructor(address _recoverer) Ownable(_recoverer) {}
3021

3122
/// @notice Recover native ETH held by this contract.
32-
/// @param to Destination address.
33-
/// @param amount Amount of ETH to transfer (use address(this).balance for all).
34-
function recoverETH(address payable to, uint256 amount) external onlyAuthorized {
23+
function recoverETH(address payable to, uint256 amount) external onlyOwner {
3524
(bool success,) = to.call{value: amount}("");
3625
if (!success) revert TransferFailed();
3726
}
3827

3928
/// @notice Recover any ERC20 token held by this contract.
40-
/// @param token The ERC20 token address.
41-
/// @param to Destination address.
42-
/// @param amount Amount to transfer (use balanceOf for all).
43-
function recoverERC20(address token, address to, uint256 amount) external onlyAuthorized {
29+
function recoverERC20(address token, address to, uint256 amount) external onlyOwner {
4430
IERC20(token).safeTransfer(to, amount);
4531
}
4632

33+
/// @notice Execute an arbitrary call. Recovers ERC721, ERC1155, or anything else.
34+
/// @param target The contract or address to call.
35+
/// @param value ETH value to send with the call.
36+
/// @param data Calldata to execute (e.g. ERC20.transfer, ERC721.transferFrom).
37+
function execute(address target, uint256 value, bytes calldata data) external onlyOwner returns (bytes memory) {
38+
(bool success, bytes memory result) = target.call{value: value}(data);
39+
if (!success) revert ExecutionFailed(result);
40+
return result;
41+
}
42+
4743
receive() external payable {}
4844
}

test/DeployRecovery.t.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ contract DeployRecoveryTest is Test {
8585

8686
if (isContract) {
8787
vm.setNonce(deployer, uint64(nonce));
88-
RecoveryContract rc = new RecoveryContract(deployer, recoverer);
88+
RecoveryContract rc = new RecoveryContract(recoverer);
8989
assertEq(address(rc), expected, string.concat("Address mismatch at nonce ", vm.toString(nonce)));
9090

9191
if (nonce == TARGET_NONCE) {

test/RecoveryContract.t.sol

Lines changed: 74 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,28 @@ import {RecoveryContract} from "../src/RecoveryContract.sol";
77

88
contract RecoveryContractTest is Test {
99
address constant OWNER = address(0x1);
10-
address constant RECOVERER = address(0x2);
1110
address constant ATTACKER = address(0x3);
1211
address payable constant RECIPIENT = payable(address(0x4));
1312

1413
RecoveryContract rc;
1514
MockERC20 token;
15+
MockERC721 nft;
1616

1717
function setUp() public {
18-
rc = new RecoveryContract(OWNER, RECOVERER);
18+
rc = new RecoveryContract(OWNER);
1919
token = new MockERC20();
20+
nft = new MockERC721();
2021
}
2122

2223
// ──────────────────── Construction ────────────────────
2324

2425
function test_constructor() public view {
2526
assertEq(rc.owner(), OWNER);
26-
assertEq(rc.recoverer(), RECOVERER);
2727
}
2828

2929
// ──────────────────── ETH recovery ────────────────────
3030

31-
function test_recoverETH_owner() public {
31+
function test_recoverETH() public {
3232
vm.deal(address(rc), 1 ether);
3333

3434
vm.prank(OWNER);
@@ -38,19 +38,10 @@ contract RecoveryContractTest is Test {
3838
assertEq(address(rc).balance, 0);
3939
}
4040

41-
function test_recoverETH_recoverer() public {
42-
vm.deal(address(rc), 1 ether);
43-
44-
vm.prank(RECOVERER);
45-
rc.recoverETH(RECIPIENT, 1 ether);
46-
47-
assertEq(RECIPIENT.balance, 1 ether);
48-
}
49-
5041
function test_recoverETH_partial() public {
5142
vm.deal(address(rc), 2 ether);
5243

53-
vm.prank(RECOVERER);
44+
vm.prank(OWNER);
5445
rc.recoverETH(RECIPIENT, 0.5 ether);
5546

5647
assertEq(RECIPIENT.balance, 0.5 ether);
@@ -61,13 +52,13 @@ contract RecoveryContractTest is Test {
6152
vm.deal(address(rc), 1 ether);
6253

6354
vm.prank(ATTACKER);
64-
vm.expectRevert(RecoveryContract.NotAuthorized.selector);
55+
vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, ATTACKER));
6556
rc.recoverETH(payable(ATTACKER), 1 ether);
6657
}
6758

6859
// ──────────────────── ERC20 recovery ────────────────────
6960

70-
function test_recoverERC20_owner() public {
61+
function test_recoverERC20() public {
7162
token.mint(address(rc), 1000e18);
7263

7364
vm.prank(OWNER);
@@ -77,21 +68,67 @@ contract RecoveryContractTest is Test {
7768
assertEq(token.balanceOf(address(rc)), 0);
7869
}
7970

80-
function test_recoverERC20_recoverer() public {
71+
function test_recoverERC20_unauthorized() public {
8172
token.mint(address(rc), 1000e18);
8273

83-
vm.prank(RECOVERER);
74+
vm.prank(ATTACKER);
75+
vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, ATTACKER));
8476
rc.recoverERC20(address(token), RECIPIENT, 1000e18);
77+
}
8578

86-
assertEq(token.balanceOf(RECIPIENT), 1000e18);
79+
// ──────────────────── Arbitrary execute ────────────────────
80+
81+
function test_execute_ETH() public {
82+
vm.deal(address(rc), 1 ether);
83+
84+
vm.prank(OWNER);
85+
rc.execute(RECIPIENT, 1 ether, "");
86+
87+
assertEq(RECIPIENT.balance, 1 ether);
8788
}
8889

89-
function test_recoverERC20_unauthorized() public {
90-
token.mint(address(rc), 1000e18);
90+
function test_execute_ERC721() public {
91+
nft.mint(address(rc), 42);
92+
93+
vm.prank(OWNER);
94+
rc.execute(address(nft), 0, abi.encodeCall(MockERC721.transferFrom, (address(rc), RECIPIENT, 42)));
95+
96+
assertEq(nft.ownerOf(42), RECIPIENT);
97+
}
98+
99+
function test_execute_unauthorized() public {
100+
vm.deal(address(rc), 1 ether);
91101

92102
vm.prank(ATTACKER);
93-
vm.expectRevert(RecoveryContract.NotAuthorized.selector);
94-
rc.recoverERC20(address(token), RECIPIENT, 1000e18);
103+
vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, ATTACKER));
104+
rc.execute(RECIPIENT, 1 ether, "");
105+
}
106+
107+
function test_execute_revertsOnFailure() public {
108+
vm.prank(OWNER);
109+
vm.expectRevert();
110+
rc.execute(address(token), 0, abi.encodeCall(MockERC20.transfer, (RECIPIENT, 1)));
111+
}
112+
113+
// ──────────────────── Ownable ────────────────────
114+
115+
function test_transferOwnership() public {
116+
address newOwner = makeAddr("newOwner");
117+
118+
vm.prank(OWNER);
119+
rc.transferOwnership(newOwner);
120+
assertEq(rc.owner(), newOwner);
121+
122+
vm.deal(address(rc), 1 ether);
123+
vm.prank(newOwner);
124+
rc.execute(RECIPIENT, 1 ether, "");
125+
assertEq(RECIPIENT.balance, 1 ether);
126+
}
127+
128+
function test_transferOwnership_unauthorized() public {
129+
vm.prank(ATTACKER);
130+
vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, ATTACKER));
131+
rc.transferOwnership(ATTACKER);
95132
}
96133

97134
// ──────────────────── Receive ────────────────────
@@ -118,3 +155,17 @@ contract MockERC20 {
118155
return true;
119156
}
120157
}
158+
159+
/// @dev Minimal ERC721 for testing
160+
contract MockERC721 {
161+
mapping(uint256 => address) public ownerOf;
162+
163+
function mint(address to, uint256 tokenId) external {
164+
ownerOf[tokenId] = to;
165+
}
166+
167+
function transferFrom(address from, address to, uint256 tokenId) external {
168+
require(ownerOf[tokenId] == from, "not owner");
169+
ownerOf[tokenId] = to;
170+
}
171+
}

0 commit comments

Comments
 (0)