From a1925c12079be2f568b8abcb2ceb87ce6b9b556f Mon Sep 17 00:00:00 2001 From: Vectorized Date: Mon, 18 Aug 2025 11:48:23 +0000 Subject: [PATCH 1/6] T --- src/utils/SafeTransferLib.sol | 45 +++++++++++++++++++ test/SafeTransferLib.t.sol | 85 +++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/src/utils/SafeTransferLib.sol b/src/utils/SafeTransferLib.sol index 1b47baf10a..d4ef4b793c 100644 --- a/src/utils/SafeTransferLib.sol +++ b/src/utils/SafeTransferLib.sol @@ -63,6 +63,11 @@ library SafeTransferLib { /// [Etherscan](https://etherscan.io/address/0x000000000022D473030F116dDEE9F6B43aC78BA3) address internal constant PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + /// @dev The canonical address of the `SELFDESTRUCT` ETH mover. + /// + /// [Etherscan](https://etherscan.io/address/0x00000000000073c48c8055bD43D1A53799176f0D) + address internal constant ETH_MOVER = 0x00000000000073c48c8055bD43D1A53799176f0D; + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* ETH OPERATIONS */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ @@ -192,6 +197,46 @@ library SafeTransferLib { } } + /// @dev Force transfers ETH to `to`, without triggering the fallback (if any). + /// This method attempts to use a separate contract to send via `SELFDESTRUCT`, + /// and upon failure, deploys a minimal vault to accrue the ETH. + function saveMoveETH(address to, uint256 amount) internal returns (address vault) { + /// @solidity memory-safe-assembly + assembly { + to := shr(96, shl(96, to)) // Clean upper 96 bits. + for { let mover := ETH_MOVER } iszero(eq(to, address())) {} { + if or(lt(selfbalance(), amount), eq(to, mover)) { + mstore(0x00, 0xb12d13eb) // `ETHTransferFailed()`. + revert(0x1c, 0x04) + } + if extcodesize(mover) { + let balanceBefore := balance(to) // Check via delta, in case `SELFDESTRUCT` is bricked. + pop(call(gas(), mover, amount, codesize(), 0x00, codesize(), 0x00)) + if iszero(lt(add(amount, balance(to)), balanceBefore)) { break } + } + let m := mload(0x40) + // If the mover is missing or bricked, deploy a minimal vault + // that withdraws all ETH to `to` when being called only by `to`. + // forgefmt: disable-next-item + mstore(add(m, 0x20), 0x33146025575b600160005260206000f35b3d3d3d3d47335af1601a5760003dfd) + mstore(m, or(to, shl(160, 0x6035600b3d3960353df3fe73))) + // Compute and store the bytecode hash. + mstore8(0x00, 0xff) // Write the prefix. + mstore(0x35, keccak256(m, 0x40)) + mstore(0x01, shl(96, address())) // Deployer. + mstore(0x15, 0) // Salt. + vault := keccak256(0x00, 0x55) + pop(call(gas(), vault, amount, codesize(), 0x00, codesize(), 0x00)) + // The vault returns a single word on success. Failure reverts with empty data. + if iszero(returndatasize()) { + if iszero(create2(0, m, 0x40, 0)) { revert(codesize(), codesize()) } // For gas estimation. + } + mstore(0x40, m) // Restore the free memory pointer. + break + } + } + } + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* ERC20 OPERATIONS */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ diff --git a/test/SafeTransferLib.t.sol b/test/SafeTransferLib.t.sol index 1835dc9ef1..8634498933 100644 --- a/test/SafeTransferLib.t.sol +++ b/test/SafeTransferLib.t.sol @@ -1176,4 +1176,89 @@ contract SafeTransferLibTest is SoladyTest { function totalSupplyQuery(address token) public view returns (uint256) { return SafeTransferLib.totalSupply(token); } + + function testSaveMoveETHViaVault(bytes32) public { + address to = _randomHashedAddress(); + assertEq(to.balance, 0); + + uint256 amount0 = _bound(_random(), 0, 2 ** 128 - 1); + uint256 amount1 = _bound(_random(), 0, 2 ** 128 - 1); + vm.deal(address(this), 2 ** 160 - 1); + address vault = SafeTransferLib.saveMoveETH(_brutalized(to), amount0); + assertEq(vault.balance, amount0); + assertEq(SafeTransferLib.saveMoveETH(_brutalized(to), amount1), vault); + assertEq(vault.balance, amount0 + amount1); + + vm.prank(to); + (bool success,) = vault.call(""); + require(success); + assertEq(vault.balance, 0); + assertEq(to.balance, amount0 + amount1); + } + + function saveMoveETHViaMover(bytes32) public { + _deployETHMover(); + + address to = _randomHashedAddress(); + assertEq(to.balance, 0); + + uint256 amount0 = _bound(_random(), 0, 2 ** 128 - 1); + uint256 amount1 = _bound(_random(), 0, 2 ** 128 - 1); + vm.deal(address(this), 2 ** 160 - 1); + uint256 selfBalanceBefore = address(this).balance; + assertEq(SafeTransferLib.saveMoveETH(_brutalized(to), amount0), address(0)); + + assertEq(to.balance, amount0); + assertEq(address(this).balance, selfBalanceBefore - amount0); + + if (SafeTransferLib.ETH_MOVER.code.length == 0) { + address vault = SafeTransferLib.saveMoveETH(_brutalized(to), amount0); + assertEq(vault.balance, amount1); + assertEq(to.balance, amount0); + assertEq(address(this).balance, selfBalanceBefore - amount0); + } else { + assertEq(SafeTransferLib.saveMoveETH(_brutalized(to), amount0), address(0)); + assertEq(to.balance, amount0 + amount1); + assertEq(address(this).balance, selfBalanceBefore - amount0 - amount1); + } + } + + function _deployETHMover() internal { + bytes memory initCode = hex"623d35ff3d526003601df3"; + bytes32 salt = 0x000000000000000000000000000000000000000063d76c4f57ebf10084429e18; + address mover = _nicksCreate2(0, salt, initCode); + assertEq(mover.code, hex"3d35ff"); + assertEq(mover, SafeTransferLib.ETH_MOVER); + } + + function _deployOneTimeVault(address to, uint256 amount) internal returns (address vault) { + /// @solidity memory-safe-assembly + assembly { + to := shr(96, shl(96, to)) // Clean upper 96 bits. + for {} 1 {} { + let m := mload(0x40) + // If the mover is missing or bricked, deploy a minimal accrual contract + // that withdraws all ETH to `to` when being called only by `to`. + mstore( + add(m, 0x1f), 0x33146025575b600160005260206000f35b3d3d3d3d47335af1601a573d3dfd + ) + mstore(m, or(to, shl(160, 0x6034600b3d3960343df3fe73))) + // Compute and store the bytecode hash. + mstore8(0x00, 0xff) // Write the prefix. + mstore(0x35, keccak256(m, 0x3f)) + mstore(0x01, shl(96, address())) // Deployer. + mstore(0x15, 0) // Salt. + vault := keccak256(0x00, 0x55) + if iszero( + mul( + returndatasize(), + call(gas(), vault, amount, codesize(), 0x00, codesize(), 0x00) + ) + ) { if iszero(create2(0, m, 0x3f, 0)) { revert(codesize(), codesize()) } } // For gas estimation. + + mstore(0x40, m) + break + } + } + } } From f3a69a781cf1b8e1044486348c6b9a47ade62c81 Mon Sep 17 00:00:00 2001 From: Vectorized Date: Mon, 18 Aug 2025 11:56:03 +0000 Subject: [PATCH 2/6] T --- src/utils/SafeTransferLib.sol | 2 +- test/SafeTransferLib.t.sol | 37 +++++++++++++++++++++++++++++------ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/utils/SafeTransferLib.sol b/src/utils/SafeTransferLib.sol index d4ef4b793c..3e4232ad60 100644 --- a/src/utils/SafeTransferLib.sol +++ b/src/utils/SafeTransferLib.sol @@ -200,7 +200,7 @@ library SafeTransferLib { /// @dev Force transfers ETH to `to`, without triggering the fallback (if any). /// This method attempts to use a separate contract to send via `SELFDESTRUCT`, /// and upon failure, deploys a minimal vault to accrue the ETH. - function saveMoveETH(address to, uint256 amount) internal returns (address vault) { + function safeMoveETH(address to, uint256 amount) internal returns (address vault) { /// @solidity memory-safe-assembly assembly { to := shr(96, shl(96, to)) // Clean upper 96 bits. diff --git a/test/SafeTransferLib.t.sol b/test/SafeTransferLib.t.sol index 8634498933..953868dbe4 100644 --- a/test/SafeTransferLib.t.sol +++ b/test/SafeTransferLib.t.sol @@ -1184,9 +1184,9 @@ contract SafeTransferLibTest is SoladyTest { uint256 amount0 = _bound(_random(), 0, 2 ** 128 - 1); uint256 amount1 = _bound(_random(), 0, 2 ** 128 - 1); vm.deal(address(this), 2 ** 160 - 1); - address vault = SafeTransferLib.saveMoveETH(_brutalized(to), amount0); + address vault = this.safeMoveETH(to, amount0); assertEq(vault.balance, amount0); - assertEq(SafeTransferLib.saveMoveETH(_brutalized(to), amount1), vault); + assertEq(this.safeMoveETH(to, amount1), vault); assertEq(vault.balance, amount0 + amount1); vm.prank(to); @@ -1196,7 +1196,7 @@ contract SafeTransferLibTest is SoladyTest { assertEq(to.balance, amount0 + amount1); } - function saveMoveETHViaMover(bytes32) public { + function safeMoveETHViaMover(bytes32) public { _deployETHMover(); address to = _randomHashedAddress(); @@ -1206,23 +1206,48 @@ contract SafeTransferLibTest is SoladyTest { uint256 amount1 = _bound(_random(), 0, 2 ** 128 - 1); vm.deal(address(this), 2 ** 160 - 1); uint256 selfBalanceBefore = address(this).balance; - assertEq(SafeTransferLib.saveMoveETH(_brutalized(to), amount0), address(0)); + assertEq(SafeTransferLib.safeMoveETH(to, amount0), address(0)); assertEq(to.balance, amount0); assertEq(address(this).balance, selfBalanceBefore - amount0); if (SafeTransferLib.ETH_MOVER.code.length == 0) { - address vault = SafeTransferLib.saveMoveETH(_brutalized(to), amount0); + address vault = this.safeMoveETH(to, amount0); assertEq(vault.balance, amount1); assertEq(to.balance, amount0); assertEq(address(this).balance, selfBalanceBefore - amount0); } else { - assertEq(SafeTransferLib.saveMoveETH(_brutalized(to), amount0), address(0)); + assertEq(this.safeMoveETH(to, amount0), address(0)); assertEq(to.balance, amount0 + amount1); assertEq(address(this).balance, selfBalanceBefore - amount0 - amount1); } } + function testSaveMoveETHToSelfIsNoOp(bytes32) public { + if (_randomChance(2)) _deployETHMover(); + address to = address(this); + uint256 amount = _bound(_random(), 0, 2 ** 128 - 1); + vm.deal(address(this), 2 ** 160 - 1); + uint256 selfBalanceBefore = address(this).balance; + assertEq(this.safeMoveETH(to, amount), address(0)); + assertEq(address(this).balance, selfBalanceBefore); + } + + function testSaveMoveETHToMoverReverts() public { + if (_randomChance(2)) _deployETHMover(); + address to = SafeTransferLib.ETH_MOVER; + + uint256 amount = _bound(_random(), 0, 2 ** 128 - 1); + vm.deal(address(this), 2 ** 160 - 1); + + vm.expectRevert(SafeTransferLib.ETHTransferFailed.selector); + this.safeMoveETH(to, amount); + } + + function safeMoveETH(address to, uint256 amount) public returns (address) { + return SafeTransferLib.safeMoveETH(_brutalized(to), amount); + } + function _deployETHMover() internal { bytes memory initCode = hex"623d35ff3d526003601df3"; bytes32 salt = 0x000000000000000000000000000000000000000063d76c4f57ebf10084429e18; From 343aafb63cbfbe89691c8f47ba1f5f325aaef703 Mon Sep 17 00:00:00 2001 From: Vectorized Date: Mon, 18 Aug 2025 11:57:11 +0000 Subject: [PATCH 3/6] T --- test/SafeTransferLib.t.sol | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/test/SafeTransferLib.t.sol b/test/SafeTransferLib.t.sol index 953868dbe4..2cc7a638c3 100644 --- a/test/SafeTransferLib.t.sol +++ b/test/SafeTransferLib.t.sol @@ -1233,7 +1233,7 @@ contract SafeTransferLibTest is SoladyTest { assertEq(address(this).balance, selfBalanceBefore); } - function testSaveMoveETHToMoverReverts() public { + function testSaveMoveETHToMoverReverts(bytes32) public { if (_randomChance(2)) _deployETHMover(); address to = SafeTransferLib.ETH_MOVER; @@ -1244,6 +1244,19 @@ contract SafeTransferLibTest is SoladyTest { this.safeMoveETH(to, amount); } + function testSaveMoveETHInsufficientBalanceReverts(bytes32) public { + if (_randomChance(2)) _deployETHMover(); + address to = _randomHashedAddress(); + + uint256 amount = _bound(_random(), 0, 2 ** 128 - 1); + vm.deal(address(this), 2 ** 128 - 1); + + if (address(this).balance > amount) { + vm.expectRevert(SafeTransferLib.ETHTransferFailed.selector); + this.safeMoveETH(to, amount); + } + } + function safeMoveETH(address to, uint256 amount) public returns (address) { return SafeTransferLib.safeMoveETH(_brutalized(to), amount); } From 58abef4229e252f7cb0c5f000b37652c347d0e59 Mon Sep 17 00:00:00 2001 From: Vectorized Date: Mon, 18 Aug 2025 11:58:07 +0000 Subject: [PATCH 4/6] T --- test/SafeTransferLib.t.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/SafeTransferLib.t.sol b/test/SafeTransferLib.t.sol index 2cc7a638c3..0cd845e801 100644 --- a/test/SafeTransferLib.t.sol +++ b/test/SafeTransferLib.t.sol @@ -1251,9 +1251,11 @@ contract SafeTransferLibTest is SoladyTest { uint256 amount = _bound(_random(), 0, 2 ** 128 - 1); vm.deal(address(this), 2 ** 128 - 1); - if (address(this).balance > amount) { + if (address(this).balance < amount) { vm.expectRevert(SafeTransferLib.ETHTransferFailed.selector); this.safeMoveETH(to, amount); + } else { + this.safeMoveETH(to, amount); } } From eaf503265869bef223c2761f746d7cc355985acb Mon Sep 17 00:00:00 2001 From: Vectorized Date: Mon, 18 Aug 2025 12:00:51 +0000 Subject: [PATCH 5/6] T --- src/utils/SafeTransferLib.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/SafeTransferLib.sol b/src/utils/SafeTransferLib.sol index 3e4232ad60..3c63993adc 100644 --- a/src/utils/SafeTransferLib.sol +++ b/src/utils/SafeTransferLib.sol @@ -64,7 +64,7 @@ library SafeTransferLib { address internal constant PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; /// @dev The canonical address of the `SELFDESTRUCT` ETH mover. - /// + /// See: https://gist.github.com/Vectorized/1cb8ad4cf393b1378e08f23f79bd99fa /// [Etherscan](https://etherscan.io/address/0x00000000000073c48c8055bD43D1A53799176f0D) address internal constant ETH_MOVER = 0x00000000000073c48c8055bD43D1A53799176f0D; From 0e7d7909ccc926018b4b54b91a1036d6d5c0f537 Mon Sep 17 00:00:00 2001 From: Vectorized Date: Mon, 18 Aug 2025 12:06:11 +0000 Subject: [PATCH 6/6] T --- test/SafeTransferLib.t.sol | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/SafeTransferLib.t.sol b/test/SafeTransferLib.t.sol index 0cd845e801..120e496f93 100644 --- a/test/SafeTransferLib.t.sol +++ b/test/SafeTransferLib.t.sol @@ -1178,7 +1178,7 @@ contract SafeTransferLibTest is SoladyTest { } function testSaveMoveETHViaVault(bytes32) public { - address to = _randomHashedAddress(); + address to = _randomUniqueHashedAddress(); assertEq(to.balance, 0); uint256 amount0 = _bound(_random(), 0, 2 ** 128 - 1); @@ -1189,9 +1189,16 @@ contract SafeTransferLibTest is SoladyTest { assertEq(this.safeMoveETH(to, amount1), vault); assertEq(vault.balance, amount0 + amount1); - vm.prank(to); + address pranker = _randomUniqueHashedAddress(); + vm.prank(pranker); (bool success,) = vault.call(""); require(success); + assertEq(vault.balance, amount0 + amount1); + assertEq(to.balance, 0); + + vm.prank(to); + (success,) = vault.call(""); + require(success); assertEq(vault.balance, 0); assertEq(to.balance, amount0 + amount1); }