diff --git a/.gitmodules b/.gitmodules index b05c85a..535241c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,12 +1,9 @@ [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/solady"] + path = lib/solady + url = https://github.com/vectorized/solady diff --git a/README.md b/README.md index 2a93ecb..f04175b 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,391 @@ -# 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 +``` + +### Step 3: Approve the Transaction Hash + +Each Safe owner needs to approve the transaction hash: + +```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 + +# 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 + +### Local Testing of Transactions -## Installation +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: -```forge install ind-igo/forge-safe``` +```solidity +// Enable or disable local testing globally +setLocalTesting(true); // or false -## Usage +// Add a transaction with default testing behavior (follows global setting) +bool success = addToBatch( + tokenAddress, + 0, + abi.encodeWithSelector(IERC20.transfer.selector, recipient, amount) +); -Steps: +// 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 +``` -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 +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 -```js -import {BatchScript} from "forge-safe/BatchScript.sol"; +**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. -... -contract TestScript is BatchScript { -... -function run(bool send_) public isBatch(safe) { - string memory gm = "gm"; - address greeter = 0x1111; +### Custom Operations (DelegateCall) - bytes memory txn = abi.encodeWithSelector( - Greeter.greet.selector, - gm - ); - addToBatch(greeter, 0, txn); +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); +} +``` + +Then use it in your run function: + +```solidity +// Define an interface for the contract you want to delegate call +interface IExternalContract { + function doSomething() external; + function doSomethingElse(uint256 value) external; +} - executeBatch(send_); +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); } ``` + +### Working with ERC-20 Tokens + +Here's how to include common ERC-20 token operations in your batch: + +```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. + +## 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/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/dep/IMultiSend.sol b/dep/IMultiSend.sol new file mode 100644 index 0000000..f99c439 --- /dev/null +++ b/dep/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/dep/ISafe.sol b/dep/ISafe.sol new file mode 100644 index 0000000..5775be3 --- /dev/null +++ b/dep/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/dep/Surl.sol b/dep/Surl.sol new file mode 100644 index 0000000..913e0dd --- /dev/null +++ b/dep/Surl.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Vm} from "lib/forge-std/src/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 a8998be..5dd98d0 100644 --- a/foundry.toml +++ b/foundry.toml @@ -8,8 +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', - 'surl/=lib/surl/src', + 'forge-std/=lib/forge-std/src' ] ffi = true +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/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/TestBatch.s.sol b/script/TestBatch.s.sol deleted file mode 100644 index 35c97ae..0000000 --- a/script/TestBatch.s.sol +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.17; - -import {BatchScript} from "src/BatchScript.sol"; - -interface ICrossChainBridge { - /// @dev path_ = abi.encodePacked(remoteAddress, localAddress) - function setTrustedRemote( - uint16 srcChainId_, - bytes calldata path_ - ) external; -} - -interface IKernel { - function executeAction(uint8 action_, address target_) external; -} - -/// @notice A test for Gnosis Safe batching script -/// @dev GOERLI -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) - ); - 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) - ); - addToBatch(address(bridge), txn2); - - // Execute batch - executeBatch(send_); - } -} 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/createOnchainBatch.sh b/shell/createOnchainBatch.sh new file mode 100755 index 0000000..1b50d39 --- /dev/null +++ b/shell/createOnchainBatch.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/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/BatchOnchain.s.sol b/src/BatchOnchain.s.sol new file mode 100644 index 0000000..bac8490 --- /dev/null +++ b/src/BatchOnchain.s.sol @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Script} from "forge-std/Script.sol"; +import {console2} from "forge-std/console2.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 +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; + + /// @notice Struct representing a Safe batch transaction + struct Batch { + address to; + uint256 value; + bytes data; + ISafe.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 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 + /// @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, + value, + data.length, + 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 + /// @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, + uint256(0), // value must be 0 for delegatecall + data.length, + 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 + /// @param safe_ The address of the Gnosis Safe + function buildBatch(address safe_) internal { + require(safe_ != address(0), "Safe address cannot be zero"); + + ISafe SAFE = ISafe(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 = ISafe.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 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/BatchScript.sol b/src/BatchSTS.s.sol similarity index 98% rename from src/BatchScript.sol rename to src/BatchSTS.s.sol index fee681b..983d605 100644 --- a/src/BatchScript.sol +++ b/src/BatchSTS.s.sol @@ -7,10 +7,11 @@ 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 "dep/Surl.sol"; -// ⭐️ SCRIPT -abstract contract BatchScript is 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 new file mode 100644 index 0000000..893575f --- /dev/null +++ b/src/ExecBatchOnchain.s.sol @@ -0,0 +1,105 @@ +// 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 {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; + + struct Batch { + address to; + uint256 value; + bytes data; + ISafe.Operation operation; + uint256 safeTxGas; + uint256 baseGas; + uint256 gasPrice; + address gasToken; + address payable refundReceiver; + uint256 nonce; + } + + 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"); + batch.data = json.readBytes(".data"); + batch.operation = ISafe.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"); + + ISafe SAFE = ISafe(payable(vm.envAddress("SAFE_ADDRESS"))); + + // 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 + ); + + { + // 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) { + approvingOwners.push(owners[i]); + approvalCount++; + } + } + + require(approvalCount >= threshold, "Not enough approvals"); + + console2.log("Executing transaction with hash:", vm.toString(txHash)); + 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(); + bool success = SAFE.execTransaction( + batch.to, batch.value, batch.data, batch.operation, + batch.safeTxGas, batch.baseGas, batch.gasPrice, + batch.gasToken, batch.refundReceiver, signatures + ); + vm.stopBroadcast(); + + require(success, "Transaction execution failed"); + console2.log("Transaction executed successfully"); + } +} \ No newline at end of file diff --git a/script/TestAuthBatch.s.sol b/test/TestAuthBatch.s.sol similarity index 99% rename from script/TestAuthBatch.s.sol rename to test/TestAuthBatch.s.sol index 1507921..9fb2ef1 100644 --- a/script/TestAuthBatch.s.sol +++ b/test/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/test/TestOnchainBatch.s.sol b/test/TestOnchainBatch.s.sol new file mode 100644 index 0000000..3e483ee --- /dev/null +++ b/test/TestOnchainBatch.s.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "src/BatchOnchain.s.sol"; + +// 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); +} + +/** + * @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 + */ +contract TestOnchainBatch is BatchOnchainScript { + + function run() external { + // Use a test Safe address (replace with your actual Safe for real testing) + 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 = 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 + ); + + // Example 2: Token approval (using DAI as an example) + // Note: This is just for demonstration, replace with real token addresses + 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:", wethToken); + console2.log(" Spender:", testSpender); + console2.log(" Amount:", approvalAmount); + + addToBatch( + safeAddress, + wethToken, + 0, + abi.encodeWithSelector( + IERC20Test.approve.selector, + testSpender, + approvalAmount + ) + ); + + // Build the batch with the Safe address + console2.log("----------------------------------------"); + console2.log("Building batch transaction..."); + buildBatch(safeAddress); + } +}