From 3bd3955e699a456c7aadbc7c78285a57d0b8eeae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vicente=20Berm=C3=BAdez?= Date: Sat, 9 Nov 2024 14:01:48 -0300 Subject: [PATCH 01/15] keygate python wrapper --- src/lib.rs | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 3 ++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index baf536b..62ae5db 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ +use std::cell::RefCell; use std::io::Error; use std::str::FromStr; +use std::sync::{Arc, Mutex}; use candid::{CandidType, Decode}; use ic_agent::agent::CallResponse; @@ -255,4 +257,64 @@ impl KeygateClient { )), } } +} + +#[pyclass] +struct PyKeygateClient { + identity_path: String, + url: String, + keygate: Arc>>, +} + +#[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(Mutex::new(None)), + }) + } + + fn init<'py>(&'py mut self, py: Python<'py>) -> PyResult<&'py PyAny> { + let identity_path = self.identity_path.clone(); + let url = self.url.clone(); + + // Thread-safe mutable state + // Keygate is now a Mutex: meaning that it can be accessed by multiple threads + // And it is Arc: meaning that it can be shared between threads + // So we can mutate it from multiple threads by locking it + // And we can reference it from multiple threads by cloning the Arc (reference counting) + let keygate_clone = self.keygate.clone(); + + // Future_into_py takes a future and a Python context + // and runs the future in the context of the Python thread + // and returns the result of the future to the Python thread. + // + // async move { ... } is used to capture the variables by value + // and move them into the async block. This is necessary because the + // future needs to capture the variables from the surrounding scope + // and keep them alive for the duration of the future. + // + + pyo3_asyncio::async_std::future_into_py(py, async move { + let identity = load_identity(&identity_path).await?; + let client = KeygateClient::new(identity, &url).await?; + + // Update through Mutex instead of RefCell + *keygate_clone.lock().unwrap() = Some(client); + Ok(()) + }) + } +} + +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(()) } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 45d49be..a0edd07 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ -use std::error::Error; + +use std::io::Error; use keygate_sdk::load_identity; use keygate_sdk::KeygateClient; From 30678ad464b460e815f5af368989b62730ea8906 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vicente=20Berm=C3=BAdez?= Date: Sat, 9 Nov 2024 15:22:36 -0300 Subject: [PATCH 02/15] add init_sync --- .python-version | 1 + Cargo.toml | 5 ++-- pyproject.toml | 2 +- src/lib.rs | 80 +++++++++++++++++++++++++------------------------ 4 files changed, 46 insertions(+), 42 deletions(-) create mode 100644 .python-version 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 dda0327..7b93c96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [lib] name = "keygate_sdk" -crate-type = ["cdylib", "rlib] +crate-type = ["cdylib", "rlib"] [[bin]] name = "keygate_sdk" @@ -23,4 +23,5 @@ 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"] } \ No newline at end of file +pyo3 = { version = "0.20", features = ["extension-module"] } +pyo3-asyncio = { version = "0.20", features = ["async-std"] } diff --git a/pyproject.toml b/pyproject.toml index e0dd83d..7eb3af3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["maturin>=1,<2"] build-backend = "maturin" [project] -name = "pyo3_example" +name = "keygate_sdk" requires-python = ">=3.7" classifiers = [ "Programming Language :: Rust", diff --git a/src/lib.rs b/src/lib.rs index 62ae5db..0f0baa0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -266,47 +266,49 @@ struct PyKeygateClient { keygate: Arc>>, } -#[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(Mutex::new(None)), - }) + // 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.lock().unwrap() = Some(client); + Ok(()) } +} - fn init<'py>(&'py mut self, py: Python<'py>) -> PyResult<&'py PyAny> { - let identity_path = self.identity_path.clone(); - let url = self.url.clone(); - - // Thread-safe mutable state - // Keygate is now a Mutex: meaning that it can be accessed by multiple threads - // And it is Arc: meaning that it can be shared between threads - // So we can mutate it from multiple threads by locking it - // And we can reference it from multiple threads by cloning the Arc (reference counting) - let keygate_clone = self.keygate.clone(); - - // Future_into_py takes a future and a Python context - // and runs the future in the context of the Python thread - // and returns the result of the future to the Python thread. - // - // async move { ... } is used to capture the variables by value - // and move them into the async block. This is necessary because the - // future needs to capture the variables from the surrounding scope - // and keep them alive for the duration of the future. - // - - pyo3_asyncio::async_std::future_into_py(py, async move { - let identity = load_identity(&identity_path).await?; - let client = KeygateClient::new(identity, &url).await?; - - // Update through Mutex instead of RefCell - *keygate_clone.lock().unwrap() = Some(client); - 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(Mutex::new(None)), + }) + } + + 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::async_std::future_into_py(py, async move { + Self::initialize(&identity_path, &url, keygate).await + }) + } + + fn init_sync(&mut self) -> PyResult<()> { + Python::with_gil(|py| { + let event_loop = pyo3_asyncio::async_std::get_current_loop(py)?; + let identity_path = self.identity_path.clone(); + let url = self.url.clone(); + let keygate = self.keygate.clone(); + + pyo3_asyncio::async_std::run_until_complete(event_loop, async move { + Self::initialize(identity_path.as_str(), url.as_str(), keygate).await + }) + }) + } } fn init_test<'py>(py: Python<'py>) -> PyResult<&PyAny> { @@ -315,6 +317,6 @@ fn init_test<'py>(py: Python<'py>) -> PyResult<&PyAny> { #[pymodule] fn keygate_sdk(_py: Python<'_>, m: &PyModule) -> PyResult<()> { - m.add_class::()?; + m.add_class::()?; Ok(()) } \ No newline at end of file From 7f02ea664ea2778a7e94eb4310ee1cb337c7680f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vicente=20Berm=C3=BAdez?= Date: Sat, 9 Nov 2024 21:18:06 -0300 Subject: [PATCH 03/15] PyKeygateClient init w/ tokio --- Cargo.toml | 2 +- examples/python-script/main.py | 10 ++++++++++ src/lib.rs | 15 +-------------- 3 files changed, 12 insertions(+), 15 deletions(-) create mode 100644 examples/python-script/main.py diff --git a/Cargo.toml b/Cargo.toml index 7b93c96..f4e2f68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,4 +24,4 @@ candid = "0.10.10" serde = "1.0.214" strum_macros = "0.26.2" pyo3 = { version = "0.20", features = ["extension-module"] } -pyo3-asyncio = { version = "0.20", features = ["async-std"] } +pyo3-asyncio = { version = "0.20", features = ["async-std", "tokio-runtime"] } diff --git a/examples/python-script/main.py b/examples/python-script/main.py new file mode 100644 index 0000000..ceedec2 --- /dev/null +++ b/examples/python-script/main.py @@ -0,0 +1,10 @@ +import keygate_sdk +import asyncio + +async def main(): + keygate = keygate_sdk.PyKeygateClient(identity_path="identity.pem", url="https://ic0.app") + await keygate.init() + print("Initialized Keygate") + print("--------------------------------") + +asyncio.run(main()) \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 0f0baa0..a17c98e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -292,23 +292,10 @@ impl PyKeygateClient { let url = self.url.clone(); let keygate = self.keygate.clone(); - pyo3_asyncio::async_std::future_into_py(py, async move { + pyo3_asyncio::tokio::future_into_py(py, async move { Self::initialize(&identity_path, &url, keygate).await }) } - - fn init_sync(&mut self) -> PyResult<()> { - Python::with_gil(|py| { - let event_loop = pyo3_asyncio::async_std::get_current_loop(py)?; - let identity_path = self.identity_path.clone(); - let url = self.url.clone(); - let keygate = self.keygate.clone(); - - pyo3_asyncio::async_std::run_until_complete(event_loop, async move { - Self::initialize(identity_path.as_str(), url.as_str(), keygate).await - }) - }) - } } fn init_test<'py>(py: Python<'py>) -> PyResult<&PyAny> { From 7a5cc8aa66a06b83c71fb63f8ab1bb2611887ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vicente=20Berm=C3=BAdez?= Date: Sun, 10 Nov 2024 09:13:42 -0300 Subject: [PATCH 04/15] started py example --- examples/python-script/main.py | 5 +++++ src/lib.rs | 30 +++++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/examples/python-script/main.py b/examples/python-script/main.py index ceedec2..0429c3c 100644 --- a/examples/python-script/main.py +++ b/examples/python-script/main.py @@ -7,4 +7,9 @@ async def main(): print("Initialized Keygate") print("--------------------------------") + print("Creating a wallet") + print(await keygate.create_wallet()) + + print("--------------------------------") + asyncio.run(main()) \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index a17c98e..d46c91e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ use std::cell::RefCell; use std::io::Error; use std::str::FromStr; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use candid::{CandidType, Decode}; use ic_agent::agent::CallResponse; @@ -263,15 +263,15 @@ impl KeygateClient { struct PyKeygateClient { identity_path: String, url: String, - keygate: Arc>>, + keygate: Arc>>, } impl PyKeygateClient { // Common initialization logic (not exposed to Python) - async fn initialize(identity_path: &str, url: &str, keygate: Arc>>) -> PyResult<()> { + 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.lock().unwrap() = Some(client); + *keygate.write().unwrap() = Some(client); Ok(()) } } @@ -283,7 +283,7 @@ impl PyKeygateClient { Ok(Self { identity_path: identity_path.to_string(), url: url.to_string(), - keygate: Arc::new(Mutex::new(None)), + keygate: Arc::new(RwLock::new(None)), }) } @@ -296,6 +296,26 @@ impl PyKeygateClient { 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 { + // Get the reference to client before the async block + let client = { + let guard = keygate.read().unwrap(); + guard.as_ref() + .ok_or_else(|| PyErr::new::("Client not initialized"))? + .clone() // Clone the entire client + }; + + + let principal = client.create_wallet().await + .map_err(|e| PyErr::new::(e.to_string()))?; + + Ok(principal.to_text()) + }) + } } fn init_test<'py>(py: Python<'py>) -> PyResult<&PyAny> { From cc59e6d245ebefbc40c5dca0139fbea11f921853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vicente=20Berm=C3=BAdez?= Date: Sun, 10 Nov 2024 22:26:27 -0300 Subject: [PATCH 05/15] added python test script --- .debug | 9 +++++++++ examples/python-script/main.py | 4 +++- src/lib.rs | 16 +++++++--------- 3 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 .debug 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/examples/python-script/main.py b/examples/python-script/main.py index 0429c3c..5067095 100644 --- a/examples/python-script/main.py +++ b/examples/python-script/main.py @@ -2,7 +2,7 @@ import asyncio async def main(): - keygate = keygate_sdk.PyKeygateClient(identity_path="identity.pem", url="https://ic0.app") + keygate = keygate_sdk.PyKeygateClient(identity_path="identity.pem", url="http://localhost:55174") await keygate.init() print("Initialized Keygate") print("--------------------------------") @@ -12,4 +12,6 @@ async def main(): print("--------------------------------") + + asyncio.run(main()) \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index d46c91e..30dbf0c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,7 +33,7 @@ pub async fn load_identity(path: &str) -> Result { } } -#[pyclass] +#[derive(Clone)] pub struct KeygateClient { agent: Agent, } @@ -301,19 +301,17 @@ impl PyKeygateClient { let keygate = self.keygate.clone(); pyo3_asyncio::tokio::future_into_py(py, async move { - // Get the reference to client before the async block let client = { let guard = keygate.read().unwrap(); - guard.as_ref() - .ok_or_else(|| PyErr::new::("Client not initialized"))? - .clone() // Clone the entire client + guard.as_ref().cloned().expect("KeygateClient not initialized. Make sure to call init() before using other methods.") }; - - let principal = client.create_wallet().await - .map_err(|e| PyErr::new::(e.to_string()))?; + let created_wallet_principal = client.create_wallet().await; - Ok(principal.to_text()) + match created_wallet_principal { + Ok(principal) => Ok(principal.to_text()), + Err(e) => Err(PyErr::new::(format!("Error creating a Keygate wallet: {}", e))), + } }) } } From 66b518c2b522acc46d1994cf476eef4dcd38c26f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vicente=20Berm=C3=BAdez?= Date: Mon, 11 Nov 2024 12:30:28 -0300 Subject: [PATCH 06/15] config.json --- Cargo.toml | 1 + examples/python-script/main.py | 5 +++- src/lib.rs | 47 ++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index f4e2f68..55acc71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,3 +25,4 @@ serde = "1.0.214" strum_macros = "0.26.2" pyo3 = { version = "0.20", features = ["extension-module"] } pyo3-asyncio = { version = "0.20", features = ["async-std", "tokio-runtime"] } +serde_json = "1.0.132" diff --git a/examples/python-script/main.py b/examples/python-script/main.py index 5067095..bb32441 100644 --- a/examples/python-script/main.py +++ b/examples/python-script/main.py @@ -2,7 +2,7 @@ import asyncio async def main(): - keygate = keygate_sdk.PyKeygateClient(identity_path="identity.pem", url="http://localhost:55174") + keygate = keygate_sdk.PyKeygateClient(identity_path="identity.pem", url="http://localhost:63617") await keygate.init() print("Initialized Keygate") print("--------------------------------") @@ -12,6 +12,9 @@ async def main(): print("--------------------------------") + print("Getting ICP balance") + print(await keygate.get_icp_balance("")) + asyncio.run(main()) \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 30dbf0c..4c3283c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ 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}; @@ -259,11 +261,18 @@ 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 { @@ -274,6 +283,23 @@ impl PyKeygateClient { *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] @@ -284,6 +310,7 @@ impl PyKeygateClient { 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")))), }) } @@ -314,6 +341,26 @@ impl PyKeygateClient { } }) } + + fn get_icp_balance<'py>(&'py self, wallet_id: &str, py: Python<'py>) -> PyResult<&'py PyAny> { + let keygate = self.keygate.clone(); + + let wallet_id = wallet_id.to_string(); + + 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 init_test<'py>(py: Python<'py>) -> PyResult<&PyAny> { From 1f28e4c22ad36a35121940d8abeea111491d50a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vicente=20Berm=C3=BAdez?= Date: Mon, 11 Nov 2024 18:47:24 -0300 Subject: [PATCH 07/15] get icp balance --- examples/python-script/main.py | 11 +++++++---- src/lib.rs | 27 +++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/examples/python-script/main.py b/examples/python-script/main.py index bb32441..6daab1d 100644 --- a/examples/python-script/main.py +++ b/examples/python-script/main.py @@ -8,13 +8,16 @@ async def main(): print("--------------------------------") print("Creating a wallet") - print(await keygate.create_wallet()) + wallet_id = await keygate.create_wallet() + print(wallet_id) print("--------------------------------") + print("Getting ICP address") + print(await keygate.get_icp_address(wallet_id)) - print("Getting ICP balance") - print(await keygate.get_icp_balance("")) + print("--------------------------------") - + print("Getting ICP balance") + print(await keygate.get_icp_balance(wallet_id)) asyncio.run(main()) \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 4c3283c..47d4324 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ 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 pyo3::prelude::*; @@ -342,10 +343,15 @@ impl PyKeygateClient { }) } - fn get_icp_balance<'py>(&'py self, wallet_id: &str, py: Python<'py>) -> PyResult<&'py PyAny> { + #[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(); - let wallet_id = wallet_id.to_string(); + println!("Getting ICP balance for wallet: {}", wallet_id); pyo3_asyncio::tokio::future_into_py(py, async move { let client = { @@ -361,6 +367,23 @@ impl PyKeygateClient { } }) } + + 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 init_test<'py>(py: Python<'py>) -> PyResult<&PyAny> { From ad5b9cc6ea1c28ad9b1424dd0aae8ad209228731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vicente=20Berm=C3=BAdez?= Date: Mon, 11 Nov 2024 19:31:04 -0300 Subject: [PATCH 08/15] ai agent --- .env.local | 1 + examples/ai-agent/main.py | 214 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 .env.local create mode 100644 examples/ai-agent/main.py diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..ed35d5d --- /dev/null +++ b/.env.local @@ -0,0 +1 @@ +ANTHROPIC_API_KEY=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 \ 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..b29d86f --- /dev/null +++ b/examples/ai-agent/main.py @@ -0,0 +1,214 @@ +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:63617" + ): + 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 + } + 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() + + 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, + 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 From 646b7fce48d885c5d72150a69f7389eb8e01903e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vicente=20Berm=C3=BAdez?= Date: Mon, 11 Nov 2024 19:33:01 -0300 Subject: [PATCH 09/15] gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index fb2da4a..c505190 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,5 @@ Cargo.lock identity.pem -.env \ No newline at end of file +.env +.env.local \ No newline at end of file From 459ac5fb9e4b9c8b76805a2bc30d3cb652989084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vicente=20Berm=C3=BAdez?= Date: Mon, 11 Nov 2024 19:33:47 -0300 Subject: [PATCH 10/15] remove key --- .env.local | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.local b/.env.local index ed35d5d..80a79e6 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -ANTHROPIC_API_KEY=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 \ No newline at end of file +ANTHROPIC_API_KEY= \ No newline at end of file From 630bc531b5e5579532c5bb3dc384330ff510b756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vicente=20Berm=C3=BAdez?= Date: Tue, 12 Nov 2024 11:42:47 -0300 Subject: [PATCH 11/15] gitignore --- .gitignore | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 09ec080..c420c26 100644 --- a/.gitignore +++ b/.gitignore @@ -26,11 +26,6 @@ Cargo.lock identity.pem -<<<<<<< HEAD .env .env.local -======= -wallet.csv - -.env ->>>>>>> main +wallet.csv \ No newline at end of file From 1008a09ad6f77b191b0b428bbd03eb81818dfcfc Mon Sep 17 00:00:00 2001 From: Quino Date: Wed, 13 Nov 2024 14:02:07 -0300 Subject: [PATCH 12/15] execute_transaction with errors --- .env.local | 2 +- examples/python-script/main.py | 11 ++- pyproject.toml | 2 +- src/lib.rs | 134 ++++++++++++++++++++++++--------- 4 files changed, 110 insertions(+), 39 deletions(-) diff --git a/.env.local b/.env.local index 80a79e6..66f1db6 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -ANTHROPIC_API_KEY= \ No newline at end of file +ANTHROPIC_API_KEY=sk-ant-api03-mBv...LgAA \ No newline at end of file diff --git a/examples/python-script/main.py b/examples/python-script/main.py index 6daab1d..9d31d03 100644 --- a/examples/python-script/main.py +++ b/examples/python-script/main.py @@ -2,7 +2,7 @@ import asyncio async def main(): - keygate = keygate_sdk.PyKeygateClient(identity_path="identity.pem", url="http://localhost:63617") + keygate = keygate_sdk.PyKeygateClient(identity_path="identity.pem", url="http://localhost:54614") await keygate.init() print("Initialized Keygate") print("--------------------------------") @@ -20,4 +20,13 @@ async def main(): print("Getting ICP balance") print(await keygate.get_icp_balance(wallet_id)) + print("--------------------------------") + + print("Transferring ICP") + transaction_args = keygate_sdk.PyTransactionArgs( + to=await keygate.get_icp_address(wallet_id), + amount=0.001 + ) + print(await keygate.execute_transaction(wallet_id, transaction_args)) + asyncio.run(main()) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 7eb3af3..ffebbfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,4 +9,4 @@ 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 94a980f..5d7647b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -383,7 +383,6 @@ impl KeygateClient { } } - #[derive(Deserialize, Serialize)] struct PersistedState { wallet_ids: HashSet, @@ -397,9 +396,19 @@ struct PyKeygateClient { wallet_ids: Arc>>, } +#[pyclass] +struct PyTransactionArgs { + to: String, + amount: f64, +} + impl PyKeygateClient { // Common initialization logic (not exposed to Python) - async fn initialize(identity_path: &str, url: &str, keygate: Arc>>) -> PyResult<()> { + 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); @@ -408,7 +417,9 @@ impl PyKeygateClient { 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(), + Ok(content) => serde_json::from_str::(&content) + .map(|sw| sw.wallet_ids) + .unwrap(), Err(_) => HashSet::new(), } } @@ -426,30 +437,32 @@ impl PyKeygateClient { #[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 { + #[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.") @@ -459,17 +472,22 @@ impl PyKeygateClient { match created_wallet_principal { Ok(principal) => Ok(principal.to_text()), - Err(e) => Err(PyErr::new::(format!("Error creating a Keygate wallet: {}", e))), + 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> { + #[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")); + return Err(PyErr::new::( + "Wallet ID cannot be empty", + )); } - + let keygate = self.keygate.clone(); println!("Getting ICP balance for wallet: {}", wallet_id); @@ -484,7 +502,10 @@ impl PyKeygateClient { match balance { Ok(balance) => Ok(balance), - Err(e) => Err(PyErr::new::(format!("Error getting ICP balance: {}", e))), + Err(e) => Err(PyErr::new::(format!( + "Error getting ICP balance: {}", + e + ))), } }) } @@ -501,7 +522,48 @@ impl PyKeygateClient { 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))), + Err(e) => Err(PyErr::new::(format!( + "Error getting ICP address: {}", + e + ))), + } + }) + } + + fn execute_transaction<'py>( + &self, + wallet_id: &str, + transaction: &PyTransactionArgs, + py: Python<'py>, + ) -> PyResult<&'py PyAny> { + let keygate = self.keygate.clone(); + let wallet_id = wallet_id.to_string(); + let transaction: TransactionArgs = TransactionArgs { + to: transaction.to.clone(), + amount: transaction.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 + ))), } }) } From 1dc812cc0532d4720270799ceacb387b88cc363e Mon Sep 17 00:00:00 2001 From: Quino Date: Wed, 13 Nov 2024 14:04:59 -0300 Subject: [PATCH 13/15] add class --- .env.local | 2 +- src/lib.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.env.local b/.env.local index 66f1db6..80a79e6 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -ANTHROPIC_API_KEY=sk-ant-api03-mBv...LgAA \ No newline at end of file +ANTHROPIC_API_KEY= \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 5d7647b..d01ea66 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -576,5 +576,6 @@ fn init_test<'py>(py: Python<'py>) -> PyResult<&PyAny> { #[pymodule] fn keygate_sdk(_py: Python<'_>, m: &PyModule) -> PyResult<()> { m.add_class::()?; + m.add_class::()?; Ok(()) } From ea594ec255c68ea5f7e0c9b4fa9242ce0f59932c Mon Sep 17 00:00:00 2001 From: Quino Date: Thu, 14 Nov 2024 09:44:11 -0300 Subject: [PATCH 14/15] execute transaction py --- .DS_Store | Bin 0 -> 6148 bytes .env.local | 2 +- examples/ai-agent/main.py | 12 ++++++++-- examples/python-script/main.py | 8 ++----- src/lib.rs | 42 +++++++++++++++++++++++---------- 5 files changed, 43 insertions(+), 21 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5e7600e163f38dcf7b027447c641ad38308e6588 GIT binary patch literal 6148 zcmeHKJx?1!5S;}>EQi7kC`F_kQkp1$WPZV6IeiEP5&{;t{nYO24vXY38j$RwIU-5%BGka|{S`mt?$&DyzN-QT*lkspNP>d6sS zec_=1HnlQUsDmr-Vbr0T&EosFq!^RS%4Xq0bBV2GGD?d}L&36^ORVV=iDxd) zM-$gJ`937x=FR6CO;ej%RIyZiKRO$GOio)n$N#j`c_xFn*Q&Q9>&@a>Dx+c@N?GsJ z!Ka_MsA~Dm+V4L1M}2kQ?!}Cs?nyBwkH)#YUnrGoIJ4QZFBQ7pDc}@v3j9}q_XiJ+ z(bpI$lv@Wn`3V5bU|Jf+at{Q0JOT7IMhYo{sZ1MhaCr30WEI z=*mKWp$J(W^PUVR;VX2#Q@|-;D=@8o=K1{pF!}r6PIAAT0#1QvrGUtkHcCZYl0I8w x7sqF float: 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.""" @@ -188,6 +195,7 @@ async def run_autonomous_mode(instructions: str, check_interval: int = 60): functions=[ ICPAgent.get_balance, ICPAgent.get_wallet_address, + ICPAgent.execute_transaction, lambda: AutoTasks.monitor_balance(agent) ] ) diff --git a/examples/python-script/main.py b/examples/python-script/main.py index 9d31d03..19f368b 100644 --- a/examples/python-script/main.py +++ b/examples/python-script/main.py @@ -2,7 +2,7 @@ import asyncio async def main(): - keygate = keygate_sdk.PyKeygateClient(identity_path="identity.pem", url="http://localhost:54614") + keygate = keygate_sdk.PyKeygateClient(identity_path="identity.pem", url="http://localhost:4943") await keygate.init() print("Initialized Keygate") print("--------------------------------") @@ -23,10 +23,6 @@ async def main(): print("--------------------------------") print("Transferring ICP") - transaction_args = keygate_sdk.PyTransactionArgs( - to=await keygate.get_icp_address(wallet_id), - amount=0.001 - ) - print(await keygate.execute_transaction(wallet_id, transaction_args)) + 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/src/lib.rs b/src/lib.rs index d01ea66..64ae655 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ use pyo3::types::PyString; use serde::{Deserialize, Serialize}; use std::fs::OpenOptions; use std::io::Write; +use std::process::Command; use pyo3::prelude::*; @@ -396,12 +397,6 @@ struct PyKeygateClient { wallet_ids: Arc>>, } -#[pyclass] -struct PyTransactionArgs { - to: String, - amount: f64, -} - impl PyKeygateClient { // Common initialization logic (not exposed to Python) async fn initialize( @@ -471,7 +466,30 @@ impl PyKeygateClient { let created_wallet_principal = client.create_wallet().await; match created_wallet_principal { - Ok(principal) => Ok(principal.to_text()), + 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 @@ -531,16 +549,17 @@ impl PyKeygateClient { } fn execute_transaction<'py>( - &self, + &'py self, wallet_id: &str, - transaction: &PyTransactionArgs, + 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: transaction.to.clone(), - amount: transaction.amount, + to: to.to_string(), + amount: amount, }; let client = { let guard = keygate.read().unwrap(); @@ -576,6 +595,5 @@ fn init_test<'py>(py: Python<'py>) -> PyResult<&PyAny> { #[pymodule] fn keygate_sdk(_py: Python<'_>, m: &PyModule) -> PyResult<()> { m.add_class::()?; - m.add_class::()?; Ok(()) } From 20ee4e530cbf7a57243fb46ac20df5be5afa632a Mon Sep 17 00:00:00 2001 From: Quino Date: Thu, 14 Nov 2024 09:44:48 -0300 Subject: [PATCH 15/15] execute transaction py --- .env.local | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.local b/.env.local index cfd2c44..80a79e6 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -ANTHROPIC_API_KEY=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 \ No newline at end of file +ANTHROPIC_API_KEY= \ No newline at end of file