From 257f35421811d2c758457437dc035d026201cc27 Mon Sep 17 00:00:00 2001 From: DeDTihoN Date: Wed, 6 Nov 2024 13:09:27 +0200 Subject: [PATCH 01/17] Init subscription-manager commit --- .gitignore | 2 + subscription-manager/Cargo.toml | 15 +- subscription-manager/src/contract.rs | 259 +++++++++++++++++++++++++-- subscription-manager/src/state.rs | 10 +- 4 files changed, 271 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 59e33b6..7c9e285 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ target Cargo.lock contract.wasm* +node_modules +subscription-manager/tests/package-lock.json # Cargo+Git helper file (https://github.com/rust-lang/cargo/blob/0.44.1/src/cargo/sources/git/utils.rs#L320-L327) .cargo-ok diff --git a/subscription-manager/Cargo.toml b/subscription-manager/Cargo.toml index 3ed95e2..ea03d5e 100644 --- a/subscription-manager/Cargo.toml +++ b/subscription-manager/Cargo.toml @@ -25,12 +25,23 @@ default = [] backtraces = ["cosmwasm-std/backtraces"] [dependencies] -cosmwasm-std = { git = "https://github.com/scrtlabs/cosmwasm", tag = "v1.1.9-secret" } -cosmwasm-storage = { git = "https://github.com/scrtlabs/cosmwasm", tag = "v1.1.9-secret" } +cosmwasm-std = { package = "secret-cosmwasm-std", version = "1.1.11" } +cosmwasm-storage = { package = "secret-cosmwasm-storage", version = "1.1.11" } +secret-toolkit = { version = "0.10.0", default-features = false, features = [ + "utils", + "storage", + "serialization", + "viewing-key", + "permit", +] } schemars = { version = "0.8.11" } serde = { version = "1.0" } +serde-json-wasm = "1.0.0" thiserror = { version = "1.0" } cosmwasm-schema = "1.0.0" +secp256k1 = "0.30.0" +hex = "0.4.3" +sha2 = "0.10.8" # Uncomment these for some common extra tools # secret-toolkit = { git = "https://github.com/scrtlabs/secret-toolkit", tag = "v0.8.0" } diff --git a/subscription-manager/src/contract.rs b/subscription-manager/src/contract.rs index 2cd4763..66b13bf 100644 --- a/subscription-manager/src/contract.rs +++ b/subscription-manager/src/contract.rs @@ -1,9 +1,11 @@ use cosmwasm_std::{ - entry_point, to_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdError, StdResult, + entry_point, to_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdError, StdResult }; +use secp256k1::{Message, PublicKey, Secp256k1, ecdsa::Signature}; +use sha2::{Digest, Sha256}; use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg, SubscriberStatusResponse}; -use crate::state::{config, State}; +use crate::state::{config, config_read, State, Subscriber, SB_MAP}; #[entry_point] pub fn instantiate( @@ -42,8 +44,28 @@ pub fn try_register_subscriber( _info: MessageInfo, _address: String, ) -> StdResult { - // TODO: IMPLEMENT ME - Err(StdError::generic_err("not implemented")) + + let config = config_read(_deps.storage); + let state = config.load()?; + if _info.sender != state.admin { + return Err(StdError::generic_err("Only admin can register subscribers")); + } + + let map_contains_sb = SB_MAP.contains(_deps.storage, &_address); + + if map_contains_sb { + return Err(StdError::generic_err("Subscriber already registered")); + } + + let subscriber = Subscriber { address: _address.clone(), status: true }; + // Insert new value + + SB_MAP.insert(_deps.storage, &_address, &subscriber) + .map_err(|err| StdError::generic_err(err.to_string()))?; + + Ok(Response::new() + .add_attribute("action", "register_subscriber") + .add_attribute("subscriber", _address)) } pub fn try_remove_subscriber( @@ -51,13 +73,62 @@ pub fn try_remove_subscriber( _info: MessageInfo, _address: String, ) -> StdResult { - // TODO: IMPLEMENT ME - Err(StdError::generic_err("not implemented")) + let config = config_read(_deps.storage); + let state = config.load()?; + if _info.sender != state.admin { + return Err(StdError::generic_err("Only admin can remove subscribers")); + } + + let map_contains_sb = SB_MAP.contains(_deps.storage, &_address); + + if !map_contains_sb { + return Err(StdError::generic_err("Subscriber not registered")); + } + + SB_MAP.remove(_deps.storage, &_address) + .map_err(|err| StdError::generic_err(err.to_string()))?; + + Ok(Response::new() + .add_attribute("action", "remove_subscriber") + .add_attribute("subscriber", _address)) } pub fn try_set_admin(_deps: DepsMut, _info: MessageInfo, _address: String) -> StdResult { - // TODO: IMPLEMENT ME - Err(StdError::generic_err("not implemented")) + let mut config = config(_deps.storage); + let mut state = config.load()?; + + // Only the current admin can set a new admin + if _info.sender != state.admin { + return Err(StdError::generic_err("Only the current admin can set a new admin")); + } + + // let canonical_address = _deps.api.addr_canonicalize(&_address) + // .map_err(|err| { + // StdError::generic_err(format!("Invalid address: {}", err)) + // }); + + // if canonical_address.is_err() { + // return Err(StdError::generic_err("Invalid address")); + // } + + // let final_address = _deps.api.addr_humanize(&canonical_address.unwrap()); + + // if final_address.is_err() { + // return Err(StdError::generic_err("Invalid address")); + // } + + let final_address = _deps.api.addr_validate(&_address).map_err(|err| { + StdError::generic_err(format!("Invalid address: {}", err)) + })?; + + // Update the admin to the new address + state.admin = final_address; + config.save(&state)?; + + // Log the admin change + Ok(Response::new() + .add_attribute("action", "set_admin") + .add_attribute("new_admin", _address)) } #[entry_point] @@ -82,15 +153,55 @@ fn query_subscriber( _signature: String, _sender_public_key: String, ) -> StdResult { - // TODO: IMPLEMENT ME + + let payload = format!("{}{}", _address, "_payload_message"); + + let is_valid = verify_signature(_sender_public_key, _signature, payload.as_bytes())?; + + if !is_valid { + return Err(StdError::generic_err("Invalid signature")); + } + + let subscriber = SB_MAP.get(_deps.storage, &_address); + + if !subscriber.is_none() { + return Ok(SubscriberStatusResponse { active: true }); + } + Ok(SubscriberStatusResponse { active: false }) } +fn verify_signature( + public_key_hex: String, + signature_hex: String, + message: &[u8], +) -> StdResult { + let secp = Secp256k1::verification_only(); + + let public_key_bytes = hex::decode(public_key_hex) + .map_err(|_| StdError::generic_err("Invalid public key hex"))?; + let public_key = PublicKey::from_slice(&public_key_bytes) + .map_err(|_| StdError::generic_err("Invalid public key"))?; + + let signature_bytes = hex::decode(signature_hex) + .map_err(|_| StdError::generic_err("Invalid signature hex"))?; + let signature = Signature::from_der(&signature_bytes) + .map_err(|_| StdError::generic_err("Invalid signature"))?; + + let message_hash = sha2::Sha256::digest(message); + let message = Message::from_slice(&message_hash) + .map_err(|_| StdError::generic_err("Invalid message"))?; + + secp.verify_ecdsa(&message, &signature, &public_key) + .map(|_| true) + .map_err(|_| StdError::generic_err("Signature verification failed")) +} + #[cfg(test)] mod tests { use super::*; use cosmwasm_std::testing::*; - use cosmwasm_std::{Coin, Uint128}; + use cosmwasm_std::{attr, from_binary, Coin, Uint128}; #[test] fn proper_initialization() { @@ -104,9 +215,133 @@ mod tests { ); let init_msg = InstantiateMsg {}; - // we can just call .unwrap() to assert this was a success + // Assert successful initialization let res = instantiate(deps.as_mut(), mock_env(), info, init_msg).unwrap(); + assert_eq!(0, res.messages.len()); + } + + #[test] + fn register_subscriber_success() { + let mut deps = mock_dependencies(); + let info = mock_info("admin", &[]); + let init_msg = InstantiateMsg {}; + instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + let register_msg = ExecuteMsg::RegisterSubscriber { + address: "subscriber1".to_string(), + }; + + let res = execute(deps.as_mut(), mock_env(), info, register_msg).unwrap(); assert_eq!(0, res.messages.len()); + assert_eq!(res.attributes, vec![ + attr("action", "register_subscriber"), + attr("subscriber", "subscriber1") + ]); } -} + + #[test] + fn register_subscriber_unauthorized() { + let mut deps = mock_dependencies(); + let info = mock_info("admin", &[]); + let init_msg = InstantiateMsg {}; + instantiate(deps.as_mut(), mock_env(), info, init_msg).unwrap(); + + let unauthorized_info = mock_info("not_admin", &[]); + let register_msg = ExecuteMsg::RegisterSubscriber { + address: "subscriber1".to_string(), + }; + + let res = execute(deps.as_mut(), mock_env(), unauthorized_info, register_msg); + assert!(res.is_err()); + assert_eq!( + res.err().unwrap(), + StdError::generic_err("Only admin can register subscribers") + ); + } + + #[test] + fn remove_subscriber_success() { + let mut deps = mock_dependencies(); + let info = mock_info("admin", &[]); + let init_msg = InstantiateMsg {}; + instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + + // Register a subscriber first + let register_msg = ExecuteMsg::RegisterSubscriber { + address: "subscriber1".to_string(), + }; + execute(deps.as_mut(), mock_env(), info.clone(), register_msg).unwrap(); + + // Now remove the subscriber + let remove_msg = ExecuteMsg::RemoveSubscriber { + address: "subscriber1".to_string(), + }; + let res = execute(deps.as_mut(), mock_env(), info, remove_msg).unwrap(); + assert_eq!(0, res.messages.len()); + assert_eq!(res.attributes, vec![ + attr("action", "remove_subscriber"), + attr("subscriber", "subscriber1") + ]); + } + + #[test] + fn remove_subscriber_not_registered() { + let mut deps = mock_dependencies(); + let info = mock_info("admin", &[]); + let init_msg = InstantiateMsg {}; + instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + + let remove_msg = ExecuteMsg::RemoveSubscriber { + address: "subscriber1".to_string(), + }; + let res = execute(deps.as_mut(), mock_env(), info, remove_msg); + assert!(res.is_err()); + assert_eq!( + res.err().unwrap(), + StdError::generic_err("Subscriber not registered") + ); + } + + #[test] + fn set_admin_success() { + let mut deps = mock_dependencies(); + let info = mock_info("admin", &[]); + let init_msg = InstantiateMsg {}; + instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + + let set_admin_msg = ExecuteMsg::SetAdmin { + address: "new_admin".to_string(), + }; + + let res = execute(deps.as_mut(), mock_env(), info, set_admin_msg).unwrap(); + assert_eq!(0, res.messages.len()); + assert_eq!(res.attributes, vec![ + attr("action", "set_admin"), + attr("new_admin", "new_admin") + ]); + + // Check that the admin was updated + let config = config_read(&deps.storage).load().unwrap(); + assert_eq!(config.admin, Addr::unchecked("new_admin")); + } + + #[test] + fn set_admin_unauthorized() { + let mut deps = mock_dependencies(); + let info = mock_info("admin", &[]); + let init_msg = InstantiateMsg {}; + instantiate(deps.as_mut(), mock_env(), info, init_msg).unwrap(); + + let unauthorized_info = mock_info("not_admin", &[]); + let set_admin_msg = ExecuteMsg::SetAdmin { + address: "new_admin".to_string(), + }; + + let res = execute(deps.as_mut(), mock_env(), unauthorized_info, set_admin_msg); + assert!(res.is_err()); + assert_eq!( + res.err().unwrap(), + StdError::generic_err("Only the current admin can set a new admin") + ); + } +} \ No newline at end of file diff --git a/subscription-manager/src/state.rs b/subscription-manager/src/state.rs index decfe48..a9f3125 100644 --- a/subscription-manager/src/state.rs +++ b/subscription-manager/src/state.rs @@ -1,20 +1,28 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use secret_toolkit::storage::{Item, Keymap}; use cosmwasm_std::{Addr, Storage}; use cosmwasm_storage::{singleton, singleton_read, ReadonlySingleton, Singleton}; pub static CONFIG_KEY: &[u8] = b"config"; +pub static SB_MAP: Keymap = Keymap::new(b"SB_MAP"); #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] pub struct State { pub admin: Addr, } +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] +pub struct Subscriber { + pub status: bool, + pub address: String, +} + pub fn config(storage: &mut dyn Storage) -> Singleton { singleton(storage, CONFIG_KEY) } pub fn config_read(storage: &dyn Storage) -> ReadonlySingleton { singleton_read(storage, CONFIG_KEY) -} +} \ No newline at end of file From 8bb85e35ce055fd56910e3de7ab118df79addd53 Mon Sep 17 00:00:00 2001 From: DeDTihoN Date: Wed, 6 Nov 2024 16:21:29 +0200 Subject: [PATCH 02/17] added query unit tests --- subscription-manager/src/contract.rs | 160 ++++++++++++++++++++++++++- 1 file changed, 158 insertions(+), 2 deletions(-) diff --git a/subscription-manager/src/contract.rs b/subscription-manager/src/contract.rs index 66b13bf..b8418d3 100644 --- a/subscription-manager/src/contract.rs +++ b/subscription-manager/src/contract.rs @@ -1,7 +1,7 @@ use cosmwasm_std::{ entry_point, to_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdError, StdResult }; -use secp256k1::{Message, PublicKey, Secp256k1, ecdsa::Signature}; +use secp256k1::{Message, PublicKey, Secp256k1, ecdsa::Signature, SecretKey}; use sha2::{Digest, Sha256}; use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg, SubscriberStatusResponse}; @@ -194,7 +194,7 @@ fn verify_signature( secp.verify_ecdsa(&message, &signature, &public_key) .map(|_| true) - .map_err(|_| StdError::generic_err("Signature verification failed")) + .map_err(|_| StdError::generic_err("Invalid signature")) } #[cfg(test)] @@ -344,4 +344,160 @@ mod tests { StdError::generic_err("Only the current admin can set a new admin") ); } + + #[test] + fn query_subscriber_with_valid_signature() { + let mut deps = mock_dependencies(); + let info = mock_info("admin", &[]); + let init_msg = InstantiateMsg {}; + instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + + // Register a subscriber + let register_msg = ExecuteMsg::RegisterSubscriber { + address: "subscriber1".to_string(), + }; + execute(deps.as_mut(), mock_env(), info, register_msg).unwrap(); + + // Generate a valid signature using secp256k1 + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[0x01; 32]).unwrap(); + let public_key = secp256k1::PublicKey::from_secret_key(&secp, &secret_key); + + let message = format!("{}{}", "subscriber1", "_payload_message"); + let message_hash = Sha256::digest(message.as_bytes()); + let message = Message::from_slice(&message_hash).unwrap(); + + + let signature = secp.sign_ecdsa(&message, &secret_key); + + let signature_hex = hex::encode(signature.serialize_der()); + let public_key_hex = hex::encode(public_key.serialize_uncompressed()); + + let query_msg = QueryMsg::SubscriberStatus { + address: "subscriber1".to_string(), + signature: signature_hex, + sender_public_key: public_key_hex, + }; + + let bin = query(deps.as_ref(), mock_env(), query_msg).unwrap(); + let response: SubscriberStatusResponse = from_binary(&bin).unwrap(); + + // Проверяем, что подписчик активен + assert!(response.active); + } + + #[test] + fn query_subscriber_with_wrong_public_key() { + let mut deps = mock_dependencies(); + let info = mock_info("admin", &[]); + let init_msg = InstantiateMsg {}; + instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + + let register_msg = ExecuteMsg::RegisterSubscriber { + address: "subscriber1".to_string(), + }; + execute(deps.as_mut(), mock_env(), info, register_msg).unwrap(); + + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[0x01; 32]).unwrap(); + let public_key = secp256k1::PublicKey::from_secret_key(&secp, &secret_key); + + let message = format!("{}{}", "subscriber1", "_payload_message"); + let message_hash = Sha256::digest(message.as_bytes()); + let message = Message::from_slice(&message_hash).unwrap(); + + let signature = secp.sign_ecdsa(&message, &secret_key); + + let wrong_secret_key = SecretKey::from_slice(&[0x02; 32]).unwrap(); + let wrong_public_key = secp256k1::PublicKey::from_secret_key(&secp, &wrong_secret_key); + + let signature_hex = hex::encode(signature.serialize_der()); + let wrong_public_key_hex = hex::encode(wrong_public_key.serialize_uncompressed()); + + let query_msg = QueryMsg::SubscriberStatus { + address: "subscriber1".to_string(), + signature: signature_hex, + sender_public_key: wrong_public_key_hex, + }; + + let result = query(deps.as_ref(), mock_env(), query_msg); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + StdError::generic_err("Invalid signature") + ); + } + + #[test] + fn query_subscriber_with_wrong_signature() { + let mut deps = mock_dependencies(); + let info = mock_info("admin", &[]); + let init_msg = InstantiateMsg {}; + instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + + let register_msg = ExecuteMsg::RegisterSubscriber { + address: "subscriber1".to_string(), + }; + execute(deps.as_mut(), mock_env(), info, register_msg).unwrap(); + + let secp = Secp256k1::new(); + let wrong_secret_key = SecretKey::from_slice(&[0x02; 32]).unwrap(); // Используем другой ключ + let message = format!("{}{}", "subscriber1", "_payload_message"); + let message_hash = Sha256::digest(message.as_bytes()); + let message = Message::from_slice(&message_hash).unwrap(); + let wrong_signature = secp.sign_ecdsa(&message, &wrong_secret_key); + + let correct_secret_key = SecretKey::from_slice(&[0x01; 32]).unwrap(); + let correct_public_key = secp256k1::PublicKey::from_secret_key(&secp, &correct_secret_key); + + let wrong_signature_hex = hex::encode(wrong_signature.serialize_der()); + let correct_public_key_hex = hex::encode(correct_public_key.serialize_uncompressed()); + + let query_msg = QueryMsg::SubscriberStatus { + address: "subscriber1".to_string(), + signature: wrong_signature_hex, + sender_public_key: correct_public_key_hex, + }; + + let result = query(deps.as_ref(), mock_env(), query_msg); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + StdError::generic_err("Invalid signature") + ); + } + + #[test] + fn query_unregistered_subscriber() { + let mut deps = mock_dependencies(); + let info = mock_info("admin", &[]); + let init_msg = InstantiateMsg {}; + instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[0x01; 32]).unwrap(); + let public_key = secp256k1::PublicKey::from_secret_key(&secp, &secret_key); + + let message = format!("{}{}", "unregistered_subscriber", "_payload_message"); + let message_hash = Sha256::digest(message.as_bytes()); + let message = Message::from_slice(&message_hash).unwrap(); + let signature = secp.sign_ecdsa(&message, &secret_key); + + let signature_hex = hex::encode(signature.serialize_der()); + let public_key_hex = hex::encode(public_key.serialize_uncompressed()); + + let query_msg = QueryMsg::SubscriberStatus { + address: "unregistered_subscriber".to_string(), + signature: signature_hex, + sender_public_key: public_key_hex, + }; + + let bin = query(deps.as_ref(), mock_env(), query_msg).unwrap(); + let response: SubscriberStatusResponse = from_binary(&bin).unwrap(); + + // Ожидаем, что подписчик не активен + assert!(!response.active); + } } \ No newline at end of file From 8e1ac29afbf1351b5dd8ca6bee479c800cb397d5 Mon Sep 17 00:00:00 2001 From: DeDTihoN Date: Fri, 8 Nov 2024 12:55:16 +0200 Subject: [PATCH 03/17] replaced the secp256k1 library with _deps.api.secp256k1 --- subscription-manager/Cargo.toml | 17 +++--- subscription-manager/src/contract.rs | 88 ++++++++++++++-------------- 2 files changed, 54 insertions(+), 51 deletions(-) diff --git a/subscription-manager/Cargo.toml b/subscription-manager/Cargo.toml index ea03d5e..08882c4 100644 --- a/subscription-manager/Cargo.toml +++ b/subscription-manager/Cargo.toml @@ -25,23 +25,25 @@ default = [] backtraces = ["cosmwasm-std/backtraces"] [dependencies] -cosmwasm-std = { package = "secret-cosmwasm-std", version = "1.1.11" } -cosmwasm-storage = { package = "secret-cosmwasm-storage", version = "1.1.11" } +cosmwasm-std = { package = "secret-cosmwasm-std", version = "1.0.0" } +cosmwasm-storage = { package = "secret-cosmwasm-storage", version = "1.0.0" } secret-toolkit = { version = "0.10.0", default-features = false, features = [ - "utils", +# "utils", "storage", - "serialization", - "viewing-key", - "permit", +# "serialization", +# "viewing-key", +# "permit", ] } +getrandom = { version = "0.2.15", features = ["js"]} schemars = { version = "0.8.11" } serde = { version = "1.0" } serde-json-wasm = "1.0.0" thiserror = { version = "1.0" } cosmwasm-schema = "1.0.0" -secp256k1 = "0.30.0" hex = "0.4.3" sha2 = "0.10.8" +k256 = "0.13.4" +secp256k1 = "0.30.0" # Uncomment these for some common extra tools # secret-toolkit = { git = "https://github.com/scrtlabs/secret-toolkit", tag = "v0.8.0" } @@ -53,3 +55,4 @@ sha2 = "0.10.8" [[bin]] name = "schema" +required-features = ["schema"] diff --git a/subscription-manager/src/contract.rs b/subscription-manager/src/contract.rs index b8418d3..9a680ed 100644 --- a/subscription-manager/src/contract.rs +++ b/subscription-manager/src/contract.rs @@ -1,9 +1,7 @@ use cosmwasm_std::{ entry_point, to_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdError, StdResult }; -use secp256k1::{Message, PublicKey, Secp256k1, ecdsa::Signature, SecretKey}; use sha2::{Digest, Sha256}; - use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg, SubscriberStatusResponse}; use crate::state::{config, config_read, State, Subscriber, SB_MAP}; @@ -153,13 +151,24 @@ fn query_subscriber( _signature: String, _sender_public_key: String, ) -> StdResult { - let payload = format!("{}{}", _address, "_payload_message"); - let is_valid = verify_signature(_sender_public_key, _signature, payload.as_bytes())?; + let public_key_hex = _sender_public_key.clone(); + let signature_hex = _signature.clone(); + let message = payload.as_bytes(); - if !is_valid { - return Err(StdError::generic_err("Invalid signature")); + let public_key_bytes = hex::decode(public_key_hex) + .map_err(|_| StdError::generic_err("Invalid public key hex"))?; + + let signature_bytes = hex::decode(signature_hex) + .map_err(|_| StdError::generic_err("Invalid signature hex"))?; + + let message_hash = Sha256::digest(message); + + let verify = _deps.api.secp256k1_verify(message_hash.as_slice(), &signature_bytes, &public_key_bytes).map_err(|e| StdError::generic_err("Failed to verify signature: ".to_string() + &e.to_string()))?; + + if !verify { + return Err(StdError::generic_err("Signature verification failed")); } let subscriber = SB_MAP.get(_deps.storage, &_address); @@ -171,37 +180,12 @@ fn query_subscriber( Ok(SubscriberStatusResponse { active: false }) } -fn verify_signature( - public_key_hex: String, - signature_hex: String, - message: &[u8], -) -> StdResult { - let secp = Secp256k1::verification_only(); - - let public_key_bytes = hex::decode(public_key_hex) - .map_err(|_| StdError::generic_err("Invalid public key hex"))?; - let public_key = PublicKey::from_slice(&public_key_bytes) - .map_err(|_| StdError::generic_err("Invalid public key"))?; - - let signature_bytes = hex::decode(signature_hex) - .map_err(|_| StdError::generic_err("Invalid signature hex"))?; - let signature = Signature::from_der(&signature_bytes) - .map_err(|_| StdError::generic_err("Invalid signature"))?; - - let message_hash = sha2::Sha256::digest(message); - let message = Message::from_slice(&message_hash) - .map_err(|_| StdError::generic_err("Invalid message"))?; - - secp.verify_ecdsa(&message, &signature, &public_key) - .map(|_| true) - .map_err(|_| StdError::generic_err("Invalid signature")) -} - #[cfg(test)] mod tests { use super::*; use cosmwasm_std::testing::*; - use cosmwasm_std::{attr, from_binary, Coin, Uint128}; + use cosmwasm_std::{attr, from_binary, Api, Coin, Uint128}; + use secp256k1::{Message, PublicKey, Secp256k1, ecdsa::Signature, SecretKey}; #[test] fn proper_initialization() { @@ -367,11 +351,15 @@ mod tests { let message_hash = Sha256::digest(message.as_bytes()); let message = Message::from_slice(&message_hash).unwrap(); - let signature = secp.sign_ecdsa(&message, &secret_key); - let signature_hex = hex::encode(signature.serialize_der()); - let public_key_hex = hex::encode(public_key.serialize_uncompressed()); + // Use compact format for the signature (64 bytes) + let signature_bytes = signature.serialize_compact(); + // Use uncompressed format for the public key (65 bytes) + let public_key_bytes = public_key.serialize_uncompressed(); + + let signature_hex = hex::encode(signature_bytes); + let public_key_hex = hex::encode(public_key_bytes); let query_msg = QueryMsg::SubscriberStatus { address: "subscriber1".to_string(), @@ -382,7 +370,7 @@ mod tests { let bin = query(deps.as_ref(), mock_env(), query_msg).unwrap(); let response: SubscriberStatusResponse = from_binary(&bin).unwrap(); - // Проверяем, что подписчик активен + // Check that the subscriber is active assert!(response.active); } @@ -393,11 +381,13 @@ mod tests { let init_msg = InstantiateMsg {}; instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + // Register a subscriber let register_msg = ExecuteMsg::RegisterSubscriber { address: "subscriber1".to_string(), }; execute(deps.as_mut(), mock_env(), info, register_msg).unwrap(); + // Generate signature with the correct key let secp = Secp256k1::new(); let secret_key = SecretKey::from_slice(&[0x01; 32]).unwrap(); let public_key = secp256k1::PublicKey::from_secret_key(&secp, &secret_key); @@ -408,10 +398,12 @@ mod tests { let signature = secp.sign_ecdsa(&message, &secret_key); + // Generate a wrong public key let wrong_secret_key = SecretKey::from_slice(&[0x02; 32]).unwrap(); let wrong_public_key = secp256k1::PublicKey::from_secret_key(&secp, &wrong_secret_key); - let signature_hex = hex::encode(signature.serialize_der()); + // Convert signature and wrong public key to hex + let signature_hex = hex::encode(signature.serialize_compact()); let wrong_public_key_hex = hex::encode(wrong_public_key.serialize_uncompressed()); let query_msg = QueryMsg::SubscriberStatus { @@ -422,10 +414,11 @@ mod tests { let result = query(deps.as_ref(), mock_env(), query_msg); + // Expect the signature verification to fail assert!(result.is_err()); assert_eq!( result.unwrap_err(), - StdError::generic_err("Invalid signature") + StdError::generic_err("Signature verification failed") ); } @@ -436,22 +429,26 @@ mod tests { let init_msg = InstantiateMsg {}; instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + // Register a subscriber let register_msg = ExecuteMsg::RegisterSubscriber { address: "subscriber1".to_string(), }; execute(deps.as_mut(), mock_env(), info, register_msg).unwrap(); + // Generate a wrong signature using a different secret key let secp = Secp256k1::new(); - let wrong_secret_key = SecretKey::from_slice(&[0x02; 32]).unwrap(); // Используем другой ключ + let wrong_secret_key = SecretKey::from_slice(&[0x02; 32]).unwrap(); let message = format!("{}{}", "subscriber1", "_payload_message"); let message_hash = Sha256::digest(message.as_bytes()); let message = Message::from_slice(&message_hash).unwrap(); let wrong_signature = secp.sign_ecdsa(&message, &wrong_secret_key); + // Use the correct public key let correct_secret_key = SecretKey::from_slice(&[0x01; 32]).unwrap(); let correct_public_key = secp256k1::PublicKey::from_secret_key(&secp, &correct_secret_key); - let wrong_signature_hex = hex::encode(wrong_signature.serialize_der()); + // Convert the wrong signature and correct public key to hex + let wrong_signature_hex = hex::encode(wrong_signature.serialize_compact()); let correct_public_key_hex = hex::encode(correct_public_key.serialize_uncompressed()); let query_msg = QueryMsg::SubscriberStatus { @@ -462,10 +459,11 @@ mod tests { let result = query(deps.as_ref(), mock_env(), query_msg); + // Expect the signature verification to fail assert!(result.is_err()); assert_eq!( result.unwrap_err(), - StdError::generic_err("Invalid signature") + StdError::generic_err("Signature verification failed") ); } @@ -476,6 +474,7 @@ mod tests { let init_msg = InstantiateMsg {}; instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + // Generate signature for an unregistered subscriber let secp = Secp256k1::new(); let secret_key = SecretKey::from_slice(&[0x01; 32]).unwrap(); let public_key = secp256k1::PublicKey::from_secret_key(&secp, &secret_key); @@ -485,7 +484,8 @@ mod tests { let message = Message::from_slice(&message_hash).unwrap(); let signature = secp.sign_ecdsa(&message, &secret_key); - let signature_hex = hex::encode(signature.serialize_der()); + // Convert signature and public key to hex + let signature_hex = hex::encode(signature.serialize_compact()); let public_key_hex = hex::encode(public_key.serialize_uncompressed()); let query_msg = QueryMsg::SubscriberStatus { @@ -497,7 +497,7 @@ mod tests { let bin = query(deps.as_ref(), mock_env(), query_msg).unwrap(); let response: SubscriberStatusResponse = from_binary(&bin).unwrap(); - // Ожидаем, что подписчик не активен + // Expect the subscriber to be inactive assert!(!response.active); } } \ No newline at end of file From 8c7cfdac9e9054eb51382f38952fcbf0763d6e5f Mon Sep 17 00:00:00 2001 From: DeDTihoN Date: Mon, 11 Nov 2024 11:20:00 +0200 Subject: [PATCH 04/17] fixed parameters names and removed secp256k1, added comments and documentation --- subscription-manager/.gitignore | 2 + subscription-manager/Readme.md | 365 ++++ subscription-manager/node/.gitignore | 21 + subscription-manager/node/package-lock.json | 1814 +++++++++++++++++++ subscription-manager/node/package.json | 20 + subscription-manager/node/upload.js | 82 + subscription-manager/src/contract.rs | 332 ++-- subscription-manager/src/msg.rs | 22 +- subscription-manager/src/state.rs | 14 +- 9 files changed, 2448 insertions(+), 224 deletions(-) create mode 100644 subscription-manager/.gitignore create mode 100644 subscription-manager/Readme.md create mode 100644 subscription-manager/node/.gitignore create mode 100644 subscription-manager/node/package-lock.json create mode 100644 subscription-manager/node/package.json create mode 100644 subscription-manager/node/upload.js diff --git a/subscription-manager/.gitignore b/subscription-manager/.gitignore new file mode 100644 index 0000000..45326df --- /dev/null +++ b/subscription-manager/.gitignore @@ -0,0 +1,2 @@ +contract-opt.wasm +Dockerfile \ No newline at end of file diff --git a/subscription-manager/Readme.md b/subscription-manager/Readme.md new file mode 100644 index 0000000..155242f --- /dev/null +++ b/subscription-manager/Readme.md @@ -0,0 +1,365 @@ +# Claive Subscription Manager Contract + +This repository contains a CosmWasm smart contract for managing subscriptions on Secret Network. The contract provides functionality to register and remove subscribers and includes admin management features. Secret Network's privacy features are utilized to keep subscriber data confidential and secure. + +--- + +## Overview + +The Claive Subscription Manager Contract is designed for subscription-based use cases, where an admin manages subscribers using their public keys. The contract keeps track of registered subscribers and ensures that only authorized admins can add or remove subscribers or change admin permissions. + +### Contract State +The contract stores: +- **Admin Address**: The account that has permission to register or remove subscribers and manage admin rights. +- **Subscribers**: A mapping from a public key to the subscriber's status (active or inactive). + +### Methods + +1. **Instantiate** + - Initializes the contract and sets the admin to the sender's address. +2. **Execute** + - `RegisterSubscriber`: Adds a new subscriber using their public key. Only callable by the admin. + - `RemoveSubscriber`: Removes a subscriber using their public key. Only callable by the admin. + - `SetAdmin`: Changes the admin to a new address. Only callable by the current admin. +3. **Query** + - `SubscriberStatus`: Checks if a subscriber with the given public key is active. + +--- + +## Prerequisites + +To use and deploy this contract, you'll need: +- [**SecretCLI**](https://docs.scrt.network/secret-network-documentation/infrastructure/secret-cli) for interacting with the Secret Network. +- [**LocalSecret**](https://docs.scrt.network/secret-network-documentation/development/readme-1/setting-up-your-environment) for local testing and development. + +Please refer to the documentation above to install and familiarize yourself with these tools. + +--- + +## Step 1: Build the Contract + +### Prerequisites + +- **Rust** and **wasm-opt** must be installed. +- Add the `wasm32-unknown-unknown` target for Rust if you haven’t done so: + ```bash + rustup target add wasm32-unknown-unknown + ``` + +### Build Instructions + +1. **Build the Contract**: + ```bash + cargo build --release --target wasm32-unknown-unknown + ``` + +2. **Optimize the Contract**: + ```bash + wasm-opt -Oz -o contract-opt.wasm target/wasm32-unknown-unknown/release/claive_subscription_manager.wasm + ``` + +3. **Compress the Contract**: + ```bash + gzip -9 -c contract-opt.wasm > contract.wasm.gz + ``` + +### Example Output +```bash +$ cargo build --release --target wasm32-unknown-unknown + Compiling claive_subscription_manager v0.1.0 (/path/to/contract) + Finished release [optimized] target(s) in 23.45s + +$ wasm-opt -Oz -o contract-opt.wasm target/wasm32-unknown-unknown/release/claive_subscription_manager.wasm +# wasm-opt optimization completed + +$ gzip -9 -c contract-opt.wasm > contract.wasm.gz +# Contract compressed successfully +``` + +--- + +## Step 2: Deploy the Contract + +### Prerequisites + +- **SecretCLI** must be configured. + +### Deploy Instructions + +1. **Deploy the Contract**: + ```bash + secretcli tx compute store contract.wasm.gz --gas 5000000 --from myWallet -y + ``` + +2. **Get the `code_id`**: + ```bash + secretcli query compute list-code + ``` + +### Example Output +```bash +$ secretcli tx compute store contract.wasm.gz --gas 5000000 --from myWallet -y +{ + "height": "0", + "txhash": "DABA1EA6380DF252C844355109298681C28EC52BE0031E7E3B8730D8ECFC2BE0", + "code": 0, + "logs": [] +} + +$ secretcli query compute list-code +[ + { + "code_id": 1, + "creator": "secret1msmqzrp8ahvwe2jzk9n0xula0vnmv7vt3883y8", + "code_hash": "afd0b5bda5a14dd41dc98d4cf112c1a239b5689796ac0fec4845db69d0a11f28" + } +] +``` + +--- + +## Step 3: Instantiate the Contract + +### Prerequisites + +- You need the `code_id` from the previous step. + +### Instantiate Instructions + +1. **Instantiate the Contract**: + ```bash + secretcli tx compute instantiate '{}' --from myWallet --label subContract -y + ``` + +2. **Get the Contract Address**: + ```bash + secretcli query compute list-contract-by-code + ``` + +### Example Output +```bash +$ secretcli tx compute instantiate 1 '{}' --from myWallet --label subContract -y +{ + "height": "0", + "txhash": "ACFD28FB7DE8ADC706B3595A32E2EA85219E203C9CA67EEF1DF5A7E23509FD9B", + "code": 0, + "logs": [] +} + +$ secretcli query compute list-contract-by-code 1 +[ + { + "contract_address": "secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf", + "code_id": 1, + "label": "subContract", + "creator": "secret1msmqzrp8ahvwe2jzk9n0xula0vnmv7vt3883y8" + } +] +``` + +--- + +## Use Cases + +### Use Case 1: Register a Subscriber + +**Description**: Register a subscriber using their public key. Only the admin can perform this action. + +#### Command +```bash +secretcli tx compute execute '{"register_subscriber":{"public_key":"subscriber_pub_key"}}' --from myWallet -y +``` + +to get logs of the transaction use +```bash +secretcli q compute tx +``` + +#### Example +```bash +$ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"register_subscriber":{"public_key":"subscriber_pub_key"}}' --from myWallet -y +{"height":"0","txhash":"F9435CA04E44FD924966089DBBBE395E7CA21422FF8D6A29BC31E9A0B016CCE4","codespace":"","code":0,"data":"","raw_log":"[]","logs":[],"info":"","gas_wanted":"0","gas_used":"0","tx":null,"timestamp":"","events":[]} +$ secretcli q compute tx F9435CA04E44FD924966089DBBBE395E7CA21422FF8D6A29BC31E9A0B016CCE4 +{ + "height": "0", + "txhash": "F9435CA04E44FD924966089DBBBE395E7CA21422FF8D6A29BC31E9A0B016CCE4", + "code": 0, + "logs": [ + { + "type": "wasm", + "attributes": [ + { + "key": "contract_address", + "value": "secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf" + }, + { + "key": "action", + "value": "register_subscriber" + }, + { + "key": "subscriber", + "value": "subscriber_pub_key" + } + ] + } + ] +} +``` + +--- + +### Use Case 2: Query Subscriber Status + +**Description**: Check if a subscriber is active or not. + +#### Command +```bash +secretcli query compute query '{"subscriber_status":{"public_key":"subscriber_pub_key"}}' +``` + +#### Example +```bash +$ secretcli query compute query secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"subscriber_status":{"public_key":"subscriber_pub_key"}}' +{ + "active": true +} +``` + +--- + +### Use Case 3: Remove a Subscriber + +**Description**: Remove a subscriber using their public key. Only the admin can perform this action. + +#### Command +```bash +secretcli tx compute execute '{"remove_subscriber":{"public_key":"subscriber_pub_key"}}' --from myWallet -y +``` + +#### Example +```bash +$ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"remove_subscriber":{"public_key":"subscriber_pub_key"}}' --from myWallet -y +{"height":"0","txhash":"C6E5113A94FDFA05FD5FB3214E6FA1E604AD927D1848C9CB191407BA11233E41","codespace":"","code":0,"data":"","raw_log":"[]","logs":[],"info":"","gas_wanted":"0","gas_used":"0","tx":null,"timestamp":"","events":[]} +$ secretcli q compute tx C6E5113A94FDFA05FD5FB3214E6FA1E604AD927D1848C9CB191407BA11233E41 +{ + "height": "0", + "txhash": "C6E5113A94FDFA05FD5FB3214E6FA1E604AD927D1848C9CB191407BA11233E41", + "code": 0, + "logs": [ + { + "type": "wasm", + "attributes": [ + { + "key": "contract_address", + "value": "secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf" + }, + { + "key": "action", + "value": "remove_subscriber" + }, + { + "key": "subscriber", + "value": "subscriber_pub_key" + } + ] + } + ] +} +$ secretcli query compute query secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{\"subscriber_status\":{\"public_key\":\"subscriber_pub_key\"}}' +{"active":false} +``` + +--- + +### Use Case 4: Set a New Admin + +**Description**: Update the admin to a new public key. Only the current admin can perform this action. + +#### Command +```bash +secretcli tx compute execute '{"set_admin":{"public_key":"new_admin_pub_key"}}' --from myWallet -y +``` + +#### Example +```bash +$ secretcli keys list +[{"name":"myNewWallet","type":"local","address":"secret1qvapn5ns28xrevn7kdudwvrp6a4fven2kzq8jc","pubkey":"{\"@type\":\"/cosmos.crypto.secp256k1.PubKey\",\"key\":\"AsKefYYrOHWvOfziuk/ITmcEpS6ZwWOTb6zlmqOu3FrW\"}"},{"name":"myWallet","type":"local","address":"secret1msmqzrp8ahvwe2jzk9n0xula0vnmv7vt3883y8","pubkey":"{\"@type\":\"/cosmos.crypto.secp256k1.PubKey\",\"key\":\"A5YOx9fAvahYlFqzyWAF38w3EuDTUtpZCyKW6Zk88NzO\"}"}] +$ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{\"register_subscriber\":{\"public_key\":\"subscriber_pub_key\"}}' --from myNewWallet -y +{"height":"0","txhash":"2DB0821F55F1DCC51E29ACBAE529D9B3B14F2DA41D09BD0D05CB7C0436E1E9DC","codespace":"","code":0,"data":"","raw_log":"[]","logs":[],"info":"","gas_wanted":"0","gas_used":"0","tx":null,"timestamp":"","events":[]} +$ secretcli q compute tx 2DB0821F55F1DCC51E29ACBAE529D9B3B14F2DA41D09BD0D05CB7C0436E1E9DC +{ + "answers": [ + { + "type": "execute", + "input": "afd0b5bda5a14dd41dc98d4cf112c1a239b5689796ac0fec4845db69d0a11f28{\"register_subscriber\":{\"public_key\":\"subscriber_pub_key\"}}" + } + ], + "output_logs": [], + "output_error": "message index 0: Generic error: Only admin can register subscribers" +} +$ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"set_admin":{"public_key":"secret1qvapn5ns28xrevn7kdudwvrp6a4fven2kzq8jc"}}' --from myWallet -y +{ + "height": "0", + "txhash": "D5D86A32A654D3BBE7A4491F74BB96F68FC4481BECD00B5D10DFF271D76C75B2", + "code": 0, + "logs": [] +} +$ secretcli q compute tx D5D86A32A654D3BBE7A4491F74BB96F68FC4481BECD00B5D10DFF271D76C75B2 +{ + "answers": [ + { + "type": "execute", + "input": "afd0b5bda5a14dd41dc98d4cf112c1a239b5689796ac0fec4845db69d0a11f28{\"set_admin\":{\"public_key\":\"secret1qvapn5ns28xrevn7kdudwvrp6a4fven2kzq8jc\"}}" + } + ], + "output_logs": [ + { + "type": "wasm", + { + "key": "contract_address", + "value": "secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf" + }, + { + "key": "action", + "value": "set_admin" + }, + { + "key": "new_admin", + "value": "secret1qvapn5ns28xrevn7kdudwvrp6a4fven2kzq8jc" + } + ] + } + ] +} +$ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{\"register_subscriber\":{\"public_key\":\"subscriber_pub_key\"}}' --from myNewWallet -y +{"height":"0","txhash":"C05875F06001649DF9D00D600AA3E07C7A0A6A3674E4F6336C9E4FEEF521E155","codespace":"","code":0,"data":"","raw_log":"[]","logs":[],"info":"","gas_wanted":"0","gas_used":"0","tx":null,"timestamp":"","events":[]} +$ secretcli q compute tx C05875F06001649DF9D00D600AA3E07C7A0A6A3674E4F6336C9E4FEEF521E155 +{ + "answers": [ + { + "type": "execute", + "input": "afd0b5bda5a14dd41dc98d4cf112c1a239b5689796ac0fec4845db69d0a11f28{\"register_subscriber\":{\"public_key\":\"subscriber_pub_key\"}}" + } + ], + "output_logs": [ + { + "type": "wasm", + "attributes": [ + { + "key": "contract_address", + "value": "secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf" + }, + { + "key": "action", + "value": "register_subscriber" + }, + { + "key": "subscriber", + "value": "subscriber_pub_key" + } + ] + } + ] +} + +``` diff --git a/subscription-manager/node/.gitignore b/subscription-manager/node/.gitignore new file mode 100644 index 0000000..1c0d0e5 --- /dev/null +++ b/subscription-manager/node/.gitignore @@ -0,0 +1,21 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/node_modules* +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc + +.env + +npm-debug.log* +yarn-debug.log* +yarn-error.log* \ No newline at end of file diff --git a/subscription-manager/node/package-lock.json b/subscription-manager/node/package-lock.json new file mode 100644 index 0000000..bd21b50 --- /dev/null +++ b/subscription-manager/node/package-lock.json @@ -0,0 +1,1814 @@ +{ + "name": "new", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "new", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@solar-republic/cosmos-grpc": "^0.14.2", + "@solar-republic/neutrino": "^1.0.8", + "dotenv": "^16.4.5", + "ethers": "^6.13.4", + "secretjs": "^1.15.0-beta.0", + "secure-random": "^1.1.2" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "license": "MIT" + }, + "node_modules/@blake.regalia/belt": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@blake.regalia/belt/-/belt-0.37.0.tgz", + "integrity": "sha512-kpAXIn9ioUBVRjwvtyyjZRzwQ2MRJbI/AKY+4maQZD+daG/9tbCN0ET8BP9WzfqD0jZ8i5TIjEByjUdWpzn1MQ==", + "license": "ISC" + }, + "node_modules/@cosmjs/amino": { + "version": "0.32.4", + "resolved": "https://registry.npmjs.org/@cosmjs/amino/-/amino-0.32.4.tgz", + "integrity": "sha512-zKYOt6hPy8obIFtLie/xtygCkH9ZROiQ12UHfKsOkWaZfPQUvVbtgmu6R4Kn1tFLI/SRkw7eqhaogmW/3NYu/Q==", + "license": "Apache-2.0", + "dependencies": { + "@cosmjs/crypto": "^0.32.4", + "@cosmjs/encoding": "^0.32.4", + "@cosmjs/math": "^0.32.4", + "@cosmjs/utils": "^0.32.4" + } + }, + "node_modules/@cosmjs/amino/node_modules/@cosmjs/encoding": { + "version": "0.32.4", + "resolved": "https://registry.npmjs.org/@cosmjs/encoding/-/encoding-0.32.4.tgz", + "integrity": "sha512-tjvaEy6ZGxJchiizzTn7HVRiyTg1i4CObRRaTRPknm5EalE13SV+TCHq38gIDfyUeden4fCuaBVEdBR5+ti7Hw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "bech32": "^1.1.4", + "readonly-date": "^1.0.0" + } + }, + "node_modules/@cosmjs/amino/node_modules/@cosmjs/math": { + "version": "0.32.4", + "resolved": "https://registry.npmjs.org/@cosmjs/math/-/math-0.32.4.tgz", + "integrity": "sha512-++dqq2TJkoB8zsPVYCvrt88oJWsy1vMOuSOKcdlnXuOA/ASheTJuYy4+oZlTQ3Fr8eALDLGGPhJI02W2HyAQaw==", + "license": "Apache-2.0", + "dependencies": { + "bn.js": "^5.2.0" + } + }, + "node_modules/@cosmjs/crypto": { + "version": "0.32.4", + "resolved": "https://registry.npmjs.org/@cosmjs/crypto/-/crypto-0.32.4.tgz", + "integrity": "sha512-zicjGU051LF1V9v7bp8p7ovq+VyC91xlaHdsFOTo2oVry3KQikp8L/81RkXmUIT8FxMwdx1T7DmFwVQikcSDIw==", + "license": "Apache-2.0", + "dependencies": { + "@cosmjs/encoding": "^0.32.4", + "@cosmjs/math": "^0.32.4", + "@cosmjs/utils": "^0.32.4", + "@noble/hashes": "^1", + "bn.js": "^5.2.0", + "elliptic": "^6.5.4", + "libsodium-wrappers-sumo": "^0.7.11" + } + }, + "node_modules/@cosmjs/crypto/node_modules/@cosmjs/encoding": { + "version": "0.32.4", + "resolved": "https://registry.npmjs.org/@cosmjs/encoding/-/encoding-0.32.4.tgz", + "integrity": "sha512-tjvaEy6ZGxJchiizzTn7HVRiyTg1i4CObRRaTRPknm5EalE13SV+TCHq38gIDfyUeden4fCuaBVEdBR5+ti7Hw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "bech32": "^1.1.4", + "readonly-date": "^1.0.0" + } + }, + "node_modules/@cosmjs/crypto/node_modules/@cosmjs/math": { + "version": "0.32.4", + "resolved": "https://registry.npmjs.org/@cosmjs/math/-/math-0.32.4.tgz", + "integrity": "sha512-++dqq2TJkoB8zsPVYCvrt88oJWsy1vMOuSOKcdlnXuOA/ASheTJuYy4+oZlTQ3Fr8eALDLGGPhJI02W2HyAQaw==", + "license": "Apache-2.0", + "dependencies": { + "bn.js": "^5.2.0" + } + }, + "node_modules/@cosmjs/encoding": { + "version": "0.32.3", + "resolved": "https://registry.npmjs.org/@cosmjs/encoding/-/encoding-0.32.3.tgz", + "integrity": "sha512-p4KF7hhv8jBQX3MkB3Defuhz/W0l3PwWVYU2vkVuBJ13bJcXyhU9nJjiMkaIv+XP+W2QgRceqNNgFUC5chNR7w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "bech32": "^1.1.4", + "readonly-date": "^1.0.0" + } + }, + "node_modules/@cosmjs/math": { + "version": "0.32.3", + "resolved": "https://registry.npmjs.org/@cosmjs/math/-/math-0.32.3.tgz", + "integrity": "sha512-amumUtZs8hCCnV+lSBaJIiZkGabQm22QGg/IotYrhcmoOEOjt82n7hMNlNXRs7V6WLMidGrGYcswB5zcmp0Meg==", + "license": "Apache-2.0", + "dependencies": { + "bn.js": "^5.2.0" + } + }, + "node_modules/@cosmjs/proto-signing": { + "version": "0.32.4", + "resolved": "https://registry.npmjs.org/@cosmjs/proto-signing/-/proto-signing-0.32.4.tgz", + "integrity": "sha512-QdyQDbezvdRI4xxSlyM1rSVBO2st5sqtbEIl3IX03uJ7YiZIQHyv6vaHVf1V4mapusCqguiHJzm4N4gsFdLBbQ==", + "license": "Apache-2.0", + "dependencies": { + "@cosmjs/amino": "^0.32.4", + "@cosmjs/crypto": "^0.32.4", + "@cosmjs/encoding": "^0.32.4", + "@cosmjs/math": "^0.32.4", + "@cosmjs/utils": "^0.32.4", + "cosmjs-types": "^0.9.0" + } + }, + "node_modules/@cosmjs/proto-signing/node_modules/@cosmjs/encoding": { + "version": "0.32.4", + "resolved": "https://registry.npmjs.org/@cosmjs/encoding/-/encoding-0.32.4.tgz", + "integrity": "sha512-tjvaEy6ZGxJchiizzTn7HVRiyTg1i4CObRRaTRPknm5EalE13SV+TCHq38gIDfyUeden4fCuaBVEdBR5+ti7Hw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "bech32": "^1.1.4", + "readonly-date": "^1.0.0" + } + }, + "node_modules/@cosmjs/proto-signing/node_modules/@cosmjs/math": { + "version": "0.32.4", + "resolved": "https://registry.npmjs.org/@cosmjs/math/-/math-0.32.4.tgz", + "integrity": "sha512-++dqq2TJkoB8zsPVYCvrt88oJWsy1vMOuSOKcdlnXuOA/ASheTJuYy4+oZlTQ3Fr8eALDLGGPhJI02W2HyAQaw==", + "license": "Apache-2.0", + "dependencies": { + "bn.js": "^5.2.0" + } + }, + "node_modules/@cosmjs/utils": { + "version": "0.32.4", + "resolved": "https://registry.npmjs.org/@cosmjs/utils/-/utils-0.32.4.tgz", + "integrity": "sha512-D1Yc+Zy8oL/hkUkFUL/bwxvuDBzRGpc4cF7/SkdhxX4iHpSLgdOuTt1mhCh9+kl6NQREy9t7SYZ6xeW5gFe60w==", + "license": "Apache-2.0" + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.0.0.tgz", + "integrity": "sha512-DZVbtY62kc3kkBtMHqwCOfXrT/hnoORy5BJ4+HU1IR59X0KWAOqsfzQPcUl/lQLlG7qXbe/fZ3r/emxtAl+sqg==", + "license": "MIT" + }, + "node_modules/@noble/secp256k1": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.0.tgz", + "integrity": "sha512-kbacwGSsH/CTout0ZnZWxnW1B+jH/7r/WAAKLBtrRJ/+CUH7lgmQzl3GTrQua3SGKWNSDsS6lmjnDpIJ5Dxyaw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@solar-republic/cosmos-grpc": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/@solar-republic/cosmos-grpc/-/cosmos-grpc-0.14.2.tgz", + "integrity": "sha512-Q74TXtwUM2J5lvs3MnK1uH9+Wx4DFAyekEpOo0nvkPMh/0JRCdSFPZywWezNi//QVenVHXF2DK1V49UBHPoNyg==", + "license": "MIT", + "dependencies": { + "@solar-republic/crypto": "^0.2.5", + "google-protobuf": "^3.21.2", + "protobufjs": "^7.2.6" + }, + "bin": { + "protoc-gen-secret": "build/protoc/run.js" + } + }, + "node_modules/@solar-republic/crypto": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/@solar-republic/crypto/-/crypto-0.2.14.tgz", + "integrity": "sha512-lm9U5QuBamqYAsdrJsnVfE7Uef4TyCvgLsgypPI1/RNjFdRCeKpMXoCRmJxeu3m/FzOhmPvX3WDd+ePCGw149w==", + "license": "MIT", + "dependencies": { + "@blake.regalia/belt": "^0.37.0", + "@solar-republic/wasm-secp256k1": "^0.2.8", + "hash-wasm": "^4.11.0" + } + }, + "node_modules/@solar-republic/neutrino": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@solar-republic/neutrino/-/neutrino-1.6.0.tgz", + "integrity": "sha512-SmcFcxAfKI4OeeoDjJJdSvAICTAqkCF8nMAwCmBMwTXgEa0NhSoD72DNyI3c3jRWMqjlZHIScHrKABnu9kuOkA==", + "license": "MIT", + "dependencies": { + "@blake.regalia/belt": "^0.45.1", + "@solar-republic/cosmos-grpc": "^0.17.1", + "@solar-republic/crypto": "^0.2.14" + } + }, + "node_modules/@solar-republic/neutrino/node_modules/@blake.regalia/belt": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/@blake.regalia/belt/-/belt-0.45.1.tgz", + "integrity": "sha512-0vH/y2sJyQAJDUUPG3fYpZUQJBBhqWmRQaU3jibWpfd1RKiF9OjnHO7ttWr6Rf7wQeNZ59V8GYlUBeOkKKPG7Q==", + "license": "ISC" + }, + "node_modules/@solar-republic/neutrino/node_modules/@solar-republic/cosmos-grpc": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@solar-republic/cosmos-grpc/-/cosmos-grpc-0.17.1.tgz", + "integrity": "sha512-5w49cExdiL+/EczMuUG/yj/lLciQwheKWfhA7jshyKDKKEovcdpVjloH6VIWuSUv3lLBxGt7F20UbT4DcAzeqQ==", + "license": "MIT", + "dependencies": { + "@solar-republic/crypto": "^0.2.14", + "google-protobuf": "^3.21.2", + "protobufjs": "7.2.6" + }, + "bin": { + "protoc-gen-secret": "build/protoc/run.js" + } + }, + "node_modules/@solar-republic/neutrino/node_modules/protobufjs": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.6.tgz", + "integrity": "sha512-dgJaEDDL6x8ASUZ1YqWciTRrdOuYNzoOf27oHNfdyvKqHr5i0FV7FSLU+aIeFjyFgVxrpTOtQUi0BLLBymZaBw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@solar-republic/wasm-secp256k1": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@solar-republic/wasm-secp256k1/-/wasm-secp256k1-0.2.9.tgz", + "integrity": "sha512-fjDX7u9jNfpKcXvlVikHDrmw0rlgwq4aToqXM+XltVCbZcL+8RKj1530IaIcbU7WtNj8ve6zC2VcTzBw2J5Jag==", + "license": "MIT", + "dependencies": { + "@blake.regalia/belt": "^0.40.1" + } + }, + "node_modules/@solar-republic/wasm-secp256k1/node_modules/@blake.regalia/belt": { + "version": "0.40.1", + "resolved": "https://registry.npmjs.org/@blake.regalia/belt/-/belt-0.40.1.tgz", + "integrity": "sha512-1KgtASOjGtzcC8IjL2gm4Ofwk9HvGW+l5VsqbKgxGrceEiVcccPf65eX20hek3p7prpNwbw9vImlXEU0Bii7Aw==", + "license": "ISC" + }, + "node_modules/@types/node": { + "version": "22.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", + "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.8" + } + }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "license": "BSD-2-Clause" + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base-x": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.10.tgz", + "integrity": "sha512-7d0s06rR9rYaIWHkpfLIFICM/tkSVdoPC9qYAQRpxn9DdKNWNsKC0uk++akckyLq16Tx2WIinnZ6WRriAt6njQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bech32": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", + "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==", + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.51", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", + "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bignumber.js": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.2.tgz", + "integrity": "sha512-GAcQvbpsM0pUb0zw1EI0KhQEZ+lRwR5fYaAp3vPOYuP7aDvGy6cVN6XHLauvF8SOga2y0dcLcjt3iQDTSEliyw==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bip32": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/bip32/-/bip32-2.0.6.tgz", + "integrity": "sha512-HpV5OMLLGTjSVblmrtYRfFFKuQB+GArM0+XP8HGWfJ5vxYBqo+DesvJwOdC2WJ3bCkZShGf0QIfoIpeomVzVdA==", + "license": "MIT", + "dependencies": { + "@types/node": "10.12.18", + "bs58check": "^2.1.1", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "tiny-secp256k1": "^1.1.3", + "typeforce": "^1.11.5", + "wif": "^2.0.6" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bip32/node_modules/@types/node": { + "version": "10.12.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.18.tgz", + "integrity": "sha512-fh+pAqt4xRzPfqA6eh3Z2y6fyZavRIumvjhaCL753+TVkGKGhpPeyrJG2JftD0T9q4GF00KjefsQ+PQNDdWQaQ==", + "license": "MIT" + }, + "node_modules/bip39": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.0.4.tgz", + "integrity": "sha512-YZKQlb752TrUWqHWj7XAwCSjYEgGAk+/Aas3V7NyjQeZYsztO8JnQUaCWhcnL4T+jL8nvB8typ2jRPzTlgugNw==", + "license": "ISC", + "dependencies": { + "@types/node": "11.11.6", + "create-hash": "^1.1.0", + "pbkdf2": "^3.0.9", + "randombytes": "^2.0.1" + } + }, + "node_modules/bip39/node_modules/@types/node": { + "version": "11.11.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-11.11.6.tgz", + "integrity": "sha512-Exw4yUWMBXM3X+8oqzJNRqZSwUAaS4+7NdvHqQuFi/d+synz++xmX3QIf+BFqneW8N31R8Ky+sikfZUXq07ggQ==", + "license": "MIT" + }, + "node_modules/bn.js": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", + "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, + "node_modules/bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", + "license": "MIT", + "dependencies": { + "base-x": "^3.0.2" + } + }, + "node_modules/bs58check": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-2.1.2.tgz", + "integrity": "sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==", + "license": "MIT", + "dependencies": { + "bs58": "^4.0.0", + "create-hash": "^1.1.0", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cipher-base": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", + "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/cosmjs-types": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/cosmjs-types/-/cosmjs-types-0.9.0.tgz", + "integrity": "sha512-MN/yUe6mkJwHnCFfsNPeCfXVhyxHYW6c/xDUzrSbBycYzw++XvWDMJArXp2pLdgD6FQ8DW79vkPjeNKVrXaHeQ==", + "license": "Apache-2.0" + }, + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "node_modules/create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "node_modules/cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "license": "MIT", + "dependencies": { + "node-fetch": "2.6.7" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz", + "integrity": "sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/curve25519-js": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz", + "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==", + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/elliptic": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.0.tgz", + "integrity": "sha512-dpwoQcLc/2WLQvJvLRHKZ+f9FgOdjnq11rurqwekGQygGPsYSK29OMMD2WalatiqQ+XGFDglTNixpPfI+lpaAA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/elliptic/node_modules/bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ethers": { + "version": "6.13.4", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.13.4.tgz", + "integrity": "sha512-21YtnZVg4/zKkCQPjrDj38B1r4nQvTZLopUGMLQ1ePU2zV/joCfDC3t3iKQjWRzjjjbzR+mdAIoikeBRNkdllA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-protobuf": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.21.4.tgz", + "integrity": "sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==", + "license": "(BSD-3-Clause AND Apache-2.0)" + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash-base": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz", + "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^3.6.0", + "safe-buffer": "^5.2.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/hash-wasm": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.11.0.tgz", + "integrity": "sha512-HVusNXlVqHe0fzIzdQOGolnFN6mX/fqcrSAOcTBXdvzrXVHwTz11vXeKRmkR5gTuwVpvHZEIyKoePDvuAR+XwQ==", + "license": "MIT" + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/json-stable-stringify": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.1.1.tgz", + "integrity": "sha512-SU/971Kt5qVQfJpyDveVhQ/vya+5hvrjClFOcr8c0Fq5aODJjMwutrOfCU+eCnVD5gpx1Q3fEqkyom77zH1iIg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.5", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, + "node_modules/libsodium-sumo": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.7.15.tgz", + "integrity": "sha512-5tPmqPmq8T8Nikpm1Nqj0hBHvsLFCXvdhBFV7SGOitQPZAA6jso8XoL0r4L7vmfKXr486fiQInvErHtEvizFMw==", + "license": "ISC" + }, + "node_modules/libsodium-wrappers-sumo": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.15.tgz", + "integrity": "sha512-aSWY8wKDZh5TC7rMvEdTHoyppVq/1dTSAeAR7H6pzd6QRT3vQWcT5pGwCotLcpPEOLXX6VvqihSPkpEhYAjANA==", + "license": "ISC", + "dependencies": { + "libsodium-sumo": "^0.7.15" + } + }, + "node_modules/long": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==", + "license": "Apache-2.0" + }, + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "license": "MIT", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/miscreant": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/miscreant/-/miscreant-0.3.2.tgz", + "integrity": "sha512-fL9KxsQz9BJB2KGPMHFrReioywkiomBiuaLk6EuChijK0BsJsIKJXdVomR+/bPj5mvbFD6wM0CM3bZio9g7OHA==", + "license": "MIT" + }, + "node_modules/nan": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz", + "integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-v8tweI900AUkZN6heMU/4Uy4cXRc2AYNRggVmTR+dEncawDJgCdLMximOVA2p4qO57WMynangsfGRb5WD6L1Bg==", + "license": "(MIT AND Zlib)" + }, + "node_modules/patch-package": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.0.tgz", + "integrity": "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA==", + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^9.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "rimraf": "^2.6.3", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.0.33", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pbkdf2": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz", + "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==", + "license": "MIT", + "dependencies": { + "create-hash": "^1.1.2", + "create-hmac": "^1.1.4", + "ripemd160": "^2.0.1", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/protobufjs": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", + "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readonly-date": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/readonly-date/-/readonly-date-1.0.0.tgz", + "integrity": "sha512-tMKIV7hlk0h4mO3JTmmVuIlJVXjKk3Sep9Bf5OH0O+758ruuVkUy2J9SttDLm91IEX/WHlXPSpxMGjPj4beMIQ==", + "license": "Apache-2.0" + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/ripemd160": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", + "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "license": "MIT", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/secretjs": { + "version": "1.15.0-beta.0", + "resolved": "https://registry.npmjs.org/secretjs/-/secretjs-1.15.0-beta.0.tgz", + "integrity": "sha512-zxFVWixArto6qd2+h9f4HEGTtlD2ZB0dggDsFdkHyXNA7728vVX0QWFfKcnxR04mqOtr7CnASxxTTSK42EFoKQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@cosmjs/amino": "^0.32.4", + "@cosmjs/encoding": "0.32.3", + "@cosmjs/math": "0.32.3", + "@cosmjs/proto-signing": "^0.32.3", + "@noble/hashes": "1.0.0", + "@noble/secp256k1": "1.7.0", + "big-integer": "1.6.51", + "bignumber.js": "9.0.2", + "bip32": "2.0.6", + "bip39": "3.0.4", + "cross-fetch": "3.1.5", + "curve25519-js": "0.0.4", + "google-protobuf": "^3.14.0", + "miscreant": "0.3.2", + "pako": "2.0.4", + "patch-package": "^8.0.0", + "protobufjs": "7.2.5", + "secure-random": "1.1.2" + } + }, + "node_modules/secretjs/node_modules/protobufjs": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.5.tgz", + "integrity": "sha512-gGXRSXvxQ7UiPgfw8gevrfRWcTlSbOFg+p/N+JVJEK5VhueL2miT6qTymqAmjr1Q5WbOCyJbyrk6JfWKwlFn6A==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/secure-random": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/secure-random/-/secure-random-1.1.2.tgz", + "integrity": "sha512-H2bdSKERKdBV1SwoqYm6C0y+9EA94v6SUBOWO8kDndc4NoUih7Dv6Tsgma7zO1lv27wIvjlD0ZpMQk7um5dheQ==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sha.js": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + }, + "bin": { + "sha.js": "bin.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tiny-secp256k1": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/tiny-secp256k1/-/tiny-secp256k1-1.1.7.tgz", + "integrity": "sha512-eb+F6NabSnjbLwNoC+2o5ItbmP1kg7HliWue71JgLegQt6A5mTN8YbvTLCazdlg6e5SV6A+r8OGvZYskdlmhqQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.3.0", + "bn.js": "^4.11.8", + "create-hmac": "^1.1.7", + "elliptic": "^6.4.0", + "nan": "^2.13.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/tiny-secp256k1/node_modules/bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/typeforce": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz", + "integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wif": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/wif/-/wif-2.0.6.tgz", + "integrity": "sha512-HIanZn1zmduSF+BQhkE+YXIbEiH0xPr1012QbFEGB0xsKqJii0/SqJjyn8dFv6y36kOznMgMB+LGcbZTJ1xACQ==", + "license": "MIT", + "dependencies": { + "bs58check": "<3.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + } + } +} diff --git a/subscription-manager/node/package.json b/subscription-manager/node/package.json new file mode 100644 index 0000000..20f6f68 --- /dev/null +++ b/subscription-manager/node/package.json @@ -0,0 +1,20 @@ +{ + "name": "new", + "type": "module", + "version": "1.0.0", + "description": "for tests", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "@solar-republic/cosmos-grpc": "^0.14.2", + "@solar-republic/neutrino": "^1.0.8", + "dotenv": "^16.4.5", + "ethers": "^6.13.4", + "secretjs": "^1.15.0-beta.0", + "secure-random": "^1.1.2" + } +} diff --git a/subscription-manager/node/upload.js b/subscription-manager/node/upload.js new file mode 100644 index 0000000..c7eb598 --- /dev/null +++ b/subscription-manager/node/upload.js @@ -0,0 +1,82 @@ +import { SecretNetworkClient, Wallet, coinsFromString } from "secretjs"; +import * as fs from "fs"; +import dotenv from "dotenv"; +dotenv.config(); + +const wallet = new Wallet(process.env.MNEMONIC); + +const contract_wasm = fs.readFileSync("../contract.wasm.gz"); + +const secretjs = new SecretNetworkClient({ + chainId: "pulsar-3", + url: "https://api.pulsar3.scrttestnet.com", + wallet: wallet, + walletAddress: wallet.address, +}); + +// Declare global variables +let codeId; +let contractCodeHash; +let contractAddress; + +let upload_contract = async () => { + console.log("Starting deployment…"); + + let tx = await secretjs.tx.compute.storeCode( + { + sender: wallet.address, + wasm_byte_code: contract_wasm, + source: "", + builder: "", + }, + { + gasLimit: 4_000_000, + } + ); + + codeId = Number( + tx.arrayLog.find((log) => log.type === "message" && log.key === "code_id") + .value + ); + console.log("codeId: ", codeId); + + contractCodeHash = ( + await secretjs.query.compute.codeHashByCodeId({ code_id: codeId }) + ).code_hash; + console.log(`CODE_HASH: ${contractCodeHash}`); +}; + +let instantiate_contract = async () => { + if (!codeId || !contractCodeHash) { + throw new Error("codeId or contractCodeHash is not set."); + } + console.log("Instantiating contract…"); + + let tx = await secretjs.tx.compute.instantiateContract( + { + code_id: codeId, + sender: wallet.address, + code_hash: contractCodeHash, + label: "ClaiveSubscription " + Math.ceil(Math.random() * 10000), + }, + { + gasLimit: 400_000, + } + ); + + //Find the contract_address in the logs + const contractAddress = tx.arrayLog.find( + (log) => log.type === "message" && log.key === "contract_address" + ).value; + + console.log("SECRET_ADDRESS: ", contractAddress); +}; + +// Chain the execution using promises +upload_contract() + .then(() => { + instantiate_contract(); + }) + .catch((error) => { + console.error("Error:", error); + }); diff --git a/subscription-manager/src/contract.rs b/subscription-manager/src/contract.rs index 9a680ed..082de1b 100644 --- a/subscription-manager/src/contract.rs +++ b/subscription-manager/src/contract.rs @@ -5,6 +5,7 @@ use sha2::{Digest, Sha256}; use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg, SubscriberStatusResponse}; use crate::state::{config, config_read, State, Subscriber, SB_MAP}; +// Entry point for contract initialization #[entry_point] pub fn instantiate( deps: DepsMut, @@ -12,17 +13,22 @@ pub fn instantiate( info: MessageInfo, _msg: InstantiateMsg, ) -> StdResult { + // Set the admin to the sender who initializes the contract let state = State { admin: info.sender.clone(), }; + // Log a debug message deps.api .debug(format!("Contract was initialized by {}", info.sender).as_str()); + + // Save the initial state config(deps.storage).save(&state)?; Ok(Response::default()) } +// Entry point for executing messages #[entry_point] pub fn execute( deps: DepsMut, @@ -31,152 +37,120 @@ pub fn execute( msg: ExecuteMsg, ) -> StdResult { match msg { - ExecuteMsg::RegisterSubscriber { address } => try_register_subscriber(deps, info, address), - ExecuteMsg::RemoveSubscriber { address } => try_remove_subscriber(deps, info, address), - ExecuteMsg::SetAdmin { address } => try_set_admin(deps, info, address), + // Handle registration of a subscriber + ExecuteMsg::RegisterSubscriber { public_key } => try_register_subscriber(deps, info, public_key), + // Handle removal of a subscriber + ExecuteMsg::RemoveSubscriber { public_key } => try_remove_subscriber(deps, info, public_key), + // Handle setting a new admin + ExecuteMsg::SetAdmin { public_key } => try_set_admin(deps, info, public_key), } } +// Function to register a new subscriber pub fn try_register_subscriber( _deps: DepsMut, _info: MessageInfo, - _address: String, + _public_key: String, ) -> StdResult { - + // Check if the sender is the admin let config = config_read(_deps.storage); let state = config.load()?; if _info.sender != state.admin { return Err(StdError::generic_err("Only admin can register subscribers")); } - let map_contains_sb = SB_MAP.contains(_deps.storage, &_address); - + // Check if the subscriber is already registered + let map_contains_sb = SB_MAP.contains(_deps.storage, &_public_key); if map_contains_sb { return Err(StdError::generic_err("Subscriber already registered")); } - let subscriber = Subscriber { address: _address.clone(), status: true }; - // Insert new value - - SB_MAP.insert(_deps.storage, &_address, &subscriber) + // Create a new subscriber and insert it into the map + let subscriber = Subscriber { public_key: _public_key.clone(), status: true }; + SB_MAP.insert(_deps.storage, &_public_key, &subscriber) .map_err(|err| StdError::generic_err(err.to_string()))?; + // Return a response indicating successful registration Ok(Response::new() .add_attribute("action", "register_subscriber") - .add_attribute("subscriber", _address)) + .add_attribute("subscriber", _public_key)) } +// Function to remove a subscriber pub fn try_remove_subscriber( _deps: DepsMut, _info: MessageInfo, - _address: String, + _public_key: String, ) -> StdResult { + // Check if the sender is the admin let config = config_read(_deps.storage); let state = config.load()?; if _info.sender != state.admin { return Err(StdError::generic_err("Only admin can remove subscribers")); } - let map_contains_sb = SB_MAP.contains(_deps.storage, &_address); - + // Check if the subscriber is registered + let map_contains_sb = SB_MAP.contains(_deps.storage, &_public_key); if !map_contains_sb { return Err(StdError::generic_err("Subscriber not registered")); } - SB_MAP.remove(_deps.storage, &_address) + // Remove the subscriber from the map + SB_MAP.remove(_deps.storage, &_public_key) .map_err(|err| StdError::generic_err(err.to_string()))?; + // Return a response indicating successful removal Ok(Response::new() .add_attribute("action", "remove_subscriber") - .add_attribute("subscriber", _address)) + .add_attribute("subscriber", _public_key)) } -pub fn try_set_admin(_deps: DepsMut, _info: MessageInfo, _address: String) -> StdResult { +// Function to set a new admin +pub fn try_set_admin(_deps: DepsMut, _info: MessageInfo, _public_key: String) -> StdResult { let mut config = config(_deps.storage); let mut state = config.load()?; - // Only the current admin can set a new admin + // Check if the sender is the current admin if _info.sender != state.admin { return Err(StdError::generic_err("Only the current admin can set a new admin")); } - // let canonical_address = _deps.api.addr_canonicalize(&_address) - // .map_err(|err| { - // StdError::generic_err(format!("Invalid address: {}", err)) - // }); - - // if canonical_address.is_err() { - // return Err(StdError::generic_err("Invalid address")); - // } - - // let final_address = _deps.api.addr_humanize(&canonical_address.unwrap()); - - // if final_address.is_err() { - // return Err(StdError::generic_err("Invalid address")); - // } - - let final_address = _deps.api.addr_validate(&_address).map_err(|err| { + // Validate the new admin's public key + let final_address = _deps.api.addr_validate(&_public_key).map_err(|err| { StdError::generic_err(format!("Invalid address: {}", err)) })?; - // Update the admin to the new address + // Update the admin in the state state.admin = final_address; config.save(&state)?; - // Log the admin change + // Return a response indicating successful admin update Ok(Response::new() .add_attribute("action", "set_admin") - .add_attribute("new_admin", _address)) + .add_attribute("new_admin", _public_key)) } +// Entry point for handling queries #[entry_point] pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { match msg { - QueryMsg::SubscriberStatus { - address, - signature, - sender_public_key, - } => to_binary(&query_subscriber( - deps, - address, - signature, - sender_public_key, - )?), + // Handle query for subscriber status + QueryMsg::SubscriberStatus { public_key } => to_binary(&query_subscriber(deps, public_key)?), } } +// Function to check if a subscriber is active fn query_subscriber( _deps: Deps, - _address: String, - _signature: String, - _sender_public_key: String, + _public_key: String, ) -> StdResult { - let payload = format!("{}{}", _address, "_payload_message"); - - let public_key_hex = _sender_public_key.clone(); - let signature_hex = _signature.clone(); - let message = payload.as_bytes(); - - let public_key_bytes = hex::decode(public_key_hex) - .map_err(|_| StdError::generic_err("Invalid public key hex"))?; - - let signature_bytes = hex::decode(signature_hex) - .map_err(|_| StdError::generic_err("Invalid signature hex"))?; - - let message_hash = Sha256::digest(message); - - let verify = _deps.api.secp256k1_verify(message_hash.as_slice(), &signature_bytes, &public_key_bytes).map_err(|e| StdError::generic_err("Failed to verify signature: ".to_string() + &e.to_string()))?; - - if !verify { - return Err(StdError::generic_err("Signature verification failed")); - } - - let subscriber = SB_MAP.get(_deps.storage, &_address); - + // Check if the subscriber exists in the map + let subscriber = SB_MAP.get(_deps.storage, &_public_key); if !subscriber.is_none() { return Ok(SubscriberStatusResponse { active: true }); } + // Return false if the subscriber is not found Ok(SubscriberStatusResponse { active: false }) } @@ -188,6 +162,7 @@ mod tests { use secp256k1::{Message, PublicKey, Secp256k1, ecdsa::Signature, SecretKey}; #[test] + /// Test for successful initialization of the contract fn proper_initialization() { let mut deps = mock_dependencies(); let info = mock_info( @@ -205,6 +180,7 @@ mod tests { } #[test] + /// Test successful registration of a subscriber by admin fn register_subscriber_success() { let mut deps = mock_dependencies(); let info = mock_info("admin", &[]); @@ -212,18 +188,23 @@ mod tests { instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); let register_msg = ExecuteMsg::RegisterSubscriber { - address: "subscriber1".to_string(), + public_key: "subscriber1".to_string(), }; + // Execute the message to register the subscriber and check the response let res = execute(deps.as_mut(), mock_env(), info, register_msg).unwrap(); assert_eq!(0, res.messages.len()); - assert_eq!(res.attributes, vec![ - attr("action", "register_subscriber"), - attr("subscriber", "subscriber1") - ]); + assert_eq!( + res.attributes, + vec![ + attr("action", "register_subscriber"), + attr("subscriber", "subscriber1") + ] + ); } #[test] + /// Test registration attempt by a non-admin, expecting failure fn register_subscriber_unauthorized() { let mut deps = mock_dependencies(); let info = mock_info("admin", &[]); @@ -232,9 +213,10 @@ mod tests { let unauthorized_info = mock_info("not_admin", &[]); let register_msg = ExecuteMsg::RegisterSubscriber { - address: "subscriber1".to_string(), + public_key: "subscriber1".to_string(), }; + // Attempt to register with a non-admin account and expect an error let res = execute(deps.as_mut(), mock_env(), unauthorized_info, register_msg); assert!(res.is_err()); assert_eq!( @@ -244,6 +226,7 @@ mod tests { } #[test] + /// Test successful removal of a subscriber by admin fn remove_subscriber_success() { let mut deps = mock_dependencies(); let info = mock_info("admin", &[]); @@ -252,23 +235,29 @@ mod tests { // Register a subscriber first let register_msg = ExecuteMsg::RegisterSubscriber { - address: "subscriber1".to_string(), + public_key: "subscriber1".to_string(), }; execute(deps.as_mut(), mock_env(), info.clone(), register_msg).unwrap(); // Now remove the subscriber let remove_msg = ExecuteMsg::RemoveSubscriber { - address: "subscriber1".to_string(), + public_key: "subscriber1".to_string(), }; + + // Execute the message to remove the subscriber and check the response let res = execute(deps.as_mut(), mock_env(), info, remove_msg).unwrap(); assert_eq!(0, res.messages.len()); - assert_eq!(res.attributes, vec![ - attr("action", "remove_subscriber"), - attr("subscriber", "subscriber1") - ]); + assert_eq!( + res.attributes, + vec![ + attr("action", "remove_subscriber"), + attr("subscriber", "subscriber1") + ] + ); } #[test] + /// Test removal attempt of a non-registered subscriber, expecting failure fn remove_subscriber_not_registered() { let mut deps = mock_dependencies(); let info = mock_info("admin", &[]); @@ -276,8 +265,10 @@ mod tests { instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); let remove_msg = ExecuteMsg::RemoveSubscriber { - address: "subscriber1".to_string(), + public_key: "subscriber1".to_string(), }; + + // Attempt to remove a non-registered subscriber and expect an error let res = execute(deps.as_mut(), mock_env(), info, remove_msg); assert!(res.is_err()); assert_eq!( @@ -287,6 +278,7 @@ mod tests { } #[test] + /// Test successful update of the admin by the current admin fn set_admin_success() { let mut deps = mock_dependencies(); let info = mock_info("admin", &[]); @@ -294,22 +286,27 @@ mod tests { instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); let set_admin_msg = ExecuteMsg::SetAdmin { - address: "new_admin".to_string(), + public_key: "new_admin".to_string(), }; + // Execute the message to set a new admin and check the response let res = execute(deps.as_mut(), mock_env(), info, set_admin_msg).unwrap(); assert_eq!(0, res.messages.len()); - assert_eq!(res.attributes, vec![ - attr("action", "set_admin"), - attr("new_admin", "new_admin") - ]); + assert_eq!( + res.attributes, + vec![ + attr("action", "set_admin"), + attr("new_admin", "new_admin") + ] + ); - // Check that the admin was updated + // Check that the admin was updated successfully let config = config_read(&deps.storage).load().unwrap(); assert_eq!(config.admin, Addr::unchecked("new_admin")); } #[test] + /// Test admin update attempt by a non-admin, expecting failure fn set_admin_unauthorized() { let mut deps = mock_dependencies(); let info = mock_info("admin", &[]); @@ -318,9 +315,10 @@ mod tests { let unauthorized_info = mock_info("not_admin", &[]); let set_admin_msg = ExecuteMsg::SetAdmin { - address: "new_admin".to_string(), + public_key: "new_admin".to_string(), }; + // Attempt to set a new admin with a non-admin account and expect an error let res = execute(deps.as_mut(), mock_env(), unauthorized_info, set_admin_msg); assert!(res.is_err()); assert_eq!( @@ -330,7 +328,8 @@ mod tests { } #[test] - fn query_subscriber_with_valid_signature() { + /// Test querying for a registered subscriber, expecting active status + fn query_registered_subscriber() { let mut deps = mock_dependencies(); let info = mock_info("admin", &[]); let init_msg = InstantiateMsg {}; @@ -338,35 +337,14 @@ mod tests { // Register a subscriber let register_msg = ExecuteMsg::RegisterSubscriber { - address: "subscriber1".to_string(), + public_key: "subscriber_public_key".to_string(), }; execute(deps.as_mut(), mock_env(), info, register_msg).unwrap(); - // Generate a valid signature using secp256k1 - let secp = Secp256k1::new(); - let secret_key = SecretKey::from_slice(&[0x01; 32]).unwrap(); - let public_key = secp256k1::PublicKey::from_secret_key(&secp, &secret_key); - - let message = format!("{}{}", "subscriber1", "_payload_message"); - let message_hash = Sha256::digest(message.as_bytes()); - let message = Message::from_slice(&message_hash).unwrap(); - - let signature = secp.sign_ecdsa(&message, &secret_key); - - // Use compact format for the signature (64 bytes) - let signature_bytes = signature.serialize_compact(); - // Use uncompressed format for the public key (65 bytes) - let public_key_bytes = public_key.serialize_uncompressed(); - - let signature_hex = hex::encode(signature_bytes); - let public_key_hex = hex::encode(public_key_bytes); - + // Query for the registered subscriber and check the response let query_msg = QueryMsg::SubscriberStatus { - address: "subscriber1".to_string(), - signature: signature_hex, - sender_public_key: public_key_hex, + public_key: "subscriber_public_key".to_string(), }; - let bin = query(deps.as_ref(), mock_env(), query_msg).unwrap(); let response: SubscriberStatusResponse = from_binary(&bin).unwrap(); @@ -375,55 +353,27 @@ mod tests { } #[test] - fn query_subscriber_with_wrong_public_key() { + /// Test querying for an unregistered subscriber, expecting inactive status + fn query_unregistered_subscriber() { let mut deps = mock_dependencies(); let info = mock_info("admin", &[]); let init_msg = InstantiateMsg {}; - instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); - - // Register a subscriber - let register_msg = ExecuteMsg::RegisterSubscriber { - address: "subscriber1".to_string(), - }; - execute(deps.as_mut(), mock_env(), info, register_msg).unwrap(); - - // Generate signature with the correct key - let secp = Secp256k1::new(); - let secret_key = SecretKey::from_slice(&[0x01; 32]).unwrap(); - let public_key = secp256k1::PublicKey::from_secret_key(&secp, &secret_key); - - let message = format!("{}{}", "subscriber1", "_payload_message"); - let message_hash = Sha256::digest(message.as_bytes()); - let message = Message::from_slice(&message_hash).unwrap(); - - let signature = secp.sign_ecdsa(&message, &secret_key); - - // Generate a wrong public key - let wrong_secret_key = SecretKey::from_slice(&[0x02; 32]).unwrap(); - let wrong_public_key = secp256k1::PublicKey::from_secret_key(&secp, &wrong_secret_key); - - // Convert signature and wrong public key to hex - let signature_hex = hex::encode(signature.serialize_compact()); - let wrong_public_key_hex = hex::encode(wrong_public_key.serialize_uncompressed()); + instantiate(deps.as_mut(), mock_env(), info, init_msg).unwrap(); + // Query for an unregistered subscriber and check the response let query_msg = QueryMsg::SubscriberStatus { - address: "subscriber1".to_string(), - signature: signature_hex, - sender_public_key: wrong_public_key_hex, + public_key: "unregistered_public_key".to_string(), }; + let bin = query(deps.as_ref(), mock_env(), query_msg).unwrap(); + let response: SubscriberStatusResponse = from_binary(&bin).unwrap(); - let result = query(deps.as_ref(), mock_env(), query_msg); - - // Expect the signature verification to fail - assert!(result.is_err()); - assert_eq!( - result.unwrap_err(), - StdError::generic_err("Signature verification failed") - ); + // Check that the subscriber is not active + assert!(!response.active); } #[test] - fn query_subscriber_with_wrong_signature() { + /// Test querying for a subscriber after removal, expecting inactive status + fn query_subscriber_after_removal() { let mut deps = mock_dependencies(); let info = mock_info("admin", &[]); let init_msg = InstantiateMsg {}; @@ -431,73 +381,25 @@ mod tests { // Register a subscriber let register_msg = ExecuteMsg::RegisterSubscriber { - address: "subscriber1".to_string(), + public_key: "subscriber_public_key".to_string(), }; - execute(deps.as_mut(), mock_env(), info, register_msg).unwrap(); - - // Generate a wrong signature using a different secret key - let secp = Secp256k1::new(); - let wrong_secret_key = SecretKey::from_slice(&[0x02; 32]).unwrap(); - let message = format!("{}{}", "subscriber1", "_payload_message"); - let message_hash = Sha256::digest(message.as_bytes()); - let message = Message::from_slice(&message_hash).unwrap(); - let wrong_signature = secp.sign_ecdsa(&message, &wrong_secret_key); - - // Use the correct public key - let correct_secret_key = SecretKey::from_slice(&[0x01; 32]).unwrap(); - let correct_public_key = secp256k1::PublicKey::from_secret_key(&secp, &correct_secret_key); - - // Convert the wrong signature and correct public key to hex - let wrong_signature_hex = hex::encode(wrong_signature.serialize_compact()); - let correct_public_key_hex = hex::encode(correct_public_key.serialize_uncompressed()); + execute(deps.as_mut(), mock_env(), info.clone(), register_msg).unwrap(); - let query_msg = QueryMsg::SubscriberStatus { - address: "subscriber1".to_string(), - signature: wrong_signature_hex, - sender_public_key: correct_public_key_hex, + // Remove the subscriber + let remove_msg = ExecuteMsg::RemoveSubscriber { + public_key: "subscriber_public_key".to_string(), }; + execute(deps.as_mut(), mock_env(), info, remove_msg).unwrap(); - let result = query(deps.as_ref(), mock_env(), query_msg); - - // Expect the signature verification to fail - assert!(result.is_err()); - assert_eq!( - result.unwrap_err(), - StdError::generic_err("Signature verification failed") - ); - } - - #[test] - fn query_unregistered_subscriber() { - let mut deps = mock_dependencies(); - let info = mock_info("admin", &[]); - let init_msg = InstantiateMsg {}; - instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); - - // Generate signature for an unregistered subscriber - let secp = Secp256k1::new(); - let secret_key = SecretKey::from_slice(&[0x01; 32]).unwrap(); - let public_key = secp256k1::PublicKey::from_secret_key(&secp, &secret_key); - - let message = format!("{}{}", "unregistered_subscriber", "_payload_message"); - let message_hash = Sha256::digest(message.as_bytes()); - let message = Message::from_slice(&message_hash).unwrap(); - let signature = secp.sign_ecdsa(&message, &secret_key); - - // Convert signature and public key to hex - let signature_hex = hex::encode(signature.serialize_compact()); - let public_key_hex = hex::encode(public_key.serialize_uncompressed()); - + // Query for the subscriber after removal and check the response let query_msg = QueryMsg::SubscriberStatus { - address: "unregistered_subscriber".to_string(), - signature: signature_hex, - sender_public_key: public_key_hex, + public_key: "subscriber_public_key".to_string(), }; - let bin = query(deps.as_ref(), mock_env(), query_msg).unwrap(); let response: SubscriberStatusResponse = from_binary(&bin).unwrap(); - // Expect the subscriber to be inactive + // Check that the subscriber is not active assert!(!response.active); } + } \ No newline at end of file diff --git a/subscription-manager/src/msg.rs b/subscription-manager/src/msg.rs index 5582e94..617bc6f 100644 --- a/subscription-manager/src/msg.rs +++ b/subscription-manager/src/msg.rs @@ -1,29 +1,37 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +// Struct for the message used to instantiate the contract #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] pub struct InstantiateMsg {} +// Enum representing the different executable messages that the contract can handle #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ExecuteMsg { - RegisterSubscriber { address: String }, - RemoveSubscriber { address: String }, - SetAdmin { address: String }, + // Message to register a new subscriber using a public key + RegisterSubscriber { public_key: String }, + + // Message to remove a subscriber using a public key + RemoveSubscriber { public_key: String }, + + // Message to set a new admin for the contract using a public key + SetAdmin { public_key: String }, } +// Enum representing the different query messages that the contract can respond to #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum QueryMsg { + // Query to check the status of a subscriber using a public key SubscriberStatus { - address: String, - signature: String, - sender_public_key: String, + public_key: String, }, } -// We define a custom struct for each query response +// Struct used to respond to a query about a subscriber's status #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] pub struct SubscriberStatusResponse { + // Indicates if the subscriber is active or not pub active: bool, } diff --git a/subscription-manager/src/state.rs b/subscription-manager/src/state.rs index a9f3125..48620c0 100644 --- a/subscription-manager/src/state.rs +++ b/subscription-manager/src/state.rs @@ -5,24 +5,34 @@ use secret_toolkit::storage::{Item, Keymap}; use cosmwasm_std::{Addr, Storage}; use cosmwasm_storage::{singleton, singleton_read, ReadonlySingleton, Singleton}; +// Key for accessing the configuration state in storage pub static CONFIG_KEY: &[u8] = b"config"; + +// Keymap for storing subscribers' information, using public keys as keys pub static SB_MAP: Keymap = Keymap::new(b"SB_MAP"); +// Structure representing the state of the contract #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] pub struct State { + // Address of the admin pub admin: Addr, } +// Structure representing a subscriber's information #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] pub struct Subscriber { + // Status of the subscriber (active or not) pub status: bool, - pub address: String, + // Public key of the subscriber + pub public_key: String, } +// Function to access and modify the configuration state pub fn config(storage: &mut dyn Storage) -> Singleton { singleton(storage, CONFIG_KEY) } +// Function to read the configuration state without modifying it pub fn config_read(storage: &dyn Storage) -> ReadonlySingleton { singleton_read(storage, CONFIG_KEY) -} \ No newline at end of file +} From 082f2fa852b2b90087343dce2a01fa5204326d52 Mon Sep 17 00:00:00 2001 From: DeDTihoN Date: Wed, 13 Nov 2024 15:44:26 +0200 Subject: [PATCH 05/17] added integration python sdk tests --- .../deactivate_subscriber.py | 44 +++++++ .../node_height(ping chain).py | 15 +++ .../query_subscriber_status.py | 26 ++++ .../register_subscriber.py | 44 +++++++ .../store_code_test.py | 112 ++++++++++++++++++ 5 files changed, 241 insertions(+) create mode 100644 subscription-manager/integration_python_tests/deactivate_subscriber.py create mode 100644 subscription-manager/integration_python_tests/node_height(ping chain).py create mode 100644 subscription-manager/integration_python_tests/query_subscriber_status.py create mode 100644 subscription-manager/integration_python_tests/register_subscriber.py create mode 100644 subscription-manager/integration_python_tests/store_code_test.py diff --git a/subscription-manager/integration_python_tests/deactivate_subscriber.py b/subscription-manager/integration_python_tests/deactivate_subscriber.py new file mode 100644 index 0000000..fc21bce --- /dev/null +++ b/subscription-manager/integration_python_tests/deactivate_subscriber.py @@ -0,0 +1,44 @@ +import os + +from dotenv import load_dotenv +from secret_sdk.client.lcd import LCDClient +from secret_sdk.client.localsecret import LocalSecret, main_net_chain_id, test_net_chain_id +from secret_sdk.core.coins import Coins +from secret_sdk.key.mnemonic import MnemonicKey +from secret_sdk.protobuf.cosmos.tx.v1beta1 import BroadcastMode + +load_dotenv() +chain_id = os.getenv('CHAIN_ID') +contract = os.getenv('CONTRACT_ADDRESS') +node_url = os.getenv('NODE_URL') +mnemonic = os.getenv('MNEMONIC') + +print("chain_id: " + chain_id) +print("node_url: " + node_url) +print("contract: " + contract) +print("mnemonic: " + mnemonic) + +mk = MnemonicKey(mnemonic=mnemonic) +secret = LCDClient(chain_id=chain_id, url=node_url) +wallet = secret.wallet(mk) + +wallet_public_key = str(wallet.key.acc_address) + +print("wallet_public_key: " + wallet_public_key) + +contract_address = contract +sent_funds = Coins('100uscrt') + +public_key = "subscriber" +handle_msg = {"remove_subscriber": {"public_key": public_key}} + +t = wallet.execute_tx( + contract_addr=contract_address, + handle_msg=handle_msg, + transfer_amount=sent_funds, +) + +print(t) + +assert t.code == 0, f"Transaction failed with code {t.code}: {t.rawlog}" +print("Transaction successful:", t.txhash) diff --git a/subscription-manager/integration_python_tests/node_height(ping chain).py b/subscription-manager/integration_python_tests/node_height(ping chain).py new file mode 100644 index 0000000..e08703f --- /dev/null +++ b/subscription-manager/integration_python_tests/node_height(ping chain).py @@ -0,0 +1,15 @@ +import os +from dotenv import load_dotenv +from secret_sdk.client.lcd import LCDClient + +# Загрузите переменные из .env файла +load_dotenv() + +# Получите значения из переменных окружения +chain_id = os.getenv('CHAIN_ID') +node_url = os.getenv('NODE_URL') + +# Используйте значения для создания LCDClient +secret = LCDClient(chain_id=chain_id, url=node_url) +height = secret.tendermint.block_info()['block']['header']['height'] +print(height) diff --git a/subscription-manager/integration_python_tests/query_subscriber_status.py b/subscription-manager/integration_python_tests/query_subscriber_status.py new file mode 100644 index 0000000..c655472 --- /dev/null +++ b/subscription-manager/integration_python_tests/query_subscriber_status.py @@ -0,0 +1,26 @@ +import os + +from dotenv import load_dotenv +from secret_sdk.client.lcd import LCDClient +from secret_sdk.client.localsecret import LocalSecret, main_net_chain_id, test_net_chain_id +from secret_sdk.core.coins import Coins +from secret_sdk.key.mnemonic import MnemonicKey + +load_dotenv() +chain_id = os.getenv('CHAIN_ID') +contract = os.getenv('CONTRACT_ADDRESS') +node_url = os.getenv('NODE_URL') + +print("chain_id: " + chain_id) +print("node_url: " + node_url) +print("contract: " + contract) + +secret = LCDClient(chain_id=chain_id, url=node_url) + +public_key = "subscriber" + +query = {"subscriber_status":{"public_key":public_key}} + +result = secret.wasm.contract_query(contract, query) + +print(result) diff --git a/subscription-manager/integration_python_tests/register_subscriber.py b/subscription-manager/integration_python_tests/register_subscriber.py new file mode 100644 index 0000000..38aa8ea --- /dev/null +++ b/subscription-manager/integration_python_tests/register_subscriber.py @@ -0,0 +1,44 @@ +import os + +from dotenv import load_dotenv +from secret_sdk.client.lcd import LCDClient +from secret_sdk.client.localsecret import LocalSecret, main_net_chain_id, test_net_chain_id +from secret_sdk.core.coins import Coins +from secret_sdk.key.mnemonic import MnemonicKey +from secret_sdk.protobuf.cosmos.tx.v1beta1 import BroadcastMode + +load_dotenv() +chain_id = os.getenv('CHAIN_ID') +contract = os.getenv('CONTRACT_ADDRESS') +node_url = os.getenv('NODE_URL') +mnemonic = os.getenv('MNEMONIC') + +print("chain_id: " + chain_id) +print("node_url: " + node_url) +print("contract: " + contract) +print("mnemonic: " + mnemonic) + +mk = MnemonicKey(mnemonic=mnemonic) +secret = LCDClient(chain_id=chain_id, url=node_url) +wallet = secret.wallet(mk) + +wallet_public_key = str(wallet.key.acc_address) + +print("wallet_public_key: " + wallet_public_key) + +contract_address = contract +sent_funds = Coins('100uscrt') + +public_key = "subscriber" +handle_msg = {"register_subscriber": {"public_key": public_key}} + +t = wallet.execute_tx( + contract_addr=contract_address, + handle_msg=handle_msg, + transfer_amount=sent_funds, +) + +print(t) + +assert t.code == 0, f"Transaction failed with code {t.code}: {t.rawlog}" +print("Transaction successful:", t.txhash) diff --git a/subscription-manager/integration_python_tests/store_code_test.py b/subscription-manager/integration_python_tests/store_code_test.py new file mode 100644 index 0000000..50bd5a3 --- /dev/null +++ b/subscription-manager/integration_python_tests/store_code_test.py @@ -0,0 +1,112 @@ +import datetime +import pytest +from secret_sdk.key.mnemonic import MnemonicKey +from secret_sdk.core import Coins, TxResultCode +from secret_sdk.client.lcd.api.gov import ProposalStatus +from secret_sdk.core.wasm.msgs import MsgStoreCode, MsgInstantiateContract, MsgExecuteContract +from secret_sdk.util.tx import get_value_from_raw_log +from secret_sdk.protobuf.cosmos.tx.v1beta1 import BroadcastMode + + +def test_store_code(): + wallet = pytest.accounts[0]['wallet'] + + with open(r'tests/data/snip20-ibc.wasm.gz', 'rb') as fl: + wasm_byte_code = fl.read() + + msg_store_code = MsgStoreCode( + sender=pytest.accounts[0]['address'], + wasm_byte_code=wasm_byte_code, + source='', + builder='' + ) + tx_store = wallet.create_and_broadcast_tx( + [msg_store_code], + gas='3000000', + gas_prices=Coins('0.25uscrt') + ) + if tx_store.code != TxResultCode.Success.value: + raise Exception(f"Failed MsgStoreCode: {tx_store.raw_log}") + assert tx_store.code == TxResultCode.Success.value + + code_id = int(get_value_from_raw_log(tx_store.rawlog, 'message.code_id')) + + code_info = pytest.secret.wasm.code_info(code_id) + code_hash = code_info['code_info']['code_hash'] + pytest.sscrt_code_info = code_info + pytest.sscrt_code_hash = code_hash + + msg_init = MsgInstantiateContract( + sender=pytest.accounts[0]['address'], + code_id=code_id, + code_hash=code_hash, + init_msg={ + "name": "Secret SCRT", + "admin": pytest.accounts[0]['address'], + "symbol": "SSCRT", + "decimals": 6, + "initial_balances": [{"address": pytest.accounts[0]['address'], "amount": "1"}], + "prng_seed": "eW8=", + "config": { + "public_total_supply": True, + "enable_deposit": True, + "enable_redeem": True, + "enable_mint": False, + "enable_burn": False, + }, + "supported_denoms": ["uscrt"], + }, + label=f"Label {datetime.datetime.now()}", + init_funds=[], + encryption_utils=pytest.secret.encrypt_utils + ) + tx_init = wallet.create_and_broadcast_tx( + [msg_init], + gas='5000000', + gas_prices=Coins('0.25uscrt') + ) + + if tx_init.code != TxResultCode.Success.value: + raise Exception(f"Failed MsgInstiateContract: {tx_init.raw_log}") + assert tx_init.code == TxResultCode.Success.value + assert get_value_from_raw_log(tx_init.rawlog, 'message.action') == "/secret.compute.v1beta1.MsgInstantiateContract" + + contract_adress = get_value_from_raw_log(tx_init.rawlog, 'message.contract_address') + assert contract_adress == tx_init.data[0].address + pytest.sscrt_contract_address = contract_adress + + msg_execute = MsgExecuteContract( + sender=pytest.accounts[0]['address'], + contract=contract_adress, + msg={ + 'create_viewing_key': { + 'entropy': 'bla bla' + } + }, + code_hash=code_hash, + encryption_utils=pytest.secret.encrypt_utils + ) + tx_execute = wallet.create_and_broadcast_tx( + [msg_execute], + gas='5000000', + gas_prices=Coins('0.25uscrt') + ) + if tx_execute.code != TxResultCode.Success.value: + raise Exception(f"Failed MsgExecuteContract: {tx_execute.raw_log}") + assert tx_execute.code == TxResultCode.Success.value + assert '{"create_viewing_key":{"key":"' in tx_execute.data[0].data.decode('utf-8') + + tx = wallet.create_and_broadcast_tx( + [msg_execute], + gas='5000000', + gas_prices=Coins('0.25uscrt'), + broadcast_mode=BroadcastMode.BROADCAST_MODE_ASYNC + ) + tx_hash = tx.txhash + tx_execute = pytest.secret.tx.get_tx(tx_hash) + while tx_execute is None: + tx_execute = pytest.secret.tx.get_tx(tx_hash) + if tx_execute.code != TxResultCode.Success.value: + raise Exception(f"Failed MsgExecuteContract: {tx_execute.raw_log}") + assert tx_execute.code == TxResultCode.Success.value + assert '{"create_viewing_key":{"key":"' in tx_execute.data[0].data.decode('utf-8') \ No newline at end of file From b96816f11c13d97a7e5b33bce75f2086e273ac87 Mon Sep 17 00:00:00 2001 From: DeDTihoN Date: Wed, 13 Nov 2024 16:09:55 +0200 Subject: [PATCH 06/17] added set_admin test and transaction infos to test --- .../deactivate_subscriber.py | 11 +++- .../register_subscriber.py | 8 +++ .../integration_python_tests/set_admin.py | 52 +++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 subscription-manager/integration_python_tests/set_admin.py diff --git a/subscription-manager/integration_python_tests/deactivate_subscriber.py b/subscription-manager/integration_python_tests/deactivate_subscriber.py index fc21bce..3cea01d 100644 --- a/subscription-manager/integration_python_tests/deactivate_subscriber.py +++ b/subscription-manager/integration_python_tests/deactivate_subscriber.py @@ -1,4 +1,5 @@ import os +from time import sleep from dotenv import load_dotenv from secret_sdk.client.lcd import LCDClient @@ -6,6 +7,7 @@ from secret_sdk.core.coins import Coins from secret_sdk.key.mnemonic import MnemonicKey from secret_sdk.protobuf.cosmos.tx.v1beta1 import BroadcastMode +from secret_sdk.util.contract import get_contract_events load_dotenv() chain_id = os.getenv('CHAIN_ID') @@ -38,7 +40,12 @@ transfer_amount=sent_funds, ) -print(t) - assert t.code == 0, f"Transaction failed with code {t.code}: {t.rawlog}" print("Transaction successful:", t.txhash) + +sleep(5) + +tx_info = secret.tx.tx_info( + tx_hash=t.txhash, +) +print("Transaction info:", tx_info) diff --git a/subscription-manager/integration_python_tests/register_subscriber.py b/subscription-manager/integration_python_tests/register_subscriber.py index 38aa8ea..e066a75 100644 --- a/subscription-manager/integration_python_tests/register_subscriber.py +++ b/subscription-manager/integration_python_tests/register_subscriber.py @@ -1,4 +1,5 @@ import os +from time import sleep from dotenv import load_dotenv from secret_sdk.client.lcd import LCDClient @@ -42,3 +43,10 @@ assert t.code == 0, f"Transaction failed with code {t.code}: {t.rawlog}" print("Transaction successful:", t.txhash) + +sleep(5) + +tx_info = secret.tx.tx_info( + tx_hash=t.txhash, +) +print("Transaction info:", tx_info) diff --git a/subscription-manager/integration_python_tests/set_admin.py b/subscription-manager/integration_python_tests/set_admin.py new file mode 100644 index 0000000..c39c62a --- /dev/null +++ b/subscription-manager/integration_python_tests/set_admin.py @@ -0,0 +1,52 @@ +import os +from time import sleep + +from dotenv import load_dotenv +from secret_sdk.client.lcd import LCDClient +from secret_sdk.client.localsecret import LocalSecret, main_net_chain_id, test_net_chain_id +from secret_sdk.core.coins import Coins +from secret_sdk.key.mnemonic import MnemonicKey +from secret_sdk.protobuf.cosmos.tx.v1beta1 import BroadcastMode + +load_dotenv() +chain_id = os.getenv('CHAIN_ID') +contract = os.getenv('CONTRACT_ADDRESS') +node_url = os.getenv('NODE_URL') +mnemonic = os.getenv('MNEMONIC') + +print("chain_id: " + chain_id) +print("node_url: " + node_url) +print("contract: " + contract) +print("mnemonic: " + mnemonic) + +mk = MnemonicKey(mnemonic=mnemonic) +secret = LCDClient(chain_id=chain_id, url=node_url) +wallet = secret.wallet(mk) + +wallet_public_key = str(wallet.key.acc_address) + +print("wallet_public_key: " + wallet_public_key) + +contract_address = contract +sent_funds = Coins('100uscrt') + +public_key = "secret1ap26qrlp8mcq2pg6r47w43l0y8zkqm8a450s03" +handle_msg = {"set_admin": {"public_key": public_key}} + +t = wallet.execute_tx( + contract_addr=contract_address, + handle_msg=handle_msg, + transfer_amount=sent_funds, +) + +print(t) + +assert t.code == 0, f"Transaction failed with code {t.code}: {t.rawlog}" +print("Transaction successful:", t.txhash) + +sleep(5) + +tx_info = secret.tx.tx_info( + tx_hash=t.txhash, +) +print("Transaction info:", tx_info) From ec603d8228a31a034615772279480521863951d0 Mon Sep 17 00:00:00 2001 From: DeDTihoN Date: Tue, 19 Nov 2024 20:24:25 +0200 Subject: [PATCH 07/17] made migrateable and added js queryScript --- .../store_code_test.py | 23 ++++++++++++++++++- subscription-manager/node/package-lock.json | 8 +++---- subscription-manager/node/package.json | 2 +- subscription-manager/node/queryContactInfo.js | 23 +++++++++++++++++++ subscription-manager/src/contract.rs | 10 +++++++- subscription-manager/src/msg.rs | 7 ++++++ 6 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 subscription-manager/node/queryContactInfo.js diff --git a/subscription-manager/integration_python_tests/store_code_test.py b/subscription-manager/integration_python_tests/store_code_test.py index 50bd5a3..31b0def 100644 --- a/subscription-manager/integration_python_tests/store_code_test.py +++ b/subscription-manager/integration_python_tests/store_code_test.py @@ -4,11 +4,32 @@ from secret_sdk.core import Coins, TxResultCode from secret_sdk.client.lcd.api.gov import ProposalStatus from secret_sdk.core.wasm.msgs import MsgStoreCode, MsgInstantiateContract, MsgExecuteContract +from secret_sdk.protobuf import secret from secret_sdk.util.tx import get_value_from_raw_log from secret_sdk.protobuf.cosmos.tx.v1beta1 import BroadcastMode +@pytest.fixture +def mnemonics(): + # Initialize genesis accounts + return [ + "grant rice replace explain federal release fix clever romance raise often wild taxi quarter soccer fiber love must tape steak together observe swap guitar", + "jelly shadow frog dirt dragon use armed praise universe win jungle close inmate rain oil canvas beauty pioneer chef soccer icon dizzy thunder meadow", + "chair love bleak wonder skirt permit say assist aunt credit roast size obtain minute throw sand usual age smart exact enough room shadow charge", + "word twist toast cloth movie predict advance crumble escape whale sail such angry muffin balcony keen move employ cook valve hurt glimpse breeze brick", + ] def test_store_code(): + accounts = [] + + for mnemonic in mnemonics: + wallet = secret.wallet(MnemonicKey(mnemonic)) + accounts.append({ + 'address': wallet.key.acc_address, + 'mnemonic': mnemonic, + 'wallet': wallet, + 'secret': secret + }) + wallet = pytest.accounts[0]['wallet'] with open(r'tests/data/snip20-ibc.wasm.gz', 'rb') as fl: @@ -109,4 +130,4 @@ def test_store_code(): if tx_execute.code != TxResultCode.Success.value: raise Exception(f"Failed MsgExecuteContract: {tx_execute.raw_log}") assert tx_execute.code == TxResultCode.Success.value - assert '{"create_viewing_key":{"key":"' in tx_execute.data[0].data.decode('utf-8') \ No newline at end of file + assert '{"create_viewing_key":{"key":"' in tx_execute.data[0].data.decode('utf-8') diff --git a/subscription-manager/node/package-lock.json b/subscription-manager/node/package-lock.json index bd21b50..d5f1a98 100644 --- a/subscription-manager/node/package-lock.json +++ b/subscription-manager/node/package-lock.json @@ -13,7 +13,7 @@ "@solar-republic/neutrino": "^1.0.8", "dotenv": "^16.4.5", "ethers": "^6.13.4", - "secretjs": "^1.15.0-beta.0", + "secretjs": "^1.15.0-beta.1", "secure-random": "^1.1.2" } }, @@ -1496,9 +1496,9 @@ "license": "MIT" }, "node_modules/secretjs": { - "version": "1.15.0-beta.0", - "resolved": "https://registry.npmjs.org/secretjs/-/secretjs-1.15.0-beta.0.tgz", - "integrity": "sha512-zxFVWixArto6qd2+h9f4HEGTtlD2ZB0dggDsFdkHyXNA7728vVX0QWFfKcnxR04mqOtr7CnASxxTTSK42EFoKQ==", + "version": "1.15.0-beta.1", + "resolved": "https://registry.npmjs.org/secretjs/-/secretjs-1.15.0-beta.1.tgz", + "integrity": "sha512-+ITVih6vvRX2tk6jYI79doeFAnpoNIfT85TQC8mM1JS5s4nXCD7tG5vtn+8j1uxEaw9+DglbpTKmlaIPiiS7Og==", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/subscription-manager/node/package.json b/subscription-manager/node/package.json index 20f6f68..1e6d1ae 100644 --- a/subscription-manager/node/package.json +++ b/subscription-manager/node/package.json @@ -14,7 +14,7 @@ "@solar-republic/neutrino": "^1.0.8", "dotenv": "^16.4.5", "ethers": "^6.13.4", - "secretjs": "^1.15.0-beta.0", + "secretjs": "^1.15.0-beta.1", "secure-random": "^1.1.2" } } diff --git a/subscription-manager/node/queryContactInfo.js b/subscription-manager/node/queryContactInfo.js new file mode 100644 index 0000000..622e8b6 --- /dev/null +++ b/subscription-manager/node/queryContactInfo.js @@ -0,0 +1,23 @@ +import { SecretNetworkClient, Wallet } from "secretjs"; +import dotenv from "dotenv"; +dotenv.config(); + +const wallet = new Wallet(process.env.MNEMONIC); + +const secretjs = new SecretNetworkClient({ + chainId: "pulsar-3", + url: "https://api.pulsar.scrttestnet.com", + wallet: wallet, + walletAddress: wallet.address, +}); + +let queryContractInfo = async () => { + let query = await secretjs.query.compute.contractInfo({ + contract_address: "secret1dzynxw5hvcy5tm0mg4k2ftaqwuzfyce2ydjzj4", + code_hash: "afd0b5bda5a14dd41dc98d4cf112c1a239b5689796ac0fec4845db69d0a11f28", + }); + + console.log(query); +}; + +queryContractInfo(); \ No newline at end of file diff --git a/subscription-manager/src/contract.rs b/subscription-manager/src/contract.rs index 082de1b..afcd04a 100644 --- a/subscription-manager/src/contract.rs +++ b/subscription-manager/src/contract.rs @@ -2,7 +2,7 @@ use cosmwasm_std::{ entry_point, to_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdError, StdResult }; use sha2::{Digest, Sha256}; -use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg, SubscriberStatusResponse}; +use crate::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg, SubscriberStatusResponse}; use crate::state::{config, config_read, State, Subscriber, SB_MAP}; // Entry point for contract initialization @@ -46,6 +46,14 @@ pub fn execute( } } +#[entry_point] +pub fn migrate(_deps: DepsMut, _env: Env, msg: MigrateMsg) -> StdResult { + match msg { + MigrateMsg::Migrate {} => Ok(Response::default()), + MigrateMsg::StdError {} => Err(StdError::generic_err("this is an std error")), + } +} + // Function to register a new subscriber pub fn try_register_subscriber( _deps: DepsMut, diff --git a/subscription-manager/src/msg.rs b/subscription-manager/src/msg.rs index 617bc6f..3d6c5d9 100644 --- a/subscription-manager/src/msg.rs +++ b/subscription-manager/src/msg.rs @@ -19,6 +19,13 @@ pub enum ExecuteMsg { SetAdmin { public_key: String }, } +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum MigrateMsg { + Migrate {}, + StdError {}, +} + // Enum representing the different query messages that the contract can respond to #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] From 4de952678ea4df23c104093c19abe2a395f679aa Mon Sep 17 00:00:00 2001 From: DeDTihoN Date: Wed, 25 Dec 2024 03:37:51 +0200 Subject: [PATCH 08/17] added api-keys functionality (messages,queiries and storage) --- subscription-manager/src/contract.rs | 124 ++++++++++++++++++++++++++- subscription-manager/src/msg.rs | 12 +++ subscription-manager/src/state.rs | 10 +++ 3 files changed, 144 insertions(+), 2 deletions(-) diff --git a/subscription-manager/src/contract.rs b/subscription-manager/src/contract.rs index afcd04a..835f230 100644 --- a/subscription-manager/src/contract.rs +++ b/subscription-manager/src/contract.rs @@ -2,8 +2,8 @@ use cosmwasm_std::{ entry_point, to_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdError, StdResult }; use sha2::{Digest, Sha256}; -use crate::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg, SubscriberStatusResponse}; -use crate::state::{config, config_read, State, Subscriber, SB_MAP}; +use crate::msg::{ExecuteMsg, GetApiKeysResponse, InstantiateMsg, MigrateMsg, QueryMsg, SubscriberStatusResponse}; +use crate::state::{config, config_read, ApiKey, State, Subscriber, API_KEY_MAP, SB_MAP}; // Entry point for contract initialization #[entry_point] @@ -43,9 +43,70 @@ pub fn execute( ExecuteMsg::RemoveSubscriber { public_key } => try_remove_subscriber(deps, info, public_key), // Handle setting a new admin ExecuteMsg::SetAdmin { public_key } => try_set_admin(deps, info, public_key), + // Handle adding an API key + ExecuteMsg::AddApiKey { api_key } => try_add_api_key(deps, info, api_key), + // Handle revoking an API key + ExecuteMsg::RevokeApiKey { api_key } => try_revoke_api_key(deps, info, api_key), } } +// Function to add a new API key +pub fn try_add_api_key( + deps: DepsMut, + info: MessageInfo, + api_key: String, +) -> StdResult { + let config = config_read(deps.storage); + let state = config.load()?; + + // Check if the sender is the admin + if info.sender != state.admin { + return Err(StdError::generic_err("Only admin can add API keys")); + } + + // Check if the API key already exists + if API_KEY_MAP.contains(deps.storage, &api_key) { + return Err(StdError::generic_err("API key already exists")); + } + + // Insert the API key into the map + let api_key_data = ApiKey { key: api_key.clone() }; + API_KEY_MAP.insert(deps.storage, &api_key, &api_key_data) + .map_err(|err| StdError::generic_err(err.to_string()))?; + + Ok(Response::new() + .add_attribute("action", "add_api_key") + .add_attribute("api_key", api_key)) +} + +// Function to revoke an API key +pub fn try_revoke_api_key( + deps: DepsMut, + info: MessageInfo, + api_key: String, +) -> StdResult { + let config = config_read(deps.storage); + let state = config.load()?; + + // Check if the sender is the admin + if info.sender != state.admin { + return Err(StdError::generic_err("Only admin can revoke API keys")); + } + + // Check if the API key exists + if !API_KEY_MAP.contains(deps.storage, &api_key) { + return Err(StdError::generic_err("API key not found")); + } + + // Remove the API key from the map + API_KEY_MAP.remove(deps.storage, &api_key) + .map_err(|err| StdError::generic_err(err.to_string()))?; + + Ok(Response::new() + .add_attribute("action", "revoke_api_key") + .add_attribute("api_key", api_key)) +} + #[entry_point] pub fn migrate(_deps: DepsMut, _env: Env, msg: MigrateMsg) -> StdResult { match msg { @@ -144,6 +205,7 @@ pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { match msg { // Handle query for subscriber status QueryMsg::SubscriberStatus { public_key } => to_binary(&query_subscriber(deps, public_key)?), + QueryMsg::ApiKeys {} => to_binary(&query_api_keys(deps)?), } } @@ -162,6 +224,21 @@ fn query_subscriber( Ok(SubscriberStatusResponse { active: false }) } +// Function to query all API keys +pub fn query_api_keys(deps: Deps) -> StdResult { + let api_keys: Vec = API_KEY_MAP + .iter(deps.storage)? + .filter_map(|x| { + if let Ok((_, api_key)) = x { + Some(api_key) + } else { + None + } + }) + .collect(); + + Ok(GetApiKeysResponse { api_keys }) +} #[cfg(test)] mod tests { use super::*; @@ -169,6 +246,49 @@ mod tests { use cosmwasm_std::{attr, from_binary, Api, Coin, Uint128}; use secp256k1::{Message, PublicKey, Secp256k1, ecdsa::Signature, SecretKey}; + #[test] + fn add_and_query_api_keys() { + let mut deps = mock_dependencies(); + let info = mock_info("admin", &[]); + let init_msg = InstantiateMsg {}; + instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + + let add_msg = ExecuteMsg::AddApiKey { + api_key: "test_api_key".to_string(), + }; + execute(deps.as_mut(), mock_env(), info.clone(), add_msg).unwrap(); + + let query_msg = QueryMsg::ApiKeys {}; + let res = query(deps.as_ref(), mock_env(), query_msg).unwrap(); + let response: GetApiKeysResponse = from_binary(&res).unwrap(); + + assert_eq!(response.api_keys, vec![ApiKey { key: "test_api_key".to_string() }]); + } + + #[test] + fn revoke_api_key() { + let mut deps = mock_dependencies(); + let info = mock_info("admin", &[]); + let init_msg = InstantiateMsg {}; + instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + + let add_msg = ExecuteMsg::AddApiKey { + api_key: "test_api_key".to_string(), + }; + execute(deps.as_mut(), mock_env(), info.clone(), add_msg).unwrap(); + + let revoke_msg = ExecuteMsg::RevokeApiKey { + api_key: "test_api_key".to_string(), + }; + execute(deps.as_mut(), mock_env(), info.clone(), revoke_msg).unwrap(); + + let query_msg = QueryMsg::ApiKeys {}; + let res = query(deps.as_ref(), mock_env(), query_msg).unwrap(); + let response: GetApiKeysResponse = from_binary(&res).unwrap(); + + assert!(response.api_keys.is_empty()); + } + #[test] /// Test for successful initialization of the contract fn proper_initialization() { diff --git a/subscription-manager/src/msg.rs b/subscription-manager/src/msg.rs index 3d6c5d9..586600d 100644 --- a/subscription-manager/src/msg.rs +++ b/subscription-manager/src/msg.rs @@ -1,5 +1,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::state::ApiKey; // Struct for the message used to instantiate the contract #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] @@ -17,6 +18,10 @@ pub enum ExecuteMsg { // Message to set a new admin for the contract using a public key SetAdmin { public_key: String }, + // Message to add an API key + AddApiKey { api_key: String }, + // Message to revoke an API key + RevokeApiKey { api_key: String }, } #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] @@ -34,6 +39,7 @@ pub enum QueryMsg { SubscriberStatus { public_key: String, }, + ApiKeys {}, } // Struct used to respond to a query about a subscriber's status @@ -42,3 +48,9 @@ pub struct SubscriberStatusResponse { // Indicates if the subscriber is active or not pub active: bool, } + +// Structure for GetApiKeysResponse +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] +pub struct GetApiKeysResponse { + pub api_keys: Vec, +} \ No newline at end of file diff --git a/subscription-manager/src/state.rs b/subscription-manager/src/state.rs index 48620c0..a1d7f8f 100644 --- a/subscription-manager/src/state.rs +++ b/subscription-manager/src/state.rs @@ -11,6 +11,9 @@ pub static CONFIG_KEY: &[u8] = b"config"; // Keymap for storing subscribers' information, using public keys as keys pub static SB_MAP: Keymap = Keymap::new(b"SB_MAP"); +// Keymap for storing API keys +pub static API_KEY_MAP: Keymap = Keymap::new(b"API_KEY_MAP"); + // Structure representing the state of the contract #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] pub struct State { @@ -27,6 +30,13 @@ pub struct Subscriber { pub public_key: String, } +// Structure representing an API key +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] +pub struct ApiKey { + // API key value + pub key: String, +} + // Function to access and modify the configuration state pub fn config(storage: &mut dyn Storage) -> Singleton { singleton(storage, CONFIG_KEY) From 8777ea4e769d306a44f441b081e44d9088f18738 Mon Sep 17 00:00:00 2001 From: DeDTihoN Date: Wed, 25 Dec 2024 04:57:36 +0200 Subject: [PATCH 09/17] Updated Readme --- subscription-manager/Readme.md | 204 +++++++++++++-------------------- 1 file changed, 77 insertions(+), 127 deletions(-) diff --git a/subscription-manager/Readme.md b/subscription-manager/Readme.md index 155242f..654ab75 100644 --- a/subscription-manager/Readme.md +++ b/subscription-manager/Readme.md @@ -1,28 +1,32 @@ # Claive Subscription Manager Contract -This repository contains a CosmWasm smart contract for managing subscriptions on Secret Network. The contract provides functionality to register and remove subscribers and includes admin management features. Secret Network's privacy features are utilized to keep subscriber data confidential and secure. +This repository contains a CosmWasm smart contract for managing subscriptions on Secret Network. The contract provides functionality to register and remove subscribers, manage API keys, and includes admin management features. Secret Network's privacy features are utilized to keep subscriber data confidential and secure. --- ## Overview -The Claive Subscription Manager Contract is designed for subscription-based use cases, where an admin manages subscribers using their public keys. The contract keeps track of registered subscribers and ensures that only authorized admins can add or remove subscribers or change admin permissions. +The Claive Subscription Manager Contract is designed for subscription-based use cases, where an admin manages subscribers using their public keys. The contract keeps track of registered subscribers and API keys, ensuring that only authorized admins can manage them. ### Contract State The contract stores: -- **Admin Address**: The account that has permission to register or remove subscribers and manage admin rights. +- **Admin Address**: The account that has permission to register or remove subscribers, manage API keys, and change admin rights. - **Subscribers**: A mapping from a public key to the subscriber's status (active or inactive). +- **API Keys**: A mapping of API keys used for external access control. ### Methods 1. **Instantiate** - - Initializes the contract and sets the admin to the sender's address. + - Initializes the contract and sets the admin to the sender's address. 2. **Execute** - - `RegisterSubscriber`: Adds a new subscriber using their public key. Only callable by the admin. - - `RemoveSubscriber`: Removes a subscriber using their public key. Only callable by the admin. - - `SetAdmin`: Changes the admin to a new address. Only callable by the current admin. + - `RegisterSubscriber`: Adds a new subscriber using their public key. Only callable by the admin. + - `RemoveSubscriber`: Removes a subscriber using their public key. Only callable by the admin. + - `SetAdmin`: Changes the admin to a new address. Only callable by the current admin. + - `AddApiKey`: Adds a new API key for access control. Only callable by the admin. + - `RevokeApiKey`: Revokes an existing API key. Only callable by the admin. 3. **Query** - - `SubscriberStatus`: Checks if a subscriber with the given public key is active. + - `SubscriberStatus`: Checks if a subscriber with the given public key is active. + - `ApiKeys`: Returns a list of all registered API keys. --- @@ -170,39 +174,14 @@ $ secretcli query compute list-contract-by-code 1 secretcli tx compute execute '{"register_subscriber":{"public_key":"subscriber_pub_key"}}' --from myWallet -y ``` -to get logs of the transaction use -```bash -secretcli q compute tx -``` - #### Example ```bash $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"register_subscriber":{"public_key":"subscriber_pub_key"}}' --from myWallet -y -{"height":"0","txhash":"F9435CA04E44FD924966089DBBBE395E7CA21422FF8D6A29BC31E9A0B016CCE4","codespace":"","code":0,"data":"","raw_log":"[]","logs":[],"info":"","gas_wanted":"0","gas_used":"0","tx":null,"timestamp":"","events":[]} -$ secretcli q compute tx F9435CA04E44FD924966089DBBBE395E7CA21422FF8D6A29BC31E9A0B016CCE4 { "height": "0", "txhash": "F9435CA04E44FD924966089DBBBE395E7CA21422FF8D6A29BC31E9A0B016CCE4", "code": 0, - "logs": [ - { - "type": "wasm", - "attributes": [ - { - "key": "contract_address", - "value": "secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf" - }, - { - "key": "action", - "value": "register_subscriber" - }, - { - "key": "subscriber", - "value": "subscriber_pub_key" - } - ] - } - ] + "logs": [] } ``` @@ -239,34 +218,12 @@ secretcli tx compute execute '{"remove_subscriber":{"public_k #### Example ```bash $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"remove_subscriber":{"public_key":"subscriber_pub_key"}}' --from myWallet -y -{"height":"0","txhash":"C6E5113A94FDFA05FD5FB3214E6FA1E604AD927D1848C9CB191407BA11233E41","codespace":"","code":0,"data":"","raw_log":"[]","logs":[],"info":"","gas_wanted":"0","gas_used":"0","tx":null,"timestamp":"","events":[]} -$ secretcli q compute tx C6E5113A94FDFA05FD5FB3214E6FA1E604AD927D1848C9CB191407BA11233E41 { "height": "0", "txhash": "C6E5113A94FDFA05FD5FB3214E6FA1E604AD927D1848C9CB191407BA11233E41", "code": 0, - "logs": [ - { - "type": "wasm", - "attributes": [ - { - "key": "contract_address", - "value": "secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf" - }, - { - "key": "action", - "value": "remove_subscriber" - }, - { - "key": "subscriber", - "value": "subscriber_pub_key" - } - ] - } - ] + "logs": [] } -$ secretcli query compute query secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{\"subscriber_status\":{\"public_key\":\"subscriber_pub_key\"}}' -{"active":false} ``` --- @@ -282,21 +239,6 @@ secretcli tx compute execute '{"set_admin":{"public_key":"new #### Example ```bash -$ secretcli keys list -[{"name":"myNewWallet","type":"local","address":"secret1qvapn5ns28xrevn7kdudwvrp6a4fven2kzq8jc","pubkey":"{\"@type\":\"/cosmos.crypto.secp256k1.PubKey\",\"key\":\"AsKefYYrOHWvOfziuk/ITmcEpS6ZwWOTb6zlmqOu3FrW\"}"},{"name":"myWallet","type":"local","address":"secret1msmqzrp8ahvwe2jzk9n0xula0vnmv7vt3883y8","pubkey":"{\"@type\":\"/cosmos.crypto.secp256k1.PubKey\",\"key\":\"A5YOx9fAvahYlFqzyWAF38w3EuDTUtpZCyKW6Zk88NzO\"}"}] -$ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{\"register_subscriber\":{\"public_key\":\"subscriber_pub_key\"}}' --from myNewWallet -y -{"height":"0","txhash":"2DB0821F55F1DCC51E29ACBAE529D9B3B14F2DA41D09BD0D05CB7C0436E1E9DC","codespace":"","code":0,"data":"","raw_log":"[]","logs":[],"info":"","gas_wanted":"0","gas_used":"0","tx":null,"timestamp":"","events":[]} -$ secretcli q compute tx 2DB0821F55F1DCC51E29ACBAE529D9B3B14F2DA41D09BD0D05CB7C0436E1E9DC -{ - "answers": [ - { - "type": "execute", - "input": "afd0b5bda5a14dd41dc98d4cf112c1a239b5689796ac0fec4845db69d0a11f28{\"register_subscriber\":{\"public_key\":\"subscriber_pub_key\"}}" - } - ], - "output_logs": [], - "output_error": "message index 0: Generic error: Only admin can register subscribers" -} $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"set_admin":{"public_key":"secret1qvapn5ns28xrevn7kdudwvrp6a4fven2kzq8jc"}}' --from myWallet -y { "height": "0", @@ -304,62 +246,70 @@ $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{" "code": 0, "logs": [] } -$ secretcli q compute tx D5D86A32A654D3BBE7A4491F74BB96F68FC4481BECD00B5D10DFF271D76C75B2 -{ - "answers": [ - { - "type": "execute", - "input": "afd0b5bda5a14dd41dc98d4cf112c1a239b5689796ac0fec4845db69d0a11f28{\"set_admin\":{\"public_key\":\"secret1qvapn5ns28xrevn7kdudwvrp6a4fven2kzq8jc\"}}" - } - ], - "output_logs": [ - { - "type": "wasm", - { - "key": "contract_address", - "value": "secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf" - }, - { - "key": "action", - "value": "set_admin" - }, - { - "key": "new_admin", - "value": "secret1qvapn5ns28xrevn7kdudwvrp6a4fven2kzq8jc" - } - ] - } - ] +``` + +--- + +### Use Case 5: Add an API Key + +**Description**: Add a new API key for access control. Only the admin can perform this action. + +#### Command +```bash +secretcli tx compute execute '{"add_api_key":{"api_key":"new_api_key"}}' --from myWallet -y +``` + +#### Example +```bash +$ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"add_api_key":{"api_key":"test_api_key"}}' --from myWallet -y +{ + "height": "0", + "txhash": "E9435CA04E44FD924966089DBBBE395E7CA21422FF8D6A29BC31E9A0B016CCE5", + "code": 0, + "logs": [] } -$ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{\"register_subscriber\":{\"public_key\":\"subscriber_pub_key\"}}' --from myNewWallet -y -{"height":"0","txhash":"C05875F06001649DF9D00D600AA3E07C7A0A6A3674E4F6336C9E4FEEF521E155","codespace":"","code":0,"data":"","raw_log":"[]","logs":[],"info":"","gas_wanted":"0","gas_used":"0","tx":null,"timestamp":"","events":[]} -$ secretcli q compute tx C05875F06001649DF9D00D600AA3E07C7A0A6A3674E4F6336C9E4FEEF521E155 -{ - "answers": [ - { - "type": "execute", - "input": "afd0b5bda5a14dd41dc98d4cf112c1a239b5689796ac0fec4845db69d0a11f28{\"register_subscriber\":{\"public_key\":\"subscriber_pub_key\"}}" - } - ], - "output_logs": [ - { - "type": "wasm", - "attributes": [ - { - "key": "contract_address", - "value": "secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf" - }, - { - "key": "action", - "value": "register_subscriber" - }, - { - "key": "subscriber", - "value": "subscriber_pub_key" - } - ] - } - ] +``` + +--- + +### Use Case 6: Revoke an API Key + +**Description**: Revoke an existing API key. Only the admin can perform this action. + +#### Command +```bash +secretcli tx compute execute '{"revoke_api_key":{"api_key":"api_key_to_revoke"}}' --from myWallet -y +``` + +#### Example +```bash +$ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"revoke_api_key":{"api_key":"test_api_key"}}' +{ + "height": "0", + "txhash": "F9435CA04E44FD924966089DBBBE395E7CA21422FF8D6A29BC31E9A0B016CCE6", + "code": 0, + "logs": [] } +``` + +--- + +### Use Case 7: Query All API Keys +**Description**: Retrieve the list of all registered API keys. Only available through querying the contract. + +#### Command +```bash +secretcli query compute query '{"api_keys":{}}' ``` + +#### Example +```bash +$ secretcli query compute query secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"api_keys":{}}' +{ + "api_keys": [ + { "key": "test_api_key1" }, + { "key": "test_api_key2" } + ] +} +``` \ No newline at end of file From 6c5f7f5934201c16378171379ba2a301571a8cb1 Mon Sep 17 00:00:00 2001 From: DeDTihoN Date: Mon, 30 Dec 2024 16:28:18 +0200 Subject: [PATCH 10/17] permit for api_keys and hashing the api keys --- subscription-manager/.gitignore | 6 +- subscription-manager/Cargo.toml | 31 ++-- subscription-manager/Makefile | 5 +- subscription-manager/Readme.md | 144 +++++++---------- subscription-manager/src/contract.rs | 233 ++++++++++++++++++++++----- subscription-manager/src/msg.rs | 7 +- subscription-manager/src/state.rs | 6 +- 7 files changed, 281 insertions(+), 151 deletions(-) diff --git a/subscription-manager/.gitignore b/subscription-manager/.gitignore index 45326df..290b2ec 100644 --- a/subscription-manager/.gitignore +++ b/subscription-manager/.gitignore @@ -1,2 +1,6 @@ contract-opt.wasm -Dockerfile \ No newline at end of file +Dockerfile + +permit.json +signed_permit.json +claive_subscription_manager.wasm \ No newline at end of file diff --git a/subscription-manager/Cargo.toml b/subscription-manager/Cargo.toml index 08882c4..c57c2ed 100644 --- a/subscription-manager/Cargo.toml +++ b/subscription-manager/Cargo.toml @@ -20,30 +20,27 @@ overflow-checks = true [features] default = [] +# debug-print = ["cosmwasm-std/debug-print"] doesn't work anymore? # for quicker tests, cargo test --lib # for more explicit tests, cargo test --features=backtraces backtraces = ["cosmwasm-std/backtraces"] +schema = ["cosmwasm-schema"] [dependencies] -cosmwasm-std = { package = "secret-cosmwasm-std", version = "1.0.0" } -cosmwasm-storage = { package = "secret-cosmwasm-storage", version = "1.0.0" } -secret-toolkit = { version = "0.10.0", default-features = false, features = [ -# "utils", - "storage", -# "serialization", -# "viewing-key", -# "permit", -] } -getrandom = { version = "0.2.15", features = ["js"]} -schemars = { version = "0.8.11" } -serde = { version = "1.0" } +cosmwasm-schema = { version = "1.1.0", optional = true } +cosmwasm-std = { package = "secret-cosmwasm-std", version = "1.1.11" , features = ["stargate"]} +cosmwasm-storage = { package = "secret-cosmwasm-storage", version = "1.1.11" } +schemars = "0.8.11" +secret-toolkit = { version = "0.10.0", default-features = false, features = ["utils", "storage", "serialization", "viewing-key", "permit"] } +serde = { version = "1.0.144", default-features = false, features = ["derive"] } serde-json-wasm = "1.0.0" -thiserror = { version = "1.0" } -cosmwasm-schema = "1.0.0" +sha3 = "0.10.4" +base64 = "0.22.1" +anybuf = "0.5.0" +cc = { version = "=1.1.10" } +serde_json = "1.0.134" hex = "0.4.3" -sha2 = "0.10.8" -k256 = "0.13.4" -secp256k1 = "0.30.0" +sha2 = "0.9.9" # Uncomment these for some common extra tools # secret-toolkit = { git = "https://github.com/scrtlabs/secret-toolkit", tag = "v0.8.0" } diff --git a/subscription-manager/Makefile b/subscription-manager/Makefile index 5f026e8..674aaea 100644 --- a/subscription-manager/Makefile +++ b/subscription-manager/Makefile @@ -32,9 +32,10 @@ _build-mainnet: .PHONY: build-mainnet-reproducible build-mainnet-reproducible: docker run --rm -v "$$(pwd)":/contract \ - --mount type=volume,source="$$(basename "$$(pwd)")_cache",target=/contract/target \ + --mount type=volume,source="$$(basename "$$(pwd)")_cache",target=/code/target \ --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \ - ghcr.io/scrtlabs/localsecret:v1.6.0-rc.3 + mr7uca/wasm-contract-optimizer:0.0.10 + .PHONY: compress-wasm compress-wasm: diff --git a/subscription-manager/Readme.md b/subscription-manager/Readme.md index 654ab75..3fa52d1 100644 --- a/subscription-manager/Readme.md +++ b/subscription-manager/Readme.md @@ -9,30 +9,35 @@ This repository contains a CosmWasm smart contract for managing subscriptions on The Claive Subscription Manager Contract is designed for subscription-based use cases, where an admin manages subscribers using their public keys. The contract keeps track of registered subscribers and API keys, ensuring that only authorized admins can manage them. ### Contract State + The contract stores: + - **Admin Address**: The account that has permission to register or remove subscribers, manage API keys, and change admin rights. - **Subscribers**: A mapping from a public key to the subscriber's status (active or inactive). -- **API Keys**: A mapping of API keys used for external access control. +- **API Keys**: A mapping of hashed API keys used for external access control. The API keys are stored as SHA-256 hashes to enhance security. ### Methods 1. **Instantiate** - Initializes the contract and sets the admin to the sender's address. + 2. **Execute** - `RegisterSubscriber`: Adds a new subscriber using their public key. Only callable by the admin. - `RemoveSubscriber`: Removes a subscriber using their public key. Only callable by the admin. - `SetAdmin`: Changes the admin to a new address. Only callable by the current admin. - - `AddApiKey`: Adds a new API key for access control. Only callable by the admin. - - `RevokeApiKey`: Revokes an existing API key. Only callable by the admin. + - `AddApiKey`: Adds a new API key for access control. The API key is hashed using SHA-256 before storage. Only callable by the admin. + - `RevokeApiKey`: Revokes an existing API key. The API key must be provided in plaintext, and the contract verifies its hash. Only callable by the admin. + 3. **Query** - `SubscriberStatus`: Checks if a subscriber with the given public key is active. - - `ApiKeys`: Returns a list of all registered API keys. + - `ApiKeysWithPermit`: Returns a list of all registered API keys. The query requires a valid permit signed by the admin to ensure secure access. --- ## Prerequisites To use and deploy this contract, you'll need: + - [**SecretCLI**](https://docs.scrt.network/secret-network-documentation/infrastructure/secret-cli) for interacting with the Secret Network. - [**LocalSecret**](https://docs.scrt.network/secret-network-documentation/development/readme-1/setting-up-your-environment) for local testing and development. @@ -45,39 +50,30 @@ Please refer to the documentation above to install and familiarize yourself with ### Prerequisites - **Rust** and **wasm-opt** must be installed. -- Add the `wasm32-unknown-unknown` target for Rust if you haven’t done so: - ```bash - rustup target add wasm32-unknown-unknown - ``` +- Add the wasm32-unknown-unknown target for Rust if you haven’t done so: + +```bash +rustup target add wasm32-unknown-unknown +``` ### Build Instructions 1. **Build the Contract**: - ```bash - cargo build --release --target wasm32-unknown-unknown - ``` -2. **Optimize the Contract**: - ```bash - wasm-opt -Oz -o contract-opt.wasm target/wasm32-unknown-unknown/release/claive_subscription_manager.wasm - ``` +```bash +cargo build --release --target wasm32-unknown-unknown +``` -3. **Compress the Contract**: - ```bash - gzip -9 -c contract-opt.wasm > contract.wasm.gz - ``` +2. **Optimize the Contract**: -### Example Output ```bash -$ cargo build --release --target wasm32-unknown-unknown - Compiling claive_subscription_manager v0.1.0 (/path/to/contract) - Finished release [optimized] target(s) in 23.45s +wasm-opt -Oz -o contract-opt.wasm target/wasm32-unknown-unknown/release/claive_subscription_manager.wasm +``` -$ wasm-opt -Oz -o contract-opt.wasm target/wasm32-unknown-unknown/release/claive_subscription_manager.wasm -# wasm-opt optimization completed +3. **Compress the Contract**: -$ gzip -9 -c contract-opt.wasm > contract.wasm.gz -# Contract compressed successfully +```bash +gzip -9 -c contract-opt.wasm > contract.wasm.gz ``` --- @@ -91,33 +87,15 @@ $ gzip -9 -c contract-opt.wasm > contract.wasm.gz ### Deploy Instructions 1. **Deploy the Contract**: - ```bash - secretcli tx compute store contract.wasm.gz --gas 5000000 --from myWallet -y - ``` -2. **Get the `code_id`**: - ```bash - secretcli query compute list-code - ``` - -### Example Output ```bash -$ secretcli tx compute store contract.wasm.gz --gas 5000000 --from myWallet -y -{ - "height": "0", - "txhash": "DABA1EA6380DF252C844355109298681C28EC52BE0031E7E3B8730D8ECFC2BE0", - "code": 0, - "logs": [] -} +secretcli tx compute store contract.wasm.gz --gas 5000000 --from myWallet -y +``` + +2. **Get the code_id**: -$ secretcli query compute list-code -[ - { - "code_id": 1, - "creator": "secret1msmqzrp8ahvwe2jzk9n0xula0vnmv7vt3883y8", - "code_hash": "afd0b5bda5a14dd41dc98d4cf112c1a239b5689796ac0fec4845db69d0a11f28" - } -] +```bash +secretcli query compute list-code ``` --- @@ -126,39 +104,20 @@ $ secretcli query compute list-code ### Prerequisites -- You need the `code_id` from the previous step. +- You need the code_id from the previous step. ### Instantiate Instructions 1. **Instantiate the Contract**: - ```bash - secretcli tx compute instantiate '{}' --from myWallet --label subContract -y - ``` + +```bash +secretcli tx compute instantiate '{}' --from myWallet --label subContract -y +``` 2. **Get the Contract Address**: - ```bash - secretcli query compute list-contract-by-code - ``` -### Example Output ```bash -$ secretcli tx compute instantiate 1 '{}' --from myWallet --label subContract -y -{ - "height": "0", - "txhash": "ACFD28FB7DE8ADC706B3595A32E2EA85219E203C9CA67EEF1DF5A7E23509FD9B", - "code": 0, - "logs": [] -} - -$ secretcli query compute list-contract-by-code 1 -[ - { - "contract_address": "secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf", - "code_id": 1, - "label": "subContract", - "creator": "secret1msmqzrp8ahvwe2jzk9n0xula0vnmv7vt3883y8" - } -] +secretcli query compute list-contract-by-code ``` --- @@ -170,11 +129,13 @@ $ secretcli query compute list-contract-by-code 1 **Description**: Register a subscriber using their public key. Only the admin can perform this action. #### Command + ```bash secretcli tx compute execute '{"register_subscriber":{"public_key":"subscriber_pub_key"}}' --from myWallet -y ``` #### Example + ```bash $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"register_subscriber":{"public_key":"subscriber_pub_key"}}' --from myWallet -y { @@ -192,11 +153,13 @@ $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{" **Description**: Check if a subscriber is active or not. #### Command + ```bash secretcli query compute query '{"subscriber_status":{"public_key":"subscriber_pub_key"}}' ``` #### Example + ```bash $ secretcli query compute query secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"subscriber_status":{"public_key":"subscriber_pub_key"}}' { @@ -211,11 +174,13 @@ $ secretcli query compute query secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{ **Description**: Remove a subscriber using their public key. Only the admin can perform this action. #### Command + ```bash secretcli tx compute execute '{"remove_subscriber":{"public_key":"subscriber_pub_key"}}' --from myWallet -y ``` #### Example + ```bash $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"remove_subscriber":{"public_key":"subscriber_pub_key"}}' --from myWallet -y { @@ -233,11 +198,13 @@ $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{" **Description**: Update the admin to a new public key. Only the current admin can perform this action. #### Command + ```bash secretcli tx compute execute '{"set_admin":{"public_key":"new_admin_pub_key"}}' --from myWallet -y ``` #### Example + ```bash $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"set_admin":{"public_key":"secret1qvapn5ns28xrevn7kdudwvrp6a4fven2kzq8jc"}}' --from myWallet -y { @@ -252,14 +219,16 @@ $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{" ### Use Case 5: Add an API Key -**Description**: Add a new API key for access control. Only the admin can perform this action. +**Description**: Add a new API key for access control. Only the admin can perform this action. The API key is hashed using SHA-256 before storage. #### Command + ```bash secretcli tx compute execute '{"add_api_key":{"api_key":"new_api_key"}}' --from myWallet -y ``` #### Example + ```bash $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"add_api_key":{"api_key":"test_api_key"}}' --from myWallet -y { @@ -274,14 +243,16 @@ $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{" ### Use Case 6: Revoke an API Key -**Description**: Revoke an existing API key. Only the admin can perform this action. +**Description**: Revoke an existing API key. Only the admin can perform this action. The API key must be provided in plaintext, and the contract verifies its hash before removal. #### Command + ```bash secretcli tx compute execute '{"revoke_api_key":{"api_key":"api_key_to_revoke"}}' --from myWallet -y ``` #### Example + ```bash $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"revoke_api_key":{"api_key":"test_api_key"}}' { @@ -294,22 +265,25 @@ $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{" --- -### Use Case 7: Query All API Keys +### Use Case 7: Query API Keys with Permit -**Description**: Retrieve the list of all registered API keys. Only available through querying the contract. +**Description**: Retrieve the list of all registered API keys. Requires a valid permit signed by the admin. #### Command + ```bash -secretcli query compute query '{"api_keys":{}}' +secretcli query compute query '{"api_keys_with_permit":{"permit":}}' ``` #### Example + ```bash -$ secretcli query compute query secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"api_keys":{}}' +$ secretcli query compute query secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"api_keys_with_permit":{"permit":}}' { "api_keys": [ - { "key": "test_api_key1" }, - { "key": "test_api_key2" } + { "hashed_key": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, + { "hashed_key": "d14a028c2a3a2bc9476102bb288234c415a2b01f828ea62ab16f40c07b5a79a5" } ] } -``` \ No newline at end of file +``` + diff --git a/subscription-manager/src/contract.rs b/subscription-manager/src/contract.rs index 835f230..584b5da 100644 --- a/subscription-manager/src/contract.rs +++ b/subscription-manager/src/contract.rs @@ -1,6 +1,7 @@ use cosmwasm_std::{ entry_point, to_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdError, StdResult }; +use secret_toolkit::permit::{validate, Permit}; use sha2::{Digest, Sha256}; use crate::msg::{ExecuteMsg, GetApiKeysResponse, InstantiateMsg, MigrateMsg, QueryMsg, SubscriberStatusResponse}; use crate::state::{config, config_read, ApiKey, State, Subscriber, API_KEY_MAP, SB_MAP}; @@ -50,7 +51,6 @@ pub fn execute( } } -// Function to add a new API key pub fn try_add_api_key( deps: DepsMut, info: MessageInfo, @@ -64,22 +64,33 @@ pub fn try_add_api_key( return Err(StdError::generic_err("Only admin can add API keys")); } - // Check if the API key already exists - if API_KEY_MAP.contains(deps.storage, &api_key) { - return Err(StdError::generic_err("API key already exists")); + // 1. Compute the hash of the provided api_key + let mut hasher = Sha256::new(); + hasher.update(api_key.as_bytes()); + let key_hash = hex::encode(hasher.finalize()); + // This is a hex-encoded string of 64 hex characters. + + // 2. Check if this hash already exists + if API_KEY_MAP.contains(deps.storage, &key_hash) { + return Err(StdError::generic_err("API key (hash) already exists")); } - // Insert the API key into the map - let api_key_data = ApiKey { key: api_key.clone() }; - API_KEY_MAP.insert(deps.storage, &api_key, &api_key_data) + // 3. Insert the hash into the map + let api_key_data = ApiKey { + // We store the hash in the `key` field + hashed_key: key_hash.clone(), + }; + API_KEY_MAP + .insert(deps.storage, &key_hash, &api_key_data) .map_err(|err| StdError::generic_err(err.to_string()))?; + // For the response, we might *not* want to reveal the hash in events (up to you). + // But we'll do it here for illustration. Ok(Response::new() .add_attribute("action", "add_api_key") - .add_attribute("api_key", api_key)) + .add_attribute("stored_hash", key_hash)) } -// Function to revoke an API key pub fn try_revoke_api_key( deps: DepsMut, info: MessageInfo, @@ -93,18 +104,25 @@ pub fn try_revoke_api_key( return Err(StdError::generic_err("Only admin can revoke API keys")); } - // Check if the API key exists - if !API_KEY_MAP.contains(deps.storage, &api_key) { - return Err(StdError::generic_err("API key not found")); + // 1. Compute the hash again + let mut hasher = Sha256::new(); + hasher.update(api_key.as_bytes()); + let key_hash = hex::encode(hasher.finalize()); + + // 2. Check if this hash is in storage + if !API_KEY_MAP.contains(deps.storage, &key_hash) { + return Err(StdError::generic_err("API key (hash) not found")); } - // Remove the API key from the map - API_KEY_MAP.remove(deps.storage, &api_key) + // 3. Remove the entry + API_KEY_MAP + .remove(deps.storage, &key_hash) .map_err(|err| StdError::generic_err(err.to_string()))?; + // Return a response Ok(Response::new() .add_attribute("action", "revoke_api_key") - .add_attribute("api_key", api_key)) + .add_attribute("removed_hash", key_hash)) } #[entry_point] @@ -205,7 +223,7 @@ pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { match msg { // Handle query for subscriber status QueryMsg::SubscriberStatus { public_key } => to_binary(&query_subscriber(deps, public_key)?), - QueryMsg::ApiKeys {} => to_binary(&query_api_keys(deps)?), + QueryMsg::ApiKeysWithPermit { permit } => to_binary(&query_api_keys_with_permit(deps, _env, permit)?), } } @@ -224,12 +242,53 @@ fn query_subscriber( Ok(SubscriberStatusResponse { active: false }) } -// Function to query all API keys -pub fn query_api_keys(deps: Deps) -> StdResult { +/// Validates the permit and, if valid and signed by the admin, returns all API keys +fn query_api_keys_with_permit( + deps: Deps, + env: Env, + permit: Permit, +) -> StdResult { + // 1. Read current admin from contract state + let state = config_read(deps.storage).load()?; + let admin_addr = state.admin; // e.g. "secret1xyz..." + + // 2. Convert our contract address to `HumanAddr` (if needed by validate) + // Some validate methods require the "current_token_address" or similar. + // In many SNIP-20 references, the "current_token_address" is just the + // contract address itself, because you typically check that + // permit.params.allowed_tokens includes this contract. + let current_token_address = env.contract.address; + + // 3. storage_prefix is the prefix in storage for revoked permits (if used). + // Typically something like "permits" or "revoke_permits". + let storage_prefix = "permits_api_keys"; + + // 4. Validate the permit + // This should check: + // - The signature is correct + // - The permit has not been revoked + // - The contract address is in `allowed_tokens` (if you require that) + // + // In your snippet, `validate` returns the signer's bech32 address + // if the signature is valid, or an error otherwise. + let signer_addr = validate( + deps, + storage_prefix, + &permit, + current_token_address.into_string(), + Some("secret"), // The HRP, e.g. "secret", "cosmos", etc. + )?; + + // 5. Check if the signer is actually the admin + if signer_addr != admin_addr.to_string() { + return Err(StdError::generic_err("Unauthorized: not the admin")); + } + + // 6. Collect and return all stored API keys let api_keys: Vec = API_KEY_MAP .iter(deps.storage)? - .filter_map(|x| { - if let Ok((_, api_key)) = x { + .filter_map(|maybe_kv| { + if let Ok((_, api_key)) = maybe_kv { Some(api_key) } else { None @@ -241,52 +300,142 @@ pub fn query_api_keys(deps: Deps) -> StdResult { } #[cfg(test)] mod tests { + use std::fs; use super::*; use cosmwasm_std::testing::*; - use cosmwasm_std::{attr, from_binary, Api, Coin, Uint128}; - use secp256k1::{Message, PublicKey, Secp256k1, ecdsa::Signature, SecretKey}; + use cosmwasm_std::{attr, from_binary, Api, BlockInfo, Coin, ContractInfo, Timestamp, TransactionInfo, Uint128}; #[test] - fn add_and_query_api_keys() { + fn test_query_api_keys_with_real_permit() { + // 1. Initialize the contract with admin = "secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4" let mut deps = mock_dependencies(); - let info = mock_info("admin", &[]); + let info = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); let init_msg = InstantiateMsg {}; - instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); - let add_msg = ExecuteMsg::AddApiKey { - api_key: "test_api_key".to_string(), + // Create a custom Env if you need specific block/transaction data + let mut _env = Env { + block: BlockInfo { + height: 12_345, + time: Timestamp::from_nanos(1_571_797_419_879_305_533), + chain_id: "pulsar-3".to_string(), + random: Some( + Binary::from_base64("wLsKdf/sYqvSMI0G0aWRjob25mrIB0VQVjTjDXnDafk=").unwrap(), + ), + }, + transaction: Some(TransactionInfo { + index: 3, + hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_string(), + }), + contract: ContractInfo { + address: Addr::unchecked("secret1ttm9axv8hqwjv3qxvxseecppsrw4cd68getrvr"), + code_hash: "".to_string(), + }, }; - execute(deps.as_mut(), mock_env(), info.clone(), add_msg).unwrap(); - let query_msg = QueryMsg::ApiKeys {}; - let res = query(deps.as_ref(), mock_env(), query_msg).unwrap(); - let response: GetApiKeysResponse = from_binary(&res).unwrap(); - - assert_eq!(response.api_keys, vec![ApiKey { key: "test_api_key".to_string() }]); + // Instantiate the contract + instantiate(deps.as_mut(), _env.clone(), info.clone(), init_msg).unwrap(); + + // 2. Add a test API key so we can verify it during the query + execute( + deps.as_mut(), + _env.clone(), + info.clone(), + ExecuteMsg::AddApiKey { + api_key: "test_key1".to_string(), + }, + ) + .unwrap(); + + // 3. Read the permit from a file (e.g., "./permit.json"). + // This JSON should be a properly signed permit (StdSignDoc + signature), + // or a directly "cleaned" JSON that matches secret_toolkit::permit::Permit. + let json_data = std::fs::read_to_string("./permit.json").unwrap(); + let permit: Permit = serde_json::from_str(&json_data) + .expect("Could not parse Permit from JSON"); + + // 4. Query the contract using the permit + let query_msg = QueryMsg::ApiKeysWithPermit { permit }; + println!("Query_msg: {:#?}", query_msg); + let res = query(deps.as_ref(), _env.clone(), query_msg); + + // 5. Check the response to ensure the API key is returned + match res { + Ok(bin) => { + let parsed: GetApiKeysResponse = from_binary(&bin).unwrap(); + // We expect exactly 1 API key: "test_key1" + assert_eq!(parsed.api_keys.len(), 1); + println!("Response: {:#?}", parsed); + } + Err(e) => panic!("Query failed: {:?}", e), + } } #[test] - fn revoke_api_key() { + fn revoke_api_key_and_query_with_permit() { + // 1. Initialize the contract with some admin address let mut deps = mock_dependencies(); - let info = mock_info("admin", &[]); + // Suppose "admin" is just a placeholder address (like "secret1abc...") + let info = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); let init_msg = InstantiateMsg {}; - instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + // Create a custom Env if you need specific block/transaction data + let mut _env = Env { + block: BlockInfo { + height: 12_345, + time: Timestamp::from_nanos(1_571_797_419_879_305_533), + chain_id: "pulsar-3".to_string(), + random: Some( + Binary::from_base64("wLsKdf/sYqvSMI0G0aWRjob25mrIB0VQVjTjDXnDafk=").unwrap(), + ), + }, + transaction: Some(TransactionInfo { + index: 3, + hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_string(), + }), + contract: ContractInfo { + address: Addr::unchecked("secret1ttm9axv8hqwjv3qxvxseecppsrw4cd68getrvr"), + code_hash: "".to_string(), + }, + }; + + instantiate(deps.as_mut(), _env.clone(), info.clone(), init_msg).unwrap(); + + // 2. Add an API key let add_msg = ExecuteMsg::AddApiKey { api_key: "test_api_key".to_string(), }; - execute(deps.as_mut(), mock_env(), info.clone(), add_msg).unwrap(); + execute(deps.as_mut(), _env.clone(), info.clone(), add_msg).unwrap(); + // 3. Revoke (remove) that API key let revoke_msg = ExecuteMsg::RevokeApiKey { api_key: "test_api_key".to_string(), }; - execute(deps.as_mut(), mock_env(), info.clone(), revoke_msg).unwrap(); - - let query_msg = QueryMsg::ApiKeys {}; - let res = query(deps.as_ref(), mock_env(), query_msg).unwrap(); + execute(deps.as_mut(), _env.clone(), info.clone(), revoke_msg).unwrap(); + + // 4. Now load a real signed Permit from file (as in your `test_query_api_keys_with_real_permit`) + // This permit must be signed by the same admin address in order to pass validation. + let json_data = std::fs::read_to_string("./permit.json") + .expect("Failed to read permit.json"); + let permit: secret_toolkit::permit::Permit = serde_json::from_str(&json_data) + .expect("Could not parse Permit from JSON"); + + // 5. Perform a query that uses the permit + // This calls your existing `ApiKeysWithPermit { permit }` query + let query_msg = QueryMsg::ApiKeysWithPermit { permit }; + let res = query(deps.as_ref(), _env.clone(), query_msg) + .expect("Query failed unexpectedly"); + + // 6. Verify that the revoked key is no longer in the list let response: GetApiKeysResponse = from_binary(&res).unwrap(); + assert!( + response.api_keys.is_empty(), + "Expected empty API keys after revoke, got: {:?}", + response.api_keys + ); - assert!(response.api_keys.is_empty()); + println!("Revoke test passed. 'test_api_key' is no longer in the list."); } #[test] diff --git a/subscription-manager/src/msg.rs b/subscription-manager/src/msg.rs index 586600d..65f5560 100644 --- a/subscription-manager/src/msg.rs +++ b/subscription-manager/src/msg.rs @@ -1,6 +1,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::state::ApiKey; +use secret_toolkit::permit::Permit; + // Struct for the message used to instantiate the contract #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] @@ -39,7 +41,10 @@ pub enum QueryMsg { SubscriberStatus { public_key: String, }, - ApiKeys {}, + /// Query API keys using a permit (only the admin's permit will succeed) + ApiKeysWithPermit { + permit: Permit, + }, } // Struct used to respond to a query about a subscriber's status diff --git a/subscription-manager/src/state.rs b/subscription-manager/src/state.rs index a1d7f8f..0ffbeda 100644 --- a/subscription-manager/src/state.rs +++ b/subscription-manager/src/state.rs @@ -30,11 +30,11 @@ pub struct Subscriber { pub public_key: String, } -// Structure representing an API key #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] pub struct ApiKey { - // API key value - pub key: String, + // Previously `key: String`, + // Maybe rename to `hash: String` or `hashed_key: String`. + pub hashed_key: String, } // Function to access and modify the configuration state From 0f0e4099ee57ab335a7de5a0cba490ac9070d2b1 Mon Sep 17 00:00:00 2001 From: DeDTihoN Date: Fri, 10 Jan 2025 16:31:54 +0200 Subject: [PATCH 11/17] migrate clears api keys --- .../claive_subscription_manager.wasm.gz | Bin 0 -> 95550 bytes subscription-manager/src/contract.rs | 80 +++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 subscription-manager/optimized-wasm/claive_subscription_manager.wasm.gz diff --git a/subscription-manager/optimized-wasm/claive_subscription_manager.wasm.gz b/subscription-manager/optimized-wasm/claive_subscription_manager.wasm.gz new file mode 100644 index 0000000000000000000000000000000000000000..167473e4ba4f0cb2e4ec74de88484c159e1b48db GIT binary patch literal 95550 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%0DwU$M*qoX_g+}qmqB9tBHM*_ezEn zR}yU@!yvQd8n(Aewj8*G%(~ZdnzFRL32AfAxjQ=tX>$ewhjS1lqH%0A8TGURG%lO$ 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 z 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 F{{c}w3t|8O literal 0 HcmV?d00001 diff --git a/subscription-manager/src/contract.rs b/subscription-manager/src/contract.rs index 584b5da..67f8ad9 100644 --- a/subscription-manager/src/contract.rs +++ b/subscription-manager/src/contract.rs @@ -126,9 +126,29 @@ pub fn try_revoke_api_key( } #[entry_point] -pub fn migrate(_deps: DepsMut, _env: Env, msg: MigrateMsg) -> StdResult { +pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> StdResult { match msg { - MigrateMsg::Migrate {} => Ok(Response::default()), + MigrateMsg::Migrate {} => { + // Collect all keys from the API_KEY_MAP into a vector + // This avoids conflicts between mutable and immutable borrows of `deps.storage` + let keys_to_remove: Vec = API_KEY_MAP + .iter(deps.storage)? // Retrieve the iterator over the keymap + .filter_map(|item| match item { + Ok((key, _)) => Some(key), // Extract the key if the item is valid + Err(_) => None, // Skip any errors + }) + .collect(); + + // Remove each key from the API_KEY_MAP + for key in keys_to_remove { + API_KEY_MAP.remove(deps.storage, &key)?; // Remove the key from the storage + } + + // Return a response indicating successful migration + Ok(Response::new() + .add_attribute("action", "migrate") + .add_attribute("status", "api_key_map_cleared")) + } MigrateMsg::StdError {} => Err(StdError::generic_err("this is an std error")), } } @@ -305,6 +325,62 @@ mod tests { use cosmwasm_std::testing::*; use cosmwasm_std::{attr, from_binary, Api, BlockInfo, Coin, ContractInfo, Timestamp, TransactionInfo, Uint128}; + #[test] + fn test_migrate_clears_api_key_map() { + let mut deps = mock_dependencies(); + + // Initialize the contract with an admin address + let info = mock_info("admin", &[]); + let init_msg = InstantiateMsg {}; + instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + + // Add API keys to the `API_KEY_MAP` + execute( + deps.as_mut(), + mock_env(), + info.clone(), + ExecuteMsg::AddApiKey { + api_key: "test_key1".to_string(), + }, + ) + .unwrap(); + + execute( + deps.as_mut(), + mock_env(), + info.clone(), + ExecuteMsg::AddApiKey { + api_key: "test_key2".to_string(), + }, + ) + .unwrap(); + + // Ensure that the keys were added successfully + let keys: Vec = API_KEY_MAP + .iter(deps.as_ref().storage) // Retrieve the Result from the keymap iterator + .unwrap() // Unwrap the Result to access the iterator + .filter_map(|kv| kv.ok().map(|(_, v)| v)) // Filter and map the keys into a vector + .collect(); + assert_eq!(keys.len(), 2); // Assert that there are two keys in the map + + println!("keys before migrate: {:#?}", keys); + + // Perform the migration, which should clear the API_KEY_MAP + migrate(deps.as_mut(), mock_env(), MigrateMsg::Migrate {}).unwrap(); + + // Check that the API_KEY_MAP is now empty + let keys_after_migrate: Vec = API_KEY_MAP + .iter(deps.as_ref().storage) // Retrieve the Result from the keymap iterator + .unwrap() // Unwrap the Result to access the iterator + .filter_map(|kv| kv.ok().map(|(_, v)| v)) // Filter and map the keys into a vector + .collect(); + + println!("keys after migrate: {:#?}", keys_after_migrate); + + // Assert that no keys remain in the map + assert!(keys_after_migrate.is_empty()); + } + #[test] fn test_query_api_keys_with_real_permit() { // 1. Initialize the contract with admin = "secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4" From eca0b07a0eb510637dace088998f464673ddcbd5 Mon Sep 17 00:00:00 2001 From: DeDTihoN Date: Wed, 15 Jan 2025 16:01:58 +0200 Subject: [PATCH 12/17] subscriber_status with permit and get_admin message update --- subscription-manager/.gitignore | 5 +- subscription-manager/Readme.md | 13 +- .../claive_subscription_manager.wasm.gz | Bin 95550 -> 0 bytes subscription-manager/src/contract.rs | 356 +++++++++++------- subscription-manager/src/msg.rs | 17 +- subscription-manager/src/state.rs | 11 +- 6 files changed, 238 insertions(+), 164 deletions(-) delete mode 100644 subscription-manager/optimized-wasm/claive_subscription_manager.wasm.gz diff --git a/subscription-manager/.gitignore b/subscription-manager/.gitignore index 290b2ec..4ac2644 100644 --- a/subscription-manager/.gitignore +++ b/subscription-manager/.gitignore @@ -3,4 +3,7 @@ Dockerfile permit.json signed_permit.json -claive_subscription_manager.wasm \ No newline at end of file +claive_subscription_manager.wasm +permit_to_sign.json +query_subscriber_permit.json +api_keys_permit.json \ No newline at end of file diff --git a/subscription-manager/Readme.md b/subscription-manager/Readme.md index 3fa52d1..94d9a6b 100644 --- a/subscription-manager/Readme.md +++ b/subscription-manager/Readme.md @@ -29,8 +29,9 @@ The contract stores: - `RevokeApiKey`: Revokes an existing API key. The API key must be provided in plaintext, and the contract verifies its hash. Only callable by the admin. 3. **Query** - - `SubscriberStatus`: Checks if a subscriber with the given public key is active. - - `ApiKeysWithPermit`: Returns a list of all registered API keys. The query requires a valid permit signed by the admin to ensure secure access. + - `SubscriberStatusWithPermit`: Checks if a subscriber with the given public key is active. Requires a valid permit signed by the admin. + - `ApiKeysWithPermit`: Returns a list of all registered API keys. Requires a valid permit signed by the admin to ensure secure access. + - `GetAdmin`: Returns the current admin address. --- @@ -148,20 +149,20 @@ $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{" --- -### Use Case 2: Query Subscriber Status +### Use Case 2: Query Subscriber Status with Permit -**Description**: Check if a subscriber is active or not. +**Description**: Check if a subscriber is active or not. Requires a valid permit signed by the admin. #### Command ```bash -secretcli query compute query '{"subscriber_status":{"public_key":"subscriber_pub_key"}}' +secretcli query compute query '{"subscriber_status_with_permit":{"public_key":"subscriber_pub_key","permit":}}' ``` #### Example ```bash -$ secretcli query compute query secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"subscriber_status":{"public_key":"subscriber_pub_key"}}' +$ secretcli query compute query secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"subscriber_status_with_permit":{"public_key":"subscriber_pub_key","permit":}}' { "active": true } diff --git a/subscription-manager/optimized-wasm/claive_subscription_manager.wasm.gz b/subscription-manager/optimized-wasm/claive_subscription_manager.wasm.gz deleted file mode 100644 index 167473e4ba4f0cb2e4ec74de88484c159e1b48db..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 95550 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%0DwU$M*qoX_g+}qmqB9tBHM*_ezEn zR}yU@!yvQd8n(Aewj8*G%(~ZdnzFRL32AfAxjQ=tX>$ewhjS1lqH%0A8TGURG%lO$ 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 z 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 F{{c}w3t|8O diff --git a/subscription-manager/src/contract.rs b/subscription-manager/src/contract.rs index 67f8ad9..102cf82 100644 --- a/subscription-manager/src/contract.rs +++ b/subscription-manager/src/contract.rs @@ -3,7 +3,7 @@ use cosmwasm_std::{ }; use secret_toolkit::permit::{validate, Permit}; use sha2::{Digest, Sha256}; -use crate::msg::{ExecuteMsg, GetApiKeysResponse, InstantiateMsg, MigrateMsg, QueryMsg, SubscriberStatusResponse}; +use crate::msg::{ApiKeyResponse, ExecuteMsg, GetApiKeysResponse, InstantiateMsg, MigrateMsg, QueryMsg, SubscriberStatusResponse}; use crate::state::{config, config_read, ApiKey, State, Subscriber, API_KEY_MAP, SB_MAP}; // Entry point for contract initialization @@ -76,10 +76,7 @@ pub fn try_add_api_key( } // 3. Insert the hash into the map - let api_key_data = ApiKey { - // We store the hash in the `key` field - hashed_key: key_hash.clone(), - }; + let api_key_data = ApiKey {}; API_KEY_MAP .insert(deps.storage, &key_hash, &api_key_data) .map_err(|err| StdError::generic_err(err.to_string()))?; @@ -129,22 +126,17 @@ pub fn try_revoke_api_key( pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> StdResult { match msg { MigrateMsg::Migrate {} => { - // Collect all keys from the API_KEY_MAP into a vector - // This avoids conflicts between mutable and immutable borrows of `deps.storage` + // Collect all keys using `iter_keys` let keys_to_remove: Vec = API_KEY_MAP - .iter(deps.storage)? // Retrieve the iterator over the keymap - .filter_map(|item| match item { - Ok((key, _)) => Some(key), // Extract the key if the item is valid - Err(_) => None, // Skip any errors - }) + .iter_keys(deps.storage)? + .filter_map(|key_result| key_result.ok()) .collect(); - // Remove each key from the API_KEY_MAP + // Remove each key for key in keys_to_remove { - API_KEY_MAP.remove(deps.storage, &key)?; // Remove the key from the storage + API_KEY_MAP.remove(deps.storage, &key)?; } - // Return a response indicating successful migration Ok(Response::new() .add_attribute("action", "migrate") .add_attribute("status", "api_key_map_cleared")) @@ -155,75 +147,75 @@ pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> StdResult // Function to register a new subscriber pub fn try_register_subscriber( - _deps: DepsMut, - _info: MessageInfo, - _public_key: String, + deps: DepsMut, + info: MessageInfo, + public_key: String, ) -> StdResult { // Check if the sender is the admin - let config = config_read(_deps.storage); + let config = config_read(deps.storage); let state = config.load()?; - if _info.sender != state.admin { + if info.sender != state.admin { return Err(StdError::generic_err("Only admin can register subscribers")); } // Check if the subscriber is already registered - let map_contains_sb = SB_MAP.contains(_deps.storage, &_public_key); + let map_contains_sb = SB_MAP.contains(deps.storage, &public_key); if map_contains_sb { return Err(StdError::generic_err("Subscriber already registered")); } // Create a new subscriber and insert it into the map - let subscriber = Subscriber { public_key: _public_key.clone(), status: true }; - SB_MAP.insert(_deps.storage, &_public_key, &subscriber) + let subscriber = Subscriber { status: true }; + SB_MAP.insert(deps.storage, &public_key, &subscriber) .map_err(|err| StdError::generic_err(err.to_string()))?; // Return a response indicating successful registration Ok(Response::new() .add_attribute("action", "register_subscriber") - .add_attribute("subscriber", _public_key)) + .add_attribute("subscriber", public_key)) } // Function to remove a subscriber pub fn try_remove_subscriber( - _deps: DepsMut, - _info: MessageInfo, - _public_key: String, + deps: DepsMut, + info: MessageInfo, + public_key: String, ) -> StdResult { // Check if the sender is the admin - let config = config_read(_deps.storage); + let config = config_read(deps.storage); let state = config.load()?; - if _info.sender != state.admin { + if info.sender != state.admin { return Err(StdError::generic_err("Only admin can remove subscribers")); } // Check if the subscriber is registered - let map_contains_sb = SB_MAP.contains(_deps.storage, &_public_key); + let map_contains_sb = SB_MAP.contains(deps.storage, &public_key); if !map_contains_sb { return Err(StdError::generic_err("Subscriber not registered")); } // Remove the subscriber from the map - SB_MAP.remove(_deps.storage, &_public_key) + SB_MAP.remove(deps.storage, &public_key) .map_err(|err| StdError::generic_err(err.to_string()))?; // Return a response indicating successful removal Ok(Response::new() .add_attribute("action", "remove_subscriber") - .add_attribute("subscriber", _public_key)) + .add_attribute("subscriber", public_key)) } // Function to set a new admin -pub fn try_set_admin(_deps: DepsMut, _info: MessageInfo, _public_key: String) -> StdResult { - let mut config = config(_deps.storage); +pub fn try_set_admin(deps: DepsMut, info: MessageInfo, public_key: String) -> StdResult { + let mut config = config(deps.storage); let mut state = config.load()?; // Check if the sender is the current admin - if _info.sender != state.admin { + if info.sender != state.admin { return Err(StdError::generic_err("Only the current admin can set a new admin")); } // Validate the new admin's public key - let final_address = _deps.api.addr_validate(&_public_key).map_err(|err| { + let final_address = deps.api.addr_validate(&public_key).map_err(|err| { StdError::generic_err(format!("Invalid address: {}", err)) })?; @@ -234,32 +226,66 @@ pub fn try_set_admin(_deps: DepsMut, _info: MessageInfo, _public_key: String) -> // Return a response indicating successful admin update Ok(Response::new() .add_attribute("action", "set_admin") - .add_attribute("new_admin", _public_key)) + .add_attribute("new_admin", public_key)) } // Entry point for handling queries #[entry_point] -pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { match msg { - // Handle query for subscriber status - QueryMsg::SubscriberStatus { public_key } => to_binary(&query_subscriber(deps, public_key)?), - QueryMsg::ApiKeysWithPermit { permit } => to_binary(&query_api_keys_with_permit(deps, _env, permit)?), + QueryMsg::SubscriberStatusWithPermit { public_key, permit } => { + to_binary(&query_subscriber_with_permit(deps, env, public_key, permit)?) + } + QueryMsg::GetAdmin {} => to_binary(&get_admin(deps)?), + QueryMsg::ApiKeysWithPermit { permit } => { + to_binary(&query_api_keys_with_permit(deps, env, permit)?) + } } } +// Function to get the current admin +fn get_admin(deps: Deps) -> StdResult { + let state = config_read(deps.storage).load()?; + Ok(state.admin) +} + // Function to check if a subscriber is active -fn query_subscriber( - _deps: Deps, - _public_key: String, +fn query_subscriber_with_permit( + deps: Deps, + env: Env, + public_key: String, + permit: Permit, ) -> StdResult { - // Check if the subscriber exists in the map - let subscriber = SB_MAP.get(_deps.storage, &_public_key); - if !subscriber.is_none() { - return Ok(SubscriberStatusResponse { active: true }); + // 1. Read current admin from contract state + let state = config_read(deps.storage).load()?; + let admin_addr = state.admin; + + // Validate permit name + if permit.params.permit_name != "query_subscriber_permit" { + return Err(StdError::generic_err("Invalid permit name")); + } + + // 2. Validate the permit + let contract_address = env.contract.address; + let storage_prefix = "permits_subscriber_status"; + let signer_addr = validate( + deps, + storage_prefix, + &permit, + contract_address.into_string(), + Some("secret"), + )?; + + // 3. Check if the signer is actually the admin + if signer_addr != admin_addr { + return Err(StdError::generic_err("Unauthorized: not the admin")); } - // Return false if the subscriber is not found - Ok(SubscriberStatusResponse { active: false }) + // 4. Check if the subscriber exists + let subscriber = SB_MAP.get(deps.storage, &public_key); + let active = subscriber.is_some(); + + Ok(SubscriberStatusResponse { active }) } /// Validates the permit and, if valid and signed by the admin, returns all API keys @@ -272,12 +298,17 @@ fn query_api_keys_with_permit( let state = config_read(deps.storage).load()?; let admin_addr = state.admin; // e.g. "secret1xyz..." + // Validate permit name + if permit.params.permit_name != "api_keys_permit" { + return Err(StdError::generic_err("Invalid permit name")); + } + // 2. Convert our contract address to `HumanAddr` (if needed by validate) - // Some validate methods require the "current_token_address" or similar. - // In many SNIP-20 references, the "current_token_address" is just the + // Some validate methods require the "contract_address" or similar. + // In many SNIP-20 references, the "contract_address" is just the // contract address itself, because you typically check that // permit.params.allowed_tokens includes this contract. - let current_token_address = env.contract.address; + let contract_address = env.contract.address; // 3. storage_prefix is the prefix in storage for revoked permits (if used). // Typically something like "permits" or "revoke_permits". @@ -291,27 +322,31 @@ fn query_api_keys_with_permit( // // In your snippet, `validate` returns the signer's bech32 address // if the signature is valid, or an error otherwise. + let signer_addr = validate( deps, storage_prefix, &permit, - current_token_address.into_string(), + contract_address.into_string(), Some("secret"), // The HRP, e.g. "secret", "cosmos", etc. )?; // 5. Check if the signer is actually the admin - if signer_addr != admin_addr.to_string() { + if signer_addr != admin_addr { return Err(StdError::generic_err("Unauthorized: not the admin")); } - // 6. Collect and return all stored API keys - let api_keys: Vec = API_KEY_MAP - .iter(deps.storage)? - .filter_map(|maybe_kv| { - if let Ok((_, api_key)) = maybe_kv { - Some(api_key) + // 4. Use `iter_keys` to construct `ApiKeyResponse` directly from the keys + let api_keys: Vec = API_KEY_MAP + .iter_keys(deps.storage)? // Iterate over the keys + .filter_map(|key_result| { + if let Ok(key) = key_result { + // Construct `ApiKeyResponse` from the key + Some(ApiKeyResponse { + hashed_key: key, + }) } else { - None + None // Skip invalid keys } }) .collect(); @@ -320,10 +355,32 @@ fn query_api_keys_with_permit( } #[cfg(test)] mod tests { - use std::fs; use super::*; use cosmwasm_std::testing::*; - use cosmwasm_std::{attr, from_binary, Api, BlockInfo, Coin, ContractInfo, Timestamp, TransactionInfo, Uint128}; + use cosmwasm_std::{attr, from_binary, BlockInfo, Coin, ContractInfo, Timestamp, TransactionInfo, Uint128}; + + fn mock_env_for_permit() -> Env { + let env = Env { + block: BlockInfo { + height: 12_345, + time: Timestamp::from_nanos(1_571_797_419_879_305_533), + chain_id: "pulsar-3".to_string(), + random: Some( + Binary::from_base64("wLsKdf/sYqvSMI0G0aWRjob25mrIB0VQVjTjDXnDafk=").unwrap(), + ), + }, + transaction: Some(TransactionInfo { + index: 3, + hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_string(), + }), + contract: ContractInfo { + address: Addr::unchecked("secret1ttm9axv8hqwjv3qxvxseecppsrw4cd68getrvr"), + code_hash: "".to_string(), + }, + }; + env + } #[test] fn test_migrate_clears_api_key_map() { @@ -356,29 +413,23 @@ mod tests { .unwrap(); // Ensure that the keys were added successfully - let keys: Vec = API_KEY_MAP - .iter(deps.as_ref().storage) // Retrieve the Result from the keymap iterator - .unwrap() // Unwrap the Result to access the iterator - .filter_map(|kv| kv.ok().map(|(_, v)| v)) // Filter and map the keys into a vector + let keys: Vec = API_KEY_MAP + .iter_keys(deps.as_ref().storage) + .unwrap() + .filter_map(|key_result| key_result.ok()) .collect(); - assert_eq!(keys.len(), 2); // Assert that there are two keys in the map - - println!("keys before migrate: {:#?}", keys); + assert_eq!(keys.len(), 2); - // Perform the migration, which should clear the API_KEY_MAP + // Perform migration migrate(deps.as_mut(), mock_env(), MigrateMsg::Migrate {}).unwrap(); - // Check that the API_KEY_MAP is now empty - let keys_after_migrate: Vec = API_KEY_MAP - .iter(deps.as_ref().storage) // Retrieve the Result from the keymap iterator - .unwrap() // Unwrap the Result to access the iterator - .filter_map(|kv| kv.ok().map(|(_, v)| v)) // Filter and map the keys into a vector + // Ensure the keys are removed + let keys_after_migration: Vec = API_KEY_MAP + .iter_keys(deps.as_ref().storage) + .unwrap() + .filter_map(|key_result| key_result.ok()) .collect(); - - println!("keys after migrate: {:#?}", keys_after_migrate); - - // Assert that no keys remain in the map - assert!(keys_after_migrate.is_empty()); + assert!(keys_after_migration.is_empty()); } #[test] @@ -389,33 +440,15 @@ mod tests { let init_msg = InstantiateMsg {}; // Create a custom Env if you need specific block/transaction data - let mut _env = Env { - block: BlockInfo { - height: 12_345, - time: Timestamp::from_nanos(1_571_797_419_879_305_533), - chain_id: "pulsar-3".to_string(), - random: Some( - Binary::from_base64("wLsKdf/sYqvSMI0G0aWRjob25mrIB0VQVjTjDXnDafk=").unwrap(), - ), - }, - transaction: Some(TransactionInfo { - index: 3, - hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - .to_string(), - }), - contract: ContractInfo { - address: Addr::unchecked("secret1ttm9axv8hqwjv3qxvxseecppsrw4cd68getrvr"), - code_hash: "".to_string(), - }, - }; + let env = mock_env_for_permit(); // Instantiate the contract - instantiate(deps.as_mut(), _env.clone(), info.clone(), init_msg).unwrap(); + instantiate(deps.as_mut(), env.clone(), info.clone(), init_msg).unwrap(); // 2. Add a test API key so we can verify it during the query execute( deps.as_mut(), - _env.clone(), + env.clone(), info.clone(), ExecuteMsg::AddApiKey { api_key: "test_key1".to_string(), @@ -426,14 +459,14 @@ mod tests { // 3. Read the permit from a file (e.g., "./permit.json"). // This JSON should be a properly signed permit (StdSignDoc + signature), // or a directly "cleaned" JSON that matches secret_toolkit::permit::Permit. - let json_data = std::fs::read_to_string("./permit.json").unwrap(); + let json_data = std::fs::read_to_string("./api_keys_permit.json").unwrap(); let permit: Permit = serde_json::from_str(&json_data) .expect("Could not parse Permit from JSON"); // 4. Query the contract using the permit let query_msg = QueryMsg::ApiKeysWithPermit { permit }; println!("Query_msg: {:#?}", query_msg); - let res = query(deps.as_ref(), _env.clone(), query_msg); + let res = query(deps.as_ref(), env.clone(), query_msg); // 5. Check the response to ensure the API key is returned match res { @@ -456,43 +489,25 @@ mod tests { let init_msg = InstantiateMsg {}; // Create a custom Env if you need specific block/transaction data - let mut _env = Env { - block: BlockInfo { - height: 12_345, - time: Timestamp::from_nanos(1_571_797_419_879_305_533), - chain_id: "pulsar-3".to_string(), - random: Some( - Binary::from_base64("wLsKdf/sYqvSMI0G0aWRjob25mrIB0VQVjTjDXnDafk=").unwrap(), - ), - }, - transaction: Some(TransactionInfo { - index: 3, - hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - .to_string(), - }), - contract: ContractInfo { - address: Addr::unchecked("secret1ttm9axv8hqwjv3qxvxseecppsrw4cd68getrvr"), - code_hash: "".to_string(), - }, - }; + let env = mock_env_for_permit(); - instantiate(deps.as_mut(), _env.clone(), info.clone(), init_msg).unwrap(); + instantiate(deps.as_mut(), env.clone(), info.clone(), init_msg).unwrap(); // 2. Add an API key let add_msg = ExecuteMsg::AddApiKey { api_key: "test_api_key".to_string(), }; - execute(deps.as_mut(), _env.clone(), info.clone(), add_msg).unwrap(); + execute(deps.as_mut(), env.clone(), info.clone(), add_msg).unwrap(); // 3. Revoke (remove) that API key let revoke_msg = ExecuteMsg::RevokeApiKey { api_key: "test_api_key".to_string(), }; - execute(deps.as_mut(), _env.clone(), info.clone(), revoke_msg).unwrap(); + execute(deps.as_mut(), env.clone(), info.clone(), revoke_msg).unwrap(); // 4. Now load a real signed Permit from file (as in your `test_query_api_keys_with_real_permit`) // This permit must be signed by the same admin address in order to pass validation. - let json_data = std::fs::read_to_string("./permit.json") + let json_data = std::fs::read_to_string("./api_keys_permit.json") .expect("Failed to read permit.json"); let permit: secret_toolkit::permit::Permit = serde_json::from_str(&json_data) .expect("Could not parse Permit from JSON"); @@ -500,7 +515,7 @@ mod tests { // 5. Perform a query that uses the permit // This calls your existing `ApiKeysWithPermit { permit }` query let query_msg = QueryMsg::ApiKeysWithPermit { permit }; - let res = query(deps.as_ref(), _env.clone(), query_msg) + let res = query(deps.as_ref(), env.clone(), query_msg) .expect("Query failed unexpectedly"); // 6. Verify that the revoked key is no longer in the list @@ -681,26 +696,56 @@ mod tests { } #[test] - /// Test querying for a registered subscriber, expecting active status - fn query_registered_subscriber() { + fn test_get_admin() { let mut deps = mock_dependencies(); let info = mock_info("admin", &[]); let init_msg = InstantiateMsg {}; instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + let query_msg = QueryMsg::GetAdmin {}; + let bin = query(deps.as_ref(), mock_env(), query_msg).unwrap(); + let response: Addr = from_binary(&bin).unwrap(); + + println!("Response: {:#?}", response); + + assert_eq!(response, Addr::unchecked("admin")); + } + + #[test] + /// Test querying for a registered subscriber, expecting active status + fn query_registered_subscriber() { + // 1. Initialize the contract with some admin address + let mut deps = mock_dependencies(); + // Suppose "admin" is just a placeholder address (like "secret1abc...") + let info = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); + let init_msg = InstantiateMsg {}; + + // Create a custom Env if you need specific block/transaction data + let env = mock_env_for_permit(); + + instantiate(deps.as_mut(), env.clone(), info.clone(), init_msg).unwrap(); + // Register a subscriber let register_msg = ExecuteMsg::RegisterSubscriber { public_key: "subscriber_public_key".to_string(), }; - execute(deps.as_mut(), mock_env(), info, register_msg).unwrap(); + execute(deps.as_mut(), env.clone(), info, register_msg).unwrap(); + + let json_data = std::fs::read_to_string("./query_subscriber_permit.json") + .expect("Failed to read permit.json"); + let permit: secret_toolkit::permit::Permit = serde_json::from_str(&json_data) + .expect("Could not parse Permit from JSON"); // Query for the registered subscriber and check the response - let query_msg = QueryMsg::SubscriberStatus { + let query_msg = QueryMsg::SubscriberStatusWithPermit { public_key: "subscriber_public_key".to_string(), + permit: permit.clone(), }; - let bin = query(deps.as_ref(), mock_env(), query_msg).unwrap(); + let bin = query(deps.as_ref(), env.clone(), query_msg).unwrap(); let response: SubscriberStatusResponse = from_binary(&bin).unwrap(); + println!("Response: {:#?}", response); + // Check that the subscriber is active assert!(response.active); } @@ -708,16 +753,27 @@ mod tests { #[test] /// Test querying for an unregistered subscriber, expecting inactive status fn query_unregistered_subscriber() { + // 1. Initialize the contract with some admin address let mut deps = mock_dependencies(); - let info = mock_info("admin", &[]); + // Suppose "admin" is just a placeholder address (like "secret1abc...") + let info = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); let init_msg = InstantiateMsg {}; - instantiate(deps.as_mut(), mock_env(), info, init_msg).unwrap(); + + // Create a custom Env if you need specific block/transaction data + let env = mock_env_for_permit(); + instantiate(deps.as_mut(), env.clone(), info, init_msg).unwrap(); + + let json_data = std::fs::read_to_string("./query_subscriber_permit.json") + .expect("Failed to read permit.json"); + let permit: secret_toolkit::permit::Permit = serde_json::from_str(&json_data) + .expect("Could not parse Permit from JSON"); // Query for an unregistered subscriber and check the response - let query_msg = QueryMsg::SubscriberStatus { + let query_msg = QueryMsg::SubscriberStatusWithPermit { public_key: "unregistered_public_key".to_string(), + permit: permit.clone(), }; - let bin = query(deps.as_ref(), mock_env(), query_msg).unwrap(); + let bin = query(deps.as_ref(), env.clone(), query_msg).unwrap(); let response: SubscriberStatusResponse = from_binary(&bin).unwrap(); // Check that the subscriber is not active @@ -727,28 +783,40 @@ mod tests { #[test] /// Test querying for a subscriber after removal, expecting inactive status fn query_subscriber_after_removal() { + // 1. Initialize the contract with some admin address let mut deps = mock_dependencies(); - let info = mock_info("admin", &[]); + // Suppose "admin" is just a placeholder address (like "secret1abc...") + let info = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); let init_msg = InstantiateMsg {}; - instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + + // Create a custom Env if you need specific block/transaction data + let env = mock_env_for_permit(); + + instantiate(deps.as_mut(), env.clone(), info.clone(), init_msg).unwrap(); // Register a subscriber let register_msg = ExecuteMsg::RegisterSubscriber { public_key: "subscriber_public_key".to_string(), }; - execute(deps.as_mut(), mock_env(), info.clone(), register_msg).unwrap(); + execute(deps.as_mut(), env.clone(), info.clone(), register_msg).unwrap(); // Remove the subscriber let remove_msg = ExecuteMsg::RemoveSubscriber { public_key: "subscriber_public_key".to_string(), }; - execute(deps.as_mut(), mock_env(), info, remove_msg).unwrap(); + execute(deps.as_mut(), env.clone(), info, remove_msg).unwrap(); + + let json_data = std::fs::read_to_string("./query_subscriber_permit.json") + .expect("Failed to read permit.json"); + let permit: secret_toolkit::permit::Permit = serde_json::from_str(&json_data) + .expect("Could not parse Permit from JSON"); // Query for the subscriber after removal and check the response - let query_msg = QueryMsg::SubscriberStatus { + let query_msg = QueryMsg::SubscriberStatusWithPermit { public_key: "subscriber_public_key".to_string(), + permit: permit.clone(), }; - let bin = query(deps.as_ref(), mock_env(), query_msg).unwrap(); + let bin = query(deps.as_ref(), env.clone(), query_msg).unwrap(); let response: SubscriberStatusResponse = from_binary(&bin).unwrap(); // Check that the subscriber is not active diff --git a/subscription-manager/src/msg.rs b/subscription-manager/src/msg.rs index 65f5560..3017eab 100644 --- a/subscription-manager/src/msg.rs +++ b/subscription-manager/src/msg.rs @@ -1,6 +1,5 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::state::ApiKey; use secret_toolkit::permit::Permit; @@ -37,11 +36,11 @@ pub enum MigrateMsg { #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum QueryMsg { - // Query to check the status of a subscriber using a public key - SubscriberStatus { + SubscriberStatusWithPermit { public_key: String, + permit: Permit, }, - /// Query API keys using a permit (only the admin's permit will succeed) + GetAdmin {}, ApiKeysWithPermit { permit: Permit, }, @@ -54,8 +53,16 @@ pub struct SubscriberStatusResponse { pub active: bool, } +// Structure for API keys to respond to a query +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] +pub struct ApiKeyResponse { + // Previously `key: String`, + // Maybe rename to `hash: String` or `hashed_key: String`. + pub hashed_key: String, +} + // Structure for GetApiKeysResponse #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] pub struct GetApiKeysResponse { - pub api_keys: Vec, + pub api_keys: Vec, } \ No newline at end of file diff --git a/subscription-manager/src/state.rs b/subscription-manager/src/state.rs index 0ffbeda..3370ebb 100644 --- a/subscription-manager/src/state.rs +++ b/subscription-manager/src/state.rs @@ -1,6 +1,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use secret_toolkit::storage::{Item, Keymap}; +use secret_toolkit::storage::{Keymap}; use cosmwasm_std::{Addr, Storage}; use cosmwasm_storage::{singleton, singleton_read, ReadonlySingleton, Singleton}; @@ -26,16 +26,11 @@ pub struct State { pub struct Subscriber { // Status of the subscriber (active or not) pub status: bool, - // Public key of the subscriber - pub public_key: String, } +// Structure representing an API key to be stored #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] -pub struct ApiKey { - // Previously `key: String`, - // Maybe rename to `hash: String` or `hashed_key: String`. - pub hashed_key: String, -} +pub struct ApiKey {} // Function to access and modify the configuration state pub fn config(storage: &mut dyn Storage) -> Singleton { From df31b9eb75eb6baaa2661cf6b17d2b68a6196bd4 Mon Sep 17 00:00:00 2001 From: DeDTihoN Date: Wed, 29 Jan 2025 15:16:36 +0200 Subject: [PATCH 13/17] contract update --- subscription-manager/.gitignore | 3 +- subscription-manager/src/contract.rs | 324 +++++++++++++++++++++------ subscription-manager/src/msg.rs | 20 +- subscription-manager/src/state.rs | 4 +- 4 files changed, 275 insertions(+), 76 deletions(-) diff --git a/subscription-manager/.gitignore b/subscription-manager/.gitignore index 4ac2644..a5f9867 100644 --- a/subscription-manager/.gitignore +++ b/subscription-manager/.gitignore @@ -6,4 +6,5 @@ signed_permit.json claive_subscription_manager.wasm permit_to_sign.json query_subscriber_permit.json -api_keys_permit.json \ No newline at end of file +api_keys_permit.json +api_keys_by_identity_permit.json \ No newline at end of file diff --git a/subscription-manager/src/contract.rs b/subscription-manager/src/contract.rs index 102cf82..7124a92 100644 --- a/subscription-manager/src/contract.rs +++ b/subscription-manager/src/contract.rs @@ -3,7 +3,7 @@ use cosmwasm_std::{ }; use secret_toolkit::permit::{validate, Permit}; use sha2::{Digest, Sha256}; -use crate::msg::{ApiKeyResponse, ExecuteMsg, GetApiKeysResponse, InstantiateMsg, MigrateMsg, QueryMsg, SubscriberStatusResponse}; +use crate::msg::{ApiKeyResponse, ApiKeysByIdentityResponse, ExecuteMsg, GetApiKeysResponse, InstantiateMsg, MigrateMsg, QueryMsg, SubscriberStatusResponse}; use crate::state::{config, config_read, ApiKey, State, Subscriber, API_KEY_MAP, SB_MAP}; // Entry point for contract initialization @@ -43,9 +43,9 @@ pub fn execute( // Handle removal of a subscriber ExecuteMsg::RemoveSubscriber { public_key } => try_remove_subscriber(deps, info, public_key), // Handle setting a new admin - ExecuteMsg::SetAdmin { public_key } => try_set_admin(deps, info, public_key), + ExecuteMsg::SetAdmin { public_address } => try_set_admin(deps, info, public_address), // Handle adding an API key - ExecuteMsg::AddApiKey { api_key } => try_add_api_key(deps, info, api_key), + ExecuteMsg::AddApiKey { api_key , identity} => try_add_api_key(deps, info, api_key, identity), // Handle revoking an API key ExecuteMsg::RevokeApiKey { api_key } => try_revoke_api_key(deps, info, api_key), } @@ -55,71 +55,55 @@ pub fn try_add_api_key( deps: DepsMut, info: MessageInfo, api_key: String, + identity: Option, ) -> StdResult { - let config = config_read(deps.storage); - let state = config.load()?; + // Load the contract state to get the admin address + let state = config_read(deps.storage).load()?; - // Check if the sender is the admin + // Only the admin can add API keys if info.sender != state.admin { return Err(StdError::generic_err("Only admin can add API keys")); } - // 1. Compute the hash of the provided api_key - let mut hasher = Sha256::new(); - hasher.update(api_key.as_bytes()); - let key_hash = hex::encode(hasher.finalize()); - // This is a hex-encoded string of 64 hex characters. - - // 2. Check if this hash already exists - if API_KEY_MAP.contains(deps.storage, &key_hash) { - return Err(StdError::generic_err("API key (hash) already exists")); + // Check if the API key already exists in storage using `contains` + if API_KEY_MAP.contains(deps.storage, &api_key) { + return Err(StdError::generic_err("API key already exists")); } - // 3. Insert the hash into the map - let api_key_data = ApiKey {}; + // Store the API key as the map key, and only store `identity` as the value + let api_key_data = ApiKey { identity }; + API_KEY_MAP - .insert(deps.storage, &key_hash, &api_key_data) + .insert(deps.storage, &api_key, &api_key_data) .map_err(|err| StdError::generic_err(err.to_string()))?; - // For the response, we might *not* want to reveal the hash in events (up to you). - // But we'll do it here for illustration. + // Return response confirming the addition of the API key Ok(Response::new() .add_attribute("action", "add_api_key") - .add_attribute("stored_hash", key_hash)) + .add_attribute("stored_key", api_key)) } - pub fn try_revoke_api_key( deps: DepsMut, info: MessageInfo, api_key: String, ) -> StdResult { - let config = config_read(deps.storage); - let state = config.load()?; + let state = config_read(deps.storage).load()?; - // Check if the sender is the admin if info.sender != state.admin { return Err(StdError::generic_err("Only admin can revoke API keys")); } - // 1. Compute the hash again - let mut hasher = Sha256::new(); - hasher.update(api_key.as_bytes()); - let key_hash = hex::encode(hasher.finalize()); - - // 2. Check if this hash is in storage - if !API_KEY_MAP.contains(deps.storage, &key_hash) { - return Err(StdError::generic_err("API key (hash) not found")); + if !API_KEY_MAP.contains(deps.storage, &api_key) { + return Err(StdError::generic_err("API key not found")); } - // 3. Remove the entry API_KEY_MAP - .remove(deps.storage, &key_hash) + .remove(deps.storage, &api_key) .map_err(|err| StdError::generic_err(err.to_string()))?; - // Return a response Ok(Response::new() .add_attribute("action", "revoke_api_key") - .add_attribute("removed_hash", key_hash)) + .add_attribute("removed_key", api_key)) } #[entry_point] @@ -205,7 +189,7 @@ pub fn try_remove_subscriber( } // Function to set a new admin -pub fn try_set_admin(deps: DepsMut, info: MessageInfo, public_key: String) -> StdResult { +pub fn try_set_admin(deps: DepsMut, info: MessageInfo, public_address: String) -> StdResult { let mut config = config(deps.storage); let mut state = config.load()?; @@ -214,8 +198,8 @@ pub fn try_set_admin(deps: DepsMut, info: MessageInfo, public_key: String) -> St return Err(StdError::generic_err("Only the current admin can set a new admin")); } - // Validate the new admin's public key - let final_address = deps.api.addr_validate(&public_key).map_err(|err| { + // Validate the new admin's public address + let final_address = deps.api.addr_validate(&public_address).map_err(|err| { StdError::generic_err(format!("Invalid address: {}", err)) })?; @@ -226,7 +210,7 @@ pub fn try_set_admin(deps: DepsMut, info: MessageInfo, public_key: String) -> St // Return a response indicating successful admin update Ok(Response::new() .add_attribute("action", "set_admin") - .add_attribute("new_admin", public_key)) + .add_attribute("new_admin", public_address)) } // Entry point for handling queries @@ -240,6 +224,9 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { QueryMsg::ApiKeysWithPermit { permit } => { to_binary(&query_api_keys_with_permit(deps, env, permit)?) } + QueryMsg::ApiKeysByIdentityWithPermit { identity, permit } => { + to_binary(&query_api_keys_by_identity_with_permit(deps, env, identity, permit)?) + } } } @@ -288,62 +275,50 @@ fn query_subscriber_with_permit( Ok(SubscriberStatusResponse { active }) } -/// Validates the permit and, if valid and signed by the admin, returns all API keys +/// Validates the permit and, if valid and signed by the admin, returns all API keys (hashed) fn query_api_keys_with_permit( deps: Deps, env: Env, permit: Permit, ) -> StdResult { - // 1. Read current admin from contract state + // 1. Read the current admin from contract state let state = config_read(deps.storage).load()?; - let admin_addr = state.admin; // e.g. "secret1xyz..." + let admin_addr = state.admin; - // Validate permit name + // 2. Validate permit name if permit.params.permit_name != "api_keys_permit" { return Err(StdError::generic_err("Invalid permit name")); } - // 2. Convert our contract address to `HumanAddr` (if needed by validate) - // Some validate methods require the "contract_address" or similar. - // In many SNIP-20 references, the "contract_address" is just the - // contract address itself, because you typically check that - // permit.params.allowed_tokens includes this contract. + // 3. Validate the permit let contract_address = env.contract.address; - - // 3. storage_prefix is the prefix in storage for revoked permits (if used). - // Typically something like "permits" or "revoke_permits". let storage_prefix = "permits_api_keys"; - // 4. Validate the permit - // This should check: - // - The signature is correct - // - The permit has not been revoked - // - The contract address is in `allowed_tokens` (if you require that) - // - // In your snippet, `validate` returns the signer's bech32 address - // if the signature is valid, or an error otherwise. - let signer_addr = validate( deps, storage_prefix, &permit, contract_address.into_string(), - Some("secret"), // The HRP, e.g. "secret", "cosmos", etc. + Some("secret"), )?; - // 5. Check if the signer is actually the admin + // 4. Check if the signer is actually the admin if signer_addr != admin_addr { return Err(StdError::generic_err("Unauthorized: not the admin")); } - // 4. Use `iter_keys` to construct `ApiKeyResponse` directly from the keys + // 5. Iterate over stored API keys and return their hashed values let api_keys: Vec = API_KEY_MAP - .iter_keys(deps.storage)? // Iterate over the keys + .iter_keys(deps.storage)? .filter_map(|key_result| { - if let Ok(key) = key_result { - // Construct `ApiKeyResponse` from the key + if let Ok(api_key) = key_result { + // Hash the API key before returning it + let mut hasher = Sha256::new(); + hasher.update(api_key.as_bytes()); + let hashed_key = hex::encode(hasher.finalize()); + Some(ApiKeyResponse { - hashed_key: key, + hashed_key, }) } else { None // Skip invalid keys @@ -353,6 +328,58 @@ fn query_api_keys_with_permit( Ok(GetApiKeysResponse { api_keys }) } + +/// Validates the permit and, if valid and signed by the admin, returns all API keys associated with the given identity. +fn query_api_keys_by_identity_with_permit( + deps: Deps, + env: Env, + identity: String, + permit: Permit, +) -> StdResult { + // 1. Load the admin address from contract state + let state = config_read(deps.storage).load()?; + let admin_addr = state.admin; + + // 2. Validate the permit name + if permit.params.permit_name != "api_keys_by_identity_permit" { + return Err(StdError::generic_err("Invalid permit name")); + } + + // 3. Validate the permit + let contract_address = env.contract.address; + let storage_prefix = "permits_api_keys_by_identity"; + + let signer_addr = validate( + deps, + storage_prefix, + &permit, + contract_address.into_string(), + Some("secret"), + )?; + + // 4. Check if the signer is actually the admin + if signer_addr != admin_addr { + return Err(StdError::generic_err("Unauthorized: not the admin")); + } + + // 5. Retrieve API keys associated with the given identity + let api_keys: Vec = API_KEY_MAP + .iter(deps.storage)? + .filter_map(|result| { + if let Ok((key, data)) = result { + if let Some(stored_identity) = &data.identity { + if stored_identity == &identity { + return Some(key); // Return the plaintext API key + } + } + } + None + }) + .collect(); + + Ok(ApiKeysByIdentityResponse { api_keys }) +} + #[cfg(test)] mod tests { use super::*; @@ -398,6 +425,7 @@ mod tests { info.clone(), ExecuteMsg::AddApiKey { api_key: "test_key1".to_string(), + identity: None }, ) .unwrap(); @@ -408,6 +436,7 @@ mod tests { info.clone(), ExecuteMsg::AddApiKey { api_key: "test_key2".to_string(), + identity: None }, ) .unwrap(); @@ -452,6 +481,7 @@ mod tests { info.clone(), ExecuteMsg::AddApiKey { api_key: "test_key1".to_string(), + identity: None }, ) .unwrap(); @@ -496,6 +526,7 @@ mod tests { // 2. Add an API key let add_msg = ExecuteMsg::AddApiKey { api_key: "test_api_key".to_string(), + identity: None }; execute(deps.as_mut(), env.clone(), info.clone(), add_msg).unwrap(); @@ -654,7 +685,7 @@ mod tests { instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); let set_admin_msg = ExecuteMsg::SetAdmin { - public_key: "new_admin".to_string(), + public_address: "new_admin".to_string(), }; // Execute the message to set a new admin and check the response @@ -683,7 +714,7 @@ mod tests { let unauthorized_info = mock_info("not_admin", &[]); let set_admin_msg = ExecuteMsg::SetAdmin { - public_key: "new_admin".to_string(), + public_address: "new_admin".to_string(), }; // Attempt to set a new admin with a non-admin account and expect an error @@ -823,4 +854,155 @@ mod tests { assert!(!response.active); } + #[test] + fn test_query_api_keys_by_identity_with_permit() { + let mut deps = mock_dependencies(); + let info = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); + let init_msg = InstantiateMsg {}; + + // Create a custom environment + let env = mock_env_for_permit(); + + // Instantiate the contract + instantiate(deps.as_mut(), env.clone(), info.clone(), init_msg).unwrap(); + + // Add API keys with different identities + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::AddApiKey { + api_key: "api_key_1".to_string(), + identity: Some("user_123".to_string()), + }, + ) + .unwrap(); + + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::AddApiKey { + api_key: "api_key_2".to_string(), + identity: Some("user_123".to_string()), + }, + ) + .unwrap(); + + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::AddApiKey { + api_key: "api_key_3".to_string(), + identity: Some("user_456".to_string()), + }, + ) + .unwrap(); + + // Load a signed permit from a file (e.g., "./api_keys_by_identity_permit.json") + let json_data = std::fs::read_to_string("./api_keys_by_identity_permit.json") + .expect("Failed to read permit.json"); + let permit: secret_toolkit::permit::Permit = serde_json::from_str(&json_data) + .expect("Could not parse Permit from JSON"); + + // Query API keys by identity "user_123" + let query_msg = QueryMsg::ApiKeysByIdentityWithPermit { + identity: "user_123".to_string(), + permit, + }; + let bin = query(deps.as_ref(), env.clone(), query_msg).unwrap(); + let response: ApiKeysByIdentityResponse = from_binary(&bin).unwrap(); + + // Verify that only the keys belonging to "user_123" are returned + assert_eq!(response.api_keys.len(), 2); + assert!(response.api_keys.contains(&"api_key_1".to_string())); + assert!(response.api_keys.contains(&"api_key_2".to_string())); + assert!(!response.api_keys.contains(&"api_key_3".to_string())); + + println!("Query API keys by identity test passed!"); + } + + #[test] + fn test_query_api_keys_by_empty_identity_with_permit() { + let mut deps = mock_dependencies(); + let info = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); + let init_msg = InstantiateMsg {}; + + // Create a custom environment + let env = mock_env_for_permit(); + + // Instantiate the contract + instantiate(deps.as_mut(), env.clone(), info.clone(), init_msg).unwrap(); + + // Add API keys with identities + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::AddApiKey { + api_key: "api_key_1".to_string(), + identity: Some("user_123".to_string()), + }, + ) + .unwrap(); + + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::AddApiKey { + api_key: "api_key_2".to_string(), + identity: Some("user_456".to_string()), + }, + ) + .unwrap(); + + // Add API keys without identities + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::AddApiKey { + api_key: "api_key_3".to_string(), + identity: None, + }, + ) + .unwrap(); + + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::AddApiKey { + api_key: "api_key_4".to_string(), + identity: None, + }, + ) + .unwrap(); + + // Load a signed permit from a file (e.g., "./api_keys_by_identity_permit.json") + let json_data = std::fs::read_to_string("./api_keys_by_identity_permit.json") + .expect("Failed to read permit.json"); + let permit: secret_toolkit::permit::Permit = serde_json::from_str(&json_data) + .expect("Could not parse Permit from JSON"); + + // Query API keys with an empty identity ("") + let query_msg = QueryMsg::ApiKeysByIdentityWithPermit { + identity: "".to_string(), + permit, + }; + let bin = query(deps.as_ref(), env.clone(), query_msg).unwrap(); + let response: ApiKeysByIdentityResponse = from_binary(&bin).unwrap(); + + // Verify that no keys are returned + assert_eq!( + response.api_keys.len(), + 0, + "Expected empty result, but got: {:?}", + response.api_keys + ); + + println!("✅ Test passed! Querying with an empty identity does not return keys without an identity."); + } } \ No newline at end of file diff --git a/subscription-manager/src/msg.rs b/subscription-manager/src/msg.rs index 3017eab..942374e 100644 --- a/subscription-manager/src/msg.rs +++ b/subscription-manager/src/msg.rs @@ -17,10 +17,14 @@ pub enum ExecuteMsg { // Message to remove a subscriber using a public key RemoveSubscriber { public_key: String }, - // Message to set a new admin for the contract using a public key - SetAdmin { public_key: String }, + // Message to set a new admin for the contract using a public address + SetAdmin { public_address: String }, // Message to add an API key - AddApiKey { api_key: String }, + // Add an API key with an optional identity + AddApiKey { + api_key: String, + identity: Option, // Optional field to associate an API key with an identity + }, // Message to revoke an API key RevokeApiKey { api_key: String }, } @@ -44,6 +48,10 @@ pub enum QueryMsg { ApiKeysWithPermit { permit: Permit, }, + ApiKeysByIdentityWithPermit { + identity: String, + permit: Permit, + } } // Struct used to respond to a query about a subscriber's status @@ -65,4 +73,10 @@ pub struct ApiKeyResponse { #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] pub struct GetApiKeysResponse { pub api_keys: Vec, +} + +// Struct for the response of the `query_by_identity` query +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] +pub struct ApiKeysByIdentityResponse { + pub api_keys: Vec, // List of API keys associated with the identity } \ No newline at end of file diff --git a/subscription-manager/src/state.rs b/subscription-manager/src/state.rs index 3370ebb..86cfd5d 100644 --- a/subscription-manager/src/state.rs +++ b/subscription-manager/src/state.rs @@ -30,7 +30,9 @@ pub struct Subscriber { // Structure representing an API key to be stored #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] -pub struct ApiKey {} +pub struct ApiKey { + pub identity: Option, // The optional identity associated with the key +} // Function to access and modify the configuration state pub fn config(storage: &mut dyn Storage) -> Singleton { From cca6d7092293dc8ed316c5eae3d95639064cfefc Mon Sep 17 00:00:00 2001 From: DeDTihoN Date: Wed, 29 Jan 2025 16:15:46 +0200 Subject: [PATCH 14/17] updated readme --- subscription-manager/.gitignore | 3 +- subscription-manager/Readme.md | 127 ++++++++++---------------------- 2 files changed, 40 insertions(+), 90 deletions(-) diff --git a/subscription-manager/.gitignore b/subscription-manager/.gitignore index a5f9867..156fe22 100644 --- a/subscription-manager/.gitignore +++ b/subscription-manager/.gitignore @@ -7,4 +7,5 @@ claive_subscription_manager.wasm permit_to_sign.json query_subscriber_permit.json api_keys_permit.json -api_keys_by_identity_permit.json \ No newline at end of file +api_keys_by_identity_permit.json +/optimized-wasm \ No newline at end of file diff --git a/subscription-manager/Readme.md b/subscription-manager/Readme.md index 94d9a6b..39d8c19 100644 --- a/subscription-manager/Readme.md +++ b/subscription-manager/Readme.md @@ -14,24 +14,28 @@ The contract stores: - **Admin Address**: The account that has permission to register or remove subscribers, manage API keys, and change admin rights. - **Subscribers**: A mapping from a public key to the subscriber's status (active or inactive). -- **API Keys**: A mapping of hashed API keys used for external access control. The API keys are stored as SHA-256 hashes to enhance security. +- **API Keys**: A mapping of API keys used for external access control. API keys can optionally be associated with an identity, and they are stored securely. ### Methods -1. **Instantiate** - - Initializes the contract and sets the admin to the sender's address. +#### 1. **Instantiate** -2. **Execute** - - `RegisterSubscriber`: Adds a new subscriber using their public key. Only callable by the admin. - - `RemoveSubscriber`: Removes a subscriber using their public key. Only callable by the admin. - - `SetAdmin`: Changes the admin to a new address. Only callable by the current admin. - - `AddApiKey`: Adds a new API key for access control. The API key is hashed using SHA-256 before storage. Only callable by the admin. - - `RevokeApiKey`: Revokes an existing API key. The API key must be provided in plaintext, and the contract verifies its hash. Only callable by the admin. +- Initializes the contract and sets the admin to the sender's address. -3. **Query** - - `SubscriberStatusWithPermit`: Checks if a subscriber with the given public key is active. Requires a valid permit signed by the admin. - - `ApiKeysWithPermit`: Returns a list of all registered API keys. Requires a valid permit signed by the admin to ensure secure access. - - `GetAdmin`: Returns the current admin address. +#### 2. **Execute** + +- `RegisterSubscriber`: Adds a new subscriber using their public key. Only callable by the admin. +- `RemoveSubscriber`: Removes a subscriber using their public key. Only callable by the admin. +- `SetAdmin`: Changes the admin to a new address. Only callable by the current admin. +- `AddApiKey`: Adds a new API key for access control, with an optional identity. Only callable by the admin. +- `RevokeApiKey`: Revokes an existing API key. The contract verifies its existence before removal. Only callable by the admin. + +#### 3. **Query** + +- `SubscriberStatusWithPermit`: Checks if a subscriber with the given public key is active. Requires a valid permit signed by the admin. +- `ApiKeysWithPermit`: Returns a list of all registered API keys (**hashed by sha-256**). Requires a valid permit signed by the admin. +- `ApiKeysByIdentityWithPermit`: Retrieves API keys associated with a given identity. Requires admin authorization. +- `GetAdmin`: Returns the current admin address. --- @@ -135,18 +139,6 @@ secretcli query compute list-contract-by-code secretcli tx compute execute '{"register_subscriber":{"public_key":"subscriber_pub_key"}}' --from myWallet -y ``` -#### Example - -```bash -$ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"register_subscriber":{"public_key":"subscriber_pub_key"}}' --from myWallet -y -{ - "height": "0", - "txhash": "F9435CA04E44FD924966089DBBBE395E7CA21422FF8D6A29BC31E9A0B016CCE4", - "code": 0, - "logs": [] -} -``` - --- ### Use Case 2: Query Subscriber Status with Permit @@ -159,15 +151,6 @@ $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{" secretcli query compute query '{"subscriber_status_with_permit":{"public_key":"subscriber_pub_key","permit":}}' ``` -#### Example - -```bash -$ secretcli query compute query secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"subscriber_status_with_permit":{"public_key":"subscriber_pub_key","permit":}}' -{ - "active": true -} -``` - --- ### Use Case 3: Remove a Subscriber @@ -180,18 +163,6 @@ $ secretcli query compute query secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{ secretcli tx compute execute '{"remove_subscriber":{"public_key":"subscriber_pub_key"}}' --from myWallet -y ``` -#### Example - -```bash -$ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"remove_subscriber":{"public_key":"subscriber_pub_key"}}' --from myWallet -y -{ - "height": "0", - "txhash": "C6E5113A94FDFA05FD5FB3214E6FA1E604AD927D1848C9CB191407BA11233E41", - "code": 0, - "logs": [] -} -``` - --- ### Use Case 4: Set a New Admin @@ -204,18 +175,6 @@ $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{" secretcli tx compute execute '{"set_admin":{"public_key":"new_admin_pub_key"}}' --from myWallet -y ``` -#### Example - -```bash -$ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"set_admin":{"public_key":"secret1qvapn5ns28xrevn7kdudwvrp6a4fven2kzq8jc"}}' --from myWallet -y -{ - "height": "0", - "txhash": "D5D86A32A654D3BBE7A4491F74BB96F68FC4481BECD00B5D10DFF271D76C75B2", - "code": 0, - "logs": [] -} -``` - --- ### Use Case 5: Add an API Key @@ -228,18 +187,6 @@ $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{" secretcli tx compute execute '{"add_api_key":{"api_key":"new_api_key"}}' --from myWallet -y ``` -#### Example - -```bash -$ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"add_api_key":{"api_key":"test_api_key"}}' --from myWallet -y -{ - "height": "0", - "txhash": "E9435CA04E44FD924966089DBBBE395E7CA21422FF8D6A29BC31E9A0B016CCE5", - "code": 0, - "logs": [] -} -``` - --- ### Use Case 6: Revoke an API Key @@ -252,39 +199,41 @@ $ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{" secretcli tx compute execute '{"revoke_api_key":{"api_key":"api_key_to_revoke"}}' --from myWallet -y ``` -#### Example +--- + +### Use Case 7: Query API Keys with Permit + +**Description**: Retrieve the list of all registered API keys in **hashed format**. Requires a valid permit signed by the admin. + +#### Command ```bash -$ secretcli tx compute execute secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"revoke_api_key":{"api_key":"test_api_key"}}' -{ - "height": "0", - "txhash": "F9435CA04E44FD924966089DBBBE395E7CA21422FF8D6A29BC31E9A0B016CCE6", - "code": 0, - "logs": [] -} +secretcli query compute query '{"api_keys_with_permit":{"permit":}}' ``` --- -### Use Case 7: Query API Keys with Permit +### Use Case 8: Query API Keys by Identity with Permit -**Description**: Retrieve the list of all registered API keys. Requires a valid permit signed by the admin. +**Description**: Retrieve all **actual API keys** associated with a given identity. Requires a valid permit signed by the admin. #### Command ```bash -secretcli query compute query '{"api_keys_with_permit":{"permit":}}' +secretcli query compute query '{"api_keys_by_identity_with_permit":{"identity":"some_identity","permit":}}' ``` -#### Example +--- + +### Use Case 9: Get Admin Address + +**Description**: Retrieve the current admin address. + +#### Command ```bash -$ secretcli query compute query secret1nahrq5c0hf2v8fj703glsd7y3j7dccayadd9cf '{"api_keys_with_permit":{"permit":}}' -{ - "api_keys": [ - { "hashed_key": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, - { "hashed_key": "d14a028c2a3a2bc9476102bb288234c415a2b01f828ea62ab16f40c07b5a79a5" } - ] -} +secretcli query compute query '{"get_admin":{}}' ``` +--- + From 157397e14b70298454b7a9c6c50b41bbae19cb6b Mon Sep 17 00:00:00 2001 From: DeDTihoN Date: Wed, 5 Feb 2025 18:07:14 +0200 Subject: [PATCH 15/17] updated with 'name' and 'created' fields --- subscription-manager/src/contract.rs | 394 ++++++++++++--------------- subscription-manager/src/msg.rs | 58 ++-- subscription-manager/src/state.rs | 24 +- 3 files changed, 216 insertions(+), 260 deletions(-) diff --git a/subscription-manager/src/contract.rs b/subscription-manager/src/contract.rs index 7124a92..16d6cc5 100644 --- a/subscription-manager/src/contract.rs +++ b/subscription-manager/src/contract.rs @@ -1,12 +1,16 @@ use cosmwasm_std::{ - entry_point, to_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdError, StdResult + entry_point, to_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdError, + StdResult, }; use secret_toolkit::permit::{validate, Permit}; use sha2::{Digest, Sha256}; -use crate::msg::{ApiKeyResponse, ApiKeysByIdentityResponse, ExecuteMsg, GetApiKeysResponse, InstantiateMsg, MigrateMsg, QueryMsg, SubscriberStatusResponse}; + +use crate::msg::{ + ApiKeyDetail, ApiKeyResponse, ApiKeysByIdentityResponse, ExecuteMsg, GetApiKeysResponse, + InstantiateMsg, MigrateMsg, QueryMsg, SubscriberStatusResponse, +}; use crate::state::{config, config_read, ApiKey, State, Subscriber, API_KEY_MAP, SB_MAP}; -// Entry point for contract initialization #[entry_point] pub fn instantiate( deps: DepsMut, @@ -14,74 +18,86 @@ pub fn instantiate( info: MessageInfo, _msg: InstantiateMsg, ) -> StdResult { - // Set the admin to the sender who initializes the contract + // Set admin as the sender of the instantiate message let state = State { admin: info.sender.clone(), }; - // Log a debug message + // Log initialization debug message deps.api .debug(format!("Contract was initialized by {}", info.sender).as_str()); - // Save the initial state + // Save state to storage config(deps.storage).save(&state)?; Ok(Response::default()) } -// Entry point for executing messages #[entry_point] pub fn execute( deps: DepsMut, - _env: Env, + env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> StdResult { match msg { - // Handle registration of a subscriber - ExecuteMsg::RegisterSubscriber { public_key } => try_register_subscriber(deps, info, public_key), - // Handle removal of a subscriber - ExecuteMsg::RemoveSubscriber { public_key } => try_remove_subscriber(deps, info, public_key), - // Handle setting a new admin + ExecuteMsg::RegisterSubscriber { public_key } => { + try_register_subscriber(deps, info, public_key) + } + ExecuteMsg::RemoveSubscriber { public_key } => { + try_remove_subscriber(deps, info, public_key) + } ExecuteMsg::SetAdmin { public_address } => try_set_admin(deps, info, public_address), - // Handle adding an API key - ExecuteMsg::AddApiKey { api_key , identity} => try_add_api_key(deps, info, api_key, identity), - // Handle revoking an API key + ExecuteMsg::AddApiKey { + api_key, + identity, + name, + created, + } => try_add_api_key(deps, info, api_key, identity, name, created), ExecuteMsg::RevokeApiKey { api_key } => try_revoke_api_key(deps, info, api_key), } } +/// Adds an API key with optional identity, name, and creation timestamp pub fn try_add_api_key( deps: DepsMut, info: MessageInfo, api_key: String, identity: Option, + name: Option, + created: Option, ) -> StdResult { - // Load the contract state to get the admin address + // Load current contract state to verify admin privileges let state = config_read(deps.storage).load()?; - // Only the admin can add API keys + // Only admin can add API keys if info.sender != state.admin { return Err(StdError::generic_err("Only admin can add API keys")); } - // Check if the API key already exists in storage using `contains` + // Check if API key already exists if API_KEY_MAP.contains(deps.storage, &api_key) { return Err(StdError::generic_err("API key already exists")); } - // Store the API key as the map key, and only store `identity` as the value - let api_key_data = ApiKey { identity }; + // Create a new API key entry with provided details + let api_key_data = ApiKey { + identity, + name, + created, + }; + // Insert the API key data into storage API_KEY_MAP .insert(deps.storage, &api_key, &api_key_data) .map_err(|err| StdError::generic_err(err.to_string()))?; - // Return response confirming the addition of the API key Ok(Response::new() .add_attribute("action", "add_api_key") .add_attribute("stored_key", api_key)) } + +/// Revokes (removes) an existing API key pub fn try_revoke_api_key( deps: DepsMut, info: MessageInfo, @@ -89,14 +105,17 @@ pub fn try_revoke_api_key( ) -> StdResult { let state = config_read(deps.storage).load()?; + // Only admin can revoke API keys if info.sender != state.admin { return Err(StdError::generic_err("Only admin can revoke API keys")); } + // Check if API key exists if !API_KEY_MAP.contains(deps.storage, &api_key) { return Err(StdError::generic_err("API key not found")); } + // Remove the API key from storage API_KEY_MAP .remove(deps.storage, &api_key) .map_err(|err| StdError::generic_err(err.to_string()))?; @@ -110,16 +129,15 @@ pub fn try_revoke_api_key( pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> StdResult { match msg { MigrateMsg::Migrate {} => { - // Collect all keys using `iter_keys` - let keys_to_remove: Vec = API_KEY_MAP - .iter_keys(deps.storage)? - .filter_map(|key_result| key_result.ok()) - .collect(); - - // Remove each key - for key in keys_to_remove { - API_KEY_MAP.remove(deps.storage, &key)?; - } + // // Iterate through all API keys and remove them + // let keys_to_remove: Vec = API_KEY_MAP + // .iter_keys(deps.storage)? + // .filter_map(|key_result| key_result.ok()) + // .collect(); + // + // for key in keys_to_remove { + // API_KEY_MAP.remove(deps.storage, &key)?; + // } Ok(Response::new() .add_attribute("action", "migrate") @@ -129,91 +147,85 @@ pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> StdResult } } -// Function to register a new subscriber +/// Registers a subscriber using a public key pub fn try_register_subscriber( deps: DepsMut, info: MessageInfo, public_key: String, ) -> StdResult { - // Check if the sender is the admin - let config = config_read(deps.storage); - let state = config.load()?; + let state = config_read(deps.storage).load()?; if info.sender != state.admin { return Err(StdError::generic_err("Only admin can register subscribers")); } - // Check if the subscriber is already registered - let map_contains_sb = SB_MAP.contains(deps.storage, &public_key); - if map_contains_sb { + // Check if subscriber already exists + if SB_MAP.contains(deps.storage, &public_key) { return Err(StdError::generic_err("Subscriber already registered")); } - // Create a new subscriber and insert it into the map let subscriber = Subscriber { status: true }; - SB_MAP.insert(deps.storage, &public_key, &subscriber) + SB_MAP + .insert(deps.storage, &public_key, &subscriber) .map_err(|err| StdError::generic_err(err.to_string()))?; - // Return a response indicating successful registration Ok(Response::new() .add_attribute("action", "register_subscriber") .add_attribute("subscriber", public_key)) } -// Function to remove a subscriber +/// Removes a subscriber using a public key pub fn try_remove_subscriber( deps: DepsMut, info: MessageInfo, public_key: String, ) -> StdResult { - // Check if the sender is the admin - let config = config_read(deps.storage); - let state = config.load()?; + let state = config_read(deps.storage).load()?; if info.sender != state.admin { return Err(StdError::generic_err("Only admin can remove subscribers")); } - // Check if the subscriber is registered - let map_contains_sb = SB_MAP.contains(deps.storage, &public_key); - if !map_contains_sb { + // Check if subscriber exists + if !SB_MAP.contains(deps.storage, &public_key) { return Err(StdError::generic_err("Subscriber not registered")); } - // Remove the subscriber from the map - SB_MAP.remove(deps.storage, &public_key) + SB_MAP + .remove(deps.storage, &public_key) .map_err(|err| StdError::generic_err(err.to_string()))?; - // Return a response indicating successful removal Ok(Response::new() .add_attribute("action", "remove_subscriber") .add_attribute("subscriber", public_key)) } -// Function to set a new admin -pub fn try_set_admin(deps: DepsMut, info: MessageInfo, public_address: String) -> StdResult { +/// Sets a new admin for the contract +pub fn try_set_admin( + deps: DepsMut, + info: MessageInfo, + public_address: String, +) -> StdResult { let mut config = config(deps.storage); let mut state = config.load()?; - // Check if the sender is the current admin + // Only current admin can change the admin if info.sender != state.admin { return Err(StdError::generic_err("Only the current admin can set a new admin")); } - // Validate the new admin's public address + // Validate the new admin address let final_address = deps.api.addr_validate(&public_address).map_err(|err| { StdError::generic_err(format!("Invalid address: {}", err)) })?; - // Update the admin in the state + // Update state with new admin address state.admin = final_address; config.save(&state)?; - // Return a response indicating successful admin update Ok(Response::new() .add_attribute("action", "set_admin") .add_attribute("new_admin", public_address)) } -// Entry point for handling queries #[entry_point] pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { match msg { @@ -230,29 +242,27 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { } } -// Function to get the current admin +/// Returns the current admin address fn get_admin(deps: Deps) -> StdResult { let state = config_read(deps.storage).load()?; Ok(state.admin) } -// Function to check if a subscriber is active +/// Query subscriber status using a permit for authorization fn query_subscriber_with_permit( deps: Deps, env: Env, public_key: String, permit: Permit, ) -> StdResult { - // 1. Read current admin from contract state let state = config_read(deps.storage).load()?; let admin_addr = state.admin; - // Validate permit name + // Validate permit name if permit.params.permit_name != "query_subscriber_permit" { return Err(StdError::generic_err("Invalid permit name")); } - // 2. Validate the permit let contract_address = env.contract.address; let storage_prefix = "permits_subscriber_status"; let signer_addr = validate( @@ -263,34 +273,31 @@ fn query_subscriber_with_permit( Some("secret"), )?; - // 3. Check if the signer is actually the admin + // Only admin is allowed to query if signer_addr != admin_addr { return Err(StdError::generic_err("Unauthorized: not the admin")); } - // 4. Check if the subscriber exists let subscriber = SB_MAP.get(deps.storage, &public_key); let active = subscriber.is_some(); Ok(SubscriberStatusResponse { active }) } -/// Validates the permit and, if valid and signed by the admin, returns all API keys (hashed) +/// Query all API keys (returns hashed API keys) using a permit for authorization fn query_api_keys_with_permit( deps: Deps, env: Env, permit: Permit, ) -> StdResult { - // 1. Read the current admin from contract state let state = config_read(deps.storage).load()?; let admin_addr = state.admin; - // 2. Validate permit name + // Validate permit name if permit.params.permit_name != "api_keys_permit" { return Err(StdError::generic_err("Invalid permit name")); } - // 3. Validate the permit let contract_address = env.contract.address; let storage_prefix = "permits_api_keys"; @@ -302,26 +309,23 @@ fn query_api_keys_with_permit( Some("secret"), )?; - // 4. Check if the signer is actually the admin + // Only admin is allowed to query if signer_addr != admin_addr { return Err(StdError::generic_err("Unauthorized: not the admin")); } - // 5. Iterate over stored API keys and return their hashed values + // Iterate over all API keys, hash the keys, and return their hashed values let api_keys: Vec = API_KEY_MAP .iter_keys(deps.storage)? .filter_map(|key_result| { if let Ok(api_key) = key_result { - // Hash the API key before returning it let mut hasher = Sha256::new(); hasher.update(api_key.as_bytes()); let hashed_key = hex::encode(hasher.finalize()); - Some(ApiKeyResponse { - hashed_key, - }) + Some(ApiKeyResponse { hashed_key }) } else { - None // Skip invalid keys + None } }) .collect(); @@ -329,23 +333,21 @@ fn query_api_keys_with_permit( Ok(GetApiKeysResponse { api_keys }) } -/// Validates the permit and, if valid and signed by the admin, returns all API keys associated with the given identity. +/// Query API keys by identity (returns detailed API key information) using a permit for authorization fn query_api_keys_by_identity_with_permit( deps: Deps, env: Env, identity: String, permit: Permit, ) -> StdResult { - // 1. Load the admin address from contract state let state = config_read(deps.storage).load()?; let admin_addr = state.admin; - // 2. Validate the permit name + // Validate permit name if permit.params.permit_name != "api_keys_by_identity_permit" { return Err(StdError::generic_err("Invalid permit name")); } - // 3. Validate the permit let contract_address = env.contract.address; let storage_prefix = "permits_api_keys_by_identity"; @@ -357,19 +359,23 @@ fn query_api_keys_by_identity_with_permit( Some("secret"), )?; - // 4. Check if the signer is actually the admin + // Only admin is allowed to query if signer_addr != admin_addr { return Err(StdError::generic_err("Unauthorized: not the admin")); } - // 5. Retrieve API keys associated with the given identity - let api_keys: Vec = API_KEY_MAP + // Iterate over all API keys and filter by the provided identity + let api_keys: Vec = API_KEY_MAP .iter(deps.storage)? .filter_map(|result| { if let Ok((key, data)) = result { if let Some(stored_identity) = &data.identity { if stored_identity == &identity { - return Some(key); // Return the plaintext API key + return Some(ApiKeyDetail { + api_key: key, + name: data.name, + created: data.created, + }); } } } @@ -384,10 +390,13 @@ fn query_api_keys_by_identity_with_permit( mod tests { use super::*; use cosmwasm_std::testing::*; - use cosmwasm_std::{attr, from_binary, BlockInfo, Coin, ContractInfo, Timestamp, TransactionInfo, Uint128}; + use cosmwasm_std::{ + attr, from_binary, BlockInfo, Coin, ContractInfo, Timestamp, TransactionInfo, Uint128, + }; + /// Mocks an environment for permit tests fn mock_env_for_permit() -> Env { - let env = Env { + Env { block: BlockInfo { height: 12_345, time: Timestamp::from_nanos(1_571_797_419_879_305_533), @@ -398,34 +407,34 @@ mod tests { }, transaction: Some(TransactionInfo { index: 3, - hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - .to_string(), + hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(), }), contract: ContractInfo { address: Addr::unchecked("secret1ttm9axv8hqwjv3qxvxseecppsrw4cd68getrvr"), code_hash: "".to_string(), }, - }; - env + } } #[test] fn test_migrate_clears_api_key_map() { let mut deps = mock_dependencies(); - // Initialize the contract with an admin address + // Initialize contract with admin address let info = mock_info("admin", &[]); let init_msg = InstantiateMsg {}; instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); - // Add API keys to the `API_KEY_MAP` + // Add two API keys execute( deps.as_mut(), mock_env(), info.clone(), ExecuteMsg::AddApiKey { api_key: "test_key1".to_string(), - identity: None + identity: None, + name: None, + created: None, }, ) .unwrap(); @@ -436,12 +445,14 @@ mod tests { info.clone(), ExecuteMsg::AddApiKey { api_key: "test_key2".to_string(), - identity: None + identity: None, + name: None, + created: None, }, ) .unwrap(); - // Ensure that the keys were added successfully + // Ensure two keys are added let keys: Vec = API_KEY_MAP .iter_keys(deps.as_ref().storage) .unwrap() @@ -449,10 +460,10 @@ mod tests { .collect(); assert_eq!(keys.len(), 2); - // Perform migration + // Migrate (clear) the API key map migrate(deps.as_mut(), mock_env(), MigrateMsg::Migrate {}).unwrap(); - // Ensure the keys are removed + // Check that API key map is empty let keys_after_migration: Vec = API_KEY_MAP .iter_keys(deps.as_ref().storage) .unwrap() @@ -463,48 +474,39 @@ mod tests { #[test] fn test_query_api_keys_with_real_permit() { - // 1. Initialize the contract with admin = "secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4" let mut deps = mock_dependencies(); let info = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); let init_msg = InstantiateMsg {}; - - // Create a custom Env if you need specific block/transaction data let env = mock_env_for_permit(); - // Instantiate the contract instantiate(deps.as_mut(), env.clone(), info.clone(), init_msg).unwrap(); - // 2. Add a test API key so we can verify it during the query + // Add a test API key execute( deps.as_mut(), env.clone(), info.clone(), ExecuteMsg::AddApiKey { api_key: "test_key1".to_string(), - identity: None + identity: None, + name: None, + created: None, }, ) .unwrap(); - // 3. Read the permit from a file (e.g., "./permit.json"). - // This JSON should be a properly signed permit (StdSignDoc + signature), - // or a directly "cleaned" JSON that matches secret_toolkit::permit::Permit. + // Read a permit from file "./api_keys_permit.json" let json_data = std::fs::read_to_string("./api_keys_permit.json").unwrap(); - let permit: Permit = serde_json::from_str(&json_data) - .expect("Could not parse Permit from JSON"); + let permit: secret_toolkit::permit::Permit = + serde_json::from_str(&json_data).expect("Could not parse Permit from JSON"); - // 4. Query the contract using the permit let query_msg = QueryMsg::ApiKeysWithPermit { permit }; - println!("Query_msg: {:#?}", query_msg); let res = query(deps.as_ref(), env.clone(), query_msg); - // 5. Check the response to ensure the API key is returned match res { Ok(bin) => { let parsed: GetApiKeysResponse = from_binary(&bin).unwrap(); - // We expect exactly 1 API key: "test_key1" assert_eq!(parsed.api_keys.len(), 1); - println!("Response: {:#?}", parsed); } Err(e) => panic!("Query failed: {:?}", e), } @@ -512,56 +514,41 @@ mod tests { #[test] fn revoke_api_key_and_query_with_permit() { - // 1. Initialize the contract with some admin address let mut deps = mock_dependencies(); - // Suppose "admin" is just a placeholder address (like "secret1abc...") let info = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); let init_msg = InstantiateMsg {}; - - // Create a custom Env if you need specific block/transaction data let env = mock_env_for_permit(); instantiate(deps.as_mut(), env.clone(), info.clone(), init_msg).unwrap(); - // 2. Add an API key + // Add an API key let add_msg = ExecuteMsg::AddApiKey { api_key: "test_api_key".to_string(), - identity: None + identity: None, + name: None, + created: None, }; execute(deps.as_mut(), env.clone(), info.clone(), add_msg).unwrap(); - // 3. Revoke (remove) that API key + // Revoke the API key let revoke_msg = ExecuteMsg::RevokeApiKey { api_key: "test_api_key".to_string(), }; execute(deps.as_mut(), env.clone(), info.clone(), revoke_msg).unwrap(); - // 4. Now load a real signed Permit from file (as in your `test_query_api_keys_with_real_permit`) - // This permit must be signed by the same admin address in order to pass validation. let json_data = std::fs::read_to_string("./api_keys_permit.json") .expect("Failed to read permit.json"); - let permit: secret_toolkit::permit::Permit = serde_json::from_str(&json_data) - .expect("Could not parse Permit from JSON"); + let permit: secret_toolkit::permit::Permit = + serde_json::from_str(&json_data).expect("Could not parse Permit from JSON"); - // 5. Perform a query that uses the permit - // This calls your existing `ApiKeysWithPermit { permit }` query let query_msg = QueryMsg::ApiKeysWithPermit { permit }; - let res = query(deps.as_ref(), env.clone(), query_msg) - .expect("Query failed unexpectedly"); + let res = query(deps.as_ref(), env.clone(), query_msg).expect("Query failed unexpectedly"); - // 6. Verify that the revoked key is no longer in the list let response: GetApiKeysResponse = from_binary(&res).unwrap(); - assert!( - response.api_keys.is_empty(), - "Expected empty API keys after revoke, got: {:?}", - response.api_keys - ); - - println!("Revoke test passed. 'test_api_key' is no longer in the list."); + assert!(response.api_keys.is_empty()); } #[test] - /// Test for successful initialization of the contract fn proper_initialization() { let mut deps = mock_dependencies(); let info = mock_info( @@ -573,13 +560,11 @@ mod tests { ); let init_msg = InstantiateMsg {}; - // Assert successful initialization let res = instantiate(deps.as_mut(), mock_env(), info, init_msg).unwrap(); assert_eq!(0, res.messages.len()); } #[test] - /// Test successful registration of a subscriber by admin fn register_subscriber_success() { let mut deps = mock_dependencies(); let info = mock_info("admin", &[]); @@ -590,7 +575,6 @@ mod tests { public_key: "subscriber1".to_string(), }; - // Execute the message to register the subscriber and check the response let res = execute(deps.as_mut(), mock_env(), info, register_msg).unwrap(); assert_eq!(0, res.messages.len()); assert_eq!( @@ -603,7 +587,6 @@ mod tests { } #[test] - /// Test registration attempt by a non-admin, expecting failure fn register_subscriber_unauthorized() { let mut deps = mock_dependencies(); let info = mock_info("admin", &[]); @@ -615,7 +598,6 @@ mod tests { public_key: "subscriber1".to_string(), }; - // Attempt to register with a non-admin account and expect an error let res = execute(deps.as_mut(), mock_env(), unauthorized_info, register_msg); assert!(res.is_err()); assert_eq!( @@ -625,25 +607,21 @@ mod tests { } #[test] - /// Test successful removal of a subscriber by admin fn remove_subscriber_success() { let mut deps = mock_dependencies(); let info = mock_info("admin", &[]); let init_msg = InstantiateMsg {}; instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); - // Register a subscriber first let register_msg = ExecuteMsg::RegisterSubscriber { public_key: "subscriber1".to_string(), }; execute(deps.as_mut(), mock_env(), info.clone(), register_msg).unwrap(); - // Now remove the subscriber let remove_msg = ExecuteMsg::RemoveSubscriber { public_key: "subscriber1".to_string(), }; - // Execute the message to remove the subscriber and check the response let res = execute(deps.as_mut(), mock_env(), info, remove_msg).unwrap(); assert_eq!(0, res.messages.len()); assert_eq!( @@ -656,7 +634,6 @@ mod tests { } #[test] - /// Test removal attempt of a non-registered subscriber, expecting failure fn remove_subscriber_not_registered() { let mut deps = mock_dependencies(); let info = mock_info("admin", &[]); @@ -667,7 +644,6 @@ mod tests { public_key: "subscriber1".to_string(), }; - // Attempt to remove a non-registered subscriber and expect an error let res = execute(deps.as_mut(), mock_env(), info, remove_msg); assert!(res.is_err()); assert_eq!( @@ -677,7 +653,6 @@ mod tests { } #[test] - /// Test successful update of the admin by the current admin fn set_admin_success() { let mut deps = mock_dependencies(); let info = mock_info("admin", &[]); @@ -688,7 +663,6 @@ mod tests { public_address: "new_admin".to_string(), }; - // Execute the message to set a new admin and check the response let res = execute(deps.as_mut(), mock_env(), info, set_admin_msg).unwrap(); assert_eq!(0, res.messages.len()); assert_eq!( @@ -699,13 +673,11 @@ mod tests { ] ); - // Check that the admin was updated successfully let config = config_read(&deps.storage).load().unwrap(); assert_eq!(config.admin, Addr::unchecked("new_admin")); } #[test] - /// Test admin update attempt by a non-admin, expecting failure fn set_admin_unauthorized() { let mut deps = mock_dependencies(); let info = mock_info("admin", &[]); @@ -717,7 +689,6 @@ mod tests { public_address: "new_admin".to_string(), }; - // Attempt to set a new admin with a non-admin account and expect an error let res = execute(deps.as_mut(), mock_env(), unauthorized_info, set_admin_msg); assert!(res.is_err()); assert_eq!( @@ -737,37 +708,28 @@ mod tests { let bin = query(deps.as_ref(), mock_env(), query_msg).unwrap(); let response: Addr = from_binary(&bin).unwrap(); - println!("Response: {:#?}", response); - assert_eq!(response, Addr::unchecked("admin")); } #[test] - /// Test querying for a registered subscriber, expecting active status fn query_registered_subscriber() { - // 1. Initialize the contract with some admin address let mut deps = mock_dependencies(); - // Suppose "admin" is just a placeholder address (like "secret1abc...") let info = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); let init_msg = InstantiateMsg {}; - - // Create a custom Env if you need specific block/transaction data let env = mock_env_for_permit(); instantiate(deps.as_mut(), env.clone(), info.clone(), init_msg).unwrap(); - // Register a subscriber let register_msg = ExecuteMsg::RegisterSubscriber { public_key: "subscriber_public_key".to_string(), }; - execute(deps.as_mut(), env.clone(), info, register_msg).unwrap(); + execute(deps.as_mut(), env.clone(), info.clone(), register_msg).unwrap(); let json_data = std::fs::read_to_string("./query_subscriber_permit.json") .expect("Failed to read permit.json"); - let permit: secret_toolkit::permit::Permit = serde_json::from_str(&json_data) - .expect("Could not parse Permit from JSON"); + let permit: secret_toolkit::permit::Permit = + serde_json::from_str(&json_data).expect("Could not parse Permit from JSON"); - // Query for the registered subscriber and check the response let query_msg = QueryMsg::SubscriberStatusWithPermit { public_key: "subscriber_public_key".to_string(), permit: permit.clone(), @@ -775,31 +737,22 @@ mod tests { let bin = query(deps.as_ref(), env.clone(), query_msg).unwrap(); let response: SubscriberStatusResponse = from_binary(&bin).unwrap(); - println!("Response: {:#?}", response); - - // Check that the subscriber is active assert!(response.active); } #[test] - /// Test querying for an unregistered subscriber, expecting inactive status fn query_unregistered_subscriber() { - // 1. Initialize the contract with some admin address let mut deps = mock_dependencies(); - // Suppose "admin" is just a placeholder address (like "secret1abc...") let info = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); let init_msg = InstantiateMsg {}; - - // Create a custom Env if you need specific block/transaction data let env = mock_env_for_permit(); instantiate(deps.as_mut(), env.clone(), info, init_msg).unwrap(); let json_data = std::fs::read_to_string("./query_subscriber_permit.json") .expect("Failed to read permit.json"); - let permit: secret_toolkit::permit::Permit = serde_json::from_str(&json_data) - .expect("Could not parse Permit from JSON"); + let permit: secret_toolkit::permit::Permit = + serde_json::from_str(&json_data).expect("Could not parse Permit from JSON"); - // Query for an unregistered subscriber and check the response let query_msg = QueryMsg::SubscriberStatusWithPermit { public_key: "unregistered_public_key".to_string(), permit: permit.clone(), @@ -807,31 +760,23 @@ mod tests { let bin = query(deps.as_ref(), env.clone(), query_msg).unwrap(); let response: SubscriberStatusResponse = from_binary(&bin).unwrap(); - // Check that the subscriber is not active assert!(!response.active); } #[test] - /// Test querying for a subscriber after removal, expecting inactive status fn query_subscriber_after_removal() { - // 1. Initialize the contract with some admin address let mut deps = mock_dependencies(); - // Suppose "admin" is just a placeholder address (like "secret1abc...") let info = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); let init_msg = InstantiateMsg {}; - - // Create a custom Env if you need specific block/transaction data let env = mock_env_for_permit(); instantiate(deps.as_mut(), env.clone(), info.clone(), init_msg).unwrap(); - // Register a subscriber let register_msg = ExecuteMsg::RegisterSubscriber { public_key: "subscriber_public_key".to_string(), }; execute(deps.as_mut(), env.clone(), info.clone(), register_msg).unwrap(); - // Remove the subscriber let remove_msg = ExecuteMsg::RemoveSubscriber { public_key: "subscriber_public_key".to_string(), }; @@ -839,10 +784,9 @@ mod tests { let json_data = std::fs::read_to_string("./query_subscriber_permit.json") .expect("Failed to read permit.json"); - let permit: secret_toolkit::permit::Permit = serde_json::from_str(&json_data) - .expect("Could not parse Permit from JSON"); + let permit: secret_toolkit::permit::Permit = + serde_json::from_str(&json_data).expect("Could not parse Permit from JSON"); - // Query for the subscriber after removal and check the response let query_msg = QueryMsg::SubscriberStatusWithPermit { public_key: "subscriber_public_key".to_string(), permit: permit.clone(), @@ -850,7 +794,6 @@ mod tests { let bin = query(deps.as_ref(), env.clone(), query_msg).unwrap(); let response: SubscriberStatusResponse = from_binary(&bin).unwrap(); - // Check that the subscriber is not active assert!(!response.active); } @@ -859,14 +802,11 @@ mod tests { let mut deps = mock_dependencies(); let info = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); let init_msg = InstantiateMsg {}; - - // Create a custom environment let env = mock_env_for_permit(); - // Instantiate the contract instantiate(deps.as_mut(), env.clone(), info.clone(), init_msg).unwrap(); - // Add API keys with different identities + // Add API keys with additional fields execute( deps.as_mut(), env.clone(), @@ -874,6 +814,8 @@ mod tests { ExecuteMsg::AddApiKey { api_key: "api_key_1".to_string(), identity: Some("user_123".to_string()), + name: Some("Test Key 1".to_string()), + created: Some(1000), }, ) .unwrap(); @@ -885,6 +827,8 @@ mod tests { ExecuteMsg::AddApiKey { api_key: "api_key_2".to_string(), identity: Some("user_123".to_string()), + name: Some("Test Key 2".to_string()), + created: Some(2000), }, ) .unwrap(); @@ -896,17 +840,17 @@ mod tests { ExecuteMsg::AddApiKey { api_key: "api_key_3".to_string(), identity: Some("user_456".to_string()), + name: Some("Other Key".to_string()), + created: Some(3000), }, ) .unwrap(); - // Load a signed permit from a file (e.g., "./api_keys_by_identity_permit.json") let json_data = std::fs::read_to_string("./api_keys_by_identity_permit.json") .expect("Failed to read permit.json"); - let permit: secret_toolkit::permit::Permit = serde_json::from_str(&json_data) - .expect("Could not parse Permit from JSON"); + let permit: secret_toolkit::permit::Permit = + serde_json::from_str(&json_data).expect("Could not parse Permit from JSON"); - // Query API keys by identity "user_123" let query_msg = QueryMsg::ApiKeysByIdentityWithPermit { identity: "user_123".to_string(), permit, @@ -914,13 +858,23 @@ mod tests { let bin = query(deps.as_ref(), env.clone(), query_msg).unwrap(); let response: ApiKeysByIdentityResponse = from_binary(&bin).unwrap(); - // Verify that only the keys belonging to "user_123" are returned + // Verify that only the keys belonging to "user_123" are returned with correct details assert_eq!(response.api_keys.len(), 2); - assert!(response.api_keys.contains(&"api_key_1".to_string())); - assert!(response.api_keys.contains(&"api_key_2".to_string())); - assert!(!response.api_keys.contains(&"api_key_3".to_string())); - - println!("Query API keys by identity test passed!"); + let key1 = response + .api_keys + .iter() + .find(|x| x.api_key == "api_key_1") + .expect("Missing api_key_1"); + assert_eq!(key1.name, Some("Test Key 1".to_string())); + assert_eq!(key1.created, Some(1000)); + + let key2 = response + .api_keys + .iter() + .find(|x| x.api_key == "api_key_2") + .expect("Missing api_key_2"); + assert_eq!(key2.name, Some("Test Key 2".to_string())); + assert_eq!(key2.created, Some(2000)); } #[test] @@ -928,14 +882,11 @@ mod tests { let mut deps = mock_dependencies(); let info = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); let init_msg = InstantiateMsg {}; - - // Create a custom environment let env = mock_env_for_permit(); - // Instantiate the contract instantiate(deps.as_mut(), env.clone(), info.clone(), init_msg).unwrap(); - // Add API keys with identities + // Add API keys with and without identity execute( deps.as_mut(), env.clone(), @@ -943,6 +894,8 @@ mod tests { ExecuteMsg::AddApiKey { api_key: "api_key_1".to_string(), identity: Some("user_123".to_string()), + name: Some("Key 1".to_string()), + created: Some(1000), }, ) .unwrap(); @@ -954,11 +907,13 @@ mod tests { ExecuteMsg::AddApiKey { api_key: "api_key_2".to_string(), identity: Some("user_456".to_string()), + name: Some("Key 2".to_string()), + created: Some(2000), }, ) .unwrap(); - // Add API keys without identities + // API keys without identity execute( deps.as_mut(), env.clone(), @@ -966,6 +921,8 @@ mod tests { ExecuteMsg::AddApiKey { api_key: "api_key_3".to_string(), identity: None, + name: None, + created: None, }, ) .unwrap(); @@ -977,17 +934,18 @@ mod tests { ExecuteMsg::AddApiKey { api_key: "api_key_4".to_string(), identity: None, + name: None, + created: None, }, ) .unwrap(); - // Load a signed permit from a file (e.g., "./api_keys_by_identity_permit.json") let json_data = std::fs::read_to_string("./api_keys_by_identity_permit.json") .expect("Failed to read permit.json"); - let permit: secret_toolkit::permit::Permit = serde_json::from_str(&json_data) - .expect("Could not parse Permit from JSON"); + let permit: secret_toolkit::permit::Permit = + serde_json::from_str(&json_data).expect("Could not parse Permit from JSON"); - // Query API keys with an empty identity ("") + // Query with an empty identity should return an empty result let query_msg = QueryMsg::ApiKeysByIdentityWithPermit { identity: "".to_string(), permit, @@ -995,14 +953,6 @@ mod tests { let bin = query(deps.as_ref(), env.clone(), query_msg).unwrap(); let response: ApiKeysByIdentityResponse = from_binary(&bin).unwrap(); - // Verify that no keys are returned - assert_eq!( - response.api_keys.len(), - 0, - "Expected empty result, but got: {:?}", - response.api_keys - ); - - println!("✅ Test passed! Querying with an empty identity does not return keys without an identity."); + assert_eq!(response.api_keys.len(), 0); } -} \ No newline at end of file +} diff --git a/subscription-manager/src/msg.rs b/subscription-manager/src/msg.rs index 942374e..e9d2a11 100644 --- a/subscription-manager/src/msg.rs +++ b/subscription-manager/src/msg.rs @@ -2,33 +2,32 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use secret_toolkit::permit::Permit; - -// Struct for the message used to instantiate the contract +/// Instantiate message for the contract #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] pub struct InstantiateMsg {} -// Enum representing the different executable messages that the contract can handle +/// Execute message enum for the contract #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ExecuteMsg { - // Message to register a new subscriber using a public key + /// Register a new subscriber with a public key RegisterSubscriber { public_key: String }, - - // Message to remove a subscriber using a public key + /// Remove an existing subscriber using a public key RemoveSubscriber { public_key: String }, - - // Message to set a new admin for the contract using a public address + /// Set a new admin address for the contract SetAdmin { public_address: String }, - // Message to add an API key - // Add an API key with an optional identity + /// Add an API key with optional identity, name, and created timestamp AddApiKey { api_key: String, - identity: Option, // Optional field to associate an API key with an identity + identity: Option, + name: Option, // optional field: name of the API key + created: Option, // optional field: creation timestamp }, - // Message to revoke an API key + /// Revoke an existing API key RevokeApiKey { api_key: String }, } +/// Migrate message enum for contract migration #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum MigrateMsg { @@ -36,47 +35,54 @@ pub enum MigrateMsg { StdError {}, } -// Enum representing the different query messages that the contract can respond to +/// Query message enum for the contract #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum QueryMsg { + /// Query subscriber status with permit SubscriberStatusWithPermit { public_key: String, permit: Permit, }, + /// Get the admin address GetAdmin {}, - ApiKeysWithPermit { - permit: Permit, - }, + /// Query all API keys with permit (returns hashed API keys) + ApiKeysWithPermit { permit: Permit }, + /// Query API keys by identity with permit (returns API key details) ApiKeysByIdentityWithPermit { identity: String, permit: Permit, - } + }, } -// Struct used to respond to a query about a subscriber's status +/// Response structure for subscriber status query #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] pub struct SubscriberStatusResponse { - // Indicates if the subscriber is active or not pub active: bool, } -// Structure for API keys to respond to a query +/// Response structure for API keys query (hashed keys) #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] pub struct ApiKeyResponse { - // Previously `key: String`, - // Maybe rename to `hash: String` or `hashed_key: String`. pub hashed_key: String, } -// Structure for GetApiKeysResponse +/// Response structure for GetApiKeys query #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] pub struct GetApiKeysResponse { pub api_keys: Vec, } -// Struct for the response of the `query_by_identity` query +/// Structure returned in API keys by identity query containing key details +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] +pub struct ApiKeyDetail { + pub api_key: String, + pub name: Option, + pub created: Option, +} + +/// Response structure for API keys by identity query #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] pub struct ApiKeysByIdentityResponse { - pub api_keys: Vec, // List of API keys associated with the identity -} \ No newline at end of file + pub api_keys: Vec, +} diff --git a/subscription-manager/src/state.rs b/subscription-manager/src/state.rs index 86cfd5d..0e37fa7 100644 --- a/subscription-manager/src/state.rs +++ b/subscription-manager/src/state.rs @@ -1,45 +1,45 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use secret_toolkit::storage::{Keymap}; +use secret_toolkit::storage::Keymap; use cosmwasm_std::{Addr, Storage}; use cosmwasm_storage::{singleton, singleton_read, ReadonlySingleton, Singleton}; -// Key for accessing the configuration state in storage +/// Storage key for contract configuration pub static CONFIG_KEY: &[u8] = b"config"; -// Keymap for storing subscribers' information, using public keys as keys +/// Keymap for storing subscribers (keyed by public key) pub static SB_MAP: Keymap = Keymap::new(b"SB_MAP"); -// Keymap for storing API keys +/// Keymap for storing API keys pub static API_KEY_MAP: Keymap = Keymap::new(b"API_KEY_MAP"); -// Structure representing the state of the contract +/// Contract state structure containing the admin address #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] pub struct State { - // Address of the admin pub admin: Addr, } -// Structure representing a subscriber's information +/// Structure representing a subscriber #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] pub struct Subscriber { - // Status of the subscriber (active or not) pub status: bool, } -// Structure representing an API key to be stored +/// Structure representing an API key with additional fields #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] pub struct ApiKey { - pub identity: Option, // The optional identity associated with the key + pub identity: Option, + pub name: Option, // optional name field for the API key + pub created: Option, // optional creation timestamp } -// Function to access and modify the configuration state +/// Returns a mutable singleton for contract configuration pub fn config(storage: &mut dyn Storage) -> Singleton { singleton(storage, CONFIG_KEY) } -// Function to read the configuration state without modifying it +/// Returns a read-only singleton for contract configuration pub fn config_read(storage: &dyn Storage) -> ReadonlySingleton { singleton_read(storage, CONFIG_KEY) } From 2e0e83c0caf2b0732c550f2d2615262f9246ab39 Mon Sep 17 00:00:00 2001 From: DeDTihoN Date: Mon, 17 Feb 2025 16:16:10 +0200 Subject: [PATCH 16/17] currently disabled admin-only for add-api-key and revoke-api-key --- subscription-manager/src/contract.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/subscription-manager/src/contract.rs b/subscription-manager/src/contract.rs index 16d6cc5..fad002e 100644 --- a/subscription-manager/src/contract.rs +++ b/subscription-manager/src/contract.rs @@ -71,9 +71,10 @@ pub fn try_add_api_key( let state = config_read(deps.storage).load()?; // Only admin can add API keys - if info.sender != state.admin { - return Err(StdError::generic_err("Only admin can add API keys")); - } + // currently disabled for devportal use + // if info.sender != state.admin { + // return Err(StdError::generic_err("Only admin can add API keys")); + // } // Check if API key already exists if API_KEY_MAP.contains(deps.storage, &api_key) { @@ -106,9 +107,10 @@ pub fn try_revoke_api_key( let state = config_read(deps.storage).load()?; // Only admin can revoke API keys - if info.sender != state.admin { - return Err(StdError::generic_err("Only admin can revoke API keys")); - } + // currently disabled for devportal use + // if info.sender != state.admin { + // return Err(StdError::generic_err("Only admin can revoke API keys")); + // } // Check if API key exists if !API_KEY_MAP.contains(deps.storage, &api_key) { From 0a91d47a788853bd46ee86d79c0faf56d96c913d Mon Sep 17 00:00:00 2001 From: DeDTihoN Date: Thu, 20 Feb 2025 16:25:12 +0200 Subject: [PATCH 17/17] refactoring changes --- subscription-manager/Cargo.toml | 2 +- subscription-manager/src/contract.rs | 362 +++++++++++++++------------ subscription-manager/src/msg.rs | 13 +- subscription-manager/src/state.rs | 7 +- 4 files changed, 207 insertions(+), 177 deletions(-) diff --git a/subscription-manager/Cargo.toml b/subscription-manager/Cargo.toml index c57c2ed..e4e7898 100644 --- a/subscription-manager/Cargo.toml +++ b/subscription-manager/Cargo.toml @@ -28,7 +28,7 @@ schema = ["cosmwasm-schema"] [dependencies] cosmwasm-schema = { version = "1.1.0", optional = true } -cosmwasm-std = { package = "secret-cosmwasm-std", version = "1.1.11" , features = ["stargate"]} +cosmwasm-std = { package = "secret-cosmwasm-std", version = "1.1.11" , features = ["stargate", "random"]} cosmwasm-storage = { package = "secret-cosmwasm-storage", version = "1.1.11" } schemars = "0.8.11" secret-toolkit = { version = "0.10.0", default-features = false, features = ["utils", "storage", "serialization", "viewing-key", "permit"] } diff --git a/subscription-manager/src/contract.rs b/subscription-manager/src/contract.rs index fad002e..785d72a 100644 --- a/subscription-manager/src/contract.rs +++ b/subscription-manager/src/contract.rs @@ -4,13 +4,49 @@ use cosmwasm_std::{ }; use secret_toolkit::permit::{validate, Permit}; use sha2::{Digest, Sha256}; - use crate::msg::{ ApiKeyDetail, ApiKeyResponse, ApiKeysByIdentityResponse, ExecuteMsg, GetApiKeysResponse, InstantiateMsg, MigrateMsg, QueryMsg, SubscriberStatusResponse, }; use crate::state::{config, config_read, ApiKey, State, Subscriber, API_KEY_MAP, SB_MAP}; +/// Generates a pseudo-random API key using env.block.random and the provided identity. +/// Mimics the following JavaScript function: +/// ```js +/// const generateApiKey = (): string => { +/// const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-'; +/// return `sk-${Array.from({ length: 72 }, () => characters.charAt(Math.floor(Math.random() * characters.length))).join('')}`; +/// }; +/// ``` +/// Instead of Math.random(), it uses the available random seed and the identity. +fn generate_api_key(random: &[u8], identity: &str, created: u64) -> String { + let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"; + let mut key = String::from("sk-"); // prefix as in the JS example + // Create an initial seed by hashing the random seed with the identity. + let mut hasher = Sha256::new(); + sha2::Digest::update(&mut hasher, random); + sha2::Digest::update(&mut hasher, identity.as_bytes()); + sha2::Digest::update(&mut hasher, created.to_string().as_bytes()); + let mut seed = hasher.finalize_reset().to_vec(); + + // Append characters until key reaches desired length. + while key.len() < 75 { + for &byte in seed.iter() { + if key.len() >= 75 { + break; + } + let idx = (byte as usize) % alphabet.len(); + let ch = alphabet.chars().nth(idx).unwrap(); + key.push(ch); + } + // Update the seed by hashing the current seed. + let mut seed_hasher = Sha256::new(); + sha2::Digest::update(&mut seed_hasher, &seed); + seed = seed_hasher.finalize().to_vec(); + } + key +} + #[entry_point] pub fn instantiate( deps: DepsMut, @@ -18,16 +54,14 @@ pub fn instantiate( info: MessageInfo, _msg: InstantiateMsg, ) -> StdResult { - // Set admin as the sender of the instantiate message + // Set the admin as the sender of the instantiate message. let state = State { admin: info.sender.clone(), }; - // Log initialization debug message deps.api .debug(format!("Contract was initialized by {}", info.sender).as_str()); - // Save state to storage config(deps.storage).save(&state)?; Ok(Response::default()) @@ -48,83 +82,108 @@ pub fn execute( try_remove_subscriber(deps, info, public_key) } ExecuteMsg::SetAdmin { public_address } => try_set_admin(deps, info, public_address), + // Note: AddApiKey now generates the key internally. ExecuteMsg::AddApiKey { - api_key, identity, name, created, - } => try_add_api_key(deps, info, api_key, identity, name, created), + } => try_add_api_key(deps, env, info, identity, name, created), ExecuteMsg::RevokeApiKey { api_key } => try_revoke_api_key(deps, info, api_key), } } -/// Adds an API key with optional identity, name, and creation timestamp +/// Adds a new API key for the given identity. The sender must either be the identity owner or the admin. +/// The API key is generated using env.block.random and the identity. The full API key is returned to +/// the caller, but only its hash (SHA‑256) is stored in the contract. pub fn try_add_api_key( deps: DepsMut, + env: Env, info: MessageInfo, - api_key: String, - identity: Option, + identity: String, name: Option, created: Option, ) -> StdResult { - // Load current contract state to verify admin privileges let state = config_read(deps.storage).load()?; + // Verify that the sender is either the identity owner or an admin. + if info.sender != Addr::unchecked(identity.clone()) && info.sender != state.admin { + return Err(StdError::generic_err( + "Sender must be admin or match the identity", + )); + } + + // Use env.block.random to generate a pseudo-random API key. + let random = env + .block + .random + .ok_or_else(|| StdError::generic_err("Missing random seed"))?; + let full_api_key = generate_api_key(random.as_slice(), &identity, created.unwrap_or(0)); + // Compute the string representation: first 10 characters + "..." + last 3 characters. + let str_representation = if full_api_key.len() >= 13 { + format!( + "{}...{}", + &full_api_key[..10], + &full_api_key[full_api_key.len() - 3..] + ) + } else { + full_api_key.clone() + }; - // Only admin can add API keys - // currently disabled for devportal use - // if info.sender != state.admin { - // return Err(StdError::generic_err("Only admin can add API keys")); - // } - - // Check if API key already exists - if API_KEY_MAP.contains(deps.storage, &api_key) { + // Check if the API key (by its string representation) already exists. + if API_KEY_MAP.contains(deps.storage, &str_representation) { return Err(StdError::generic_err("API key already exists")); } - // Create a new API key entry with provided details + // Compute the hash of the API key to store in the contract. + let mut key_hasher = Sha256::new(); + key_hasher.update(full_api_key.as_bytes()); + let key_hash = hex::encode(key_hasher.finalize()); + + // Create a new API key entry storing only the hash, along with additional details. let api_key_data = ApiKey { - identity, + identity: identity.clone(), + hash: key_hash, name, created, }; - // Insert the API key data into storage API_KEY_MAP - .insert(deps.storage, &api_key, &api_key_data) + .insert(deps.storage, &str_representation, &api_key_data) .map_err(|err| StdError::generic_err(err.to_string()))?; + // Return the full API key to the caller. Ok(Response::new() .add_attribute("action", "add_api_key") - .add_attribute("stored_key", api_key)) + .add_attribute("api_key", full_api_key)) } -/// Revokes (removes) an existing API key +/// Revokes (removes) an existing API key. The parameter is the string representation of the API key. +/// The sender must be either the admin or the owner (matching the stored identity). pub fn try_revoke_api_key( deps: DepsMut, info: MessageInfo, - api_key: String, + api_key_str: String, ) -> StdResult { let state = config_read(deps.storage).load()?; - // Only admin can revoke API keys - // currently disabled for devportal use - // if info.sender != state.admin { - // return Err(StdError::generic_err("Only admin can revoke API keys")); - // } + // Check if the API key exists. + let api_key_data = API_KEY_MAP + .get(deps.storage, &api_key_str) + .ok_or_else(|| StdError::generic_err("API key not found"))?; - // Check if API key exists - if !API_KEY_MAP.contains(deps.storage, &api_key) { - return Err(StdError::generic_err("API key not found")); + // Verify that the sender is either the admin or the owner of the API key. + if info.sender != state.admin && info.sender != Addr::unchecked(api_key_data.identity.clone()) { + return Err(StdError::generic_err( + "Unauthorized: sender does not own this API key", + )); } - // Remove the API key from storage API_KEY_MAP - .remove(deps.storage, &api_key) + .remove(deps.storage, &api_key_str) .map_err(|err| StdError::generic_err(err.to_string()))?; Ok(Response::new() .add_attribute("action", "revoke_api_key") - .add_attribute("removed_key", api_key)) + .add_attribute("removed_api_key", api_key_str)) } #[entry_point] @@ -149,7 +208,7 @@ pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> StdResult } } -/// Registers a subscriber using a public key +/// Registers a subscriber using a public key. pub fn try_register_subscriber( deps: DepsMut, info: MessageInfo, @@ -160,7 +219,6 @@ pub fn try_register_subscriber( return Err(StdError::generic_err("Only admin can register subscribers")); } - // Check if subscriber already exists if SB_MAP.contains(deps.storage, &public_key) { return Err(StdError::generic_err("Subscriber already registered")); } @@ -175,7 +233,7 @@ pub fn try_register_subscriber( .add_attribute("subscriber", public_key)) } -/// Removes a subscriber using a public key +/// Removes a subscriber using a public key. pub fn try_remove_subscriber( deps: DepsMut, info: MessageInfo, @@ -186,7 +244,6 @@ pub fn try_remove_subscriber( return Err(StdError::generic_err("Only admin can remove subscribers")); } - // Check if subscriber exists if !SB_MAP.contains(deps.storage, &public_key) { return Err(StdError::generic_err("Subscriber not registered")); } @@ -200,7 +257,7 @@ pub fn try_remove_subscriber( .add_attribute("subscriber", public_key)) } -/// Sets a new admin for the contract +/// Sets a new admin for the contract. pub fn try_set_admin( deps: DepsMut, info: MessageInfo, @@ -209,17 +266,16 @@ pub fn try_set_admin( let mut config = config(deps.storage); let mut state = config.load()?; - // Only current admin can change the admin if info.sender != state.admin { - return Err(StdError::generic_err("Only the current admin can set a new admin")); + return Err(StdError::generic_err( + "Only the current admin can set a new admin", + )); } - // Validate the new admin address let final_address = deps.api.addr_validate(&public_address).map_err(|err| { StdError::generic_err(format!("Invalid address: {}", err)) })?; - // Update state with new admin address state.admin = final_address; config.save(&state)?; @@ -244,13 +300,13 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { } } -/// Returns the current admin address +/// Returns the current admin address. fn get_admin(deps: Deps) -> StdResult { let state = config_read(deps.storage).load()?; Ok(state.admin) } -/// Query subscriber status using a permit for authorization +/// Queries subscriber status using a permit. fn query_subscriber_with_permit( deps: Deps, env: Env, @@ -260,7 +316,6 @@ fn query_subscriber_with_permit( let state = config_read(deps.storage).load()?; let admin_addr = state.admin; - // Validate permit name if permit.params.permit_name != "query_subscriber_permit" { return Err(StdError::generic_err("Invalid permit name")); } @@ -275,7 +330,6 @@ fn query_subscriber_with_permit( Some("secret"), )?; - // Only admin is allowed to query if signer_addr != admin_addr { return Err(StdError::generic_err("Unauthorized: not the admin")); } @@ -286,7 +340,7 @@ fn query_subscriber_with_permit( Ok(SubscriberStatusResponse { active }) } -/// Query all API keys (returns hashed API keys) using a permit for authorization +/// Queries all API keys using a permit. The stored hash is returned directly. fn query_api_keys_with_permit( deps: Deps, env: Env, @@ -295,7 +349,6 @@ fn query_api_keys_with_permit( let state = config_read(deps.storage).load()?; let admin_addr = state.admin; - // Validate permit name if permit.params.permit_name != "api_keys_permit" { return Err(StdError::generic_err("Invalid permit name")); } @@ -311,21 +364,15 @@ fn query_api_keys_with_permit( Some("secret"), )?; - // Only admin is allowed to query if signer_addr != admin_addr { return Err(StdError::generic_err("Unauthorized: not the admin")); } - // Iterate over all API keys, hash the keys, and return their hashed values let api_keys: Vec = API_KEY_MAP - .iter_keys(deps.storage)? - .filter_map(|key_result| { - if let Ok(api_key) = key_result { - let mut hasher = Sha256::new(); - hasher.update(api_key.as_bytes()); - let hashed_key = hex::encode(hasher.finalize()); - - Some(ApiKeyResponse { hashed_key }) + .iter(deps.storage)? + .filter_map(|result| { + if let Ok((_key, data)) = result { + Some(ApiKeyResponse { hashed_key: data.hash }) } else { None } @@ -335,7 +382,7 @@ fn query_api_keys_with_permit( Ok(GetApiKeysResponse { api_keys }) } -/// Query API keys by identity (returns detailed API key information) using a permit for authorization +/// Queries API keys by identity using a permit. fn query_api_keys_by_identity_with_permit( deps: Deps, env: Env, @@ -345,7 +392,6 @@ fn query_api_keys_by_identity_with_permit( let state = config_read(deps.storage).load()?; let admin_addr = state.admin; - // Validate permit name if permit.params.permit_name != "api_keys_by_identity_permit" { return Err(StdError::generic_err("Invalid permit name")); } @@ -361,24 +407,20 @@ fn query_api_keys_by_identity_with_permit( Some("secret"), )?; - // Only admin is allowed to query if signer_addr != admin_addr { return Err(StdError::generic_err("Unauthorized: not the admin")); } - // Iterate over all API keys and filter by the provided identity let api_keys: Vec = API_KEY_MAP .iter(deps.storage)? .filter_map(|result| { if let Ok((key, data)) = result { - if let Some(stored_identity) = &data.identity { - if stored_identity == &identity { - return Some(ApiKeyDetail { - api_key: key, - name: data.name, - created: data.created, - }); - } + if data.identity == identity { + return Some(ApiKeyDetail { + api_key: key, // string representation + name: data.name, + created: data.created, + }); } } None @@ -393,7 +435,7 @@ mod tests { use super::*; use cosmwasm_std::testing::*; use cosmwasm_std::{ - attr, from_binary, BlockInfo, Coin, ContractInfo, Timestamp, TransactionInfo, Uint128, + attr, from_binary, BlockInfo, Coin, ContractInfo, Timestamp, TransactionInfo, Uint128, Addr, }; /// Mocks an environment for permit tests @@ -422,19 +464,18 @@ mod tests { fn test_migrate_clears_api_key_map() { let mut deps = mock_dependencies(); - // Initialize contract with admin address + // Initialize contract with admin address. let info = mock_info("admin", &[]); let init_msg = InstantiateMsg {}; instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); - // Add two API keys + // Add two API keys with different identities. execute( deps.as_mut(), mock_env(), info.clone(), ExecuteMsg::AddApiKey { - api_key: "test_key1".to_string(), - identity: None, + identity: "user1".to_string(), name: None, created: None, }, @@ -446,58 +487,57 @@ mod tests { mock_env(), info.clone(), ExecuteMsg::AddApiKey { - api_key: "test_key2".to_string(), - identity: None, + identity: "user2".to_string(), name: None, created: None, }, ) .unwrap(); - // Ensure two keys are added + // Ensure two keys are added. let keys: Vec = API_KEY_MAP .iter_keys(deps.as_ref().storage) .unwrap() - .filter_map(|key_result| key_result.ok()) + .filter_map(|res| res.ok()) .collect(); assert_eq!(keys.len(), 2); - // Migrate (clear) the API key map + // Migrate (clear) the API key map. migrate(deps.as_mut(), mock_env(), MigrateMsg::Migrate {}).unwrap(); - // Check that API key map is empty - let keys_after_migration: Vec = API_KEY_MAP + let keys_after: Vec = API_KEY_MAP .iter_keys(deps.as_ref().storage) .unwrap() - .filter_map(|key_result| key_result.ok()) + .filter_map(|res| res.ok()) .collect(); - assert!(keys_after_migration.is_empty()); + assert!(keys_after.is_empty()); } #[test] fn test_query_api_keys_with_real_permit() { let mut deps = mock_dependencies(); - let info = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); + let info_for_instantiate = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); let init_msg = InstantiateMsg {}; let env = mock_env_for_permit(); - instantiate(deps.as_mut(), env.clone(), info.clone(), init_msg).unwrap(); + instantiate(deps.as_mut(), env.clone(), info_for_instantiate.clone(), init_msg).unwrap(); + + let info = mock_info("user1", &[]); - // Add a test API key + // Add a test API key. execute( deps.as_mut(), env.clone(), info.clone(), ExecuteMsg::AddApiKey { - api_key: "test_key1".to_string(), - identity: None, + identity: "user1".to_string(), name: None, created: None, }, ) .unwrap(); - // Read a permit from file "./api_keys_permit.json" + // Read a permit from file "./api_keys_permit.json". let json_data = std::fs::read_to_string("./api_keys_permit.json").unwrap(); let permit: secret_toolkit::permit::Permit = serde_json::from_str(&json_data).expect("Could not parse Permit from JSON"); @@ -517,26 +557,59 @@ mod tests { #[test] fn revoke_api_key_and_query_with_permit() { let mut deps = mock_dependencies(); - let info = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); + let info_for_instantiate = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); let init_msg = InstantiateMsg {}; let env = mock_env_for_permit(); - instantiate(deps.as_mut(), env.clone(), info.clone(), init_msg).unwrap(); + instantiate(deps.as_mut(), env.clone(), info_for_instantiate.clone(), init_msg).unwrap(); - // Add an API key - let add_msg = ExecuteMsg::AddApiKey { - api_key: "test_api_key".to_string(), - identity: None, - name: None, - created: None, - }; - execute(deps.as_mut(), env.clone(), info.clone(), add_msg).unwrap(); + let info = mock_info("user1", &[]); - // Revoke the API key - let revoke_msg = ExecuteMsg::RevokeApiKey { - api_key: "test_api_key".to_string(), + // Add an API key. + let add_res = execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::AddApiKey { + identity: "user1".to_string(), + name: None, + created: None, + }, + ) + .unwrap(); + + // Extract the full API key from the response attributes. + let full_api_key = add_res + .attributes + .iter() + .find(|attr| attr.key == "api_key") + .expect("Missing api_key attribute") + .value + .clone(); + + println!("full_api_key: {}", full_api_key); + + // Compute the string representation: first 10 characters + "..." + last 3 characters. + let str_repr = if full_api_key.len() >= 13 { + format!( + "{}...{}", + &full_api_key[..10], + &full_api_key[full_api_key.len() - 3..] + ) + } else { + full_api_key.clone() }; - execute(deps.as_mut(), env.clone(), info.clone(), revoke_msg).unwrap(); + + // Revoke the API key using its string representation. + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::RevokeApiKey { + api_key: str_repr.clone(), + }, + ) + .unwrap(); let json_data = std::fs::read_to_string("./api_keys_permit.json") .expect("Failed to read permit.json"); @@ -745,7 +818,7 @@ mod tests { #[test] fn query_unregistered_subscriber() { let mut deps = mock_dependencies(); - let info = mock_info("secret1p55wr2n6f63wyap8g9dckkxmf4wvq73ensxrw4", &[]); + let info = mock_info("user1", &[]); let init_msg = InstantiateMsg {}; let env = mock_env_for_permit(); instantiate(deps.as_mut(), env.clone(), info, init_msg).unwrap(); @@ -808,14 +881,13 @@ mod tests { instantiate(deps.as_mut(), env.clone(), info.clone(), init_msg).unwrap(); - // Add API keys with additional fields + // Add API keys for different identities. execute( deps.as_mut(), env.clone(), info.clone(), ExecuteMsg::AddApiKey { - api_key: "api_key_1".to_string(), - identity: Some("user_123".to_string()), + identity: "user_123".to_string(), name: Some("Test Key 1".to_string()), created: Some(1000), }, @@ -827,8 +899,7 @@ mod tests { env.clone(), info.clone(), ExecuteMsg::AddApiKey { - api_key: "api_key_2".to_string(), - identity: Some("user_123".to_string()), + identity: "user_123".to_string(), name: Some("Test Key 2".to_string()), created: Some(2000), }, @@ -840,8 +911,7 @@ mod tests { env.clone(), info.clone(), ExecuteMsg::AddApiKey { - api_key: "api_key_3".to_string(), - identity: Some("user_456".to_string()), + identity: "user_456".to_string(), name: Some("Other Key".to_string()), created: Some(3000), }, @@ -860,23 +930,16 @@ mod tests { let bin = query(deps.as_ref(), env.clone(), query_msg).unwrap(); let response: ApiKeysByIdentityResponse = from_binary(&bin).unwrap(); - // Verify that only the keys belonging to "user_123" are returned with correct details + // Verify that only keys belonging to "user_123" are returned. assert_eq!(response.api_keys.len(), 2); - let key1 = response - .api_keys - .iter() - .find(|x| x.api_key == "api_key_1") - .expect("Missing api_key_1"); - assert_eq!(key1.name, Some("Test Key 1".to_string())); - assert_eq!(key1.created, Some(1000)); - - let key2 = response - .api_keys - .iter() - .find(|x| x.api_key == "api_key_2") - .expect("Missing api_key_2"); - assert_eq!(key2.name, Some("Test Key 2".to_string())); - assert_eq!(key2.created, Some(2000)); + for key_detail in response.api_keys { + assert!(!key_detail.api_key.is_empty()); + match key_detail.name.as_deref() { + Some("Test Key 1") => assert_eq!(key_detail.created, Some(1000)), + Some("Test Key 2") => assert_eq!(key_detail.created, Some(2000)), + _ => panic!("Unexpected key detail returned"), + } + } } #[test] @@ -888,14 +951,13 @@ mod tests { instantiate(deps.as_mut(), env.clone(), info.clone(), init_msg).unwrap(); - // Add API keys with and without identity + // Add API keys for specific identities. execute( deps.as_mut(), env.clone(), info.clone(), ExecuteMsg::AddApiKey { - api_key: "api_key_1".to_string(), - identity: Some("user_123".to_string()), + identity: "user_123".to_string(), name: Some("Key 1".to_string()), created: Some(1000), }, @@ -907,47 +969,19 @@ mod tests { env.clone(), info.clone(), ExecuteMsg::AddApiKey { - api_key: "api_key_2".to_string(), - identity: Some("user_456".to_string()), + identity: "user_456".to_string(), name: Some("Key 2".to_string()), created: Some(2000), }, ) .unwrap(); - // API keys without identity - execute( - deps.as_mut(), - env.clone(), - info.clone(), - ExecuteMsg::AddApiKey { - api_key: "api_key_3".to_string(), - identity: None, - name: None, - created: None, - }, - ) - .unwrap(); - - execute( - deps.as_mut(), - env.clone(), - info.clone(), - ExecuteMsg::AddApiKey { - api_key: "api_key_4".to_string(), - identity: None, - name: None, - created: None, - }, - ) - .unwrap(); - + // Query with an empty identity should return an empty result. let json_data = std::fs::read_to_string("./api_keys_by_identity_permit.json") .expect("Failed to read permit.json"); let permit: secret_toolkit::permit::Permit = serde_json::from_str(&json_data).expect("Could not parse Permit from JSON"); - // Query with an empty identity should return an empty result let query_msg = QueryMsg::ApiKeysByIdentityWithPermit { identity: "".to_string(), permit, diff --git a/subscription-manager/src/msg.rs b/subscription-manager/src/msg.rs index e9d2a11..143164e 100644 --- a/subscription-manager/src/msg.rs +++ b/subscription-manager/src/msg.rs @@ -10,20 +10,15 @@ pub struct InstantiateMsg {} #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ExecuteMsg { - /// Register a new subscriber with a public key RegisterSubscriber { public_key: String }, - /// Remove an existing subscriber using a public key RemoveSubscriber { public_key: String }, - /// Set a new admin address for the contract SetAdmin { public_address: String }, - /// Add an API key with optional identity, name, and created timestamp + // The AddApiKey message now only requires identity, name, and created. AddApiKey { - api_key: String, - identity: Option, - name: Option, // optional field: name of the API key - created: Option, // optional field: creation timestamp + identity: String, + name: Option, + created: Option, }, - /// Revoke an existing API key RevokeApiKey { api_key: String }, } diff --git a/subscription-manager/src/state.rs b/subscription-manager/src/state.rs index 0e37fa7..5faee9e 100644 --- a/subscription-manager/src/state.rs +++ b/subscription-manager/src/state.rs @@ -29,9 +29,10 @@ pub struct Subscriber { /// Structure representing an API key with additional fields #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, JsonSchema)] pub struct ApiKey { - pub identity: Option, - pub name: Option, // optional name field for the API key - pub created: Option, // optional creation timestamp + pub identity: String, // Associated identity + pub hash: String, // Hash of the API key + pub name: Option, + pub created: Option, } /// Returns a mutable singleton for contract configuration