Skip to content

Commit 59f88b0

Browse files
committed
feat: upgrade bridge to allow for transfer of all token assets
1 parent a2d5398 commit 59f88b0

File tree

2 files changed

+327
-2
lines changed

2 files changed

+327
-2
lines changed

contracts/src/Portal.sol

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,33 @@ contract Portal is Initializable, ResourceMetering, ISemver {
9090
/// @param success Whether the withdrawal transaction was successful.
9191
event WithdrawalFinalized(bytes32 indexed withdrawalHash, bool success);
9292

93+
/// @notice Emitted when an emergency withdrawal is executed.
94+
/// @param recipient The address that received the funds.
95+
/// @param token The token address (Constants.ETHER for native ETH).
96+
/// @param amount The amount withdrawn.
97+
event EmergencyWithdrawal(address indexed recipient, address indexed token, uint256 amount);
98+
9399
/// @notice Reverts when paused.
94100
modifier whenNotPaused() {
95101
if (paused()) revert CallPaused();
96102
_;
97103
}
98104

105+
/// @notice Reverts if caller is not the proxy admin.
106+
modifier onlyAdmin() {
107+
address admin;
108+
bytes32 adminSlot = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
109+
assembly {
110+
admin := sload(adminSlot)
111+
}
112+
require(msg.sender == admin, "Portal: caller is not admin");
113+
_;
114+
}
115+
99116
/// @notice Semantic version.
100-
/// @custom:semver 1.0.0
117+
/// @custom:semver 1.1.0
101118
function version() public pure virtual returns (string memory) {
102-
return "1.0.0";
119+
return "1.1.0";
103120
}
104121

105122
/// @notice Constructs the OptimismPortal contract.
@@ -489,6 +506,33 @@ contract Portal is Initializable, ResourceMetering, ISemver {
489506
);
490507
}
491508

509+
/// @notice Emergency function to withdraw all tokens held by the portal.
510+
/// This is intended for fund recovery in emergency situations.
511+
/// Can be called regardless of pause state.
512+
/// @param _recipient The address to receive the withdrawn funds.
513+
function emergencyWithdraw(address _recipient) external onlyAdmin {
514+
require(_recipient != address(0), "Portal: zero recipient");
515+
516+
(address token,) = gasPayingToken();
517+
uint256 amount;
518+
519+
if (token == Constants.ETHER) {
520+
amount = address(this).balance;
521+
if (amount > 0) {
522+
(bool success,) = _recipient.call{value: amount}("");
523+
require(success, "Portal: ETH transfer failed");
524+
}
525+
} else {
526+
amount = IERC20(token).balanceOf(address(this));
527+
if (amount > 0) {
528+
_balance = 0;
529+
IERC20(token).safeTransfer(_recipient, amount);
530+
}
531+
}
532+
533+
emit EmergencyWithdrawal(_recipient, token, amount);
534+
}
535+
492536
/// @notice Determine if a given output is finalized.
493537
/// Reverts if the call to l2Oracle.getL2Output reverts.
494538
/// Returns a boolean otherwise.
@@ -505,4 +549,7 @@ contract Portal is Initializable, ResourceMetering, ISemver {
505549
function _isFinalizationPeriodElapsed(uint256 _timestamp) internal view returns (bool) {
506550
return block.timestamp > _timestamp;
507551
}
552+
553+
/// @dev Reserved storage for future upgrades.
554+
uint256[44] private __gap;
508555
}

contracts/test/Portal.t.sol

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.15;
3+
4+
import {Test} from "forge-std/Test.sol";
5+
import {ProxyAdmin} from "@eth-optimism-bedrock/src/universal/ProxyAdmin.sol";
6+
import {Proxy} from "@eth-optimism-bedrock/src/universal/Proxy.sol";
7+
import {ISuperchainConfig} from "@eth-optimism-bedrock/src/L1/interfaces/ISuperchainConfig.sol";
8+
import {ISystemConfig} from "@eth-optimism-bedrock/src/L1/interfaces/ISystemConfig.sol";
9+
import {IResourceMetering} from "@eth-optimism-bedrock/src/L1/interfaces/IResourceMetering.sol";
10+
import {Constants} from "@eth-optimism-bedrock/src/libraries/Constants.sol";
11+
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
12+
13+
import {Portal} from "../src/Portal.sol";
14+
import {OutputOracle} from "../src/OutputOracle.sol";
15+
16+
/// @notice Mock ERC20 token for testing
17+
contract MockERC20 is ERC20 {
18+
constructor() ERC20("Mock Token", "MCK") {}
19+
20+
function mint(address to, uint256 amount) external {
21+
_mint(to, amount);
22+
}
23+
24+
function decimals() public pure override returns (uint8) {
25+
return 18;
26+
}
27+
}
28+
29+
/// @notice Mock SuperchainConfig for testing
30+
contract MockSuperchainConfig is ISuperchainConfig {
31+
bool internal _paused;
32+
address internal _guardian;
33+
34+
function setPaused(bool paused_) external {
35+
_paused = paused_;
36+
}
37+
38+
function paused() external view returns (bool) {
39+
return _paused;
40+
}
41+
42+
function guardian() external view returns (address) {
43+
return _guardian;
44+
}
45+
46+
function GUARDIAN_SLOT() external pure returns (bytes32) {
47+
return bytes32(0);
48+
}
49+
50+
function PAUSED_SLOT() external pure returns (bytes32) {
51+
return bytes32(0);
52+
}
53+
54+
function initialize(address, bool) external {}
55+
56+
function pause(string memory) external {}
57+
58+
function unpause() external {}
59+
60+
function version() external pure returns (string memory) {
61+
return "1.0.0";
62+
}
63+
}
64+
65+
/// @notice Mock SystemConfig for testing
66+
contract MockSystemConfig {
67+
address internal _gasPayingToken;
68+
69+
function setGasPayingToken(address token) external {
70+
_gasPayingToken = token;
71+
}
72+
73+
function gasPayingToken() external view returns (address, uint8) {
74+
if (_gasPayingToken == address(0)) {
75+
return (Constants.ETHER, 18);
76+
}
77+
return (_gasPayingToken, 18);
78+
}
79+
80+
function resourceConfig() external pure returns (IResourceMetering.ResourceConfig memory) {
81+
return IResourceMetering.ResourceConfig({
82+
maxResourceLimit: 20_000_000,
83+
elasticityMultiplier: 10,
84+
baseFeeMaxChangeDenominator: 8,
85+
minimumBaseFee: 1 gwei,
86+
systemTxMaxGas: 1_000_000,
87+
maximumBaseFee: type(uint128).max
88+
});
89+
}
90+
}
91+
92+
contract PortalTest is Test {
93+
Portal internal portalImpl;
94+
Portal internal portal;
95+
ProxyAdmin internal admin;
96+
Proxy internal proxy;
97+
MockSuperchainConfig internal superchainConfig;
98+
MockSystemConfig internal systemConfig;
99+
MockERC20 internal token;
100+
101+
address internal recipient = makeAddr("recipient");
102+
address internal nonAdmin = makeAddr("nonAdmin");
103+
104+
/// @notice EIP-1967 admin slot
105+
bytes32 internal constant ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
106+
107+
/// @notice Emitted when an emergency withdrawal is executed.
108+
event EmergencyWithdrawal(address indexed recipient, address indexed token, uint256 amount);
109+
110+
function setUp() public {
111+
// Deploy mocks
112+
superchainConfig = new MockSuperchainConfig();
113+
systemConfig = new MockSystemConfig();
114+
token = new MockERC20();
115+
116+
// Deploy Portal implementation
117+
portalImpl = new Portal();
118+
119+
// Deploy proxy with admin
120+
admin = new ProxyAdmin(address(this));
121+
proxy = new Proxy(address(admin));
122+
123+
// Upgrade proxy to Portal implementation
124+
admin.upgrade(payable(address(proxy)), address(portalImpl));
125+
126+
// Get Portal interface on proxy
127+
portal = Portal(payable(address(proxy)));
128+
129+
// Initialize portal
130+
portal.initialize({
131+
_l2Oracle: OutputOracle(address(0)),
132+
_systemConfig: ISystemConfig(address(systemConfig)),
133+
_superchainConfig: ISuperchainConfig(address(superchainConfig))
134+
});
135+
}
136+
137+
function test_emergencyWithdraw_ETH_success() public {
138+
// Fund the portal with ETH
139+
uint256 amount = 10 ether;
140+
vm.deal(address(portal), amount);
141+
142+
uint256 recipientBalanceBefore = recipient.balance;
143+
144+
// Admin withdraws (admin is ProxyAdmin, which is owned by address(this))
145+
// But the actual proxy admin is the ProxyAdmin contract, so we need to call from it
146+
// Actually, the admin slot stores the ProxyAdmin address, so we need to prank as ProxyAdmin
147+
vm.prank(address(admin));
148+
portal.emergencyWithdraw(recipient);
149+
150+
assertEq(address(portal).balance, 0);
151+
assertEq(recipient.balance, recipientBalanceBefore + amount);
152+
}
153+
154+
function test_emergencyWithdraw_ERC20_success() public {
155+
// Set up custom gas token
156+
systemConfig.setGasPayingToken(address(token));
157+
158+
// Fund the portal with ERC20 tokens
159+
uint256 amount = 1000 ether;
160+
token.mint(address(portal), amount);
161+
162+
uint256 recipientBalanceBefore = token.balanceOf(recipient);
163+
164+
// Admin withdraws
165+
vm.prank(address(admin));
166+
portal.emergencyWithdraw(recipient);
167+
168+
assertEq(token.balanceOf(address(portal)), 0);
169+
assertEq(token.balanceOf(recipient), recipientBalanceBefore + amount);
170+
}
171+
172+
function test_emergencyWithdraw_onlyAdmin_reverts() public {
173+
// Fund the portal
174+
vm.deal(address(portal), 10 ether);
175+
176+
// Non-admin tries to withdraw
177+
vm.prank(nonAdmin);
178+
vm.expectRevert("Portal: caller is not admin");
179+
portal.emergencyWithdraw(recipient);
180+
}
181+
182+
function test_emergencyWithdraw_zeroRecipient_reverts() public {
183+
// Fund the portal
184+
vm.deal(address(portal), 10 ether);
185+
186+
// Admin tries to withdraw to zero address
187+
vm.prank(address(admin));
188+
vm.expectRevert("Portal: zero recipient");
189+
portal.emergencyWithdraw(address(0));
190+
}
191+
192+
function test_emergencyWithdraw_worksWhenPaused() public {
193+
// Fund the portal
194+
uint256 amount = 10 ether;
195+
vm.deal(address(portal), amount);
196+
197+
// Pause the superchain config
198+
superchainConfig.setPaused(true);
199+
assertTrue(portal.paused());
200+
201+
uint256 recipientBalanceBefore = recipient.balance;
202+
203+
// Admin can still withdraw even when paused
204+
vm.prank(address(admin));
205+
portal.emergencyWithdraw(recipient);
206+
207+
assertEq(address(portal).balance, 0);
208+
assertEq(recipient.balance, recipientBalanceBefore + amount);
209+
}
210+
211+
function test_emergencyWithdraw_emitsEvent() public {
212+
// Fund the portal
213+
uint256 amount = 10 ether;
214+
vm.deal(address(portal), amount);
215+
216+
// Expect the EmergencyWithdrawal event
217+
vm.expectEmit(true, true, false, true);
218+
emit EmergencyWithdrawal(recipient, Constants.ETHER, amount);
219+
220+
vm.prank(address(admin));
221+
portal.emergencyWithdraw(recipient);
222+
}
223+
224+
function test_emergencyWithdraw_emitsEvent_ERC20() public {
225+
// Set up custom gas token
226+
systemConfig.setGasPayingToken(address(token));
227+
228+
// Fund the portal
229+
uint256 amount = 1000 ether;
230+
token.mint(address(portal), amount);
231+
232+
// Expect the EmergencyWithdrawal event
233+
vm.expectEmit(true, true, false, true);
234+
emit EmergencyWithdrawal(recipient, address(token), amount);
235+
236+
vm.prank(address(admin));
237+
portal.emergencyWithdraw(recipient);
238+
}
239+
240+
function test_emergencyWithdraw_zeroBalance_ETH() public {
241+
// Portal has no ETH
242+
assertEq(address(portal).balance, 0);
243+
244+
uint256 recipientBalanceBefore = recipient.balance;
245+
246+
// Admin withdraws (should succeed with 0 amount)
247+
vm.expectEmit(true, true, false, true);
248+
emit EmergencyWithdrawal(recipient, Constants.ETHER, 0);
249+
250+
vm.prank(address(admin));
251+
portal.emergencyWithdraw(recipient);
252+
253+
assertEq(recipient.balance, recipientBalanceBefore);
254+
}
255+
256+
function test_emergencyWithdraw_zeroBalance_ERC20() public {
257+
// Set up custom gas token
258+
systemConfig.setGasPayingToken(address(token));
259+
260+
// Portal has no tokens
261+
assertEq(token.balanceOf(address(portal)), 0);
262+
263+
uint256 recipientBalanceBefore = token.balanceOf(recipient);
264+
265+
// Admin withdraws (should succeed with 0 amount)
266+
vm.expectEmit(true, true, false, true);
267+
emit EmergencyWithdrawal(recipient, address(token), 0);
268+
269+
vm.prank(address(admin));
270+
portal.emergencyWithdraw(recipient);
271+
272+
assertEq(token.balanceOf(recipient), recipientBalanceBefore);
273+
}
274+
275+
function test_version() public view {
276+
assertEq(portal.version(), "1.1.0");
277+
}
278+
}

0 commit comments

Comments
 (0)