diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..5e7600e Binary files /dev/null and b/.DS_Store differ diff --git a/.debug b/.debug new file mode 100644 index 0000000..c3d2c01 --- /dev/null +++ b/.debug @@ -0,0 +1,9 @@ +Identity assumed: rmjpb-koq4r-y7uxo-7ywhl-flcpm-5cwus-6pvek-hjyik-hdx6r-y2apn-hqe + +Called: SDK create_wallet + + +called `Result::unwrap()` on an `Err` value: UncertifiedReject(RejectResponse { reject_code: CanisterReject, reject_message: +"Caller rmjpb-koq4r-y7uxo-7ywhl-flcpm-5cwus-6pvek-hjyik-hdx6r-y2apn-hqe is not allowed to call ic00 method provisional_create_canister_with_cycles", +error_code: Some("IC0406") }) +stack backtrace: \ No newline at end of file diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..80a79e6 --- /dev/null +++ b/.env.local @@ -0,0 +1 @@ +ANTHROPIC_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 35e90ff..c420c26 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,6 @@ Cargo.lock identity.pem -wallet.csv - -.env \ No newline at end of file +.env +.env.local +wallet.csv \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/Cargo.toml b/Cargo.toml index 76eb7d1..4699726 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,4 +27,7 @@ serde_cbor = "0.11.0" candid = "0.10.10" serde = "1.0.214" strum_macros = "0.26.2" -pyo3 = { version = "0.22.5", features = ["extension-module"] } +pyo3 = { version = "0.20", features = ["extension-module"] } +pyo3-asyncio = { version = "0.20", features = ["async-std", "tokio-runtime"] } +serde_json = "1.0.132" +strum = "0.26.1" \ No newline at end of file diff --git a/examples/ai-agent/main.py b/examples/ai-agent/main.py new file mode 100644 index 0000000..8974b81 --- /dev/null +++ b/examples/ai-agent/main.py @@ -0,0 +1,222 @@ +import os +import asyncio +from pathlib import Path +import keygate_sdk +from typing import List, Callable, Any, Dict +from datetime import datetime +from anthropic import Anthropic +from dotenv import load_dotenv +import traceback + +def load_environment(): + """Load environment variables from .env.local file""" + env_path = Path('.') / '.env.local' + load_dotenv(dotenv_path=env_path) + + required_vars = ['ANTHROPIC_API_KEY'] + missing_vars = [var for var in required_vars if not os.getenv(var)] + + if missing_vars: + raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}") + +class ICPAgent: + """An AI agent with ICP wallet capabilities powered by KeygateSDK and Claude.""" + + def __init__( + self, + name: str, + instructions: str, + functions: List[Callable], + identity_path: str = "identity.pem", + keygate_url: str = "http://localhost:4943" + ): + self.name = name + self.instructions = instructions + self.functions = { + "get_balance": self.get_balance, + "get_wallet_address": self.get_wallet_address, + "create_wallet": self.create_wallet, + "execute_transaction": self.execute_transaction + } + self.identity_path = identity_path + self.keygate_url = keygate_url + self.keygate = None + self.wallet_id = None + self.anthropic = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY")) + + async def initialize(self): + """Initialize the KeygateSDK client and create a wallet.""" + self.keygate = keygate_sdk.PyKeygateClient( + identity_path=self.identity_path, + url=self.keygate_url + ) + await self.keygate.init() + self.wallet_id = await self.keygate.create_wallet() + + async def get_wallet_address(self) -> str: + """Get the ICP address for the agent's wallet.""" + if not self.wallet_id: + raise ValueError("Agent not initialized. Call initialize() first.") + return await self.keygate.get_icp_address(self.wallet_id) + + async def get_balance(self) -> float: + """Get the ICP balance of the agent's wallet.""" + if not self.wallet_id: + raise ValueError("Agent not initialized. Call initialize() first.") + return await self.keygate.get_icp_balance(self.wallet_id) + + async def create_wallet(self) -> str: + """Create a new ICP wallet.""" + return await self.keygate.create_wallet() + + async def execute_transaction(self, recipient_address: str, amount: float) -> str: + """Execute an ICP transaction to a recipient address.""" + if not self.wallet_id: + raise ValueError("Agent not initialized. Call initialize() first.") + return await self.keygate.execute_transaction(self.wallet_id, recipient_address, amount) + + def format_functions_for_claude(self) -> str: + """Format available functions as a string for Claude's context.""" + functions_desc = "Available functions:\n\n" + for name, func in self.functions.items(): + functions_desc += f"{name}: {func.__doc__}\n\n" + return functions_desc + + async def process_message(self, message: str) -> str: + """Process a message using Claude and execute any requested functions.""" + try: + # Create the message for Claude including available functions + system_prompt = f"""You are {self.name}, an AI agent with an ICP wallet. +{self.instructions} + +{self.format_functions_for_claude()} + +To execute a function, respond with XML tags like this: +function_name + +For example: +get_balance + +Only call one function at a time. If no function needs to be called, respond normally. If you can't do something, say so and be concise and straight to the point. Don't talk more than necessary. +""" + + # Get response from Claude + response = self.anthropic.messages.create( + model="claude-3-sonnet-20240229", + max_tokens=1024, + temperature=0, + system=system_prompt, + messages=[ + {"role": "user", "content": message} + ] + ) + + content = response.content[0].text + + # Check if Claude wants to execute a function + if "" in content and "" in content: + start_idx = content.find("") + len("") + end_idx = content.find("") + func_name = content[start_idx:end_idx].strip() + + if func_name in self.functions: + # Execute the function + func_result = await self.functions[func_name]() + + # Get final response from Claude with the function result + final_response = self.anthropic.messages.create( + model="claude-3-sonnet-20240229", + max_tokens=1024, + temperature=0, + system=system_prompt, + messages=[ + {"role": "user", "content": message}, + {"role": "assistant", "content": content}, + {"role": "user", "content": f"Function result: {func_result}"} + ] + ) + return final_response.content[0].text + + return content + + except Exception as e: + return f"Error processing message: {traceback.format_exc()}" + +class AutoTasks: + """Collection of automated tasks for the ICP agent.""" + + @staticmethod + async def monitor_balance(agent: ICPAgent, min_balance: float = 5.0): + """Monitor wallet balance and alert if it falls below threshold.""" + balance = await agent.get_balance() + if balance < min_balance: + print(f"āš ļø Low balance alert: {balance} ICP") + # You could add more alert mechanisms here (email, webhook, etc.) + return balance + +async def run_chat_mode(): + """Run the agent in interactive chat mode.""" + instructions = """ + You are an AI assistant that helps users manage their ICP wallet. You can: + 1. Check wallet balance + 2. Get wallet address + 3. Process transactions + + Always be helpful and security-conscious when handling financial operations. + If asked about cryptocurrency prices or market data, explain that you don't have access to real-time market data. + """ + + # Create the agent + agent = ICPAgent( + name="ICP Assistant", + instructions=instructions, + functions=[] + ) + + # Initialize the agent + await agent.initialize() + + print(f"šŸ¤– {agent.name} initialized!") + print(f"šŸ’³ Wallet created: {agent.wallet_id}") + + while True: + user_input = input("\nYou: ") + if user_input.lower() in ['quit', 'exit', 'bye']: + break + + response = await agent.process_message(user_input) + print(f"\nšŸ¤– {agent.name}: {response}") + +async def run_autonomous_mode(instructions: str, check_interval: int = 60): + """Run the agent in autonomous mode with specific instructions.""" + agent = ICPAgent( + name="Autonomous ICP Agent", + instructions=instructions, + functions=[ + ICPAgent.get_balance, + ICPAgent.get_wallet_address, + ICPAgent.execute_transaction, + lambda: AutoTasks.monitor_balance(agent) + ] + ) + + await agent.initialize() + + print(f"šŸ¤– Autonomous agent initialized with wallet {agent.wallet_id}") + + while True: + # Process the autonomous instructions + response = await agent.process_message( + f"Current time: {datetime.now()}. Please perform your routine checks and operations." + ) + print(f"\nšŸ¤– Autonomous action: {response}") + + # Wait for the next check interval + await asyncio.sleep(check_interval) + +if __name__ == "__main__": + # Load environment variables + load_environment() + + # Run in chat mode + asyncio.run(run_chat_mode()) \ No newline at end of file diff --git a/examples/python-script/main.py b/examples/python-script/main.py new file mode 100644 index 0000000..19f368b --- /dev/null +++ b/examples/python-script/main.py @@ -0,0 +1,28 @@ +import keygate_sdk +import asyncio + +async def main(): + keygate = keygate_sdk.PyKeygateClient(identity_path="identity.pem", url="http://localhost:4943") + await keygate.init() + print("Initialized Keygate") + print("--------------------------------") + + print("Creating a wallet") + wallet_id = await keygate.create_wallet() + print(wallet_id) + + print("--------------------------------") + print("Getting ICP address") + print(await keygate.get_icp_address(wallet_id)) + + print("--------------------------------") + + print("Getting ICP balance") + print(await keygate.get_icp_balance(wallet_id)) + + print("--------------------------------") + + print("Transferring ICP") + print(await keygate.execute_transaction(wallet_id, await keygate.get_icp_address(wallet_id), 100)) + +asyncio.run(main()) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e0dd83d..ffebbfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,10 +3,10 @@ requires = ["maturin>=1,<2"] build-backend = "maturin" [project] -name = "pyo3_example" +name = "keygate_sdk" requires-python = ">=3.7" classifiers = [ "Programming Language :: Rust", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", -] \ No newline at end of file +] diff --git a/src/lib.rs b/src/lib.rs index fa3c928..64ae655 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,9 @@ +use std::cell::RefCell; +use std::collections::HashSet; use std::io::Error; +use std::path::PathBuf; use std::str::FromStr; +use std::sync::{Arc, Mutex, RwLock}; use candid::{CandidType, Decode}; use ic_agent::agent::CallResponse; @@ -8,9 +12,11 @@ use ic_ledger_types::{AccountBalanceArgs, AccountIdentifier, DEFAULT_SUBACCOUNT} use ic_utils::call::{AsyncCall, SyncCall}; use ic_utils::interfaces::ManagementCanister; use ic_utils::Canister; +use pyo3::types::PyString; use serde::{Deserialize, Serialize}; use std::fs::OpenOptions; use std::io::Write; +use std::process::Command; use pyo3::prelude::*; @@ -35,7 +41,6 @@ pub async fn load_identity(path: &str) -> Result { /// - Get account IDs /// - Get balances /// - Execute transactions -#[pyclass] #[derive(Clone, Debug)] pub struct KeygateClient { agent: Agent, @@ -378,3 +383,217 @@ impl KeygateClient { } } } + +#[derive(Deserialize, Serialize)] +struct PersistedState { + wallet_ids: HashSet, +} + +#[pyclass] +struct PyKeygateClient { + identity_path: String, + url: String, + keygate: Arc>>, + wallet_ids: Arc>>, +} + +impl PyKeygateClient { + // Common initialization logic (not exposed to Python) + async fn initialize( + identity_path: &str, + url: &str, + keygate: Arc>>, + ) -> PyResult<()> { + let identity = load_identity(identity_path).await?; + let client = KeygateClient::new(identity, url).await?; + *keygate.write().unwrap() = Some(client); + Ok(()) + } + + fn load_wallets(path: &PathBuf) -> HashSet { + match std::fs::read_to_string(path) { + Ok(content) => serde_json::from_str::(&content) + .map(|sw| sw.wallet_ids) + .unwrap(), + Err(_) => HashSet::new(), + } + } + + fn save_wallets(&self) -> Result<(), std::io::Error> { + let wallets = self.wallet_ids.read().unwrap(); + let state = PersistedState { + wallet_ids: wallets.clone(), + }; + let json = serde_json::to_string_pretty(&state)?; + std::fs::write("config.json", json)?; + Ok(()) + } +} + +#[pymethods] +impl PyKeygateClient { + #[new] + fn new(identity_path: &str, url: &str) -> PyResult { + Ok(Self { + identity_path: identity_path.to_string(), + url: url.to_string(), + keygate: Arc::new(RwLock::new(None)), + wallet_ids: Arc::new(RwLock::new(Self::load_wallets(&PathBuf::from( + "config.json", + )))), + }) + } + + fn init<'py>(&'py mut self, py: Python<'py>) -> PyResult<&'py PyAny> { + let identity_path = self.identity_path.clone(); + let url = self.url.clone(); + let keygate = self.keygate.clone(); + + pyo3_asyncio::tokio::future_into_py(py, async move { + Self::initialize(&identity_path, &url, keygate).await + }) + } + + fn create_wallet<'py>(&'py self, py: Python<'py>) -> PyResult<&'py PyAny> { + let keygate = self.keygate.clone(); + + pyo3_asyncio::tokio::future_into_py(py, async move { + let client = { + let guard = keygate.read().unwrap(); + guard.as_ref().cloned().expect("KeygateClient not initialized. Make sure to call init() before using other methods.") + }; + + let created_wallet_principal = client.create_wallet().await; + + match created_wallet_principal { + Ok(principal) => { + let output = Command::new("dfx") + .args(&[ + "ledger", + "transfer", + &client + .get_icp_account(&created_wallet_principal.unwrap().to_text()) + .await + .unwrap(), + "--amount", + "100", + "--memo", + "1", + "--network", + "local", + "--identity", + "minter", + "--fee", + "0", + ]) + .output() + .expect("failed to execute process"); + Ok(principal.to_text()) + } + Err(e) => Err(PyErr::new::(format!( + "Error creating a Keygate wallet: {}", + e + ))), + } + }) + } + + #[pyo3(signature = (wallet_id))] + fn get_icp_balance<'py>(&'py self, wallet_id: String, py: Python<'py>) -> PyResult<&'py PyAny> { + if wallet_id.trim().is_empty() { + return Err(PyErr::new::( + "Wallet ID cannot be empty", + )); + } + + let keygate = self.keygate.clone(); + + println!("Getting ICP balance for wallet: {}", wallet_id); + + pyo3_asyncio::tokio::future_into_py(py, async move { + let client = { + let guard = keygate.read().unwrap(); + guard.as_ref().cloned().expect("KeygateClient not initialized. Make sure to call init() before using other methods.") + }; + + let balance = client.get_icp_balance(&wallet_id).await; + + match balance { + Ok(balance) => Ok(balance), + Err(e) => Err(PyErr::new::(format!( + "Error getting ICP balance: {}", + e + ))), + } + }) + } + + fn get_icp_address<'py>(&'py self, wallet_id: &str, py: Python<'py>) -> PyResult<&'py PyAny> { + let keygate = self.keygate.clone(); + let wallet_id = wallet_id.to_string(); + let client = { + let guard = keygate.read().unwrap(); + guard.as_ref().cloned().expect("KeygateClient not initialized. Make sure to call init() before using other methods.") + }; + + pyo3_asyncio::tokio::future_into_py(py, async move { + let address = client.get_icp_account(&wallet_id).await; + match address { + Ok(address) => Ok(address), + Err(e) => Err(PyErr::new::(format!( + "Error getting ICP address: {}", + e + ))), + } + }) + } + + fn execute_transaction<'py>( + &'py self, + wallet_id: &str, + to: &str, + amount: f64, + py: Python<'py>, + ) -> PyResult<&'py PyAny> { + let keygate = self.keygate.clone(); + let wallet_id = wallet_id.to_string(); + let transaction: TransactionArgs = TransactionArgs { + to: to.to_string(), + amount: amount, + }; + let client = { + let guard = keygate.read().unwrap(); + guard.as_ref().cloned().expect("KeygateClient not initialized. Make sure to call init() before using other methods.") + }; + + pyo3_asyncio::tokio::future_into_py(py, async move { + let status = client.execute_transaction(&wallet_id, &transaction).await; + match status { + Ok(status) => { + let status_str = match status { + IntentStatus::Pending(s) => s, + IntentStatus::InProgress(s) => s, + IntentStatus::Completed(s) => s, + IntentStatus::Rejected(s) => s, + IntentStatus::Failed(s) => s, + }; + Ok(status_str) + } + Err(e) => Err(PyErr::new::(format!( + "Error executing transaction: {}", + e + ))), + } + }) + } +} + +fn init_test<'py>(py: Python<'py>) -> PyResult<&PyAny> { + pyo3_asyncio::async_std::future_into_py(py, async { Ok(()) }) +} + +#[pymodule] +fn keygate_sdk(_py: Python<'_>, m: &PyModule) -> PyResult<()> { + m.add_class::()?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 884abd2..993a5d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,11 @@ -use std::error::Error; + use keygate_sdk::load_identity; use keygate_sdk::KeygateClient; use keygate_sdk::TransactionArgs; #[tokio::main] -async fn main() -> Result<(), Box> { +async fn main() -> Result<(), Box> { let identity = load_identity("identity.pem").await?; println!("Loaded identity.");