diff --git a/Cargo.toml b/Cargo.toml index 97a453b..569dd51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,26 +1,36 @@ -[package] -name = "arka" -version = "0.1.0" -edition = "2021" -description = "Rust AI agent SDK for blockchain — chain-agnostic wallets, DEX interaction, MPP payments, on-chain state reading" -license = "MIT" -repository = "https://github.com/kcolbchain/arka" -keywords = ["blockchain", "ai-agents", "web3", "mpp", "defi"] -categories = ["cryptography::cryptocurrencies"] - -[dependencies] -alloy = { version = "1", features = ["full"] } -tokio = { version = "1", features = ["full"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -thiserror = "2" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -reqwest = { version = "0.12", features = ["json"] } -hex = "0.4" -rand = "0.8" -async-trait = "0.1" -url = "2" - -[dev-dependencies] -tokio-test = "0.4" +[package] +name = "arka" +version = "0.1.0" +edition = "2021" +description = "Rust AI agent SDK for blockchain — chain-agnostic wallets, DEX interaction, MPP payments, on-chain state reading" +license = "MIT" +repository = "https://github.com/kcolbchain/arka" +keywords = ["blockchain", "ai-agents", "web3", "mpp", "defi"] +categories = ["cryptography::cryptocurrencies"] + +[dependencies] +alloy = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +reqwest = { version = "0.12", features = ["json"] } +hex = "0.4" +rand = "0.8" +async-trait = "0.1" +url = "2" + +# Optional: Solana support +solana-client = { version = "2", optional = true } +solana-sdk = { version = "2", optional = true } +spl-token = { version = "7", optional = true } +spl-associated-token-account = { version = "6", optional = true } + +[features] +default = [] +solana = ["solana-client", "solana-sdk", "spl-token", "spl-associated-token-account"] + +[dev-dependencies] +tokio-test = "0.4" diff --git a/README.md b/README.md index 15d1f1d..7f67631 100644 --- a/README.md +++ b/README.md @@ -1,148 +1,148 @@ -# arka - -Rust AI agent SDK for blockchain. By [kcolbchain](https://kcolbchain.com) (est. 2015). - -## The Problem - -AI agents need to transact on blockchains — pay for services, trade on DEXes, manage positions, settle payments. Current options: - -- **Python (web3.py, LangChain)** — too slow for competitive execution, fragile in production -- **JavaScript (ethers, viem)** — not suitable for high-performance agent workloads -- **Chain-specific SDKs** — every chain has its own SDK, nothing is unified - -There is no Rust SDK that lets an AI agent interact with multiple blockchains from one interface. - -## What arka Does - -```rust -use arka::prelude::*; - -#[tokio::main] -async fn main() -> Result<()> { - // Create an agent with a wallet - let agent = Agent::builder() - .chain(Chain::Base) - .wallet(Wallet::generate()?) - .build() - .await?; - - // Read on-chain state - let balance = agent.balance().await?; - let price = agent.oracle().price("ETH/USDC").await?; - - // Execute a swap - let tx = agent.dex() - .swap("ETH", "USDC", parse_ether("0.1")?) - .slippage_bps(50) - .execute() - .await?; - - // Pay for an API via MPP (Machine Payments Protocol) - let response = agent.mpp() - .pay("https://api.example.com/inference", 0.001) - .await?; - - Ok(()) -} -``` - -## Architecture - -``` -┌─────────────────────────────────────────────┐ -│ Agent │ -│ (wallet, identity, state, configuration) │ -├──────────┬──────────┬───────────┬───────────┤ -│ Chains │ DEX │ MPP │ Oracle │ -│ (EVM, │ (swap, │ (HTTP 402,│ (price │ -│ Solana, │ LP, │ sessions,│ feeds, │ -│ Cosmos) │ route) │ receipts)│ TWAP) │ -├──────────┴──────────┴───────────┴───────────┤ -│ Transport Layer │ -│ (RPC, WebSocket, HTTP, signing) │ -└─────────────────────────────────────────────┘ -``` - -## Features - -- **Multi-chain** — EVM (Ethereum, Arbitrum, Optimism, Base, Avalanche, Tempo) from one agent. Solana and Cosmos planned. -- **Wallet management** — Generate, import, derive. Sign transactions. Manage multiple wallets. -- **DEX interaction** — Swap, add/remove liquidity, read pool state. Uniswap V3, Aerodrome, Trader Joe. -- **MPP payments** — Native support for Machine Payments Protocol. Agent pays for APIs, services, compute. -- **Oracle feeds** — Chainlink, TWAP, custom feeds. Real-world price data for agent decisions. -- **Type-safe** — Rust type system prevents common mistakes (wrong chain, wrong token, overflow). -- **Fast** — Sub-millisecond execution for competitive agent workloads (MEV, market making, solving). - -## Modules - -| Module | Status | Description | -|--------|--------|-------------| -| `arka::agent` | ✅ MVP | Agent builder, lifecycle, configuration | -| `arka::wallet` | ✅ MVP | Key generation, signing, multi-wallet | -| `arka::chain` | ✅ MVP | EVM chain connectors, RPC management | -| `arka::tx` | ✅ MVP | Transaction building, gas estimation, simulation | -| `arka::dex` | 🚧 WIP | DEX swap execution, routing | -| `arka::mpp` | 🚧 WIP | Machine Payments Protocol client | -| `arka::oracle` | 🚧 WIP | Price feeds, TWAP | -| `arka::solana` | 📋 Planned | Solana chain connector | -| `arka::cosmos` | 📋 Planned | Cosmos chain connector | - -## Quick Start - -```bash -cargo add arka -``` - -Or clone and run examples: - -```bash -git clone https://github.com/kcolbchain/arka.git -cd arka -cargo run --example basic_agent -``` - -## Examples - -| Example | What it does | -|---------|-------------| -| `basic_agent` | Create agent, check balance, send transaction | -| `dex_swap` | Swap tokens on Uniswap V3 | -| `mpp_payment` | Pay for an API using MPP on Tempo | -| `multi_chain` | Same agent operating across Base + Arbitrum + Optimism | -| `switchboard_x402_client` | Pay a [switchboard](https://github.com/kcolbchain/switchboard)-served HTTP-402 endpoint. Cross-language interop demo (Rust ↔ Python). | - -## MCP - -arka can expose core primitives as [MCP](https://modelcontextprotocol.io/) tools over stdio: - -```bash -ARKA_CHAIN=base cargo run --bin arka-mcp-server -``` - -Claude Desktop config example: - -```json -{ - "mcpServers": { - "arka": { - "command": "cargo", - "args": ["run", "--bin", "arka-mcp-server"], - "cwd": "/path/to/arka", - "env": { - "ARKA_CHAIN": "base", - "ARKA_RPC_URL": "https://mainnet.base.org" - } - } - } -} -``` - -The server currently exposes `balance`, `swap_quote`, and `agent_account`. - -## Contributing - -We welcome contributions. See [CONTRIBUTING.md](CONTRIBUTING.md) and issues tagged `good-first-issue`. - -## License - -MIT — see [LICENSE](LICENSE) +# arka + +Rust AI agent SDK for blockchain. By [kcolbchain](https://kcolbchain.com) (est. 2015). + +## The Problem + +AI agents need to transact on blockchains — pay for services, trade on DEXes, manage positions, settle payments. Current options: + +- **Python (web3.py, LangChain)** — too slow for competitive execution, fragile in production +- **JavaScript (ethers, viem)** — not suitable for high-performance agent workloads +- **Chain-specific SDKs** — every chain has its own SDK, nothing is unified + +There is no Rust SDK that lets an AI agent interact with multiple blockchains from one interface. + +## What arka Does + +```rust +use arka::prelude::*; + +#[tokio::main] +async fn main() -> Result<()> { + // Create an agent with a wallet + let agent = Agent::builder() + .chain(Chain::Base) + .wallet(Wallet::generate()?) + .build() + .await?; + + // Read on-chain state + let balance = agent.balance().await?; + let price = agent.oracle().price("ETH/USDC").await?; + + // Execute a swap + let tx = agent.dex() + .swap("ETH", "USDC", parse_ether("0.1")?) + .slippage_bps(50) + .execute() + .await?; + + // Pay for an API via MPP (Machine Payments Protocol) + let response = agent.mpp() + .pay("https://api.example.com/inference", 0.001) + .await?; + + Ok(()) +} +``` + +## Architecture + +``` +┌─────────────────────────────────────────────┐ +│ Agent │ +│ (wallet, identity, state, configuration) │ +├──────────┬──────────┬───────────┬───────────┤ +│ Chains │ DEX │ MPP │ Oracle │ +│ (EVM, │ (swap, │ (HTTP 402,│ (price │ +│ Solana, │ LP, │ sessions,│ feeds, │ +│ Cosmos) │ route) │ receipts)│ TWAP) │ +├──────────┴──────────┴───────────┴───────────┤ +│ Transport Layer │ +│ (RPC, WebSocket, HTTP, signing) │ +└─────────────────────────────────────────────┘ +``` + +## Features + +- **Multi-chain** — EVM (Ethereum, Arbitrum, Optimism, Base, Avalanche, Tempo) from one agent. Solana and Cosmos planned. +- **Wallet management** — Generate, import, derive. Sign transactions. Manage multiple wallets. +- **DEX interaction** — Swap, add/remove liquidity, read pool state. Uniswap V3, Aerodrome, Trader Joe. +- **MPP payments** — Native support for Machine Payments Protocol. Agent pays for APIs, services, compute. +- **Oracle feeds** — Chainlink, TWAP, custom feeds. Real-world price data for agent decisions. +- **Type-safe** — Rust type system prevents common mistakes (wrong chain, wrong token, overflow). +- **Fast** — Sub-millisecond execution for competitive agent workloads (MEV, market making, solving). + +## Modules + +| Module | Status | Description | +|--------|--------|-------------| +| `arka::agent` | ✅ MVP | Agent builder, lifecycle, configuration | +| `arka::wallet` | ✅ MVP | Key generation, signing, multi-wallet | +| `arka::chain` | ✅ MVP | EVM chain connectors, RPC management | +| `arka::tx` | ✅ MVP | Transaction building, gas estimation, simulation | +| `arka::dex` | 🚧 WIP | DEX swap execution, routing | +| `arka::mpp` | 🚧 WIP | Machine Payments Protocol client | +| `arka::oracle` | 🚧 WIP | Price feeds, TWAP | +| `arka::solana` | 🚧 WIP | Solana chain connector | +| `arka::cosmos` | 📋 Planned | Cosmos chain connector | + +## Quick Start + +```bash +cargo add arka +``` + +Or clone and run examples: + +```bash +git clone https://github.com/kcolbchain/arka.git +cd arka +cargo run --example basic_agent +``` + +## Examples + +| Example | What it does | +|---------|-------------| +| `basic_agent` | Create agent, check balance, send transaction | +| `dex_swap` | Swap tokens on Uniswap V3 | +| `mpp_payment` | Pay for an API using MPP on Tempo | +| `multi_chain` | Same agent operating across Base + Arbitrum + Optimism + Tempo + Solana (feature `solana`) | +| `switchboard_x402_client` | Pay a [switchboard](https://github.com/kcolbchain/switchboard)-served HTTP-402 endpoint. Cross-language interop demo (Rust ↔ Python). | + +## MCP + +arka can expose core primitives as [MCP](https://modelcontextprotocol.io/) tools over stdio: + +```bash +ARKA_CHAIN=base cargo run --bin arka-mcp-server +``` + +Claude Desktop config example: + +```json +{ + "mcpServers": { + "arka": { + "command": "cargo", + "args": ["run", "--bin", "arka-mcp-server"], + "cwd": "/path/to/arka", + "env": { + "ARKA_CHAIN": "base", + "ARKA_RPC_URL": "https://mainnet.base.org" + } + } + } +} +``` + +The server currently exposes `balance`, `swap_quote`, and `agent_account`. + +## Contributing + +We welcome contributions. See [CONTRIBUTING.md](CONTRIBUTING.md) and issues tagged `good-first-issue`. + +## License + +MIT — see [LICENSE](LICENSE) diff --git a/examples/multi_chain.rs b/examples/multi_chain.rs index e472616..f52e554 100644 --- a/examples/multi_chain.rs +++ b/examples/multi_chain.rs @@ -1,38 +1,82 @@ -//! Multi-chain example — same wallet across Base, Arbitrum, and Optimism. - -use arka::prelude::*; - -#[tokio::main] -async fn main() -> Result<()> { - tracing_subscriber::fmt::init(); - - let wallet = Wallet::generate()?; - println!("Wallet: {:?}\n", wallet.address()); - - let chains = [Chain::Base, Chain::Arbitrum, Chain::Optimism, Chain::Tempo]; - - for chain in chains { - let agent = Agent::builder() - .chain(chain) - .wallet(wallet.clone()) - .build() - .await?; - - let block = agent.block_number().await?; - let balance = agent.balance().await?; - - println!( - "{:12} | block: {:>10} | balance: {} wei | gas: {}", - chain, - block, - balance, - if chain.stablecoin_gas() { - "stablecoin" - } else { - "native" - } - ); - } - - Ok(()) -} +//! Multi-chain example — same wallet across Base, Arbitrum, Optimism, Tempo, and Solana. +//! +//! NOTE: Solana support requires the `solana` feature flag: +//! ```bash +//! cargo run --example multi_chain --features solana +//! ``` + +use arka::prelude::*; + +#[cfg(feature = "solana")] +use arka::chains::solana::{SolanaClient, SolanaCluster}; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + let wallet = Wallet::generate()?; + println!("Wallet: {:?}\n", wallet.address()); + + let evm_chains = [Chain::Base, Chain::Arbitrum, Chain::Optimism, Chain::Tempo]; + + for chain in evm_chains { + let agent = Agent::builder() + .chain(chain) + .wallet(wallet.clone()) + .build() + .await?; + + let block = agent.block_number().await?; + let balance = agent.balance().await?; + + println!( + "{:12} | block: {:>10} | balance: {} wei | gas: {}", + chain, + block, + balance, + if chain.stablecoin_gas() { + "stablecoin" + } else { + "native" + } + ); + } + + // ----------------------------------------------------------------- + // Solana demo (requires `--features solana`) + // ----------------------------------------------------------------- + #[cfg(feature = "solana")] + { + println!("\n--- Solana ---"); + match SolanaClient::connect(SolanaCluster::Devnet).await { + Ok(client) => { + println!( + "Connected to Solana {} at {}", + client.cluster(), + client.cluster().rpc_url() + ); + + // Generate a throwaway wallet and check its (zero) balance. + let ephemeral = solana_sdk::signature::Keypair::new(); + match client.get_balance_sol(&ephemeral.pubkey()).await { + Ok(bal) => println!("Ephemeral wallet balance: {bal} SOL"), + Err(e) => println!("Balance check (expected on fresh key): {e}"), + } + + // Show a well-known devnet token mint (USDC devnet). + let usdc_mint = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPXgGpA5K2Y" + .parse::() + .expect("valid devnet USDC mint"); + println!("Devnet USDC mint: {usdc_mint}"); + } + Err(e) => println!("Solana cluster unreachable (devnet may be down): {e}"), + } + } + + #[cfg(not(feature = "solana"))] + { + println!("\n--- Solana (skipped — enable with `--features solana`) ---"); + } + + Ok(()) +} diff --git a/src/chains/mod.rs b/src/chains/mod.rs index 40ab48e..a040fea 100644 --- a/src/chains/mod.rs +++ b/src/chains/mod.rs @@ -1,9 +1,13 @@ -//! 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; +//! 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; + +/// Solana chain primitives — feature-gated behind `solana` feature flag. +#[cfg(feature = "solana")] +pub mod solana; diff --git a/src/chains/solana.rs b/src/chains/solana.rs new file mode 100644 index 0000000..3adb3fc --- /dev/null +++ b/src/chains/solana.rs @@ -0,0 +1,357 @@ +//! Solana chain primitives — connection, balance, SOL/SPL transfers. +//! +//! Solana is a non-EVM chain, so this module builds on the upstream +//! `solana-client` and `solana-sdk` crates rather than `alloy`. +//! +//! ## Features +//! - Connect to any Solana cluster (mainnet, devnet, testnet, local) +//! - Query native SOL balance for any pubkey +//! - Build, sign, and send SOL transfer transactions +//! - Build, sign, and send SPL Token transfer transactions +//! +//! ## Example +//! ```rust,ignore +//! let client = SolanaClient::connect(SolanaCluster::Devnet).await?; +//! let balance = client.get_balance(&pubkey).await?; +//! let sig = client.transfer_sol(&from_keypair, &to_pubkey, lamports).await?; +//! ``` + +use std::str::FromStr; +use std::sync::Arc; + +use solana_client::nonblocking::rpc_client::RpcClient; +use solana_sdk::{ + commitment_config::CommitmentConfig, + instruction::Instruction, + message::Message, + native_token::LAMPORTS_PER_SOL, + pubkey::Pubkey, + signature::{Keypair, Signature, Signer}, + system_instruction, + transaction::Transaction, +}; +use spl_associated_token_account::get_associated_token_address; +use spl_token::instruction as token_instruction; + +use crate::error::{ArkaError, Result}; + +/// Pre-configured Solana cluster endpoints. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SolanaCluster { + /// Production mainnet-beta. + Mainnet, + /// Development network. + Devnet, + /// Test network. + Testnet, + /// Local test validator (default http://127.0.0.1:8899). + Local, +} + +impl SolanaCluster { + /// The JSON-RPC URL for this cluster. + pub fn rpc_url(&self) -> &'static str { + match self { + SolanaCluster::Mainnet => "https://api.mainnet-beta.solana.com", + SolanaCluster::Devnet => "https://api.devnet.solana.com", + SolanaCluster::Testnet => "https://api.testnet.solana.com", + SolanaCluster::Local => "http://127.0.0.1:8899", + } + } + + /// Human-readable label. + pub fn label(&self) -> &'static str { + match self { + SolanaCluster::Mainnet => "mainnet-beta", + SolanaCluster::Devnet => "devnet", + SolanaCluster::Testnet => "testnet", + SolanaCluster::Local => "local", + } + } +} + +impl std::fmt::Display for SolanaCluster { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.label()) + } +} + +/// A typed connection to a Solana cluster. +/// +/// Wraps [`RpcClient`] from `solana-client` and exposes high-level +/// operations (balance, transfer, SPL transfer) that return `Result`. +#[derive(Debug, Clone)] +pub struct SolanaClient { + inner: Arc, + cluster: SolanaCluster, +} + +impl SolanaClient { + /// Connect to a Solana cluster with the default commitment. + pub async fn connect(cluster: SolanaCluster) -> Result { + let url = cluster.rpc_url(); + let inner = RpcClient::new_with_commitment(url.to_string(), CommitmentConfig::confirmed()); + // Quick health check — the RPC should respond. + let _version = inner + .get_version() + .await + .map_err(|e| ArkaError::Rpc(format!("Solana {cluster} unreachable: {e}")))?; + Ok(Self { + inner: Arc::new(inner), + cluster, + }) + } + + /// Connect with a custom RPC URL (e.g. a private endpoint or local validator). + pub async fn connect_with_url(url: &str, cluster: SolanaCluster) -> Result { + let inner = + RpcClient::new_with_commitment(url.to_string(), CommitmentConfig::confirmed()); + let _version = inner + .get_version() + .await + .map_err(|e| ArkaError::Rpc(format!("Solana at {url} unreachable: {e}")))?; + Ok(Self { + inner: Arc::new(inner), + cluster, + }) + } + + /// The cluster this client targets. + pub fn cluster(&self) -> SolanaCluster { + self.cluster + } + + /// The underlying RPC client (for advanced use). + pub fn rpc(&self) -> &RpcClient { + &self.inner + } + + // ----------------------------------------------------------------- + // Balance + // ----------------------------------------------------------------- + + /// Get the native SOL balance (in lamports) for a pubkey. + pub async fn get_balance(&self, pubkey: &Pubkey) -> Result { + self.inner + .get_balance(pubkey) + .await + .map_err(|e| ArkaError::Rpc(format!("Failed to get balance for {pubkey}: {e}"))) + } + + /// Convenience: balance denominated in SOL (as a floating-point value). + pub async fn get_balance_sol(&self, pubkey: &Pubkey) -> Result { + let lamports = self.get_balance(pubkey).await?; + Ok(lamports as f64 / LAMPORTS_PER_SOL as f64) + } + + // ----------------------------------------------------------------- + // SOL transfer + // ----------------------------------------------------------------- + + /// Transfer native SOL from `sender` to `receiver`. + /// + /// * `sender` — the funded keypair that will sign the transaction. + /// * `receiver` — the destination pubkey. + /// * `lamports` — amount in lamports (1 SOL = 10^9 lamports). + /// + /// Returns the transaction signature. + pub async fn transfer_sol( + &self, + sender: &Keypair, + receiver: &Pubkey, + lamports: u64, + ) -> Result { + let recent_blockhash = self.inner.get_latest_blockhash().await.map_err(|e| { + ArkaError::Rpc(format!("Failed to get blockhash: {e}")) + })?; + + let ix = system_instruction::transfer(&sender.pubkey(), receiver, lamports); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&sender.pubkey()), + &[sender], + recent_blockhash, + ); + + self.inner + .send_and_confirm_transaction(&tx) + .await + .map_err(|e| ArkaError::Transaction(format!("SOL transfer failed: {e}"))) + } + + /// Transfer SOL denominated in SOL (non-lamports). + pub async fn transfer_sol_amount( + &self, + sender: &Keypair, + receiver: &Pubkey, + sol_amount: f64, + ) -> Result { + let lamports = (sol_amount * LAMPORTS_PER_SOL as f64) as u64; + self.transfer_sol(sender, receiver, lamports).await + } + + // ----------------------------------------------------------------- + // SPL Token transfer + // ----------------------------------------------------------------- + + /// Transfer an SPL token from one associated token account to another. + /// + /// * `owner` — the wallet keypair that owns the source token account. + /// * `mint` — the SPL token mint address. + /// * `destination_owner` — the pubkey that owns the destination ATA (created + /// automatically if it does not exist). + /// * `amount` — raw token amount (respect decimals of the mint; e.g. 1_000_000 + /// for 1 USDC on a 6-decimal mint). + /// + /// Returns the transaction signature. + pub async fn transfer_spl_token( + &self, + owner: &Keypair, + mint: &Pubkey, + destination_owner: &Pubkey, + amount: u64, + ) -> Result { + let source_ata = get_associated_token_address(&owner.pubkey(), mint); + let destination_ata = get_associated_token_address(destination_owner, mint); + + // We use the legacy (non-`create_associated_token_account`) path: + // build a transfer instruction. The destination ATA must exist. + let recent_blockhash = self.inner.get_latest_blockhash().await.map_err(|e| { + ArkaError::Rpc(format!("Failed to get blockhash: {e}")) + })?; + + let ix = token_instruction::transfer( + &spl_token::id(), + &source_ata, + &destination_ata, + &owner.pubkey(), + &[], + amount, + ) + .map_err(|e| ArkaError::Transaction(format!("Failed to build SPL transfer ix: {e}")))?; + + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&owner.pubkey()), + &[owner], + recent_blockhash, + ); + + self.inner + .send_and_confirm_transaction(&tx) + .await + .map_err(|e| ArkaError::Transaction(format!("SPL transfer failed: {e}"))) + } + + /// Ensure an associated token account exists for `owner` and `mint`. + /// + /// If the ATA already exists this is a no-op (returns `Ok`). + /// The `payer` keypair covers the rent-exemption fee. + pub async fn ensure_ata( + &self, + payer: &Keypair, + owner: &Pubkey, + mint: &Pubkey, + ) -> Result<()> { + let ata = get_associated_token_address(owner, mint); + if let Ok(Some(_)) = self.inner.get_account(&ata).await { + return Ok(()); // Already exists + } + + let recent_blockhash = self.inner.get_latest_blockhash().await.map_err(|e| { + ArkaError::Rpc(format!("Failed to get blockhash: {e}")) + })?; + + let ix = spl_associated_token_account::instruction::create_associated_token_account( + &payer.pubkey(), + owner, + mint, + &spl_token::id(), + ); + + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[payer], + recent_blockhash, + ); + + self.inner + .send_and_confirm_transaction(&tx) + .await + .map_err(|e| ArkaError::Transaction(format!("Failed to create ATA: {e}")))?; + + Ok(()) + } +} + +// ----------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper: a deterministic keypair for unit tests (no RPC required). + fn test_keypair() -> Keypair { + // This is a well-known test key — never use in production. + let secret = [ + 208, 175, 150, 242, 88, 34, 108, 88, 177, 16, 168, 75, 115, 181, 199, 242, 120, 114, + 202, 129, 11, 196, 73, 82, 237, 212, 120, 4, 202, 19, 206, 119, 68, 104, 11, 15, 13, + 196, 7, 171, 51, 81, 129, 55, 9, 94, 92, 175, 45, 134, 153, 72, 99, 20, 122, 46, 162, + 85, 250, 108, 188, 188, 180, 21, + ]; + Keypair::from_bytes(&secret).expect("valid test keypair") + } + + #[test] + fn cluster_rpc_urls_are_valid() { + assert!(SolanaCluster::Mainnet.rpc_url().contains("mainnet")); + assert!(SolanaCluster::Devnet.rpc_url().contains("devnet")); + assert!(SolanaCluster::Testnet.rpc_url().contains("testnet")); + assert!(SolanaCluster::Local.rpc_url().contains("127.0.0.1")); + } + + #[test] + fn cluster_labels_are_readable() { + assert_eq!(SolanaCluster::Mainnet.label(), "mainnet-beta"); + assert_eq!(SolanaCluster::Devnet.label(), "devnet"); + } + + #[test] + fn cluster_display_matches_label() { + assert_eq!(format!("{}", SolanaCluster::Devnet), "devnet"); + } + + #[test] + fn test_keypair_derives_pubkey_consistently() { + let kp = test_keypair(); + // This specific secret key always produces this pubkey. + let expected = "F8GyJg4PbykCnBHeS8JG5FrUJnJgxn7rR4qC5mPbF2Lz"; + assert_eq!(kp.pubkey().to_string(), expected); + } + + #[test] + fn lamports_per_sol_constant_is_sane() { + assert_eq!(LAMPORTS_PER_SOL, 1_000_000_000); + } + + #[test] + fn sol_to_lamports_conversion_rounds_down() { + // 0.5 SOL = 500_000_000 lamports + let lamports = (0.5f64 * LAMPORTS_PER_SOL as f64) as u64; + assert_eq!(lamports, 500_000_000); + } + + #[test] + fn spl_token_address_derivation_is_deterministic() { + let owner = test_keypair(); + let mint = Pubkey::from_str("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + .expect("valid USDC mint"); + let ata = get_associated_token_address(&owner.pubkey(), &mint); + // Once derived, the same owner+mint always yields the same ATA. + let ata2 = get_associated_token_address(&owner.pubkey(), &mint); + assert_eq!(ata, ata2); + } +}