From 30eeb68cbb731badebb4990a47e2d5b68611fd53 Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Tue, 12 May 2026 13:28:23 -0500 Subject: [PATCH 01/18] Revert "Abandon Katana chain" (#557) This reverts fe0395b84064a021bcfe9cca0e4f2725f5ba2aeb. --- CHANGELOG.md | 1 - README.md | 2 +- api_secrets.json.template | 4 ++ chain_config.json | 37 ++++++++++ script/SafeConfig.sol | 3 + src/Settler.sol | 4 ++ src/chains/Katana/BridgeSettler.sol | 25 +++++++ src/chains/Katana/Common.sol | 49 +++++++++++++ src/chains/Katana/Intent.sol | 102 +++++++++++++++++++++++++++ src/chains/Katana/MetaTxn.sol | 68 ++++++++++++++++++ src/chains/Katana/TakerSubmitted.sol | 58 +++++++++++++++ 11 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 src/chains/Katana/BridgeSettler.sol create mode 100644 src/chains/Katana/Common.sol create mode 100644 src/chains/Katana/Intent.sol create mode 100644 src/chains/Katana/MetaTxn.sol create mode 100644 src/chains/Katana/TakerSubmitted.sol diff --git a/CHANGELOG.md b/CHANGELOG.md index 05043cbd4..d4affc8b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,6 @@ ### Breaking changes * SolidlyV3 UniV3 fork removed from Mainnet and Sonic -* Abandon Katana chain (which only had stripped-down Settler anyways) * `BRIDGE_TO_CCIP` modified to remove the `token` argument that is already included in `ccipSendData` * `BRIDGE_TO_LAYER_ZERO_OFT` modified to remove the `nativeFee` argument that is already included in `sendData` * `BRIDGE_ERC20_TO_MAYAN` and `BRIDGE_NATIVE_TO_MAYAN` modified to remove `forwarder` argument. It is now hardcoded. diff --git a/README.md b/README.md index a28d2a818..ff639bd84 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ your integration. * `0x0000000000001fF3684f28c67538d4D072C22734` on chains supporting the Cancun hardfork (Ethereum mainnet, Ethereum Sepolia testnet, Polygon, Base, Optimism, Arbitrum, Bnb, World Chain, Fantom Sonic, Ink, Avalanche, Unichain, Berachain, - Scroll, HyperEvm, Plasma, Monad mainnet, Abstract, Linea, Tempo) + Scroll, HyperEvm, Katana, Plasma, Monad mainnet, Abstract, Linea, Tempo) * `0x0000000000005E88410CcDFaDe4a5EfaE4b49562` on chains supporting the Shanghai hardfork (Mantle) diff --git a/api_secrets.json.template b/api_secrets.json.template index 0845e33ab..4cbe17b4f 100644 --- a/api_secrets.json.template +++ b/api_secrets.json.template @@ -65,6 +65,10 @@ "etherscanKey": "", "rpcUrl": "" }, + "katana": { + "etherscanKey": "", + "rpcUrl": "" + }, "plasma": { "etherscanKey": "verifyContract", "rpcUrl": "" diff --git a/chain_config.json b/chain_config.json index ab6f47c0a..49ac4827f 100644 --- a/chain_config.json +++ b/chain_config.json @@ -626,6 +626,43 @@ "blockscoutApi": "https://www.hyperscan.com/api", "etherscanApi": "https://api.etherscan.io/v2/api?chainid=999" }, + "katana": { + "chainId": 747474, + "displayName": "Katana", + "wnative": "0xEE7D8BCFb72bC1880D0Cf19822eB0A2e6577aB62", + "hardfork": { + "shanghai": true, + "cancun": true, + "osaka": false, + "eraVm": false + }, + "extraFlags": "--legacy", + "extraScriptFlags": "--isolate", + "gasMultiplierPercent": 200, + "minGasPriceGwei": 1, + "safe": { + "toehold": "0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7", + "singleton": "0xfb1bffC9d739B8D520DaF37dF666da4C687191EA", + "factory": "0xC22834581EbC8527d974F8a1c97E1bEA4EF910BC", + "fallback": "0x017062a1dE2FE6b99BE3d9d37841FeD19F573804", + "multiCall": "0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B", + "apiUrl": "https://api.safe.global/tx-service/katana/api" + }, + "governance": { + "upgradeSafe": "0xf36b9f50E59870A24F42F9Ba43b2aD0A4b8f2F51", + "deploymentSafe": "0x8E5DE7118a596E99B0563D3022039c11927f4827", + "pause": "0x1CeC01DC0fFEE5eB5aF47DbEc1809F2A7c601C30", + "daoSafe": "0x23030a6124E871F4744Cb9bc14D519b1f033FFe3" + }, + "deployment": { + "allowanceHolder": "0x0000000000001fF3684f28c67538d4D072C22734", + "deployer": "0x00000000000004533Fe15556B1E086BB1A72cEae", + "forwardingMultiCall": "0x00000000000000CF9E3c5A26621af382fA17f24f", + "crossChainFactory": "0x00000000000000304861c3aDfb80dd5ebeC96325" + }, + "etherscanApi": "https://api.etherscan.io/v2/api?chainid=747474", + "blockscoutApi": "https://explorer.katanarpc.com/api" + }, "plasma": { "chainId": 9745, "displayName": "Plasma", diff --git a/script/SafeConfig.sol b/script/SafeConfig.sol index 186dae267..3bdcad759 100644 --- a/script/SafeConfig.sol +++ b/script/SafeConfig.sol @@ -31,6 +31,7 @@ library SafeConfig { || block.chainid == 59144 // linea || block.chainid == 80094 // berachain || block.chainid == 534352 // scroll + || block.chainid == 747474 // katana ) { return false; } @@ -61,6 +62,7 @@ library SafeConfig { || block.chainid == 59144 // linea || block.chainid == 80094 // berachain || block.chainid == 534352 // scroll + || block.chainid == 747474 // katana || block.chainid == 11155111 // sepolia ) { return false; @@ -94,6 +96,7 @@ library SafeConfig { || block.chainid == 59144 // linea || block.chainid == 80094 // berachain || block.chainid == 534352 // scroll + || block.chainid == 747474 // katana || block.chainid == 11155111 // sepolia ) { return false; diff --git a/src/Settler.sol b/src/Settler.sol index 9a789d179..db2cba5e6 100644 --- a/src/Settler.sol +++ b/src/Settler.sol @@ -69,6 +69,10 @@ abstract contract Settler is ISettlerTakerSubmitted, Permit2PaymentTakerSubmitte } function _dispatchVIP(uint256 action, bytes calldata data) internal virtual returns (bool) { + //// NOTICE: Portions of this function have been copy/paste'd into + //// `src/chains/Katana/TakerSubmitted.sol:KatanaSettler._dispatchVIP`. If you make changes + //// here, you need to make sure that corresponding changes are made to that function. + if (action == uint32(ISettlerActions.TRANSFER_FROM.selector)) { (address recipient, ISignatureTransfer.PermitTransferFrom memory permit, bytes memory sig) = abi.decode(data, (address, ISignatureTransfer.PermitTransferFrom, bytes)); diff --git a/src/chains/Katana/BridgeSettler.sol b/src/chains/Katana/BridgeSettler.sol new file mode 100644 index 000000000..0a9b4cbfe --- /dev/null +++ b/src/chains/Katana/BridgeSettler.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.34; + +import {IERC20} from "@forge-std/interfaces/IERC20.sol"; +import {IBridgeSettlerActions} from "../../bridge/IBridgeSettlerActions.sol"; +import {BridgeSettler, BridgeSettlerBase} from "../../bridge/BridgeSettler.sol"; + +contract KatanaBridgeSettler is BridgeSettler { + constructor(bytes20 gitCommit) BridgeSettlerBase(gitCommit) { + assert(block.chainid == 747474 || block.chainid == 31337); + } + + function _dispatch(uint256 i, uint256 action, bytes calldata data) + internal + override(BridgeSettlerBase) + returns (bool) + { + if (super._dispatch(i, action, data)) { + return true; + } else { + return false; + } + return true; + } +} diff --git a/src/chains/Katana/Common.sol b/src/chains/Katana/Common.sol new file mode 100644 index 000000000..e348f0ffe --- /dev/null +++ b/src/chains/Katana/Common.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.34; + +import {SettlerBase} from "../../SettlerBase.sol"; + +import {IERC20} from "@forge-std/interfaces/IERC20.sol"; +import {FreeMemory} from "../../utils/FreeMemory.sol"; + +import {ISettlerActions} from "../../ISettlerActions.sol"; +import {ISignatureTransfer} from "@permit2/interfaces/ISignatureTransfer.sol"; +import {revertUnknownForkId} from "../../core/SettlerErrors.sol"; + +// Solidity inheritance is stupid +import {SettlerSwapAbstract} from "../../SettlerAbstract.sol"; + +abstract contract KatanaMixin is FreeMemory, SettlerBase { + constructor() { + assert(block.chainid == 747474 || block.chainid == 31337); + } + + function _dispatch(uint256 i, uint256 action, bytes calldata data, AllowedSlippage memory slippage) + internal + virtual + override(/* SettlerSwapAbstract, */ SettlerBase) + DANGEROUS_freeMemory + returns (bool) + { + // This does not make use of `super._dispatch`. This chain's Settler is extremely + // stripped-down and has almost no capabilities + if (action == uint32(ISettlerActions.BASIC.selector)) { + (IERC20 sellToken, uint256 bps, address pool, uint256 offset, bytes memory _data) = + abi.decode(data, (IERC20, uint256, address, uint256, bytes)); + + basicSellToPool(sellToken, bps, pool, offset, _data); + } else { + return false; + } + return true; + } + + function _uniV3ForkInfo(uint8 forkId) + internal + pure + override + returns (address factory, bytes32 initHash, uint32 callbackSelector) + { + revertUnknownForkId(forkId); + } +} diff --git a/src/chains/Katana/Intent.sol b/src/chains/Katana/Intent.sol new file mode 100644 index 000000000..98697d7d1 --- /dev/null +++ b/src/chains/Katana/Intent.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.34; + +import {KatanaSettlerMetaTxn} from "./MetaTxn.sol"; +import {SettlerIntent} from "../../SettlerIntent.sol"; + +import {IERC20} from "@forge-std/interfaces/IERC20.sol"; +import {ISignatureTransfer} from "@permit2/interfaces/ISignatureTransfer.sol"; +import {ISettlerActions} from "../../ISettlerActions.sol"; + +// Solidity inheritance is stupid +import {SettlerAbstract} from "../../SettlerAbstract.sol"; +import {SettlerBase} from "../../SettlerBase.sol"; +import {SettlerMetaTxn} from "../../SettlerMetaTxn.sol"; +import {SettlerIntent} from "../../SettlerIntent.sol"; +import {AbstractContext, Context} from "../../Context.sol"; +import {Permit2PaymentAbstract} from "../../core/Permit2PaymentAbstract.sol"; +import {Permit2PaymentMetaTxn} from "../../core/Permit2Payment.sol"; + +/// @custom:security-contact security@0x.org +contract KatanaSettlerIntent is SettlerIntent, KatanaSettlerMetaTxn { + constructor(bytes20 gitCommit) KatanaSettlerMetaTxn(gitCommit) {} + + // Solidity inheritance is stupid + function executeMetaTxn( + AllowedSlippage memory slippage, + bytes[] calldata actions, + bytes32, /* zid & affiliate */ + address msgSender, + bytes calldata sig + ) public override(SettlerIntent, SettlerMetaTxn) returns (bool) { + return super.executeMetaTxn(slippage, actions, bytes32(0), msgSender, sig); + } + + function _dispatch(uint256 i, uint256 action, bytes calldata data, AllowedSlippage memory slippage) + internal + override(KatanaSettlerMetaTxn, SettlerBase) + returns (bool) + { + return super._dispatch(i, action, data, slippage); + } + + function _isForwarded() internal view override(AbstractContext, Context, SettlerIntent) returns (bool) { + return super._isForwarded(); + } + + function _msgData() internal view override(AbstractContext, Context, SettlerIntent) returns (bytes calldata) { + return super._msgData(); + } + + function _msgSender() internal view override(SettlerIntent, KatanaSettlerMetaTxn) returns (address) { + return super._msgSender(); + } + + function _witnessTypeSuffix() internal pure override(SettlerIntent, Permit2PaymentMetaTxn) returns (string memory) { + return super._witnessTypeSuffix(); + } + + function _mandatorySlippageCheck() internal pure override(SettlerBase, SettlerIntent) returns (bool) { + return super._mandatorySlippageCheck(); + } + + function _tokenId() internal pure override(SettlerIntent, SettlerMetaTxn, SettlerAbstract) returns (uint256) { + return super._tokenId(); + } + + function _dispatchVIP(uint256 action, bytes calldata data, bytes calldata sig) + internal + override(KatanaSettlerMetaTxn, SettlerMetaTxn) + returns (bool) + { + return super._dispatchVIP(action, data, sig); + } + + function _permitToSellAmountCalldata(ISignatureTransfer.PermitTransferFrom calldata permit) + internal + view + override(SettlerIntent, Permit2PaymentAbstract, Permit2PaymentMetaTxn) + returns (uint256) + { + return super._permitToSellAmountCalldata(permit); + } + + function _permitToSellAmount(ISignatureTransfer.PermitTransferFrom memory permit) + internal + view + override(SettlerIntent, Permit2PaymentAbstract, Permit2PaymentMetaTxn) + returns (uint256) + { + return super._permitToSellAmount(permit); + } + + function _isRestrictedTarget(address target) + internal + view + virtual + override(KatanaSettlerMetaTxn, SettlerIntent) + returns (bool) + { + return super._isRestrictedTarget(target); + } +} diff --git a/src/chains/Katana/MetaTxn.sol b/src/chains/Katana/MetaTxn.sol new file mode 100644 index 000000000..06f808fc1 --- /dev/null +++ b/src/chains/Katana/MetaTxn.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.34; + +import {KatanaMixin} from "./Common.sol"; +import {SettlerMetaTxn} from "../../SettlerMetaTxn.sol"; + +import {IERC20} from "@forge-std/interfaces/IERC20.sol"; +import {ISignatureTransfer} from "@permit2/interfaces/ISignatureTransfer.sol"; +import {ISettlerActions} from "../../ISettlerActions.sol"; + +// Solidity inheritance is stupid +import {SettlerBase} from "../../SettlerBase.sol"; +import {AbstractContext} from "../../Context.sol"; +import {Permit2PaymentAbstract} from "../../core/Permit2PaymentAbstract.sol"; +import {Permit2PaymentBase} from "../../core/Permit2Payment.sol"; + +/// @custom:security-contact security@0x.org +contract KatanaSettlerMetaTxn is SettlerMetaTxn, KatanaMixin { + constructor(bytes20 gitCommit) SettlerBase(gitCommit) {} + + function _dispatchVIP(uint256 action, bytes calldata data, bytes calldata sig) + internal + virtual + override + DANGEROUS_freeMemory + returns (bool) + { + // This does not make use of `super._dispatchVIP`. This chain's Settler is extremely + // stripped-down and has almost no capabilities + if (action == uint32(ISettlerActions.METATXN_TRANSFER_FROM.selector)) { + (address recipient, ISignatureTransfer.PermitTransferFrom memory permit) = + abi.decode(data, (address, ISignatureTransfer.PermitTransferFrom)); + (ISignatureTransfer.SignatureTransferDetails memory transferDetails,) = + _permitToTransferDetails(permit, recipient); + + // We simultaneously transfer-in the taker's tokens and authenticate the + // metatransaction. + _transferFrom(permit, transferDetails, sig); + } else { + return false; + } + return true; + } + + // Solidity inheritance is stupid + function _dispatch(uint256 i, uint256 action, bytes calldata data, AllowedSlippage memory slippage) + internal + virtual + override(SettlerBase, KatanaMixin) + returns (bool) + { + return super._dispatch(i, action, data, slippage); + } + + function _msgSender() internal view virtual override(SettlerMetaTxn, AbstractContext) returns (address) { + return super._msgSender(); + } + + function _isRestrictedTarget(address target) + internal + view + virtual + override(SettlerMetaTxn, Permit2PaymentAbstract) + returns (bool) + { + return super._isRestrictedTarget(target); + } +} diff --git a/src/chains/Katana/TakerSubmitted.sol b/src/chains/Katana/TakerSubmitted.sol new file mode 100644 index 000000000..6d450173b --- /dev/null +++ b/src/chains/Katana/TakerSubmitted.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.34; + +import {KatanaMixin} from "./Common.sol"; +import {Settler} from "../../Settler.sol"; + +import {IERC20} from "@forge-std/interfaces/IERC20.sol"; +import {ISignatureTransfer} from "@permit2/interfaces/ISignatureTransfer.sol"; +import {ISettlerActions} from "../../ISettlerActions.sol"; +import {FastLogic} from "../../utils/FastLogic.sol"; +import {revertConfusedDeputy} from "../../core/SettlerErrors.sol"; + +// Solidity inheritance is stupid +import {SettlerBase} from "../../SettlerBase.sol"; +import {Permit2PaymentAbstract} from "../../core/Permit2PaymentAbstract.sol"; +import {AbstractContext} from "../../Context.sol"; + +/// @custom:security-contact security@0x.org +contract KatanaSettler is Settler, KatanaMixin { + constructor(bytes20 gitCommit) SettlerBase(gitCommit) {} + + function _dispatchVIP(uint256 action, bytes calldata data) internal override DANGEROUS_freeMemory returns (bool) { + // This does not make use of `super._dispatchVIP`. This chain's Settler is extremely + // stripped-down and has almost no capabilities + if (action == uint32(ISettlerActions.TRANSFER_FROM.selector)) { + (address recipient, ISignatureTransfer.PermitTransferFrom memory permit, bytes memory sig) = + abi.decode(data, (address, ISignatureTransfer.PermitTransferFrom, bytes)); + (ISignatureTransfer.SignatureTransferDetails memory transferDetails,) = + _permitToTransferDetails(permit, recipient); + _transferFrom(permit, transferDetails, sig); + } else { + return false; + } + return true; + } + + // Solidity inheritance is stupid + function _isRestrictedTarget(address target) + internal + view + override(Settler, Permit2PaymentAbstract) + returns (bool) + { + return super._isRestrictedTarget(target); + } + + function _dispatch(uint256 i, uint256 action, bytes calldata data, AllowedSlippage memory slippage) + internal + override(Settler, KatanaMixin) + returns (bool) + { + return super._dispatch(i, action, data, slippage); + } + + function _msgSender() internal view override(Settler, AbstractContext) returns (address) { + return super._msgSender(); + } +} From b0f5378877805370cb5e1bf8a7eb6c00122c92ef Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Tue, 12 May 2026 13:32:43 -0500 Subject: [PATCH 02/18] Katana: delegate to base dispatch and add SushiSwapV3 fork --- CHANGELOG.md | 4 ++++ src/Settler.sol | 4 ---- src/chains/Katana/Common.sol | 33 +++++++++++++--------------- src/chains/Katana/MetaTxn.sol | 16 +------------- src/chains/Katana/TakerSubmitted.sol | 15 +------------ src/core/univ3forks/SushiswapV3.sol | 2 ++ 6 files changed, 23 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4affc8b3..2d25cc2ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ ### Non-breaking changes +* Katana Settler now delegates to the standard base dispatch, gaining `UNISWAPV3`, + `UNISWAPV3_VIP`, `UNISWAPV2`, `RFQ`, `VELODROME`, and `POSITIVE_SLIPPAGE` actions. +* Add SushiSwapV3 UniV3 fork on Katana + ## 2026-04-10 ### Breaking changes diff --git a/src/Settler.sol b/src/Settler.sol index db2cba5e6..9a789d179 100644 --- a/src/Settler.sol +++ b/src/Settler.sol @@ -69,10 +69,6 @@ abstract contract Settler is ISettlerTakerSubmitted, Permit2PaymentTakerSubmitte } function _dispatchVIP(uint256 action, bytes calldata data) internal virtual returns (bool) { - //// NOTICE: Portions of this function have been copy/paste'd into - //// `src/chains/Katana/TakerSubmitted.sol:KatanaSettler._dispatchVIP`. If you make changes - //// here, you need to make sure that corresponding changes are made to that function. - if (action == uint32(ISettlerActions.TRANSFER_FROM.selector)) { (address recipient, ISignatureTransfer.PermitTransferFrom memory permit, bytes memory sig) = abi.decode(data, (address, ISignatureTransfer.PermitTransferFrom, bytes)); diff --git a/src/chains/Katana/Common.sol b/src/chains/Katana/Common.sol index e348f0ffe..7cf95377c 100644 --- a/src/chains/Katana/Common.sol +++ b/src/chains/Katana/Common.sol @@ -3,15 +3,16 @@ pragma solidity =0.8.34; import {SettlerBase} from "../../SettlerBase.sol"; -import {IERC20} from "@forge-std/interfaces/IERC20.sol"; import {FreeMemory} from "../../utils/FreeMemory.sol"; -import {ISettlerActions} from "../../ISettlerActions.sol"; -import {ISignatureTransfer} from "@permit2/interfaces/ISignatureTransfer.sol"; import {revertUnknownForkId} from "../../core/SettlerErrors.sol"; -// Solidity inheritance is stupid -import {SettlerSwapAbstract} from "../../SettlerAbstract.sol"; +import {IUniswapV3Callback} from "../../core/univ3forks/UniswapV3.sol"; +import { + sushiswapV3KatanaFactory, + sushiswapV3KatanaInitHash, + sushiswapV3ForkId +} from "../../core/univ3forks/SushiswapV3.sol"; abstract contract KatanaMixin is FreeMemory, SettlerBase { constructor() { @@ -21,21 +22,11 @@ abstract contract KatanaMixin is FreeMemory, SettlerBase { function _dispatch(uint256 i, uint256 action, bytes calldata data, AllowedSlippage memory slippage) internal virtual - override(/* SettlerSwapAbstract, */ SettlerBase) + override DANGEROUS_freeMemory returns (bool) { - // This does not make use of `super._dispatch`. This chain's Settler is extremely - // stripped-down and has almost no capabilities - if (action == uint32(ISettlerActions.BASIC.selector)) { - (IERC20 sellToken, uint256 bps, address pool, uint256 offset, bytes memory _data) = - abi.decode(data, (IERC20, uint256, address, uint256, bytes)); - - basicSellToPool(sellToken, bps, pool, offset, _data); - } else { - return false; - } - return true; + return super._dispatch(i, action, data, slippage); } function _uniV3ForkInfo(uint8 forkId) @@ -44,6 +35,12 @@ abstract contract KatanaMixin is FreeMemory, SettlerBase { override returns (address factory, bytes32 initHash, uint32 callbackSelector) { - revertUnknownForkId(forkId); + if (forkId == sushiswapV3ForkId) { + factory = sushiswapV3KatanaFactory; + initHash = sushiswapV3KatanaInitHash; + callbackSelector = uint32(IUniswapV3Callback.uniswapV3SwapCallback.selector); + } else { + revertUnknownForkId(forkId); + } } } diff --git a/src/chains/Katana/MetaTxn.sol b/src/chains/Katana/MetaTxn.sol index 06f808fc1..a9b3117e9 100644 --- a/src/chains/Katana/MetaTxn.sol +++ b/src/chains/Katana/MetaTxn.sol @@ -25,21 +25,7 @@ contract KatanaSettlerMetaTxn is SettlerMetaTxn, KatanaMixin { DANGEROUS_freeMemory returns (bool) { - // This does not make use of `super._dispatchVIP`. This chain's Settler is extremely - // stripped-down and has almost no capabilities - if (action == uint32(ISettlerActions.METATXN_TRANSFER_FROM.selector)) { - (address recipient, ISignatureTransfer.PermitTransferFrom memory permit) = - abi.decode(data, (address, ISignatureTransfer.PermitTransferFrom)); - (ISignatureTransfer.SignatureTransferDetails memory transferDetails,) = - _permitToTransferDetails(permit, recipient); - - // We simultaneously transfer-in the taker's tokens and authenticate the - // metatransaction. - _transferFrom(permit, transferDetails, sig); - } else { - return false; - } - return true; + return super._dispatchVIP(action, data, sig); } // Solidity inheritance is stupid diff --git a/src/chains/Katana/TakerSubmitted.sol b/src/chains/Katana/TakerSubmitted.sol index 6d450173b..d88693de6 100644 --- a/src/chains/Katana/TakerSubmitted.sol +++ b/src/chains/Katana/TakerSubmitted.sol @@ -7,8 +7,6 @@ import {Settler} from "../../Settler.sol"; import {IERC20} from "@forge-std/interfaces/IERC20.sol"; import {ISignatureTransfer} from "@permit2/interfaces/ISignatureTransfer.sol"; import {ISettlerActions} from "../../ISettlerActions.sol"; -import {FastLogic} from "../../utils/FastLogic.sol"; -import {revertConfusedDeputy} from "../../core/SettlerErrors.sol"; // Solidity inheritance is stupid import {SettlerBase} from "../../SettlerBase.sol"; @@ -20,18 +18,7 @@ contract KatanaSettler is Settler, KatanaMixin { constructor(bytes20 gitCommit) SettlerBase(gitCommit) {} function _dispatchVIP(uint256 action, bytes calldata data) internal override DANGEROUS_freeMemory returns (bool) { - // This does not make use of `super._dispatchVIP`. This chain's Settler is extremely - // stripped-down and has almost no capabilities - if (action == uint32(ISettlerActions.TRANSFER_FROM.selector)) { - (address recipient, ISignatureTransfer.PermitTransferFrom memory permit, bytes memory sig) = - abi.decode(data, (address, ISignatureTransfer.PermitTransferFrom, bytes)); - (ISignatureTransfer.SignatureTransferDetails memory transferDetails,) = - _permitToTransferDetails(permit, recipient); - _transferFrom(permit, transferDetails, sig); - } else { - return false; - } - return true; + return super._dispatchVIP(action, data); } // Solidity inheritance is stupid diff --git a/src/core/univ3forks/SushiswapV3.sol b/src/core/univ3forks/SushiswapV3.sol index 9646727fb..4e88779a3 100644 --- a/src/core/univ3forks/SushiswapV3.sol +++ b/src/core/univ3forks/SushiswapV3.sol @@ -9,4 +9,6 @@ address constant sushiswapV3ArbitrumFactory = 0x1af415a1EbA07a4986a52B6f2e7dE700 address constant sushiswapV3OptimismFactory = 0x9c6522117e2ed1fE5bdb72bb0eD5E3f2bdE7DBe0; address constant sushiswapV3PolygonFactory = 0x917933899c6a5F8E37F31E19f92CdBFF7e8FF0e2; address constant sushiswapV3ScrollFactory = 0x46B3fDF7b5CDe91Ac049936bF0bDb12c5d22202e; +address constant sushiswapV3KatanaFactory = 0x203e8740894c8955cB8950759876d7E7E45E04c1; +bytes32 constant sushiswapV3KatanaInitHash = 0xe040f12c7cee3904b78f24f8fc395629c2e69525c2815da7a659f7483e378ecb; uint8 constant sushiswapV3ForkId = 2; From 2f3b0ec25d2082e1cbb8cc3e6a1c2188b0ded94d Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Tue, 12 May 2026 13:49:06 -0500 Subject: [PATCH 03/18] Katana: integration fork test for SushiV3 vbETH/AUSD swap --- foundry.toml | 1 + test/integration/katana/SushiV3.t.sol | 141 ++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 test/integration/katana/SushiV3.t.sol diff --git a/foundry.toml b/foundry.toml index 1e23aeb69..736a3dd31 100644 --- a/foundry.toml +++ b/foundry.toml @@ -50,6 +50,7 @@ mainnet = "${MAINNET_RPC_URL}" bnb = "${BNB_MAINNET_RPC_URL}" plasma = "${PLASMA_MAINNET_RPC_URL}" arbitrum = "${ARBITRUM_MAINNET_RPC_URL}" +katana = "${KATANA_MAINNET_RPC_URL}" base = "${BASE_MAINNET_RPC_URL}" monad = "${MONAD_MAINNET_RPC_URL}" diff --git a/test/integration/katana/SushiV3.t.sol b/test/integration/katana/SushiV3.t.sol new file mode 100644 index 000000000..29c54e5fa --- /dev/null +++ b/test/integration/katana/SushiV3.t.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {IERC20} from "@forge-std/interfaces/IERC20.sol"; +import {KatanaSettler} from "src/chains/Katana/TakerSubmitted.sol"; +import {ActionDataBuilder} from "../../utils/ActionDataBuilder.sol"; +import {ISettlerActions} from "src/ISettlerActions.sol"; +import {ISettlerBase} from "src/interfaces/ISettlerBase.sol"; +import {IAllowanceHolder} from "src/allowanceholder/IAllowanceHolder.sol"; +import {ISignatureTransfer} from "@permit2/interfaces/ISignatureTransfer.sol"; +import {SettlerBasePairTest, Shim} from "../SettlerBasePairTest.t.sol"; +import {sushiswapV3ForkId} from "src/core/univ3forks/SushiswapV3.sol"; + +IERC20 constant KATANA_VBETH = IERC20(0xEE7D8BCFb72bC1880D0Cf19822eB0A2e6577aB62); +IERC20 constant KATANA_AUSD = IERC20(0x00000000eFE302BEAA2b3e6e1b18d08D69a9012a); +address constant KATANA_SUSHIV3_VBETH_AUSD_POOL = 0xa522683eCE4b864a505cC7D4f65fAeFC93e72f38; +uint256 constant KATANA_BLOCK = 31_863_093; + +contract SushiV3KatanaIntegrationTest is SettlerBasePairTest { + function setUp() public virtual override { + // vbETH/vbUSDC on Katana are TransparentUpgradeableProxies, so `deal()` cannot find + // their balance storage slot. We replicate the base setUp inline but transfer from the + // SushiV3 pool instead of using `deal()` for these tokens. + vm.createSelectFork(_testChainId(), _testBlockNumber()); + vm.setEvmVersion("osaka"); + permit2Domain = keccak256( + abi.encode( + keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"), + keccak256("Permit2"), + block.chainid, + address(PERMIT2) + ) + ); + vm.label(address(this), "FoundryTest"); + vm.label(address(PERMIT2), "Permit2"); + vm.label(FROM, "FROM"); + vm.label(MAKER, "MAKER"); + vm.label(BURN_ADDRESS, "BURN"); + vm.label(address(fromToken()), "vbETH"); + vm.label(address(toToken()), "AUSD"); + + // Source tokens from the SushiV3 pool (1.1 WETH of liquidity is plenty for our tiny swap). + vm.prank(KATANA_SUSHIV3_VBETH_AUSD_POOL); + fromToken().transfer(FROM, amount()); + + // SettlerBasePairTest setUp deploys KatanaSettler + etches AllowanceHolder. + allowanceHolder = IAllowanceHolder(0x0000000000001fF3684f28c67538d4D072C22734); + uint256 forkChainId = (new Shim()).chainId(); + vm.chainId(31337); + bytes memory initCode = settlerInitCode(); + assembly ("memory-safe") { + let s := create(0x00, add(0x20, initCode), mload(initCode)) + if iszero(s) { revert(0x00, 0x00) } + sstore(settler.slot, s) + } + vm.label(address(settler), "Settler"); + vm.etch(address(allowanceHolder), vm.getDeployedCode("AllowanceHolder.sol:AllowanceHolder")); + vm.label(address(allowanceHolder), "AllowanceHolder"); + vm.chainId(forkChainId); + } + + function settlerInitCode() internal virtual override returns (bytes memory) { + return bytes.concat(type(KatanaSettler).creationCode, abi.encode(bytes20(0))); + } + + function _testChainId() internal pure virtual override returns (string memory) { + return "katana"; + } + + function _testBlockNumber() internal pure virtual override returns (uint256) { + return KATANA_BLOCK; + } + + function fromToken() internal pure virtual override returns (IERC20) { + return KATANA_VBETH; + } + + function toToken() internal pure virtual override returns (IERC20) { + return KATANA_AUSD; + } + + function _testName() internal pure virtual override returns (string memory) { + return "KATANA-SUSHIV3"; + } + + function amount() internal pure virtual override returns (uint256) { + return 0.001 ether; + } + + function _sushiV3Path() internal view returns (bytes memory) { + // For Katana SushiV3 WETH/AUSD: AUSD is token0 (lower address), WETH is token1. + // Selling WETH→AUSD is oneForZero, so the price limit is MAX_SQRT_RATIO-1. + // Uniswap V3 TickMath.MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342. + uint160 sqrtPriceLimitX96 = 1461446703485210103287273052203988822378723970341; + return abi.encodePacked( + fromToken(), uint8(sushiswapV3ForkId), uint24(3000), sqrtPriceLimitX96, toToken() + ); + } + + // Sells 0.1 WETH for AUSD via the SushiV3 WETH/AUSD pool on Katana. + // Verifies the full path: AllowanceHolder → KatanaSettler.execute → UNISWAPV3 action → + // KatanaMixin._uniV3ForkInfo(sushiswapV3ForkId) → CREATE2 derives pool 0xa522…2f38. + function testKatanaSushiV3() public { + uint256 sellAmount = amount(); + uint256 buyBalanceBefore = toToken().balanceOf(FROM); + + vm.startPrank(FROM); + fromToken().approve(address(allowanceHolder), sellAmount); + + // For the AllowanceHolder flow, TRANSFER_FROM with an empty signature pulls + // tokens from msg.sender (FROM) into the settler using AH's transient allowance. + ISignatureTransfer.PermitTransferFrom memory permit = ISignatureTransfer.PermitTransferFrom({ + permitted: ISignatureTransfer.TokenPermissions({token: address(fromToken()), amount: sellAmount}), + nonce: 0, + deadline: type(uint256).max + }); + bytes[] memory actions = ActionDataBuilder.build( + abi.encodeCall(ISettlerActions.TRANSFER_FROM, (address(settler), permit, new bytes(0))), + abi.encodeCall(ISettlerActions.UNISWAPV3, (FROM, 10_000, _sushiV3Path(), 0)) + ); + bytes memory ahData = abi.encodeCall( + settler.execute, + ( + ISettlerBase.AllowedSlippage({ + recipient: payable(FROM), + buyToken: toToken(), + minAmountOut: 0 + }), + actions, + bytes32(0) + ) + ); + allowanceHolder.exec( + address(settler), address(fromToken()), sellAmount, payable(address(settler)), ahData + ); + vm.stopPrank(); + + assertEq(fromToken().balanceOf(FROM), 0, "all vbETH should be spent"); + assertGt(toToken().balanceOf(FROM), buyBalanceBefore, "should have bought AUSD"); + } +} From 107c05ba35df3795134e068d3fd6229cd5a91851 Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Tue, 12 May 2026 14:32:19 -0500 Subject: [PATCH 04/18] Katana: wire CI integration env + fix stale test comment --- .github/workflows/integration.yml | 1 + test/integration/katana/SushiV3.t.sol | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 38910e809..2c83a2eec 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -47,6 +47,7 @@ jobs: MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} BNB_MAINNET_RPC_URL: ${{ secrets.BNB_MAINNET_RPC_URL }} PLASMA_MAINNET_RPC_URL: ${{ secrets.PLASMA_MAINNET_RPC_URL }} + KATANA_MAINNET_RPC_URL: ${{ secrets.KATANA_MAINNET_RPC_URL }} ARBITRUM_MAINNET_RPC_URL: ${{ secrets.ARBITRUM_MAINNET_RPC_URL }} BASE_MAINNET_RPC_URL: ${{ secrets.BASE_MAINNET_RPC_URL }} MONAD_MAINNET_RPC_URL: ${{ secrets.MONAD_MAINNET_RPC_URL }} diff --git a/test/integration/katana/SushiV3.t.sol b/test/integration/katana/SushiV3.t.sol index 29c54e5fa..660077e72 100644 --- a/test/integration/katana/SushiV3.t.sol +++ b/test/integration/katana/SushiV3.t.sol @@ -97,7 +97,7 @@ contract SushiV3KatanaIntegrationTest is SettlerBasePairTest { ); } - // Sells 0.1 WETH for AUSD via the SushiV3 WETH/AUSD pool on Katana. + // Sells 0.001 WETH for AUSD via the SushiV3 WETH/AUSD pool on Katana. // Verifies the full path: AllowanceHolder → KatanaSettler.execute → UNISWAPV3 action → // KatanaMixin._uniV3ForkInfo(sushiswapV3ForkId) → CREATE2 derives pool 0xa522…2f38. function testKatanaSushiV3() public { From 1d9a4249c3a5f0484095119142f286ad6550ede5 Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Tue, 12 May 2026 14:35:04 -0500 Subject: [PATCH 05/18] Katana: add UNISWAPV3_VIP + slippage revert tests --- test/integration/katana/SushiV3.t.sol | 88 +++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 4 deletions(-) diff --git a/test/integration/katana/SushiV3.t.sol b/test/integration/katana/SushiV3.t.sol index 660077e72..e3a6d2f97 100644 --- a/test/integration/katana/SushiV3.t.sol +++ b/test/integration/katana/SushiV3.t.sol @@ -10,6 +10,7 @@ import {IAllowanceHolder} from "src/allowanceholder/IAllowanceHolder.sol"; import {ISignatureTransfer} from "@permit2/interfaces/ISignatureTransfer.sol"; import {SettlerBasePairTest, Shim} from "../SettlerBasePairTest.t.sol"; import {sushiswapV3ForkId} from "src/core/univ3forks/SushiswapV3.sol"; +import {TooMuchSlippage} from "src/core/SettlerErrors.sol"; IERC20 constant KATANA_VBETH = IERC20(0xEE7D8BCFb72bC1880D0Cf19822eB0A2e6577aB62); IERC20 constant KATANA_AUSD = IERC20(0x00000000eFE302BEAA2b3e6e1b18d08D69a9012a); @@ -39,7 +40,7 @@ contract SushiV3KatanaIntegrationTest is SettlerBasePairTest { vm.label(address(fromToken()), "vbETH"); vm.label(address(toToken()), "AUSD"); - // Source tokens from the SushiV3 pool (1.1 WETH of liquidity is plenty for our tiny swap). + // Source tokens from the SushiV3 pool (1.1 vbETH of liquidity is plenty for our tiny swap). vm.prank(KATANA_SUSHIV3_VBETH_AUSD_POOL); fromToken().transfer(FROM, amount()); @@ -88,8 +89,8 @@ contract SushiV3KatanaIntegrationTest is SettlerBasePairTest { } function _sushiV3Path() internal view returns (bytes memory) { - // For Katana SushiV3 WETH/AUSD: AUSD is token0 (lower address), WETH is token1. - // Selling WETH→AUSD is oneForZero, so the price limit is MAX_SQRT_RATIO-1. + // For Katana SushiV3 vbETH/AUSD: AUSD is token0 (lower address), vbETH is token1. + // Selling vbETH->AUSD is oneForZero, so the price limit is MAX_SQRT_RATIO-1. // Uniswap V3 TickMath.MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342. uint160 sqrtPriceLimitX96 = 1461446703485210103287273052203988822378723970341; return abi.encodePacked( @@ -97,7 +98,7 @@ contract SushiV3KatanaIntegrationTest is SettlerBasePairTest { ); } - // Sells 0.001 WETH for AUSD via the SushiV3 WETH/AUSD pool on Katana. + // Sells 0.001 vbETH for AUSD via the SushiV3 vbETH/AUSD pool on Katana. // Verifies the full path: AllowanceHolder → KatanaSettler.execute → UNISWAPV3 action → // KatanaMixin._uniV3ForkInfo(sushiswapV3ForkId) → CREATE2 derives pool 0xa522…2f38. function testKatanaSushiV3() public { @@ -138,4 +139,83 @@ contract SushiV3KatanaIntegrationTest is SettlerBasePairTest { assertEq(fromToken().balanceOf(FROM), 0, "all vbETH should be spent"); assertGt(toToken().balanceOf(FROM), buyBalanceBefore, "should have bought AUSD"); } + + // Same swap but via UNISWAPV3_VIP — Settler pulls the sell tokens inside the swap + // callback via Permit2/AH (no separate TRANSFER_FROM action). This is the gas-cheaper + // single-action path and exercises Settler._dispatchVIP → super._dispatchVIP after the + // hand-copied override was removed in commit 2. + function testKatanaSushiV3_VIP() public { + uint256 sellAmount = amount(); + uint256 buyBalanceBefore = toToken().balanceOf(FROM); + + vm.startPrank(FROM); + fromToken().approve(address(allowanceHolder), sellAmount); + + ISignatureTransfer.PermitTransferFrom memory permit = ISignatureTransfer.PermitTransferFrom({ + permitted: ISignatureTransfer.TokenPermissions({token: address(fromToken()), amount: sellAmount}), + nonce: 0, + deadline: type(uint256).max + }); + bytes[] memory actions = ActionDataBuilder.build( + abi.encodeCall(ISettlerActions.UNISWAPV3_VIP, (FROM, permit, _sushiV3Path(), new bytes(0), 0)) + ); + bytes memory ahData = abi.encodeCall( + settler.execute, + ( + ISettlerBase.AllowedSlippage({ + recipient: payable(FROM), + buyToken: toToken(), + minAmountOut: 0 + }), + actions, + bytes32(0) + ) + ); + allowanceHolder.exec( + address(settler), address(fromToken()), sellAmount, payable(address(settler)), ahData + ); + vm.stopPrank(); + + assertEq(fromToken().balanceOf(FROM), 0, "all vbETH should be spent"); + assertGt(toToken().balanceOf(FROM), buyBalanceBefore, "should have bought AUSD"); + } + + // Negative path: a UNISWAPV3 swap with an unsatisfiable amountOutMin must revert with + // TooMuchSlippage. Proves the slippage guard fires before the swap settles. + function testKatanaSushiV3_slippageRevert() public { + uint256 sellAmount = amount(); + + vm.startPrank(FROM); + fromToken().approve(address(allowanceHolder), sellAmount); + + ISignatureTransfer.PermitTransferFrom memory permit = ISignatureTransfer.PermitTransferFrom({ + permitted: ISignatureTransfer.TokenPermissions({token: address(fromToken()), amount: sellAmount}), + nonce: 0, + deadline: type(uint256).max + }); + bytes[] memory actions = ActionDataBuilder.build( + abi.encodeCall(ISettlerActions.TRANSFER_FROM, (address(settler), permit, new bytes(0))), + abi.encodeCall( + ISettlerActions.UNISWAPV3, (FROM, 10_000, _sushiV3Path(), type(uint256).max) + ) + ); + bytes memory ahData = abi.encodeCall( + settler.execute, + ( + ISettlerBase.AllowedSlippage({ + recipient: payable(FROM), + buyToken: toToken(), + minAmountOut: 0 + }), + actions, + bytes32(0) + ) + ); + + vm.expectPartialRevert(TooMuchSlippage.selector); + allowanceHolder.exec( + address(settler), address(fromToken()), sellAmount, payable(address(settler)), ahData + ); + vm.stopPrank(); + } } From 70c8f6013cbd017bfa376b557494c14a19f0d929 Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Tue, 12 May 2026 15:24:32 -0500 Subject: [PATCH 06/18] Simplify Katana SushiV3 changes --- CHANGELOG.md | 4 +- src/chains/Katana/BridgeSettler.sol | 1 - src/chains/Katana/Intent.sol | 3 - src/chains/Katana/MetaTxn.sol | 5 -- src/chains/Katana/TakerSubmitted.sol | 4 - test/integration/katana/SushiV3.t.sol | 125 ++++++-------------------- 6 files changed, 29 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d25cc2ab..a5f44ebb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,7 @@ ### Non-breaking changes -* Katana Settler now delegates to the standard base dispatch, gaining `UNISWAPV3`, - `UNISWAPV3_VIP`, `UNISWAPV2`, `RFQ`, `VELODROME`, and `POSITIVE_SLIPPAGE` actions. -* Add SushiSwapV3 UniV3 fork on Katana +* Add SushiSwapV3 UniV3 fork on Katana. ## 2026-04-10 diff --git a/src/chains/Katana/BridgeSettler.sol b/src/chains/Katana/BridgeSettler.sol index 0a9b4cbfe..d1adc8d4e 100644 --- a/src/chains/Katana/BridgeSettler.sol +++ b/src/chains/Katana/BridgeSettler.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity =0.8.34; -import {IERC20} from "@forge-std/interfaces/IERC20.sol"; import {IBridgeSettlerActions} from "../../bridge/IBridgeSettlerActions.sol"; import {BridgeSettler, BridgeSettlerBase} from "../../bridge/BridgeSettler.sol"; diff --git a/src/chains/Katana/Intent.sol b/src/chains/Katana/Intent.sol index 98697d7d1..d44630fab 100644 --- a/src/chains/Katana/Intent.sol +++ b/src/chains/Katana/Intent.sol @@ -2,11 +2,8 @@ pragma solidity =0.8.34; import {KatanaSettlerMetaTxn} from "./MetaTxn.sol"; -import {SettlerIntent} from "../../SettlerIntent.sol"; -import {IERC20} from "@forge-std/interfaces/IERC20.sol"; import {ISignatureTransfer} from "@permit2/interfaces/ISignatureTransfer.sol"; -import {ISettlerActions} from "../../ISettlerActions.sol"; // Solidity inheritance is stupid import {SettlerAbstract} from "../../SettlerAbstract.sol"; diff --git a/src/chains/Katana/MetaTxn.sol b/src/chains/Katana/MetaTxn.sol index a9b3117e9..611b2df11 100644 --- a/src/chains/Katana/MetaTxn.sol +++ b/src/chains/Katana/MetaTxn.sol @@ -4,15 +4,10 @@ pragma solidity =0.8.34; import {KatanaMixin} from "./Common.sol"; import {SettlerMetaTxn} from "../../SettlerMetaTxn.sol"; -import {IERC20} from "@forge-std/interfaces/IERC20.sol"; -import {ISignatureTransfer} from "@permit2/interfaces/ISignatureTransfer.sol"; -import {ISettlerActions} from "../../ISettlerActions.sol"; - // Solidity inheritance is stupid import {SettlerBase} from "../../SettlerBase.sol"; import {AbstractContext} from "../../Context.sol"; import {Permit2PaymentAbstract} from "../../core/Permit2PaymentAbstract.sol"; -import {Permit2PaymentBase} from "../../core/Permit2Payment.sol"; /// @custom:security-contact security@0x.org contract KatanaSettlerMetaTxn is SettlerMetaTxn, KatanaMixin { diff --git a/src/chains/Katana/TakerSubmitted.sol b/src/chains/Katana/TakerSubmitted.sol index d88693de6..1458e5e23 100644 --- a/src/chains/Katana/TakerSubmitted.sol +++ b/src/chains/Katana/TakerSubmitted.sol @@ -4,10 +4,6 @@ pragma solidity =0.8.34; import {KatanaMixin} from "./Common.sol"; import {Settler} from "../../Settler.sol"; -import {IERC20} from "@forge-std/interfaces/IERC20.sol"; -import {ISignatureTransfer} from "@permit2/interfaces/ISignatureTransfer.sol"; -import {ISettlerActions} from "../../ISettlerActions.sol"; - // Solidity inheritance is stupid import {SettlerBase} from "../../SettlerBase.sol"; import {Permit2PaymentAbstract} from "../../core/Permit2PaymentAbstract.sol"; diff --git a/test/integration/katana/SushiV3.t.sol b/test/integration/katana/SushiV3.t.sol index e3a6d2f97..52d944953 100644 --- a/test/integration/katana/SushiV3.t.sol +++ b/test/integration/katana/SushiV3.t.sol @@ -19,9 +19,7 @@ uint256 constant KATANA_BLOCK = 31_863_093; contract SushiV3KatanaIntegrationTest is SettlerBasePairTest { function setUp() public virtual override { - // vbETH/vbUSDC on Katana are TransparentUpgradeableProxies, so `deal()` cannot find - // their balance storage slot. We replicate the base setUp inline but transfer from the - // SushiV3 pool instead of using `deal()` for these tokens. + // Katana proxy token balance slots are not discoverable by `deal()`. vm.createSelectFork(_testChainId(), _testBlockNumber()); vm.setEvmVersion("osaka"); permit2Domain = keccak256( @@ -35,16 +33,12 @@ contract SushiV3KatanaIntegrationTest is SettlerBasePairTest { vm.label(address(this), "FoundryTest"); vm.label(address(PERMIT2), "Permit2"); vm.label(FROM, "FROM"); - vm.label(MAKER, "MAKER"); - vm.label(BURN_ADDRESS, "BURN"); vm.label(address(fromToken()), "vbETH"); vm.label(address(toToken()), "AUSD"); - // Source tokens from the SushiV3 pool (1.1 vbETH of liquidity is plenty for our tiny swap). vm.prank(KATANA_SUSHIV3_VBETH_AUSD_POOL); fromToken().transfer(FROM, amount()); - // SettlerBasePairTest setUp deploys KatanaSettler + etches AllowanceHolder. allowanceHolder = IAllowanceHolder(0x0000000000001fF3684f28c67538d4D072C22734); uint256 forkChainId = (new Shim()).chainId(); vm.chainId(31337); @@ -89,133 +83,70 @@ contract SushiV3KatanaIntegrationTest is SettlerBasePairTest { } function _sushiV3Path() internal view returns (bytes memory) { - // For Katana SushiV3 vbETH/AUSD: AUSD is token0 (lower address), vbETH is token1. - // Selling vbETH->AUSD is oneForZero, so the price limit is MAX_SQRT_RATIO-1. - // Uniswap V3 TickMath.MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342. - uint160 sqrtPriceLimitX96 = 1461446703485210103287273052203988822378723970341; - return abi.encodePacked( - fromToken(), uint8(sushiswapV3ForkId), uint24(3000), sqrtPriceLimitX96, toToken() - ); + return + abi.encodePacked(fromToken(), uint8(sushiswapV3ForkId), uint24(3000), sqrtPriceLimitX96FromTo(), toToken()); } - // Sells 0.001 vbETH for AUSD via the SushiV3 vbETH/AUSD pool on Katana. - // Verifies the full path: AllowanceHolder → KatanaSettler.execute → UNISWAPV3 action → - // KatanaMixin._uniV3ForkInfo(sushiswapV3ForkId) → CREATE2 derives pool 0xa522…2f38. - function testKatanaSushiV3() public { - uint256 sellAmount = amount(); - uint256 buyBalanceBefore = toToken().balanceOf(FROM); + function _permit() internal view returns (ISignatureTransfer.PermitTransferFrom memory) { + return defaultERC20PermitTransfer(address(fromToken()), amount(), 0); + } - vm.startPrank(FROM); - fromToken().approve(address(allowanceHolder), sellAmount); - - // For the AllowanceHolder flow, TRANSFER_FROM with an empty signature pulls - // tokens from msg.sender (FROM) into the settler using AH's transient allowance. - ISignatureTransfer.PermitTransferFrom memory permit = ISignatureTransfer.PermitTransferFrom({ - permitted: ISignatureTransfer.TokenPermissions({token: address(fromToken()), amount: sellAmount}), - nonce: 0, - deadline: type(uint256).max - }); - bytes[] memory actions = ActionDataBuilder.build( - abi.encodeCall(ISettlerActions.TRANSFER_FROM, (address(settler), permit, new bytes(0))), - abi.encodeCall(ISettlerActions.UNISWAPV3, (FROM, 10_000, _sushiV3Path(), 0)) - ); + function _exec(bytes[] memory actions) internal { bytes memory ahData = abi.encodeCall( settler.execute, ( - ISettlerBase.AllowedSlippage({ - recipient: payable(FROM), - buyToken: toToken(), - minAmountOut: 0 - }), + ISettlerBase.AllowedSlippage({recipient: payable(FROM), buyToken: toToken(), minAmountOut: 0}), actions, bytes32(0) ) ); - allowanceHolder.exec( - address(settler), address(fromToken()), sellAmount, payable(address(settler)), ahData + allowanceHolder.exec(address(settler), address(fromToken()), amount(), payable(address(settler)), ahData); + } + + function testKatanaSushiV3() public { + uint256 buyBalanceBefore = toToken().balanceOf(FROM); + + vm.startPrank(FROM); + fromToken().approve(address(allowanceHolder), amount()); + + bytes[] memory actions = ActionDataBuilder.build( + abi.encodeCall(ISettlerActions.TRANSFER_FROM, (address(settler), _permit(), new bytes(0))), + abi.encodeCall(ISettlerActions.UNISWAPV3, (FROM, 10_000, _sushiV3Path(), 0)) ); + _exec(actions); vm.stopPrank(); assertEq(fromToken().balanceOf(FROM), 0, "all vbETH should be spent"); assertGt(toToken().balanceOf(FROM), buyBalanceBefore, "should have bought AUSD"); } - // Same swap but via UNISWAPV3_VIP — Settler pulls the sell tokens inside the swap - // callback via Permit2/AH (no separate TRANSFER_FROM action). This is the gas-cheaper - // single-action path and exercises Settler._dispatchVIP → super._dispatchVIP after the - // hand-copied override was removed in commit 2. function testKatanaSushiV3_VIP() public { - uint256 sellAmount = amount(); uint256 buyBalanceBefore = toToken().balanceOf(FROM); vm.startPrank(FROM); - fromToken().approve(address(allowanceHolder), sellAmount); + fromToken().approve(address(allowanceHolder), amount()); - ISignatureTransfer.PermitTransferFrom memory permit = ISignatureTransfer.PermitTransferFrom({ - permitted: ISignatureTransfer.TokenPermissions({token: address(fromToken()), amount: sellAmount}), - nonce: 0, - deadline: type(uint256).max - }); bytes[] memory actions = ActionDataBuilder.build( - abi.encodeCall(ISettlerActions.UNISWAPV3_VIP, (FROM, permit, _sushiV3Path(), new bytes(0), 0)) - ); - bytes memory ahData = abi.encodeCall( - settler.execute, - ( - ISettlerBase.AllowedSlippage({ - recipient: payable(FROM), - buyToken: toToken(), - minAmountOut: 0 - }), - actions, - bytes32(0) - ) - ); - allowanceHolder.exec( - address(settler), address(fromToken()), sellAmount, payable(address(settler)), ahData + abi.encodeCall(ISettlerActions.UNISWAPV3_VIP, (FROM, _permit(), _sushiV3Path(), new bytes(0), 0)) ); + _exec(actions); vm.stopPrank(); assertEq(fromToken().balanceOf(FROM), 0, "all vbETH should be spent"); assertGt(toToken().balanceOf(FROM), buyBalanceBefore, "should have bought AUSD"); } - // Negative path: a UNISWAPV3 swap with an unsatisfiable amountOutMin must revert with - // TooMuchSlippage. Proves the slippage guard fires before the swap settles. function testKatanaSushiV3_slippageRevert() public { - uint256 sellAmount = amount(); - vm.startPrank(FROM); - fromToken().approve(address(allowanceHolder), sellAmount); + fromToken().approve(address(allowanceHolder), amount()); - ISignatureTransfer.PermitTransferFrom memory permit = ISignatureTransfer.PermitTransferFrom({ - permitted: ISignatureTransfer.TokenPermissions({token: address(fromToken()), amount: sellAmount}), - nonce: 0, - deadline: type(uint256).max - }); bytes[] memory actions = ActionDataBuilder.build( - abi.encodeCall(ISettlerActions.TRANSFER_FROM, (address(settler), permit, new bytes(0))), - abi.encodeCall( - ISettlerActions.UNISWAPV3, (FROM, 10_000, _sushiV3Path(), type(uint256).max) - ) - ); - bytes memory ahData = abi.encodeCall( - settler.execute, - ( - ISettlerBase.AllowedSlippage({ - recipient: payable(FROM), - buyToken: toToken(), - minAmountOut: 0 - }), - actions, - bytes32(0) - ) + abi.encodeCall(ISettlerActions.TRANSFER_FROM, (address(settler), _permit(), new bytes(0))), + abi.encodeCall(ISettlerActions.UNISWAPV3, (FROM, 10_000, _sushiV3Path(), type(uint256).max)) ); vm.expectPartialRevert(TooMuchSlippage.selector); - allowanceHolder.exec( - address(settler), address(fromToken()), sellAmount, payable(address(settler)), ahData - ); + _exec(actions); vm.stopPrank(); } } From d7e8ca2b9f74e1f59a29d752d0387420e40b8464 Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Tue, 19 May 2026 17:15:06 -0500 Subject: [PATCH 07/18] Add script to redeploy Settlers and reclaim Safe ownership on revived chains --- script/RedeploySettlers.s.sol | 345 ++++++++++++++++++++++++++++++++++ sh/revive_chain.sh | 222 ++++++++++++++++++++++ 2 files changed, 567 insertions(+) create mode 100644 script/RedeploySettlers.s.sol create mode 100755 sh/revive_chain.sh diff --git a/script/RedeploySettlers.s.sol b/script/RedeploySettlers.s.sol new file mode 100644 index 000000000..d1e4acd70 --- /dev/null +++ b/script/RedeploySettlers.s.sol @@ -0,0 +1,345 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {Script} from "@forge-std/Script.sol"; +import {Deployer, Feature} from "src/deployer/Deployer.sol"; +import {SafeConfig} from "./SafeConfig.sol"; + +interface ISafeExecute { + enum Operation { + Call, + DelegateCall + } + + function execTransaction( + address to, + uint256 value, + bytes calldata data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + bytes calldata signatures + ) external payable returns (bool); +} + +interface ISafeOwners { + function addOwnerWithThreshold(address owner, uint256 _threshold) external; + function removeOwner(address prevOwner, address owner, uint256 _threshold) external; + function changeThreshold(uint256 _threshold) external; + function getOwners() external view returns (address[] memory); + function getThreshold() external view returns (uint256); + function isModuleEnabled(address module) external view returns (bool); +} + +/// @notice Redeploys Settlers on an abandoned chain and restores Safe signers. +contract RedeploySettlers is Script { + bytes32 internal constant multicallHash = 0xa9865ac2d9c7a1591619b188c4d88167b50df6cc0c5327fcbd1c8c75f7c066ad; + + function _encodeMultisend(bytes[] memory calls) internal view returns (bytes memory result) { + // The Gnosis multicall contract uses a very obnoxious packed encoding + // that is very similar to, but not exactly the same as + // `abi.encodePacked` + assembly ("memory-safe") { + result := mload(0x40) + mstore(add(0x04, result), 0x8d80ff0a) // selector for `multiSend(bytes)` + mstore(add(0x24, result), 0x20) + let bytes_length_ptr := add(0x44, result) + mstore(bytes_length_ptr, 0x00) + for { + let i := add(0x20, calls) + let end := add(i, shl(0x05, mload(calls))) + let dst := add(0x20, bytes_length_ptr) + } lt(i, end) { i := add(0x20, i) } { + let src := mload(i) + let len := mload(src) + src := add(0x20, src) + + // We're using the old identity precompile version instead of + // the MCOPY opcode version because I don't want to have to deal + // with maintaining two versions of this + if or(xor(returndatasize(), len), iszero(staticcall(gas(), 0x04, src, len, dst, len))) { + invalid() + } + + dst := add(dst, len) + mstore(bytes_length_ptr, add(len, mload(bytes_length_ptr))) + } + mstore(result, add(0x44, mload(bytes_length_ptr))) + mstore(0x40, add(0x20, add(mload(result), result))) + } + } + + function _encodeMultisend(address safe, bytes memory call) internal pure returns (bytes memory) { + return abi.encodePacked( + uint8(ISafeExecute.Operation.Call), + safe, + uint256(0), // value + call.length, + call + ); + } + + function _encodeChangeOwners(address safe, uint256 threshold, address oldOwner, address[] memory newOwners) + internal + view + returns (bytes[] memory) + { + bytes[] memory subCalls = new bytes[](newOwners.length + 1); + for (uint256 i; i < newOwners.length; i++) { + bytes memory data = + abi.encodeCall(ISafeOwners.addOwnerWithThreshold, (newOwners[newOwners.length - i - 1], 1)); + subCalls[i] = _encodeMultisend(safe, data); + } + { + bytes memory data = + abi.encodeCall(ISafeOwners.removeOwner, (newOwners[newOwners.length - 1], oldOwner, threshold)); + subCalls[newOwners.length] = _encodeMultisend(safe, data); + } + return subCalls; + } + + function _deployCall( + string calldata chainDisplayName, + Feature feature, + string memory flatSuffix, + string memory contractSuffix, + bytes calldata constructorArgs + ) internal view returns (bytes memory) { + return abi.encodeCall( + Deployer.deploy, + ( + feature, + bytes.concat( + vm.getCode(string.concat(chainDisplayName, flatSuffix, ":", chainDisplayName, contractSuffix)), + constructorArgs + ) + ) + ); + } + + function run( + address moduleDeployer, + address proxyDeployer, + address iceColdCoffee, + address deployerProxy, + address deploymentSafe, + address upgradeSafe, + address safeMulticall, + Feature takerSubmittedFeature, + Feature metaTxFeature, + Feature intentFeature, + Feature bridgeFeature, + string calldata chainDisplayName, + bytes calldata constructorArgs, + address[] calldata solvers + ) public { + require(safeMulticall.codehash == multicallHash, "Safe multicall codehash"); + + require(Feature.unwrap(takerSubmittedFeature) == 2, "wrong taker-submitted feature (tokenId)"); + require(Feature.unwrap(metaTxFeature) == 3, "wrong metatransaction feature (tokenId)"); + require(Feature.unwrap(intentFeature) == 4, "wrong intents feature (tokenId)"); + require(Feature.unwrap(bridgeFeature) == 5, "wrong bridge feature (tokenId)"); + + uint256 moduleDeployerKey = vm.envUint("ICECOLDCOFFEE_DEPLOYER_KEY"); + uint256 proxyDeployerKey = vm.envUint("DEPLOYER_PROXY_DEPLOYER_KEY"); + require(vm.addr(moduleDeployerKey) == moduleDeployer, "module deployer key/address mismatch"); + require(vm.addr(proxyDeployerKey) == proxyDeployer, "proxy deployer key/address mismatch"); + + require(deploymentSafe.code.length > 0, "deployment safe not deployed"); + require(upgradeSafe.code.length > 0, "upgrade safe not deployed"); + require(deployerProxy.code.length > 0, "deployer proxy not deployed"); + require(iceColdCoffee.code.length > 0, "iceColdCoffee module not deployed"); + require( + ISafeOwners(deploymentSafe).isModuleEnabled(iceColdCoffee), + "iceColdCoffee module not enabled on deployment safe" + ); + { + address[] memory currentOwners = ISafeOwners(deploymentSafe).getOwners(); + require( + currentOwners.length == 1 && currentOwners[0] == moduleDeployer, + "deployment safe is not sole-owned by moduleDeployer" + ); + require(ISafeOwners(deploymentSafe).getThreshold() == 1, "deployment safe threshold != 1"); + } + { + address[] memory currentOwners = ISafeOwners(upgradeSafe).getOwners(); + require( + currentOwners.length == 1 && currentOwners[0] == proxyDeployer, + "upgrade safe is not sole-owned by proxyDeployer" + ); + require(ISafeOwners(upgradeSafe).getThreshold() == 1, "upgrade safe threshold != 1"); + } + require(Deployer(deployerProxy).owner() == upgradeSafe, "deployer proxy not owned by upgrade safe"); + require(Deployer(deployerProxy).pendingOwner() == address(0), "deployer proxy has pending owner transfer"); + + // `authorize` reverts on uninitialized features; `setDescription` is intentionally not re-called. + require(Deployer(deployerProxy).descriptionHash(takerSubmittedFeature) != 0, "taker feature not initialized"); + require(Deployer(deployerProxy).descriptionHash(metaTxFeature) != 0, "metatx feature not initialized"); + require(Deployer(deployerProxy).descriptionHash(intentFeature) != 0, "intent feature not initialized"); + require(Deployer(deployerProxy).descriptionHash(bridgeFeature) != 0, "bridge feature not initialized"); + + address predictedTakerSubmittedSettler = Deployer(deployerProxy).next(takerSubmittedFeature); + address predictedMetaTxSettler = Deployer(deployerProxy).next(metaTxFeature); + address predictedIntentSettler = Deployer(deployerProxy).next(intentFeature); + address predictedBridgeSettler = Deployer(deployerProxy).next(bridgeFeature); + require(predictedTakerSubmittedSettler.code.length == 0, "predicted taker settler slot occupied"); + require(predictedMetaTxSettler.code.length == 0, "predicted metatx settler slot occupied"); + require(predictedIntentSettler.code.length == 0, "predicted intent settler slot occupied"); + require(predictedBridgeSettler.code.length == 0, "predicted bridge settler slot occupied"); + + bytes memory takerSubmittedAuthorizeCall = abi.encodeCall( + Deployer.authorize, (takerSubmittedFeature, deploymentSafe, uint40(block.timestamp + 365 days)) + ); + bytes memory metaTxAuthorizeCall = + abi.encodeCall(Deployer.authorize, (metaTxFeature, deploymentSafe, uint40(block.timestamp + 365 days))); + bytes memory intentAuthorizeCall = + abi.encodeCall(Deployer.authorize, (intentFeature, deploymentSafe, uint40(block.timestamp + 365 days))); + bytes memory bridgeAuthorizeCall = + abi.encodeCall(Deployer.authorize, (bridgeFeature, deploymentSafe, uint40(block.timestamp + 365 days))); + + bytes memory takerSubmittedDeployCall = + _deployCall(chainDisplayName, takerSubmittedFeature, "TakerSubmittedFlat.sol", "Settler", constructorArgs); + bytes memory metaTxDeployCall = + _deployCall(chainDisplayName, metaTxFeature, "MetaTxnFlat.sol", "SettlerMetaTxn", constructorArgs); + bytes memory intentDeployCall = + _deployCall(chainDisplayName, intentFeature, "IntentFlat.sol", "SettlerIntent", constructorArgs); + bytes memory bridgeDeployCall = + _deployCall(chainDisplayName, bridgeFeature, "BridgeSettlerFlat.sol", "BridgeSettler", constructorArgs); + + address[] memory upgradeOwners = SafeConfig.getUpgradeSafeSigners(); + bytes[] memory changeOwnersCalls = + _encodeChangeOwners(upgradeSafe, SafeConfig.upgradeSafeThreshold, proxyDeployer, upgradeOwners); + assert(changeOwnersCalls.length == upgradeOwners.length + 1); + bytes[] memory upgradeSetupCalls = new bytes[](4 + changeOwnersCalls.length); + upgradeSetupCalls[0] = _encodeMultisend(deployerProxy, takerSubmittedAuthorizeCall); + upgradeSetupCalls[1] = _encodeMultisend(deployerProxy, metaTxAuthorizeCall); + upgradeSetupCalls[2] = _encodeMultisend(deployerProxy, intentAuthorizeCall); + upgradeSetupCalls[3] = _encodeMultisend(deployerProxy, bridgeAuthorizeCall); + for (uint256 i; i < changeOwnersCalls.length; i++) { + upgradeSetupCalls[i + 4] = changeOwnersCalls[i]; + } + bytes memory upgradeSetupCall = _encodeMultisend(upgradeSetupCalls); + + address[] memory deployerOwners = SafeConfig.getDeploymentSafeSigners(); + changeOwnersCalls = + _encodeChangeOwners(deploymentSafe, SafeConfig.deploymentSafeThreshold, moduleDeployer, deployerOwners); + assert(changeOwnersCalls.length == deployerOwners.length + 1); + bytes[] memory deploySetupCalls = new bytes[](4 + solvers.length + changeOwnersCalls.length); + deploySetupCalls[0] = _encodeMultisend(deployerProxy, takerSubmittedDeployCall); + deploySetupCalls[1] = _encodeMultisend(deployerProxy, metaTxDeployCall); + deploySetupCalls[2] = _encodeMultisend(deployerProxy, intentDeployCall); + deploySetupCalls[3] = _encodeMultisend(deployerProxy, bridgeDeployCall); + { + address prevSolver = 0x0000000000000000000000000000000000000001; + for (uint256 i; i < solvers.length; i++) { + address solver = solvers[i]; + deploySetupCalls[i + 4] = _encodeMultisend( + predictedIntentSettler, + abi.encodeWithSignature("setSolver(address,address,bool)", prevSolver, solver, true) + ); + prevSolver = solver; + } + } + for (uint256 i; i < changeOwnersCalls.length; i++) { + deploySetupCalls[i + 4 + solvers.length] = changeOwnersCalls[i]; + } + bytes memory deploySetupCall = _encodeMultisend(deploySetupCalls); + + bytes memory deploymentSignature = abi.encodePacked(uint256(uint160(moduleDeployer)), bytes32(0), uint8(1)); + bytes memory upgradeSignature = abi.encodePacked(uint256(uint160(proxyDeployer)), bytes32(0), uint8(1)); + + uint256[] memory gasSplits = new uint256[](4); + + vm.startBroadcast(proxyDeployerKey); + + // configure the deployer (authorize; set new owners) + gasSplits[0] = gasleft(); + ISafeExecute(upgradeSafe) + .execTransaction( + safeMulticall, + 0, + upgradeSetupCall, + ISafeExecute.Operation.DelegateCall, + 0, + 0, + 0, + address(0), + address(0), + upgradeSignature + ); + gasSplits[1] = gasleft(); + vm.stopBroadcast(); + + vm.startBroadcast(moduleDeployerKey); + + // deploy settlers; register solvers; set new owners + gasSplits[2] = gasleft(); + ISafeExecute(deploymentSafe) + .execTransaction( + safeMulticall, + 0, + deploySetupCall, + ISafeExecute.Operation.DelegateCall, + 0, + 0, + 0, + address(0), + address(0), + deploymentSignature + ); + gasSplits[3] = gasleft(); + vm.stopBroadcast(); + + for (uint256 i = 1; i < gasSplits.length; i++) { + require(gasSplits[i] + 15728639 > gasSplits[i - 1], "transaction is likely to exceed EIP-7825 limit"); + } + + require( + Deployer(deployerProxy).ownerOf(Feature.unwrap(takerSubmittedFeature)) == predictedTakerSubmittedSettler, + "predicted taker submitted settler address mismatch" + ); + require( + Deployer(deployerProxy).ownerOf(Feature.unwrap(metaTxFeature)) == predictedMetaTxSettler, + "predicted metatransaction settler address mismatch" + ); + require( + Deployer(deployerProxy).ownerOf(Feature.unwrap(intentFeature)) == predictedIntentSettler, + "predicted intent settler address mismatch" + ); + require( + Deployer(deployerProxy).ownerOf(Feature.unwrap(bridgeFeature)) == predictedBridgeSettler, + "predicted bridgesettler address mismatch" + ); + require( + keccak256(abi.encodePacked(ISafeOwners(deploymentSafe).getOwners())) + == keccak256(abi.encodePacked(deployerOwners)), + "deployment safe owners mismatch" + ); + require( + ISafeOwners(deploymentSafe).getThreshold() == SafeConfig.deploymentSafeThreshold, + "deployment safe threshold mismatch" + ); + require( + keccak256(abi.encodePacked(ISafeOwners(upgradeSafe).getOwners())) + == keccak256(abi.encodePacked(upgradeOwners)), + "upgrade safe owners mismatch" + ); + require( + ISafeOwners(upgradeSafe).getThreshold() == SafeConfig.upgradeSafeThreshold, + "upgrade safe threshold mismatch" + ); + { + (bool success, bytes memory returndata) = + predictedIntentSettler.staticcall(abi.encodeWithSignature("getSolvers()")); + if (!success) { + assembly ("memory-safe") { + revert(add(0x20, returndata), mload(returndata)) + } + } + require(keccak256(returndata) == keccak256(abi.encode(solvers)), "solvers/`getSolvers()` mismatch"); + } + } +} diff --git a/sh/revive_chain.sh b/sh/revive_chain.sh new file mode 100755 index 000000000..51c93a39c --- /dev/null +++ b/sh/revive_chain.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash + +## POSIX Bash implementation of realpath +## Copied and modified from https://github.com/mkropat/sh-realpath and https://github.com/AsymLabs/realpath-lib/ +## Copyright (c) 2014 Michael Kropat - MIT License +## Copyright (c) 2013 Asymmetry Laboratories - MIT License + +function realpath { + _resolve_symlinks "$(_canonicalize "$1")" +} + +function _directory { + local out slsh + slsh=/ + out="$1" + out="${out//$slsh$slsh/$slsh}" + if [ "$out" = / ]; then + echo / + return + fi + out="${out%/}" + case "$out" in + */*) + out="${out%/*}" + ;; + *) + out=. + ;; + esac + if [ "$out" ]; then + printf '%s\n' "$out" + else + echo / + fi +} + +function _file { + local out slsh + slsh=/ + out="$1" + out="${out//$slsh$slsh/$slsh}" + if [ "$out" = / ]; then + echo / + return + fi + out="${out%/}" + out="${out##*/}" + printf '%s\n' "$out" +} + +function _resolve_symlinks { + local path pattern context + while [ -L "$1" ]; do + context="$(_directory "$1")" + path="$(POSIXLY_CORRECT=y ls -ld -- "$1" 2>/dev/null)" + pattern='*'"$(_escape "$1")"' -> ' + path="${path#$pattern}" + set -- "$(_canonicalize "$(_prepend_context "$context" "$path")")" "$@" + _assert_no_path_cycles "$@" || return 1 + done + printf '%s\n' "$1" +} + +function _escape { + local out + out='' + local -i i + for ((i=0; i < ${#1}; i+=1)); do + out+='\'"${1:$i:1}" + done + printf '%s\n' "$out" +} + +function _prepend_context { + if [ "$1" = . ]; then + printf '%s\n' "$2" + else + case "$2" in + /* ) printf '%s\n' "$2" ;; + * ) printf '%s\n' "$1/$2" ;; + esac + fi +} + +function _assert_no_path_cycles { + local target path + + if [ $# -gt 16 ]; then + return 1 + fi + + target="$1" + shift + + for path in "$@"; do + if [ "$path" = "$target" ]; then + return 1 + fi + done +} + +function _canonicalize { + local d f + if [ -d "$1" ]; then + (CDPATH= cd -P "$1" 2>/dev/null && pwd -P) + else + d="$(_directory "$1")" + f="$(_file "$1")" + (CDPATH= cd -P "$d" 2>/dev/null && printf '%s/%s\n' "$(pwd -P)" "$f") + fi +} + +## end POSIX Bash implementation of realpath + +set -Eeufo pipefail -o posix + +declare project_root +project_root="$(_directory "$(_directory "$(realpath "${BASH_SOURCE[0]}")")")" +declare -r project_root +cd "$project_root" + +. "$project_root"/sh/common.sh +. "$project_root"/sh/common_secrets.sh + +decrypt_secrets + +declare -r bridge_settler_skip_clean=Yes +. "$project_root"/sh/common_deploy_settler.sh +. "$project_root"/sh/common_deploy_bridge_settler.sh + +declare module_deployer +module_deployer="$(get_secret iceColdCoffee deployer)" +declare -r module_deployer +declare proxy_deployer +proxy_deployer="$(get_secret deployer deployer)" +declare -r proxy_deployer + +declare deployer_proxy +deployer_proxy="$(get_config deployment.deployer)" +declare -r deployer_proxy +declare ice_cold_coffee +ice_cold_coffee="$(get_config governance.pause)" +declare -r ice_cold_coffee + +declare deployment_safe +deployment_safe="$(get_config governance.deploymentSafe)" +declare -r deployment_safe +declare upgrade_safe +upgrade_safe="$(get_config governance.upgradeSafe)" +declare -r upgrade_safe + +declare safe_multicall +safe_multicall="$(get_config safe.multiCall)" +declare -r safe_multicall + +# set minimum gas price (mostly for Arbitrum and BNB) +declare -i min_gas_price +min_gas_price="$(get_config minGasPriceGwei)" +min_gas_price=$((min_gas_price * 1000000000)) +declare -r -i min_gas_price +declare -i gas_price +gas_price="$(cast gas-price --rpc-url "$rpc_url")" +if (( gas_price < min_gas_price )) ; then + echo 'Setting gas price to minimum of '$((min_gas_price / 1000000000))' gwei' >&2 + gas_price=$min_gas_price +fi +declare -r -i gas_price + +# set gas multiplier/headroom (again mostly for Arbitrum) +declare -i gas_estimate_multiplier +gas_estimate_multiplier="$(get_config gasMultiplierPercent)" +declare -r -i gas_estimate_multiplier + +declare -a maybe_broadcast=() +if [[ ${BROADCAST-no} = [Yy]es ]] ; then + maybe_broadcast+=(--broadcast) +fi +declare -r -a maybe_broadcast + +if [[ ${BROADCAST-no} = [Yy]es ]] ; then + if (( $(cast balance --rpc-url "$rpc_url" "$module_deployer") == 0 )) ; then + echo 'You forgot to send ETH to '"$module_deployer"'.' >&2 + exit 1 + fi + if (( $(cast balance --rpc-url "$rpc_url" "$proxy_deployer") == 0 )) ; then + echo 'You forgot to send ETH to '"$proxy_deployer"'.' >&2 + exit 1 + fi +fi + +export FOUNDRY_OPTIMIZER_RUNS=1000000 + +ICECOLDCOFFEE_DEPLOYER_KEY="$(get_secret iceColdCoffee key)" DEPLOYER_PROXY_DEPLOYER_KEY="$(get_secret deployer key)" \ + forge script \ + --slow \ + --no-storage-caching \ + --skip 'Flat.sol' \ + --skip 'CrossChainReceiverFactory.sol' \ + --skip 'src/allowanceholder/*.sol' \ + --skip 'src/chains/*.sol' \ + --skip 'src/core/*.sol' \ + --skip 'src/multicall/*.sol' \ + --skip 'src/utils/*.sol' \ + --isolate \ + --gas-estimate-multiplier $gas_estimate_multiplier \ + --with-gas-price $gas_price \ + --chain $chainid \ + --rpc-url "$rpc_url" \ + -vvvvv \ + "${maybe_broadcast[@]}" \ + --sig 'run(address,address,address,address,address,address,address,uint128,uint128,uint128,uint128,string,bytes,address[])' \ + $(get_config extraFlags) \ + $(get_config extraScriptFlags) \ + script/RedeploySettlers.s.sol:RedeploySettlers \ + "$module_deployer" "$proxy_deployer" "$ice_cold_coffee" "$deployer_proxy" "$deployment_safe" "$upgrade_safe" "$safe_multicall" \ + 2 3 4 5 \ + "$chain_display_name" "$constructor_args" "$(IFS=, ; echo "[${solvers[*]}]")" + +if [[ ${BROADCAST-no} = [Yy]es ]] ; then + echo 'Settlers redeployed and Safes returned to canonical multisig ownership.' >&2 + echo 'Run `sh/verify_settler.sh '"$chain_name"'` to verify on Etherscan.' >&2 +fi From 9c81b49a1c681f189aaf3b5940b43c190f340872 Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Wed, 20 May 2026 02:04:10 -0500 Subject: [PATCH 08/18] revive_chain.sh: drop hardcoded --isolate --- sh/revive_chain.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/sh/revive_chain.sh b/sh/revive_chain.sh index 51c93a39c..898363978 100755 --- a/sh/revive_chain.sh +++ b/sh/revive_chain.sh @@ -201,7 +201,6 @@ ICECOLDCOFFEE_DEPLOYER_KEY="$(get_secret iceColdCoffee key)" DEPLOYER_PROXY_DEPL --skip 'src/core/*.sol' \ --skip 'src/multicall/*.sol' \ --skip 'src/utils/*.sol' \ - --isolate \ --gas-estimate-multiplier $gas_estimate_multiplier \ --with-gas-price $gas_price \ --chain $chainid \ From 09a4c80c00cf92d31d37d8adfbb9eae32c4d8fda Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Thu, 21 May 2026 16:21:59 -0500 Subject: [PATCH 09/18] Bug! EIP-7825 gas-budget check was a no-op --- script/DeploySafes.s.sol | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/script/DeploySafes.s.sol b/script/DeploySafes.s.sol index 75bfb9c7a..7ca99f86f 100644 --- a/script/DeploySafes.s.sol +++ b/script/DeploySafes.s.sol @@ -719,11 +719,8 @@ contract DeploySafes is Script { gasSplits[10] = gasleft(); _stopBroadcast(safeCompatConfig); - { - uint256 gasPrev = gasSplits[0]; - for (uint256 i = 1; i < gasSplits.length; i++) { - require(gasPrev + 15728639 > (gasPrev = gasSplits[i]), "transaction is likely to exceed EIP-7825 limit"); - } + for (uint256 i = 1; i < gasSplits.length; i++) { + require(gasSplits[i] + 15728639 > gasSplits[i - 1], "transaction is likely to exceed EIP-7825 limit"); } require(deployedModule == iceColdCoffee, "deployment/prediction mismatch"); From d8279e957a03ae4e1f69df6c1db61a69e8df45f5 Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Thu, 21 May 2026 16:25:57 -0500 Subject: [PATCH 10/18] Extract SafeMultisend script base --- script/DeploySafes.s.sol | 126 ++-------------------------------- script/RedeploySettlers.s.sol | 120 +++----------------------------- script/SafeMultisend.sol | 123 +++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 232 deletions(-) create mode 100644 script/SafeMultisend.sol diff --git a/script/DeploySafes.s.sol b/script/DeploySafes.s.sol index 7ca99f86f..03fddba84 100644 --- a/script/DeploySafes.s.sol +++ b/script/DeploySafes.s.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.25; -import {Script} from "@forge-std/Script.sol"; +import {SafeMultisend, ISafeExecute, ISafeOwners} from "./SafeMultisend.sol"; import {Vm, VmSafe} from "@forge-std/Vm.sol"; import {AddressDerivation} from "src/utils/AddressDerivation.sol"; import {Create3} from "src/utils/Create3.sol"; @@ -31,61 +31,17 @@ interface ISafeSetup { ) external; } -interface ISafeExecute { - enum Operation { - Call, - DelegateCall - } - - function execTransaction( - address to, - uint256 value, - bytes calldata data, - Operation operation, - uint256 safeTxGas, - uint256 baseGas, - uint256 gasPrice, - address gasToken, - address refundReceiver, - bytes calldata signatures - ) external payable returns (bool); -} - -interface ISafeOwners { - function addOwnerWithThreshold(address owner, uint256 _threshold) external; - function removeOwner(address prevOwner, address owner, uint256 _threshold) external; - function changeThreshold(uint256 _threshold) external; - function getOwners() external view returns (address[] memory); -} - interface ISafeModule { function enableModule(address module) external; } -interface ISafeMulticall { - /// @dev Sends multiple transactions and reverts all if one fails. - /// @param transactions Encoded transactions. Each transaction is encoded as a packed bytes of - /// operation has to be uint8(0) in this version (=> 1 byte), - /// to as a address (=> 20 bytes), - /// value as a uint256 (=> 32 bytes), - /// data length as a uint256 (=> 32 bytes), - /// data as bytes. - /// see abi.encodePacked for more information on packed encoding - /// @notice The code is for most part the same as the normal MultiSend (to keep compatibility), - /// but reverts if a transaction tries to use a delegatecall. - /// @notice This method is payable as delegatecalls keep the msg.value from the previous call - /// If the calling method (e.g. execTransaction) received ETH this would revert otherwise - function multiSend(bytes memory transactions) external payable; -} - -contract DeploySafes is Script { +contract DeploySafes is SafeMultisend { bytes32 internal constant singletonHash = 0x21842597390c4c6e3c1239e434a682b054bd9548eee5e9b1d6a4482731023c0f; bytes32 internal constant singletonHashEraVm = 0xe2ca068330339d608367d83a0b25545efe39e619098597699ab8ff828cb1ddd8; bytes32 internal constant factoryHash = 0x337d7f54be11b6ed55fef7b667ea5488db53db8320a05d1146aa4bd169a39a9b; bytes32 internal constant factoryHashEraVm = 0x55daa5d390d283edbc5fa835bd53befce45179c758feaac8c149a95850d0a6b6; bytes32 internal constant fallbackHash = 0x03e69f7ce809e81687c69b19a7d7cca45b6d551ffdec73d9bb87178476de1abf; bytes32 internal constant fallbackHashEraVm = 0x017e9a83d5513f503fb85274f4d1ad1811040d7caa31772750ffb08638c28fbb; - bytes32 internal constant multicallHash = 0xa9865ac2d9c7a1591619b188c4d88167b50df6cc0c5327fcbd1c8c75f7c066ad; bytes32 internal constant multicallHashEraVm = 0x064ddbf252714bcd4cb79f679e8c12df96d998ce07bbb13b3118c1dbf4a31942; uint256 internal constant safeDeploymentSaltNonce = 0; @@ -107,69 +63,6 @@ contract DeploySafes is Script { SafeBytecodes safeBytecodes; } - function _encodeMultisend(bytes[] memory calls) internal view returns (bytes memory result) { - // The Gnosis multicall contract uses a very obnoxious packed encoding - // that is very similar to, but not exactly the same as - // `abi.encodePacked` - assembly ("memory-safe") { - result := mload(0x40) - mstore(add(0x04, result), 0x8d80ff0a) // selector for `multiSend(bytes)` - mstore(add(0x24, result), 0x20) - let bytes_length_ptr := add(0x44, result) - mstore(bytes_length_ptr, 0x00) - for { - let i := add(0x20, calls) - let end := add(i, shl(0x05, mload(calls))) - let dst := add(0x20, bytes_length_ptr) - } lt(i, end) { i := add(0x20, i) } { - let src := mload(i) - let len := mload(src) - src := add(0x20, src) - - // We're using the old identity precompile version instead of - // the MCOPY opcode version because I don't want to have to deal - // with maintaining two versions of this - if or(xor(returndatasize(), len), iszero(staticcall(gas(), 0x04, src, len, dst, len))) { - invalid() - } - - dst := add(dst, len) - mstore(bytes_length_ptr, add(len, mload(bytes_length_ptr))) - } - mstore(result, add(0x44, mload(bytes_length_ptr))) - mstore(0x40, add(0x20, add(mload(result), result))) - } - } - - function _encodeMultisend(address safe, bytes memory call) internal pure returns (bytes memory) { - return abi.encodePacked( - uint8(ISafeExecute.Operation.Call), - safe, - uint256(0), // value - call.length, - call - ); - } - - function _encodeChangeOwners(address safe, uint256 threshold, address oldOwner, address[] memory newOwners) - internal - view - returns (bytes[] memory) - { - bytes[] memory subCalls = new bytes[](newOwners.length + 1); - for (uint256 i; i < newOwners.length; i++) { - bytes memory data = - abi.encodeCall(ISafeOwners.addOwnerWithThreshold, (newOwners[newOwners.length - i - 1], 1)); - subCalls[i] = _encodeMultisend(safe, data); - } - { - bytes memory data = - abi.encodeCall(ISafeOwners.removeOwner, (newOwners[newOwners.length - 1], oldOwner, threshold)); - subCalls[newOwners.length] = _encodeMultisend(safe, data); - } - return subCalls; - } - modifier eraVmCompat( bool isEraVm, uint256 privateKey, @@ -619,14 +512,9 @@ contract DeploySafes is Script { deploySetupCalls[3] = _encodeMultisend(deployerProxy, intentDeployCall); deploySetupCalls[4] = _encodeMultisend(deployerProxy, bridgeDeployCall); { - address prevSolver = 0x0000000000000000000000000000000000000001; - for (uint256 i; i < solvers.length; i++) { - address solver = solvers[i]; - deploySetupCalls[i + 5] = _encodeMultisend( - predictedIntentSettler, - abi.encodeWithSignature("setSolver(address,address,bool)", prevSolver, solver, true) - ); - prevSolver = solver; + bytes[] memory solverCalls = _encodeSolversMultisend(predictedIntentSettler, solvers); + for (uint256 i; i < solverCalls.length; i++) { + deploySetupCalls[i + 5] = solverCalls[i]; } } for (uint256 i; i < changeOwnersCalls.length; i++) { @@ -719,9 +607,7 @@ contract DeploySafes is Script { gasSplits[10] = gasleft(); _stopBroadcast(safeCompatConfig); - for (uint256 i = 1; i < gasSplits.length; i++) { - require(gasSplits[i] + 15728639 > gasSplits[i - 1], "transaction is likely to exceed EIP-7825 limit"); - } + _assertEip7825(gasSplits); require(deployedModule == iceColdCoffee, "deployment/prediction mismatch"); require(deployedDeploymentSafe == deploymentSafe, "deployed safe/predicted safe mismatch"); diff --git a/script/RedeploySettlers.s.sol b/script/RedeploySettlers.s.sol index d1e4acd70..3294fbfe9 100644 --- a/script/RedeploySettlers.s.sol +++ b/script/RedeploySettlers.s.sol @@ -1,106 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.25; -import {Script} from "@forge-std/Script.sol"; +import {SafeMultisend, ISafeExecute, ISafeOwners} from "./SafeMultisend.sol"; import {Deployer, Feature} from "src/deployer/Deployer.sol"; import {SafeConfig} from "./SafeConfig.sol"; -interface ISafeExecute { - enum Operation { - Call, - DelegateCall - } - - function execTransaction( - address to, - uint256 value, - bytes calldata data, - Operation operation, - uint256 safeTxGas, - uint256 baseGas, - uint256 gasPrice, - address gasToken, - address refundReceiver, - bytes calldata signatures - ) external payable returns (bool); -} - -interface ISafeOwners { - function addOwnerWithThreshold(address owner, uint256 _threshold) external; - function removeOwner(address prevOwner, address owner, uint256 _threshold) external; - function changeThreshold(uint256 _threshold) external; - function getOwners() external view returns (address[] memory); - function getThreshold() external view returns (uint256); - function isModuleEnabled(address module) external view returns (bool); -} - -/// @notice Redeploys Settlers on an abandoned chain and restores Safe signers. -contract RedeploySettlers is Script { - bytes32 internal constant multicallHash = 0xa9865ac2d9c7a1591619b188c4d88167b50df6cc0c5327fcbd1c8c75f7c066ad; - - function _encodeMultisend(bytes[] memory calls) internal view returns (bytes memory result) { - // The Gnosis multicall contract uses a very obnoxious packed encoding - // that is very similar to, but not exactly the same as - // `abi.encodePacked` - assembly ("memory-safe") { - result := mload(0x40) - mstore(add(0x04, result), 0x8d80ff0a) // selector for `multiSend(bytes)` - mstore(add(0x24, result), 0x20) - let bytes_length_ptr := add(0x44, result) - mstore(bytes_length_ptr, 0x00) - for { - let i := add(0x20, calls) - let end := add(i, shl(0x05, mload(calls))) - let dst := add(0x20, bytes_length_ptr) - } lt(i, end) { i := add(0x20, i) } { - let src := mload(i) - let len := mload(src) - src := add(0x20, src) - - // We're using the old identity precompile version instead of - // the MCOPY opcode version because I don't want to have to deal - // with maintaining two versions of this - if or(xor(returndatasize(), len), iszero(staticcall(gas(), 0x04, src, len, dst, len))) { - invalid() - } - - dst := add(dst, len) - mstore(bytes_length_ptr, add(len, mload(bytes_length_ptr))) - } - mstore(result, add(0x44, mload(bytes_length_ptr))) - mstore(0x40, add(0x20, add(mload(result), result))) - } - } - - function _encodeMultisend(address safe, bytes memory call) internal pure returns (bytes memory) { - return abi.encodePacked( - uint8(ISafeExecute.Operation.Call), - safe, - uint256(0), // value - call.length, - call - ); - } - - function _encodeChangeOwners(address safe, uint256 threshold, address oldOwner, address[] memory newOwners) - internal - view - returns (bytes[] memory) - { - bytes[] memory subCalls = new bytes[](newOwners.length + 1); - for (uint256 i; i < newOwners.length; i++) { - bytes memory data = - abi.encodeCall(ISafeOwners.addOwnerWithThreshold, (newOwners[newOwners.length - i - 1], 1)); - subCalls[i] = _encodeMultisend(safe, data); - } - { - bytes memory data = - abi.encodeCall(ISafeOwners.removeOwner, (newOwners[newOwners.length - 1], oldOwner, threshold)); - subCalls[newOwners.length] = _encodeMultisend(safe, data); - } - return subCalls; - } - +contract RedeploySettlers is SafeMultisend { function _deployCall( string calldata chainDisplayName, Feature feature, @@ -227,24 +132,17 @@ contract RedeploySettlers is Script { changeOwnersCalls = _encodeChangeOwners(deploymentSafe, SafeConfig.deploymentSafeThreshold, moduleDeployer, deployerOwners); assert(changeOwnersCalls.length == deployerOwners.length + 1); - bytes[] memory deploySetupCalls = new bytes[](4 + solvers.length + changeOwnersCalls.length); + bytes[] memory solverCalls = _encodeSolversMultisend(predictedIntentSettler, solvers); + bytes[] memory deploySetupCalls = new bytes[](4 + solverCalls.length + changeOwnersCalls.length); deploySetupCalls[0] = _encodeMultisend(deployerProxy, takerSubmittedDeployCall); deploySetupCalls[1] = _encodeMultisend(deployerProxy, metaTxDeployCall); deploySetupCalls[2] = _encodeMultisend(deployerProxy, intentDeployCall); deploySetupCalls[3] = _encodeMultisend(deployerProxy, bridgeDeployCall); - { - address prevSolver = 0x0000000000000000000000000000000000000001; - for (uint256 i; i < solvers.length; i++) { - address solver = solvers[i]; - deploySetupCalls[i + 4] = _encodeMultisend( - predictedIntentSettler, - abi.encodeWithSignature("setSolver(address,address,bool)", prevSolver, solver, true) - ); - prevSolver = solver; - } + for (uint256 i; i < solverCalls.length; i++) { + deploySetupCalls[i + 4] = solverCalls[i]; } for (uint256 i; i < changeOwnersCalls.length; i++) { - deploySetupCalls[i + 4 + solvers.length] = changeOwnersCalls[i]; + deploySetupCalls[i + 4 + solverCalls.length] = changeOwnersCalls[i]; } bytes memory deploySetupCall = _encodeMultisend(deploySetupCalls); @@ -293,9 +191,7 @@ contract RedeploySettlers is Script { gasSplits[3] = gasleft(); vm.stopBroadcast(); - for (uint256 i = 1; i < gasSplits.length; i++) { - require(gasSplits[i] + 15728639 > gasSplits[i - 1], "transaction is likely to exceed EIP-7825 limit"); - } + _assertEip7825(gasSplits); require( Deployer(deployerProxy).ownerOf(Feature.unwrap(takerSubmittedFeature)) == predictedTakerSubmittedSettler, diff --git a/script/SafeMultisend.sol b/script/SafeMultisend.sol new file mode 100644 index 000000000..dca7d4698 --- /dev/null +++ b/script/SafeMultisend.sol @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {Script} from "@forge-std/Script.sol"; + +interface ISafeExecute { + enum Operation { + Call, + DelegateCall + } + + function execTransaction( + address to, + uint256 value, + bytes calldata data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + bytes calldata signatures + ) external payable returns (bool); +} + +interface ISafeOwners { + function addOwnerWithThreshold(address owner, uint256 _threshold) external; + function removeOwner(address prevOwner, address owner, uint256 _threshold) external; + function changeThreshold(uint256 _threshold) external; + function getOwners() external view returns (address[] memory); + function getThreshold() external view returns (uint256); + function isModuleEnabled(address module) external view returns (bool); +} + +abstract contract SafeMultisend is Script { + bytes32 internal constant multicallHash = 0xa9865ac2d9c7a1591619b188c4d88167b50df6cc0c5327fcbd1c8c75f7c066ad; + + function _encodeMultisend(bytes[] memory calls) internal view returns (bytes memory result) { + // The Gnosis multicall contract uses a very obnoxious packed encoding + // that is very similar to, but not exactly the same as + // `abi.encodePacked` + assembly ("memory-safe") { + result := mload(0x40) + mstore(add(0x04, result), 0x8d80ff0a) // selector for `multiSend(bytes)` + mstore(add(0x24, result), 0x20) + let bytes_length_ptr := add(0x44, result) + mstore(bytes_length_ptr, 0x00) + for { + let i := add(0x20, calls) + let end := add(i, shl(0x05, mload(calls))) + let dst := add(0x20, bytes_length_ptr) + } lt(i, end) { i := add(0x20, i) } { + let src := mload(i) + let len := mload(src) + src := add(0x20, src) + + // We're using the old identity precompile version instead of + // the MCOPY opcode version because I don't want to have to deal + // with maintaining two versions of this + if or(xor(returndatasize(), len), iszero(staticcall(gas(), 0x04, src, len, dst, len))) { + invalid() + } + + dst := add(dst, len) + mstore(bytes_length_ptr, add(len, mload(bytes_length_ptr))) + } + mstore(result, add(0x44, mload(bytes_length_ptr))) + mstore(0x40, add(0x20, add(mload(result), result))) + } + } + + function _encodeMultisend(address safe, bytes memory call) internal pure returns (bytes memory) { + return abi.encodePacked( + uint8(ISafeExecute.Operation.Call), + safe, + uint256(0), // value + call.length, + call + ); + } + + function _encodeChangeOwners(address safe, uint256 threshold, address oldOwner, address[] memory newOwners) + internal + pure + returns (bytes[] memory) + { + bytes[] memory subCalls = new bytes[](newOwners.length + 1); + for (uint256 i; i < newOwners.length; i++) { + bytes memory data = + abi.encodeCall(ISafeOwners.addOwnerWithThreshold, (newOwners[newOwners.length - i - 1], 1)); + subCalls[i] = _encodeMultisend(safe, data); + } + { + bytes memory data = + abi.encodeCall(ISafeOwners.removeOwner, (newOwners[newOwners.length - 1], oldOwner, threshold)); + subCalls[newOwners.length] = _encodeMultisend(safe, data); + } + return subCalls; + } + + function _encodeSolversMultisend(address intentSettler, address[] memory solvers) + internal + pure + returns (bytes[] memory) + { + bytes[] memory subCalls = new bytes[](solvers.length); + address prevSolver = 0x0000000000000000000000000000000000000001; + for (uint256 i; i < solvers.length; i++) { + address solver = solvers[i]; + subCalls[i] = _encodeMultisend( + intentSettler, abi.encodeWithSignature("setSolver(address,address,bool)", prevSolver, solver, true) + ); + prevSolver = solver; + } + return subCalls; + } + + function _assertEip7825(uint256[] memory gasSplits) internal pure { + for (uint256 i = 1; i < gasSplits.length; i++) { + require(gasSplits[i] + 15728639 > gasSplits[i - 1], "transaction is likely to exceed EIP-7825 limit"); + } + } +} From 546d2817eb28f929e930677e20ad57ae945b975c Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Thu, 21 May 2026 16:26:16 -0500 Subject: [PATCH 11/18] RedeploySettlers: split MultiSend per Safe; allow keyless simulation --- script/RedeploySettlers.s.sol | 98 +++++++++++++++++++---------------- 1 file changed, 52 insertions(+), 46 deletions(-) diff --git a/script/RedeploySettlers.s.sol b/script/RedeploySettlers.s.sol index 3294fbfe9..25ebdd0e4 100644 --- a/script/RedeploySettlers.s.sol +++ b/script/RedeploySettlers.s.sol @@ -6,6 +6,16 @@ import {Deployer, Feature} from "src/deployer/Deployer.sol"; import {SafeConfig} from "./SafeConfig.sol"; contract RedeploySettlers is SafeMultisend { + function _execDelegateCall(address safe, address multicall, bytes memory data, bytes memory signature) + internal + returns (bool) + { + return ISafeExecute(safe) + .execTransaction( + multicall, 0, data, ISafeExecute.Operation.DelegateCall, 0, 0, 0, address(0), address(0), signature + ); + } + function _deployCall( string calldata chainDisplayName, Feature feature, @@ -48,10 +58,15 @@ contract RedeploySettlers is SafeMultisend { require(Feature.unwrap(intentFeature) == 4, "wrong intents feature (tokenId)"); require(Feature.unwrap(bridgeFeature) == 5, "wrong bridge feature (tokenId)"); - uint256 moduleDeployerKey = vm.envUint("ICECOLDCOFFEE_DEPLOYER_KEY"); - uint256 proxyDeployerKey = vm.envUint("DEPLOYER_PROXY_DEPLOYER_KEY"); - require(vm.addr(moduleDeployerKey) == moduleDeployer, "module deployer key/address mismatch"); - require(vm.addr(proxyDeployerKey) == proxyDeployer, "proxy deployer key/address mismatch"); + // Keys are optional so the script can be dry-run in simulation without decrypting secrets. + uint256 moduleDeployerKey = vm.envOr("ICECOLDCOFFEE_DEPLOYER_KEY", uint256(0)); + uint256 proxyDeployerKey = vm.envOr("DEPLOYER_PROXY_DEPLOYER_KEY", uint256(0)); + if (moduleDeployerKey != 0) { + require(vm.addr(moduleDeployerKey) == moduleDeployer, "module deployer key/address mismatch"); + } + if (proxyDeployerKey != 0) { + require(vm.addr(proxyDeployerKey) == proxyDeployer, "proxy deployer key/address mismatch"); + } require(deploymentSafe.code.length > 0, "deployment safe not deployed"); require(upgradeSafe.code.length > 0, "upgrade safe not deployed"); @@ -132,63 +147,54 @@ contract RedeploySettlers is SafeMultisend { changeOwnersCalls = _encodeChangeOwners(deploymentSafe, SafeConfig.deploymentSafeThreshold, moduleDeployer, deployerOwners); assert(changeOwnersCalls.length == deployerOwners.length + 1); - bytes[] memory solverCalls = _encodeSolversMultisend(predictedIntentSettler, solvers); - bytes[] memory deploySetupCalls = new bytes[](4 + solverCalls.length + changeOwnersCalls.length); - deploySetupCalls[0] = _encodeMultisend(deployerProxy, takerSubmittedDeployCall); - deploySetupCalls[1] = _encodeMultisend(deployerProxy, metaTxDeployCall); - deploySetupCalls[2] = _encodeMultisend(deployerProxy, intentDeployCall); - deploySetupCalls[3] = _encodeMultisend(deployerProxy, bridgeDeployCall); - for (uint256 i; i < solverCalls.length; i++) { - deploySetupCalls[i + 4] = solverCalls[i]; - } - for (uint256 i; i < changeOwnersCalls.length; i++) { - deploySetupCalls[i + 4 + solverCalls.length] = changeOwnersCalls[i]; - } - bytes memory deploySetupCall = _encodeMultisend(deploySetupCalls); + + bytes[] memory deploySetupCalls1 = new bytes[](2); + deploySetupCalls1[0] = _encodeMultisend(deployerProxy, takerSubmittedDeployCall); + deploySetupCalls1[1] = _encodeMultisend(deployerProxy, metaTxDeployCall); + bytes memory deploySetupCall1 = _encodeMultisend(deploySetupCalls1); + + bytes[] memory deploySetupCalls2 = new bytes[](2); + deploySetupCalls2[0] = _encodeMultisend(deployerProxy, intentDeployCall); + deploySetupCalls2[1] = _encodeMultisend(deployerProxy, bridgeDeployCall); + bytes memory deploySetupCall2 = _encodeMultisend(deploySetupCalls2); + + bytes memory deploySetupCall3 = _encodeMultisend(_encodeSolversMultisend(predictedIntentSettler, solvers)); + + bytes memory deploySetupCall4 = _encodeMultisend(changeOwnersCalls); bytes memory deploymentSignature = abi.encodePacked(uint256(uint160(moduleDeployer)), bytes32(0), uint8(1)); bytes memory upgradeSignature = abi.encodePacked(uint256(uint160(proxyDeployer)), bytes32(0), uint8(1)); - uint256[] memory gasSplits = new uint256[](4); + uint256[] memory gasSplits = new uint256[](7); - vm.startBroadcast(proxyDeployerKey); + if (proxyDeployerKey != 0) vm.startBroadcast(proxyDeployerKey); + else vm.startBroadcast(proxyDeployer); // configure the deployer (authorize; set new owners) gasSplits[0] = gasleft(); - ISafeExecute(upgradeSafe) - .execTransaction( - safeMulticall, - 0, - upgradeSetupCall, - ISafeExecute.Operation.DelegateCall, - 0, - 0, - 0, - address(0), - address(0), - upgradeSignature - ); + _execDelegateCall(upgradeSafe, safeMulticall, upgradeSetupCall, upgradeSignature); gasSplits[1] = gasleft(); vm.stopBroadcast(); - vm.startBroadcast(moduleDeployerKey); + if (moduleDeployerKey != 0) vm.startBroadcast(moduleDeployerKey); + else vm.startBroadcast(moduleDeployer); // deploy settlers; register solvers; set new owners gasSplits[2] = gasleft(); - ISafeExecute(deploymentSafe) - .execTransaction( - safeMulticall, - 0, - deploySetupCall, - ISafeExecute.Operation.DelegateCall, - 0, - 0, - 0, - address(0), - address(0), - deploymentSignature - ); + _execDelegateCall(deploymentSafe, safeMulticall, deploySetupCall1, deploymentSignature); + gasSplits[3] = gasleft(); + _execDelegateCall(deploymentSafe, safeMulticall, deploySetupCall2, deploymentSignature); + + gasSplits[4] = gasleft(); + if (solvers.length > 0) { + _execDelegateCall(deploymentSafe, safeMulticall, deploySetupCall3, deploymentSignature); + } + + gasSplits[5] = gasleft(); + _execDelegateCall(deploymentSafe, safeMulticall, deploySetupCall4, deploymentSignature); + + gasSplits[6] = gasleft(); vm.stopBroadcast(); _assertEip7825(gasSplits); From 08c809380852994680a6a14a0491dd8371588341 Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Thu, 21 May 2026 16:26:22 -0500 Subject: [PATCH 12/18] revive_chain.sh: bc-based balance floor --- sh/revive_chain.sh | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/sh/revive_chain.sh b/sh/revive_chain.sh index 898363978..4ed570045 100755 --- a/sh/revive_chain.sh +++ b/sh/revive_chain.sh @@ -178,12 +178,20 @@ fi declare -r -a maybe_broadcast if [[ ${BROADCAST-no} = [Yy]es ]] ; then - if (( $(cast balance --rpc-url "$rpc_url" "$module_deployer") == 0 )) ; then - echo 'You forgot to send ETH to '"$module_deployer"'.' >&2 + # Revived chains may have dust left over from prior deploy attempts, so checking >0 isn't + # enough. Require enough balance to cover the full multi-tx deployment sequence + # (~25M gas total per deployer, scaled by gasMultiplierPercent) at the current gas price. + # `bc` avoids 64-bit overflow on high-gas-price chains (e.g. polygon at 200 gwei). + declare min_balance module_deployer_balance proxy_deployer_balance + min_balance="$(bc <<<"$gas_price * 25000000 * $gas_estimate_multiplier / 100")" + module_deployer_balance="$(cast balance --rpc-url "$rpc_url" "$module_deployer")" + proxy_deployer_balance="$(cast balance --rpc-url "$rpc_url" "$proxy_deployer")" + if (( $(bc <<<"$module_deployer_balance < $min_balance") )) ; then + echo "Insufficient ETH at $module_deployer ($module_deployer_balance wei, need >= $min_balance wei)." >&2 exit 1 fi - if (( $(cast balance --rpc-url "$rpc_url" "$proxy_deployer") == 0 )) ; then - echo 'You forgot to send ETH to '"$proxy_deployer"'.' >&2 + if (( $(bc <<<"$proxy_deployer_balance < $min_balance") )) ; then + echo "Insufficient ETH at $proxy_deployer ($proxy_deployer_balance wei, need >= $min_balance wei)." >&2 exit 1 fi fi From 49dad0c2173c80b204280f02cb20b994b2e027d0 Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Thu, 21 May 2026 16:26:31 -0500 Subject: [PATCH 13/18] Katana SushiV3 test: align EVM version with chain_config, use vm.getChainId --- test/integration/katana/SushiV3.t.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/integration/katana/SushiV3.t.sol b/test/integration/katana/SushiV3.t.sol index 52d944953..3e6400ad4 100644 --- a/test/integration/katana/SushiV3.t.sol +++ b/test/integration/katana/SushiV3.t.sol @@ -8,7 +8,7 @@ import {ISettlerActions} from "src/ISettlerActions.sol"; import {ISettlerBase} from "src/interfaces/ISettlerBase.sol"; import {IAllowanceHolder} from "src/allowanceholder/IAllowanceHolder.sol"; import {ISignatureTransfer} from "@permit2/interfaces/ISignatureTransfer.sol"; -import {SettlerBasePairTest, Shim} from "../SettlerBasePairTest.t.sol"; +import {SettlerBasePairTest} from "../SettlerBasePairTest.t.sol"; import {sushiswapV3ForkId} from "src/core/univ3forks/SushiswapV3.sol"; import {TooMuchSlippage} from "src/core/SettlerErrors.sol"; @@ -21,7 +21,7 @@ contract SushiV3KatanaIntegrationTest is SettlerBasePairTest { function setUp() public virtual override { // Katana proxy token balance slots are not discoverable by `deal()`. vm.createSelectFork(_testChainId(), _testBlockNumber()); - vm.setEvmVersion("osaka"); + vm.setEvmVersion("cancun"); permit2Domain = keccak256( abi.encode( keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"), @@ -40,7 +40,7 @@ contract SushiV3KatanaIntegrationTest is SettlerBasePairTest { fromToken().transfer(FROM, amount()); allowanceHolder = IAllowanceHolder(0x0000000000001fF3684f28c67538d4D072C22734); - uint256 forkChainId = (new Shim()).chainId(); + uint256 forkChainId = vm.getChainId(); vm.chainId(31337); bytes memory initCode = settlerInitCode(); assembly ("memory-safe") { From ccaa7747c1363dd5ebca64f0a8a49b711a6b9445 Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Thu, 21 May 2026 21:11:18 -0500 Subject: [PATCH 14/18] revive_chain.sh: derive balance floor from sim --- sh/revive_chain.sh | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/sh/revive_chain.sh b/sh/revive_chain.sh index 4ed570045..353d8c87e 100755 --- a/sh/revive_chain.sh +++ b/sh/revive_chain.sh @@ -178,12 +178,9 @@ fi declare -r -a maybe_broadcast if [[ ${BROADCAST-no} = [Yy]es ]] ; then - # Revived chains may have dust left over from prior deploy attempts, so checking >0 isn't - # enough. Require enough balance to cover the full multi-tx deployment sequence - # (~25M gas total per deployer, scaled by gasMultiplierPercent) at the current gas price. - # `bc` avoids 64-bit overflow on high-gas-price chains (e.g. polygon at 200 gwei). declare min_balance module_deployer_balance proxy_deployer_balance - min_balance="$(bc <<<"$gas_price * 25000000 * $gas_estimate_multiplier / 100")" + # 24M = sum of per-tx gas from a Katana dry-run sim (~23.1M actual + small pad) + min_balance="$(bc <<<"$gas_price * 24000000 * $gas_estimate_multiplier / 100")" module_deployer_balance="$(cast balance --rpc-url "$rpc_url" "$module_deployer")" proxy_deployer_balance="$(cast balance --rpc-url "$rpc_url" "$proxy_deployer")" if (( $(bc <<<"$module_deployer_balance < $min_balance") )) ; then From 54b61691814e7c3158bf13df1b0680f56fe61684 Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Tue, 26 May 2026 11:30:45 -0500 Subject: [PATCH 15/18] Address review: EraVm-aware codehash, per-deploy txs, require solvers, balance floor --- script/DeploySafes.s.sol | 6 +----- script/RedeploySettlers.s.sol | 38 +++++++++++++++++------------------ script/SafeMultisend.sol | 15 ++++++++++++++ sh/revive_chain.sh | 4 ++-- 4 files changed, 37 insertions(+), 26 deletions(-) diff --git a/script/DeploySafes.s.sol b/script/DeploySafes.s.sol index 03fddba84..27a55fcd3 100644 --- a/script/DeploySafes.s.sol +++ b/script/DeploySafes.s.sol @@ -42,7 +42,6 @@ contract DeploySafes is SafeMultisend { bytes32 internal constant factoryHashEraVm = 0x55daa5d390d283edbc5fa835bd53befce45179c758feaac8c149a95850d0a6b6; bytes32 internal constant fallbackHash = 0x03e69f7ce809e81687c69b19a7d7cca45b6d551ffdec73d9bb87178476de1abf; bytes32 internal constant fallbackHashEraVm = 0x017e9a83d5513f503fb85274f4d1ad1811040d7caa31772750ffb08638c28fbb; - bytes32 internal constant multicallHashEraVm = 0x064ddbf252714bcd4cb79f679e8c12df96d998ce07bbb13b3118c1dbf4a31942; uint256 internal constant safeDeploymentSaltNonce = 0; // This is derived from calling `proxyCreationCode()` on the factory and then decoding the EraVm-style encoded @@ -298,10 +297,7 @@ contract DeploySafes is SafeMultisend { safeFallback.codehash == (safeCompatConfig.isEraVm ? fallbackHashEraVm : fallbackHash), "Safe fallback codehash" ); - require( - safeMulticall.codehash == (safeCompatConfig.isEraVm ? multicallHashEraVm : multicallHash), - "Safe multicall codehash" - ); + _assertMulticallCodehash(safeMulticall); require(Feature.unwrap(takerSubmittedFeature) == 2, "wrong taker-submitted feature (tokenId)"); require(Feature.unwrap(metaTxFeature) == 3, "wrong metatransaction feature (tokenId)"); diff --git a/script/RedeploySettlers.s.sol b/script/RedeploySettlers.s.sol index 25ebdd0e4..dd56eb46c 100644 --- a/script/RedeploySettlers.s.sol +++ b/script/RedeploySettlers.s.sol @@ -51,12 +51,13 @@ contract RedeploySettlers is SafeMultisend { bytes calldata constructorArgs, address[] calldata solvers ) public { - require(safeMulticall.codehash == multicallHash, "Safe multicall codehash"); + _assertMulticallCodehash(safeMulticall); require(Feature.unwrap(takerSubmittedFeature) == 2, "wrong taker-submitted feature (tokenId)"); require(Feature.unwrap(metaTxFeature) == 3, "wrong metatransaction feature (tokenId)"); require(Feature.unwrap(intentFeature) == 4, "wrong intents feature (tokenId)"); require(Feature.unwrap(bridgeFeature) == 5, "wrong bridge feature (tokenId)"); + require(solvers.length > 0, "solvers must not be empty"); // Keys are optional so the script can be dry-run in simulation without decrypting secrets. uint256 moduleDeployerKey = vm.envOr("ICECOLDCOFFEE_DEPLOYER_KEY", uint256(0)); @@ -148,24 +149,19 @@ contract RedeploySettlers is SafeMultisend { _encodeChangeOwners(deploymentSafe, SafeConfig.deploymentSafeThreshold, moduleDeployer, deployerOwners); assert(changeOwnersCalls.length == deployerOwners.length + 1); - bytes[] memory deploySetupCalls1 = new bytes[](2); - deploySetupCalls1[0] = _encodeMultisend(deployerProxy, takerSubmittedDeployCall); - deploySetupCalls1[1] = _encodeMultisend(deployerProxy, metaTxDeployCall); - bytes memory deploySetupCall1 = _encodeMultisend(deploySetupCalls1); + bytes memory takerDeployTx = _wrapSingleMultisend(_encodeMultisend(deployerProxy, takerSubmittedDeployCall)); + bytes memory metaTxDeployTx = _wrapSingleMultisend(_encodeMultisend(deployerProxy, metaTxDeployCall)); + bytes memory intentDeployTx = _wrapSingleMultisend(_encodeMultisend(deployerProxy, intentDeployCall)); + bytes memory bridgeDeployTx = _wrapSingleMultisend(_encodeMultisend(deployerProxy, bridgeDeployCall)); - bytes[] memory deploySetupCalls2 = new bytes[](2); - deploySetupCalls2[0] = _encodeMultisend(deployerProxy, intentDeployCall); - deploySetupCalls2[1] = _encodeMultisend(deployerProxy, bridgeDeployCall); - bytes memory deploySetupCall2 = _encodeMultisend(deploySetupCalls2); + bytes memory solversTx = _encodeMultisend(_encodeSolversMultisend(predictedIntentSettler, solvers)); - bytes memory deploySetupCall3 = _encodeMultisend(_encodeSolversMultisend(predictedIntentSettler, solvers)); - - bytes memory deploySetupCall4 = _encodeMultisend(changeOwnersCalls); + bytes memory changeOwnersTx = _encodeMultisend(changeOwnersCalls); bytes memory deploymentSignature = abi.encodePacked(uint256(uint160(moduleDeployer)), bytes32(0), uint8(1)); bytes memory upgradeSignature = abi.encodePacked(uint256(uint160(proxyDeployer)), bytes32(0), uint8(1)); - uint256[] memory gasSplits = new uint256[](7); + uint256[] memory gasSplits = new uint256[](9); if (proxyDeployerKey != 0) vm.startBroadcast(proxyDeployerKey); else vm.startBroadcast(proxyDeployer); @@ -181,20 +177,24 @@ contract RedeploySettlers is SafeMultisend { // deploy settlers; register solvers; set new owners gasSplits[2] = gasleft(); - _execDelegateCall(deploymentSafe, safeMulticall, deploySetupCall1, deploymentSignature); + _execDelegateCall(deploymentSafe, safeMulticall, takerDeployTx, deploymentSignature); gasSplits[3] = gasleft(); - _execDelegateCall(deploymentSafe, safeMulticall, deploySetupCall2, deploymentSignature); + _execDelegateCall(deploymentSafe, safeMulticall, metaTxDeployTx, deploymentSignature); gasSplits[4] = gasleft(); - if (solvers.length > 0) { - _execDelegateCall(deploymentSafe, safeMulticall, deploySetupCall3, deploymentSignature); - } + _execDelegateCall(deploymentSafe, safeMulticall, intentDeployTx, deploymentSignature); gasSplits[5] = gasleft(); - _execDelegateCall(deploymentSafe, safeMulticall, deploySetupCall4, deploymentSignature); + _execDelegateCall(deploymentSafe, safeMulticall, bridgeDeployTx, deploymentSignature); gasSplits[6] = gasleft(); + _execDelegateCall(deploymentSafe, safeMulticall, solversTx, deploymentSignature); + + gasSplits[7] = gasleft(); + _execDelegateCall(deploymentSafe, safeMulticall, changeOwnersTx, deploymentSignature); + + gasSplits[8] = gasleft(); vm.stopBroadcast(); _assertEip7825(gasSplits); diff --git a/script/SafeMultisend.sol b/script/SafeMultisend.sol index dca7d4698..7b81d95eb 100644 --- a/script/SafeMultisend.sol +++ b/script/SafeMultisend.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.25; import {Script} from "@forge-std/Script.sol"; +import {SafeConfig} from "./SafeConfig.sol"; interface ISafeExecute { enum Operation { @@ -34,6 +35,20 @@ interface ISafeOwners { abstract contract SafeMultisend is Script { bytes32 internal constant multicallHash = 0xa9865ac2d9c7a1591619b188c4d88167b50df6cc0c5327fcbd1c8c75f7c066ad; + bytes32 internal constant multicallHashEraVm = 0x064ddbf252714bcd4cb79f679e8c12df96d998ce07bbb13b3118c1dbf4a31942; + + function _assertMulticallCodehash(address safeMulticall) internal view { + require( + safeMulticall.codehash == (SafeConfig.isEraVm() ? multicallHashEraVm : multicallHash), + "Safe multicall codehash" + ); + } + + function _wrapSingleMultisend(bytes memory call) internal view returns (bytes memory) { + bytes[] memory calls = new bytes[](1); + calls[0] = call; + return _encodeMultisend(calls); + } function _encodeMultisend(bytes[] memory calls) internal view returns (bytes memory result) { // The Gnosis multicall contract uses a very obnoxious packed encoding diff --git a/sh/revive_chain.sh b/sh/revive_chain.sh index 353d8c87e..8981dc390 100755 --- a/sh/revive_chain.sh +++ b/sh/revive_chain.sh @@ -179,8 +179,8 @@ declare -r -a maybe_broadcast if [[ ${BROADCAST-no} = [Yy]es ]] ; then declare min_balance module_deployer_balance proxy_deployer_balance - # 24M = sum of per-tx gas from a Katana dry-run sim (~23.1M actual + small pad) - min_balance="$(bc <<<"$gas_price * 24000000 * $gas_estimate_multiplier / 100")" + # 25M ~ sum of per-tx gas from a Katana dry-run (~24M measured, padded for per-chain solver-count variance). + min_balance="$(bc <<<"$gas_price * 25000000 * $gas_estimate_multiplier / 100")" module_deployer_balance="$(cast balance --rpc-url "$rpc_url" "$module_deployer")" proxy_deployer_balance="$(cast balance --rpc-url "$rpc_url" "$proxy_deployer")" if (( $(bc <<<"$module_deployer_balance < $min_balance") )) ; then From f03592b16c8e9c93e7538a204132597536ce076b Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Tue, 26 May 2026 11:49:46 -0500 Subject: [PATCH 16/18] revive_chain.sh: derive per-sender balance floor from dry-run sim --- sh/revive_chain.sh | 97 ++++++++++++++++++++++++++-------------------- 1 file changed, 54 insertions(+), 43 deletions(-) diff --git a/sh/revive_chain.sh b/sh/revive_chain.sh index 8981dc390..81fbae444 100755 --- a/sh/revive_chain.sh +++ b/sh/revive_chain.sh @@ -171,56 +171,67 @@ declare -i gas_estimate_multiplier gas_estimate_multiplier="$(get_config gasMultiplierPercent)" declare -r -i gas_estimate_multiplier -declare -a maybe_broadcast=() -if [[ ${BROADCAST-no} = [Yy]es ]] ; then - maybe_broadcast+=(--broadcast) -fi -declare -r -a maybe_broadcast +export FOUNDRY_OPTIMIZER_RUNS=1000000 + +function _run_redeploy_script { + ICECOLDCOFFEE_DEPLOYER_KEY="$(get_secret iceColdCoffee key)" DEPLOYER_PROXY_DEPLOYER_KEY="$(get_secret deployer key)" \ + forge script \ + --slow \ + --no-storage-caching \ + --skip 'Flat.sol' \ + --skip 'CrossChainReceiverFactory.sol' \ + --skip 'src/allowanceholder/*.sol' \ + --skip 'src/chains/*.sol' \ + --skip 'src/core/*.sol' \ + --skip 'src/multicall/*.sol' \ + --skip 'src/utils/*.sol' \ + --gas-estimate-multiplier $gas_estimate_multiplier \ + --with-gas-price $gas_price \ + --chain $chainid \ + --rpc-url "$rpc_url" \ + -vvvvv \ + "$@" \ + --sig 'run(address,address,address,address,address,address,address,uint128,uint128,uint128,uint128,string,bytes,address[])' \ + $(get_config extraFlags) \ + $(get_config extraScriptFlags) \ + script/RedeploySettlers.s.sol:RedeploySettlers \ + "$module_deployer" "$proxy_deployer" "$ice_cold_coffee" "$deployer_proxy" "$deployment_safe" "$upgrade_safe" "$safe_multicall" \ + 2 3 4 5 \ + "$chain_display_name" "$constructor_args" "$(IFS=, ; echo "[${solvers[*]}]")" +} + +# Always run a dry-run simulation first: populates broadcast/.../dry-run/run-latest.json +# with per-tx gas the node would charge under whatever rules are currently active. +_run_redeploy_script if [[ ${BROADCAST-no} = [Yy]es ]] ; then - declare min_balance module_deployer_balance proxy_deployer_balance - # 25M ~ sum of per-tx gas from a Katana dry-run (~24M measured, padded for per-chain solver-count variance). - min_balance="$(bc <<<"$gas_price * 25000000 * $gas_estimate_multiplier / 100")" - module_deployer_balance="$(cast balance --rpc-url "$rpc_url" "$module_deployer")" - proxy_deployer_balance="$(cast balance --rpc-url "$rpc_url" "$proxy_deployer")" - if (( $(bc <<<"$module_deployer_balance < $min_balance") )) ; then - echo "Insufficient ETH at $module_deployer ($module_deployer_balance wei, need >= $min_balance wei)." >&2 + # Check each deployer's balance against its own subtotal of tx gas (sender-grouped from + # the dry-run). Auto-tracks active gas pricing including EIP-8037-style changes. + declare dry_run_json + dry_run_json="$project_root/broadcast/RedeploySettlers.s.sol/$chainid/dry-run/run-latest.json" + declare -r dry_run_json + if [ ! -f "$dry_run_json" ] ; then + echo "Dry-run JSON not found at $dry_run_json" >&2 exit 1 fi - if (( $(bc <<<"$proxy_deployer_balance < $min_balance") )) ; then - echo "Insufficient ETH at $proxy_deployer ($proxy_deployer_balance wei, need >= $min_balance wei)." >&2 - exit 1 - fi -fi -export FOUNDRY_OPTIMIZER_RUNS=1000000 + declare -A sender_gas=() + while IFS=$'\t' read -r from gas_hex ; do + sender_gas[$from]=$(( ${sender_gas[$from]:-0} + gas_hex )) + done < <(jq -r '.transactions[] | [.transaction.from, .transaction.gas] | @tsv' "$dry_run_json") + + declare sender required actual + for sender in "${!sender_gas[@]}" ; do + required="$(bc <<<"$gas_price * ${sender_gas[$sender]} * $gas_estimate_multiplier / 100")" + actual="$(cast balance --rpc-url "$rpc_url" "$sender")" + if (( $(bc <<<"$actual < $required") )) ; then + echo "Insufficient ETH at $sender ($actual wei, need >= $required wei for ${sender_gas[$sender]} gas)" >&2 + exit 1 + fi + done -ICECOLDCOFFEE_DEPLOYER_KEY="$(get_secret iceColdCoffee key)" DEPLOYER_PROXY_DEPLOYER_KEY="$(get_secret deployer key)" \ - forge script \ - --slow \ - --no-storage-caching \ - --skip 'Flat.sol' \ - --skip 'CrossChainReceiverFactory.sol' \ - --skip 'src/allowanceholder/*.sol' \ - --skip 'src/chains/*.sol' \ - --skip 'src/core/*.sol' \ - --skip 'src/multicall/*.sol' \ - --skip 'src/utils/*.sol' \ - --gas-estimate-multiplier $gas_estimate_multiplier \ - --with-gas-price $gas_price \ - --chain $chainid \ - --rpc-url "$rpc_url" \ - -vvvvv \ - "${maybe_broadcast[@]}" \ - --sig 'run(address,address,address,address,address,address,address,uint128,uint128,uint128,uint128,string,bytes,address[])' \ - $(get_config extraFlags) \ - $(get_config extraScriptFlags) \ - script/RedeploySettlers.s.sol:RedeploySettlers \ - "$module_deployer" "$proxy_deployer" "$ice_cold_coffee" "$deployer_proxy" "$deployment_safe" "$upgrade_safe" "$safe_multicall" \ - 2 3 4 5 \ - "$chain_display_name" "$constructor_args" "$(IFS=, ; echo "[${solvers[*]}]")" + _run_redeploy_script --broadcast -if [[ ${BROADCAST-no} = [Yy]es ]] ; then echo 'Settlers redeployed and Safes returned to canonical multisig ownership.' >&2 echo 'Run `sh/verify_settler.sh '"$chain_name"'` to verify on Etherscan.' >&2 fi From ab978adbbf4560d2c6f94972710adf333c86a21b Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Tue, 26 May 2026 12:55:35 -0500 Subject: [PATCH 17/18] Address review: hoist eraVmCompat to SafeMultisend, guard against silent dry-run parse failure --- script/DeploySafes.s.sol | 94 +--------------------------------------- script/SafeMultisend.sol | 93 +++++++++++++++++++++++++++++++++++++++ sh/revive_chain.sh | 9 +++- 3 files changed, 102 insertions(+), 94 deletions(-) diff --git a/script/DeploySafes.s.sol b/script/DeploySafes.s.sol index 27a55fcd3..b79dd5640 100644 --- a/script/DeploySafes.s.sol +++ b/script/DeploySafes.s.sol @@ -1,8 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.25; -import {SafeMultisend, ISafeExecute, ISafeOwners} from "./SafeMultisend.sol"; -import {Vm, VmSafe} from "@forge-std/Vm.sol"; +import {SafeMultisend, ISafeExecute, ISafeFactory, ISafeOwners} from "./SafeMultisend.sol"; import {AddressDerivation} from "src/utils/AddressDerivation.sol"; import {Create3} from "src/utils/Create3.sol"; import {ZeroExSettlerDeployerSafeModule} from "src/deployer/SafeModule.sol"; @@ -11,13 +10,6 @@ import {ERC1967UUPSProxy} from "src/proxy/ERC1967UUPSProxy.sol"; import {SafeConfig} from "./SafeConfig.sol"; import {SafeBytecodes} from "./SafeCode.sol"; -interface ISafeFactory { - function createProxyWithNonce(address singleton, bytes calldata initializer, uint256 saltNonce) - external - returns (address); - function proxyCreationCode() external view returns (bytes memory); -} - interface ISafeSetup { function setup( address[] calldata owners, @@ -62,90 +54,6 @@ contract DeploySafes is SafeMultisend { SafeBytecodes safeBytecodes; } - modifier eraVmCompat( - bool isEraVm, - uint256 privateKey, - ISafeExecute safe, - ISafeFactory safeFactory, - address safeSingleton, - address safeFallback, - address safeMulticall, - SafeBytecodes memory safeBytecodes - ) { - if (isEraVm) { - (VmSafe.CallerMode callerMode, address msgSender, address txOrigin) = vm.readCallers(); - require(callerMode != VmSafe.CallerMode.Broadcast); - if (callerMode == VmSafe.CallerMode.RecurrentBroadcast) { - require(msgSender == txOrigin); - require(msgSender == vm.addr(privateKey)); - vm.stopBroadcast(); - } - - bytes memory oldFactoryCode = address(safeFactory).code; - vm.etch(address(safeFactory), safeBytecodes.factoryCode); - bytes memory oldSingletonCode = safeSingleton.code; - vm.etch(safeSingleton, safeBytecodes.singletonCode); - bytes memory oldFallbackCode = safeFallback.code; - vm.etch(safeFallback, safeBytecodes.fallbackCode); - bytes memory oldMulticallCode = safeMulticall.code; - vm.etch(safeMulticall, safeBytecodes.multicallCode); - - bytes memory oldSafeCode; - if (address(safe) != address(0)) { - oldSafeCode = address(safe).code; - vm.etch(address(safe), safeBytecodes.proxyCode); - } - - vm.startPrank(msgSender, txOrigin); - vm.startStateDiffRecording(); - _; - uint256 gasUsed = vm.lastCallGas().gasTotalUsed; - Vm.AccountAccess[] memory accesses = vm.stopAndReturnStateDiff(); - vm.stopPrank(); - gasUsed = gasUsed * 6 / 5; - - Vm.AccountAccess memory theOneImportantCall; - for (uint256 i; i < accesses.length; i++) { - theOneImportantCall = accesses[i]; - if (theOneImportantCall.kind == VmSafe.AccountAccessKind.Call) { - require(theOneImportantCall.accessor == msgSender, "unexpected top-level call"); - for (uint256 j = i + 1; j < accesses.length; j++) { - Vm.AccountAccess memory jAA = accesses[j]; - if (jAA.kind == VmSafe.AccountAccessKind.Call) { - require(jAA.accessor != msgSender || jAA.account == address(vm), "duplicate top-level call"); - } - } - break; - } - } - - vm.etch(address(safeFactory), oldFactoryCode); - vm.etch(safeSingleton, oldSingletonCode); - vm.etch(safeFallback, oldFallbackCode); - vm.etch(safeMulticall, oldMulticallCode); - - if (address(safe) != address(0)) { - vm.etch(address(safe), oldSafeCode); - } - - if (callerMode == VmSafe.CallerMode.RecurrentBroadcast) { - vm.startBroadcast(privateKey); - - // repeat the call from the modified function, blindly, while broadcasting - { - address target = theOneImportantCall.account; - uint256 value = theOneImportantCall.value; - bytes memory data = theOneImportantCall.data; - assembly ("memory-safe") { - pop(call(gasUsed, target, value, add(0x20, data), mload(data), 0x00, 0x00)) - } - } - } - } else { - _; - } - } - function _createProxyWithNonce(SafeCompatConfig memory compatConfig, bytes memory initializer, uint256 saltNonce) private eraVmCompat( diff --git a/script/SafeMultisend.sol b/script/SafeMultisend.sol index 7b81d95eb..6e2ec0a66 100644 --- a/script/SafeMultisend.sol +++ b/script/SafeMultisend.sol @@ -2,7 +2,16 @@ pragma solidity ^0.8.25; import {Script} from "@forge-std/Script.sol"; +import {Vm, VmSafe} from "@forge-std/Vm.sol"; import {SafeConfig} from "./SafeConfig.sol"; +import {SafeBytecodes} from "./SafeCode.sol"; + +interface ISafeFactory { + function createProxyWithNonce(address singleton, bytes calldata initializer, uint256 saltNonce) + external + returns (address); + function proxyCreationCode() external view returns (bytes memory); +} interface ISafeExecute { enum Operation { @@ -135,4 +144,88 @@ abstract contract SafeMultisend is Script { require(gasSplits[i] + 15728639 > gasSplits[i - 1], "transaction is likely to exceed EIP-7825 limit"); } } + + modifier eraVmCompat( + bool isEraVm, + uint256 privateKey, + ISafeExecute safe, + ISafeFactory safeFactory, + address safeSingleton, + address safeFallback, + address safeMulticall, + SafeBytecodes memory safeBytecodes + ) { + if (isEraVm) { + (VmSafe.CallerMode callerMode, address msgSender, address txOrigin) = vm.readCallers(); + require(callerMode != VmSafe.CallerMode.Broadcast); + if (callerMode == VmSafe.CallerMode.RecurrentBroadcast) { + require(msgSender == txOrigin); + require(msgSender == vm.addr(privateKey)); + vm.stopBroadcast(); + } + + bytes memory oldFactoryCode = address(safeFactory).code; + vm.etch(address(safeFactory), safeBytecodes.factoryCode); + bytes memory oldSingletonCode = safeSingleton.code; + vm.etch(safeSingleton, safeBytecodes.singletonCode); + bytes memory oldFallbackCode = safeFallback.code; + vm.etch(safeFallback, safeBytecodes.fallbackCode); + bytes memory oldMulticallCode = safeMulticall.code; + vm.etch(safeMulticall, safeBytecodes.multicallCode); + + bytes memory oldSafeCode; + if (address(safe) != address(0)) { + oldSafeCode = address(safe).code; + vm.etch(address(safe), safeBytecodes.proxyCode); + } + + vm.startPrank(msgSender, txOrigin); + vm.startStateDiffRecording(); + _; + uint256 gasUsed = vm.lastCallGas().gasTotalUsed; + Vm.AccountAccess[] memory accesses = vm.stopAndReturnStateDiff(); + vm.stopPrank(); + gasUsed = gasUsed * 6 / 5; + + Vm.AccountAccess memory theOneImportantCall; + for (uint256 i; i < accesses.length; i++) { + theOneImportantCall = accesses[i]; + if (theOneImportantCall.kind == VmSafe.AccountAccessKind.Call) { + require(theOneImportantCall.accessor == msgSender, "unexpected top-level call"); + for (uint256 j = i + 1; j < accesses.length; j++) { + Vm.AccountAccess memory jAA = accesses[j]; + if (jAA.kind == VmSafe.AccountAccessKind.Call) { + require(jAA.accessor != msgSender || jAA.account == address(vm), "duplicate top-level call"); + } + } + break; + } + } + + vm.etch(address(safeFactory), oldFactoryCode); + vm.etch(safeSingleton, oldSingletonCode); + vm.etch(safeFallback, oldFallbackCode); + vm.etch(safeMulticall, oldMulticallCode); + + if (address(safe) != address(0)) { + vm.etch(address(safe), oldSafeCode); + } + + if (callerMode == VmSafe.CallerMode.RecurrentBroadcast) { + vm.startBroadcast(privateKey); + + // repeat the call from the modified function, blindly, while broadcasting + { + address target = theOneImportantCall.account; + uint256 value = theOneImportantCall.value; + bytes memory data = theOneImportantCall.data; + assembly ("memory-safe") { + pop(call(gasUsed, target, value, add(0x20, data), mload(data), 0x00, 0x00)) + } + } + } + } else { + _; + } + } } diff --git a/sh/revive_chain.sh b/sh/revive_chain.sh index 81fbae444..f4d42b6e3 100755 --- a/sh/revive_chain.sh +++ b/sh/revive_chain.sh @@ -218,7 +218,14 @@ if [[ ${BROADCAST-no} = [Yy]es ]] ; then declare -A sender_gas=() while IFS=$'\t' read -r from gas_hex ; do sender_gas[$from]=$(( ${sender_gas[$from]:-0} + gas_hex )) - done < <(jq -r '.transactions[] | [.transaction.from, .transaction.gas] | @tsv' "$dry_run_json") + done < <(jq -r '.transactions[] | [.transaction.from // error, .transaction.gas // error] | @tsv' "$dry_run_json") + + # `jq` failures inside the process substitution don't trip `set -e`; guard against a silently + # empty parse (malformed JSON, unexpected schema, etc.) so we never broadcast unchecked. + if (( ${#sender_gas[@]} == 0 )) ; then + echo "No transactions parsed from dry-run JSON; cannot verify balances" >&2 + exit 1 + fi declare sender required actual for sender in "${!sender_gas[@]}" ; do From c0adf8dea5b43b96e7c5365123318d6247b03737 Mon Sep 17 00:00:00 2001 From: Josh Levine <24902242+jparklev@users.noreply.github.com> Date: Tue, 26 May 2026 13:18:08 -0500 Subject: [PATCH 18/18] revive_chain.sh: fix balance-floor double-multiplier + partial-parse vulnerability --- sh/revive_chain.sh | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/sh/revive_chain.sh b/sh/revive_chain.sh index f4d42b6e3..b7044bdd6 100755 --- a/sh/revive_chain.sh +++ b/sh/revive_chain.sh @@ -215,13 +215,20 @@ if [[ ${BROADCAST-no} = [Yy]es ]] ; then exit 1 fi + # Buffer jq's output and check its exit code before iterating: `jq | while read` via process + # substitution would emit valid prefix rows to stdout before failing, leaving sender_gas + # partially populated and silently broadcasting an unchecked sender. + declare jq_out + if ! jq_out="$(jq -r '.transactions[] | [.transaction.from // error, .transaction.gas // error] | @tsv' "$dry_run_json")" ; then + echo "Failed to parse dry-run JSON at $dry_run_json" >&2 + exit 1 + fi + declare -A sender_gas=() while IFS=$'\t' read -r from gas_hex ; do sender_gas[$from]=$(( ${sender_gas[$from]:-0} + gas_hex )) - done < <(jq -r '.transactions[] | [.transaction.from // error, .transaction.gas // error] | @tsv' "$dry_run_json") + done <<<"$jq_out" - # `jq` failures inside the process substitution don't trip `set -e`; guard against a silently - # empty parse (malformed JSON, unexpected schema, etc.) so we never broadcast unchecked. if (( ${#sender_gas[@]} == 0 )) ; then echo "No transactions parsed from dry-run JSON; cannot verify balances" >&2 exit 1 @@ -229,7 +236,9 @@ if [[ ${BROADCAST-no} = [Yy]es ]] ; then declare sender required actual for sender in "${!sender_gas[@]}" ; do - required="$(bc <<<"$gas_price * ${sender_gas[$sender]} * $gas_estimate_multiplier / 100")" + # `sender_gas` already includes `gas_estimate_multiplier` (forge applies it during + # simulation before serializing tx.gas), so don't multiply again. + required="$(bc <<<"$gas_price * ${sender_gas[$sender]}")" actual="$(cast balance --rpc-url "$rpc_url" "$sender")" if (( $(bc <<<"$actual < $required") )) ; then echo "Insufficient ETH at $sender ($actual wei, need >= $required wei for ${sender_gas[$sender]} gas)" >&2