diff --git a/examples/agent_account.rs b/examples/agent_account.rs new file mode 100644 index 0000000..e02ed16 --- /dev/null +++ b/examples/agent_account.rs @@ -0,0 +1,60 @@ +//! Example: agent-registry deposit / execute-task flow, first against an +//! in-memory account (runs anywhere, no RPC needed), then building the +//! calldata an agent would submit to an Arbitrum AgentAccount-shaped +//! registry contract. +//! +//! Run with: `cargo run --example agent_account` + +use arka::prelude::*; +use arka::Result; + +#[tokio::main] +async fn main() -> Result<()> { + // ---- In-memory flow ---- + let registry = InMemoryAgentAccount::with_default_address(); + let agent: Address = "0x00000000000000000000000000000000000A9E41" + .parse() + .unwrap(); + + // Seed the agent with 1.00 USDC (6 decimals). + registry.deposit_for(agent, U256::from(1_000_000u64)); + let bal = AgentAccount::balance(®istry, agent).await?; + println!("in-memory balance after deposit = {bal}"); + + // Execute a paid task costing 0.10 USDC. + let task_id = alloy::primitives::FixedBytes::from([0xABu8; 32]); + let receipt = AgentAccount::execute_task( + ®istry, + agent, + task_id, + U256::from(100_000u64), + alloy::primitives::Bytes::from(b"hello".to_vec()), + ) + .await?; + println!( + "task executed: success={} fee={} remaining={}", + receipt.success, + receipt.fee, + AgentAccount::balance(®istry, agent).await? + ); + + // ---- On-chain calldata build (Arbitrum) ---- + // Replace with the real deployed AgentAccount contract address. + let contract: Address = "0x0000000000000000000000000000000000000001" + .parse() + .unwrap(); + let client = AgentDepositClient::with_usdc(contract)?; + let calldata = client.encode_deposit(U256::from(1_000_000u64)); + println!( + "arbitrum deposit calldata ({} bytes, selector={:02x?}) ready to submit to {:?}", + calldata.len(), + &calldata[..4], + client.contract() + ); + println!( + "settlement token (native Arbitrum USDC) = {:?}", + client.settlement_token() + ); + + Ok(()) +} diff --git a/examples/multi_chain.rs b/examples/multi_chain.rs index 4f50b4b..e472616 100644 --- a/examples/multi_chain.rs +++ b/examples/multi_chain.rs @@ -26,7 +26,11 @@ async fn main() -> Result<()> { chain, block, balance, - if chain.stablecoin_gas() { "stablecoin" } else { "native" } + if chain.stablecoin_gas() { + "stablecoin" + } else { + "native" + } ); } diff --git a/src/agent/account.rs b/src/agent/account.rs new file mode 100644 index 0000000..5042357 --- /dev/null +++ b/src/agent/account.rs @@ -0,0 +1,274 @@ +//! `AgentAccount` — the agent-registry abstraction. +//! +//! An `AgentAccount` is an on-chain account owned by an agent into which +//! the agent deposits settlement funds (typically USDC), from which the +//! agent earns fees, and through which the agent executes paid tasks. +//! +//! This trait is shaped to match common agent-registry contracts: +//! - AgentDeposit-style registries (CR8 / Create Protocol). +//! - ERC-4337 smart accounts that hold a settlement balance. +//! - x402-style escrows where the agent's running balance funds per-task fees. +//! +//! Two implementations are provided: +//! - [`InMemoryAgentAccount`] — a deterministic local mock for tests and +//! local simulations. Real. +//! - On-chain implementations live under `crate::chains::*` (e.g. +//! `AgentDepositClient` for Arbitrum) and use this trait as a common +//! interface so higher-level agent logic stays chain-agnostic. + +use alloy::primitives::{Address, Bytes, FixedBytes, U256}; +use async_trait::async_trait; +use std::collections::HashMap; +use std::sync::Mutex; + +use crate::error::{ArkaError, Result}; + +/// Receipt emitted when an agent executes a paid task through its account. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskReceipt { + pub task_id: FixedBytes<32>, + pub agent: Address, + /// Fee charged against the agent's account balance, in settlement-token + /// smallest units (e.g. USDC = 6 decimals). + pub fee: U256, + pub success: bool, +} + +/// A registered agent account that holds settlement-token balance and +/// executes paid tasks. +/// +/// Callers interact with this trait when they don't care whether the +/// account is on-chain or a local mock. The on-chain flavor (`AgentDepositClient` +/// on Arbitrum) wires this up to a real contract; the in-memory flavor +/// gives you a deterministic sandbox. +#[async_trait] +pub trait AgentAccount: Send + Sync { + /// Chain-scoped address of the agent account. + fn address(&self) -> Address; + + /// Deposit `amount` of settlement token into the agent's account. + async fn deposit(&self, amount: U256) -> Result; + + /// Current balance for the given agent address, in settlement-token smallest units. + async fn balance(&self, agent: Address) -> Result; + + /// Withdraw `amount` from the agent's account. Returns the new balance. + async fn withdraw(&self, agent: Address, amount: U256) -> Result; + + /// Execute a paid task. Deducts `fee` from `agent`'s account and emits a receipt. + async fn execute_task( + &self, + agent: Address, + task_id: FixedBytes<32>, + fee: U256, + payload: Bytes, + ) -> Result; +} + +/// A deterministic, in-memory implementation of [`AgentAccount`] for tests +/// and local simulations. This is not a mock in the "unreachable" sense — +/// it's a working registry that honors every invariant the trait specifies +/// and can drive real end-to-end agent flows without an RPC node. +pub struct InMemoryAgentAccount { + address: Address, + state: Mutex, +} + +struct InMemoryState { + balances: HashMap, + executed_tasks: Vec, +} + +impl InMemoryAgentAccount { + /// Create a fresh in-memory account registry bound to a pseudo-address. + pub fn new(address: Address) -> Self { + Self { + address, + state: Mutex::new(InMemoryState { + balances: HashMap::new(), + executed_tasks: Vec::new(), + }), + } + } + + /// Create with a default address useful for tests. + pub fn with_default_address() -> Self { + let addr: Address = "0x000000000000000000000000000000000000a1ca" + .parse() + .expect("static address literal"); + Self::new(addr) + } + + /// Deposit on behalf of a specific agent address (the trait's `deposit` + /// doesn't take an agent — this helper lets tests seed multiple agents). + pub fn deposit_for(&self, agent: Address, amount: U256) -> U256 { + let mut st = self.state.lock().expect("poisoned"); + let entry = st.balances.entry(agent).or_insert(U256::ZERO); + *entry = entry.saturating_add(amount); + *entry + } + + /// View all executed task receipts (for test assertions). + pub fn executed_tasks(&self) -> Vec { + self.state.lock().expect("poisoned").executed_tasks.clone() + } + + /// Number of agents with a non-zero balance. + pub fn registered_agents(&self) -> usize { + self.state + .lock() + .expect("poisoned") + .balances + .iter() + .filter(|(_, v)| **v > U256::ZERO) + .count() + } +} + +#[async_trait] +impl AgentAccount for InMemoryAgentAccount { + fn address(&self) -> Address { + self.address + } + + async fn deposit(&self, amount: U256) -> Result { + // Deposits without an explicit agent target credit the account's own + // "self" address — matches an ERC-4337 / smart-account deposit flow + // where msg.sender IS the agent. + Ok(self.deposit_for(self.address, amount)) + } + + async fn balance(&self, agent: Address) -> Result { + let st = self.state.lock().expect("poisoned"); + Ok(st.balances.get(&agent).copied().unwrap_or(U256::ZERO)) + } + + async fn withdraw(&self, agent: Address, amount: U256) -> Result { + let mut st = self.state.lock().expect("poisoned"); + let entry = st.balances.entry(agent).or_insert(U256::ZERO); + if *entry < amount { + return Err(ArkaError::InsufficientBalance { + have: entry.to_string(), + need: amount.to_string(), + }); + } + *entry -= amount; + Ok(*entry) + } + + async fn execute_task( + &self, + agent: Address, + task_id: FixedBytes<32>, + fee: U256, + _payload: Bytes, + ) -> Result { + let mut st = self.state.lock().expect("poisoned"); + let entry = st.balances.entry(agent).or_insert(U256::ZERO); + if *entry < fee { + return Err(ArkaError::InsufficientBalance { + have: entry.to_string(), + need: fee.to_string(), + }); + } + *entry -= fee; + let receipt = TaskReceipt { + task_id, + agent, + fee, + success: true, + }; + st.executed_tasks.push(receipt.clone()); + Ok(receipt) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn agent_addr(byte: u8) -> Address { + let mut raw = [0u8; 20]; + raw[19] = byte; + Address::from(raw) + } + + #[tokio::test] + async fn deposit_and_balance_roundtrip() { + let acct = InMemoryAgentAccount::with_default_address(); + let agent = agent_addr(1); + acct.deposit_for(agent, U256::from(1_000_000u64)); + assert_eq!(acct.balance(agent).await.unwrap(), U256::from(1_000_000u64)); + } + + #[tokio::test] + async fn withdraw_reduces_balance() { + let acct = InMemoryAgentAccount::with_default_address(); + let agent = agent_addr(2); + acct.deposit_for(agent, U256::from(500u64)); + let remaining = acct.withdraw(agent, U256::from(200u64)).await.unwrap(); + assert_eq!(remaining, U256::from(300u64)); + } + + #[tokio::test] + async fn withdraw_rejects_overdraw() { + let acct = InMemoryAgentAccount::with_default_address(); + let agent = agent_addr(3); + acct.deposit_for(agent, U256::from(100u64)); + let err = acct.withdraw(agent, U256::from(200u64)).await.unwrap_err(); + matches!(err, ArkaError::InsufficientBalance { .. }) + .then_some(()) + .expect("expected InsufficientBalance"); + } + + #[tokio::test] + async fn execute_task_charges_fee_and_emits_receipt() { + let acct = InMemoryAgentAccount::with_default_address(); + let agent = agent_addr(4); + acct.deposit_for(agent, U256::from(10_000u64)); + + let task_id = FixedBytes::from([7u8; 32]); + let receipt = acct + .execute_task(agent, task_id, U256::from(250u64), Bytes::from(vec![0x01])) + .await + .unwrap(); + + assert!(receipt.success); + assert_eq!(receipt.fee, U256::from(250u64)); + assert_eq!(receipt.agent, agent); + + let bal = acct.balance(agent).await.unwrap(); + assert_eq!(bal, U256::from(9_750u64)); + + let tasks = acct.executed_tasks(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].task_id, task_id); + } + + #[tokio::test] + async fn execute_task_refuses_without_balance() { + let acct = InMemoryAgentAccount::with_default_address(); + let agent = agent_addr(5); + // No deposit. + let task_id = FixedBytes::from([9u8; 32]); + let err = acct + .execute_task(agent, task_id, U256::from(1u64), Bytes::from(vec![])) + .await + .unwrap_err(); + matches!(err, ArkaError::InsufficientBalance { .. }) + .then_some(()) + .expect("expected InsufficientBalance"); + + // Nothing recorded. + assert_eq!(acct.executed_tasks().len(), 0); + } + + #[tokio::test] + async fn registered_agents_counts_nonzero_balances() { + let acct = InMemoryAgentAccount::with_default_address(); + acct.deposit_for(agent_addr(10), U256::from(1u64)); + acct.deposit_for(agent_addr(11), U256::from(2u64)); + acct.deposit_for(agent_addr(12), U256::ZERO); + assert_eq!(acct.registered_agents(), 2); + } +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 920cd95..e7990e6 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,14 +1,16 @@ //! Agent — the core abstraction. An autonomous entity with a wallet, chain connection, //! and modules for DEX, MPP, and oracle interaction. +pub mod account; + use alloy::primitives::{Address, U256}; use crate::chain::{Chain, ChainConnector}; -use crate::wallet::Wallet; use crate::dex::DexModule; +use crate::error::{ArkaError, Result}; use crate::mpp::MppClient; use crate::oracle::OracleModule; -use crate::error::{ArkaError, Result}; +use crate::wallet::Wallet; /// An autonomous blockchain agent. pub struct Agent { @@ -78,22 +80,13 @@ impl Agent { } /// Builder for constructing agents. +#[derive(Default)] pub struct AgentBuilder { wallet: Option, chain: Option, rpc_url: Option, } -impl Default for AgentBuilder { - fn default() -> Self { - Self { - wallet: None, - chain: None, - rpc_url: None, - } - } -} - impl AgentBuilder { /// Set the wallet for this agent. pub fn wallet(mut self, wallet: Wallet) -> Self { @@ -115,8 +108,12 @@ impl AgentBuilder { /// Build the agent. pub async fn build(self) -> Result { - let wallet = self.wallet.ok_or_else(|| ArkaError::Config("Wallet is required".into()))?; - let chain = self.chain.ok_or_else(|| ArkaError::Config("Chain is required".into()))?; + let wallet = self + .wallet + .ok_or_else(|| ArkaError::Config("Wallet is required".into()))?; + let chain = self + .chain + .ok_or_else(|| ArkaError::Config("Chain is required".into()))?; let connector = match &self.rpc_url { Some(url) => ChainConnector::with_rpc(chain, url).await?, diff --git a/src/chain/connector.rs b/src/chain/connector.rs index 3a87552..72c56dc 100644 --- a/src/chain/connector.rs +++ b/src/chain/connector.rs @@ -1,31 +1,36 @@ //! Chain connector — manages RPC connections and provider state. -use alloy::providers::{Provider, ProviderBuilder}; use alloy::primitives::{Address, U256}; +use alloy::providers::{Provider, ProviderBuilder}; use super::Chain; use crate::error::{ArkaError, Result}; -/// Manages connection to a specific chain. -pub struct ChainConnector { - chain: Chain, - rpc_url: String, - provider: alloy::providers::fillers::FillProvider< +/// Alloy's default filled provider — the concrete type returned by +/// `ProviderBuilder::new().connect_http(...)`. Aliased here to keep +/// the public struct signature readable (and clippy quiet). +type DefaultFilledProvider = alloy::providers::fillers::FillProvider< + alloy::providers::fillers::JoinFill< + alloy::providers::Identity, alloy::providers::fillers::JoinFill< - alloy::providers::Identity, + alloy::providers::fillers::GasFiller, alloy::providers::fillers::JoinFill< - alloy::providers::fillers::GasFiller, + alloy::providers::fillers::BlobGasFiller, alloy::providers::fillers::JoinFill< - alloy::providers::fillers::BlobGasFiller, - alloy::providers::fillers::JoinFill< - alloy::providers::fillers::NonceFiller, - alloy::providers::fillers::ChainIdFiller, - >, + alloy::providers::fillers::NonceFiller, + alloy::providers::fillers::ChainIdFiller, >, >, >, - alloy::providers::RootProvider, >, + alloy::providers::RootProvider, +>; + +/// Manages connection to a specific chain. +pub struct ChainConnector { + chain: Chain, + rpc_url: String, + provider: DefaultFilledProvider, } impl ChainConnector { @@ -81,4 +86,9 @@ impl ChainConnector { pub fn rpc_url(&self) -> &str { &self.rpc_url } + + /// Get a reference to the underlying alloy provider (for contract calls). + pub fn provider(&self) -> &DefaultFilledProvider { + &self.provider + } } diff --git a/src/chains/arbitrum.rs b/src/chains/arbitrum.rs new file mode 100644 index 0000000..f2f454d --- /dev/null +++ b/src/chains/arbitrum.rs @@ -0,0 +1,234 @@ +//! Arbitrum One primitives — well-known contract addresses, agent-registry +//! client helpers, and USDC settlement wiring. +//! +//! Arbitrum is the home chain for the agent-economy deployments arka targets: +//! low-cost settlement, mature DeFi liquidity (Uniswap V3, Camelot), native +//! USDC, and fast finality — the right substrate for registered autonomous +//! agents that deposit funds, earn fees, and run tasks. +//! +//! ## What this module provides +//! - `ArbitrumContracts` — static addresses: USDC, USDT, WETH, Uniswap V3 router. +//! - `AgentDepositClient` — typed client for an ERC-20-denominated agent +//! deposit / balance / withdrawal contract. The ABI matches a minimal +//! `AgentAccount`-shaped registry: `deposit(uint256)`, `balanceOf(address)`, +//! `withdraw(uint256)`, `executeTask(bytes32,bytes)`. +//! +//! The contract address is **configurable** (not hard-coded) because the +//! registry address will change between testnet deployments and production +//! Arbitrum One. Agents supply the address explicitly. + +use alloy::primitives::{Address, Bytes, FixedBytes, U256}; +use alloy::sol; +use alloy::sol_types::SolCall; + +use crate::chain::Chain; +use crate::error::{ArkaError, Result}; + +/// Arbitrum One chain ID. +pub const ARBITRUM_ONE_CHAIN_ID: u64 = 42161; + +/// Arbitrum Sepolia (testnet) chain ID. +pub const ARBITRUM_SEPOLIA_CHAIN_ID: u64 = 421614; + +/// Well-known contract addresses on Arbitrum One. +/// +/// These are stable across the lifetime of the network. Protocol-specific +/// deployment addresses (like an agent registry) are NOT listed here — +/// they belong to a specific deployment and should be passed by the caller. +pub struct ArbitrumContracts; + +impl ArbitrumContracts { + /// Native USDC on Arbitrum One (Circle, not bridged USDC.e). + pub const USDC: &'static str = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"; + + /// Bridged USDC.e on Arbitrum One (legacy, kept for interop). + pub const USDC_E: &'static str = "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8"; + + /// Tether USD on Arbitrum One. + pub const USDT: &'static str = "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"; + + /// Wrapped Ether on Arbitrum One. + pub const WETH: &'static str = "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"; + + /// Uniswap V3 SwapRouter02 on Arbitrum One. + pub const UNISWAP_V3_ROUTER: &'static str = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45"; + + /// Uniswap V3 QuoterV2 on Arbitrum One. + pub const UNISWAP_V3_QUOTER: &'static str = "0x61fFE014bA17989E743c5F6cB21bF9697530B21e"; +} + +// Solidity bindings for a minimal AgentAccount-shaped registry. The shape +// intentionally matches common agent-deposit contracts (CR8 / Create Protocol +// AgentDeposit, ERC-4337 accounts, and x402-style escrows): an ERC-20-backed +// account that exposes deposit, balance, withdraw, and executeTask. +sol! { + #[derive(Debug)] + interface IAgentAccount { + function deposit(uint256 amount) external; + function balanceOf(address agent) external view returns (uint256); + function withdraw(uint256 amount) external; + function executeTask(bytes32 taskId, bytes calldata payload) external returns (bool); + } +} + +/// Typed client for an Arbitrum-deployed AgentAccount-shaped contract. +/// +/// This does NOT submit transactions on its own — it only builds the +/// calldata and holds the target address. Callers feed the calldata into +/// their signing / broadcast pipeline (e.g. `crate::tx::TxRequest`). This +/// separation keeps the client trivially testable without an RPC. +#[derive(Debug, Clone)] +pub struct AgentDepositClient { + /// Address of the deployed AgentAccount-shaped registry contract. + contract: Address, + /// Settlement token address (e.g. Arbitrum USDC). + settlement_token: Address, +} + +impl AgentDepositClient { + /// Create a client bound to a specific deployed contract and + /// settlement token (typically USDC on Arbitrum One). + pub fn new(contract: Address, settlement_token: Address) -> Self { + Self { + contract, + settlement_token, + } + } + + /// Shortcut: bind to a contract using native Arbitrum USDC. + pub fn with_usdc(contract: Address) -> Result { + let usdc: Address = ArbitrumContracts::USDC.parse().map_err(|e| { + ArkaError::Config(format!("Failed to parse Arbitrum USDC address: {e}")) + })?; + Ok(Self::new(contract, usdc)) + } + + /// Contract address. + pub fn contract(&self) -> Address { + self.contract + } + + /// Settlement token (ERC-20) address. + pub fn settlement_token(&self) -> Address { + self.settlement_token + } + + /// Chain this client targets. + pub fn chain(&self) -> Chain { + Chain::Arbitrum + } + + /// Encode calldata for `deposit(amount)`. + pub fn encode_deposit(&self, amount: U256) -> Bytes { + let call = IAgentAccount::depositCall { amount }; + Bytes::from(call.abi_encode()) + } + + /// Encode calldata for `balanceOf(agent)`. + pub fn encode_balance_of(&self, agent: Address) -> Bytes { + let call = IAgentAccount::balanceOfCall { agent }; + Bytes::from(call.abi_encode()) + } + + /// Encode calldata for `withdraw(amount)`. + pub fn encode_withdraw(&self, amount: U256) -> Bytes { + let call = IAgentAccount::withdrawCall { amount }; + Bytes::from(call.abi_encode()) + } + + /// Encode calldata for `executeTask(taskId, payload)`. + pub fn encode_execute_task(&self, task_id: FixedBytes<32>, payload: Bytes) -> Bytes { + let call = IAgentAccount::executeTaskCall { + taskId: task_id, + payload, + }; + Bytes::from(call.abi_encode()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_contract() -> Address { + "0x1111111111111111111111111111111111111111" + .parse() + .unwrap() + } + + #[test] + fn usdc_address_parses() { + let usdc: Address = ArbitrumContracts::USDC.parse().expect("valid address"); + // Native Arbitrum USDC starts with 0xaf88 + assert_eq!( + format!("{usdc:?}").to_lowercase(), + "0xaf88d065e77c8cc2239327c5edb3a432268e5831" + ); + } + + #[test] + fn chain_id_matches() { + assert_eq!(Chain::Arbitrum.chain_id(), ARBITRUM_ONE_CHAIN_ID); + } + + #[test] + fn client_binds_usdc_by_default() { + let client = AgentDepositClient::with_usdc(sample_contract()).unwrap(); + let usdc: Address = ArbitrumContracts::USDC.parse().unwrap(); + assert_eq!(client.settlement_token(), usdc); + assert_eq!(client.contract(), sample_contract()); + assert_eq!(client.chain(), Chain::Arbitrum); + } + + #[test] + fn encode_deposit_selector_is_correct() { + let client = AgentDepositClient::with_usdc(sample_contract()).unwrap(); + let calldata = client.encode_deposit(U256::from(1_000_000u64)); + // deposit(uint256) selector = first 4 bytes of keccak256("deposit(uint256)") = 0xb6b55f25 + assert_eq!(&calldata[..4], &[0xb6, 0xb5, 0x5f, 0x25]); + // Full calldata = 4 (selector) + 32 (amount) = 36 bytes + assert_eq!(calldata.len(), 36); + } + + #[test] + fn encode_balance_of_selector_is_correct() { + let client = AgentDepositClient::with_usdc(sample_contract()).unwrap(); + let agent: Address = "0x2222222222222222222222222222222222222222" + .parse() + .unwrap(); + let calldata = client.encode_balance_of(agent); + // balanceOf(address) selector = 0x70a08231 + assert_eq!(&calldata[..4], &[0x70, 0xa0, 0x82, 0x31]); + assert_eq!(calldata.len(), 36); + } + + #[test] + fn encode_withdraw_roundtrips_amount() { + let client = AgentDepositClient::with_usdc(sample_contract()).unwrap(); + let amount = U256::from(42u64); + let calldata = client.encode_withdraw(amount); + // The amount must appear in the last 32 bytes as big-endian uint256. + let last_byte = calldata[calldata.len() - 1]; + assert_eq!(last_byte, 42); + } + + #[test] + fn encode_execute_task_contains_task_id() { + let client = AgentDepositClient::with_usdc(sample_contract()).unwrap(); + let mut task_id = [0u8; 32]; + task_id[31] = 0x7f; + let calldata = + client.encode_execute_task(FixedBytes::from(task_id), Bytes::from(vec![0xde, 0xad])); + // Selector (4) + taskId (32) + offset (32) + length (32) + payload (32 padded) = 132 + assert_eq!(calldata.len(), 132); + // taskId sits right after the 4-byte selector + assert_eq!(calldata[4 + 31], 0x7f); + } + + #[test] + fn custom_settlement_token_is_honored() { + let usdt: Address = ArbitrumContracts::USDT.parse().unwrap(); + let client = AgentDepositClient::new(sample_contract(), usdt); + assert_eq!(client.settlement_token(), usdt); + } +} diff --git a/src/chains/mod.rs b/src/chains/mod.rs new file mode 100644 index 0000000..40ab48e --- /dev/null +++ b/src/chains/mod.rs @@ -0,0 +1,9 @@ +//! Chain-specific primitives (addresses, known contracts, helpers). +//! +//! The top-level `crate::chain` module handles the generic `Chain` enum +//! and RPC connector. This `chains` module contains per-network modules +//! with constants and typed clients for well-known contracts on that +//! network — starting with Arbitrum, the home chain for the agent-economy +//! deployments arka is designed to support. + +pub mod arbitrum; diff --git a/src/dex/router.rs b/src/dex/router.rs index 31624d4..57ea2ed 100644 --- a/src/dex/router.rs +++ b/src/dex/router.rs @@ -12,7 +12,7 @@ type U160 = Uint<160, 3>; use crate::chain::Chain; use crate::error::{ArkaError, Result}; -use super::types::{FeeTier, SwapParams}; +use super::types::FeeTier; /// Uniswap V3 SwapRouter02 addresses per chain. fn router_address(chain: Chain) -> Result
{ @@ -24,7 +24,8 @@ fn router_address(chain: Chain) -> Result
{ Chain::Polygon => "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45", _ => return Err(ArkaError::Dex(format!("No Uniswap V3 router on {chain}"))), }; - addr.parse().map_err(|e| ArkaError::Dex(format!("Invalid router address: {e}"))) + addr.parse() + .map_err(|e| ArkaError::Dex(format!("Invalid router address: {e}"))) } // Generate Rust bindings for the SwapRouter02 exactInputSingle function. @@ -61,6 +62,11 @@ impl UniswapV3Router { } } + /// Get the chain this router targets. + pub fn chain(&self) -> Chain { + self.chain + } + /// Get the router address for this chain. pub fn address(&self) -> Result
{ match &self.router { diff --git a/src/dex/types.rs b/src/dex/types.rs index ea0d340..ba8fa0d 100644 --- a/src/dex/types.rs +++ b/src/dex/types.rs @@ -1,6 +1,6 @@ //! DEX types — swap parameters, results, fee tiers. -use alloy::primitives::{Address, U256}; +use alloy::primitives::U256; use serde::{Deserialize, Serialize}; use crate::chain::Chain; diff --git a/src/lib.rs b/src/lib.rs index 92b78d3..988e65c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,20 +5,23 @@ pub mod agent; pub mod chain; -pub mod wallet; -pub mod tx; +pub mod chains; pub mod dex; pub mod mpp; pub mod oracle; +pub mod tx; +pub mod wallet; mod error; pub use error::{ArkaError, Result}; /// Convenience re-exports. pub mod prelude { + pub use crate::agent::account::{AgentAccount, InMemoryAgentAccount, TaskReceipt}; pub use crate::agent::{Agent, AgentBuilder}; pub use crate::chain::Chain; - pub use crate::wallet::Wallet; + pub use crate::chains::arbitrum::{AgentDepositClient, ArbitrumContracts}; pub use crate::error::{ArkaError, Result}; + pub use crate::wallet::Wallet; pub use alloy::primitives::{Address, U256}; } diff --git a/src/mpp/mod.rs b/src/mpp/mod.rs index 1062f97..08d5e4a 100644 --- a/src/mpp/mod.rs +++ b/src/mpp/mod.rs @@ -48,7 +48,8 @@ impl MppClient { /// Make a request to an MPP-enabled endpoint. /// If the server returns 402, parse payment options. pub async fn request(&self, url: &str) -> Result { - let resp = self.http + let resp = self + .http .get(url) .send() .await @@ -57,11 +58,13 @@ impl MppClient { match resp.status() { StatusCode::PAYMENT_REQUIRED => { // Parse payment options from 402 response - let body = resp.text().await + let body = resp + .text() + .await .map_err(|e| ArkaError::Mpp(format!("Failed to read 402 body: {e}")))?; - let options: PaymentOptions = serde_json::from_str(&body) - .unwrap_or(PaymentOptions { + let options: PaymentOptions = + serde_json::from_str(&body).unwrap_or(PaymentOptions { methods: vec![], amount: None, currency: None, @@ -71,13 +74,13 @@ impl MppClient { Ok(MppResponse::PaymentRequired(options)) } StatusCode::OK => { - let body = resp.text().await + let body = resp + .text() + .await .map_err(|e| ArkaError::Mpp(format!("Failed to read response: {e}")))?; Ok(MppResponse::Success(body)) } - status => { - Err(ArkaError::Mpp(format!("Unexpected status: {status}"))) - } + status => Err(ArkaError::Mpp(format!("Unexpected status: {status}"))), } } diff --git a/src/oracle/mod.rs b/src/oracle/mod.rs index 1ef342e..539d054 100644 --- a/src/oracle/mod.rs +++ b/src/oracle/mod.rs @@ -2,7 +2,7 @@ //! //! Supports Chainlink price feeds and on-chain TWAP from DEX pools. -use alloy::primitives::{Address, U256}; +use alloy::primitives::Address; use alloy::sol; use crate::chain::Chain; @@ -54,11 +54,12 @@ impl OracleModule { /// Get the Chainlink feed address for a given pair on this chain. pub fn feed_address(&self, pair: &str) -> Result
{ - let addr_str = chainlink_feed(self.chain, pair) - .ok_or_else(|| ArkaError::Oracle( - format!("No Chainlink feed for {pair} on {}", self.chain) - ))?; - addr_str.parse().map_err(|e| ArkaError::Oracle(format!("Invalid feed address: {e}"))) + let addr_str = chainlink_feed(self.chain, pair).ok_or_else(|| { + ArkaError::Oracle(format!("No Chainlink feed for {pair} on {}", self.chain)) + })?; + addr_str + .parse() + .map_err(|e| ArkaError::Oracle(format!("Invalid feed address: {e}"))) } /// Check if a Chainlink feed exists for a pair on this chain. @@ -69,7 +70,8 @@ impl OracleModule { /// List available feeds for this chain. pub fn available_feeds(&self) -> Vec<&'static str> { let pairs = ["ETH/USD", "BTC/USD", "USDC/USD", "DAI/USD", "LINK/USD"]; - pairs.into_iter() + pairs + .into_iter() .filter(|p| chainlink_feed(self.chain, p).is_some()) .collect() } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 2b3c025..2ffdbf2 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -93,8 +93,9 @@ impl WalletManager { self.wallets.push(wallet); } - /// Get the next wallet in rotation. - pub fn next(&mut self) -> Option<&Wallet> { + /// Get the next wallet in rotation. Named `next_wallet` (not `next`) + /// to avoid ambiguity with `Iterator::next`. + pub fn next_wallet(&mut self) -> Option<&Wallet> { if self.wallets.is_empty() { return None; }