From 2b81bd22f8f6a0a85e83c7d7c490b7cd06457eef Mon Sep 17 00:00:00 2001 From: indigo Date: Thu, 27 Feb 2025 15:51:43 +0800 Subject: [PATCH 1/3] feat: new onchain method --- .gitmodules | 3 + README.md | 325 +++++++++++++++++++++++++--- foundry.toml | 1 + lib/safe-contracts | 1 + script/BatchScript.sol | 129 +++++++++++ script/ExecBatchWithApprovals.s.sol | 81 +++++++ script/TestBatch.s.sol | 104 +++++---- script/defi_example.s.sol | 137 ++++++++++++ 8 files changed, 703 insertions(+), 78 deletions(-) create mode 160000 lib/safe-contracts create mode 100644 script/BatchScript.sol create mode 100644 script/ExecBatchWithApprovals.s.sol create mode 100644 script/defi_example.s.sol diff --git a/.gitmodules b/.gitmodules index b05c85a..7ecc333 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,6 @@ [submodule "lib/ds-test"] path = lib/ds-test url = https://github.com/dapphub/ds-test +[submodule "lib/safe-contracts"] + path = lib/safe-contracts + url = https://github.com/safe-global/safe-contracts diff --git a/README.md b/README.md index 2a93ecb..c925b1b 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,307 @@ -# forge-safe: Gnosis Safe batch builder +# forge-safe: Gnosis Safe Batch Builder (On-Chain Approvals) -Forge Safe lets Forge users build Gnosis Safe batch transactions using Forge scripting in Solidity. Forge Safe builds a collection of encoded transactions, then sends them to the Gnosis [Safe Transaction Service](https://github.com/safe-global/safe-transaction-service) uses [surl](https://github.com/memester-xyz/surl). +This tool builds and executes Gnosis Safe batch transactions using on-chain approvals via the `approveHash` function. -The goal of this tool is to allow users to quickly build, validate and version control complex Safe batches as code. +## Prerequisites +- Foundry installed (`forge`) +- A deployed Gnosis Safe +- Network RPC URL and owner private keys -Inspired by [ape-safe](https://github.com/banteg/ape-safe) and Olymsig +## Setup +1. Clone the repo and install dependencies: + ```sh + git clone + cd forge-safe + forge install + ``` -## Supported Chains +## Workflow -Only supports Mainnet, Goerli and Arbitrum currently. If you'd like more to be supported, please make a PR. +### Build the Batch: +1. Customize `script/BatchScript.sol`'s run function with your transactions. +2. Run the script: + ```sh + forge script script/BatchScript.sol:BatchScript --rpc-url $RPC_URL -vvvv + ``` +3. Note the transaction hash output and check `data/batch.json`. -The only chains supported by Gnosis Safe API can be found [here](https://docs.safe.global/learn/safe-core/safe-core-api/available-services#safe-transaction-service). +### Approve the Hash: +Each Safe owner approves the hash: +```sh +cast send --private-key $OWNER_KEY $SAFE_ADDRESS "approveHash(bytes32)" $TX_HASH +``` + +### Execute the Transaction: +Once enough approvals are collected, execute: +```sh +SAFE=$SAFE_ADDRESS PRIVATE_KEY=$EXECUTOR_KEY forge script script/ExecBatchWithApprovals.s.sol:ExecBatchWithApprovals --rpc-url $RPC_URL --broadcast -vvvv +``` + +## Example Scripts + +### Basic Test Script + +For a quick test of the batch functionality, you can use the included test script: + +1. Update the `TEST_SAFE_ADDRESS` in `script/testbatch.s.sol` with your Safe address. +2. Run the test script: + ```sh + forge script script/testbatch.s.sol:TestBatch --rpc-url $RPC_URL -vvvv + ``` + +The test script includes two simple example transactions: +- A small ETH transfer (0.001 ETH) +- A token approval for 1000 DAI + +This provides a ready-to-use example to verify your setup before customizing for your own needs. + +### DeFi Example Script + +For a more advanced example with real DeFi protocols, use the DeFi example script: + +1. Update the `SAFE_ADDRESS` in `script/defi_example.s.sol` with your Safe address. +2. Run the DeFi example script: + ```sh + forge script script/defi_example.s.sol:DeFiExample --rpc-url $RPC_URL -vvvv + ``` + +This script demonstrates a realistic DeFi workflow by: +1. Approving USDC for Uniswap V3 +2. Swapping USDC to ETH using Uniswap V3 +3. Approving DAI for Aave V3 +4. Supplying DAI to Aave V3 + +All contract addresses used are real mainnet addresses, making this script ready for production use with minimal changes. + +## Detailed Example + +### Example Scenario +In this example, we'll create a batch transaction for a Gnosis Safe on Ethereum Mainnet that: +1. Sends 0.1 ETH to a recipient +2. Approves a token contract to spend 1000 USDC +3. Calls a custom contract function + +### Step 1: Configure BatchScript.sol + +```solidity +// Define interfaces for the contracts you'll interact with +interface IERC20 { + function approve(address spender, uint256 amount) external returns (bool); +} + +interface ICustomContract { + function setConfig(string calldata name, uint256 value) external; +} + +// In script/BatchScript.sol, modify the run function: +function run() external { + address safeAddress = 0x1234567890123456789012345678901234567890; // Your Safe address + + // Example 1: Send 0.1 ETH to a recipient + addToBatch( + 0xabcdef0123456789abcdef0123456789abcdef01, // Recipient address + 100000000000000000, // 0.1 ETH in wei + "" + ); + + // Example 2: Approve USDC spending + // USDC contract on Ethereum Mainnet: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 + addToBatch( + 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, // USDC contract + 0, + abi.encodeWithSelector( + IERC20.approve.selector, + 0x2222222222222222222222222222222222222222, // Spender address + 1000000000 // 1000 USDC (with 6 decimals) + ) + ); + + // Example 3: Call a custom contract function + addToBatch( + 0x3333333333333333333333333333333333333333, // Contract address + 0, + abi.encodeWithSelector( + ICustomContract.setConfig.selector, + "maxGasPrice", + 50000000000 // 50 gwei + ) + ); + + // Build the batch with your Safe address + buildBatch(safeAddress); +} +``` + +### Step 2: Run the Script + +```sh +# For Ethereum Mainnet +forge script script/BatchScript.sol:BatchScript --rpc-url https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY -vvvv +``` + +This will output something like: +``` +Transaction hash to approve: 0xabc123def456789abc123def456789abc123def456789abc123def456789abcd +``` -## Installation +### Step 3: Approve the Transaction Hash -```forge install ind-igo/forge-safe``` +Each Safe owner needs to approve the transaction hash: -## Usage +```sh +# Owner 1 +cast send --rpc-url https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY --private-key 0xYOUR_PRIVATE_KEY_1 0x1234567890123456789012345678901234567890 "approveHash(bytes32)" 0xabc123def456789abc123def456789abc123def456789abc123def456789abcd -Steps: +# Owner 2 +cast send --rpc-url https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY --private-key 0xYOUR_PRIVATE_KEY_2 0x1234567890123456789012345678901234567890 "approveHash(bytes32)" 0xabc123def456789abc123def456789abc123def456789abc123def456789abcd +``` + +### Step 4: Execute the Transaction + +Once enough owners have approved (meeting the Safe's threshold): + +```sh +SAFE=0x1234567890123456789012345678901234567890 PRIVATE_KEY=0xYOUR_PRIVATE_KEY forge script script/ExecBatchWithApprovals.s.sol:ExecBatchWithApprovals --rpc-url https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY --broadcast -vvvv +``` + +## Network-Specific MultiSend Addresses + +The MultiSend contract address varies by network. Here are the addresses for common networks: + +| Network | MultiSend Contract Address | +|------------------|---------------------------------------------| +| Ethereum Mainnet | 0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761 | +| Polygon | 0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761 | +| Arbitrum One | 0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761 | +| Optimism | 0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761 | +| BSC | 0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761 | +| Avalanche | 0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761 | +| Gnosis Chain | 0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761 | +| Base | 0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761 | +| Sepolia (testnet)| 0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761 | + +Update the `SAFE_MULTISEND_ADDRESS` constant in `BatchScript.sol` to match your target network. + +## Advanced Usage + +### Custom Operations (DelegateCall) + +By default, `addToBatch` creates CALL operations (operation type 0). If you need to use DELEGATECALL (operation type 1), you can modify the BatchScript.sol file to add this functionality: + +```solidity +// Add this function to BatchScript.sol +function addDelegateCallToBatch(address to, bytes memory data) public { + bytes memory encodedTxn = abi.encodePacked( + uint8(1), // operation (1 for DELEGATECALL) + to, + uint256(0), // value must be 0 for delegatecall + data.length, + data + ); + encodedTxns.push(encodedTxn); +} +``` -1. In your .env file - - Set `CHAIN` to the name of the chain your Safe is on - - Set `WALLET_TYPE` with `LOCAL` or `LEDGER` depending on your wallet -2. Import `BatchScript.sol` into your Forge script -3. add isBatch({SAFE_ADDRESS}) modifier to `function run()` -4. Call `addToBatch()` for each encoded call -5. After all encoded txs have been added, call `executeBatch()` with your Safe address and whether to send the transaction -6. Sign the batch data -7. ??? -8. Profit +Then use it in your run function: -```js -import {BatchScript} from "forge-safe/BatchScript.sol"; +```solidity +// Define an interface for the contract you want to delegate call +interface IExternalContract { + function doSomething() external; + function doSomethingElse(uint256 value) external; +} + +function run() external { + address safeAddress = 0x1234567890123456789012345678901234567890; + + // Regular call + addToBatch( + 0xabcdef0123456789abcdef0123456789abcdef01, + 0, + abi.encodeWithSelector(IExternalContract.doSomething.selector) + ); + + // Delegate call + addDelegateCallToBatch( + 0xfedcba9876543210fedcba9876543210fedcba98, + abi.encodeWithSelector(IExternalContract.doSomethingElse.selector, 123) + ); + + buildBatch(safeAddress); +} +``` -... -contract TestScript is BatchScript { -... -function run(bool send_) public isBatch(safe) { - string memory gm = "gm"; - address greeter = 0x1111; +### Working with ERC-20 Tokens - bytes memory txn = abi.encodeWithSelector( - Greeter.greet.selector, - gm - ); - addToBatch(greeter, 0, txn); +Here's how to include common ERC-20 token operations in your batch: - executeBatch(send_); +```solidity +// Define the ERC-20 interface +interface IERC20 { + function transfer(address to, uint256 amount) external returns (bool); + function approve(address spender, uint256 amount) external returns (bool); } + +// Transfer tokens +addToBatch( + 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, // Token contract address (USDC) + 0, + abi.encodeWithSelector( + IERC20.transfer.selector, + 0xRecipientAddress, // Recipient + 1000000 // Amount (adjust for token decimals) + ) +); + +// Approve tokens +addToBatch( + 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, // Token contract address (USDC) + 0, + abi.encodeWithSelector( + IERC20.approve.selector, + 0xSpenderAddress, // Spender (e.g., DEX contract) + 1000000 // Amount (adjust for token decimals) + ) +); ``` + +## Troubleshooting + +### Common Issues + +1. **Transaction Reverts During Execution** + - Check that the nonce in the batch matches the current Safe nonce + - Verify that enough owners have approved the transaction hash + - Ensure the executor has enough ETH to pay for gas + +2. **approveHash Transaction Fails** + - Make sure you're using the exact transaction hash output by the BatchScript + - Verify that the caller is an owner of the Safe + +3. **Invalid MultiSend Address** + - Verify that the MultiSend contract address is correct for your network + - Check the address checksum (capitalization matters in Ethereum addresses) + +4. **Batch Execution Fails with "Not Enough Approvals"** + - Ensure that enough owners have called approveHash + - Check that the Safe threshold hasn't changed since the batch was created + +### Debugging Tips + +- Use the `-vvvv` flag with forge script commands to see detailed output +- Check the Safe's on-chain state using: + ```sh + cast call $SAFE_ADDRESS "getThreshold()(uint256)" --rpc-url $RPC_URL + cast call $SAFE_ADDRESS "nonce()(uint256)" --rpc-url $RPC_URL + cast call $SAFE_ADDRESS "isOwner(address)(bool)" $OWNER_ADDRESS --rpc-url $RPC_URL + ``` +- Verify approval status for a transaction hash: + ```sh + cast call $SAFE_ADDRESS "approvedHashes(address,bytes32)(uint256)" $OWNER_ADDRESS $TX_HASH --rpc-url $RPC_URL + ``` + +## Notes +- If the Safe nonce changes (e.g., another transaction executes), rebuild the batch with the new nonce. +- The execution script checks if enough approvals have been collected before executing the transaction. +- Make sure to replace the placeholder addresses in the scripts with your actual Safe address. +- Never share your private keys. The examples above are for illustration only. diff --git a/foundry.toml b/foundry.toml index a8998be..07b1b1d 100644 --- a/foundry.toml +++ b/foundry.toml @@ -11,5 +11,6 @@ remappings = [ 'forge-std/=lib/forge-std/src', 'solmate/=lib/solmate/src', 'surl/=lib/surl/src', + 'safe-contracts/=lib/safe-contracts/contracts/', ] ffi = true diff --git a/lib/safe-contracts b/lib/safe-contracts new file mode 160000 index 0000000..2dd0763 --- /dev/null +++ b/lib/safe-contracts @@ -0,0 +1 @@ +Subproject commit 2dd0763301b95d5bf16b877267c818736733aa0b diff --git a/script/BatchScript.sol b/script/BatchScript.sol new file mode 100644 index 0000000..ae52057 --- /dev/null +++ b/script/BatchScript.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "forge-std/Script.sol"; +import "safe-contracts/Safe.sol"; +import "safe-contracts/libraries/Enum.sol"; + +/// @title Interface for the MultiSend contract +interface IMultiSend { + function multiSend(bytes memory transactions) external payable; +} + +/// @title BatchScript - A script for creating and executing Gnosis Safe batch transactions +/// @notice This contract provides utilities for building batch transactions with on-chain approvals +contract BatchScript is Script { + // NOTE: This is the MultiSend v1.4.1 contract address for most chains. Modify if using a custom deployment. + address constant SAFE_MULTISEND_ADDRESS = 0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526; + + /// @notice Array to store encoded transactions for the batch + bytes[] internal encodedTxns; + + /// @notice Struct representing a Safe batch transaction + struct Batch { + address to; + uint256 value; + bytes data; + Enum.Operation operation; + uint256 safeTxGas; + uint256 baseGas; + uint256 gasPrice; + address gasToken; + address payable refundReceiver; + uint256 nonce; + } + + /// @notice Add a standard call operation to the batch + /// @param to The target address for the transaction + /// @param value The amount of ETH to send + /// @param data The calldata for the transaction + function addToBatch(address to, uint256 value, bytes memory data) public { + bytes memory encodedTxn = abi.encodePacked( + uint8(0), // operation (0 for CALL) + to, + value, + data.length, + data + ); + encodedTxns.push(encodedTxn); + } + + /// @notice Add a delegate call operation to the batch + /// @param to The target address for the delegate call + /// @param data The calldata for the transaction + function addDelegateCallToBatch(address to, bytes memory data) public { + bytes memory encodedTxn = abi.encodePacked( + uint8(1), // operation (1 for DELEGATECALL) + to, + uint256(0), // value must be 0 for delegatecall + data.length, + data + ); + encodedTxns.push(encodedTxn); + } + + /// @notice Build the batch transaction and save to JSON + /// @param safe_ The address of the Gnosis Safe + function buildBatch(address safe_) internal { + require(safe_ != address(0), "Safe address cannot be zero"); + + Safe SAFE = Safe(payable(safe_)); + uint256 nonce = SAFE.nonce(); + Batch memory batch = _createBatch(nonce); + + // Serialize batch to JSON + string memory json = ""; + json = vm.serializeAddress("batch", "to", batch.to); + json = vm.serializeUint("batch", "value", batch.value); + json = vm.serializeBytes("batch", "data", batch.data); + json = vm.serializeUint("batch", "operation", uint8(batch.operation)); + json = vm.serializeUint("batch", "safeTxGas", batch.safeTxGas); + json = vm.serializeUint("batch", "baseGas", batch.baseGas); + json = vm.serializeUint("batch", "gasPrice", batch.gasPrice); + json = vm.serializeAddress("batch", "gasToken", batch.gasToken); + json = vm.serializeAddress("batch", "refundReceiver", batch.refundReceiver); + json = vm.serializeUint("batch", "nonce", batch.nonce); + vm.writeJson(json, "./data/batch.json"); + + // Compute and output transaction hash + bytes32 txHash = SAFE.getTransactionHash( + batch.to, batch.value, batch.data, batch.operation, + batch.safeTxGas, batch.baseGas, batch.gasPrice, + batch.gasToken, batch.refundReceiver, batch.nonce + ); + console2.log("Transaction hash to approve:", vm.toString(txHash)); + } + + /// @notice Create a batch transaction from collected transactions + /// @param nonce The Safe's current nonce + /// @return batch The constructed batch transaction + function _createBatch(uint256 nonce) private view returns (Batch memory batch) { + batch.to = SAFE_MULTISEND_ADDRESS; + batch.value = 0; + batch.operation = Enum.Operation.DelegateCall; + bytes memory data; + for (uint256 i; i < encodedTxns.length; ++i) { + data = bytes.concat(data, encodedTxns[i]); + } + batch.data = abi.encodeWithSelector(IMultiSend.multiSend.selector, data); + batch.safeTxGas = 0; + batch.baseGas = 0; + batch.gasPrice = 0; + batch.gasToken = address(0); + batch.refundReceiver = payable(address(0)); + batch.nonce = nonce; + } + + /// @notice Template run function - override this in derived contracts + /// @dev Implement this function with your specific transactions + function run() external virtual { + // Replace with your actual Safe address + address safeAddress = 0x0000000000000000000000000000000000000000; + + // Add your transactions to the batch + // Example: addToBatch(targetAddress, ethValue, callData); + + // Build and output the batch transaction + buildBatch(safeAddress); + } +} \ No newline at end of file diff --git a/script/ExecBatchWithApprovals.s.sol b/script/ExecBatchWithApprovals.s.sol new file mode 100644 index 0000000..1c5143e --- /dev/null +++ b/script/ExecBatchWithApprovals.s.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Script, console2} from "forge-std/Script.sol"; +import {stdJson} from "forge-std/StdJson.sol"; +import {Safe} from "safe-contracts/Safe.sol"; +import {Enum} from "safe-contracts/libraries/Enum.sol"; + +contract ExecBatchWithApprovals is Script { + using stdJson for string; + + struct Batch { + address to; + uint256 value; + bytes data; + Enum.Operation operation; + uint256 safeTxGas; + uint256 baseGas; + uint256 gasPrice; + address gasToken; + address payable refundReceiver; + uint256 nonce; + } + + function run() public { + string memory json = vm.readFile("./data/batch.json"); + Batch memory batch; + batch.to = json.readAddress(".to"); + batch.value = json.readUint(".value"); + batch.data = json.readBytes(".data"); + batch.operation = Enum.Operation(json.readUint(".operation")); + batch.safeTxGas = json.readUint(".safeTxGas"); + batch.baseGas = json.readUint(".baseGas"); + batch.gasPrice = json.readUint(".gasPrice"); + batch.gasToken = json.readAddress(".gasToken"); + batch.refundReceiver = payable(json.readAddress(".refundReceiver")); + batch.nonce = json.readUint(".nonce"); + + address safeAddress = vm.envAddress("SAFE"); + Safe SAFE = Safe(payable(safeAddress)); + + // Compute transaction hash + bytes32 txHash = SAFE.getTransactionHash( + batch.to, batch.value, batch.data, batch.operation, + batch.safeTxGas, batch.baseGas, batch.gasPrice, + batch.gasToken, batch.refundReceiver, batch.nonce + ); + + // Get the sender address (the one executing the transaction) + address sender = vm.addr(vm.envUint("PRIVATE_KEY")); + + // Check if the transaction has enough approvals + uint256 threshold = SAFE.getThreshold(); + uint256 approvalCount = 0; + + // Count approvals by checking each owner + address[] memory owners = SAFE.getOwners(); + for (uint256 i = 0; i < owners.length; i++) { + if (SAFE.approvedHashes(owners[i], txHash) == 1) { + approvalCount++; + } + } + + require(approvalCount >= threshold, "Not enough approvals"); + + console2.log("Executing transaction with hash:", vm.toString(txHash)); + console2.log("Approvals:", approvalCount, "Threshold:", threshold); + + // Execute the transaction + vm.startBroadcast(vm.envUint("PRIVATE_KEY")); + bool success = SAFE.execTransaction( + batch.to, batch.value, batch.data, batch.operation, + batch.safeTxGas, batch.baseGas, batch.gasPrice, + batch.gasToken, batch.refundReceiver, bytes("") + ); + vm.stopBroadcast(); + + require(success, "Transaction execution failed"); + console2.log("Transaction executed successfully"); + } +} \ No newline at end of file diff --git a/script/TestBatch.s.sol b/script/TestBatch.s.sol index 35c97ae..1ef0442 100644 --- a/script/TestBatch.s.sol +++ b/script/TestBatch.s.sol @@ -1,53 +1,71 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.17; +pragma solidity ^0.8.0; -import {BatchScript} from "src/BatchScript.sol"; +import "./BatchScript.sol"; -interface ICrossChainBridge { - /// @dev path_ = abi.encodePacked(remoteAddress, localAddress) - function setTrustedRemote( - uint16 srcChainId_, - bytes calldata path_ - ) external; +// A simple ERC20 interface for testing token approvals +interface IERC20Test { + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); + function approve(address spender, uint256 amount) external returns (bool); } -interface IKernel { - function executeAction(uint8 action_, address target_) external; -} - -/// @notice A test for Gnosis Safe batching script -/// @dev GOERLI +/** + * @title TestBatch + * @dev A simple script to test the Gnosis Safe batch functionality with a basic test transaction. + * To run: forge script script/testbatch.s.sol:TestBatch --rpc-url $RPC_URL -vvvv + */ contract TestBatch is BatchScript { - address localBridgeAddr = 0xefffab0Aa61828c4af926E039ee754e3edE10dAc; // Goerli bridge - address remoteBridgeAddr = 0xB01432c01A9128e3d1d70583eA873477B2a1f5e1; // Arb goerli bridge - uint16 lzChainId = 10143; - - /// @notice The main script entrypoint - function run(bool send_) external isBatch(0x84C0C005cF574D0e5C602EA7b366aE9c707381E0) { - - IKernel kernel = IKernel(0xDb7cf68154bd422dF5196D90285ceA057786b4c3); - ICrossChainBridge bridge = ICrossChainBridge(localBridgeAddr); - - // Start batch - // Install on kernel - // kernel.executeAction(kernel.Action.ActivatePolicy, policy) - bytes memory txn1 = abi.encodeWithSelector( - IKernel.executeAction.selector, - 2, - address(bridge) + // Update this with your actual Safe address for testing + address constant TEST_SAFE_ADDRESS = 0x0000000000000000000000000000000000000000; + + + function run() external override { + // Use a test Safe address (replace with your actual Safe for real testing) + address safeAddress = TEST_SAFE_ADDRESS; + + console2.log("Building test batch for Safe:", safeAddress); + console2.log("----------------------------------------"); + + // Example 1: Simple ETH transfer + // Sends a small amount of ETH (0.001 ETH) to a test address + address testRecipient = 0x0000000000000000000000000000000000000001; + uint256 testAmount = 0.001 ether; // 0.001 ETH in wei + + console2.log("Adding ETH transfer:"); + console2.log(" To:", testRecipient); + console2.log(" Amount:", testAmount); + + addToBatch( + testRecipient, + testAmount, + "" // Empty call data for a simple ETH transfer ); - addToBatch(address(kernel), 0, txn1); - - // Call some initialize function on the contract - // bridge.setTrustedRemote(vm.envUint("CHAIN_ID"), abi.encodePacked(remoteBridgeAddr, localBridgeAddr)); - bytes memory txn2 = abi.encodeWithSignature( - "setTrustedRemote(uint16,bytes)", - lzChainId, - abi.encodePacked(remoteBridgeAddr, localBridgeAddr) + + // Example 2: Token approval (using DAI as an example) + // Note: This is just for demonstration, replace with real token addresses + address daiToken = 0x6B175474E89094C44Da98b954EedeAC495271d0F; // DAI on Ethereum Mainnet + address testSpender = 0x0000000000000000000000000000000000000002; + uint256 approvalAmount = 1000 * 1e18; // 1000 DAI (with 18 decimals) + + console2.log("Adding token approval:"); + console2.log(" Token:", daiToken); + console2.log(" Spender:", testSpender); + console2.log(" Amount:", approvalAmount); + + addToBatch( + daiToken, + 0, + abi.encodeWithSelector( + IERC20Test.approve.selector, + testSpender, + approvalAmount + ) ); - addToBatch(address(bridge), txn2); - - // Execute batch - executeBatch(send_); + + // Build the batch with the Safe address + console2.log("----------------------------------------"); + console2.log("Building batch transaction..."); + buildBatch(safeAddress); } } diff --git a/script/defi_example.s.sol b/script/defi_example.s.sol new file mode 100644 index 0000000..e2672ee --- /dev/null +++ b/script/defi_example.s.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "./BatchScript.sol"; + +// Interfaces for DeFi protocols +interface IERC20 { + function approve(address spender, uint256 amount) external returns (bool); +} + +interface IUniswapV3Router { + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + + function exactInputSingle(ExactInputSingleParams calldata params) external returns (uint256 amountOut); +} + +interface IAaveV3Pool { + function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external; +} + +/** + * @title DeFiExample + * @dev A script demonstrating real-world DeFi interactions using Gnosis Safe batch transactions. + * To run: forge script script/defi_example.s.sol:DeFiExample --rpc-url $RPC_URL -vvvv + */ +contract DeFiExample is BatchScript { + // Update this with your actual Safe address + address constant SAFE_ADDRESS = 0x0000000000000000000000000000000000000000; + + // Token addresses on Ethereum Mainnet + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + + // DeFi protocol addresses + address constant UNISWAP_V3_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564; + address constant AAVE_V3_POOL = 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2; + + function run() external override { + console2.log("Building DeFi example batch for Safe:", SAFE_ADDRESS); + console2.log("----------------------------------------"); + + // Example 1: Approve USDC for Uniswap V3 Router + uint256 usdcApprovalAmount = 10000 * 1e6; // 10,000 USDC (6 decimals) + + console2.log("1. Adding USDC approval for Uniswap V3:"); + console2.log(" Token:", USDC); + console2.log(" Spender:", UNISWAP_V3_ROUTER); + console2.log(" Amount:", usdcApprovalAmount); + + addToBatch( + USDC, + 0, + abi.encodeWithSelector( + IERC20.approve.selector, + UNISWAP_V3_ROUTER, + usdcApprovalAmount + ) + ); + + // Example 2: Swap USDC for ETH on Uniswap V3 + // exactInputSingle parameters for swapping USDC to ETH + console2.log("2. Swapping USDC for ETH on Uniswap V3"); + + IUniswapV3Router.ExactInputSingleParams memory params = IUniswapV3Router.ExactInputSingleParams({ + tokenIn: USDC, + tokenOut: WETH, + fee: 3000, // 0.3% + recipient: address(this), + deadline: block.timestamp + 1800, // 30 minutes + amountIn: 5000 * 1e6, // 5,000 USDC + amountOutMinimum: 0, // 0 for example, use a real value in production + sqrtPriceLimitX96: 0 // 0 for no limit + }); + + bytes memory swapCalldata = abi.encodeWithSelector( + IUniswapV3Router.exactInputSingle.selector, + params + ); + + addToBatch( + UNISWAP_V3_ROUTER, + 0, + swapCalldata + ); + + // Example 3: Supply DAI to Aave V3 + // First, approve DAI for Aave + uint256 daiSupplyAmount = 2000 * 1e18; // 2,000 DAI (18 decimals) + + console2.log("3. Approving DAI for Aave V3:"); + console2.log(" Token:", DAI); + console2.log(" Spender:", AAVE_V3_POOL); + console2.log(" Amount:", daiSupplyAmount); + + addToBatch( + DAI, + 0, + abi.encodeWithSelector( + IERC20.approve.selector, + AAVE_V3_POOL, + daiSupplyAmount + ) + ); + + // Then, supply DAI to Aave + console2.log("4. Supplying DAI to Aave V3"); + + bytes memory supplyCalldata = abi.encodeWithSelector( + IAaveV3Pool.supply.selector, + DAI, // asset + daiSupplyAmount, // amount + SAFE_ADDRESS, // onBehalfOf + 0 // referralCode + ); + + addToBatch( + AAVE_V3_POOL, + 0, + supplyCalldata + ); + + // Build the batch with the Safe address + console2.log("----------------------------------------"); + console2.log("Building batch transaction..."); + buildBatch(SAFE_ADDRESS); + } +} \ No newline at end of file From 848f71f9fdbf9af2f4d6afba0a78927bb30a440b Mon Sep 17 00:00:00 2001 From: indigo Date: Thu, 27 Feb 2025 20:05:57 +0800 Subject: [PATCH 2/3] ww3 mode --- .gitmodules | 9 -- README.md | 45 ++++++ data/batch.json | 12 ++ deps/IMultiSend.sol | 8 + deps/ISafe.sol | 49 +++++++ deps/Surl.sol | 115 +++++++++++++++ foundry.toml | 6 +- lib/safe-contracts | 1 - lib/solmate | 1 - lib/surl | 1 - script/TestAuthBatch.s.sol | 2 + ...TestBatch.s.sol => TestOnchainBatch.s.sol} | 31 ++-- script/defi_example.s.sol | 137 ------------------ shell/approveBatch.sh | 5 + shell/testBatch.sh | 5 + .../BatchOnchainScript.sol | 105 ++++++++++---- src/{BatchScript.sol => BatchSTSScript.sol} | 4 +- .../ExecBatchOnchain.s.sol | 22 ++- test/LocalTransactionTester.t.sol | 101 +++++++++++++ 19 files changed, 450 insertions(+), 209 deletions(-) create mode 100644 data/batch.json create mode 100644 deps/IMultiSend.sol create mode 100644 deps/ISafe.sol create mode 100644 deps/Surl.sol delete mode 160000 lib/safe-contracts delete mode 160000 lib/solmate delete mode 160000 lib/surl rename script/{TestBatch.s.sol => TestOnchainBatch.s.sol} (68%) delete mode 100644 script/defi_example.s.sol create mode 100644 shell/approveBatch.sh create mode 100755 shell/testBatch.sh rename script/BatchScript.sol => src/BatchOnchainScript.sol (58%) rename src/{BatchScript.sol => BatchSTSScript.sol} (99%) rename script/ExecBatchWithApprovals.s.sol => src/ExecBatchOnchain.s.sol (80%) create mode 100644 test/LocalTransactionTester.t.sol diff --git a/.gitmodules b/.gitmodules index 7ecc333..4b4c42b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,15 +1,6 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std -[submodule "lib/solmate"] - path = lib/solmate - url = https://github.com/transmissions11/solmate -[submodule "lib/surl"] - path = lib/surl - url = https://github.com/memester-xyz/surl [submodule "lib/ds-test"] path = lib/ds-test url = https://github.com/dapphub/ds-test -[submodule "lib/safe-contracts"] - path = lib/safe-contracts - url = https://github.com/safe-global/safe-contracts diff --git a/README.md b/README.md index c925b1b..fcffe72 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,51 @@ Update the `SAFE_MULTISEND_ADDRESS` constant in `BatchScript.sol` to match your ## Advanced Usage +### Local Testing of Transactions + +The BatchScript now includes functionality to locally test transactions before adding them to the batch. This allows you to verify that transactions will work as expected in the current chain state: + +```solidity +// Enable or disable local testing globally +setLocalTesting(true); // or false + +// Add a transaction with default testing behavior (follows global setting) +bool success = addToBatch( + tokenAddress, + 0, + abi.encodeWithSelector(IERC20.transfer.selector, recipient, amount) +); + +// Check if the transaction succeeded +if (success) { + console2.log("Transaction will succeed"); +} else { + console2.log("Transaction will fail"); +} + +// Override global setting for a specific transaction +addToBatch( + contractAddress, + 0, + callData, + true // Force enable testing for this transaction +); +``` + +To run a comprehensive example that demonstrates local testing: + +```sh +forge script script/LocalTestingExample.s.sol:LocalTestingExample --rpc-url $RPC_URL -vvvv +``` + +This feature is particularly useful for: +- Validating complex DeFi interactions before execution +- Ensuring that transactions are called in the correct order +- Testing that your contracts have sufficient balances for transfers +- Verifying permission settings before attempting operations + +**Note:** Some transactions may fail in the local test but succeed in the actual execution or vice versa, depending on differences between your local fork state and the real chain state when the batch is executed. + ### Custom Operations (DelegateCall) By default, `addToBatch` creates CALL operations (operation type 0). If you need to use DELEGATECALL (operation type 1), you can modify the BatchScript.sol file to add this functionality: diff --git a/data/batch.json b/data/batch.json new file mode 100644 index 0000000..16ac744 --- /dev/null +++ b/data/batch.json @@ -0,0 +1,12 @@ +{ + "baseGas": 0, + "data": "0x8d80ff0a000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000ee0016408b5393e7a5053736d9166be3505a80c07a2500000000000000000000000000000000000000000000000000005af3107a4000000000000000000000000000000000000000000000000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000016408b5393e7a5053736d9166be3505a80c07a250000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000", + "gasPrice": 0, + "gasToken": "0x0000000000000000000000000000000000000000", + "nonce": 3, + "operation": 1, + "refundReceiver": "0x0000000000000000000000000000000000000000", + "safeTxGas": 0, + "to": "0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526", + "value": 0 +} \ No newline at end of file diff --git a/deps/IMultiSend.sol b/deps/IMultiSend.sol new file mode 100644 index 0000000..f99c439 --- /dev/null +++ b/deps/IMultiSend.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title Interface for the MultiSend contract +interface IMultiSend { + function multiSend(bytes memory transactions) external payable; +} + diff --git a/deps/ISafe.sol b/deps/ISafe.sol new file mode 100644 index 0000000..5775be3 --- /dev/null +++ b/deps/ISafe.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/** + * @title ISafe - A multisignature wallet interface with support for confirmations using signed messages based on EIP-712. + * @author @safe-global/safe-protocol + */ +interface ISafe { + + enum Operation { + CALL, + DELEGATECALL + } + + function getTransactionHash( + address to, + uint256 value, + bytes calldata data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + uint256 _nonce + ) external view returns (bytes32); + + function execTransaction( + address to, + uint256 value, + bytes calldata data, + Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + bytes memory signatures + ) external payable returns (bool success); + + + function nonce() external view returns (uint256); + + function getThreshold() external view returns (uint256); + + function getOwners() external view returns (address[] memory); + + function approvedHashes(address owner, bytes32 hash) external view returns (uint256); +} \ No newline at end of file diff --git a/deps/Surl.sol b/deps/Surl.sol new file mode 100644 index 0000000..a49f746 --- /dev/null +++ b/deps/Surl.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Vm} from "forge-std/Vm.sol"; + +library Surl { + Vm constant vm = Vm(address(bytes20(uint160(uint256(keccak256("hevm cheat code")))))); + + function get(string memory self) internal returns (uint256 status, bytes memory data) { + string[] memory empty = new string[](0); + return get(self, empty); + } + + function get(string memory self, string[] memory headers) internal returns (uint256 status, bytes memory data) { + return curl(self, headers, "", "GET"); + } + + function del(string memory self) internal returns (uint256 status, bytes memory data) { + string[] memory empty = new string[](0); + return curl(self, empty, "", "DELETE"); + } + + function del(string memory self, string memory body) internal returns (uint256 status, bytes memory data) { + string[] memory empty = new string[](0); + return curl(self, empty, body, "DELETE"); + } + + function del(string memory self, string[] memory headers, string memory body) + internal + returns (uint256 status, bytes memory data) + { + return curl(self, headers, body, "DELETE"); + } + + function patch(string memory self) internal returns (uint256 status, bytes memory data) { + string[] memory empty = new string[](0); + return curl(self, empty, "", "PATCH"); + } + + function patch(string memory self, string memory body) internal returns (uint256 status, bytes memory data) { + string[] memory empty = new string[](0); + return curl(self, empty, body, "PATCH"); + } + + function patch(string memory self, string[] memory headers, string memory body) + internal + returns (uint256 status, bytes memory data) + { + return curl(self, headers, body, "PATCH"); + } + + function post(string memory self) internal returns (uint256 status, bytes memory data) { + string[] memory empty = new string[](0); + return curl(self, empty, "", "POST"); + } + + function post(string memory self, string memory body) internal returns (uint256 status, bytes memory data) { + string[] memory empty = new string[](0); + return curl(self, empty, body, "POST"); + } + + function post(string memory self, string[] memory headers, string memory body) + internal + returns (uint256 status, bytes memory data) + { + return curl(self, headers, body, "POST"); + } + + function put(string memory self) internal returns (uint256 status, bytes memory data) { + string[] memory empty = new string[](0); + return curl(self, empty, "", "PUT"); + } + + function put(string memory self, string memory body) internal returns (uint256 status, bytes memory data) { + string[] memory empty = new string[](0); + return curl(self, empty, body, "PUT"); + } + + function put(string memory self, string[] memory headers, string memory body) + internal + returns (uint256 status, bytes memory data) + { + return curl(self, headers, body, "PUT"); + } + + function curl(string memory self, string[] memory headers, string memory body, string memory method) + internal + returns (uint256 status, bytes memory data) + { + string memory scriptStart = 'response=$(curl -s -w "\\n%{http_code}" '; + string memory scriptEnd = '); status=$(tail -n1 <<< "$response"); data=$(sed "$ d" <<< "$response");data=$(echo "$data" | tr -d "\\n"); cast abi-encode "response(uint256,string)" "$status" "$data";'; + + string memory curlParams = ""; + + for (uint256 i = 0; i < headers.length; i++) { + curlParams = string.concat(curlParams, '-H "', headers[i], '" '); + } + + curlParams = string.concat(curlParams, " -X ", method, " "); + + if (bytes(body).length > 0) { + curlParams = string.concat(curlParams, ' -d \'', body, '\' '); + } + + string memory quotedURL = string.concat('"', self, '"'); + + string[] memory inputs = new string[](3); + inputs[0] = "bash"; + inputs[1] = "-c"; + inputs[2] = string.concat(scriptStart, curlParams, quotedURL, scriptEnd, ""); + bytes memory res = vm.ffi(inputs); + + (status, data) = abi.decode(res, (uint256, bytes)); + } +} \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index 07b1b1d..4f45464 100644 --- a/foundry.toml +++ b/foundry.toml @@ -10,7 +10,9 @@ remappings = [ 'ds-test/=lib/ds-test/src/', 'forge-std/=lib/forge-std/src', 'solmate/=lib/solmate/src', - 'surl/=lib/surl/src', - 'safe-contracts/=lib/safe-contracts/contracts/', ] ffi = true +#fs_permission = [{ access = "write", path = "data" }] +fs_permissions = [{ access = "write", path = "./"}] + + diff --git a/lib/safe-contracts b/lib/safe-contracts deleted file mode 160000 index 2dd0763..0000000 --- a/lib/safe-contracts +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2dd0763301b95d5bf16b877267c818736733aa0b diff --git a/lib/solmate b/lib/solmate deleted file mode 160000 index bfc9c25..0000000 --- a/lib/solmate +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfc9c25865a274a7827fea5abf6e4fb64fc64e6c diff --git a/lib/surl b/lib/surl deleted file mode 160000 index 88cf6c6..0000000 --- a/lib/surl +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 88cf6c658fa3478594ef25f45516735ed10542f5 diff --git a/script/TestAuthBatch.s.sol b/script/TestAuthBatch.s.sol index 1507921..9fb2ef1 100644 --- a/script/TestAuthBatch.s.sol +++ b/script/TestAuthBatch.s.sol @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.17; +/* import {BatchScript} from "src/BatchScript.sol"; @@ -35,3 +36,4 @@ contract TestAuthBatch is BatchScript { executeBatch(send_); } } +*/ diff --git a/script/TestBatch.s.sol b/script/TestOnchainBatch.s.sol similarity index 68% rename from script/TestBatch.s.sol rename to script/TestOnchainBatch.s.sol index 1ef0442..1f73a50 100644 --- a/script/TestBatch.s.sol +++ b/script/TestOnchainBatch.s.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import "./BatchScript.sol"; +import "../src/BatchOnchainScript.sol"; // A simple ERC20 interface for testing token approvals interface IERC20Test { @@ -11,32 +11,30 @@ interface IERC20Test { } /** - * @title TestBatch + * @title TestOnchainBatch * @dev A simple script to test the Gnosis Safe batch functionality with a basic test transaction. - * To run: forge script script/testbatch.s.sol:TestBatch --rpc-url $RPC_URL -vvvv + * To run: forge script script/TestOnchainBatch.s.sol:TestOnchainBatch --rpc-url $RPC_URL -vvvv */ -contract TestBatch is BatchScript { - // Update this with your actual Safe address for testing - address constant TEST_SAFE_ADDRESS = 0x0000000000000000000000000000000000000000; +contract TestOnchainBatch is BatchOnchainScript { - - function run() external override { + function run() external { // Use a test Safe address (replace with your actual Safe for real testing) - address safeAddress = TEST_SAFE_ADDRESS; + address safeAddress = vm.envAddress("SAFE_ADDRESS"); console2.log("Building test batch for Safe:", safeAddress); console2.log("----------------------------------------"); // Example 1: Simple ETH transfer // Sends a small amount of ETH (0.001 ETH) to a test address - address testRecipient = 0x0000000000000000000000000000000000000001; - uint256 testAmount = 0.001 ether; // 0.001 ETH in wei + address testRecipient = vm.envAddress("TEST_RECIPIENT"); + uint256 testAmount = 0.0001 ether; // 0.0001 ETH in wei console2.log("Adding ETH transfer:"); console2.log(" To:", testRecipient); console2.log(" Amount:", testAmount); addToBatch( + safeAddress, testRecipient, testAmount, "" // Empty call data for a simple ETH transfer @@ -44,17 +42,18 @@ contract TestBatch is BatchScript { // Example 2: Token approval (using DAI as an example) // Note: This is just for demonstration, replace with real token addresses - address daiToken = 0x6B175474E89094C44Da98b954EedeAC495271d0F; // DAI on Ethereum Mainnet - address testSpender = 0x0000000000000000000000000000000000000002; - uint256 approvalAmount = 1000 * 1e18; // 1000 DAI (with 18 decimals) + address wethToken = 0x4200000000000000000000000000000000000006; // WETH on Base + address testSpender = testRecipient; + uint256 approvalAmount = 1 wei; // 1 wei WETH (with 18 decimals) console2.log("Adding token approval:"); - console2.log(" Token:", daiToken); + console2.log(" Token:", wethToken); console2.log(" Spender:", testSpender); console2.log(" Amount:", approvalAmount); addToBatch( - daiToken, + safeAddress, + wethToken, 0, abi.encodeWithSelector( IERC20Test.approve.selector, diff --git a/script/defi_example.s.sol b/script/defi_example.s.sol deleted file mode 100644 index e2672ee..0000000 --- a/script/defi_example.s.sol +++ /dev/null @@ -1,137 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "./BatchScript.sol"; - -// Interfaces for DeFi protocols -interface IERC20 { - function approve(address spender, uint256 amount) external returns (bool); -} - -interface IUniswapV3Router { - struct ExactInputSingleParams { - address tokenIn; - address tokenOut; - uint24 fee; - address recipient; - uint256 deadline; - uint256 amountIn; - uint256 amountOutMinimum; - uint160 sqrtPriceLimitX96; - } - - function exactInputSingle(ExactInputSingleParams calldata params) external returns (uint256 amountOut); -} - -interface IAaveV3Pool { - function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external; -} - -/** - * @title DeFiExample - * @dev A script demonstrating real-world DeFi interactions using Gnosis Safe batch transactions. - * To run: forge script script/defi_example.s.sol:DeFiExample --rpc-url $RPC_URL -vvvv - */ -contract DeFiExample is BatchScript { - // Update this with your actual Safe address - address constant SAFE_ADDRESS = 0x0000000000000000000000000000000000000000; - - // Token addresses on Ethereum Mainnet - address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; - address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; - address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; - - // DeFi protocol addresses - address constant UNISWAP_V3_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564; - address constant AAVE_V3_POOL = 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2; - - function run() external override { - console2.log("Building DeFi example batch for Safe:", SAFE_ADDRESS); - console2.log("----------------------------------------"); - - // Example 1: Approve USDC for Uniswap V3 Router - uint256 usdcApprovalAmount = 10000 * 1e6; // 10,000 USDC (6 decimals) - - console2.log("1. Adding USDC approval for Uniswap V3:"); - console2.log(" Token:", USDC); - console2.log(" Spender:", UNISWAP_V3_ROUTER); - console2.log(" Amount:", usdcApprovalAmount); - - addToBatch( - USDC, - 0, - abi.encodeWithSelector( - IERC20.approve.selector, - UNISWAP_V3_ROUTER, - usdcApprovalAmount - ) - ); - - // Example 2: Swap USDC for ETH on Uniswap V3 - // exactInputSingle parameters for swapping USDC to ETH - console2.log("2. Swapping USDC for ETH on Uniswap V3"); - - IUniswapV3Router.ExactInputSingleParams memory params = IUniswapV3Router.ExactInputSingleParams({ - tokenIn: USDC, - tokenOut: WETH, - fee: 3000, // 0.3% - recipient: address(this), - deadline: block.timestamp + 1800, // 30 minutes - amountIn: 5000 * 1e6, // 5,000 USDC - amountOutMinimum: 0, // 0 for example, use a real value in production - sqrtPriceLimitX96: 0 // 0 for no limit - }); - - bytes memory swapCalldata = abi.encodeWithSelector( - IUniswapV3Router.exactInputSingle.selector, - params - ); - - addToBatch( - UNISWAP_V3_ROUTER, - 0, - swapCalldata - ); - - // Example 3: Supply DAI to Aave V3 - // First, approve DAI for Aave - uint256 daiSupplyAmount = 2000 * 1e18; // 2,000 DAI (18 decimals) - - console2.log("3. Approving DAI for Aave V3:"); - console2.log(" Token:", DAI); - console2.log(" Spender:", AAVE_V3_POOL); - console2.log(" Amount:", daiSupplyAmount); - - addToBatch( - DAI, - 0, - abi.encodeWithSelector( - IERC20.approve.selector, - AAVE_V3_POOL, - daiSupplyAmount - ) - ); - - // Then, supply DAI to Aave - console2.log("4. Supplying DAI to Aave V3"); - - bytes memory supplyCalldata = abi.encodeWithSelector( - IAaveV3Pool.supply.selector, - DAI, // asset - daiSupplyAmount, // amount - SAFE_ADDRESS, // onBehalfOf - 0 // referralCode - ); - - addToBatch( - AAVE_V3_POOL, - 0, - supplyCalldata - ); - - // Build the batch with the Safe address - console2.log("----------------------------------------"); - console2.log("Building batch transaction..."); - buildBatch(SAFE_ADDRESS); - } -} \ No newline at end of file diff --git a/shell/approveBatch.sh b/shell/approveBatch.sh new file mode 100644 index 0000000..36e779f --- /dev/null +++ b/shell/approveBatch.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +source .env + +cast send --private-key $PRIVATE_KEY $SAFE_ADDRESS "approveHash(bytes32)" $TX_HASH --rpc-url $RPC_URL \ No newline at end of file diff --git a/shell/testBatch.sh b/shell/testBatch.sh new file mode 100755 index 0000000..1b50d39 --- /dev/null +++ b/shell/testBatch.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +source .env + +forge script ./script/TestOnchainBatch.s.sol:TestOnchainBatch --rpc-url $RPC_URL -vvvv \ No newline at end of file diff --git a/script/BatchScript.sol b/src/BatchOnchainScript.sol similarity index 58% rename from script/BatchScript.sol rename to src/BatchOnchainScript.sol index ae52057..bacff70 100644 --- a/script/BatchScript.sol +++ b/src/BatchOnchainScript.sol @@ -1,21 +1,18 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import "forge-std/Script.sol"; -import "safe-contracts/Safe.sol"; -import "safe-contracts/libraries/Enum.sol"; +import {Script} from "forge-std/Script.sol"; +import {console2} from "forge-std/console2.sol"; -/// @title Interface for the MultiSend contract -interface IMultiSend { - function multiSend(bytes memory transactions) external payable; -} +import {IMultiSend} from "../deps/IMultiSend.sol"; +import {ISafe} from "../deps/ISafe.sol"; /// @title BatchScript - A script for creating and executing Gnosis Safe batch transactions /// @notice This contract provides utilities for building batch transactions with on-chain approvals -contract BatchScript is Script { +contract BatchOnchainScript is Script { // NOTE: This is the MultiSend v1.4.1 contract address for most chains. Modify if using a custom deployment. address constant SAFE_MULTISEND_ADDRESS = 0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526; - + /// @notice Array to store encoded transactions for the batch bytes[] internal encodedTxns; @@ -24,7 +21,7 @@ contract BatchScript is Script { address to; uint256 value; bytes data; - Enum.Operation operation; + ISafe.Operation operation; uint256 safeTxGas; uint256 baseGas; uint256 gasPrice; @@ -34,10 +31,20 @@ contract BatchScript is Script { } /// @notice Add a standard call operation to the batch + /// @param safe The address of the Gnosis Safe /// @param to The target address for the transaction /// @param value The amount of ETH to send /// @param data The calldata for the transaction - function addToBatch(address to, uint256 value, bytes memory data) public { + /// @return returnData The data returned from the local execution of the call + function addToBatch( + address safe, + address to, + uint256 value, + bytes memory data + ) public returns (bytes memory returnData) { + if (safe == address(0)) revert("Safe address cannot be zero"); + + // Encode the transaction for the batch bytes memory encodedTxn = abi.encodePacked( uint8(0), // operation (0 for CALL) to, @@ -46,12 +53,44 @@ contract BatchScript is Script { data ); encodedTxns.push(encodedTxn); + + // Execute locally to verify the transaction would succeed + console2.log("=== Testing transaction locally from Safe ==="); + console2.log("Safe:", safe); + console2.log("Target:", to); + console2.log("Value:", value); + + // Impersonate the Safe address during the call + vm.prank(safe); + + // Execute the call and capture both success status and returned data + bool success; + (success, returnData) = to.call{value: value}(data); + + // If call failed, revert with failure message + if (!success) { + console2.log("Local execution FAILED"); + revert("Transaction would fail when executed from the Safe"); + } + + console2.log("Local execution: SUCCESS"); + console2.log("============================"); + + return returnData; } /// @notice Add a delegate call operation to the batch /// @param to The target address for the delegate call /// @param data The calldata for the transaction - function addDelegateCallToBatch(address to, bytes memory data) public { + /// @return returnData The data returned from the local execution of the delegatecall + function addDelegateCallToBatch( + address safeAddress, + address to, + bytes memory data + ) public returns (bytes memory returnData) { + if (safeAddress == address(0)) revert("Safe address cannot be zero"); + + // Encode the transaction for the batch bytes memory encodedTxn = abi.encodePacked( uint8(1), // operation (1 for DELEGATECALL) to, @@ -60,6 +99,29 @@ contract BatchScript is Script { data ); encodedTxns.push(encodedTxn); + + // Execute locally to verify the transaction would succeed + console2.log("=== Testing delegatecall locally from Safe ==="); + console2.log("Safe:", safeAddress); + console2.log("Target:", to); + + // Impersonate the Safe address during the delegatecall + vm.prank(safeAddress); + + // Execute the delegatecall and capture both success status and returned data + bool success; + (success, returnData) = to.delegatecall(data); + + // If call failed, revert with failure message + if (!success) { + console2.log("Local execution FAILED"); + revert("Delegatecall would fail when executed from the Safe"); + } + + console2.log("Local execution: SUCCESS"); + console2.log("============================"); + + return returnData; } /// @notice Build the batch transaction and save to JSON @@ -67,7 +129,7 @@ contract BatchScript is Script { function buildBatch(address safe_) internal { require(safe_ != address(0), "Safe address cannot be zero"); - Safe SAFE = Safe(payable(safe_)); + ISafe SAFE = ISafe(payable(safe_)); uint256 nonce = SAFE.nonce(); Batch memory batch = _createBatch(nonce); @@ -100,7 +162,7 @@ contract BatchScript is Script { function _createBatch(uint256 nonce) private view returns (Batch memory batch) { batch.to = SAFE_MULTISEND_ADDRESS; batch.value = 0; - batch.operation = Enum.Operation.DelegateCall; + batch.operation = ISafe.Operation.DELEGATECALL; bytes memory data; for (uint256 i; i < encodedTxns.length; ++i) { data = bytes.concat(data, encodedTxns[i]); @@ -113,17 +175,4 @@ contract BatchScript is Script { batch.refundReceiver = payable(address(0)); batch.nonce = nonce; } - - /// @notice Template run function - override this in derived contracts - /// @dev Implement this function with your specific transactions - function run() external virtual { - // Replace with your actual Safe address - address safeAddress = 0x0000000000000000000000000000000000000000; - - // Add your transactions to the batch - // Example: addToBatch(targetAddress, ethValue, callData); - - // Build and output the batch transaction - buildBatch(safeAddress); - } -} \ No newline at end of file +} diff --git a/src/BatchScript.sol b/src/BatchSTSScript.sol similarity index 99% rename from src/BatchScript.sol rename to src/BatchSTSScript.sol index fee681b..37f28cb 100644 --- a/src/BatchScript.sol +++ b/src/BatchSTSScript.sol @@ -7,10 +7,10 @@ pragma solidity >=0.6.2 <0.9.0; // 🧩 MODULES import {Script, console2, StdChains, stdJson, stdMath, StdStorage, stdStorageSafe, VmSafe} from "forge-std/Script.sol"; -import {Surl} from "../lib/surl/src/Surl.sol"; +import {Surl} from "../deps/Surl.sol"; // ⭐️ SCRIPT -abstract contract BatchScript is Script { +abstract contract BatchSTSScript is Script { using stdJson for string; using Surl for *; diff --git a/script/ExecBatchWithApprovals.s.sol b/src/ExecBatchOnchain.s.sol similarity index 80% rename from script/ExecBatchWithApprovals.s.sol rename to src/ExecBatchOnchain.s.sol index 1c5143e..2bcd28a 100644 --- a/script/ExecBatchWithApprovals.s.sol +++ b/src/ExecBatchOnchain.s.sol @@ -3,17 +3,16 @@ pragma solidity ^0.8.0; import {Script, console2} from "forge-std/Script.sol"; import {stdJson} from "forge-std/StdJson.sol"; -import {Safe} from "safe-contracts/Safe.sol"; -import {Enum} from "safe-contracts/libraries/Enum.sol"; +import {ISafe} from "../deps/ISafe.sol"; -contract ExecBatchWithApprovals is Script { +contract ExecBatchOnchain is Script { using stdJson for string; struct Batch { address to; uint256 value; bytes data; - Enum.Operation operation; + ISafe.Operation operation; uint256 safeTxGas; uint256 baseGas; uint256 gasPrice; @@ -28,7 +27,7 @@ contract ExecBatchWithApprovals is Script { batch.to = json.readAddress(".to"); batch.value = json.readUint(".value"); batch.data = json.readBytes(".data"); - batch.operation = Enum.Operation(json.readUint(".operation")); + batch.operation = ISafe.Operation(json.readUint(".operation")); batch.safeTxGas = json.readUint(".safeTxGas"); batch.baseGas = json.readUint(".baseGas"); batch.gasPrice = json.readUint(".gasPrice"); @@ -37,7 +36,7 @@ contract ExecBatchWithApprovals is Script { batch.nonce = json.readUint(".nonce"); address safeAddress = vm.envAddress("SAFE"); - Safe SAFE = Safe(payable(safeAddress)); + ISafe SAFE = ISafe(payable(safeAddress)); // Compute transaction hash bytes32 txHash = SAFE.getTransactionHash( @@ -46,11 +45,9 @@ contract ExecBatchWithApprovals is Script { batch.gasToken, batch.refundReceiver, batch.nonce ); - // Get the sender address (the one executing the transaction) - address sender = vm.addr(vm.envUint("PRIVATE_KEY")); - + { // Check if the transaction has enough approvals - uint256 threshold = SAFE.getThreshold(); + // uint256 threshold = SAFE.getThreshold(); uint256 approvalCount = 0; // Count approvals by checking each owner @@ -61,10 +58,11 @@ contract ExecBatchWithApprovals is Script { } } - require(approvalCount >= threshold, "Not enough approvals"); + require(approvalCount >= SAFE.getThreshold(), "Not enough approvals"); console2.log("Executing transaction with hash:", vm.toString(txHash)); - console2.log("Approvals:", approvalCount, "Threshold:", threshold); + console2.log("Approvals:", approvalCount, "Threshold:", SAFE.getThreshold()); + } // Execute the transaction vm.startBroadcast(vm.envUint("PRIVATE_KEY")); diff --git a/test/LocalTransactionTester.t.sol b/test/LocalTransactionTester.t.sol new file mode 100644 index 0000000..55ad6fb --- /dev/null +++ b/test/LocalTransactionTester.t.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; + +// Sample interface for testing +interface IERC20 { + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); + function approve(address spender, uint256 amount) external returns (bool); +} + +/** + * @title LocalTransactionTester + * @dev A test contract demonstrating how to locally test transactions before batching + */ +contract LocalTransactionTester is Test { + + // WETH address on Ethereum Mainnet + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + + // DAI address on Ethereum Mainnet + address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + + // Structure to store transaction details + struct Transaction { + address to; + uint256 value; + bytes data; + } + + // Array to hold all transactions + Transaction[] public transactions; + + function setUp() public { + // Configure test to use a fork of mainnet + vm.createSelectFork("mainnet"); + } + + // Add a transaction to the batch and test locally + function addTransaction(address to, uint256 value, bytes memory data) public returns (bool success) { + // Store the transaction + transactions.push(Transaction({ + to: to, + value: value, + data: data + })); + + // Execute the call locally + (success, ) = to.call{value: value}(data); + + // Log the result + if (success) { + console.log("Transaction to", to, "succeeded"); + } else { + console.log("Transaction to", to, "failed"); + } + + return success; + } + + // Test function: demonstrating local testing + function testLocalTransactionTesting() public { + console.log("=== LOCAL TRANSACTION TESTING DEMO ==="); + + // 1. Test a WETH balanceOf call (should succeed) + bytes memory balanceCallData = abi.encodeWithSelector( + IERC20.balanceOf.selector, + address(this) + ); + + bool success = addTransaction(WETH, 0, balanceCallData); + assertTrue(success, "WETH balanceOf call failed"); + + // 2. Test a DAI transfer (should fail because we don't have any) + bytes memory transferCallData = abi.encodeWithSelector( + IERC20.transfer.selector, + address(0x1111), + 1000 * 10**18 // 1000 DAI + ); + + success = addTransaction(DAI, 0, transferCallData); + assertFalse(success, "DAI transfer unexpectedly succeeded"); + + // 3. Try a DAI approval (should succeed as it doesn't check balances) + bytes memory approveCallData = abi.encodeWithSelector( + IERC20.approve.selector, + address(0x2222), + 1000 * 10**18 // 1000 DAI + ); + + success = addTransaction(DAI, 0, approveCallData); + assertTrue(success, "DAI approve call failed"); + + // In a real implementation, you'd then: + // 1. Encode all these transactions into a Gnosis Safe batch + // 2. Execute or schedule the batch + + console.log("Total transactions in batch:", transactions.length); + } +} \ No newline at end of file From 4b4cd7e1a7f6082c8b839be6842e3169aae9552e Mon Sep 17 00:00:00 2001 From: indigo Date: Thu, 27 Feb 2025 22:56:33 +0800 Subject: [PATCH 3/3] feat: tested, made justfile cli --- .gitmodules | 3 + README.md | 39 +++++++ {deps => dep}/IMultiSend.sol | 0 {deps => dep}/ISafe.sol | 0 {deps => dep}/Surl.sol | 2 +- foundry.toml | 8 +- justfile | 68 ++++++++++++ lib/solady | 1 + shell/approveBatch.sh | 5 - shell/approveOnchainBatch.sh | 15 +++ shell/{testBatch.sh => createOnchainBatch.sh} | 0 shell/executeOnchainBatch.sh | 17 +++ shell/{executeBatch.sh => executeSTSBatch.sh} | 0 ...chOnchainScript.sol => BatchOnchain.s.sol} | 40 ++++++- src/{BatchSTSScript.sol => BatchSTS.s.sol} | 5 +- src/ExecBatchOnchain.s.sol | 48 +++++++-- test/LocalTransactionTester.t.sol | 101 ------------------ {script => test}/TestAuthBatch.s.sol | 0 {script => test}/TestOnchainBatch.s.sol | 4 +- 19 files changed, 226 insertions(+), 130 deletions(-) rename {deps => dep}/IMultiSend.sol (100%) rename {deps => dep}/ISafe.sol (100%) rename {deps => dep}/Surl.sol (98%) create mode 100644 justfile create mode 160000 lib/solady delete mode 100644 shell/approveBatch.sh create mode 100644 shell/approveOnchainBatch.sh rename shell/{testBatch.sh => createOnchainBatch.sh} (100%) create mode 100755 shell/executeOnchainBatch.sh rename shell/{executeBatch.sh => executeSTSBatch.sh} (100%) rename src/{BatchOnchainScript.sol => BatchOnchain.s.sol} (77%) rename src/{BatchSTSScript.sol => BatchSTS.s.sol} (98%) delete mode 100644 test/LocalTransactionTester.t.sol rename {script => test}/TestAuthBatch.s.sol (100%) rename {script => test}/TestOnchainBatch.s.sol (94%) diff --git a/.gitmodules b/.gitmodules index 4b4c42b..535241c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "lib/ds-test"] path = lib/ds-test url = https://github.com/dapphub/ds-test +[submodule "lib/solady"] + path = lib/solady + url = https://github.com/vectorized/solady diff --git a/README.md b/README.md index fcffe72..f04175b 100644 --- a/README.md +++ b/README.md @@ -350,3 +350,42 @@ addToBatch( - The execution script checks if enough approvals have been collected before executing the transaction. - Make sure to replace the placeholder addresses in the scripts with your actual Safe address. - Never share your private keys. The examples above are for illustration only. + +## Using a Custom Batch File Path + +By default, the script reads the batch transaction data from `./data/batch.json`. However, you can now specify a custom path to the batch JSON file in several ways: + +### 1. Using the Shell Script + +The `executeOnchainBatch.sh` script now accepts an optional file path parameter: + +```bash +# Execute using the default batch file path (./data/batch.json) +./shell/executeOnchainBatch.sh + +# Execute using a custom batch file path +./shell/executeOnchainBatch.sh /path/to/your/custom-batch.json +``` + +### 2. Using Forge Script Directly + +You can also run the script directly using the Forge CLI with the `--sig` parameter: + +```bash +# Using direct function argument +forge script ./src/ExecBatchOnchain.s.sol:ExecBatchOnchain --sig "run(string)" "/path/to/your/custom-batch.json" --broadcast --private-key $PRIVATE_KEY --rpc-url $RPC_URL +``` + +### 3. Using Environment Variables + +If you prefer using environment variables, you can set the `BATCH_FILE_PATH` variable: + +```bash +# Set the batch file path as an environment variable +export BATCH_FILE_PATH="/path/to/your/custom-batch.json" + +# Run the script (it will detect the environment variable) +forge script ./src/ExecBatchOnchain.s.sol:ExecBatchOnchain --broadcast --private-key $PRIVATE_KEY --rpc-url $RPC_URL +``` + +The script will log which batch file is being used to help you confirm the correct file path is being read. diff --git a/deps/IMultiSend.sol b/dep/IMultiSend.sol similarity index 100% rename from deps/IMultiSend.sol rename to dep/IMultiSend.sol diff --git a/deps/ISafe.sol b/dep/ISafe.sol similarity index 100% rename from deps/ISafe.sol rename to dep/ISafe.sol diff --git a/deps/Surl.sol b/dep/Surl.sol similarity index 98% rename from deps/Surl.sol rename to dep/Surl.sol index a49f746..913e0dd 100644 --- a/deps/Surl.sol +++ b/dep/Surl.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.13; -import {Vm} from "forge-std/Vm.sol"; +import {Vm} from "lib/forge-std/src/Vm.sol"; library Surl { Vm constant vm = Vm(address(bytes20(uint160(uint256(keccak256("hevm cheat code")))))); diff --git a/foundry.toml b/foundry.toml index 4f45464..5dd98d0 100644 --- a/foundry.toml +++ b/foundry.toml @@ -8,11 +8,7 @@ auto_detect_solc = false optimizer_runs = 1_000 remappings = [ 'ds-test/=lib/ds-test/src/', - 'forge-std/=lib/forge-std/src', - 'solmate/=lib/solmate/src', + 'forge-std/=lib/forge-std/src' ] ffi = true -#fs_permission = [{ access = "write", path = "data" }] -fs_permissions = [{ access = "write", path = "./"}] - - +fs_permissions = [{ access = "read-write", path = "./"}] \ No newline at end of file diff --git a/justfile b/justfile new file mode 100644 index 0000000..be7cb0f --- /dev/null +++ b/justfile @@ -0,0 +1,68 @@ +# Load environment variables from .env file +set dotenv-load +# Show usage information +default: + @echo "Available commands:" + @echo " just create-batch - Create a new onchain batch using the specified script" + @echo " just approve-batch - Approve an onchain batch transaction using a batch file" + @echo " just execute-batch - Execute an approved onchain batch" + @echo "" + @echo "Required environment variables (in .env file):" + @echo " - RPC_URL: The RPC endpoint URL for the target blockchain" + @echo " - PRIVATE_KEY: Your private key for signing transactions" + @echo " - SAFE_ADDRESS: The address of the Safe contract" + @echo "" + @echo "Examples:" + @echo " just create-batch ./script/TestOnchainBatch.s.sol:TestOnchainBatch" + @echo " just approve-batch ./data/batch.json" + @echo " just execute-batch ./data/batch.json" + +# Create an onchain batch with a specified forge script +create-batch script-path: + #!/usr/bin/env bash + if [ ! -f "{{script-path}}" ]; then + echo "Error: Script file not found: {{script-path}}" + echo "Please provide a valid path to a Forge script" + exit 1 + fi + + # Extract the contract name from the path (assuming format like path/to/Script.s.sol:ContractName) + if [[ "{{script-path}}" == *":"* ]]; then + # Path already includes contract name + forge script {{script-path}} --rpc-url $RPC_URL -vvvv + else + echo "Error: Script path must include contract name (e.g., ./script/TestOnchainBatch.s.sol:TestOnchainBatch)" + exit 1 + fi + +# Approve an onchain batch transaction using a batch file +approve-batch batch-json: + #!/usr/bin/env bash + if [ ! -f "{{batch-json}}" ]; then + echo "Error: Batch JSON file not found: {{batch-json}}" + echo "Please provide a valid path to a batch JSON file" + exit 1 + fi + + # Get the transaction hash from the batch file using the encodeBatchFromFile function + TX_HASH=$(forge script ./src/BatchOnchain.s.sol:BatchOnchainScript --sig "encodeBatchFromFile(address,string)(bytes32)" $SAFE_ADDRESS "{{batch-json}}" --rpc-url $RPC_URL | grep "Transaction hash from file:" | awk '{print $NF}') + + if [[ ! "$TX_HASH" =~ ^0x[0-9a-fA-F]{64}$ ]]; then + echo "Error: Failed to extract a valid transaction hash" + exit 1 + fi + + echo "Approving transaction hash: $TX_HASH" + cast send --private-key $PRIVATE_KEY $SAFE_ADDRESS "approveHash(bytes32)" $TX_HASH --rpc-url $RPC_URL + +# Execute an onchain batch +execute-batch batch-json: + #!/usr/bin/env bash + if [ ! -f "{{batch-json}}" ]; then + echo "Error: Batch JSON file not found: {{batch-json}}" + echo "Please provide a valid path to a batch JSON file" + exit 1 + fi + + forge script ./src/ExecBatchOnchain.s.sol:ExecBatchOnchain --sig "run(string)" "{{batch-json}}" --broadcast --private-key $PRIVATE_KEY --rpc-url $RPC_URL -vvvv + diff --git a/lib/solady b/lib/solady new file mode 160000 index 0000000..ab7596b --- /dev/null +++ b/lib/solady @@ -0,0 +1 @@ +Subproject commit ab7596b1a21f8a54bec722d54505e8daa40b5760 diff --git a/shell/approveBatch.sh b/shell/approveBatch.sh deleted file mode 100644 index 36e779f..0000000 --- a/shell/approveBatch.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -source .env - -cast send --private-key $PRIVATE_KEY $SAFE_ADDRESS "approveHash(bytes32)" $TX_HASH --rpc-url $RPC_URL \ No newline at end of file diff --git a/shell/approveOnchainBatch.sh b/shell/approveOnchainBatch.sh new file mode 100644 index 0000000..86585cc --- /dev/null +++ b/shell/approveOnchainBatch.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +source .env + +# Check if transaction hash is provided as an argument +if [ $# -ne 1 ]; then + echo "Usage: $0 " + echo "Example: $0 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + exit 1 +fi + +# Use the provided transaction hash instead of reading from .env +TX_HASH=$1 + +cast send --private-key $PRIVATE_KEY $SAFE_ADDRESS "approveHash(bytes32)" $TX_HASH --rpc-url $RPC_URL \ No newline at end of file diff --git a/shell/testBatch.sh b/shell/createOnchainBatch.sh similarity index 100% rename from shell/testBatch.sh rename to shell/createOnchainBatch.sh diff --git a/shell/executeOnchainBatch.sh b/shell/executeOnchainBatch.sh new file mode 100755 index 0000000..5542ad4 --- /dev/null +++ b/shell/executeOnchainBatch.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +source .env + +# Check if a batch file path was provided as a parameter +if [ -n "$1" ]; then + # Use the provided batch file path + export BATCH_FILE_PATH=$1 + echo "Using custom batch file: $BATCH_FILE_PATH" + + # Execute with the --sig parameter to call the overloaded run function + forge script ./src/ExecBatchOnchain.s.sol:ExecBatchOnchain --sig "run(string)" "$BATCH_FILE_PATH" --broadcast --private-key $PRIVATE_KEY --rpc-url $RPC_URL -vvvv +else + # Use the default batch file path + echo "Using default batch file: ./data/batch.json" + forge script ./src/ExecBatchOnchain.s.sol:ExecBatchOnchain --broadcast --private-key $PRIVATE_KEY --rpc-url $RPC_URL -vvvv +fi \ No newline at end of file diff --git a/shell/executeBatch.sh b/shell/executeSTSBatch.sh similarity index 100% rename from shell/executeBatch.sh rename to shell/executeSTSBatch.sh diff --git a/src/BatchOnchainScript.sol b/src/BatchOnchain.s.sol similarity index 77% rename from src/BatchOnchainScript.sol rename to src/BatchOnchain.s.sol index bacff70..bac8490 100644 --- a/src/BatchOnchainScript.sol +++ b/src/BatchOnchain.s.sol @@ -4,8 +4,8 @@ pragma solidity ^0.8.0; import {Script} from "forge-std/Script.sol"; import {console2} from "forge-std/console2.sol"; -import {IMultiSend} from "../deps/IMultiSend.sol"; -import {ISafe} from "../deps/ISafe.sol"; +import {IMultiSend} from "dep/IMultiSend.sol"; +import {ISafe} from "dep/ISafe.sol"; /// @title BatchScript - A script for creating and executing Gnosis Safe batch transactions /// @notice This contract provides utilities for building batch transactions with on-chain approvals @@ -175,4 +175,40 @@ contract BatchOnchainScript is Script { batch.refundReceiver = payable(address(0)); batch.nonce = nonce; } + + /// @notice Convenience function to encode a batch transaction from a JSON file + /// and return the transaction hash + /// @param safe_ The address of the Gnosis Safe + /// @param batchFilePath Path to the JSON batch file + /// @return txHash The hash of the transaction + function encodeBatchFromFile(address safe_, string memory batchFilePath) public view returns (bytes32 txHash) { + require(safe_ != address(0), "Safe address cannot be zero"); + + ISafe SAFE = ISafe(payable(safe_)); + + // Read and parse the batch JSON file + string memory json = vm.readFile(batchFilePath); + + // Parse batch data from JSON + address to = abi.decode(vm.parseJson(json, ".to"), (address)); + uint256 value = abi.decode(vm.parseJson(json, ".value"), (uint256)); + bytes memory data = abi.decode(vm.parseJson(json, ".data"), (bytes)); + ISafe.Operation operation = ISafe.Operation(abi.decode(vm.parseJson(json, ".operation"), (uint8))); + uint256 safeTxGas = abi.decode(vm.parseJson(json, ".safeTxGas"), (uint256)); + uint256 baseGas = abi.decode(vm.parseJson(json, ".baseGas"), (uint256)); + uint256 gasPrice = abi.decode(vm.parseJson(json, ".gasPrice"), (uint256)); + address gasToken = abi.decode(vm.parseJson(json, ".gasToken"), (address)); + address payable refundReceiver = payable(abi.decode(vm.parseJson(json, ".refundReceiver"), (address))); + uint256 nonce = abi.decode(vm.parseJson(json, ".nonce"), (uint256)); + + // Compute transaction hash + txHash = SAFE.getTransactionHash( + to, value, data, operation, + safeTxGas, baseGas, gasPrice, + gasToken, refundReceiver, nonce + ); + + console2.log("Transaction hash from file:", vm.toString(txHash)); + return txHash; + } } diff --git a/src/BatchSTSScript.sol b/src/BatchSTS.s.sol similarity index 98% rename from src/BatchSTSScript.sol rename to src/BatchSTS.s.sol index 37f28cb..983d605 100644 --- a/src/BatchSTSScript.sol +++ b/src/BatchSTS.s.sol @@ -7,9 +7,10 @@ pragma solidity >=0.6.2 <0.9.0; // 🧩 MODULES import {Script, console2, StdChains, stdJson, stdMath, StdStorage, stdStorageSafe, VmSafe} from "forge-std/Script.sol"; -import {Surl} from "../deps/Surl.sol"; +import {Surl} from "dep/Surl.sol"; -// ⭐️ SCRIPT +/// @title BatchSTSScript +/// @notice A script for creating and executing Gnosis Safe batch transactions via the Safe Transaction Service API abstract contract BatchSTSScript is Script { using stdJson for string; using Surl for *; diff --git a/src/ExecBatchOnchain.s.sol b/src/ExecBatchOnchain.s.sol index 2bcd28a..893575f 100644 --- a/src/ExecBatchOnchain.s.sol +++ b/src/ExecBatchOnchain.s.sol @@ -3,8 +3,12 @@ pragma solidity ^0.8.0; import {Script, console2} from "forge-std/Script.sol"; import {stdJson} from "forge-std/StdJson.sol"; -import {ISafe} from "../deps/ISafe.sol"; +import {ISafe} from "dep/ISafe.sol"; +import {LibSort} from "lib/solady/src/utils/LibSort.sol"; + +/// @title ExecBatchOnchain +/// @notice A script for executing approved Gnosis Safe batch transactions onchain contract ExecBatchOnchain is Script { using stdJson for string; @@ -21,8 +25,13 @@ contract ExecBatchOnchain is Script { uint256 nonce; } - function run() public { - string memory json = vm.readFile("./data/batch.json"); + address[] private approvingOwners; + + // Script accepts a batch file path as a CLI argument + function run(string memory batchFilePath) public { + console2.log("Using batch file:", batchFilePath); + + string memory json = vm.readFile(batchFilePath); Batch memory batch; batch.to = json.readAddress(".to"); batch.value = json.readUint(".value"); @@ -35,8 +44,7 @@ contract ExecBatchOnchain is Script { batch.refundReceiver = payable(json.readAddress(".refundReceiver")); batch.nonce = json.readUint(".nonce"); - address safeAddress = vm.envAddress("SAFE"); - ISafe SAFE = ISafe(payable(safeAddress)); + ISafe SAFE = ISafe(payable(vm.envAddress("SAFE_ADDRESS"))); // Compute transaction hash bytes32 txHash = SAFE.getTransactionHash( @@ -47,29 +55,47 @@ contract ExecBatchOnchain is Script { { // Check if the transaction has enough approvals - // uint256 threshold = SAFE.getThreshold(); + uint256 threshold = SAFE.getThreshold(); uint256 approvalCount = 0; // Count approvals by checking each owner address[] memory owners = SAFE.getOwners(); for (uint256 i = 0; i < owners.length; i++) { if (SAFE.approvedHashes(owners[i], txHash) == 1) { + approvingOwners.push(owners[i]); approvalCount++; } } - - require(approvalCount >= SAFE.getThreshold(), "Not enough approvals"); + + require(approvalCount >= threshold, "Not enough approvals"); console2.log("Executing transaction with hash:", vm.toString(txHash)); - console2.log("Approvals:", approvalCount, "Threshold:", SAFE.getThreshold()); + console2.log("Approvals:", approvalCount, "Threshold:", threshold); + } + + // Encode a signature based on the approving owners + + // Sort the addresses in ascending order + address[] memory sortedApprovingOwners = approvingOwners; + LibSort.sort(sortedApprovingOwners); + + // Construct the signature using the sorted addresses + bytes memory signatures = new bytes(0); + for (uint256 i = 0; i < sortedApprovingOwners.length; i++) { + signatures = abi.encodePacked( + signatures, + uint256(uint160(sortedApprovingOwners[i])), + bytes32(0), + uint8(1) + ); } // Execute the transaction - vm.startBroadcast(vm.envUint("PRIVATE_KEY")); + vm.startBroadcast(); bool success = SAFE.execTransaction( batch.to, batch.value, batch.data, batch.operation, batch.safeTxGas, batch.baseGas, batch.gasPrice, - batch.gasToken, batch.refundReceiver, bytes("") + batch.gasToken, batch.refundReceiver, signatures ); vm.stopBroadcast(); diff --git a/test/LocalTransactionTester.t.sol b/test/LocalTransactionTester.t.sol deleted file mode 100644 index 55ad6fb..0000000 --- a/test/LocalTransactionTester.t.sol +++ /dev/null @@ -1,101 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.13; - -import "forge-std/Test.sol"; - -// Sample interface for testing -interface IERC20 { - function balanceOf(address account) external view returns (uint256); - function transfer(address to, uint256 amount) external returns (bool); - function approve(address spender, uint256 amount) external returns (bool); -} - -/** - * @title LocalTransactionTester - * @dev A test contract demonstrating how to locally test transactions before batching - */ -contract LocalTransactionTester is Test { - - // WETH address on Ethereum Mainnet - address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; - - // DAI address on Ethereum Mainnet - address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; - - // Structure to store transaction details - struct Transaction { - address to; - uint256 value; - bytes data; - } - - // Array to hold all transactions - Transaction[] public transactions; - - function setUp() public { - // Configure test to use a fork of mainnet - vm.createSelectFork("mainnet"); - } - - // Add a transaction to the batch and test locally - function addTransaction(address to, uint256 value, bytes memory data) public returns (bool success) { - // Store the transaction - transactions.push(Transaction({ - to: to, - value: value, - data: data - })); - - // Execute the call locally - (success, ) = to.call{value: value}(data); - - // Log the result - if (success) { - console.log("Transaction to", to, "succeeded"); - } else { - console.log("Transaction to", to, "failed"); - } - - return success; - } - - // Test function: demonstrating local testing - function testLocalTransactionTesting() public { - console.log("=== LOCAL TRANSACTION TESTING DEMO ==="); - - // 1. Test a WETH balanceOf call (should succeed) - bytes memory balanceCallData = abi.encodeWithSelector( - IERC20.balanceOf.selector, - address(this) - ); - - bool success = addTransaction(WETH, 0, balanceCallData); - assertTrue(success, "WETH balanceOf call failed"); - - // 2. Test a DAI transfer (should fail because we don't have any) - bytes memory transferCallData = abi.encodeWithSelector( - IERC20.transfer.selector, - address(0x1111), - 1000 * 10**18 // 1000 DAI - ); - - success = addTransaction(DAI, 0, transferCallData); - assertFalse(success, "DAI transfer unexpectedly succeeded"); - - // 3. Try a DAI approval (should succeed as it doesn't check balances) - bytes memory approveCallData = abi.encodeWithSelector( - IERC20.approve.selector, - address(0x2222), - 1000 * 10**18 // 1000 DAI - ); - - success = addTransaction(DAI, 0, approveCallData); - assertTrue(success, "DAI approve call failed"); - - // In a real implementation, you'd then: - // 1. Encode all these transactions into a Gnosis Safe batch - // 2. Execute or schedule the batch - - console.log("Total transactions in batch:", transactions.length); - } -} \ No newline at end of file diff --git a/script/TestAuthBatch.s.sol b/test/TestAuthBatch.s.sol similarity index 100% rename from script/TestAuthBatch.s.sol rename to test/TestAuthBatch.s.sol diff --git a/script/TestOnchainBatch.s.sol b/test/TestOnchainBatch.s.sol similarity index 94% rename from script/TestOnchainBatch.s.sol rename to test/TestOnchainBatch.s.sol index 1f73a50..3e483ee 100644 --- a/script/TestOnchainBatch.s.sol +++ b/test/TestOnchainBatch.s.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import "../src/BatchOnchainScript.sol"; +import "src/BatchOnchain.s.sol"; // A simple ERC20 interface for testing token approvals interface IERC20Test { @@ -13,7 +13,7 @@ interface IERC20Test { /** * @title TestOnchainBatch * @dev A simple script to test the Gnosis Safe batch functionality with a basic test transaction. - * To run: forge script script/TestOnchainBatch.s.sol:TestOnchainBatch --rpc-url $RPC_URL -vvvv + * To run: forge script ./script/TestOnchainBatch.s.sol:TestOnchainBatch --rpc-url $RPC_URL -vvvv */ contract TestOnchainBatch is BatchOnchainScript {