diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 9a69898..3dae549 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -26,3 +26,35 @@ jobs: INFURA_KEY: ${{ secrets.INFURA_KEY }} DUNE_API_KEY: ${{ secrets.DUNE_API_KEY }} PROPOSER_PK: ${{ secrets.PROPOSER_PK }} + + rust: + runs-on: ubuntu-latest + defaults: + run: + working-directory: rs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Cache Cargo registry and target directory + uses: actions/cache@v4 + with: + path: | + rs/.cargo/registry + rs/.cargo/git + rs/target + key: ${{ runner.os }}-cargo-${{ hashFiles('rs/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Lint with Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Run tests + run: cargo test --all + + - name: Build + run: cargo build diff --git a/.gitignore b/.gitignore index 276adf7..e38d2f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/target/ *.pyc venv __pycache__ diff --git a/README.md b/README.md index 2e7caff..9bb6515 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,26 @@ # SubSafe Commander for Safe Transaction Batching -Control your Fleet of safes from the command line! Claim your $SAFE token airdrop in style. +Control your Fleet of safes from the command line! Claim your $SAFE token airdrop in style. -## TLDR; +## 🦀 Rust Implementation Available! + +This project is now available in **both Python and Rust**! + +- **Python** (original): Stable, battle-tested implementation in the root directory +- **Rust** (new): High-performance, type-safe implementation in the `rs/` directory + +For the Rust version, see [`rs/README.md`](rs/README.md) for detailed instructions. + +**Quick start with Rust:** +```bash +cd rs +cargo build --release +./target/release/subsafe-commander --help +``` + +--- + +## TLDR; (Python Version) To Claim $SAFE Airdrop on behalf of a family of sub-safes (with signing threshold 1) all of which are owned by a single "parent" safe, do this: diff --git a/rs/.gitignore b/rs/.gitignore new file mode 100644 index 0000000..e9a962a --- /dev/null +++ b/rs/.gitignore @@ -0,0 +1,14 @@ +# Rust build artifacts +/target/ +Cargo.lock + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store + diff --git a/rs/Cargo.toml b/rs/Cargo.toml new file mode 100644 index 0000000..133c730 --- /dev/null +++ b/rs/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "subsafe-commander" +version = "0.1.0" +edition = "2024" +authors = ["bh2smith"] +description = "Control your Fleet of safes from the command line!" + +[[bin]] +name = "subsafe-commander" +path = "src/main.rs" + +[dependencies] +tokio = { version = "1.35", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +clap = { version = "4.4", features = ["derive", "env"] } +reqwest = { version = "0.11", features = ["json", "rustls-tls"], default-features = false } +duners = "0.0.2" +anyhow = "1.0" +dotenvy = "0.15" +log = "0.4" +env_logger = "0.11" +chrono = "0.4" +hex = "0.4" +alloy = { version = "1.0.42", default-features = false, features = [ + "providers", + "signer-local", + "network", + "rpc-client", + "reqwest", + "dyn-abi", + "sol-types", + "contract", +] } + +[dev-dependencies] +tokio-test = "0.4" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true + diff --git a/rs/Dockerfile b/rs/Dockerfile new file mode 100644 index 0000000..0237a63 --- /dev/null +++ b/rs/Dockerfile @@ -0,0 +1,33 @@ +# Multi-stage Dockerfile for SubSafe Commander (Rust) + +# Build stage +FROM rustlang/rust:nightly AS builder + +WORKDIR /app + +# Copy Cargo files +COPY Cargo.toml ./ + +# Copy source code +COPY src ./src + +# Build the application in release mode +RUN cargo build --release + +# Runtime stage +FROM debian:bookworm-slim + +# Install runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +# Copy the binary from builder +COPY --from=builder /app/target/release/subsafe-commander /usr/local/bin/ + +# Set the entrypoint +ENTRYPOINT ["subsafe-commander"] + +# Default command (can be overridden) +CMD ["--help"] + diff --git a/rs/README.md b/rs/README.md new file mode 100644 index 0000000..564047d --- /dev/null +++ b/rs/README.md @@ -0,0 +1,183 @@ +# SubSafe Commander (Rust) + +Control your Fleet of Safes from the command line! Claim your $SAFE token airdrop in style. + +This is the Rust implementation of the SubSafe Commander, providing the same functionality as the Python version with improved performance and type safety. + +## Features + +- ✅ Claim $SAFE token airdrops on behalf of sub-safes +- ✅ Add owners to multiple sub-safes +- ✅ Set/clear Snapshot delegates for governance +- ✅ Multisend transaction batching +- ✅ Integration with Dune Analytics +- ✅ Safe transaction encoding + +## Installation + +### Prerequisites + +- Rust 1.70+ (install from [rustup.rs](https://rustup.rs/)) + +### Build from Source + +```bash +cd rs +cargo build --release +``` + +The binary will be available at `target/release/subsafe-commander`. + +## Usage + +### Environment Variables + +Create a `.env` file in the project root: + +```bash +NODE_URL=https://rpc.ankr.com/eth +PARENT_SAFE=0x... +PROPOSER_PK=0x... # Private key of Parent Owner +DUNE_API_KEY=your_dune_api_key +``` + +### Commands + +#### Claim Airdrop + +```bash +./target/release/subsafe-commander \ + --command claim \ + --parent 0xYourParentSafe \ + --sub-safes 0xChild1,0xChild2,0xChild3 +``` + +Or fetch children from Dune: + +```bash +./target/release/subsafe-commander \ + --command claim \ + --parent 0xYourParentSafe \ + --index-from 0 \ + --num-safes 10 +``` + +#### Add Owner + +```bash +./target/release/subsafe-commander \ + --command add-owner \ + --parent 0xYourParentSafe \ + --sub-safes 0xChild1,0xChild2 \ + --new-owner 0xNewOwnerAddress \ + --threshold 1 +``` + +#### Set Snapshot Delegate + +```bash +./target/release/subsafe-commander \ + --command set-delegate \ + --parent 0xYourParentSafe \ + --sub-safes 0xChild1,0xChild2 \ + --delegate 0xDelegateAddress +``` + +If `--delegate` is not provided, it defaults to the parent safe. + +#### Clear Snapshot Delegate + +```bash +./target/release/subsafe-commander \ + --command clear-delegate \ + --parent 0xYourParentSafe \ + --sub-safes 0xChild1,0xChild2 +``` + +## Development + +### Run Tests + +```bash +cargo test +``` + +### Run with Logging + +```bash +RUST_LOG=info cargo run -- --command claim --parent 0x... +``` + +### Format Code + +```bash +cargo fmt +``` + +### Lint + +```bash +cargo clippy +``` + +## Architecture + +The Rust implementation follows a modular architecture: + +- `main.rs` - CLI entry point and command routing +- `environment.rs` - Environment configuration +- `safe.rs` - Safe operations and transaction encoding +- `multisend.rs` - Multisend transaction batching +- `airdrop/` - Airdrop-specific logic + - `allocation.rs` - Fetching allocation data + - `encode.rs` - Encoding claim transactions + - `tx.rs` - Transaction building +- `snapshot/` - Snapshot delegation + - `delegate_registry.rs` - Delegation ID handling + - `tx.rs` - Delegate transaction building +- `add_owner.rs` - Add owner functionality +- `token_transfer.rs` - ERC20 and native token transfers +- `dune.rs` - Dune Analytics integration using [`duners`](https://crates.io/crates/duners) crate +- `utils.rs` - Utility functions + +## Differences from Python Version + +1. **Type Safety**: Rust's strong type system prevents many runtime errors +2. **Performance**: Faster execution, especially for large batches +3. **Memory Safety**: No garbage collector, deterministic memory usage +4. **Async/Await**: Built-in async support with Tokio +5. **Error Handling**: Explicit error handling with `Result` types + +## Docker Support + +### Build Docker Image + +```bash +docker build -f rs/Dockerfile -t subsafe-commander:rust . +``` + +### Run with Docker + +```bash +docker run --rm --env-file .env \ + subsafe-commander:rust \ + --command claim \ + --parent $PARENT_SAFE +``` + +## Contributing + +Contributions are welcome! Please ensure: + +1. All tests pass: `cargo test` +2. Code is formatted: `cargo fmt` +3. No clippy warnings: `cargo clippy` + +## License + +Same as the parent project. + +## Acknowledgments + +This is a Rust port of the original Python implementation. The core logic and algorithms remain the same, with adaptations for Rust's type system and best practices. + diff --git a/rs/src/dune.rs b/rs/src/dune.rs new file mode 100644 index 0000000..3f21528 --- /dev/null +++ b/rs/src/dune.rs @@ -0,0 +1,100 @@ +use alloy::primitives::Address; +use anyhow::{Context, Result}; +use duners::client::DuneClient as DunersClient; +use duners::parameters::Parameter; +use serde::Deserialize; +use std::str::FromStr; + +/// Result row from Dune query for Safe families +/// Query 1416166 returns: index (number) and bracket (Address) +#[derive(Debug, Deserialize)] +struct SafeFamilyRow { + // index: i64, + bracket: String, +} + +/// Dune API client wrapper for SubSafe Commander +pub struct DuneClient { + client: DunersClient, +} + +impl DuneClient { + /// Create a new Dune client with an API key + pub fn new(api_key: String) -> Self { + Self { + client: DunersClient::new(&api_key), + } + } + + /// Fetch child safes from parent via Dune query + /// + /// Uses Dune query 1416166 to fetch Safe families on Ethereum mainnet + pub async fn fetch_child_safes( + &self, + parent: &str, + index_from: i64, + index_to: i64, + ) -> Result> { + // Query parameters for Safe Families query (query_id: 1416166) + let params = vec![ + Parameter::text("Blockchain", "ethereum"), + Parameter::text("ParentSafe", parent), + Parameter::number("IndexFrom", &index_from.to_string()), + Parameter::number("IndexTo", &index_to.to_string()), + ]; + + log::info!( + "Fetching child safes from Dune for parent {} (indices {}-{})", + parent, + index_from, + index_to + ); + + // Execute query and wait for results with 60 second timeout + let results = self + .client + .refresh::(1416166, Some(params), None) + .await + .map_err(|e| anyhow::anyhow!("Failed to execute and fetch Dune query: {:?}", e))?; + + let rows = results.result.rows; + + if rows.is_empty() { + anyhow::bail!("No results returned for parent {}", parent); + } + + log::info!("Got fleet of size {}", rows.len()); + + // Parse addresses + let addresses: Result> = rows + .iter() + .map(|row| { + Address::from_str(&row.bracket) + .with_context(|| format!("Invalid address: {}", row.bracket)) + }) + .collect(); + + addresses + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_dune_client_creation() { + let api_key = std::env::var("DUNE_API_KEY"); + if api_key.is_err() { + eprintln!("DUNE_API_KEY is not set - skipping test"); + return; + } + let client = DuneClient::new(api_key.unwrap()); + let safes = client + .fetch_child_safes("0x20026f06342e16415b070ae3bdb3983af7c51c95", 0, 10) + .await + .unwrap(); + eprintln!("safes: {:#?}", safes); + assert!(!safes.is_empty()); + } +} diff --git a/rs/src/environment.rs b/rs/src/environment.rs new file mode 100644 index 0000000..85c307c --- /dev/null +++ b/rs/src/environment.rs @@ -0,0 +1,57 @@ +use alloy::network::Ethereum; +use alloy::primitives::Address; +use alloy::providers::{Provider, RootProvider}; +use alloy::rpc::client::RpcClient; +use alloy::signers::local::PrivateKeySigner; +use anyhow::{Context, Result}; +use dotenvy::dotenv; +use std::env; +use std::str::FromStr; +use std::sync::Arc; + +/// Configuration loaded from environment variables +#[derive(Debug, Clone)] +pub struct Config { + // TODO: Direct exec for single owner safe (i.e. when proposer is owner). + pub _provider: Arc>, + pub proposer: Option, + pub dune_api_key: Option, + pub _parent_safe: Option
, +} + +impl Config { + /// Load configuration from environment variables + pub async fn from_env() -> Result { + // Load .env file if it exists + let _ = dotenv(); + + let node_url = + env::var("NODE_URL").unwrap_or_else(|_| "http://reth.dappnode:8545".to_string()); + + let dune_api_key = env::var("DUNE_API_KEY").ok(); + let proposer = if let Ok(proposer_pk) = env::var("PROPOSER_PK") { + Some(PrivateKeySigner::from_str(&proposer_pk)?) + } else { + None + }; + let provider = RootProvider::new(RpcClient::new_http(node_url.parse()?)); + + // Log the network we're connected to + let chain_id = provider + .get_chain_id() + .await + .context("Failed to get chain ID")?; + log::info!("Using network with chain ID: {}", chain_id); + + Ok(Self { + _provider: Arc::new(provider), + proposer, + _parent_safe: env::var("PARENT_SAFE") + .ok() + .as_ref() + .map(|x| Address::from_str(x)) + .transpose()?, + dune_api_key, + }) + } +} diff --git a/rs/src/log_setup.rs b/rs/src/log_setup.rs new file mode 100644 index 0000000..9fc2ba8 --- /dev/null +++ b/rs/src/log_setup.rs @@ -0,0 +1,20 @@ +use env_logger::Builder; +use log::LevelFilter; +use std::io::Write; + +/// Initialize the logger with a specific level +pub fn init_with_level(level: LevelFilter) { + Builder::new() + .format(|buf, record| { + writeln!( + buf, + "{} [{}] - {}", + chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), + record.level(), + record.args() + ) + }) + .filter_level(level) + .parse_default_env() + .init(); +} diff --git a/rs/src/main.rs b/rs/src/main.rs new file mode 100644 index 0000000..9a6f206 --- /dev/null +++ b/rs/src/main.rs @@ -0,0 +1,256 @@ +mod dune; +mod environment; +mod log_setup; +mod multisend; +mod ops; +mod safe; +mod utils; + +use alloy::primitives::{Address, Bytes, U256}; +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use std::str::FromStr; + +use dune::DuneClient; +use environment::Config; +use multisend::{MultiSendTx, partition_transactions}; +use ops::{ + add_owner::{AddOwnerArgs, build_add_owner_with_threshold}, + airdrop::transactions_for as airdrop_transactions, + snapshot::{SnapshotCommand, transactions_for as snapshot_transactions}, +}; + +use crate::{ + multisend::{MULTISEND_CONTRACT, build_encoded_multisend}, + safe::{ + api::post_safe_tx, + tx::{SafeTransaction, get_safe_nonce}, + }, +}; + +/// Supported commands for the SubSafe Commander +#[derive(Debug, Clone, Subcommand)] +enum Command { + /// Claim SAFE airdrop + Claim, + + /// Add an owner to sub-safes + AddOwner { + /// New owner address (required) + #[arg(long)] + new_owner: String, + + /// Signature threshold (default: 1) + #[arg(long, default_value = "1")] + threshold: u64, + }, + + /// Set delegate for snapshot voting + SetDelegate { + /// Delegate address (defaults to parent if omitted) + #[arg(long)] + delegate: Option, + }, + + /// Clear delegate for snapshot voting + ClearDelegate, +} + +/// SubSafe Commander - Control your fleet of Safes from the command line +#[derive(Parser, Debug)] +#[command(author, version, about)] +struct Args { + /// Parent Safe address (owner of all sub-safes) + #[arg(short, long, env = "PARENT_SAFE")] + parent: String, + + /// Comma-separated list of sub-safe addresses (optional, will fetch from Dune if not provided) + #[arg(short, long)] + sub_safes: Option, + + /// Index to start from in the list of children + #[arg(long, default_value = "0")] + index_from: i64, + + /// Number of safes to process + #[arg(long, default_value = "1000")] + num_safes: i64, + + /// Subcommand to execute + #[command(subcommand)] + command: Command, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize logger + log_setup::init_with_level(log::LevelFilter::Info); + log::info!("SubSafe Commander (Rust) starting..."); + + // Parse CLI arguments + let args = Args::parse(); + + // Load configuration + let config = Config::from_env().await?; + + // Parse parent address + let parent_address = Address::from_str(&args.parent) + .with_context(|| format!("Invalid parent address: {}", args.parent))?; + + // Get children addresses + let children_addresses = if let Some(sub_safes) = &args.sub_safes { + // Parse from comma-separated list + sub_safes + .split(',') + .map(|s| { + Address::from_str(s.trim()) + .with_context(|| format!("Invalid sub-safe address: {}", s)) + }) + .collect::>>()? + } else { + // Fetch from Dune + let dune_api_key = config + .dune_api_key + .context("DUNE_API_KEY required when --sub-safes is not provided")?; + + let dune_client = DuneClient::new(dune_api_key); + let index_to = args.index_from + args.num_safes; + + dune_client + .fetch_child_safes(&args.parent, args.index_from, index_to) + .await? + }; + + log::info!("Parent: {:?}", parent_address); + log::info!("Processing {} child safe(s)", children_addresses.len()); + + // Build transactions based on command + let transactions: Vec = match args.command { + Command::Claim => { + log::info!("Building CLAIM transactions"); + airdrop_transactions(parent_address, &children_addresses).await? + } + Command::AddOwner { + new_owner, + threshold, + } => { + let new_owner = Address::from_str(&new_owner) + .with_context(|| format!("Invalid new owner address: {}", new_owner))?; + log::info!("Building ADD_OWNER transactions for {:?}", new_owner); + + let add_owner_args = AddOwnerArgs::with_threshold(new_owner, threshold); + children_addresses + .iter() + .map(|child| { + build_add_owner_with_threshold(parent_address, *child, &add_owner_args) + }) + .collect::>>()? + } + Command::SetDelegate { delegate } => { + log::info!("Building SET_DELEGATE transactions"); + + let delegate = delegate + .as_ref() + .map(|s| Address::from_str(s)) + .transpose() + .context("Invalid delegate address")?; + snapshot_transactions( + parent_address, + &children_addresses, + SnapshotCommand::SetDelegate, + delegate, + )? + } + Command::ClearDelegate => { + log::info!("Building CLEAR_DELEGATE transactions"); + snapshot_transactions( + parent_address, + &children_addresses, + SnapshotCommand::ClearDelegate, + None, + )? + } + }; + + log::info!("Built {:#?} transaction(s)", transactions); + + if transactions.is_empty() { + log::warn!("No transactions to execute"); + return Ok(()); + } + + // Partition if needed and display summary + let partitions = partition_transactions(&transactions)?; + log::info!( + "Transactions will be executed in {} batch(es)", + partitions.len() + ); + + let chain_id = U256::from(1); // mainnet; make configurable + for (i, partition) in partitions.iter().enumerate() { + log::info!("Batch {}: {} transaction(s)", i + 1, partition.len()); + + // Encode multisend batch + let encoded_multisend = build_encoded_multisend(partition)?; + // TODO: fetch actual nonce. + let nonce = get_safe_nonce(parent_address, &config._provider).await?; + + // Construct the Safe transaction (unsigned) + let mut safe_tx = SafeTransaction::new( + Address::from_str(MULTISEND_CONTRACT)?, + U256::ZERO, + encoded_multisend.clone(), + ) + .with_nonce(nonce) + .with_operation(safe::tx::SafeOperation::DelegateCall); + + // Compute Safe transaction hash + let tx_hash = safe_tx.safe_tx_hash(parent_address, chain_id); + let tx_hash_hex = format!("0x{}", hex::encode(tx_hash)); + + if let Some(ref signer) = config.proposer { + log::info!("🔏 Signing batch {} with proposer key...", i + 1); + // Build + sign the multisend Safe transaction + safe_tx.sign(parent_address, chain_id, signer)?; + + log::info!("✅ Built and signed Safe transaction."); + log::info!("Transaction hash: {tx_hash_hex}"); + + // Post it to the Safe Transaction Service + post_safe_tx( + &safe_tx, + parent_address, + &safe_tx.signatures_blob(), + &Bytes::from(tx_hash.as_slice().to_vec()), + signer.address(), + // &config.safe_tx_service_url, + "https://api.safe.global/tx-service/eth", + ) + .await?; + + log::info!( + "Transaction queue: https://app.safe.global/transactions/queue?safe=eth:{:?}", + parent_address + ); + } else { + log::info!("Unsigned Safe transaction built:"); + log::info!(" - Batch: {}", i + 1); + log::info!(" - Nonce: {}", nonce); + log::info!(" - To: {:?}", safe_tx.to); + log::info!(" - Operation: {:?}", safe_tx.operation); + log::info!(" - Data: 0x{}", hex::encode(safe_tx.data)); + log::info!(" - SafeTx hash: {tx_hash_hex}"); + log::info!( + "You can submit this manually via: https://app.safe.global/transactions/queue?safe=eth:{:?}", + parent_address + ); + } + } + + log::info!("✓ All transactions prepared successfully"); + log::info!( + "Note: Actual signing and submission requires integration with Safe Transaction Service" + ); + + Ok(()) +} diff --git a/rs/src/multisend.rs b/rs/src/multisend.rs new file mode 100644 index 0000000..627bb98 --- /dev/null +++ b/rs/src/multisend.rs @@ -0,0 +1,165 @@ +use alloy::dyn_abi::DynSolValue; +use alloy::primitives::{Address, Bytes, U256, keccak256}; +use anyhow::Result; + +use crate::safe::tx::SafeOperation; +use crate::utils::partition_array; + +/// Multisend contract address +pub const MULTISEND_CONTRACT: &str = "0x9641d764fc13c8B624c04430C7356C1C7C8102e2"; + +/// Maximum number of transactions per batch +/// See benchmarks: https://github.com/bh2smith/subsafe-commander/issues/4#issuecomment-1297738947 +pub const BATCH_SIZE_LIMIT: usize = 80; + +/// A transaction for multisend +#[derive(Debug, Clone)] +pub struct MultiSendTx { + pub operation: SafeOperation, + pub to: Address, + pub value: U256, + pub data: Bytes, +} + +impl MultiSendTx { + /// Create a new multisend transaction with CALL operation + pub fn new(to: Address, value: U256, data: Bytes) -> Self { + Self { + operation: SafeOperation::Call, + to, + value, + data, + } + } + + /// Encode this transaction for multisend + pub fn encode(&self) -> Vec { + let mut encoded = Vec::new(); + + // Operation (1 byte) + encoded.push(self.operation as u8); + + // To address (20 bytes) + encoded.extend_from_slice(self.to.as_slice()); + + // Value (32 bytes) + let value_bytes = self.value.to_be_bytes::<32>(); + encoded.extend_from_slice(&value_bytes); + + // Data length (32 bytes) + let data_len = self.data.len(); + let len_bytes = U256::from(data_len).to_be_bytes::<32>(); + encoded.extend_from_slice(&len_bytes); + + // Data + encoded.extend_from_slice(&self.data); + + encoded + } +} + +/// Build encoded multisend data from a list of transactions +pub fn build_encoded_multisend(transactions: &[MultiSendTx]) -> Result { + log::info!("Packing {} transactions into MultiSend", transactions.len()); + + if transactions.len() > BATCH_SIZE_LIMIT { + anyhow::bail!( + "Too many transactions for single batch ({}), use partitioned build!", + transactions.len() + ); + } + + // Concatenate all encoded transactions + let mut encoded_txs = Vec::new(); + for tx in transactions { + encoded_txs.extend_from_slice(&tx.encode()); + } + // Call the contract with the selector + ABI-encoded bytes only once: + let selector = &keccak256("multiSend(bytes)")[..4]; + let call_data = [selector, &DynSolValue::Bytes(encoded_txs).abi_encode()].concat(); + + Ok(Bytes::from(call_data)) +} + +/// Partition transactions into batches according to BATCH_SIZE_LIMIT +pub fn partition_transactions(transactions: &[MultiSendTx]) -> Result>> { + let partitions = partition_array(transactions, BATCH_SIZE_LIMIT)?; + + if partitions.len() == 1 { + log::info!("Building a single multi-exec transaction"); + } else { + log::info!( + "Partitioned {} transactions into {} batches", + transactions.len(), + partitions.len() + ); + } + + Ok(partitions) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use crate::ops::airdrop::transactions_for; + + use super::*; + + #[test] + fn test_multisend_tx_encode() { + let tx = MultiSendTx::new( + Address::ZERO, + U256::from(100), + Bytes::from(vec![0x01, 0x02, 0x03]), + ); + + let encoded = tx.encode(); + + // Check operation byte + assert_eq!(encoded[0], 0); + + // Check that encoding has the right length + // 1 (op) + 20 (address) + 32 (value) + 32 (data len) + 3 (data) + assert_eq!(encoded.len(), 88); + } + + #[test] + fn test_partition_transactions() { + let txs: Vec = (0..200) + .map(|i| MultiSendTx::new(Address::ZERO, U256::from(i), Bytes::from(vec![]))) + .collect(); + + let partitions = partition_transactions(&txs).unwrap(); + + // Should be partitioned into 3 batches: 80, 80, 40 + assert_eq!(partitions.len(), 3); + assert_eq!(partitions[0].len(), 80); + assert_eq!(partitions[1].len(), 80); + assert_eq!(partitions[2].len(), 40); + } + + #[tokio::test] + async fn test_build_encoded_multisend() { + let parent = Address::from_str("0x20026F06342e16415b070ae3bdB3983AF7c51C95").unwrap(); + let children = + ["0x8926c4d7f8ada5b74827bfea51ae60517dde21cf"].map(|x| Address::from_str(x).unwrap()); + let txs = transactions_for(parent, children.as_slice()).await.unwrap(); + let x = build_encoded_multisend(&txs).unwrap(); + // Updated to match correct behavior: MultiSendTx targets child safe, not airdrop contract + assert_eq!( + hex::encode(x), + "8d80ff0a00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000572008926c4d7f8ada5b74827bfea51ae60517dde21cf000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002646a761202000000000000000000000000a0b937d5c8e32a80e3a8ed4227cd020221544ee60000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e00000000000000000000000000000000000000000000000000000000000000064166bbd3b42d030c88d965b91acbcdbe5243c86ff2720c4a68551fdd8ff1c4b732048dd1a00000000000000000000000020026f06342e16415b070ae3bdb3983af7c51c9500000000000000000000000000000000ffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004100000000000000000000000020026f06342e16415b070ae3bdb3983af7c51c9500000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000008926c4d7f8ada5b74827bfea51ae60517dde21cf000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002646a761202000000000000000000000000c0fde70a65c7569fe919be57492228dee8cdb5850000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e00000000000000000000000000000000000000000000000000000000000000064166bbd3b94c5a4c996651070ac0e0c31fbffecc478ea48a1bee26aa395d4f608fdd38aa700000000000000000000000020026f06342e16415b070ae3bdb3983af7c51c9500000000000000000000000000000000ffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004100000000000000000000000020026f06342e16415b070ae3bdb3983af7c51c95000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ) + } + + #[tokio::test] + async fn test_build_encoded_multisend_empty() { + let data = build_encoded_multisend(&[]).unwrap(); + // TODO Validate the encoded multisend. + assert_eq!( + hex::encode(data), + "8d80ff0a00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000" + ) + } +} diff --git a/rs/src/ops/add_owner.rs b/rs/src/ops/add_owner.rs new file mode 100644 index 0000000..abe89a1 --- /dev/null +++ b/rs/src/ops/add_owner.rs @@ -0,0 +1,85 @@ +use alloy::dyn_abi::DynSolValue; +use alloy::primitives::{Address, Bytes, U256}; +use anyhow::Result; + +use crate::multisend::MultiSendTx; +use crate::safe::tx::SafeTransaction; + +/// Arguments for adding an owner to a Safe +#[derive(Debug, Clone)] +pub struct AddOwnerArgs { + pub new_owner: Address, + pub threshold: u64, +} + +impl AddOwnerArgs { + /// Create new AddOwnerArgs with custom threshold + pub fn with_threshold(new_owner: Address, threshold: u64) -> Self { + Self { + new_owner, + threshold, + } + } +} + +/// Build a multisend transaction for adding an owner with threshold +pub fn build_add_owner_with_threshold( + parent_address: Address, + sub_safe_address: Address, + params: &AddOwnerArgs, +) -> Result { + log::info!( + "Building addOwnerWithThreshold({:?}, {}) on {:?} as MultiSendTx from {:?}", + params.new_owner, + params.threshold, + sub_safe_address, + parent_address + ); + + // Encode addOwnerWithThreshold(address owner, uint256 _threshold) + let method_signature = "addOwnerWithThreshold(address,uint256)"; + let selector = &alloy::primitives::keccak256(method_signature.as_bytes())[..4]; + + let method_params = vec![ + DynSolValue::Address(params.new_owner), + DynSolValue::Uint(U256::from(params.threshold), 256), + ]; + + let mut data = Vec::from(selector); + let tuple = DynSolValue::Tuple(method_params); + let encoded_params = tuple.abi_encode_params(); + data.extend_from_slice(&encoded_params); + + let transaction = SafeTransaction::new(sub_safe_address, U256::ZERO, Bytes::from(data)); + + transaction.as_multisend(sub_safe_address, &parent_address) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn test_add_owner_args_with_threshold() { + let owner = Address::from_str("0x1234567890123456789012345678901234567890").unwrap(); + let args = AddOwnerArgs::with_threshold(owner, 2); + + assert_eq!(args.new_owner, owner); + assert_eq!(args.threshold, 2); + } + + #[test] + fn test_build_add_owner_with_threshold() { + let parent = Address::from_str("0x1234567890123456789012345678901234567890").unwrap(); + let child = Address::from_str("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd").unwrap(); + let new_owner = Address::from_str("0x9876543210987654321098765432109876543210").unwrap(); + + let args = AddOwnerArgs::with_threshold(new_owner, 1); + let tx = build_add_owner_with_threshold(parent, child, &args).unwrap(); + + assert_eq!(tx.to, child); + assert_eq!(tx.value, U256::ZERO); + assert!(!tx.data.is_empty()); + } +} diff --git a/rs/src/ops/airdrop/allocation.rs b/rs/src/ops/airdrop/allocation.rs new file mode 100644 index 0000000..5c20fa1 --- /dev/null +++ b/rs/src/ops/airdrop/allocation.rs @@ -0,0 +1,134 @@ +use alloy::{ + dyn_abi::DynSolValue, + primitives::{Address, FixedBytes, U256}, +}; +use anyhow::{Context, Result}; +use reqwest; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +/// Allocation API base URL +const ALLOCATION_BASE_URL: &str = "https://safe-claiming-app-data.gnosis-safe.io/allocations"; + +/// Maximum U128 value for claiming all tokens +pub const MAX_U128: u128 = 340282366920938463463374607431768211455; + +/// Represents Safe Airdrop Allocation Data +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Allocation { + pub tag: String, + pub account: String, + pub chain_id: u64, + pub contract: String, + pub vesting_id: String, + pub duration_weeks: u64, + pub start_date: u64, + pub amount: String, + pub curve: u64, + pub proof: Vec, +} + +impl Allocation { + /// Returns API URL for a given address + fn api_url(address: &str) -> String { + let chain_id = 1; // Airdrop was only on mainnet + format!("{}/{}/{}.json", ALLOCATION_BASE_URL, chain_id, address) + } + + /// Fetches allocation data from the Safe Foundation API + pub async fn from_address(safe_address: &str) -> Result> { + let url = Self::api_url(safe_address); + + // Create a client that doesn't use system proxy (fixes macOS test issues) + let client = reqwest::Client::builder() + .no_proxy() + .use_rustls_tls() + .build() + .context("Failed to create HTTP client")?; + + let response = client + .get(&url) + .send() + .await + .context("Failed to fetch allocation data")?; + + if !response.status().is_success() { + let text = response.text().await?; + if text.contains("NoSuchKey") { + anyhow::bail!("{} is not eligible for SAFE airdrop", safe_address); + } + anyhow::bail!("Allocation request failed: {}", text); + } + + let allocations: Vec = response + .json() + .await + .context("Failed to parse allocation response")?; + + Ok(allocations) + } + + pub fn encoded_claim_params(&self, beneficiary: &str) -> Result> { + let vesting_id_bytes = if self.vesting_id.starts_with("0x") { + alloy::hex::decode(&self.vesting_id[2..]) + .map_err(|e| anyhow::anyhow!("Invalid vesting ID hex: {}", e))? + } else { + alloy::hex::decode(&self.vesting_id) + .map_err(|e| anyhow::anyhow!("Invalid vesting ID hex: {}", e))? + }; + + if vesting_id_bytes.len() != 32 { + anyhow::bail!("Vesting ID must be 32 bytes"); + } + + let vesting_bytes_fixed = FixedBytes::<32>::from_slice(&vesting_id_bytes); + let beneficiary_addr = Address::from_str(beneficiary)?; + + Ok(vec![ + DynSolValue::FixedBytes(vesting_bytes_fixed, 32), + DynSolValue::Address(beneficiary_addr), + DynSolValue::Uint(U256::from(MAX_U128), 128), + ]) + } + + /// Get the airdrop contract address + pub fn contract_address(&self) -> Result
{ + Address::from_str(&self.contract) + .with_context(|| format!("Invalid contract address: {}", self.contract)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_api_url() { + let url = Allocation::api_url("0x3A11F4c84688a1264690d696D8D807a25Ee02dd2"); + assert_eq!( + url, + "https://safe-claiming-app-data.gnosis-safe.io/allocations/1/0x3A11F4c84688a1264690d696D8D807a25Ee02dd2.json" + ); + } + + #[tokio::test] + async fn test_allocation_from_address() { + let alloc = Allocation::from_address("0x3A11F4c84688a1264690d696D8D807a25Ee02dd2") + .await + .unwrap(); + eprintln!("Allocations {:#?}", alloc); + assert!(!alloc.is_empty()); + + let alloc = Allocation::from_address("0x8F66b9DAC2E4eb07Da11DcbA76111b4677Dbac18") + .await + .unwrap(); + eprintln!("Allocations {:#?}", alloc); + assert!(!alloc.is_empty()); + } + + #[test] + fn test_max_u128() { + assert_eq!(MAX_U128, u128::MAX); + } +} diff --git a/rs/src/ops/airdrop/encode.rs b/rs/src/ops/airdrop/encode.rs new file mode 100644 index 0000000..aef59a3 --- /dev/null +++ b/rs/src/ops/airdrop/encode.rs @@ -0,0 +1,60 @@ +use alloy::dyn_abi::DynSolValue; +use alloy::primitives::{Bytes, U256}; +use anyhow::Result; + +use crate::ops::airdrop::allocation::Allocation; +use crate::safe::tx::SafeTransaction; + +/// Encode a claim transaction for the Safe airdrop +pub fn encode_claim(allocation: &Allocation, beneficiary: &str) -> Result { + let contract_address = allocation.contract_address()?; + + // claimVestedTokens(bytes32 vestingId, address beneficiary, uint128 tokensToClaim) + let method_signature = "claimVestedTokens(bytes32,address,uint128)"; + let selector = &alloy::primitives::keccak256(method_signature.as_bytes())[..4]; + + let params = allocation.encoded_claim_params(beneficiary)?; + + let mut data = Vec::from(selector); + let tuple = DynSolValue::Tuple(params); + let encoded_params = tuple.abi_encode_params(); + data.extend_from_slice(&encoded_params); + + Ok(SafeTransaction::new( + contract_address, + U256::ZERO, + Bytes::from(data), + )) +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_encode_claim() { + let allocation = Allocation { + tag: "user".to_string(), + account: "0x8926c4D7F8AdA5B74827bFEa51aE60517Dde21cf".to_string(), + chain_id: 1, + contract: "0xA0b937D5c8E32a80E3a8ed4227CD020221544ee6".to_string(), + vesting_id: "0x42d030c88d965b91acbcdbe5243c86ff2720c4a68551fdd8ff1c4b732048dd1a" + .to_string(), + duration_weeks: 52, + start_date: 1234567890, + amount: "617828971259105640448".to_string(), + curve: 0, + proof: vec![], + }; + + let beneficiary = "0x20026F06342e16415b070ae3bdB3983AF7c51C95"; + let tx = encode_claim(&allocation, beneficiary).unwrap(); + + assert_eq!(tx.to, allocation.contract_address().unwrap()); + assert_eq!( + hex::encode(tx.data), + "166bbd3b42d030c88d965b91acbcdbe5243c86ff2720c4a68551fdd8ff1c4b732048dd1a00000000000000000000000020026f06342e16415b070ae3bdb3983af7c51c9500000000000000000000000000000000ffffffffffffffffffffffffffffffff" + ); + } +} diff --git a/rs/src/ops/airdrop/mod.rs b/rs/src/ops/airdrop/mod.rs new file mode 100644 index 0000000..d2e2bdc --- /dev/null +++ b/rs/src/ops/airdrop/mod.rs @@ -0,0 +1,5 @@ +pub mod allocation; +pub mod encode; +pub mod tx; + +pub use tx::transactions_for; diff --git a/rs/src/ops/airdrop/tx.rs b/rs/src/ops/airdrop/tx.rs new file mode 100644 index 0000000..42426d4 --- /dev/null +++ b/rs/src/ops/airdrop/tx.rs @@ -0,0 +1,66 @@ +use alloy::primitives::Address; +use anyhow::Result; + +use crate::multisend::MultiSendTx; +use crate::ops::airdrop::{allocation::Allocation, encode::encode_claim}; + +/// Build transactions for claiming airdrops for all child safes +pub async fn transactions_for( + parent_address: Address, + children: &[Address], +) -> Result> { + log::info!("Using Parent Safe {:?} as Beneficiary", parent_address); + + let mut transactions = Vec::new(); + + for child in children { + let child_str = child.to_string(); + + match Allocation::from_address(&child_str).await { + Ok(allocations) => { + log::info!( + "Found {} allocation(s) for child {:?}", + allocations.len(), + child + ); + + for allocation in allocations { + // Convert to lowercase for API compatibility + let parent_str = format!("{:?}", parent_address).to_lowercase(); + match encode_claim(&allocation, &parent_str) { + Ok(tx) => transactions.push(tx.as_multisend(*child, &parent_address)?), + Err(e) => { + log::error!("Failed to build claim for child {:?}: {}", child, e); + } + } + } + } + Err(e) => { + log::warn!("No allocation found for child {:?}: {}", child, e); + } + } + } + + log::info!("Built {} claim transaction(s)", transactions.len()); + Ok(transactions) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[tokio::test] + async fn test_transactions_for() { + let _ = env_logger::builder() + .is_test(true) + .filter_level(log::LevelFilter::Info) + .try_init(); + let parent = Address::from_str("0x3A11F4c84688a1264690d696D8D807a25Ee02dd2").unwrap(); + let children: Vec
= + vec![Address::from_str("0x8F66b9DAC2E4eb07Da11DcbA76111b4677Dbac18").unwrap()]; + + let txs = transactions_for(parent, &children).await.unwrap(); + assert!(txs.len() > 1); + } +} diff --git a/rs/src/ops/mod.rs b/rs/src/ops/mod.rs new file mode 100644 index 0000000..c147a87 --- /dev/null +++ b/rs/src/ops/mod.rs @@ -0,0 +1,4 @@ +/// Operations for the SubSafe Commander +pub mod add_owner; +pub mod airdrop; +pub mod snapshot; diff --git a/rs/src/ops/snapshot/delegate_registry.rs b/rs/src/ops/snapshot/delegate_registry.rs new file mode 100644 index 0000000..994e906 --- /dev/null +++ b/rs/src/ops/snapshot/delegate_registry.rs @@ -0,0 +1,75 @@ +use alloy::primitives::Address; +use std::str::FromStr; + +/// Snapshot delegation contract address +pub const DELEGATION_CONTRACT: &str = "0x469788fE6E9E9681C6ebF3bF78e7Fd26Fc015446"; + +/// Holds logic for constructing and converting various representations of a Delegation ID +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DelegationId { + bytes: [u8; 32], +} + +impl DelegationId { + /// Build from a regular string, padding with zeros + pub fn from_str(value_str: &str) -> Self { + let mut bytes = [0u8; 32]; + let value_bytes = value_str.as_bytes(); + let len = std::cmp::min(value_bytes.len(), 32); + bytes[..len].copy_from_slice(&value_bytes[..len]); + Self { bytes } + } + + /// Get hex representation + pub fn hex(&self) -> String { + format!("0x{}", hex::encode(self.bytes)) + } + + /// Get bytes representation + pub fn as_bytes(&self) -> &[u8; 32] { + &self.bytes + } +} + +impl std::fmt::Display for DelegationId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.hex()) + } +} + +/// Lazy static for SAFE_DELEGATION_ID +pub const SAFE_DELEGATION_ID: &str = "safe.eth"; + +/// Get the delegation contract address +pub fn delegation_address() -> Address { + Address::from_str(DELEGATION_CONTRACT).expect("Invalid delegation contract address") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_delegation_id_hex() { + let id = DelegationId::from_str("safe.eth"); + let hex = id.hex(); + assert!(hex.starts_with("0x")); + assert_eq!(hex.len(), 66); // 0x + 64 hex chars + } + + #[test] + fn test_delegation_id_equality() { + let id1 = DelegationId::from_str("safe.eth"); + let id2 = DelegationId::from_str("safe.eth"); + assert_eq!(id1, id2); + } + + #[test] + fn test_delegation_address() { + let addr = delegation_address(); + assert_eq!( + format!("{:?}", addr), + "0x469788fe6e9e9681c6ebf3bf78e7fd26fc015446" + ); + } +} diff --git a/rs/src/ops/snapshot/mod.rs b/rs/src/ops/snapshot/mod.rs new file mode 100644 index 0000000..22a682a --- /dev/null +++ b/rs/src/ops/snapshot/mod.rs @@ -0,0 +1,4 @@ +pub mod delegate_registry; +pub mod tx; + +pub use tx::{SnapshotCommand, transactions_for}; diff --git a/rs/src/ops/snapshot/tx.rs b/rs/src/ops/snapshot/tx.rs new file mode 100644 index 0000000..d7bd98f --- /dev/null +++ b/rs/src/ops/snapshot/tx.rs @@ -0,0 +1,138 @@ +use alloy::dyn_abi::DynSolValue; +use alloy::primitives::{Address, U256}; +use anyhow::Result; + +use crate::multisend::MultiSendTx; +use crate::ops::snapshot::delegate_registry::{ + DelegationId, SAFE_DELEGATION_ID, delegation_address, +}; +use crate::safe::tx::encode_contract_method; + +/// Snapshot contract commands +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SnapshotCommand { + SetDelegate, + ClearDelegate, +} + +impl SnapshotCommand { + /// Get the method name for this command + pub fn method_name(&self) -> &str { + match self { + SnapshotCommand::SetDelegate => "setDelegate", + SnapshotCommand::ClearDelegate => "clearDelegate", + } + } +} + +impl std::fmt::Display for SnapshotCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.method_name()) + } +} + +impl std::str::FromStr for SnapshotCommand { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "setDelegate" => Ok(SnapshotCommand::SetDelegate), + "clearDelegate" => Ok(SnapshotCommand::ClearDelegate), + _ => anyhow::bail!("Invalid snapshot command: {}", s), + } + } +} + +/// Build transactions for a given snapshot command +pub fn transactions_for( + parent_address: Address, + children: &[Address], + command: SnapshotCommand, + delegate: Option
, +) -> Result> { + let delegation_contract = delegation_address(); + let delegation_id = DelegationId::from_str(SAFE_DELEGATION_ID); + + let (params, method_signature) = match command { + SnapshotCommand::SetDelegate => { + let delegate_addr = delegate.unwrap_or(parent_address); + log::info!( + "Setting delegation for namespace {} to {:?}", + SAFE_DELEGATION_ID, + delegate_addr + ); + + // setDelegate(bytes32 id, address delegate) + let delegation_bytes = delegation_id.as_bytes(); + ( + vec![ + DynSolValue::FixedBytes(delegation_bytes.into(), 32), + DynSolValue::Address(delegate_addr), + ], + "setDelegate(bytes32,address)", + ) + } + SnapshotCommand::ClearDelegate => { + log::info!("Clearing delegation for namespace {}", SAFE_DELEGATION_ID); + + // clearDelegate(bytes32 id) + let delegation_bytes = delegation_id.as_bytes(); + ( + vec![DynSolValue::FixedBytes(delegation_bytes.into(), 32)], + "clearDelegate(bytes32)", + ) + } + }; + + let mut transactions = Vec::new(); + + for child in children { + let safe_tx = encode_contract_method( + delegation_contract, + method_signature, + params.clone(), + U256::ZERO, + ); + + transactions.push(safe_tx.as_multisend(*child, &parent_address)?); + } + + Ok(transactions) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn test_snapshot_command_from_str() { + assert_eq!( + "setDelegate".parse::().unwrap(), + SnapshotCommand::SetDelegate + ); + assert_eq!( + "clearDelegate".parse::().unwrap(), + SnapshotCommand::ClearDelegate + ); + } + + #[test] + fn test_snapshot_command_method_name() { + assert_eq!(SnapshotCommand::SetDelegate.method_name(), "setDelegate"); + assert_eq!( + SnapshotCommand::ClearDelegate.method_name(), + "clearDelegate" + ); + } + + #[test] + fn test_transactions_for_empty() { + let parent = Address::from_str("0x1234567890123456789012345678901234567890").unwrap(); + let children: Vec
= vec![]; + + let txs = transactions_for(parent, &children, SnapshotCommand::SetDelegate, None).unwrap(); + + assert_eq!(txs.len(), 0); + } +} diff --git a/rs/src/safe/api.rs b/rs/src/safe/api.rs new file mode 100644 index 0000000..42dcde8 --- /dev/null +++ b/rs/src/safe/api.rs @@ -0,0 +1,100 @@ +use alloy::primitives::{Address, Bytes}; +use anyhow::{Context, Result}; +use serde::Serialize; +use std::io::{self, Write}; + +use crate::safe::tx::SafeTransaction; + +/// The payload expected by the Safe Transaction Service +#[derive(Serialize, Debug)] +struct SafeTxPayload { + safe: String, + to: String, + value: String, + data: String, + operation: u8, + safe_tx_gas: String, + base_gas: String, + gas_price: String, + gas_token: String, + refund_receiver: String, + nonce: String, + contract_transaction_hash: String, + sender: String, + signature: String, +} + +/// Posts a signed Safe transaction to the Safe Transaction Service. +/// Returns the nonce on success, -1 on failure. +pub async fn post_safe_tx( + safe_tx: &SafeTransaction, + safe_address: Address, + signature: &Bytes, + safe_tx_hash: &Bytes, + sender_address: Address, + safe_service_url: &str, +) -> Result { + // 1. Basic signature presence check + if signature.is_empty() { + anyhow::bail!("Attempt to post unsigned transaction!"); + } + + // 2. Print transaction info + log::info!( + "posting transaction with hash 0x{} to {}", + hex::encode(safe_tx_hash), + safe_address + ); + + // 3. Confirm interactively + print!("are you sure? (y/n) "); + io::stdout().flush()?; + let mut confirm = String::new(); + io::stdin().read_line(&mut confirm)?; + if confirm.trim() != "y" { + println!("aborted."); + return Ok(-1); + } + + // 4. Build payload + let payload = SafeTxPayload { + safe: safe_address.to_string(), + to: safe_tx.to.to_string(), + value: safe_tx.value.to_string(), + data: hex::encode(&safe_tx.data), + operation: safe_tx.operation as u8, + safe_tx_gas: safe_tx.safe_tx_gas.to_string(), + base_gas: safe_tx.base_gas.to_string(), + gas_price: safe_tx.gas_price.to_string(), + gas_token: safe_tx.gas_token.to_string(), + refund_receiver: safe_tx.refund_receiver.to_string(), + nonce: safe_tx.nonce.to_string(), + contract_transaction_hash: format!("0x{}", hex::encode(safe_tx_hash)), + sender: sender_address.to_string(), + signature: format!("0x{}", hex::encode(signature)), + }; + + log::info!("Payload: {:#?}", payload); + + // 5. POST to Safe Transaction Service + let endpoint = format!("{safe_service_url}/api/v1/safes/{safe_address}/multisig-transactions/"); + + log::info!("Endpoint: {}", endpoint); + // Create a client that doesn't use system proxy (fixes macOS test issues) + let client = reqwest::Client::builder() + .no_proxy() + .use_rustls_tls() + .build() + .context("Failed to create HTTP client")?; + let resp = client.post(&endpoint).json(&payload).send().await?; + + // 6. Handle response + if resp.status().is_success() { + println!("✅ Transaction posted successfully."); + Ok(safe_tx.nonce.as_limbs()[0] as i64) + } else { + let text = resp.text().await?; + eprintln!("❌ Safe Transaction Service error: {text}"); + Ok(-1) + } +} diff --git a/rs/src/safe/mod.rs b/rs/src/safe/mod.rs new file mode 100644 index 0000000..a967694 --- /dev/null +++ b/rs/src/safe/mod.rs @@ -0,0 +1,2 @@ +pub mod api; +pub mod tx; diff --git a/rs/src/safe/tx.rs b/rs/src/safe/tx.rs new file mode 100644 index 0000000..0dec334 --- /dev/null +++ b/rs/src/safe/tx.rs @@ -0,0 +1,304 @@ +use alloy::dyn_abi::DynSolValue; +use alloy::primitives::{Address, B256, Bytes, FixedBytes, U256, keccak256}; +use alloy::providers::Provider; +use alloy::signers::SignerSync; +use alloy::signers::local::PrivateKeySigner; +use alloy::{hex, sol}; +use anyhow::{Context, Result}; + +use crate::multisend::MultiSendTx; + +/// Safe operation type +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum SafeOperation { + Call = 0, + DelegateCall = 1, +} + +/// Basic Safe Transaction Data +#[derive(Debug, Clone)] +pub struct SafeTransaction { + pub to: Address, + pub value: U256, + pub data: Bytes, + pub operation: SafeOperation, + pub safe_tx_gas: U256, + pub base_gas: U256, + pub gas_price: U256, + pub gas_token: Address, + pub refund_receiver: Address, + pub nonce: U256, + pub signers: Vec
, + pub signatures: Vec, // concatenated 65-byte signatures +} + +impl SafeTransaction { + /// Create a new Safe transaction with CALL operation + pub fn new(to: Address, value: U256, data: Bytes) -> Self { + Self { + to, + value, + data, + operation: SafeOperation::Call, + // We never use any of these. + safe_tx_gas: U256::ZERO, + base_gas: U256::ZERO, + gas_price: U256::ZERO, + gas_token: Address::ZERO, + refund_receiver: Address::ZERO, + nonce: U256::ZERO, + signers: Vec::new(), + signatures: Vec::new(), + } + } + // Don't forget to set the nonce. + pub fn with_nonce(mut self, nonce: U256) -> Self { + self.nonce = nonce; + self + } + + pub fn with_operation(mut self, operation: SafeOperation) -> Self { + self.operation = operation; + self + } + + pub fn hash(&self) -> B256 { + // keccak256("SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)") + let tx_typehash = keccak256( + b"SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)" + ); + + let data_hash = keccak256(&self.data); + + // abi.encode(TYPEHASH, to, value, keccak256(data), operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, nonce) + let encoded = DynSolValue::Tuple(vec![ + DynSolValue::FixedBytes(tx_typehash, 32), + DynSolValue::Address(self.to), + DynSolValue::Uint(self.value, 256), + DynSolValue::FixedBytes(data_hash, 32), + DynSolValue::Uint(U256::from(self.operation as u8), 8), + DynSolValue::Uint(self.safe_tx_gas, 256), + DynSolValue::Uint(self.base_gas, 256), + DynSolValue::Uint(self.gas_price, 256), + DynSolValue::Address(self.gas_token), + DynSolValue::Address(self.refund_receiver), + DynSolValue::Uint(self.nonce, 256), + ]) + .abi_encode(); + + keccak256(&encoded) + } + + pub fn safe_tx_hash(&self, safe_address: Address, chain_id: U256) -> FixedBytes<32> { + let domain = eip712_domain_separator(chain_id, safe_address); + let tx_hash = self.hash(); + + let mut preimage = Vec::with_capacity(2 + 32 + 32); + preimage.extend_from_slice(b"\x19\x01"); + preimage.extend_from_slice(domain.as_slice()); + preimage.extend_from_slice(tx_hash.as_slice()); + + keccak256(&preimage) + } + + pub fn as_multisend(&self, safe_address: Address, owner: &Address) -> Result { + let exec_data = encode_exec_transaction(safe_address, owner, self)?; + Ok(MultiSendTx::new(safe_address, U256::ZERO, exec_data)) + } + + pub fn sign( + &mut self, + safe_address: Address, + chain_id: U256, + signer: &PrivateKeySigner, + ) -> Result<()> { + let digest = self.safe_tx_hash(safe_address, chain_id); + log::info!("Signing Safe transaction with hash: {:#?}", digest); + let sig = signer.sign_hash_sync(&digest)?; + let sig_bytes = sig.as_bytes().to_vec(); + let signer_addr = signer.address(); + + // If already signed, skip + if self.signers.contains(&signer_addr) { + return Ok(()); + } + + // Determine sorted insert position + let mut new_signers = self.signers.clone(); + new_signers.push(signer_addr); + new_signers.sort(); + + let pos = new_signers.iter().position(|a| *a == signer_addr).unwrap(); + + // Insert signature bytes at correct offset (65 * index) + let insert_at = pos * 65; + self.signatures + .splice(insert_at..insert_at, sig_bytes.clone()); + + self.signers = new_signers; + Ok(()) + } + + /// Retrieve concatenated signatures blob + pub fn signatures_blob(&self) -> Bytes { + Bytes::from(self.signatures.clone()) + } +} + +/// Encode a contract method call +pub fn encode_contract_method( + contract_address: Address, + method_signature: &str, + params: Vec, + value: U256, +) -> SafeTransaction { + // Create method selector (first 4 bytes of keccak256 hash) + let selector = &keccak256(method_signature.as_bytes())[..4]; + + log::info!( + "Building {} with value {:.6} ETH", + method_signature, + value.to::() as f64 / 1e18 + ); + + let mut data = Vec::from(selector); + let tuple = DynSolValue::Tuple(params); + data.extend_from_slice(&tuple.abi_encode_params()); + + SafeTransaction::new(contract_address, value, Bytes::from(data)) +} + +/// Encode an execTransaction call for Safe +/// This is used for executing a transaction on behalf of a "SubSafe" from a parent (owner) +pub fn encode_exec_transaction( + _safe_address: Address, + owner: &Address, + transaction: &SafeTransaction, +) -> Result { + // Build the signature for a single owner approval + // Format: 0x000000000000000000000000{owner}0000000000000000000000000000000000000000000000000000000000000001 + let owner_hex = hex::encode(owner.as_slice()); + let sigs = format!( + "0x000000000000000000000000{}000000000000000000000000000000000000000000000000000000000000000001", + owner_hex + ); + + let sig_bytes = hex::decode(&sigs[2..]).context("Failed to decode signature")?; + + // Build execTransaction call + // function execTransaction( + // address to, + // uint256 value, + // bytes calldata data, + // Enum.Operation operation, + // uint256 safeTxGas, + // uint256 baseGas, + // uint256 gasPrice, + // address gasToken, + // address refundReceiver, + // bytes memory signatures + // ) + let method_signature = "execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)"; + let selector = &alloy::primitives::keccak256(method_signature.as_bytes())[..4]; + + let params = vec![ + DynSolValue::Address(transaction.to), + DynSolValue::Uint(transaction.value, 256), + DynSolValue::Bytes(transaction.data.to_vec()), + DynSolValue::Uint(U256::from(transaction.operation as u8), 8), + DynSolValue::Uint(U256::ZERO, 256), // safeTxGas + DynSolValue::Uint(U256::ZERO, 256), // baseGas + DynSolValue::Uint(U256::ZERO, 256), // gasPrice + DynSolValue::Address(Address::ZERO), // gasToken + DynSolValue::Address(Address::ZERO), // refundReceiver + DynSolValue::Bytes(sig_bytes), // signatures + ]; + + let mut data = Vec::from(selector); + let tuple = DynSolValue::Tuple(params); + data.extend_from_slice(&tuple.abi_encode_params()); + + Ok(Bytes::from(data)) +} + +fn eip712_domain_separator(chain_id: U256, verifying_contract: Address) -> FixedBytes<32> { + // keccak256("EIP712Domain(uint256 chainId,address verifyingContract)") + let domain_typehash = keccak256(b"EIP712Domain(uint256 chainId,address verifyingContract)"); + + // abi.encode(TYPEHASH, chainId, verifyingContract) + let encoded = DynSolValue::Tuple(vec![ + DynSolValue::FixedBytes(domain_typehash, 32), // bytes32 + DynSolValue::Uint(chain_id, 256), // uint256 + DynSolValue::Address(verifying_contract), // address + ]) + .abi_encode(); + + keccak256(&encoded) +} + +pub async fn get_safe_nonce(safe_address: Address, provider: &impl Provider) -> Result { + sol! { + #[sol(rpc)] + interface ISafe { + function nonce() external view returns (uint256); + } + } + let safe = ISafe::new(safe_address, provider); + let nonce = safe.nonce().call().await?; + Ok(nonce) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_safe_operation() { + assert_eq!(SafeOperation::Call as u8, 0); + // assert_eq!(SafeOperation::DelegateCall as u8, 1); + } + + #[test] + fn test_safe_transaction_new() { + let tx = SafeTransaction::new( + Address::ZERO, + U256::from(100), + Bytes::from(vec![0x01, 0x02]), + ); + + assert_eq!(tx.to, Address::ZERO); + assert_eq!(tx.value, U256::from(100)); + assert_eq!(tx.operation, SafeOperation::Call); + } + + // #[test] + // fn test_safe_family_from_addresses() { + // let parent = "0x1234567890123456789012345678901234567890"; + // let children = + // "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd,0x9876543210987654321098765432109876543210"; + + // let family = SafeFamily::from_addresses(parent, Some(children)).unwrap(); + + // assert_eq!(family.children.len(), 2); + // } + + #[test] + fn test_encode_contract_method() { + let contract = Address::ZERO; + let tx = encode_contract_method( + contract, + "transfer(address,uint256)", + vec![ + DynSolValue::Address(Address::ZERO), + DynSolValue::Uint(U256::from(100), 256), + ], + U256::ZERO, + ); + + assert_eq!(tx.to, contract); + assert_eq!(tx.operation, SafeOperation::Call); + // Data should start with function selector + assert!(tx.data.len() > 4); + } +} diff --git a/rs/src/utils.rs b/rs/src/utils.rs new file mode 100644 index 0000000..676ea6d --- /dev/null +++ b/rs/src/utils.rs @@ -0,0 +1,52 @@ +use anyhow::{Result, bail}; + +/// Partitions array into chunks of size `part_size` +pub fn partition_array(arr: &[T], part_size: usize) -> Result>> { + if part_size == 0 { + bail!("Can't partition array into parts of size 0"); + } + + let mut result = Vec::new(); + let mut i = 0; + + while i < arr.len() { + let end = std::cmp::min(i + part_size, arr.len()); + result.push(arr[i..end].to_vec()); + i = end; + } + + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_partition_array() { + let arr = vec![1, 2, 3, 4, 5, 6, 7, 8, 9]; + let result = partition_array(&arr, 3).unwrap(); + assert_eq!(result, vec![vec![1, 2, 3], vec![4, 5, 6], vec![7, 8, 9]]); + } + + #[test] + fn test_partition_array_uneven() { + let arr = vec![1, 2, 3, 4, 5]; + let result = partition_array(&arr, 2).unwrap(); + assert_eq!(result, vec![vec![1, 2], vec![3, 4], vec![5]]); + } + + #[test] + fn test_partition_array_zero_size() { + let arr = vec![1, 2, 3]; + let result = partition_array(&arr, 0); + assert!(result.is_err()); + } + + #[test] + fn test_partition_array_larger_than_array() { + let arr = vec![1, 2, 3]; + let result = partition_array(&arr, 10).unwrap(); + assert_eq!(result, vec![vec![1, 2, 3]]); + } +} diff --git a/rs/tests/integration_test.rs b/rs/tests/integration_test.rs new file mode 100644 index 0000000..216b681 --- /dev/null +++ b/rs/tests/integration_test.rs @@ -0,0 +1,10 @@ +// Integration tests for subsafe-commander + +#[cfg(test)] +mod tests { + #[test] + fn test_basic_sanity() { + // Basic sanity test to ensure tests can run + assert_eq!(2 + 2, 4); + } +}