diff --git a/Cargo.lock b/Cargo.lock index a7d2736783..0eadd13981 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5768,6 +5768,7 @@ dependencies = [ "nym-http-api-client", "nym-http-api-common", "nym-mixnet-contract-common", + "nym-node-families-contract-common", "nym-node-requests", "nym-node-tester-utils", "nym-pemstore", @@ -7608,6 +7609,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "nym-node-families-contract-common" +version = "1.21.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-utils", + "nym-contracts-common", + "nym-mixnet-contract-common", + "schemars 0.8.22", + "serde", + "thiserror 2.0.12", +] + [[package]] name = "nym-node-metrics" version = "1.21.0" @@ -7688,6 +7704,7 @@ dependencies = [ "humantime", "itertools 0.14.0", "moka", + "nym-api-requests", "nym-bin-common", "nym-contracts-common", "nym-credential-proxy-lib", @@ -8572,6 +8589,7 @@ dependencies = [ "nym-mixnet-contract-common", "nym-multisig-contract-common", "nym-network-defaults", + "nym-node-families-contract-common", "nym-performance-contract-common", "nym-serde-helpers", "nym-vesting-contract-common", diff --git a/Cargo.toml b/Cargo.toml index db10a4b7ec..5d7ea433b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,6 @@ members = [ "common/client-libs/mixnet-client", "common/client-libs/validator-client", "common/commands", - "common/nym-common", "common/config", "common/cosmwasm-smart-contracts/coconut-dkg", "common/cosmwasm-smart-contracts/contracts-common", @@ -41,6 +40,7 @@ members = [ "common/cosmwasm-smart-contracts/group-contract", "common/cosmwasm-smart-contracts/mixnet-contract", "common/cosmwasm-smart-contracts/multisig-contract", + "common/cosmwasm-smart-contracts/node-families-contract", "common/cosmwasm-smart-contracts/nym-performance-contract", "common/cosmwasm-smart-contracts/nym-pool-contract", "common/cosmwasm-smart-contracts/vesting-contract", @@ -70,11 +70,14 @@ members = [ "common/node-tester-utils", "common/nonexhaustive-delayqueue", "common/nym-cache", + "common/nym-common", "common/nym-connection-monitor", "common/nym-id", "common/nym-kcp", - "common/nym-lp", "common/nym-kkt", + "common/nym-kkt-ciphersuite", + "common/nym-kkt-context", + "common/nym-lp", "common/nym-metrics", "common/nym_offline_compact_ecash", "common/nymnoise", @@ -90,9 +93,9 @@ members = [ "common/nymsphinx/params", "common/nymsphinx/routing", "common/nymsphinx/types", - "common/nyxd-scraper-sqlite", "common/nyxd-scraper-psql", "common/nyxd-scraper-shared", + "common/nyxd-scraper-sqlite", "common/pemstore", "common/registration", "common/serde-helpers", @@ -122,6 +125,7 @@ members = [ "common/zulip-client", "documentation/autodoc", "gateway", + "integration-tests", "nym-api", "nym-api/nym-api-requests", "nym-authenticator-client", @@ -129,6 +133,7 @@ members = [ "nym-credential-proxy/nym-credential-proxy", "nym-credential-proxy/nym-credential-proxy-requests", "nym-data-observatory", + "nym-gateway-probe", "nym-ip-packet-client", "nym-network-monitor", "nym-node", @@ -140,6 +145,7 @@ members = [ "nym-outfox", "nym-registration-client", "nym-signers-monitor", + "nym-sqlx-pool-guard", "nym-statistics-api", "nym-validator-rewarder", "nyx-chain-watcher", @@ -147,19 +153,18 @@ members = [ "sdk/ffi/go", "sdk/ffi/shared", "sdk/rust/nym-sdk", - "smolmix/core", "service-providers/common", "service-providers/ip-packet-router", "service-providers/network-requester", - "nym-sqlx-pool-guard", + "smolmix/core", "tools/echo-server", "tools/internal/contract-state-importer/importer-cli", "tools/internal/contract-state-importer/importer-contract", + "tools/internal/localnet-orchestrator", + "tools/internal/localnet-orchestrator/dkg-bypass-contract", "tools/internal/mixnet-connectivity-check", # "tools/internal/sdk-version-bump", "tools/internal/ssl-inject", - "tools/internal/localnet-orchestrator", - "tools/internal/localnet-orchestrator/dkg-bypass-contract", "tools/internal/validator-status-check", "tools/nym-cli", "tools/nym-id-cli", @@ -172,27 +177,23 @@ members = [ "wasm/mix-fetch", "wasm/node-tester", "wasm/zknym-lib", - "nym-gateway-probe", - "integration-tests", - "common/nym-kkt-ciphersuite", - "common/nym-kkt-context", ] default-members = [ "clients/native", "clients/socks5", - "nym-authenticator-client", "nym-api", + "nym-authenticator-client", "nym-credential-proxy/nym-credential-proxy", "nym-node", + "nym-registration-client", "nym-statistics-api", "nym-validator-rewarder", "nyx-chain-watcher", "service-providers/ip-packet-router", "service-providers/network-requester", + "tools/internal/localnet-orchestrator", "tools/nymvisor", - "nym-registration-client", - "tools/internal/localnet-orchestrator" ] exclude = ["contracts", "nym-wallet", "cpu-cycles"] @@ -472,6 +473,7 @@ nym-noise-keys = { version = "1.21.0", path = "common/nymnoise/keys" } nym-nonexhaustive-delayqueue = { version = "1.21.0", path = "common/nonexhaustive-delayqueue" } nym-node-requests = { version = "1.21.0", path = "nym-node/nym-node-requests", default-features = false } nym-node-metrics = { version = "1.21.0", path = "nym-node/nym-node-metrics" } +nym-node-families-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/node-families-contract" } nym-ordered-buffer = { version = "1.21.0", path = "common/socks5/ordered-buffer" } nym-outfox = { version = "1.21.0", path = "nym-outfox" } nym-registration-common = { version = "1.21.0", path = "common/registration" } @@ -617,3 +619,4 @@ exit = "deny" panic = "deny" unimplemented = "deny" unreachable = "deny" + diff --git a/common/client-libs/validator-client/Cargo.toml b/common/client-libs/validator-client/Cargo.toml index ea1de63563..ca5424537f 100644 --- a/common/client-libs/validator-client/Cargo.toml +++ b/common/client-libs/validator-client/Cargo.toml @@ -26,6 +26,7 @@ nym-ecash-contract-common = { workspace = true } nym-multisig-contract-common = { workspace = true } nym-group-contract-common = { workspace = true } nym-performance-contract-common = { workspace = true } +nym-node-families-contract-common = { workspace = true } nym-serde-helpers = { workspace = true, features = ["hex", "base64"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/common/client-libs/validator-client/src/nym_api/mod.rs b/common/client-libs/validator-client/src/nym_api/mod.rs index e2dffe496e..db6daad7a9 100644 --- a/common/client-libs/validator-client/src/nym_api/mod.rs +++ b/common/client-libs/validator-client/src/nym_api/mod.rs @@ -15,6 +15,7 @@ use nym_api_requests::ecash::models::{ VerifyEcashTicketBody, }; use nym_api_requests::ecash::VerificationKeyResponse; +use nym_api_requests::models::node_families::NodeFamily; use nym_api_requests::models::{ AnnotationResponse, ApiHealthResponse, BinaryBuildInformationOwned, ChainBlocksStatusResponse, ChainStatusResponse, KeyRotationInfoResponse, NodePerformanceResponse, NodeRefreshBody, @@ -389,6 +390,45 @@ pub trait NymApiClientExt: ApiClient { Ok(bonds) } + #[tracing::instrument(level = "debug", skip_all)] + async fn get_node_families( + &self, + page: Option, + per_page: Option, + ) -> Result, NymAPIError> { + let mut params = Vec::new(); + if let Some(page) = page { + params.push(("page", page.to_string())) + } + if let Some(per_page) = per_page { + params.push(("per_page", per_page.to_string())) + } + self.get_json( + &[routes::V1_API_VERSION, routes::NODE_FAMILIES_ROUTES], + ¶ms, + ) + .await + } + + async fn get_all_node_families(&self) -> Result, NymAPIError> { + // TODO: deal with paging in macro or some helper function or something, because it's the same pattern everywhere + let mut page = 0; + let mut families = Vec::new(); + + loop { + let mut res = self.get_node_families(Some(page), None).await?; + + families.append(&mut res.data); + if families.len() < res.pagination.total { + page += 1 + } else { + break; + } + } + + Ok(families) + } + #[deprecated] #[tracing::instrument(level = "debug", skip_all)] async fn get_basic_mixnodes(&self) -> Result, NymAPIError> { diff --git a/common/client-libs/validator-client/src/nym_api/routes.rs b/common/client-libs/validator-client/src/nym_api/routes.rs index 755b1282ef..1181a9d959 100644 --- a/common/client-libs/validator-client/src/nym_api/routes.rs +++ b/common/client-libs/validator-client/src/nym_api/routes.rs @@ -38,6 +38,7 @@ pub mod ecash { } pub const NYM_NODES_ROUTES: &str = "nym-nodes"; +pub const NODE_FAMILIES_ROUTES: &str = "node-families"; pub use nym_nodes::*; pub mod nym_nodes { diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/mod.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/mod.rs index ad7db0991b..ffc08e89c7 100644 --- a/common/client-libs/validator-client/src/nyxd/contract_traits/mod.rs +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/mod.rs @@ -13,6 +13,7 @@ pub mod ecash_query_client; pub mod group_query_client; pub mod mixnet_query_client; pub mod multisig_query_client; +pub mod node_families_query_client; pub mod performance_query_client; pub mod vesting_query_client; @@ -22,6 +23,7 @@ pub mod ecash_signing_client; pub mod group_signing_client; pub mod mixnet_signing_client; pub mod multisig_signing_client; +pub mod node_families_signing_client; pub mod performance_signing_client; pub mod vesting_signing_client; @@ -31,6 +33,7 @@ pub use ecash_query_client::{EcashQueryClient, PagedEcashQueryClient}; pub use group_query_client::{GroupQueryClient, PagedGroupQueryClient}; pub use mixnet_query_client::{MixnetQueryClient, PagedMixnetQueryClient}; pub use multisig_query_client::{MultisigQueryClient, PagedMultisigQueryClient}; +pub use node_families_query_client::{NodeFamiliesQueryClient, PagedNodeFamiliesQueryClient}; pub use performance_query_client::{PagedPerformanceQueryClient, PerformanceQueryClient}; pub use vesting_query_client::{PagedVestingQueryClient, VestingQueryClient}; @@ -40,6 +43,7 @@ pub use ecash_signing_client::EcashSigningClient; pub use group_signing_client::GroupSigningClient; pub use mixnet_signing_client::MixnetSigningClient; pub use multisig_signing_client::MultisigSigningClient; +pub use node_families_signing_client::NodeFamiliesSigningClient; pub use performance_signing_client::PerformanceSigningClient; pub use vesting_signing_client::VestingSigningClient; @@ -49,6 +53,7 @@ pub trait NymContractsProvider { fn mixnet_contract_address(&self) -> Option<&AccountId>; fn vesting_contract_address(&self) -> Option<&AccountId>; fn performance_contract_address(&self) -> Option<&AccountId>; + fn node_families_contract_address(&self) -> Option<&AccountId>; // coconut-related fn ecash_contract_address(&self) -> Option<&AccountId>; @@ -62,6 +67,7 @@ pub struct TypedNymContracts { pub mixnet_contract_address: Option, pub vesting_contract_address: Option, pub performance_contract_address: Option, + pub node_families_contract_address: Option, pub ecash_contract_address: Option, pub group_contract_address: Option, @@ -86,6 +92,10 @@ impl TryFrom for TypedNymContracts { .performance_contract_address .map(|addr| addr.parse()) .transpose()?, + node_families_contract_address: value + .node_families_contract_address + .map(|addr| addr.parse()) + .transpose()?, ecash_contract_address: value .ecash_contract_address .map(|addr| addr.parse()) diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/node_families_query_client.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/node_families_query_client.rs new file mode 100644 index 0000000000..dd62bb7dc6 --- /dev/null +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/node_families_query_client.rs @@ -0,0 +1,441 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::collect_paged; +use crate::nyxd::contract_traits::NymContractsProvider; +use crate::nyxd::error::NyxdError; +use crate::nyxd::CosmWasmClient; +use async_trait::async_trait; +use cosmrs::AccountId; +use serde::Deserialize; + +use nym_mixnet_contract_common::NodeId; +pub use nym_node_families_contract_common::{ + msg::QueryMsg as NodeFamiliesQueryMsg, AllFamilyMembersPagedResponse, + AllPastFamilyInvitationsPagedResponse, FamiliesPagedResponse, FamilyMemberRecord, + FamilyMembersPagedResponse, GlobalPastFamilyInvitationCursor, NodeFamily, + NodeFamilyByNameResponse, NodeFamilyByOwnerResponse, NodeFamilyId, + NodeFamilyMembershipResponse, NodeFamilyResponse, PastFamilyInvitation, + PastFamilyInvitationCursor, PastFamilyInvitationForNodeCursor, + PastFamilyInvitationsForNodePagedResponse, PastFamilyInvitationsPagedResponse, + PastFamilyMember, PastFamilyMemberCursor, PastFamilyMemberForNodeCursor, + PastFamilyMembersForNodePagedResponse, PastFamilyMembersPagedResponse, + PendingFamilyInvitationDetails, PendingFamilyInvitationResponse, + PendingFamilyInvitationsPagedResponse, PendingInvitationsForNodePagedResponse, + PendingInvitationsPagedResponse, +}; + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait NodeFamiliesQueryClient { + async fn query_node_families_contract( + &self, + query: NodeFamiliesQueryMsg, + ) -> Result + where + for<'a> T: Deserialize<'a>; + + async fn get_family_by_id( + &self, + family_id: NodeFamilyId, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetFamilyById { family_id }) + .await + } + + async fn get_family_by_owner( + &self, + owner: &AccountId, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetFamilyByOwner { + owner: owner.to_string(), + }) + .await + } + + async fn get_family_by_name( + &self, + name: String, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetFamilyByName { name }) + .await + } + + async fn get_families_paged( + &self, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetFamiliesPaged { + start_after, + limit, + }) + .await + } + + async fn get_family_membership( + &self, + node_id: NodeId, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetFamilyMembership { node_id }) + .await + } + + async fn get_family_members_paged( + &self, + family_id: NodeFamilyId, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetFamilyMembersPaged { + family_id, + start_after, + limit, + }) + .await + } + + async fn get_all_family_members_paged( + &self, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetAllFamilyMembersPaged { + start_after, + limit, + }) + .await + } + + async fn get_pending_invitation( + &self, + family_id: NodeFamilyId, + node_id: NodeId, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetPendingInvitation { + family_id, + node_id, + }) + .await + } + + async fn get_pending_invitations_for_family_paged( + &self, + family_id: NodeFamilyId, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract( + NodeFamiliesQueryMsg::GetPendingInvitationsForFamilyPaged { + family_id, + start_after, + limit, + }, + ) + .await + } + + async fn get_pending_invitations_for_node_paged( + &self, + node_id: NodeId, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetPendingInvitationsForNodePaged { + node_id, + start_after, + limit, + }) + .await + } + + async fn get_all_pending_invitations_paged( + &self, + start_after: Option<(NodeFamilyId, NodeId)>, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetAllPendingInvitationsPaged { + start_after, + limit, + }) + .await + } + + async fn get_past_invitations_for_family_paged( + &self, + family_id: NodeFamilyId, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetPastInvitationsForFamilyPaged { + family_id, + start_after, + limit, + }) + .await + } + + async fn get_past_invitations_for_node_paged( + &self, + node_id: NodeId, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetPastInvitationsForNodePaged { + node_id, + start_after, + limit, + }) + .await + } + + async fn get_all_past_invitations_paged( + &self, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetAllPastInvitationsPaged { + start_after, + limit, + }) + .await + } + + async fn get_past_members_for_family_paged( + &self, + family_id: NodeFamilyId, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetPastMembersForFamilyPaged { + family_id, + start_after, + limit, + }) + .await + } + + async fn get_past_members_for_node_paged( + &self, + node_id: NodeId, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetPastMembersForNodePaged { + node_id, + start_after, + limit, + }) + .await + } +} + +// extension trait to the query client to deal with the paged queries +// (it didn't feel appropriate to combine it with the existing trait) +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait PagedNodeFamiliesQueryClient: NodeFamiliesQueryClient { + async fn get_all_families(&self) -> Result, NyxdError> { + collect_paged!(self, get_families_paged, families) + } + + async fn get_all_family_members_for_family( + &self, + family_id: NodeFamilyId, + ) -> Result, NyxdError> { + collect_paged!(self, get_family_members_paged, members, family_id) + } + + async fn get_all_family_members(&self) -> Result, NyxdError> { + collect_paged!(self, get_all_family_members_paged, members) + } + + async fn get_all_pending_invitations_for_family( + &self, + family_id: NodeFamilyId, + ) -> Result, NyxdError> { + collect_paged!( + self, + get_pending_invitations_for_family_paged, + invitations, + family_id + ) + } + + async fn get_all_pending_invitations_for_node( + &self, + node_id: NodeId, + ) -> Result, NyxdError> { + collect_paged!( + self, + get_pending_invitations_for_node_paged, + invitations, + node_id + ) + } + + async fn get_all_pending_invitations( + &self, + ) -> Result, NyxdError> { + collect_paged!(self, get_all_pending_invitations_paged, invitations) + } + + async fn get_all_past_invitations_for_family( + &self, + family_id: NodeFamilyId, + ) -> Result, NyxdError> { + collect_paged!( + self, + get_past_invitations_for_family_paged, + invitations, + family_id + ) + } + + async fn get_all_past_invitations_for_node( + &self, + node_id: NodeId, + ) -> Result, NyxdError> { + collect_paged!( + self, + get_past_invitations_for_node_paged, + invitations, + node_id + ) + } + + async fn get_all_past_invitations(&self) -> Result, NyxdError> { + collect_paged!(self, get_all_past_invitations_paged, invitations) + } + + async fn get_all_past_members_for_family( + &self, + family_id: NodeFamilyId, + ) -> Result, NyxdError> { + collect_paged!(self, get_past_members_for_family_paged, members, family_id) + } + + async fn get_all_past_members_for_node( + &self, + node_id: NodeId, + ) -> Result, NyxdError> { + collect_paged!(self, get_past_members_for_node_paged, members, node_id) + } +} + +#[async_trait] +impl PagedNodeFamiliesQueryClient for T where T: NodeFamiliesQueryClient {} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl NodeFamiliesQueryClient for C +where + C: CosmWasmClient + NymContractsProvider + Send + Sync, +{ + async fn query_node_families_contract( + &self, + query: NodeFamiliesQueryMsg, + ) -> Result + where + for<'a> T: Deserialize<'a>, + { + let node_families_contract_address = &self + .node_families_contract_address() + .ok_or_else(|| NyxdError::unavailable_contract_address("node families contract"))?; + self.query_contract_smart(node_families_contract_address, &query) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::nyxd::contract_traits::tests::IgnoreValue; + use nym_node_families_contract_common::QueryMsg; + + // it's enough that this compiles and clippy is happy about it + #[allow(dead_code)] + fn all_query_variants_are_covered( + client: C, + msg: NodeFamiliesQueryMsg, + ) { + match msg { + NodeFamiliesQueryMsg::GetFamilyById { family_id } => { + client.get_family_by_id(family_id).ignore() + } + NodeFamiliesQueryMsg::GetFamilyByOwner { owner } => { + client.get_family_by_owner(&owner.parse().unwrap()).ignore() + } + NodeFamiliesQueryMsg::GetFamilyByName { name } => { + client.get_family_by_name(name).ignore() + } + NodeFamiliesQueryMsg::GetFamiliesPaged { start_after, limit } => { + client.get_families_paged(start_after, limit).ignore() + } + NodeFamiliesQueryMsg::GetFamilyMembership { node_id } => { + client.get_family_membership(node_id).ignore() + } + NodeFamiliesQueryMsg::GetFamilyMembersPaged { + family_id, + start_after, + limit, + } => client + .get_family_members_paged(family_id, start_after, limit) + .ignore(), + NodeFamiliesQueryMsg::GetAllFamilyMembersPaged { start_after, limit } => client + .get_all_family_members_paged(start_after, limit) + .ignore(), + NodeFamiliesQueryMsg::GetPendingInvitation { family_id, node_id } => { + client.get_pending_invitation(family_id, node_id).ignore() + } + NodeFamiliesQueryMsg::GetPendingInvitationsForFamilyPaged { + family_id, + start_after, + limit, + } => client + .get_pending_invitations_for_family_paged(family_id, start_after, limit) + .ignore(), + NodeFamiliesQueryMsg::GetPendingInvitationsForNodePaged { + node_id, + start_after, + limit, + } => client + .get_pending_invitations_for_node_paged(node_id, start_after, limit) + .ignore(), + NodeFamiliesQueryMsg::GetAllPendingInvitationsPaged { start_after, limit } => client + .get_all_pending_invitations_paged(start_after, limit) + .ignore(), + NodeFamiliesQueryMsg::GetPastInvitationsForFamilyPaged { + family_id, + start_after, + limit, + } => client + .get_past_invitations_for_family_paged(family_id, start_after, limit) + .ignore(), + NodeFamiliesQueryMsg::GetPastInvitationsForNodePaged { + node_id, + start_after, + limit, + } => client + .get_past_invitations_for_node_paged(node_id, start_after, limit) + .ignore(), + NodeFamiliesQueryMsg::GetAllPastInvitationsPaged { start_after, limit } => client + .get_all_past_invitations_paged(start_after, limit) + .ignore(), + NodeFamiliesQueryMsg::GetPastMembersForFamilyPaged { + family_id, + start_after, + limit, + } => client + .get_past_members_for_family_paged(family_id, start_after, limit) + .ignore(), + QueryMsg::GetPastMembersForNodePaged { + node_id, + start_after, + limit, + } => client + .get_past_members_for_node_paged(node_id, start_after, limit) + .ignore(), + }; + } +} diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/node_families_signing_client.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/node_families_signing_client.rs new file mode 100644 index 0000000000..34690c8be5 --- /dev/null +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/node_families_signing_client.rs @@ -0,0 +1,254 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::nyxd::coin::Coin; +use crate::nyxd::contract_traits::NymContractsProvider; +use crate::nyxd::cosmwasm_client::types::ExecuteResult; +use crate::nyxd::error::NyxdError; +use crate::nyxd::{Fee, SigningCosmWasmClient}; +use crate::signing::signer::OfflineSigner; +use async_trait::async_trait; +use nym_mixnet_contract_common::NodeId; +use nym_node_families_contract_common::{ + Config, ExecuteMsg as NodeFamiliesExecuteMsg, NodeFamilyId, +}; + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait NodeFamiliesSigningClient { + async fn execute_node_families_contract( + &self, + fee: Option, + msg: NodeFamiliesExecuteMsg, + memo: String, + funds: Vec, + ) -> Result; + + async fn update_node_families_config( + &self, + config: Config, + fee: Option, + ) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::UpdateConfig { config }, + "NodeFamiliesContract::UpdateConfig".to_string(), + vec![], + ) + .await + } + + async fn create_family( + &self, + name: String, + description: String, + fee: Option, + creation_fee: Vec, + ) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::CreateFamily { name, description }, + "NodeFamiliesContract::CreateFamily".to_string(), + creation_fee, + ) + .await + } + + async fn disband_family(&self, fee: Option) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::DisbandFamily {}, + "NodeFamiliesContract::DisbandFamily".to_string(), + vec![], + ) + .await + } + + async fn invite_to_family( + &self, + node_id: NodeId, + validity_secs: Option, + fee: Option, + ) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::InviteToFamily { + node_id, + validity_secs, + }, + "NodeFamiliesContract::InviteToFamily".to_string(), + vec![], + ) + .await + } + + async fn revoke_family_invitation( + &self, + node_id: NodeId, + fee: Option, + ) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::RevokeFamilyInvitation { node_id }, + "NodeFamiliesContract::RevokeFamilyInvitation".to_string(), + vec![], + ) + .await + } + + async fn accept_family_invitation( + &self, + family_id: NodeFamilyId, + node_id: NodeId, + fee: Option, + ) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::AcceptFamilyInvitation { family_id, node_id }, + "NodeFamiliesContract::AcceptFamilyInvitation".to_string(), + vec![], + ) + .await + } + + async fn reject_family_invitation( + &self, + family_id: NodeFamilyId, + node_id: NodeId, + fee: Option, + ) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::RejectFamilyInvitation { family_id, node_id }, + "NodeFamiliesContract::RejectFamilyInvitation".to_string(), + vec![], + ) + .await + } + + async fn leave_family( + &self, + node_id: NodeId, + fee: Option, + ) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::LeaveFamily { node_id }, + "NodeFamiliesContract::LeaveFamily".to_string(), + vec![], + ) + .await + } + + async fn kick_from_family( + &self, + node_id: NodeId, + fee: Option, + ) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::KickFromFamily { node_id }, + "NodeFamiliesContract::KickFromFamily".to_string(), + vec![], + ) + .await + } + + /// Cross-contract callback fired by the mixnet contract on node unbonding. + /// Exposed for completeness; the families contract rejects this call from + /// any sender other than the configured mixnet contract address. + async fn on_nym_node_unbond( + &self, + node_id: NodeId, + fee: Option, + ) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::OnNymNodeUnbond { node_id }, + "NodeFamiliesContract::OnNymNodeUnbond".to_string(), + vec![], + ) + .await + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl NodeFamiliesSigningClient for C +where + C: SigningCosmWasmClient + NymContractsProvider + Sync, + NyxdError: From<::Error>, +{ + async fn execute_node_families_contract( + &self, + fee: Option, + msg: NodeFamiliesExecuteMsg, + memo: String, + funds: Vec, + ) -> Result { + let node_families_contract_address = &self + .node_families_contract_address() + .ok_or_else(|| NyxdError::unavailable_contract_address("node families contract"))?; + + let fee = fee.unwrap_or(Fee::Auto(Some(self.simulated_gas_multiplier()))); + + let signer_address = &self.signer_addresses()[0]; + self.execute( + signer_address, + node_families_contract_address, + &msg, + fee, + memo, + funds, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::nyxd::contract_traits::tests::IgnoreValue; + use nym_node_families_contract_common::ExecuteMsg; + + // it's enough that this compiles and clippy is happy about it + #[allow(dead_code)] + fn all_execute_variants_are_covered( + client: C, + msg: NodeFamiliesExecuteMsg, + ) { + match msg { + NodeFamiliesExecuteMsg::UpdateConfig { config } => { + client.update_node_families_config(config, None).ignore() + } + NodeFamiliesExecuteMsg::CreateFamily { name, description } => client + .create_family(name, description, None, vec![]) + .ignore(), + NodeFamiliesExecuteMsg::DisbandFamily {} => client.disband_family(None).ignore(), + NodeFamiliesExecuteMsg::InviteToFamily { + node_id, + validity_secs, + } => client + .invite_to_family(node_id, validity_secs, None) + .ignore(), + NodeFamiliesExecuteMsg::RevokeFamilyInvitation { node_id } => { + client.revoke_family_invitation(node_id, None).ignore() + } + NodeFamiliesExecuteMsg::AcceptFamilyInvitation { family_id, node_id } => client + .accept_family_invitation(family_id, node_id, None) + .ignore(), + NodeFamiliesExecuteMsg::RejectFamilyInvitation { family_id, node_id } => client + .reject_family_invitation(family_id, node_id, None) + .ignore(), + NodeFamiliesExecuteMsg::LeaveFamily { node_id } => { + client.leave_family(node_id, None).ignore() + } + NodeFamiliesExecuteMsg::KickFromFamily { node_id } => { + client.kick_from_family(node_id, None).ignore() + } + ExecuteMsg::OnNymNodeUnbond { node_id } => { + client.on_nym_node_unbond(node_id, None).ignore() + } + }; + } +} diff --git a/common/client-libs/validator-client/src/nyxd/mod.rs b/common/client-libs/validator-client/src/nyxd/mod.rs index 0c931c2bd5..bb55c52bcb 100644 --- a/common/client-libs/validator-client/src/nyxd/mod.rs +++ b/common/client-libs/validator-client/src/nyxd/mod.rs @@ -286,6 +286,10 @@ impl NyxdClient { self.config.contracts.multisig_contract_address = Some(address); } + pub fn set_node_families_contract_address(&mut self, address: AccountId) { + self.config.contracts.node_families_contract_address = Some(address); + } + pub fn set_simulated_gas_multiplier(&mut self, multiplier: f32) { self.config.simulated_gas_multiplier = multiplier; } @@ -304,6 +308,13 @@ impl NymContractsProvider for NyxdClient { self.config.contracts.performance_contract_address.as_ref() } + fn node_families_contract_address(&self) -> Option<&AccountId> { + self.config + .contracts + .node_families_contract_address + .as_ref() + } + fn ecash_contract_address(&self) -> Option<&AccountId> { self.config.contracts.ecash_contract_address.as_ref() } diff --git a/common/commands/src/validator/cosmwasm/generators/mixnet.rs b/common/commands/src/validator/cosmwasm/generators/mixnet.rs index 9226a02653..b8e561f5ed 100644 --- a/common/commands/src/validator/cosmwasm/generators/mixnet.rs +++ b/common/commands/src/validator/cosmwasm/generators/mixnet.rs @@ -30,6 +30,9 @@ pub struct Args { #[clap(long)] pub vesting_contract_address: Option, + #[clap(long)] + pub node_families_contract_address: Option, + #[clap(long)] pub rewarding_denom: Option, @@ -130,6 +133,14 @@ pub async fn generate(args: Args) { .expect("Failed converting vesting contract address to AccountId") }); + let node_families_contract_address = args.node_families_contract_address.unwrap_or_else(|| { + let address = + std::env::var(nym_network_defaults::var_names::NODE_FAMILIES_CONTRACT_ADDRESS) + .expect("node families contract address has to be set"); + AccountId::from_str(address.as_str()) + .expect("Failed converting node families contract address to AccountId") + }); + let rewarding_denom = args.rewarding_denom.unwrap_or_else(|| { std::env::var(nym_network_defaults::var_names::MIX_DENOM) .expect("Rewarding (mix) denom has to be set") @@ -142,6 +153,7 @@ pub async fn generate(args: Args) { let instantiate_msg = InstantiateMsg { rewarding_validator_address: rewarding_validator_address.to_string(), vesting_contract_address: vesting_contract_address.to_string(), + node_families_contract_address: node_families_contract_address.to_string(), rewarding_denom, epochs_in_interval: args.epochs_in_interval, epoch_duration: Duration::from_secs(args.epoch_duration), diff --git a/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/basic_traits.rs b/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/basic_traits.rs index facc2983de..f543f019be 100644 --- a/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/basic_traits.rs +++ b/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/basic_traits.rs @@ -26,6 +26,14 @@ pub trait ContractOpts { fn addr_make(&self, input: &str) -> Addr; + fn make_sender_with_funds(&self, input: &str, funds: &[Coin]) -> MessageInfo { + message_info(&self.addr_make(input), funds) + } + + fn make_sender(&self, input: &str) -> MessageInfo { + self.make_sender_with_funds(input, &[]) + } + fn deps_mut_env(&mut self) -> (DepsMut<'_>, Env) { let env = self.env().clone(); (self.deps_mut(), env) diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs index dc56386238..43dc8eb6be 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs @@ -3,12 +3,121 @@ use crate::error::MixnetContractError; use crate::mixnode::PendingMixNodeChanges; +use crate::nym_node::NodeOwnershipResponse; use crate::{ - EpochEventId, IntervalEventId, MixNodeBond, MixNodeDetails, NodeId, NodeRewarding, NymNodeBond, - NymNodeDetails, PendingNodeChanges, + EpochEventId, EpochId, Interval, IntervalEventId, MixNodeBond, MixNodeDetails, NodeId, + NodeRewarding, NymNodeBond, NymNodeDetails, PendingNodeChanges, QueryMsg, }; -use cosmwasm_std::{Coin, Decimal, StdError, StdResult, Uint128}; +use cosmwasm_std::{ + Addr, Binary, Coin, CustomQuery, Decimal, QuerierWrapper, StdError, StdResult, Uint128, + from_json, +}; +use cw_storage_plus::{Key, Namespace, Path, PrimaryKey}; use nym_contracts_common::IdentityKeyRef; +use serde::de::DeserializeOwned; +use std::ops::Deref; + +pub trait MixnetContractQuerier { + #[allow(dead_code)] + fn query_mixnet_contract( + &self, + address: impl Into, + msg: &QueryMsg, + ) -> StdResult; + + fn query_mixnet_contract_storage( + &self, + address: impl Into, + key: impl Into, + ) -> StdResult>>; + + fn query_mixnet_contract_storage_value( + &self, + address: impl Into, + key: impl Into, + ) -> StdResult> { + match self.query_mixnet_contract_storage(address, key)? { + None => Ok(None), + Some(value) => Ok(Some(from_json(&value)?)), + } + } + + fn query_current_mixnet_interval(&self, address: impl Into) -> StdResult { + self.query_mixnet_contract_storage_value(address, b"ci")? + .ok_or(StdError::not_found( + "unable to retrieve interval information from the mixnet contract storage", + )) + } + + fn query_current_absolute_mixnet_epoch_id( + &self, + address: impl Into, + ) -> StdResult { + self.query_current_mixnet_interval(address) + .map(|interval| interval.current_epoch_absolute_id()) + } + + fn check_node_existence(&self, address: impl Into, node_id: NodeId) -> StdResult { + let mixnet_contract_address = address.into(); + + if let Some(nym_node) = self.query_nymnode_bond(mixnet_contract_address.clone(), node_id)? { + return Ok(!nym_node.is_unbonding); + } + + Ok(false) + } + + fn query_nymnode_bond( + &self, + address: impl Into, + node_id: NodeId, + ) -> StdResult> { + // construct proper map key + let pk_namespace = "nn"; + let path: Path = Path::new( + Namespace::from_static_str(pk_namespace).as_slice(), + &node_id.key().iter().map(Key::as_ref).collect::>(), + ); + let storage_key = path.deref(); + + self.query_mixnet_contract_storage_value(address, storage_key) + } + + fn query_nymnode_ownership( + &self, + address: impl Into, + owner: &Addr, + ) -> StdResult> { + let resp: NodeOwnershipResponse = self.query_mixnet_contract( + address, + &QueryMsg::GetOwnedNymNode { + address: owner.to_string(), + }, + )?; + Ok(resp.details.map(|d| d.bond_information)) + } +} + +impl MixnetContractQuerier for QuerierWrapper<'_, C> +where + C: CustomQuery, +{ + fn query_mixnet_contract( + &self, + address: impl Into, + msg: &QueryMsg, + ) -> StdResult { + self.query_wasm_smart(address, msg) + } + + fn query_mixnet_contract_storage( + &self, + address: impl Into, + key: impl Into, + ) -> StdResult>> { + self.query_wasm_raw(address, key) + } +} #[track_caller] pub fn compare_decimals(a: Decimal, b: Decimal, epsilon: Option) { diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/lib.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/lib.rs index 3c6f4b6b08..ac084abb6c 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/lib.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/lib.rs @@ -30,6 +30,7 @@ pub use gateway::{ Gateway, GatewayBond, GatewayBondResponse, GatewayConfigUpdate, GatewayOwnershipResponse, PagedGatewayResponse, }; +pub use helpers::MixnetContractQuerier; pub use interval::{ CurrentIntervalResponse, EpochId, EpochState, EpochStatus, Interval, IntervalId, }; diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs index 66f7d05113..a0f55f108c 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs @@ -190,6 +190,10 @@ impl NodeRewarding { truncate_reward(self.operator, denom) } + pub fn delegations_with_reward(&self, denom: impl Into) -> Coin { + truncate_reward(self.delegates, denom) + } + pub fn pending_delegator_reward(&self, delegation: &Delegation) -> StdResult { let delegator_reward = self.determine_delegation_reward(delegation)?; Ok(truncate_reward(delegator_reward, &delegation.amount.denom)) diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs index 4b0d31016d..f8b34b2898 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs @@ -63,6 +63,7 @@ use nym_contracts_common::{ContractBuildInformation, signing::Nonce}; pub struct InstantiateMsg { pub rewarding_validator_address: String, pub vesting_contract_address: String, + pub node_families_contract_address: String, pub rewarding_denom: String, pub epochs_in_interval: u32, @@ -885,4 +886,5 @@ pub enum QueryMsg { pub struct MigrateMsg { pub unsafe_skip_state_updates: Option, pub vesting_contract_address: Option, + pub node_families_contract_address: String, } diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/types.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/types.rs index 1b0590df1f..0cb427e201 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/types.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/types.rs @@ -212,6 +212,10 @@ pub struct ContractState { /// track-related messages. pub vesting_contract_address: Addr, + /// Address of the node families contract. It is called whenever nym-node unbonds + /// so that it could be removed from any family it belongs to. + pub node_families_contract_address: Addr, + /// The expected denom used for rewarding (and realistically any other operation). /// Default: `unym` pub rewarding_denom: String, diff --git a/common/cosmwasm-smart-contracts/node-families-contract/Cargo.toml b/common/cosmwasm-smart-contracts/node-families-contract/Cargo.toml new file mode 100644 index 0000000000..6a1fc4c1dc --- /dev/null +++ b/common/cosmwasm-smart-contracts/node-families-contract/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "nym-node-families-contract-common" +description = "Common crate for Nym's node families contract" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +rust-version = "1.85" +readme.workspace = true +publish = true + +[dependencies] +thiserror = { workspace = true } +serde = { workspace = true } +schemars = { workspace = true } + +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-controllers = { workspace = true } +cw-utils = { workspace = true } + +nym-contracts-common = { workspace = true } +nym-mixnet-contract-common = { workspace = true } + +[features] +schema = [] + +[lints] +workspace = true diff --git a/common/cosmwasm-smart-contracts/node-families-contract/src/constants.rs b/common/cosmwasm-smart-contracts/node-families-contract/src/constants.rs new file mode 100644 index 0000000000..372b2b9d9e --- /dev/null +++ b/common/cosmwasm-smart-contracts/node-families-contract/src/constants.rs @@ -0,0 +1,104 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +/// Storage key constants used by the node families contract. +/// +/// They are kept in the common crate so that off-chain tooling (indexers, migration +/// scripts) can reference them without depending on the contract crate itself. +/// Changing any of these values is a breaking change for already-deployed contracts. +pub mod storage_keys { + /// `Item`: address of the mixnet contract used to validate node existence. + pub const MIXNET_CONTRACT_ADDRESS: &str = "mixnet-contract-address"; + + /// `Item`: runtime configuration (fees, length limits) set at instantiation. + pub const CONFIG: &str = "config"; + + /// `Admin` (cw-controllers): admin allowed to perform privileged operations. + pub const CONTRACT_ADMIN: &str = "contract-admin"; + /// `Item`: monotonically increasing id counter for new families. + pub const NODE_FAMILY_ID_COUNTER: &str = "node-family-id-counter"; + /// Primary namespace for the current family-members `IndexedMap`, + /// keyed by `NodeId` with value [`crate::FamilyMembership`]. + pub const NODE_FAMILY_MEMBERS: &str = "node-family-members"; + /// Multi-index over current family members keyed by family id β€” + /// enables paginated listing of all nodes in a given family. + pub const NODE_FAMILY_MEMBERS_FAMILY_IDX_NAMESPACE: &str = "node-family-members__family"; + + /// Primary namespace for the families `IndexedMap`. + pub const FAMILIES_NAMESPACE: &str = "families"; + /// Secondary unique index keyed by `owner` (one family per owner). + pub const FAMILIES_OWNER_IDX_NAMESPACE: &str = "families__owner"; + /// Secondary unique index keyed by `name` (family names are globally unique). + pub const FAMILIES_NAME_IDX_NAMESPACE: &str = "families__name"; + + /// Primary namespace for the pending invitations `IndexedMap`. + pub const INVITATIONS_NAMESPACE: &str = "invitations"; + /// Multi-index over pending invitations keyed by family id. + pub const INVITATIONS_FAMILY_IDX_NAMESPACE: &str = "invitations__family"; + /// Multi-index over pending invitations keyed by node id + /// (a node can be invited to multiple families simultaneously). + pub const INVITATIONS_NODE_IDX_NAMESPACE: &str = "invitations__node"; + + /// Primary namespace for the archived (accepted/rejected/revoked) invitations `IndexedMap`. + pub const PAST_INVITATIONS_NAMESPACE: &str = "past-invitations"; + /// Multi-index over past invitations keyed by family id. + pub const PAST_INVITATIONS_FAMILY_IDX_NAMESPACE: &str = "past-invitations__family"; + /// Multi-index over past invitations keyed by node id. + pub const PAST_INVITATIONS_NODE_IDX_NAMESPACE: &str = "past-invitations__node"; + /// `Map<(NodeFamilyId, NodeId), u64>`: per-`(family, node)` counter used to + /// disambiguate repeat archive entries (a node can be invited and have the + /// invitation reach a terminal state more than once). + pub const PAST_INVITATIONS_COUNTER_NAMESPACE: &str = "past-invitations-counter"; + + /// Primary namespace for the past-members `IndexedMap`. + pub const PAST_FAMILY_MEMBER_NAMESPACE: &str = "past-family-member"; + /// Multi-index over past members keyed by family id. + pub const PAST_FAMILY_MEMBER_FAMILY_IDX_NAMESPACE: &str = "past-family-member__family"; + /// Multi-index over past members keyed by node id. + pub const PAST_FAMILY_MEMBER_NODE_IDX_NAMESPACE: &str = "past-family-member__node"; + /// `Map<(NodeFamilyId, NodeId), u64>`: per-`(family, node)` counter used to + /// disambiguate repeat past-membership entries (a node can join and leave + /// the same family more than once). + pub const PAST_FAMILY_MEMBER_COUNTER_NAMESPACE: &str = "past-family-member-counter"; +} + +pub mod events { + pub const FAMILY_CREATION_EVENT_NAME: &str = "family_creation"; + pub const FAMILY_CREATION_EVENT_FAMILY_NAME: &str = "family_name"; + pub const FAMILY_CREATION_EVENT_OWNER_ADDRESS: &str = "owner_address"; + pub const FAMILY_CREATION_EVENT_FAMILY_ID: &str = "family_id"; + pub const FAMILY_CREATION_EVENT_PAID_FEE: &str = "paid_fee"; + + pub const FAMILY_DISBAND_EVENT_NAME: &str = "family_disband"; + pub const FAMILY_DISBAND_EVENT_FAMILY_ID: &str = "family_id"; + pub const FAMILY_DISBAND_EVENT_OWNER_ADDRESS: &str = "owner_address"; + pub const FAMILY_DISBAND_EVENT_REFUNDED_FEE: &str = "refunded_fee"; + + pub const FAMILY_INVITATION_EVENT_NAME: &str = "family_invitation"; + pub const FAMILY_INVITATION_EVENT_FAMILY_ID: &str = "family_id"; + pub const FAMILY_INVITATION_EVENT_NODE_ID: &str = "node_id"; + pub const FAMILY_INVITATION_EVENT_EXPIRES_AT: &str = "expires_at"; + + pub const FAMILY_INVITATION_REVOKED_EVENT_NAME: &str = "family_invitation_revoked"; + pub const FAMILY_INVITATION_REVOKED_EVENT_FAMILY_ID: &str = "family_id"; + pub const FAMILY_INVITATION_REVOKED_EVENT_NODE_ID: &str = "node_id"; + + pub const FAMILY_INVITATION_ACCEPTED_EVENT_NAME: &str = "family_invitation_accepted"; + pub const FAMILY_INVITATION_ACCEPTED_EVENT_FAMILY_ID: &str = "family_id"; + pub const FAMILY_INVITATION_ACCEPTED_EVENT_NODE_ID: &str = "node_id"; + + pub const FAMILY_INVITATION_REJECTED_EVENT_NAME: &str = "family_invitation_rejected"; + pub const FAMILY_INVITATION_REJECTED_EVENT_FAMILY_ID: &str = "family_id"; + pub const FAMILY_INVITATION_REJECTED_EVENT_NODE_ID: &str = "node_id"; + + pub const FAMILY_MEMBER_LEFT_EVENT_NAME: &str = "family_member_left"; + pub const FAMILY_MEMBER_LEFT_EVENT_FAMILY_ID: &str = "family_id"; + pub const FAMILY_MEMBER_LEFT_EVENT_NODE_ID: &str = "node_id"; + + pub const FAMILY_MEMBER_KICKED_EVENT_NAME: &str = "family_member_kicked"; + pub const FAMILY_MEMBER_KICKED_EVENT_FAMILY_ID: &str = "family_id"; + pub const FAMILY_MEMBER_KICKED_EVENT_NODE_ID: &str = "node_id"; + + pub const NODE_UNBOND_CLEANUP_EVENT_NAME: &str = "family_node_unbond_cleanup"; + pub const NODE_UNBOND_CLEANUP_EVENT_NODE_ID: &str = "node_id"; +} diff --git a/common/cosmwasm-smart-contracts/node-families-contract/src/error.rs b/common/cosmwasm-smart-contracts/node-families-contract/src/error.rs new file mode 100644 index 0000000000..404fa09c28 --- /dev/null +++ b/common/cosmwasm-smart-contracts/node-families-contract/src/error.rs @@ -0,0 +1,161 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::NodeFamilyId; +use cosmwasm_std::{Addr, Coin}; +use cw_controllers::AdminError; +use cw_utils::PaymentError; +use nym_mixnet_contract_common::NodeId; +use thiserror::Error; + +/// Errors returned from any entry point of the node families contract. +#[derive(Error, Debug, PartialEq)] +pub enum NodeFamiliesContractError { + /// Returned from `migrate` when the on-chain state cannot be brought forward + /// to the current contract version (e.g. unsupported source version, malformed + /// stored data). + #[error("could not perform contract migration: {comment}")] + FailedMigration { comment: String }, + + /// The referenced family does not exist (or no longer exists). + #[error("family with id {family_id} does not exist")] + FamilyNotFound { family_id: NodeFamilyId }, + + /// Disbanding was requested on a family that still has members. + #[error("family {family_id} cannot be disbanded: it still has {members} member(s)")] + FamilyNotEmpty { + family_id: NodeFamilyId, + members: u64, + }, + + /// The given node is not currently a member of any family. + #[error("node {node_id} is not currently a member of any family")] + NodeNotInFamily { node_id: NodeId }, + + /// The given node is a member of a different family than the one the + /// caller is acting on. Distinct from [`NodeNotInFamily`] (which means the + /// node has no membership at all) β€” surfaces when, e.g., a family owner + /// tries to kick a node that belongs to someone else's family. + #[error("node {node_id} is not a member of family {family_id}")] + NodeNotMemberOfFamily { + node_id: NodeId, + family_id: NodeFamilyId, + }, + + /// A cross-contract callback (e.g. `OnNymNodeUnbond`) was received from a + /// sender that is not the configured mixnet contract address. + #[error("address {sender} is not authorised to invoke the mixnet-contract callback")] + UnauthorisedMixnetCallback { sender: Addr }, + + /// No pending invitation exists for the given `(family, node)` pair. + #[error("no pending invitation for node {node_id} from family {family_id}")] + InvitationNotFound { + family_id: NodeFamilyId, + node_id: NodeId, + }, + + /// A pending invitation for the given `(family, node)` pair already exists; + /// issuing a new one would silently overwrite it. + #[error("a pending invitation for node {node_id} from family {family_id} already exists")] + PendingInvitationAlreadyExists { + family_id: NodeFamilyId, + node_id: NodeId, + }, + + /// The invitation exists but its `expires_at` is at or before the current + /// block time, so it can no longer be acted on. + #[error( + "invitation for node {node_id} from family {family_id} expired at {expires_at} (now: {now})" + )] + InvitationExpired { + family_id: NodeFamilyId, + node_id: NodeId, + expires_at: u64, + now: u64, + }, + + /// The funds attached to a paid execution failed `cw_utils` payment + /// validation (no funds, wrong/extra denom). + #[error("invalid fee provided: {0}")] + InvalidDeposit(#[from] PaymentError), + + /// The funds attached to a `CreateFamily` execution don't match the + /// configured `create_family_fee`. + #[error("expected exactly {expected} as family creation fee; received {received:?}")] + InvalidFamilyCreationFee { expected: Coin, received: Vec }, + + /// The submitted family name normalised to the empty string (i.e. it + /// contained no ASCII alphanumeric characters). + #[error("family name cannot be empty after normalisation")] + EmptyFamilyName, + + /// The submitted family name exceeds the configured length limit. + #[error("family name length {length} exceeds the configured limit of {limit}")] + FamilyNameTooLong { length: usize, limit: usize }, + + /// The submitted family description exceeds the configured length limit. + #[error("family description length {length} exceeds the configured limit of {limit}")] + FamilyDescriptionTooLong { length: usize, limit: usize }, + + /// The transaction sender already owns a family. + #[error("address {address} already owns family {family_id}")] + SenderAlreadyOwnsAFamily { + address: Addr, + family_id: NodeFamilyId, + }, + + /// The transaction sender does not currently own any family - emitted by + /// owner-gated operations like `disband_family` when the sender has + /// nothing to act on. + #[error("address {address} does not currently own any family")] + SenderDoesntOwnAFamily { address: Addr }, + + /// The transaction sender is not the controller of the bonded node + /// referenced by the message. Covers all of: sender controls no bonded + /// node, sender controls a different node id, and sender's node has + /// entered the unbonding state. + #[error("address {address} is not the controller of bonded node {node_id}")] + SenderDoesntControlNode { address: Addr, node_id: NodeId }, + + /// A family with the requested (normalised) name already exists. + #[error("a family with name {name:?} already exists (id {family_id})")] + FamilyNameAlreadyTaken { + name: String, + family_id: NodeFamilyId, + }, + + /// A node controlled by the address is currently a member of a family, + /// so the address cannot also become a family owner or join another family. + #[error("address {address} controls node {node_id} which is currently in family {family_id}")] + AlreadyInFamily { + address: Addr, + node_id: NodeId, + family_id: NodeFamilyId, + }, + + /// The node referenced by an invitation does not exist as a bonded node + /// in the mixnet contract (or has already unbonded). + #[error("node {node_id} is not a bonded node in the mixnet contract")] + NodeDoesntExist { node_id: NodeId }, + + /// The node referenced by an invitation is already a member of a family, + /// so it cannot be invited to another one until it leaves / is removed. + #[error("node {node_id} is already a member of family {family_id}")] + NodeAlreadyInFamily { + node_id: NodeId, + family_id: NodeFamilyId, + }, + + /// The sender supplied a `validity_secs` of `0` for an invitation, which + /// would create one that is already expired at the moment it is stored. + #[error("invitation validity must be strictly positive")] + ZeroInvitationValidity, + + /// Wraps errors raised by `cw-controllers::Admin` (e.g. caller is not admin). + #[error(transparent)] + Admin(#[from] AdminError), + + /// Wraps any underlying `cosmwasm_std::StdError` (storage, serialization, etc.). + #[error(transparent)] + StdErr(#[from] cosmwasm_std::StdError), +} diff --git a/common/cosmwasm-smart-contracts/node-families-contract/src/lib.rs b/common/cosmwasm-smart-contracts/node-families-contract/src/lib.rs new file mode 100644 index 0000000000..265794d3e8 --- /dev/null +++ b/common/cosmwasm-smart-contracts/node-families-contract/src/lib.rs @@ -0,0 +1,22 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! Common types, messages, errors and storage-key constants shared between the +//! node families contract and any off-chain client. +//! +//! Keeping these in a separate crate allows clients to depend on the contract's +//! public surface without pulling in `cw-storage-plus` and other on-chain-only +//! dependencies. + +/// Storage-key string constants. See [`constants::storage_keys`]. +pub mod constants; +/// Contract-level error type. +pub mod error; +/// `InstantiateMsg`, `ExecuteMsg`, `QueryMsg`, `MigrateMsg` definitions. +pub mod msg; +/// Domain types stored in / returned by the contract. +pub mod types; + +pub use error::*; +pub use msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; +pub use types::*; diff --git a/common/cosmwasm-smart-contracts/node-families-contract/src/msg.rs b/common/cosmwasm-smart-contracts/node-families-contract/src/msg.rs new file mode 100644 index 0000000000..b5a509edcf --- /dev/null +++ b/common/cosmwasm-smart-contracts/node-families-contract/src/msg.rs @@ -0,0 +1,211 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::{ + Config, GlobalPastFamilyInvitationCursor, NodeFamilyId, PastFamilyInvitationCursor, + PastFamilyInvitationForNodeCursor, PastFamilyMemberCursor, PastFamilyMemberForNodeCursor, +}; +use cosmwasm_schema::cw_serde; +use nym_mixnet_contract_common::NodeId; + +#[cfg(feature = "schema")] +use crate::{ + AllFamilyMembersPagedResponse, AllPastFamilyInvitationsPagedResponse, FamiliesPagedResponse, + FamilyMembersPagedResponse, NodeFamilyByNameResponse, NodeFamilyByOwnerResponse, + NodeFamilyMembershipResponse, NodeFamilyResponse, PastFamilyInvitationsForNodePagedResponse, + PastFamilyInvitationsPagedResponse, PastFamilyMembersForNodePagedResponse, + PastFamilyMembersPagedResponse, PendingFamilyInvitationResponse, + PendingFamilyInvitationsPagedResponse, PendingInvitationsForNodePagedResponse, + PendingInvitationsPagedResponse, +}; + +/// Message used to instantiate the node families contract. +#[cw_serde] +pub struct InstantiateMsg { + pub config: Config, + + pub mixnet_contract_address: String, +} + +/// Execute messages accepted by the contract. +#[cw_serde] +pub enum ExecuteMsg { + /// Replace the contract's runtime [`Config`]. Restricted to the contract + /// admin. + UpdateConfig { config: Config }, + + /// Create a new family owned by the message sender. The configured + /// `create_family_fee` must be attached as funds. + CreateFamily { name: String, description: String }, + + /// Disband the family owned by the message sender. The family must have + /// no current members; any still-pending invitations are revoked. + DisbandFamily {}, + + /// Invite a node to the family owned by the message sender. If + /// `validity_secs` is omitted the invitation expires + /// `default_invitation_validity_secs` seconds (from [`Config`]) after the + /// current block time. + InviteToFamily { + node_id: NodeId, + validity_secs: Option, + }, + + /// Revoke a still-pending invitation previously issued by the sender's + /// family. + RevokeFamilyInvitation { node_id: NodeId }, + + /// Accept a pending invitation. The sender must control `node_id`. + AcceptFamilyInvitation { + family_id: NodeFamilyId, + node_id: NodeId, + }, + + /// Reject a pending invitation. The sender must control `node_id`. + RejectFamilyInvitation { + family_id: NodeFamilyId, + node_id: NodeId, + }, + + /// Leave the family `node_id` currently belongs to. The sender must + /// control `node_id`. + LeaveFamily { node_id: NodeId }, + + /// Remove `node_id` from the family owned by the message sender. + KickFromFamily { node_id: NodeId }, + + /// Cross-contract callback fired by the mixnet contract the moment + /// node with `node_id` initiates unbonding. + /// Removes the node from any family it currently + /// belongs to and rejects every pending invitation issued to it. + /// Sender must be the configured mixnet contract address. + OnNymNodeUnbond { node_id: NodeId }, +} + +/// Query messages accepted by the contract. +#[cw_serde] +#[cfg_attr(feature = "schema", derive(cosmwasm_schema::QueryResponses))] +pub enum QueryMsg { + /// Look up a single family by its id. + #[cfg_attr(feature = "schema", returns(NodeFamilyResponse))] + GetFamilyById { family_id: NodeFamilyId }, + + /// Look up the (at most one) family owned by a given address. + #[cfg_attr(feature = "schema", returns(NodeFamilyByOwnerResponse))] + GetFamilyByOwner { owner: String }, + + /// Look up a single family by its name. The lookup is normalised + /// contract-side (lowercased, non-alphanumerics stripped), so equivalent + /// inputs resolve to the same family. + #[cfg_attr(feature = "schema", returns(NodeFamilyByNameResponse))] + GetFamilyByName { name: String }, + + #[cfg_attr(feature = "schema", returns(FamiliesPagedResponse))] + GetFamiliesPaged { + start_after: Option, + limit: Option, + }, + + /// Look up which family β€” if any β€” a node currently belongs to. + #[cfg_attr(feature = "schema", returns(NodeFamilyMembershipResponse))] + GetFamilyMembership { node_id: NodeId }, + + /// Page through every node currently in a given family. + #[cfg_attr(feature = "schema", returns(FamilyMembersPagedResponse))] + GetFamilyMembersPaged { + family_id: NodeFamilyId, + start_after: Option, + limit: Option, + }, + + /// Page through every current family member across all families, in + /// ascending [`NodeId`] order. Each entry carries the membership record + /// (which in turn names the family the node belongs to). + #[cfg_attr(feature = "schema", returns(AllFamilyMembersPagedResponse))] + GetAllFamilyMembersPaged { + start_after: Option, + limit: Option, + }, + + /// Look up the pending invitation for a specific `(family_id, node_id)` + /// pair. + #[cfg_attr(feature = "schema", returns(PendingFamilyInvitationResponse))] + GetPendingInvitation { + family_id: NodeFamilyId, + node_id: NodeId, + }, + + /// Page through every pending invitation issued by a given family. + #[cfg_attr(feature = "schema", returns(PendingFamilyInvitationsPagedResponse))] + GetPendingInvitationsForFamilyPaged { + family_id: NodeFamilyId, + start_after: Option, + limit: Option, + }, + + /// Page through every pending invitation issued for a given node. + #[cfg_attr(feature = "schema", returns(PendingInvitationsForNodePagedResponse))] + GetPendingInvitationsForNodePaged { + node_id: NodeId, + start_after: Option, + limit: Option, + }, + + /// Page through every pending invitation across all families. + #[cfg_attr(feature = "schema", returns(PendingInvitationsPagedResponse))] + GetAllPendingInvitationsPaged { + start_after: Option<(NodeFamilyId, NodeId)>, + limit: Option, + }, + + /// Page through every archived (terminal-state) invitation issued by a + /// given family. + #[cfg_attr(feature = "schema", returns(PastFamilyInvitationsPagedResponse))] + GetPastInvitationsForFamilyPaged { + family_id: NodeFamilyId, + start_after: Option, + limit: Option, + }, + + /// Page through every archived (terminal-state) invitation issued to a + /// given node. + #[cfg_attr(feature = "schema", returns(PastFamilyInvitationsForNodePagedResponse))] + GetPastInvitationsForNodePaged { + node_id: NodeId, + start_after: Option, + limit: Option, + }, + + /// Page through every archived (terminal-state) invitation across all + /// families. + #[cfg_attr(feature = "schema", returns(AllPastFamilyInvitationsPagedResponse))] + GetAllPastInvitationsPaged { + start_after: Option, + limit: Option, + }, + + /// Page through every archived membership record for a given family + /// (nodes that used to belong to it but have since been removed). + #[cfg_attr(feature = "schema", returns(PastFamilyMembersPagedResponse))] + GetPastMembersForFamilyPaged { + family_id: NodeFamilyId, + start_after: Option, + limit: Option, + }, + + /// Page through every archived membership record for a given node + /// (every family the node used to belong to but has since been removed + /// from), across all families. + #[cfg_attr(feature = "schema", returns(PastFamilyMembersForNodePagedResponse))] + GetPastMembersForNodePaged { + node_id: NodeId, + start_after: Option, + limit: Option, + }, +} + +/// Message passed to the contract's `migrate` entry point. +#[cw_serde] +pub struct MigrateMsg { + // +} diff --git a/common/cosmwasm-smart-contracts/node-families-contract/src/types.rs b/common/cosmwasm-smart-contracts/node-families-contract/src/types.rs new file mode 100644 index 0000000000..6fefaae4ba --- /dev/null +++ b/common/cosmwasm-smart-contracts/node-families-contract/src/types.rs @@ -0,0 +1,403 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Coin}; +use nym_mixnet_contract_common::NodeId; + +/// Identifier of a node family. +/// +/// Issued sequentially by the contract on family creation; never reused even if the +/// family is later disbanded. +pub type NodeFamilyId = u32; + +/// Runtime configuration of the node families contract. +#[cw_serde] +pub struct Config { + /// Fee charged on each successful `create_family` execution. + pub create_family_fee: Coin, + + /// Maximum allowed length, in characters, of a family name. + pub family_name_length_limit: usize, + + /// Maximum allowed length, in characters, of a family description. + pub family_description_length_limit: usize, + + /// Default lifetime, in seconds, used by `invite_to_family` when the + /// sender doesn't supply an explicit value. Senders may override this + /// per-invitation via the optional `validity_secs` argument. + pub default_invitation_validity_secs: u64, +} + +/// On-chain representation of a node family. +#[cw_serde] +pub struct NodeFamily { + /// The id of the node family + pub id: NodeFamilyId, + + /// The name of the node family + pub name: String, + + /// Normalised name of the node family used for uniqueness checks + pub normalised_name: String, + + /// The optional description of the node family + pub description: String, + + /// The owner of the node family + pub owner: Addr, + + /// Records the fee paid when the family was created, + /// so that the appropriate amount could be returned upon it getting disbanded. + pub paid_fee: Coin, + + /// Memoized value of the current number of members in the node family + /// Used to detect if the family is empty + pub members: u64, + + /// Timestamp of the creation of the node family + pub created_at: u64, +} + +/// A pending invitation for a node to join a particular family. +/// +/// Invitations are stored until they are accepted, rejected, revoked, or until the +/// chain advances past `expires_at` (in which case they remain in storage but are +/// treated as inert β€” there is no background process clearing expired invitations). +#[cw_serde] +pub struct FamilyInvitation { + /// The family that issued the invitation. + pub family_id: NodeFamilyId, + + /// The node being invited. + pub node_id: NodeId, + + /// Block timestamp (unix seconds) after which the invitation is no longer valid. + pub expires_at: u64, +} + +/// On-chain record of a node's current family membership. +/// +/// A node belongs to at most one family at a time, so this is keyed by +/// `NodeId` alone β€” `family_id` is carried in the value to support reverse +/// lookups (all nodes in a given family) via a secondary index. +#[cw_serde] +pub struct FamilyMembership { + /// The family the node is currently a member of. + pub family_id: NodeFamilyId, + + /// Block timestamp (unix seconds) at which the node accepted its + /// invitation and joined the family. + pub joined_at: u64, +} + +/// Historical record of a node that used to be part of a family but has since been +/// removed (kicked, left voluntarily, or because the family was disbanded). +#[cw_serde] +pub struct PastFamilyMember { + /// The family the node used to belong to. + pub family_id: NodeFamilyId, + + /// The node that was removed. + pub node_id: NodeId, + + /// Block timestamp (unix seconds) at which the membership was terminated. + pub removed_at: u64, +} + +/// Terminal status for an invitation that has been moved out of the pending set. +/// +/// Note: timed-out invitations are not represented here β€” they are simply left in +/// the pending set (see `FamilyInvitation::expires_at`). +#[cw_serde] +pub enum FamilyInvitationStatus { + /// Still awaiting a response. Recorded with a timestamp for completeness even + /// though pending invitations live in a separate map. + Pending { at: u64 }, + /// The invitee accepted and joined the family at the given timestamp. + Accepted { at: u64 }, + /// The invitee explicitly rejected the invitation at the given timestamp. + Rejected { at: u64 }, + /// The family revoked the invitation at the given timestamp before it could + /// be accepted or rejected. + Revoked { at: u64 }, +} + +/// Historical record of an invitation that has reached a terminal state +/// (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** +/// archived here β€” they remain in the pending map until explicitly cleared. +#[cw_serde] +pub struct PastFamilyInvitation { + /// The original invitation as it was issued. + pub invitation: FamilyInvitation, + + /// What ultimately happened to it. + pub status: FamilyInvitationStatus, +} + +/// Response to [`QueryMsg::GetFamilyById`](crate::QueryMsg::GetFamilyById). +#[cw_serde] +pub struct NodeFamilyResponse { + /// The id that was queried, echoed back so paginated callers can correlate. + pub family_id: NodeFamilyId, + + /// The matching family, or `None` if no family with `family_id` exists. + pub family: Option, +} + +/// Response to [`QueryMsg::GetFamilyByOwner`](crate::QueryMsg::GetFamilyByOwner). +#[cw_serde] +pub struct NodeFamilyByOwnerResponse { + /// The (validated) owner address that was queried, echoed back so callers + /// can correlate. + pub owner: Addr, + + /// The matching family, or `None` if `owner` does not currently own one. + pub family: Option, +} + +/// Response to [`QueryMsg::GetFamilyByName`](crate::QueryMsg::GetFamilyByName). +#[cw_serde] +pub struct NodeFamilyByNameResponse { + /// The name that was queried, echoed back so callers can correlate. + pub name: String, + + /// The matching family, or `None` if no family with that name exists. + pub family: Option, +} + +/// Response to [`QueryMsg::GetFamilyMembership`](crate::QueryMsg::GetFamilyMembership). +#[cw_serde] +pub struct NodeFamilyMembershipResponse { + /// The node that was queried. + pub node_id: NodeId, + + /// The id of the family the node currently belongs to, or `None` if the + /// node is not currently a member of any family. + pub family_id: Option, +} + +/// A pending [`FamilyInvitation`] paired with whether it has already timed +/// out at the time the query was served. +#[cw_serde] +pub struct PendingFamilyInvitationDetails { + /// The stored invitation as it was issued. + pub invitation: FamilyInvitation, + + /// `true` iff `now >= invitation.expires_at` at query time, i.e. the + /// invitation is still in the pending map but can no longer be acted on. + pub expired: bool, +} + +/// Response to [`QueryMsg::GetPendingInvitation`](crate::QueryMsg::GetPendingInvitation). +#[cw_serde] +pub struct PendingFamilyInvitationResponse { + /// The family component of the queried `(family_id, node_id)` key. + pub family_id: NodeFamilyId, + + /// The node component of the queried `(family_id, node_id)` key. + pub node_id: NodeId, + + /// The matching pending invitation along with an explicit expiry flag, + /// or `None` if no such invitation exists. + pub invitation: Option, +} + +/// One entry in a [`FamilyMembersPagedResponse`] page β€” pairs a node id with +/// its [`FamilyMembership`] record (notably its `joined_at` timestamp). +#[cw_serde] +pub struct FamilyMemberRecord { + /// The node currently in the family. + pub node_id: NodeId, + + /// The membership record (carries `family_id` and `joined_at`). + pub membership: FamilyMembership, +} + +/// Response to [`QueryMsg::GetFamilyMembersPaged`](crate::QueryMsg::GetFamilyMembersPaged). +#[cw_serde] +pub struct FamilyMembersPagedResponse { + /// The family whose members were queried, echoed back so paginated + /// callers can correlate. + pub family_id: NodeFamilyId, + + /// The members on this page, in ascending [`NodeId`] order. + pub members: Vec, + + /// Cursor to pass as `start_after` on the next call, or `None` if this + /// page is empty (which the caller should treat as end-of-list). + pub start_next_after: Option, +} + +/// Response to [`QueryMsg::GetAllFamilyMembersPaged`](crate::QueryMsg::GetAllFamilyMembersPaged). +#[cw_serde] +pub struct AllFamilyMembersPagedResponse { + /// The members on this page, in ascending [`NodeId`] order across every + /// family. + pub members: Vec, + + /// Cursor (last `node_id`) to pass as `start_after` on the next call, + /// or `None` if this page is empty (treat as end-of-list). + pub start_next_after: Option, +} + +/// Response to [`QueryMsg::GetPendingInvitationsForFamilyPaged`](crate::QueryMsg::GetPendingInvitationsForFamilyPaged). +#[cw_serde] +pub struct PendingFamilyInvitationsPagedResponse { + /// The family whose pending invitations were queried, echoed back so + /// paginated callers can correlate. + pub family_id: NodeFamilyId, + + /// The pending invitations on this page, in ascending invitee + /// [`NodeId`] order, each stamped with whether it had already timed out + /// at the time the query was served. + pub invitations: Vec, + + /// Cursor (last invitee node id) to pass as `start_after` on the next + /// call, or `None` if this page is empty (treat as end-of-list). + pub start_next_after: Option, +} + +/// Response to [`QueryMsg::GetPendingInvitationsForNodePaged`](crate::QueryMsg::GetPendingInvitationsForNodePaged). +#[cw_serde] +pub struct PendingInvitationsForNodePagedResponse { + /// The node whose pending invitations were queried, echoed back so + /// paginated callers can correlate. + pub node_id: NodeId, + + /// The pending invitations addressed to this node on this page, in + /// ascending [`NodeFamilyId`] order, each stamped with whether it had + /// already timed out at the time the query was served. + pub invitations: Vec, + + /// Cursor (last issuing family id) to pass as `start_after` on the + /// next call, or `None` if this page is empty (treat as end-of-list). + pub start_next_after: Option, +} + +/// Response to [`QueryMsg::GetAllPendingInvitationsPaged`](crate::QueryMsg::GetAllPendingInvitationsPaged). +#[cw_serde] +pub struct PendingInvitationsPagedResponse { + /// The pending invitations on this page, in ascending + /// `(family_id, node_id)` order, each stamped with whether it had + /// already timed out at the time the query was served. + pub invitations: Vec, + + /// Cursor (last `(family_id, node_id)` pair) to pass as `start_after` + /// on the next call, or `None` if this page is empty (treat as + /// end-of-list). + pub start_next_after: Option<(NodeFamilyId, NodeId)>, +} + +/// Cursor for paginating per-family past-invitation listings: identifies a +/// single archive entry within a family by `(node_id, counter)`. The +/// `counter` is the per-`(family, node)` archive slot β€” multiple archived +/// invitations can exist for the same `(family, node)` pair (a node may be +/// invited and have the invitation reach a terminal state more than once). +pub type PastFamilyInvitationCursor = (NodeId, u64); + +/// Cursor for paginating per-node past-invitation listings: identifies a +/// single archive entry addressed to a fixed node by `(family_id, counter)`. +pub type PastFamilyInvitationForNodeCursor = (NodeFamilyId, u64); + +/// Cursor for paginating global past-invitation listings: identifies a +/// single archive entry across all families by `((family_id, node_id), counter)`. +pub type GlobalPastFamilyInvitationCursor = ((NodeFamilyId, NodeId), u64); + +/// Response to [`QueryMsg::GetPastInvitationsForFamilyPaged`](crate::QueryMsg::GetPastInvitationsForFamilyPaged). +#[cw_serde] +pub struct PastFamilyInvitationsPagedResponse { + /// The family whose archived invitations were queried, echoed back so + /// paginated callers can correlate. + pub family_id: NodeFamilyId, + + /// The archived invitations on this page, in ascending + /// `(node_id, counter)` order across all terminal statuses. + pub invitations: Vec, + + /// Cursor to pass as `start_after` on the next call, or `None` if this + /// page is empty (treat as end-of-list). + pub start_next_after: Option, +} + +/// Response to [`QueryMsg::GetPastInvitationsForNodePaged`](crate::QueryMsg::GetPastInvitationsForNodePaged). +#[cw_serde] +pub struct PastFamilyInvitationsForNodePagedResponse { + /// The node whose past invitations were queried, echoed back so + /// paginated callers can correlate. + pub node_id: NodeId, + + /// The archived invitations addressed to this node on this page, in + /// ascending `(family_id, counter)` order across all terminal statuses. + pub invitations: Vec, + + /// Cursor to pass as `start_after` on the next call, or `None` if this + /// page is empty (treat as end-of-list). + pub start_next_after: Option, +} + +/// Response to [`QueryMsg::GetAllPastInvitationsPaged`](crate::QueryMsg::GetAllPastInvitationsPaged). +#[cw_serde] +pub struct AllPastFamilyInvitationsPagedResponse { + /// The archived invitations on this page, in ascending + /// `((family_id, node_id), counter)` order across all terminal statuses. + pub invitations: Vec, + + /// Cursor to pass as `start_after` on the next call, or `None` if this + /// page is empty (treat as end-of-list). + pub start_next_after: Option, +} + +/// Cursor for paginating per-family past-member listings: identifies a single +/// archive entry within a family by `(node_id, counter)`. The `counter` is the +/// per-`(family, node)` archive slot β€” multiple archived membership entries +/// can exist for the same `(family, node)` pair (a node may join, leave, and +/// re-join the same family more than once). +pub type PastFamilyMemberCursor = (NodeId, u64); + +/// Cursor for paginating per-node past-member listings: identifies a single +/// archive entry for a fixed node by `(family_id, counter)`. +pub type PastFamilyMemberForNodeCursor = (NodeFamilyId, u64); + +/// Response to [`QueryMsg::GetPastMembersForFamilyPaged`](crate::QueryMsg::GetPastMembersForFamilyPaged). +#[cw_serde] +pub struct PastFamilyMembersPagedResponse { + /// The family whose archived memberships were queried, echoed back so + /// paginated callers can correlate. + pub family_id: NodeFamilyId, + + /// The archived membership records on this page, in ascending + /// `(node_id, counter)` order. + pub members: Vec, + + /// Cursor to pass as `start_after` on the next call, or `None` if this + /// page is empty (treat as end-of-list). + pub start_next_after: Option, +} + +/// Response to [`QueryMsg::GetPastMembersForNodePaged`](crate::QueryMsg::GetPastMembersForNodePaged). +#[cw_serde] +pub struct PastFamilyMembersForNodePagedResponse { + /// The node whose archived memberships were queried, echoed back so + /// paginated callers can correlate. + pub node_id: NodeId, + + /// The archived membership records for this node on this page, in + /// ascending `(family_id, counter)` order. + pub members: Vec, + + /// Cursor to pass as `start_after` on the next call, or `None` if this + /// page is empty (treat as end-of-list). + pub start_next_after: Option, +} + +/// Response to [`QueryMsg::GetFamiliesPaged`](crate::QueryMsg::GetFamiliesPaged). +#[cw_serde] +pub struct FamiliesPagedResponse { + /// The families on this page, in ascending [`NodeFamilyId`] order. + pub families: Vec, + + /// Cursor to pass as `start_after` on the next call, or `None` if this + /// page is empty (which the caller should treat as end-of-list). + pub start_next_after: Option, +} diff --git a/common/crypto/Cargo.toml b/common/crypto/Cargo.toml index 1cc48788b6..ac426b25e5 100644 --- a/common/crypto/Cargo.toml +++ b/common/crypto/Cargo.toml @@ -8,7 +8,9 @@ license = { workspace = true } repository = { workspace = true } homepage.workspace = true documentation.workspace = true -rust-version.workspace = true +# pinned (not inherited from workspace) because this crate is imported by the ecash contract, +# and the contracts workspace cannot be built with rustc more recent than 1.86 +rust-version = "1.86.0" readme.workspace = true publish = true diff --git a/common/http-api-common/src/response/mod.rs b/common/http-api-common/src/response/mod.rs index 95e8cb5533..fcffebe052 100644 --- a/common/http-api-common/src/response/mod.rs +++ b/common/http-api-common/src/response/mod.rs @@ -129,6 +129,41 @@ where } } +#[derive(Default, Debug, Serialize, Deserialize, Copy, Clone)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "lowercase")] +pub enum OutputV2 { + #[default] + Json, + Yaml, +} + +#[derive(Default, Debug, Serialize, Deserialize, Copy, Clone)] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams, utoipa::ToSchema))] +#[serde(default)] +pub struct OutputParamsV2 { + pub output: Option, +} + +impl OutputParamsV2 { + pub fn get_output(&self) -> OutputV2 { + self.output.unwrap_or_default() + } + + pub fn to_response(self, data: T) -> FormattedResponse { + self.get_output().to_response(data) + } +} + +impl OutputV2 { + pub fn to_response(self, data: T) -> FormattedResponse { + match self { + OutputV2::Json => FormattedResponse::Json(Json::from(data)), + OutputV2::Yaml => FormattedResponse::Yaml(Yaml::from(data)), + } + } +} + #[derive(Default, Debug, Serialize, Deserialize, Copy, Clone)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "lowercase")] diff --git a/common/network-defaults/Cargo.toml b/common/network-defaults/Cargo.toml index 238503673d..d83abccbc7 100644 --- a/common/network-defaults/Cargo.toml +++ b/common/network-defaults/Cargo.toml @@ -8,7 +8,9 @@ license.workspace = true repository.workspace = true homepage.workspace = true documentation.workspace = true -rust-version.workspace = true +# pinned (not inherited from workspace) because this crate is imported by the ecash contract, +# and the contracts workspace cannot be built with rustc more recent than 1.86 +rust-version = "1.86.0" readme.workspace = true publish = true # Exclude build.rs from published crate - it's only used for dev-time sync diff --git a/common/network-defaults/src/mainnet.rs b/common/network-defaults/src/mainnet.rs index 3c07beefc3..1cd1a40895 100644 --- a/common/network-defaults/src/mainnet.rs +++ b/common/network-defaults/src/mainnet.rs @@ -22,6 +22,10 @@ pub const VESTING_CONTRACT_ADDRESS: &str = pub const PERFORMANCE_CONTRACT_ADDRESS: &str = ""; // /\ TODO: this has to be updated once the contract is deployed +// \/ TODO: this has to be updated once the contract is deployed +pub const NODE_FAMILIES_CONTRACT_ADDRESS: &str = ""; +// /\ TODO: this has to be updated once the contract is deployed + pub const ECASH_CONTRACT_ADDRESS: &str = "n1r7s6aksyc6pqardx88k3rkgfagwvj4z4zum9mmz2sfk3zm2mha0sd4dnun"; pub const GROUP_CONTRACT_ADDRESS: &str = diff --git a/common/network-defaults/src/network.rs b/common/network-defaults/src/network.rs index ccba4af768..03bc65f416 100644 --- a/common/network-defaults/src/network.rs +++ b/common/network-defaults/src/network.rs @@ -39,6 +39,8 @@ pub struct NymContracts { pub vesting_contract_address: Option, #[serde(default)] pub performance_contract_address: Option, + #[serde(default)] + pub node_families_contract_address: Option, pub ecash_contract_address: Option, pub group_contract_address: Option, pub multisig_contract_address: Option, @@ -174,6 +176,9 @@ impl NymNetworkDetails { )) .with_mixnet_contract(get_optional_env(var_names::MIXNET_CONTRACT_ADDRESS)) .with_vesting_contract(get_optional_env(var_names::VESTING_CONTRACT_ADDRESS)) + .with_node_families_contract(get_optional_env( + var_names::NODE_FAMILIES_CONTRACT_ADDRESS, + )) .with_ecash_contract(get_optional_env(var_names::ECASH_CONTRACT_ADDRESS)) .with_group_contract(get_optional_env(var_names::GROUP_CONTRACT_ADDRESS)) .with_multisig_contract(get_optional_env(var_names::MULTISIG_CONTRACT_ADDRESS)) @@ -199,6 +204,9 @@ impl NymNetworkDetails { performance_contract_address: parse_optional_str( mainnet::PERFORMANCE_CONTRACT_ADDRESS, ), + node_families_contract_address: parse_optional_str( + mainnet::NODE_FAMILIES_CONTRACT_ADDRESS, + ), ecash_contract_address: parse_optional_str(mainnet::ECASH_CONTRACT_ADDRESS), group_contract_address: parse_optional_str(mainnet::GROUP_CONTRACT_ADDRESS), multisig_contract_address: parse_optional_str(mainnet::MULTISIG_CONTRACT_ADDRESS), @@ -252,6 +260,7 @@ impl NymNetworkDetails { set_optional_var(var_names::MIXNET_CONTRACT_ADDRESS, self.contracts.mixnet_contract_address); set_optional_var(var_names::VESTING_CONTRACT_ADDRESS, self.contracts.vesting_contract_address); + set_optional_var(var_names::NODE_FAMILIES_CONTRACT_ADDRESS, self.contracts.node_families_contract_address); set_optional_var(var_names::ECASH_CONTRACT_ADDRESS, self.contracts.ecash_contract_address); set_optional_var(var_names::GROUP_CONTRACT_ADDRESS, self.contracts.group_contract_address); set_optional_var(var_names::MULTISIG_CONTRACT_ADDRESS, self.contracts.multisig_contract_address); @@ -340,6 +349,12 @@ impl NymNetworkDetails { self } + #[must_use] + pub fn with_node_families_contract>(mut self, contract: Option) -> Self { + self.contracts.node_families_contract_address = contract.map(Into::into); + self + } + #[must_use] pub fn with_ecash_contract>(mut self, contract: Option) -> Self { self.contracts.ecash_contract_address = contract.map(Into::into); diff --git a/common/network-defaults/src/var_names.rs b/common/network-defaults/src/var_names.rs index 4bfb1d5877..d92de0fd16 100644 --- a/common/network-defaults/src/var_names.rs +++ b/common/network-defaults/src/var_names.rs @@ -17,6 +17,7 @@ pub const VESTING_CONTRACT_ADDRESS: &str = "VESTING_CONTRACT_ADDRESS"; pub const ECASH_CONTRACT_ADDRESS: &str = "ECASH_CONTRACT_ADDRESS"; pub const GROUP_CONTRACT_ADDRESS: &str = "GROUP_CONTRACT_ADDRESS"; pub const MULTISIG_CONTRACT_ADDRESS: &str = "MULTISIG_CONTRACT_ADDRESS"; +pub const NODE_FAMILIES_CONTRACT_ADDRESS: &str = "NODE_FAMILIES_CONTRACT_ADDRESS"; pub const COCONUT_DKG_CONTRACT_ADDRESS: &str = "COCONUT_DKG_CONTRACT_ADDRESS"; pub const REWARDING_VALIDATOR_ADDRESS: &str = "REWARDING_VALIDATOR_ADDRESS"; pub const NYXD: &str = "NYXD"; diff --git a/common/pemstore/Cargo.toml b/common/pemstore/Cargo.toml index ff2d78250e..4019c56770 100644 --- a/common/pemstore/Cargo.toml +++ b/common/pemstore/Cargo.toml @@ -8,7 +8,9 @@ license = { workspace = true } repository = { workspace = true } homepage.workspace = true documentation.workspace = true -rust-version.workspace = true +# pinned (not inherited from workspace) because this crate is imported by the ecash contract, +# and the contracts workspace cannot be built with rustc more recent than 1.86 +rust-version = "1.86.0" readme.workspace = true publish = true diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index f71026cddb..dc14cdffcb 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -2,17 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - [[package]] name = "ahash" version = "0.8.11" @@ -87,7 +76,7 @@ dependencies = [ "ark-serialize", "ark-std", "derivative", - "digest 0.10.7", + "digest", "itertools 0.10.5", "num-bigint", "num-traits", @@ -141,7 +130,7 @@ checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" dependencies = [ "ark-serialize-derive", "ark-std", - "digest 0.10.7", + "digest", "num-bigint", ] @@ -167,12 +156,6 @@ dependencies = [ "rayon", ] -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - [[package]] name = "autocfg" version = "1.2.0" @@ -215,25 +198,13 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" -[[package]] -name = "blake2" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94cb07b0da6a73955f8fb85d24c466778e70cda767a568229b104f0264089330" -dependencies = [ - "byte-tools", - "crypto-mac", - "digest 0.8.1", - "opaque-debug", -] - [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array 0.14.7", + "generic-array", ] [[package]] @@ -251,12 +222,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "byte-tools" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" - [[package]] name = "byteorder" version = "1.5.0" @@ -321,26 +286,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -[[package]] -name = "chacha" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddf3c081b5fba1e5615640aae998e0fbd10c24cbd897ee39ed754a77601a4862" -dependencies = [ - "byteorder", - "keystream", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -380,7 +325,7 @@ dependencies = [ "ark-serialize", "cosmwasm-core", "curve25519-dalek", - "digest 0.10.7", + "digest", "ecdsa", "ed25519-zebra", "k256", @@ -491,9 +436,9 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array 0.14.7", + "generic-array", "rand_core", - "subtle 2.4.1", + "subtle", "zeroize", ] @@ -503,29 +448,10 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ - "generic-array 0.14.7", + "generic-array", "typenum", ] -[[package]] -name = "crypto-mac" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4434400df11d95d556bac068ddfedd482915eb18fe8bea89bc80b6e4b1c179e5" -dependencies = [ - "generic-array 0.12.4", - "subtle 1.0.0", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -535,10 +461,10 @@ dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", - "digest 0.10.7", + "digest", "fiat-crypto", "rustc_version", - "subtle 2.4.1", + "subtle", "zeroize", ] @@ -709,7 +635,7 @@ dependencies = [ "nym-contracts-common", "nym-contracts-common-testing", "nym-group-contract-common", - "nym-multisig-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", + "nym-multisig-contract-common 1.21.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -796,15 +722,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "digest" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" -dependencies = [ - "generic-array 0.12.4", -] - [[package]] name = "digest" version = "0.10.7" @@ -814,7 +731,7 @@ dependencies = [ "block-buffer", "const-oid", "crypto-common", - "subtle 2.4.1", + "subtle", ] [[package]] @@ -825,7 +742,7 @@ checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" [[package]] name = "easy-addr" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cosmwasm-std", "quote", @@ -839,7 +756,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest 0.10.7", + "digest", "elliptic-curve", "rfc6979", "signature", @@ -866,7 +783,7 @@ dependencies = [ "rand_core", "serde", "sha2", - "subtle 2.4.1", + "subtle", "zeroize", ] @@ -899,13 +816,13 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest 0.10.7", + "digest", "ff", - "generic-array 0.14.7", + "generic-array", "group", "rand_core", "sec1", - "subtle 2.4.1", + "subtle", "zeroize", ] @@ -922,7 +839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" dependencies = [ "rand_core", - "subtle 2.4.1", + "subtle", ] [[package]] @@ -931,15 +848,6 @@ version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c007b1ae3abe1cb6f85a16305acd418b7ca6343b953633fee2b76d8f108b830f" -[[package]] -name = "generic-array" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" -dependencies = [ - "typenum", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -970,7 +878,7 @@ checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", "rand_core", - "subtle 2.4.1", + "subtle", ] [[package]] @@ -1004,22 +912,13 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - [[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.7", + "digest", ] [[package]] @@ -1048,15 +947,6 @@ dependencies = [ "hashbrown 0.15.2", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array 0.14.7", -] - [[package]] name = "itertools" version = "0.10.5" @@ -1102,12 +992,6 @@ dependencies = [ "sha2", ] -[[package]] -name = "keystream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33070833c9ee02266356de0c43f723152bd38bd96ddf52c82b3af10c9138b28" - [[package]] name = "konst" version = "0.3.16" @@ -1141,30 +1025,32 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" -[[package]] -name = "libm" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" - -[[package]] -name = "lioness" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae926706ba42c425c9457121178330d75e273df2e82e28b758faf3de3a9acb9" -dependencies = [ - "arrayref", - "blake2", - "chacha", - "keystream", -] - [[package]] name = "memchr" version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" +[[package]] +name = "node-families" +version = "0.1.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-storage-plus", + "cw-utils", + "cw2", + "nym-contracts-common", + "nym-contracts-common-testing", + "nym-crypto", + "nym-mixnet-contract", + "nym-mixnet-contract-common", + "nym-node-families-contract-common", + "serde", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1197,7 +1083,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -1233,9 +1118,9 @@ dependencies = [ [[package]] name = "nym-coconut-dkg-common" -version = "1.20.4" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a95dc43ef8954a4f79846e3224434cf389d4a9c14b77f526dae3cfd2221c6141" +checksum = "fcdfaf17c8b2a73bd6d14a3b9432118d66cc2335a371f185791099d32c25feb0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1243,14 +1128,12 @@ dependencies = [ "cw2", "cw4", "nym-contracts-common", - "nym-multisig-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", + "nym-multisig-contract-common 1.21.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] name = "nym-contracts-common" -version = "1.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47bb3e8427c193cd500c802274b11879086863c3643525b6ece3e9ab1c77bddc" +version = "1.21.0" dependencies = [ "bs58", "cosmwasm-schema", @@ -1264,9 +1147,7 @@ dependencies = [ [[package]] name = "nym-contracts-common-testing" -version = "1.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3628aac6715e844f3ab20e3b8ae8c4684f144ccb78e205f002c1c3ae375e956" +version = "1.21.0" dependencies = [ "anyhow", "cosmwasm-std", @@ -1280,16 +1161,13 @@ dependencies = [ [[package]] name = "nym-crypto" -version = "1.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b710addc28c9950dd961e7dd3837ef3b479492d2b21b5f2437eb7d2899403027" +version = "1.21.0" dependencies = [ "base64 0.22.1", "bs58", "curve25519-dalek", "ed25519-dalek", "nym-pemstore", - "nym-sphinx-types", "rand", "sha2", "subtle-encoding", @@ -1317,7 +1195,7 @@ dependencies = [ "nym-contracts-common-testing", "nym-crypto", "nym-ecash-contract-common", - "nym-multisig-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", + "nym-multisig-contract-common 1.21.0 (registry+https://github.com/rust-lang/crates.io-index)", "nym-network-defaults", "schemars", "semver", @@ -1328,7 +1206,7 @@ dependencies = [ [[package]] name = "nym-ecash-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bs58", "cosmwasm-schema", @@ -1336,15 +1214,15 @@ dependencies = [ "cw-controllers", "cw-utils", "cw2", - "nym-multisig-contract-common 1.20.4", + "nym-multisig-contract-common 1.21.0", "thiserror 2.0.12", ] [[package]] name = "nym-group-contract-common" -version = "1.20.4" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb13102740426a4a2b683f54bbd6614fe9ecd745f5117bcf197c49c300b15edf" +checksum = "f6f9773b3adc4b979c4e0f86d8932a78d10149df36e136f770653ad5405ce41a" dependencies = [ "cosmwasm-schema", "cw-controllers", @@ -1370,6 +1248,7 @@ dependencies = [ "nym-crypto", "nym-mixnet-contract", "nym-mixnet-contract-common", + "nym-node-families-contract-common", "nym-vesting-contract-common", "rand", "rand_chacha", @@ -1379,9 +1258,7 @@ dependencies = [ [[package]] name = "nym-mixnet-contract-common" -version = "1.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41c21bceb3bb8ee2789851b3f381fc035485af825bf7290b7c99a5af4e8f6ba1" +version = "1.21.0" dependencies = [ "bs58", "cosmwasm-schema", @@ -1401,7 +1278,7 @@ dependencies = [ [[package]] name = "nym-multisig-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1416,9 +1293,9 @@ dependencies = [ [[package]] name = "nym-multisig-contract-common" -version = "1.20.4" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a20b931ee849f6179ce2b387accd058720017f644ffbc8c2422f3e9ac3ff54" +checksum = "7681c7b43201d45a4958eab012a93e285ce47f7bba405e1ba1808edd195f3347" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1433,19 +1310,30 @@ dependencies = [ [[package]] name = "nym-network-defaults" -version = "1.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9834193b4641acdf9f360aea684a6bd841cad287930bc0d7c3241a133756464" +version = "1.21.0" dependencies = [ "cargo_metadata 0.19.2", "regex", ] +[[package]] +name = "nym-node-families-contract-common" +version = "1.21.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-utils", + "nym-contracts-common", + "nym-mixnet-contract-common", + "schemars", + "serde", + "thiserror 2.0.12", +] + [[package]] name = "nym-pemstore" -version = "1.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03077f9ebeb40caf8aa8e6f7bf8728449f73733e7a246986e492fa34ad3e70ab" +version = "1.21.0" dependencies = [ "pem", "tracing", @@ -1462,20 +1350,22 @@ dependencies = [ "cw-controllers", "cw-storage-plus", "cw2", + "node-families", "nym-contracts-common", "nym-contracts-common-testing", "nym-crypto", "nym-mixnet-contract", "nym-mixnet-contract-common", + "nym-node-families-contract-common", "nym-performance-contract-common", "serde", ] [[package]] name = "nym-performance-contract-common" -version = "1.20.4" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42129a72f4b0dc0304a48b0ca1769b27694d913687ace5692d4c6924ca9f2a13" +checksum = "c6e47f2a04d8b0e1c492cdd7dcfc98face5efc2a4cb999c03887a5b77e4cf055" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1503,9 +1393,9 @@ dependencies = [ [[package]] name = "nym-pool-contract-common" -version = "1.20.4" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70239cc26beda3ad19289188c50d554522af29646d7d3f855bda6fc8ed332fe7" +checksum = "9965dce8aaee8e9943b342695ff3fda45c8a4f1daab943ed752a8a0b47aa04c7" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1515,16 +1405,6 @@ dependencies = [ "thiserror 2.0.12", ] -[[package]] -name = "nym-sphinx-types" -version = "1.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ba662d39fd6da9e13166fa1162ff41c2cfaed78a77c70df72fbda6fef5eb4f5" -dependencies = [ - "sphinx-packet", - "thiserror 2.0.12", -] - [[package]] name = "nym-vesting-contract" version = "1.4.1" @@ -1548,9 +1428,9 @@ dependencies = [ [[package]] name = "nym-vesting-contract-common" -version = "1.20.4" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "676c2793efbf9ccdf86bb788c903f778a2d5993a5174729303f9511a297f4ca8" +checksum = "af0179bfbb30551414277ca7bed631d48f3321df7b18e3032f23f2f111a577d7" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1567,12 +1447,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" -[[package]] -name = "opaque-debug" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c" - [[package]] name = "p256" version = "0.13.2" @@ -1743,16 +1617,6 @@ dependencies = [ "getrandom", ] -[[package]] -name = "rand_distr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand", -] - [[package]] name = "rayon" version = "1.10.0" @@ -1809,7 +1673,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ "hmac", - "subtle 2.4.1", + "subtle", ] [[package]] @@ -1887,8 +1751,8 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", "der", - "generic-array 0.14.7", - "subtle 2.4.1", + "generic-array", + "subtle", "zeroize", ] @@ -1981,7 +1845,7 @@ checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.7", + "digest", ] [[package]] @@ -1990,36 +1854,10 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest 0.10.7", + "digest", "rand_core", ] -[[package]] -name = "sphinx-packet" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c26f0c20d909fdda1c5d0ece3973127ca421984d55b000215df365e93722fc6e" -dependencies = [ - "aes", - "arrayref", - "blake2", - "bs58", - "byteorder", - "chacha", - "ctr", - "curve25519-dalek", - "digest 0.10.7", - "hkdf", - "hmac", - "lioness", - "rand", - "rand_distr", - "sha2", - "subtle 2.4.1", - "x25519-dalek", - "zeroize", -] - [[package]] name = "spki" version = "0.7.3" @@ -2036,12 +1874,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "subtle" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d67a5a62ba6e01cb2192ff309324cb4875d0c451d55fe2319433abe7a05a8ee" - [[package]] name = "subtle" version = "2.4.1" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index fbd31e1a55..9fc98b76ea 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -4,11 +4,12 @@ members = [ "coconut-dkg", "ecash", "mixnet", - "nym-pool", "multisig/cw3-flex-multisig", "multisig/cw4-group", - "vesting", + "node-families", + "nym-pool", "performance", + "vesting", ] [workspace.package] @@ -18,6 +19,8 @@ homepage = "https://nymtech.net" documentation = "https://nymtech.net" edition = "2021" license = "Apache-2.0" +rust-version = "1.86.0" +readme = "../README.md" [profile.release] opt-level = 3 @@ -56,31 +59,33 @@ schemars = "0.8.16" thiserror = "2.0.11" -easy-addr = { version = "1.20.1", path = "../common/cosmwasm-smart-contracts/easy_addr" } +easy-addr = { version = "1.21.0", path = "../common/cosmwasm-smart-contracts/easy_addr" } # For local development with modifications, add a [patch.crates-io] section (see bottom of file) -nym-coconut-dkg-common = "1.20.4" -nym-contracts-common = "1.20.4" -nym-contracts-common-testing = "1.20.4" -nym-crypto = { version = "1.20.4", default-features = false } -nym-ecash-contract-common = "1.20.4" -nym-group-contract-common = "1.20.4" -nym-mixnet-contract-common = "1.20.4" -nym-multisig-contract-common = "1.20.4" -nym-network-defaults = { version = "1.20.4", default-features = false } -nym-performance-contract-common = "1.20.4" -nym-pool-contract-common = "1.20.4" -nym-vesting-contract-common = "1.20.4" +nym-coconut-dkg-common = "1.21.0" +nym-contracts-common = "1.21.0" +nym-contracts-common-testing = "1.21.0" +nym-crypto = { version = "1.21.0", default-features = false } +nym-ecash-contract-common = "1.21.0" +nym-group-contract-common = "1.21.0" +nym-mixnet-contract-common = "1.21.0" +nym-multisig-contract-common = "1.21.0" +nym-network-defaults = { version = "1.21.0", default-features = false } +nym-performance-contract-common = "1.21.0" +nym-pool-contract-common = "1.21.0" +nym-vesting-contract-common = "1.21.0" # Aliases for crates that some contracts import under different names -contracts-common = { version = "1.20.4", package = "nym-contracts-common" } -mixnet-contract-common = { version = "1.20.4", package = "nym-mixnet-contract-common" } -vesting-contract-common = { version = "1.20.4", package = "nym-vesting-contract-common" } +contracts-common = { version = "1.21.0", package = "nym-contracts-common" } +mixnet-contract-common = { version = "1.21.0", package = "nym-mixnet-contract-common" } +vesting-contract-common = { version = "1.21.0", package = "nym-vesting-contract-common" } +nym-node-families-contract-common = { version = "1.21.0", package = "nym-node-families-contract-common" } # Internal contract workspace members (for cross-contract testing) cw3-flex-multisig = { version = "2.0.0", path = "multisig/cw3-flex-multisig" } cw4-group = { version = "2.0.0", path = "multisig/cw4-group" } nym-mixnet-contract = { version = "1.5.1", path = "mixnet" } nym-vesting-contract = { version = "1.4.1", path = "vesting" } +node-families = { version = "0.1.0", path = "node-families" } [workspace.lints.clippy] unwrap_used = "deny" @@ -97,4 +102,10 @@ unreachable = "deny" # nym-coconut-dkg-common = { path = "../common/cosmwasm-smart-contracts/coconut-dkg" } [patch.crates-io] +nym-network-defaults = { path = "../common/network-defaults" } +nym-crypto = { path = "../common/crypto" } +nym-contracts-common-testing = { path = "../common/cosmwasm-smart-contracts/contracts-common-testing" } +nym-contracts-common = { path = "../common/cosmwasm-smart-contracts/contracts-common" } nym-ecash-contract-common = { path = "../common/cosmwasm-smart-contracts/ecash-contract" } +nym-mixnet-contract-common = { path = "../common/cosmwasm-smart-contracts/mixnet-contract" } +nym-node-families-contract-common = { path = "../common/cosmwasm-smart-contracts/node-families-contract" } diff --git a/contracts/mixnet/Cargo.toml b/contracts/mixnet/Cargo.toml index 20222063f6..88212ffa27 100644 --- a/contracts/mixnet/Cargo.toml +++ b/contracts/mixnet/Cargo.toml @@ -28,6 +28,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] mixnet-contract-common = { workspace = true } vesting-contract-common = { workspace = true } +nym-node-families-contract-common = { workspace = true } nym-contracts-common = { workspace = true } nym-contracts-common-testing = { workspace = true, optional = true } @@ -41,6 +42,8 @@ bs58 = { workspace = true } serde = { workspace = true, default-features = false, features = ["derive"] } semver = { workspace = true } +nym-crypto = { workspace = true, optional = true } + [dev-dependencies] anyhow.workspace = true @@ -56,7 +59,7 @@ nym-contracts-common-testing = { workspace = true } [features] default = [] contract-testing = ["mixnet-contract-common/contract-testing"] -testable-mixnet-contract = ["nym-contracts-common-testing"] +testable-mixnet-contract = ["nym-contracts-common-testing", "nym-crypto", "nym-crypto/asymmetric", "nym-crypto/rand"] schema-gen = ["mixnet-contract-common/schema", "cosmwasm-schema"] [lints] diff --git a/contracts/mixnet/schema/nym-mixnet-contract.json b/contracts/mixnet/schema/nym-mixnet-contract.json index cf36563949..a479011c28 100644 --- a/contracts/mixnet/schema/nym-mixnet-contract.json +++ b/contracts/mixnet/schema/nym-mixnet-contract.json @@ -11,6 +11,7 @@ "epoch_duration", "epochs_in_interval", "initial_rewarding_params", + "node_families_contract_address", "rewarding_denom", "rewarding_validator_address", "vesting_contract_address" @@ -50,6 +51,9 @@ "format": "uint32", "minimum": 0.0 }, + "node_families_contract_address": { + "type": "string" + }, "profit_margin": { "default": { "maximum": "1", @@ -3509,7 +3513,13 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "MigrateMsg", "type": "object", + "required": [ + "node_families_contract_address" + ], "properties": { + "node_families_contract_address": { + "type": "string" + }, "unsafe_skip_state_updates": { "type": [ "boolean", @@ -10774,12 +10784,21 @@ "description": "The current state of the mixnet contract.", "type": "object", "required": [ + "node_families_contract_address", "params", "rewarding_denom", "rewarding_validator_address", "vesting_contract_address" ], "properties": { + "node_families_contract_address": { + "description": "Address of the node families contract. It is called whenever nym-node unbonds so that it could be removed from any family it belongs to.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, "owner": { "description": "Address of the contract owner.", "default": null, diff --git a/contracts/mixnet/schema/raw/instantiate.json b/contracts/mixnet/schema/raw/instantiate.json index b17e1ae6bf..52caa8a639 100644 --- a/contracts/mixnet/schema/raw/instantiate.json +++ b/contracts/mixnet/schema/raw/instantiate.json @@ -7,6 +7,7 @@ "epoch_duration", "epochs_in_interval", "initial_rewarding_params", + "node_families_contract_address", "rewarding_denom", "rewarding_validator_address", "vesting_contract_address" @@ -46,6 +47,9 @@ "format": "uint32", "minimum": 0.0 }, + "node_families_contract_address": { + "type": "string" + }, "profit_margin": { "default": { "maximum": "1", diff --git a/contracts/mixnet/schema/raw/migrate.json b/contracts/mixnet/schema/raw/migrate.json index 57f0d2acdb..757f74d146 100644 --- a/contracts/mixnet/schema/raw/migrate.json +++ b/contracts/mixnet/schema/raw/migrate.json @@ -2,7 +2,13 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "MigrateMsg", "type": "object", + "required": [ + "node_families_contract_address" + ], "properties": { + "node_families_contract_address": { + "type": "string" + }, "unsafe_skip_state_updates": { "type": [ "boolean", diff --git a/contracts/mixnet/schema/raw/response_to_get_state.json b/contracts/mixnet/schema/raw/response_to_get_state.json index c4a10bdb80..35501057aa 100644 --- a/contracts/mixnet/schema/raw/response_to_get_state.json +++ b/contracts/mixnet/schema/raw/response_to_get_state.json @@ -4,12 +4,21 @@ "description": "The current state of the mixnet contract.", "type": "object", "required": [ + "node_families_contract_address", "params", "rewarding_denom", "rewarding_validator_address", "vesting_contract_address" ], "properties": { + "node_families_contract_address": { + "description": "Address of the node families contract. It is called whenever nym-node unbonds so that it could be removed from any family it belongs to.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, "owner": { "description": "Address of the contract owner.", "default": null, diff --git a/contracts/mixnet/src/contract.rs b/contracts/mixnet/src/contract.rs index 0703e6dc23..23dfa000b7 100644 --- a/contracts/mixnet/src/contract.rs +++ b/contracts/mixnet/src/contract.rs @@ -5,7 +5,7 @@ use crate::constants::INITIAL_PLEDGE_AMOUNT; use crate::interval::storage as interval_storage; use crate::mixnet_contract_settings::storage as mixnet_params_storage; use crate::nodes::storage as nymnodes_storage; -use crate::queued_migrations::introduce_key_rotation_id; +use crate::queued_migrations::introduce_node_families_contract; use crate::rewards::storage::RewardingStorage; use cosmwasm_std::{ entry_point, to_json_binary, Addr, Coin, Deps, DepsMut, Env, MessageInfo, QueryResponse, @@ -28,6 +28,7 @@ fn default_initial_state( owner: Addr, rewarding_validator_address: Addr, vesting_contract_address: Addr, + node_families_contract_address: Addr, ) -> ContractState { // we have to temporarily preserve this functionalities until it can be removed #[allow(deprecated)] @@ -35,6 +36,7 @@ fn default_initial_state( owner: Some(owner), rewarding_validator_address, vesting_contract_address, + node_families_contract_address, rewarding_denom: msg.rewarding_denom.clone(), params: ContractStateParams { delegations_params: DelegationsParams { @@ -90,11 +92,15 @@ pub fn instantiate( let rewarding_validator_address = deps.api.addr_validate(&msg.rewarding_validator_address)?; let vesting_contract_address = deps.api.addr_validate(&msg.vesting_contract_address)?; + let node_families_contract_address = deps + .api + .addr_validate(&msg.node_families_contract_address)?; let state = default_initial_state( &msg, info.sender.clone(), rewarding_validator_address.clone(), vesting_contract_address, + node_families_contract_address, ); let starting_interval = Interval::init_interval(msg.epochs_in_interval, msg.epoch_duration, &env); @@ -629,7 +635,10 @@ pub fn migrate( let skip_state_updates = msg.unsafe_skip_state_updates.unwrap_or(false); if !skip_state_updates { - introduce_key_rotation_id(deps.branch())?; + let addr = deps + .api + .addr_validate(&msg.node_families_contract_address)?; + introduce_node_families_contract(deps.branch(), addr)?; } // due to circular dependency on contract addresses (i.e. mixnet contract requiring vesting contract address @@ -668,6 +677,7 @@ mod tests { let init_msg = InstantiateMsg { rewarding_validator_address: deps.api.addr_make("foomp123").to_string(), vesting_contract_address: deps.api.addr_make("bar456").to_string(), + node_families_contract_address: deps.api.addr_make("baz789").to_string(), rewarding_denom: "uatom".to_string(), epochs_in_interval: 1234, epoch_duration: Duration::from_secs(4321), @@ -708,6 +718,7 @@ mod tests { owner: Some(deps.api.addr_make("sender")), rewarding_validator_address: deps.api.addr_make("foomp123"), vesting_contract_address: deps.api.addr_make("bar456"), + node_families_contract_address: deps.api.addr_make("baz789"), rewarding_denom: "uatom".into(), params: ContractStateParams { delegations_params: DelegationsParams { diff --git a/contracts/mixnet/src/mixnet_contract_settings/queries.rs b/contracts/mixnet/src/mixnet_contract_settings/queries.rs index 5963dbf0a2..5ea711a1d0 100644 --- a/contracts/mixnet/src/mixnet_contract_settings/queries.rs +++ b/contracts/mixnet/src/mixnet_contract_settings/queries.rs @@ -71,7 +71,7 @@ pub(crate) fn query_current_nym_node_version( pub(crate) mod tests { use super::*; use crate::support::tests::test_helpers; - use cosmwasm_std::{coin, Addr}; + use cosmwasm_std::coin; use mixnet_contract_common::{ConfigScoreParams, DelegationsParams, OperatorsParams}; #[test] @@ -80,9 +80,10 @@ pub(crate) mod tests { #[allow(deprecated)] let dummy_state = ContractState { - owner: Some(Addr::unchecked("foomp")), - rewarding_validator_address: Addr::unchecked("monitor"), - vesting_contract_address: Addr::unchecked("foomp"), + owner: Some(deps.api.addr_make("foomp")), + rewarding_validator_address: deps.api.addr_make("monitor"), + vesting_contract_address: deps.api.addr_make("foomp"), + node_families_contract_address: deps.api.addr_make("bar"), rewarding_denom: "unym".to_string(), params: ContractStateParams { delegations_params: DelegationsParams { diff --git a/contracts/mixnet/src/mixnet_contract_settings/storage.rs b/contracts/mixnet/src/mixnet_contract_settings/storage.rs index edf511e3cd..98fb47c68b 100644 --- a/contracts/mixnet/src/mixnet_contract_settings/storage.rs +++ b/contracts/mixnet/src/mixnet_contract_settings/storage.rs @@ -156,6 +156,14 @@ pub(crate) fn vesting_contract_address(storage: &dyn Storage) -> Result Result { + Ok(CONTRACT_STATE + .load(storage) + .map(|state| state.node_families_contract_address)?) +} + pub(crate) fn state_params( storage: &dyn Storage, ) -> Result { diff --git a/contracts/mixnet/src/nodes/transactions.rs b/contracts/mixnet/src/nodes/transactions.rs index 646bd48dd9..96b043935f 100644 --- a/contracts/mixnet/src/nodes/transactions.rs +++ b/contracts/mixnet/src/nodes/transactions.rs @@ -16,7 +16,7 @@ use crate::support::helpers::{ ensure_epoch_in_progress_state, ensure_no_existing_bond, ensure_operating_cost_within_range, ensure_profit_margin_within_range, validate_pledge, }; -use cosmwasm_std::{coin, Coin, DepsMut, Env, MessageInfo, Response}; +use cosmwasm_std::{coin, wasm_execute, Coin, DepsMut, Env, MessageInfo, Response}; use mixnet_contract_common::error::MixnetContractError; use mixnet_contract_common::events::{ new_nym_node_bonding_event, new_pending_cost_params_update_event, @@ -29,6 +29,7 @@ use mixnet_contract_common::{ PendingIntervalEventKind, }; use nym_contracts_common::signing::{MessageSignature, SigningPurpose}; +use nym_node_families_contract_common::msg::ExecuteMsg as NodeFamiliesExecuteMsg; use serde::Serialize; pub fn try_add_nym_node( @@ -147,13 +148,24 @@ pub(crate) fn try_remove_nym_node( }; interval_storage::push_new_epoch_event(deps.storage, &env, epoch_event)?; - Ok( - Response::new().add_event(new_pending_nym_node_unbonding_event( + // send message to the node families contract to remove this node from any family it might be a member of + let node_families_contract_addr = + mixnet_params_storage::node_families_contract_address(deps.storage)?; + let remove_from_family_exec = wasm_execute( + node_families_contract_addr, + &NodeFamiliesExecuteMsg::OnNymNodeUnbond { + node_id: existing_bond.node_id, + }, + vec![], + )?; + + Ok(Response::new() + .add_message(remove_from_family_exec) + .add_event(new_pending_nym_node_unbonding_event( &existing_bond.owner, existing_bond.identity(), existing_bond.node_id, - )), - ) + ))) } pub(crate) fn try_update_node_config( diff --git a/contracts/mixnet/src/queued_migrations.rs b/contracts/mixnet/src/queued_migrations.rs index 4f9690a87e..32d87c1ab7 100644 --- a/contracts/mixnet/src/queued_migrations.rs +++ b/contracts/mixnet/src/queued_migrations.rs @@ -1,21 +1,39 @@ // Copyright 2022-2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::interval::storage as interval_storage; -use crate::nodes::storage as nymnodes_storage; -use cosmwasm_std::DepsMut; +use crate::constants::CONTRACT_STATE_KEY; +use crate::mixnet_contract_settings::storage as mixnet_params_storage; +use cosmwasm_std::{Addr, DepsMut}; +use cw_storage_plus::Item; use mixnet_contract_common::error::MixnetContractError; -use mixnet_contract_common::KeyRotationState; +use mixnet_contract_common::{ContractState, ContractStateParams}; + +pub fn introduce_node_families_contract( + deps: DepsMut, + node_families_contract_address: Addr, +) -> Result<(), MixnetContractError> { + #[derive(serde::Serialize, serde::Deserialize)] + struct OldContractState { + owner: Option, + rewarding_validator_address: Addr, + vesting_contract_address: Addr, + rewarding_denom: String, + params: ContractStateParams, + } + + const OLD_CONTRACT_STATE: Item = Item::new(CONTRACT_STATE_KEY); + let old = OLD_CONTRACT_STATE.load(deps.storage)?; + + #[allow(deprecated)] + let updated = ContractState { + owner: old.owner, + rewarding_validator_address: old.rewarding_validator_address, + vesting_contract_address: old.vesting_contract_address, + rewarding_denom: old.rewarding_denom, + params: old.params, + node_families_contract_address, + }; + mixnet_params_storage::CONTRACT_STATE.save(deps.storage, &updated)?; -pub fn introduce_key_rotation_id(deps: DepsMut) -> Result<(), MixnetContractError> { - let current_epoch_id = - interval_storage::current_interval(deps.storage)?.current_epoch_absolute_id(); - nymnodes_storage::KEY_ROTATION_STATE.save( - deps.storage, - &KeyRotationState { - validity_epochs: 24, - initial_epoch_id: current_epoch_id, - }, - )?; Ok(()) } diff --git a/contracts/mixnet/src/testable_mixnet_contract.rs b/contracts/mixnet/src/testable_mixnet_contract.rs index 498e121222..d829105f0c 100644 --- a/contracts/mixnet/src/testable_mixnet_contract.rs +++ b/contracts/mixnet/src/testable_mixnet_contract.rs @@ -5,16 +5,26 @@ #![allow(clippy::unwrap_used)] use crate::contract::{execute, instantiate, migrate, query}; -use cosmwasm_std::Decimal; +use cosmwasm_std::testing::{message_info, mock_env}; +use cosmwasm_std::{coin, coins, Addr, Decimal, MessageInfo, StdError, StdResult}; use mixnet_contract_common::error::MixnetContractError; +use mixnet_contract_common::nym_node::{NodeDetailsResponse, NodeOwnershipResponse, Role}; use mixnet_contract_common::reward_params::RewardedSetParams; use mixnet_contract_common::{ - ExecuteMsg, InitialRewardingParams, InstantiateMsg, MigrateMsg, QueryMsg, + CurrentIntervalResponse, EpochId, ExecuteMsg, InitialRewardingParams, InstantiateMsg, Interval, + MigrateMsg, MixnetContractQuerier, NodeCostParams, NodeId, NymNode, NymNodeBondingPayload, + QueryMsg, RoleAssignment, SignableNymNodeBondingMsg, DEFAULT_INTERVAL_OPERATING_COST_AMOUNT, + DEFAULT_PROFIT_MARGIN_PERCENT, }; +use nym_contracts_common::signing::{ContractMessageContent, MessageSignature}; use nym_contracts_common::Percent; use nym_contracts_common_testing::{ - mock_dependencies, ContractFn, PermissionedFn, QueryFn, TEST_DENOM, + mock_dependencies, ArbitraryContractStorageReader, ArbitraryContractStorageWriter, BankExt, + ChainOpts, ContractFn, ContractTester, PermissionedFn, QueryFn, RandExt, TEST_DENOM, }; +use nym_crypto::asymmetric::ed25519; +use serde::de::DeserializeOwned; +use serde::Serialize; use std::time::Duration; pub use nym_contracts_common_testing::TestableNymContract; @@ -74,6 +84,10 @@ impl TestableNymContract for MixnetContract { InstantiateMsg { rewarding_validator_address: deps.api.addr_make("rewarder").to_string(), vesting_contract_address: deps.api.addr_make("vesting-contract").to_string(), + node_families_contract_address: deps + .api + .addr_make("node-families-contract") + .to_string(), rewarding_denom: TEST_DENOM.to_string(), epochs_in_interval: 720, epoch_duration: Duration::from_secs(60 * 60), @@ -87,3 +101,203 @@ impl TestableNymContract for MixnetContract { } } } + +pub trait EmbeddedMixnetContractExt: + ChainOpts + ArbitraryContractStorageWriter + ArbitraryContractStorageReader + RandExt + BankExt +{ + fn mixnet_contract_address(&self) -> StdResult; + + fn execute_mixnet_contract(&mut self, sender: MessageInfo, msg: &ExecuteMsg) -> StdResult<()> { + let address = self.mixnet_contract_address()?; + + self.execute_arbitrary_contract(address, sender, msg) + .map_err(|err| { + StdError::generic_err(format!("mixnet contract execution failure: {err}")) + })?; + Ok(()) + } + + fn read_from_mixnet_contract_storage( + &self, + key: impl AsRef<[u8]>, + ) -> StdResult { + let address = self.mixnet_contract_address()?; + + self.must_read_value_from_contract_storage(address, key) + } + + fn write_to_mixnet_contract_storage( + &mut self, + key: impl AsRef<[u8]>, + value: impl AsRef<[u8]>, + ) -> StdResult<()> { + let address = self.mixnet_contract_address()?; + + ::set_contract_storage(self, address, key, value); + Ok(()) + } + + fn write_to_mixnet_contract_storage_value( + &mut self, + key: impl AsRef<[u8]>, + value: &T, + ) -> StdResult<()> { + let address = self.mixnet_contract_address()?; + + self.set_contract_storage_value(address, key, value) + } + + fn current_mixnet_epoch(&self) -> StdResult { + let address = self.mixnet_contract_address()?; + + Ok(self + .deps() + .querier + .query_current_mixnet_interval(address.clone())? + .current_epoch_absolute_id()) + } + + fn advance_mixnet_epoch(&mut self) -> StdResult<()> { + let interval_details: CurrentIntervalResponse = self.query_arbitrary_contract( + self.mixnet_contract_address()?, + &QueryMsg::GetCurrentIntervalDetails {}, + )?; + let until_end = interval_details.time_until_current_epoch_end().as_secs(); + let timestamp = self.env().block.time.plus_seconds(until_end + 1); + self.set_block_time(timestamp); + self.next_block(); + + // this was hardcoded in mixnet init + let mixnet_rewarder = self.addr_make("rewarder"); + let rewarder = message_info(&mixnet_rewarder, &[]); + self.execute_mixnet_contract(rewarder.clone(), &ExecuteMsg::BeginEpochTransition {})?; + self.execute_mixnet_contract( + rewarder.clone(), + &ExecuteMsg::ReconcileEpochEvents { limit: None }, + )?; + + for role in [ + Role::ExitGateway, + Role::EntryGateway, + Role::Layer1, + Role::Layer2, + Role::Layer3, + Role::Standby, + ] { + self.execute_mixnet_contract( + rewarder.clone(), + &ExecuteMsg::AssignRoles { + assignment: RoleAssignment { + role, + nodes: vec![], + }, + }, + )?; + } + Ok(()) + } + + fn set_mixnet_epoch(&mut self, epoch_id: EpochId) -> StdResult<()> { + let address = self.mixnet_contract_address()?; + + let interval = self + .deps() + .querier + .query_current_mixnet_interval(address.clone())?; + + let mut to_update = if interval.current_epoch_absolute_id() <= epoch_id { + interval + } else { + Interval::init_interval( + interval.epochs_in_interval(), + interval.epoch_length(), + &mock_env(), + ) + }; + + let current = to_update.current_epoch_absolute_id(); + let diff = epoch_id - current; + for _ in 0..diff { + to_update = to_update.advance_epoch(); + } + self.set_contract_storage_value(&address, b"ci", &to_update) + } + + fn bond_dummy_nymnode_for(&mut self, node_owner: &Addr) -> Result { + let pledge = coins(100_000000, TEST_DENOM); + let keypair = ed25519::KeyPair::new(self.raw_rng()); + let identity_key = keypair.public_key().to_base58_string(); + + let node = NymNode { + host: "1.2.3.4".to_string(), + custom_http_port: None, + identity_key, + }; + let cost_params = NodeCostParams { + profit_margin_percent: Percent::from_percentage_value(DEFAULT_PROFIT_MARGIN_PERCENT) + .unwrap(), + interval_operating_cost: coin(DEFAULT_INTERVAL_OPERATING_COST_AMOUNT, TEST_DENOM), + }; + // initial signing nonce is 0 for a new address + let signing_nonce = 0; + + let payload = NymNodeBondingPayload::new(node.clone(), cost_params.clone()); + let content = ContractMessageContent::new(node_owner.clone(), pledge.clone(), payload); + let msg = SignableNymNodeBondingMsg::new(signing_nonce, content); + + let owner_signature = keypair.private_key().sign(msg.to_plaintext()?); + let owner_signature = MessageSignature::from(owner_signature.to_bytes().as_ref()); + + self.execute_mixnet_contract( + message_info(node_owner, &pledge), + &ExecuteMsg::BondNymNode { + node, + cost_params, + owner_signature, + }, + )?; + + let bond: NodeOwnershipResponse = self.query_arbitrary_contract( + self.mixnet_contract_address()?, + &QueryMsg::GetOwnedNymNode { + address: node_owner.to_string(), + }, + )?; + + Ok(bond.details.unwrap().bond_information.node_id) + } + + fn bond_dummy_nymnode(&mut self) -> Result { + let node_owner = self.generate_account_with_balance(); + self.bond_dummy_nymnode_for(&node_owner) + } + + fn unbond_nymnode(&mut self, node_id: NodeId) -> Result<(), StdError> { + let bond: NodeDetailsResponse = self.query_arbitrary_contract( + self.mixnet_contract_address()?, + &QueryMsg::GetNymNodeDetails { node_id }, + )?; + + let node_owner = bond.details.unwrap().bond_information.owner; + + self.execute_mixnet_contract( + message_info(&node_owner, &[]), + &ExecuteMsg::UnbondNymNode {}, + )?; + + self.advance_mixnet_epoch()?; + Ok(()) + } +} + +impl EmbeddedMixnetContractExt for ContractTester +where + C: TestableNymContract, +{ + fn mixnet_contract_address(&self) -> StdResult { + self.well_known_contracts + .get(MixnetContract::NAME) + .ok_or_else(|| StdError::generic_err("mixnet contract not part of the tester")) + .cloned() + } +} diff --git a/contracts/node-families/.cargo/config b/contracts/node-families/.cargo/config new file mode 100644 index 0000000000..2fb2c1afdb --- /dev/null +++ b/contracts/node-families/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --bin schema --features=schema-gen" \ No newline at end of file diff --git a/contracts/node-families/Cargo.toml b/contracts/node-families/Cargo.toml new file mode 100644 index 0000000000..289db1eae2 --- /dev/null +++ b/contracts/node-families/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "node-families" +description = "Nym Node Families contract" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +rust-version.workspace = true +readme.workspace = true +publish = false + +[[bin]] +name = "schema" +required-features = ["schema-gen"] + +[lib] +name = "node_families_contract" +crate-type = ["cdylib", "rlib"] + +[dependencies] +cosmwasm-std = { workspace = true } +cw2 = { workspace = true } +cw-storage-plus = { workspace = true } +cw-controllers = { workspace = true } +serde = { workspace = true } +cosmwasm-schema = { workspace = true, optional = true } +cw-utils = { workspace = true } + +nym-contracts-common = { workspace = true } +nym-node-families-contract-common = { workspace = true } +nym-mixnet-contract-common = { workspace = true } + +# Optional deps activated by the `testable-node-families-contract` feature so +# downstream crates can pull in `crate::testing` for their own test harnesses. +nym-contracts-common-testing = { workspace = true, optional = true } +nym-mixnet-contract = { workspace = true, optional = true, features = ["testable-mixnet-contract"] } +nym-crypto = { workspace = true, optional = true } + +[dev-dependencies] +anyhow = { workspace = true } +# make the testing helpers available for this crate's own unit tests via +# `cfg(test)`; downstream crates instead pull these in through the +# `testable-node-families-contract` feature. +nym-contracts-common-testing = { workspace = true } +nym-mixnet-contract = { workspace = true, features = ["testable-mixnet-contract"] } +nym-crypto = { workspace = true, features = ["asymmetric"] } + +[features] +schema-gen = ["nym-node-families-contract-common/schema", "cosmwasm-schema"] +testable-node-families-contract = [ + "nym-contracts-common-testing", + "nym-mixnet-contract", + "nym-crypto", + "nym-crypto/asymmetric", +] + + +[lints] +workspace = true diff --git a/contracts/node-families/Makefile b/contracts/node-families/Makefile new file mode 100644 index 0000000000..086fa71ad3 --- /dev/null +++ b/contracts/node-families/Makefile @@ -0,0 +1,5 @@ +wasm: + RUSTFLAGS='-C link-arg=-s' cargo build --release --target wasm32-unknown-unknown + +generate-schema: + cargo schema diff --git a/contracts/node-families/schema/node-families.json b/contracts/node-families/schema/node-families.json new file mode 100644 index 0000000000..b9165c7546 --- /dev/null +++ b/contracts/node-families/schema/node-families.json @@ -0,0 +1,2844 @@ +{ + "contract_name": "node-families", + "contract_version": "0.1.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "description": "Message used to instantiate the node families contract.", + "type": "object", + "required": [ + "config", + "mixnet_contract_address" + ], + "properties": { + "config": { + "$ref": "#/definitions/Config" + }, + "mixnet_contract_address": { + "type": "string" + } + }, + "additionalProperties": false, + "definitions": { + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Config": { + "description": "Runtime configuration of the node families contract.", + "type": "object", + "required": [ + "create_family_fee", + "default_invitation_validity_secs", + "family_description_length_limit", + "family_name_length_limit" + ], + "properties": { + "create_family_fee": { + "description": "Fee charged on each successful `create_family` execution.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "default_invitation_validity_secs": { + "description": "Default lifetime, in seconds, used by `invite_to_family` when the sender doesn't supply an explicit value. Senders may override this per-invitation via the optional `validity_secs` argument.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_description_length_limit": { + "description": "Maximum allowed length, in characters, of a family description.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "family_name_length_limit": { + "description": "Maximum allowed length, in characters, of a family name.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "description": "Execute messages accepted by the contract.", + "oneOf": [ + { + "description": "Replace the contract's runtime [`Config`]. Restricted to the contract admin.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "$ref": "#/definitions/Config" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Create a new family owned by the message sender. The configured `create_family_fee` must be attached as funds.", + "type": "object", + "required": [ + "create_family" + ], + "properties": { + "create_family": { + "type": "object", + "required": [ + "description", + "name" + ], + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Disband the family owned by the message sender. The family must have no current members; any still-pending invitations are revoked.", + "type": "object", + "required": [ + "disband_family" + ], + "properties": { + "disband_family": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Invite a node to the family owned by the message sender. If `validity_secs` is omitted the invitation expires `default_invitation_validity_secs` seconds (from [`Config`]) after the current block time.", + "type": "object", + "required": [ + "invite_to_family" + ], + "properties": { + "invite_to_family": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "validity_secs": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Revoke a still-pending invitation previously issued by the sender's family.", + "type": "object", + "required": [ + "revoke_family_invitation" + ], + "properties": { + "revoke_family_invitation": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Accept a pending invitation. The sender must control `node_id`.", + "type": "object", + "required": [ + "accept_family_invitation" + ], + "properties": { + "accept_family_invitation": { + "type": "object", + "required": [ + "family_id", + "node_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Reject a pending invitation. The sender must control `node_id`.", + "type": "object", + "required": [ + "reject_family_invitation" + ], + "properties": { + "reject_family_invitation": { + "type": "object", + "required": [ + "family_id", + "node_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Leave the family `node_id` currently belongs to. The sender must control `node_id`.", + "type": "object", + "required": [ + "leave_family" + ], + "properties": { + "leave_family": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Remove `node_id` from the family owned by the message sender.", + "type": "object", + "required": [ + "kick_from_family" + ], + "properties": { + "kick_from_family": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Cross-contract callback fired by the mixnet contract the moment node with `node_id` initiates unbonding. Removes the node from any family it currently belongs to and rejects every pending invitation issued to it. Sender must be the configured mixnet contract address.", + "type": "object", + "required": [ + "on_nym_node_unbond" + ], + "properties": { + "on_nym_node_unbond": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Config": { + "description": "Runtime configuration of the node families contract.", + "type": "object", + "required": [ + "create_family_fee", + "default_invitation_validity_secs", + "family_description_length_limit", + "family_name_length_limit" + ], + "properties": { + "create_family_fee": { + "description": "Fee charged on each successful `create_family` execution.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "default_invitation_validity_secs": { + "description": "Default lifetime, in seconds, used by `invite_to_family` when the sender doesn't supply an explicit value. Senders may override this per-invitation via the optional `validity_secs` argument.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_description_length_limit": { + "description": "Maximum allowed length, in characters, of a family description.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "family_name_length_limit": { + "description": "Maximum allowed length, in characters, of a family name.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "description": "Query messages accepted by the contract.", + "oneOf": [ + { + "description": "Look up a single family by its id.", + "type": "object", + "required": [ + "get_family_by_id" + ], + "properties": { + "get_family_by_id": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Look up the (at most one) family owned by a given address.", + "type": "object", + "required": [ + "get_family_by_owner" + ], + "properties": { + "get_family_by_owner": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Look up a single family by its name. The lookup is normalised contract-side (lowercased, non-alphanumerics stripped), so equivalent inputs resolve to the same family.", + "type": "object", + "required": [ + "get_family_by_name" + ], + "properties": { + "get_family_by_name": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_families_paged" + ], + "properties": { + "get_families_paged": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Look up which family β€” if any β€” a node currently belongs to.", + "type": "object", + "required": [ + "get_family_membership" + ], + "properties": { + "get_family_membership": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every node currently in a given family.", + "type": "object", + "required": [ + "get_family_members_paged" + ], + "properties": { + "get_family_members_paged": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every current family member across all families, in ascending [`NodeId`] order. Each entry carries the membership record (which in turn names the family the node belongs to).", + "type": "object", + "required": [ + "get_all_family_members_paged" + ], + "properties": { + "get_all_family_members_paged": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Look up the pending invitation for a specific `(family_id, node_id)` pair.", + "type": "object", + "required": [ + "get_pending_invitation" + ], + "properties": { + "get_pending_invitation": { + "type": "object", + "required": [ + "family_id", + "node_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every pending invitation issued by a given family.", + "type": "object", + "required": [ + "get_pending_invitations_for_family_paged" + ], + "properties": { + "get_pending_invitations_for_family_paged": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every pending invitation issued for a given node.", + "type": "object", + "required": [ + "get_pending_invitations_for_node_paged" + ], + "properties": { + "get_pending_invitations_for_node_paged": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every pending invitation across all families.", + "type": "object", + "required": [ + "get_all_pending_invitations_paged" + ], + "properties": { + "get_all_pending_invitations_paged": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived (terminal-state) invitation issued by a given family.", + "type": "object", + "required": [ + "get_past_invitations_for_family_paged" + ], + "properties": { + "get_past_invitations_for_family_paged": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived (terminal-state) invitation issued to a given node.", + "type": "object", + "required": [ + "get_past_invitations_for_node_paged" + ], + "properties": { + "get_past_invitations_for_node_paged": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived (terminal-state) invitation across all families.", + "type": "object", + "required": [ + "get_all_past_invitations_paged" + ], + "properties": { + "get_all_past_invitations_paged": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived membership record for a given family (nodes that used to belong to it but have since been removed).", + "type": "object", + "required": [ + "get_past_members_for_family_paged" + ], + "properties": { + "get_past_members_for_family_paged": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived membership record for a given node (every family the node used to belong to but has since been removed from), across all families.", + "type": "object", + "required": [ + "get_past_members_for_node_paged" + ], + "properties": { + "get_past_members_for_node_paged": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "description": "Message passed to the contract's `migrate` entry point.", + "type": "object", + "additionalProperties": false + }, + "sudo": null, + "responses": { + "get_all_family_members_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllFamilyMembersPagedResponse", + "description": "Response to [`QueryMsg::GetAllFamilyMembersPaged`](crate::QueryMsg::GetAllFamilyMembersPaged).", + "type": "object", + "required": [ + "members" + ], + "properties": { + "members": { + "description": "The members on this page, in ascending [`NodeId`] order across every family.", + "type": "array", + "items": { + "$ref": "#/definitions/FamilyMemberRecord" + } + }, + "start_next_after": { + "description": "Cursor (last `node_id`) to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyMemberRecord": { + "description": "One entry in a [`FamilyMembersPagedResponse`] page β€” pairs a node id with its [`FamilyMembership`] record (notably its `joined_at` timestamp).", + "type": "object", + "required": [ + "membership", + "node_id" + ], + "properties": { + "membership": { + "description": "The membership record (carries `family_id` and `joined_at`).", + "allOf": [ + { + "$ref": "#/definitions/FamilyMembership" + } + ] + }, + "node_id": { + "description": "The node currently in the family.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyMembership": { + "description": "On-chain record of a node's current family membership.\n\nA node belongs to at most one family at a time, so this is keyed by `NodeId` alone β€” `family_id` is carried in the value to support reverse lookups (all nodes in a given family) via a secondary index.", + "type": "object", + "required": [ + "family_id", + "joined_at" + ], + "properties": { + "family_id": { + "description": "The family the node is currently a member of.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "joined_at": { + "description": "Block timestamp (unix seconds) at which the node accepted its invitation and joined the family.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } + }, + "get_all_past_invitations_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllPastFamilyInvitationsPagedResponse", + "description": "Response to [`QueryMsg::GetAllPastInvitationsPaged`](crate::QueryMsg::GetAllPastInvitationsPaged).", + "type": "object", + "required": [ + "invitations" + ], + "properties": { + "invitations": { + "description": "The archived invitations on this page, in ascending `((family_id, node_id), counter)` order across all terminal statuses.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyInvitation" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert β€” there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyInvitationStatus": { + "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here β€” they are simply left in the pending set (see `FamilyInvitation::expires_at`).", + "oneOf": [ + { + "description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.", + "type": "object", + "required": [ + "pending" + ], + "properties": { + "pending": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee accepted and joined the family at the given timestamp.", + "type": "object", + "required": [ + "accepted" + ], + "properties": { + "accepted": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee explicitly rejected the invitation at the given timestamp.", + "type": "object", + "required": [ + "rejected" + ], + "properties": { + "rejected": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The family revoked the invitation at the given timestamp before it could be accepted or rejected.", + "type": "object", + "required": [ + "revoked" + ], + "properties": { + "revoked": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "PastFamilyInvitation": { + "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here β€” they remain in the pending map until explicitly cleared.", + "type": "object", + "required": [ + "invitation", + "status" + ], + "properties": { + "invitation": { + "description": "The original invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + }, + "status": { + "description": "What ultimately happened to it.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitationStatus" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "get_all_pending_invitations_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingInvitationsPagedResponse", + "description": "Response to [`QueryMsg::GetAllPendingInvitationsPaged`](crate::QueryMsg::GetAllPendingInvitationsPaged).", + "type": "object", + "required": [ + "invitations" + ], + "properties": { + "invitations": { + "description": "The pending invitations on this page, in ascending `(family_id, node_id)` order, each stamped with whether it had already timed out at the time the query was served.", + "type": "array", + "items": { + "$ref": "#/definitions/PendingFamilyInvitationDetails" + } + }, + "start_next_after": { + "description": "Cursor (last `(family_id, node_id)` pair) to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert β€” there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PendingFamilyInvitationDetails": { + "description": "A pending [`FamilyInvitation`] paired with whether it has already timed out at the time the query was served.", + "type": "object", + "required": [ + "expired", + "invitation" + ], + "properties": { + "expired": { + "description": "`true` iff `now >= invitation.expires_at` at query time, i.e. the invitation is still in the pending map but can no longer be acted on.", + "type": "boolean" + }, + "invitation": { + "description": "The stored invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "get_families_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "FamiliesPagedResponse", + "description": "Response to [`QueryMsg::GetFamiliesPaged`](crate::QueryMsg::GetFamiliesPaged).", + "type": "object", + "required": [ + "families" + ], + "properties": { + "families": { + "description": "The families on this page, in ascending [`NodeFamilyId`] order.", + "type": "array", + "items": { + "$ref": "#/definitions/NodeFamily" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (which the caller should treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "NodeFamily": { + "description": "On-chain representation of a node family.", + "type": "object", + "required": [ + "created_at", + "description", + "id", + "members", + "name", + "normalised_name", + "owner", + "paid_fee" + ], + "properties": { + "created_at": { + "description": "Timestamp of the creation of the node family", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "description": "The optional description of the node family", + "type": "string" + }, + "id": { + "description": "The id of the node family", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "Memoized value of the current number of members in the node family Used to detect if the family is empty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "description": "The name of the node family", + "type": "string" + }, + "normalised_name": { + "description": "Normalised name of the node family used for uniqueness checks", + "type": "string" + }, + "owner": { + "description": "The owner of the node family", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "paid_fee": { + "description": "Records the fee paid when the family was created, so that the appropriate amount could be returned upon it getting disbanded.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "get_family_by_id": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NodeFamilyResponse", + "description": "Response to [`QueryMsg::GetFamilyById`](crate::QueryMsg::GetFamilyById).", + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family": { + "description": "The matching family, or `None` if no family with `family_id` exists.", + "anyOf": [ + { + "$ref": "#/definitions/NodeFamily" + }, + { + "type": "null" + } + ] + }, + "family_id": { + "description": "The id that was queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "NodeFamily": { + "description": "On-chain representation of a node family.", + "type": "object", + "required": [ + "created_at", + "description", + "id", + "members", + "name", + "normalised_name", + "owner", + "paid_fee" + ], + "properties": { + "created_at": { + "description": "Timestamp of the creation of the node family", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "description": "The optional description of the node family", + "type": "string" + }, + "id": { + "description": "The id of the node family", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "Memoized value of the current number of members in the node family Used to detect if the family is empty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "description": "The name of the node family", + "type": "string" + }, + "normalised_name": { + "description": "Normalised name of the node family used for uniqueness checks", + "type": "string" + }, + "owner": { + "description": "The owner of the node family", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "paid_fee": { + "description": "Records the fee paid when the family was created, so that the appropriate amount could be returned upon it getting disbanded.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "get_family_by_name": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NodeFamilyByNameResponse", + "description": "Response to [`QueryMsg::GetFamilyByName`](crate::QueryMsg::GetFamilyByName).", + "type": "object", + "required": [ + "name" + ], + "properties": { + "family": { + "description": "The matching family, or `None` if no family with that name exists.", + "anyOf": [ + { + "$ref": "#/definitions/NodeFamily" + }, + { + "type": "null" + } + ] + }, + "name": { + "description": "The name that was queried, echoed back so callers can correlate.", + "type": "string" + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "NodeFamily": { + "description": "On-chain representation of a node family.", + "type": "object", + "required": [ + "created_at", + "description", + "id", + "members", + "name", + "normalised_name", + "owner", + "paid_fee" + ], + "properties": { + "created_at": { + "description": "Timestamp of the creation of the node family", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "description": "The optional description of the node family", + "type": "string" + }, + "id": { + "description": "The id of the node family", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "Memoized value of the current number of members in the node family Used to detect if the family is empty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "description": "The name of the node family", + "type": "string" + }, + "normalised_name": { + "description": "Normalised name of the node family used for uniqueness checks", + "type": "string" + }, + "owner": { + "description": "The owner of the node family", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "paid_fee": { + "description": "Records the fee paid when the family was created, so that the appropriate amount could be returned upon it getting disbanded.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "get_family_by_owner": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NodeFamilyByOwnerResponse", + "description": "Response to [`QueryMsg::GetFamilyByOwner`](crate::QueryMsg::GetFamilyByOwner).", + "type": "object", + "required": [ + "owner" + ], + "properties": { + "family": { + "description": "The matching family, or `None` if `owner` does not currently own one.", + "anyOf": [ + { + "$ref": "#/definitions/NodeFamily" + }, + { + "type": "null" + } + ] + }, + "owner": { + "description": "The (validated) owner address that was queried, echoed back so callers can correlate.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "NodeFamily": { + "description": "On-chain representation of a node family.", + "type": "object", + "required": [ + "created_at", + "description", + "id", + "members", + "name", + "normalised_name", + "owner", + "paid_fee" + ], + "properties": { + "created_at": { + "description": "Timestamp of the creation of the node family", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "description": "The optional description of the node family", + "type": "string" + }, + "id": { + "description": "The id of the node family", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "Memoized value of the current number of members in the node family Used to detect if the family is empty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "description": "The name of the node family", + "type": "string" + }, + "normalised_name": { + "description": "Normalised name of the node family used for uniqueness checks", + "type": "string" + }, + "owner": { + "description": "The owner of the node family", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "paid_fee": { + "description": "Records the fee paid when the family was created, so that the appropriate amount could be returned upon it getting disbanded.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "get_family_members_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "FamilyMembersPagedResponse", + "description": "Response to [`QueryMsg::GetFamilyMembersPaged`](crate::QueryMsg::GetFamilyMembersPaged).", + "type": "object", + "required": [ + "family_id", + "members" + ], + "properties": { + "family_id": { + "description": "The family whose members were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "The members on this page, in ascending [`NodeId`] order.", + "type": "array", + "items": { + "$ref": "#/definitions/FamilyMemberRecord" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (which the caller should treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyMemberRecord": { + "description": "One entry in a [`FamilyMembersPagedResponse`] page β€” pairs a node id with its [`FamilyMembership`] record (notably its `joined_at` timestamp).", + "type": "object", + "required": [ + "membership", + "node_id" + ], + "properties": { + "membership": { + "description": "The membership record (carries `family_id` and `joined_at`).", + "allOf": [ + { + "$ref": "#/definitions/FamilyMembership" + } + ] + }, + "node_id": { + "description": "The node currently in the family.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyMembership": { + "description": "On-chain record of a node's current family membership.\n\nA node belongs to at most one family at a time, so this is keyed by `NodeId` alone β€” `family_id` is carried in the value to support reverse lookups (all nodes in a given family) via a secondary index.", + "type": "object", + "required": [ + "family_id", + "joined_at" + ], + "properties": { + "family_id": { + "description": "The family the node is currently a member of.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "joined_at": { + "description": "Block timestamp (unix seconds) at which the node accepted its invitation and joined the family.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } + }, + "get_family_membership": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NodeFamilyMembershipResponse", + "description": "Response to [`QueryMsg::GetFamilyMembership`](crate::QueryMsg::GetFamilyMembership).", + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "family_id": { + "description": "The id of the family the node currently belongs to, or `None` if the node is not currently a member of any family.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node that was queried.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "get_past_invitations_for_family_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PastFamilyInvitationsPagedResponse", + "description": "Response to [`QueryMsg::GetPastInvitationsForFamilyPaged`](crate::QueryMsg::GetPastInvitationsForFamilyPaged).", + "type": "object", + "required": [ + "family_id", + "invitations" + ], + "properties": { + "family_id": { + "description": "The family whose archived invitations were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "invitations": { + "description": "The archived invitations on this page, in ascending `(node_id, counter)` order across all terminal statuses.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyInvitation" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert β€” there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyInvitationStatus": { + "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here β€” they are simply left in the pending set (see `FamilyInvitation::expires_at`).", + "oneOf": [ + { + "description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.", + "type": "object", + "required": [ + "pending" + ], + "properties": { + "pending": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee accepted and joined the family at the given timestamp.", + "type": "object", + "required": [ + "accepted" + ], + "properties": { + "accepted": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee explicitly rejected the invitation at the given timestamp.", + "type": "object", + "required": [ + "rejected" + ], + "properties": { + "rejected": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The family revoked the invitation at the given timestamp before it could be accepted or rejected.", + "type": "object", + "required": [ + "revoked" + ], + "properties": { + "revoked": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "PastFamilyInvitation": { + "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here β€” they remain in the pending map until explicitly cleared.", + "type": "object", + "required": [ + "invitation", + "status" + ], + "properties": { + "invitation": { + "description": "The original invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + }, + "status": { + "description": "What ultimately happened to it.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitationStatus" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "get_past_invitations_for_node_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PastFamilyInvitationsForNodePagedResponse", + "description": "Response to [`QueryMsg::GetPastInvitationsForNodePaged`](crate::QueryMsg::GetPastInvitationsForNodePaged).", + "type": "object", + "required": [ + "invitations", + "node_id" + ], + "properties": { + "invitations": { + "description": "The archived invitations addressed to this node on this page, in ascending `(family_id, counter)` order across all terminal statuses.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyInvitation" + } + }, + "node_id": { + "description": "The node whose past invitations were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert β€” there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyInvitationStatus": { + "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here β€” they are simply left in the pending set (see `FamilyInvitation::expires_at`).", + "oneOf": [ + { + "description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.", + "type": "object", + "required": [ + "pending" + ], + "properties": { + "pending": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee accepted and joined the family at the given timestamp.", + "type": "object", + "required": [ + "accepted" + ], + "properties": { + "accepted": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee explicitly rejected the invitation at the given timestamp.", + "type": "object", + "required": [ + "rejected" + ], + "properties": { + "rejected": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The family revoked the invitation at the given timestamp before it could be accepted or rejected.", + "type": "object", + "required": [ + "revoked" + ], + "properties": { + "revoked": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "PastFamilyInvitation": { + "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here β€” they remain in the pending map until explicitly cleared.", + "type": "object", + "required": [ + "invitation", + "status" + ], + "properties": { + "invitation": { + "description": "The original invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + }, + "status": { + "description": "What ultimately happened to it.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitationStatus" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "get_past_members_for_family_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PastFamilyMembersPagedResponse", + "description": "Response to [`QueryMsg::GetPastMembersForFamilyPaged`](crate::QueryMsg::GetPastMembersForFamilyPaged).", + "type": "object", + "required": [ + "family_id", + "members" + ], + "properties": { + "family_id": { + "description": "The family whose archived memberships were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "The archived membership records on this page, in ascending `(node_id, counter)` order.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyMember" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "PastFamilyMember": { + "description": "Historical record of a node that used to be part of a family but has since been removed (kicked, left voluntarily, or because the family was disbanded).", + "type": "object", + "required": [ + "family_id", + "node_id", + "removed_at" + ], + "properties": { + "family_id": { + "description": "The family the node used to belong to.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node that was removed.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "removed_at": { + "description": "Block timestamp (unix seconds) at which the membership was terminated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } + }, + "get_past_members_for_node_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PastFamilyMembersForNodePagedResponse", + "description": "Response to [`QueryMsg::GetPastMembersForNodePaged`](crate::QueryMsg::GetPastMembersForNodePaged).", + "type": "object", + "required": [ + "members", + "node_id" + ], + "properties": { + "members": { + "description": "The archived membership records for this node on this page, in ascending `(family_id, counter)` order.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyMember" + } + }, + "node_id": { + "description": "The node whose archived memberships were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "PastFamilyMember": { + "description": "Historical record of a node that used to be part of a family but has since been removed (kicked, left voluntarily, or because the family was disbanded).", + "type": "object", + "required": [ + "family_id", + "node_id", + "removed_at" + ], + "properties": { + "family_id": { + "description": "The family the node used to belong to.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node that was removed.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "removed_at": { + "description": "Block timestamp (unix seconds) at which the membership was terminated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } + }, + "get_pending_invitation": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingFamilyInvitationResponse", + "description": "Response to [`QueryMsg::GetPendingInvitation`](crate::QueryMsg::GetPendingInvitation).", + "type": "object", + "required": [ + "family_id", + "node_id" + ], + "properties": { + "family_id": { + "description": "The family component of the queried `(family_id, node_id)` key.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "invitation": { + "description": "The matching pending invitation along with an explicit expiry flag, or `None` if no such invitation exists.", + "anyOf": [ + { + "$ref": "#/definitions/PendingFamilyInvitationDetails" + }, + { + "type": "null" + } + ] + }, + "node_id": { + "description": "The node component of the queried `(family_id, node_id)` key.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert β€” there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PendingFamilyInvitationDetails": { + "description": "A pending [`FamilyInvitation`] paired with whether it has already timed out at the time the query was served.", + "type": "object", + "required": [ + "expired", + "invitation" + ], + "properties": { + "expired": { + "description": "`true` iff `now >= invitation.expires_at` at query time, i.e. the invitation is still in the pending map but can no longer be acted on.", + "type": "boolean" + }, + "invitation": { + "description": "The stored invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "get_pending_invitations_for_family_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingFamilyInvitationsPagedResponse", + "description": "Response to [`QueryMsg::GetPendingInvitationsForFamilyPaged`](crate::QueryMsg::GetPendingInvitationsForFamilyPaged).", + "type": "object", + "required": [ + "family_id", + "invitations" + ], + "properties": { + "family_id": { + "description": "The family whose pending invitations were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "invitations": { + "description": "The pending invitations on this page, in ascending invitee [`NodeId`] order, each stamped with whether it had already timed out at the time the query was served.", + "type": "array", + "items": { + "$ref": "#/definitions/PendingFamilyInvitationDetails" + } + }, + "start_next_after": { + "description": "Cursor (last invitee node id) to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert β€” there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PendingFamilyInvitationDetails": { + "description": "A pending [`FamilyInvitation`] paired with whether it has already timed out at the time the query was served.", + "type": "object", + "required": [ + "expired", + "invitation" + ], + "properties": { + "expired": { + "description": "`true` iff `now >= invitation.expires_at` at query time, i.e. the invitation is still in the pending map but can no longer be acted on.", + "type": "boolean" + }, + "invitation": { + "description": "The stored invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "get_pending_invitations_for_node_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingInvitationsForNodePagedResponse", + "description": "Response to [`QueryMsg::GetPendingInvitationsForNodePaged`](crate::QueryMsg::GetPendingInvitationsForNodePaged).", + "type": "object", + "required": [ + "invitations", + "node_id" + ], + "properties": { + "invitations": { + "description": "The pending invitations addressed to this node on this page, in ascending [`NodeFamilyId`] order, each stamped with whether it had already timed out at the time the query was served.", + "type": "array", + "items": { + "$ref": "#/definitions/PendingFamilyInvitationDetails" + } + }, + "node_id": { + "description": "The node whose pending invitations were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_next_after": { + "description": "Cursor (last issuing family id) to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert β€” there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PendingFamilyInvitationDetails": { + "description": "A pending [`FamilyInvitation`] paired with whether it has already timed out at the time the query was served.", + "type": "object", + "required": [ + "expired", + "invitation" + ], + "properties": { + "expired": { + "description": "`true` iff `now >= invitation.expires_at` at query time, i.e. the invitation is still in the pending map but can no longer be acted on.", + "type": "boolean" + }, + "invitation": { + "description": "The stored invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + } + }, + "additionalProperties": false + } + } + } + } +} diff --git a/contracts/node-families/schema/raw/execute.json b/contracts/node-families/schema/raw/execute.json new file mode 100644 index 0000000000..7b06d071f0 --- /dev/null +++ b/contracts/node-families/schema/raw/execute.json @@ -0,0 +1,318 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "description": "Execute messages accepted by the contract.", + "oneOf": [ + { + "description": "Replace the contract's runtime [`Config`]. Restricted to the contract admin.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "$ref": "#/definitions/Config" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Create a new family owned by the message sender. The configured `create_family_fee` must be attached as funds.", + "type": "object", + "required": [ + "create_family" + ], + "properties": { + "create_family": { + "type": "object", + "required": [ + "description", + "name" + ], + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Disband the family owned by the message sender. The family must have no current members; any still-pending invitations are revoked.", + "type": "object", + "required": [ + "disband_family" + ], + "properties": { + "disband_family": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Invite a node to the family owned by the message sender. If `validity_secs` is omitted the invitation expires `default_invitation_validity_secs` seconds (from [`Config`]) after the current block time.", + "type": "object", + "required": [ + "invite_to_family" + ], + "properties": { + "invite_to_family": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "validity_secs": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Revoke a still-pending invitation previously issued by the sender's family.", + "type": "object", + "required": [ + "revoke_family_invitation" + ], + "properties": { + "revoke_family_invitation": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Accept a pending invitation. The sender must control `node_id`.", + "type": "object", + "required": [ + "accept_family_invitation" + ], + "properties": { + "accept_family_invitation": { + "type": "object", + "required": [ + "family_id", + "node_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Reject a pending invitation. The sender must control `node_id`.", + "type": "object", + "required": [ + "reject_family_invitation" + ], + "properties": { + "reject_family_invitation": { + "type": "object", + "required": [ + "family_id", + "node_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Leave the family `node_id` currently belongs to. The sender must control `node_id`.", + "type": "object", + "required": [ + "leave_family" + ], + "properties": { + "leave_family": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Remove `node_id` from the family owned by the message sender.", + "type": "object", + "required": [ + "kick_from_family" + ], + "properties": { + "kick_from_family": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Cross-contract callback fired by the mixnet contract the moment node with `node_id` initiates unbonding. Removes the node from any family it currently belongs to and rejects every pending invitation issued to it. Sender must be the configured mixnet contract address.", + "type": "object", + "required": [ + "on_nym_node_unbond" + ], + "properties": { + "on_nym_node_unbond": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Config": { + "description": "Runtime configuration of the node families contract.", + "type": "object", + "required": [ + "create_family_fee", + "default_invitation_validity_secs", + "family_description_length_limit", + "family_name_length_limit" + ], + "properties": { + "create_family_fee": { + "description": "Fee charged on each successful `create_family` execution.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "default_invitation_validity_secs": { + "description": "Default lifetime, in seconds, used by `invite_to_family` when the sender doesn't supply an explicit value. Senders may override this per-invitation via the optional `validity_secs` argument.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_description_length_limit": { + "description": "Maximum allowed length, in characters, of a family description.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "family_name_length_limit": { + "description": "Maximum allowed length, in characters, of a family name.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/node-families/schema/raw/instantiate.json b/contracts/node-families/schema/raw/instantiate.json new file mode 100644 index 0000000000..596ec07212 --- /dev/null +++ b/contracts/node-families/schema/raw/instantiate.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "description": "Message used to instantiate the node families contract.", + "type": "object", + "required": [ + "config", + "mixnet_contract_address" + ], + "properties": { + "config": { + "$ref": "#/definitions/Config" + }, + "mixnet_contract_address": { + "type": "string" + } + }, + "additionalProperties": false, + "definitions": { + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Config": { + "description": "Runtime configuration of the node families contract.", + "type": "object", + "required": [ + "create_family_fee", + "default_invitation_validity_secs", + "family_description_length_limit", + "family_name_length_limit" + ], + "properties": { + "create_family_fee": { + "description": "Fee charged on each successful `create_family` execution.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "default_invitation_validity_secs": { + "description": "Default lifetime, in seconds, used by `invite_to_family` when the sender doesn't supply an explicit value. Senders may override this per-invitation via the optional `validity_secs` argument.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_description_length_limit": { + "description": "Maximum allowed length, in characters, of a family description.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "family_name_length_limit": { + "description": "Maximum allowed length, in characters, of a family name.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/node-families/schema/raw/migrate.json b/contracts/node-families/schema/raw/migrate.json new file mode 100644 index 0000000000..a02787c6a5 --- /dev/null +++ b/contracts/node-families/schema/raw/migrate.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "description": "Message passed to the contract's `migrate` entry point.", + "type": "object", + "additionalProperties": false +} diff --git a/contracts/node-families/schema/raw/query.json b/contracts/node-families/schema/raw/query.json new file mode 100644 index 0000000000..f03b673f0d --- /dev/null +++ b/contracts/node-families/schema/raw/query.json @@ -0,0 +1,620 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "description": "Query messages accepted by the contract.", + "oneOf": [ + { + "description": "Look up a single family by its id.", + "type": "object", + "required": [ + "get_family_by_id" + ], + "properties": { + "get_family_by_id": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Look up the (at most one) family owned by a given address.", + "type": "object", + "required": [ + "get_family_by_owner" + ], + "properties": { + "get_family_by_owner": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Look up a single family by its name. The lookup is normalised contract-side (lowercased, non-alphanumerics stripped), so equivalent inputs resolve to the same family.", + "type": "object", + "required": [ + "get_family_by_name" + ], + "properties": { + "get_family_by_name": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_families_paged" + ], + "properties": { + "get_families_paged": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Look up which family β€” if any β€” a node currently belongs to.", + "type": "object", + "required": [ + "get_family_membership" + ], + "properties": { + "get_family_membership": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every node currently in a given family.", + "type": "object", + "required": [ + "get_family_members_paged" + ], + "properties": { + "get_family_members_paged": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every current family member across all families, in ascending [`NodeId`] order. Each entry carries the membership record (which in turn names the family the node belongs to).", + "type": "object", + "required": [ + "get_all_family_members_paged" + ], + "properties": { + "get_all_family_members_paged": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Look up the pending invitation for a specific `(family_id, node_id)` pair.", + "type": "object", + "required": [ + "get_pending_invitation" + ], + "properties": { + "get_pending_invitation": { + "type": "object", + "required": [ + "family_id", + "node_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every pending invitation issued by a given family.", + "type": "object", + "required": [ + "get_pending_invitations_for_family_paged" + ], + "properties": { + "get_pending_invitations_for_family_paged": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every pending invitation issued for a given node.", + "type": "object", + "required": [ + "get_pending_invitations_for_node_paged" + ], + "properties": { + "get_pending_invitations_for_node_paged": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every pending invitation across all families.", + "type": "object", + "required": [ + "get_all_pending_invitations_paged" + ], + "properties": { + "get_all_pending_invitations_paged": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived (terminal-state) invitation issued by a given family.", + "type": "object", + "required": [ + "get_past_invitations_for_family_paged" + ], + "properties": { + "get_past_invitations_for_family_paged": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived (terminal-state) invitation issued to a given node.", + "type": "object", + "required": [ + "get_past_invitations_for_node_paged" + ], + "properties": { + "get_past_invitations_for_node_paged": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived (terminal-state) invitation across all families.", + "type": "object", + "required": [ + "get_all_past_invitations_paged" + ], + "properties": { + "get_all_past_invitations_paged": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived membership record for a given family (nodes that used to belong to it but have since been removed).", + "type": "object", + "required": [ + "get_past_members_for_family_paged" + ], + "properties": { + "get_past_members_for_family_paged": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived membership record for a given node (every family the node used to belong to but has since been removed from), across all families.", + "type": "object", + "required": [ + "get_past_members_for_node_paged" + ], + "properties": { + "get_past_members_for_node_paged": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] +} diff --git a/contracts/node-families/schema/raw/response_to_get_all_family_members_paged.json b/contracts/node-families/schema/raw/response_to_get_all_family_members_paged.json new file mode 100644 index 0000000000..d6d4baaf5c --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_all_family_members_paged.json @@ -0,0 +1,78 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllFamilyMembersPagedResponse", + "description": "Response to [`QueryMsg::GetAllFamilyMembersPaged`](crate::QueryMsg::GetAllFamilyMembersPaged).", + "type": "object", + "required": [ + "members" + ], + "properties": { + "members": { + "description": "The members on this page, in ascending [`NodeId`] order across every family.", + "type": "array", + "items": { + "$ref": "#/definitions/FamilyMemberRecord" + } + }, + "start_next_after": { + "description": "Cursor (last `node_id`) to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyMemberRecord": { + "description": "One entry in a [`FamilyMembersPagedResponse`] page β€” pairs a node id with its [`FamilyMembership`] record (notably its `joined_at` timestamp).", + "type": "object", + "required": [ + "membership", + "node_id" + ], + "properties": { + "membership": { + "description": "The membership record (carries `family_id` and `joined_at`).", + "allOf": [ + { + "$ref": "#/definitions/FamilyMembership" + } + ] + }, + "node_id": { + "description": "The node currently in the family.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyMembership": { + "description": "On-chain record of a node's current family membership.\n\nA node belongs to at most one family at a time, so this is keyed by `NodeId` alone β€” `family_id` is carried in the value to support reverse lookups (all nodes in a given family) via a secondary index.", + "type": "object", + "required": [ + "family_id", + "joined_at" + ], + "properties": { + "family_id": { + "description": "The family the node is currently a member of.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "joined_at": { + "description": "Block timestamp (unix seconds) at which the node accepted its invitation and joined the family.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_all_past_invitations_paged.json b/contracts/node-families/schema/raw/response_to_get_all_past_invitations_paged.json new file mode 100644 index 0000000000..dcbb67974b --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_all_past_invitations_paged.json @@ -0,0 +1,212 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllPastFamilyInvitationsPagedResponse", + "description": "Response to [`QueryMsg::GetAllPastInvitationsPaged`](crate::QueryMsg::GetAllPastInvitationsPaged).", + "type": "object", + "required": [ + "invitations" + ], + "properties": { + "invitations": { + "description": "The archived invitations on this page, in ascending `((family_id, node_id), counter)` order across all terminal statuses.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyInvitation" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert β€” there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyInvitationStatus": { + "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here β€” they are simply left in the pending set (see `FamilyInvitation::expires_at`).", + "oneOf": [ + { + "description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.", + "type": "object", + "required": [ + "pending" + ], + "properties": { + "pending": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee accepted and joined the family at the given timestamp.", + "type": "object", + "required": [ + "accepted" + ], + "properties": { + "accepted": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee explicitly rejected the invitation at the given timestamp.", + "type": "object", + "required": [ + "rejected" + ], + "properties": { + "rejected": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The family revoked the invitation at the given timestamp before it could be accepted or rejected.", + "type": "object", + "required": [ + "revoked" + ], + "properties": { + "revoked": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "PastFamilyInvitation": { + "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here β€” they remain in the pending map until explicitly cleared.", + "type": "object", + "required": [ + "invitation", + "status" + ], + "properties": { + "invitation": { + "description": "The original invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + }, + "status": { + "description": "What ultimately happened to it.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitationStatus" + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_all_pending_invitations_paged.json b/contracts/node-families/schema/raw/response_to_get_all_pending_invitations_paged.json new file mode 100644 index 0000000000..90bffdabc9 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_all_pending_invitations_paged.json @@ -0,0 +1,95 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingInvitationsPagedResponse", + "description": "Response to [`QueryMsg::GetAllPendingInvitationsPaged`](crate::QueryMsg::GetAllPendingInvitationsPaged).", + "type": "object", + "required": [ + "invitations" + ], + "properties": { + "invitations": { + "description": "The pending invitations on this page, in ascending `(family_id, node_id)` order, each stamped with whether it had already timed out at the time the query was served.", + "type": "array", + "items": { + "$ref": "#/definitions/PendingFamilyInvitationDetails" + } + }, + "start_next_after": { + "description": "Cursor (last `(family_id, node_id)` pair) to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert β€” there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PendingFamilyInvitationDetails": { + "description": "A pending [`FamilyInvitation`] paired with whether it has already timed out at the time the query was served.", + "type": "object", + "required": [ + "expired", + "invitation" + ], + "properties": { + "expired": { + "description": "`true` iff `now >= invitation.expires_at` at query time, i.e. the invitation is still in the pending map but can no longer be acted on.", + "type": "boolean" + }, + "invitation": { + "description": "The stored invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_families_paged.json b/contracts/node-families/schema/raw/response_to_get_families_paged.json new file mode 100644 index 0000000000..459247a149 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_families_paged.json @@ -0,0 +1,117 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "FamiliesPagedResponse", + "description": "Response to [`QueryMsg::GetFamiliesPaged`](crate::QueryMsg::GetFamiliesPaged).", + "type": "object", + "required": [ + "families" + ], + "properties": { + "families": { + "description": "The families on this page, in ascending [`NodeFamilyId`] order.", + "type": "array", + "items": { + "$ref": "#/definitions/NodeFamily" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (which the caller should treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "NodeFamily": { + "description": "On-chain representation of a node family.", + "type": "object", + "required": [ + "created_at", + "description", + "id", + "members", + "name", + "normalised_name", + "owner", + "paid_fee" + ], + "properties": { + "created_at": { + "description": "Timestamp of the creation of the node family", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "description": "The optional description of the node family", + "type": "string" + }, + "id": { + "description": "The id of the node family", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "Memoized value of the current number of members in the node family Used to detect if the family is empty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "description": "The name of the node family", + "type": "string" + }, + "normalised_name": { + "description": "Normalised name of the node family used for uniqueness checks", + "type": "string" + }, + "owner": { + "description": "The owner of the node family", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "paid_fee": { + "description": "Records the fee paid when the family was created, so that the appropriate amount could be returned upon it getting disbanded.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_family_by_id.json b/contracts/node-families/schema/raw/response_to_get_family_by_id.json new file mode 100644 index 0000000000..072a160145 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_family_by_id.json @@ -0,0 +1,118 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NodeFamilyResponse", + "description": "Response to [`QueryMsg::GetFamilyById`](crate::QueryMsg::GetFamilyById).", + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family": { + "description": "The matching family, or `None` if no family with `family_id` exists.", + "anyOf": [ + { + "$ref": "#/definitions/NodeFamily" + }, + { + "type": "null" + } + ] + }, + "family_id": { + "description": "The id that was queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "NodeFamily": { + "description": "On-chain representation of a node family.", + "type": "object", + "required": [ + "created_at", + "description", + "id", + "members", + "name", + "normalised_name", + "owner", + "paid_fee" + ], + "properties": { + "created_at": { + "description": "Timestamp of the creation of the node family", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "description": "The optional description of the node family", + "type": "string" + }, + "id": { + "description": "The id of the node family", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "Memoized value of the current number of members in the node family Used to detect if the family is empty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "description": "The name of the node family", + "type": "string" + }, + "normalised_name": { + "description": "Normalised name of the node family used for uniqueness checks", + "type": "string" + }, + "owner": { + "description": "The owner of the node family", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "paid_fee": { + "description": "Records the fee paid when the family was created, so that the appropriate amount could be returned upon it getting disbanded.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_family_by_name.json b/contracts/node-families/schema/raw/response_to_get_family_by_name.json new file mode 100644 index 0000000000..dec7aea652 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_family_by_name.json @@ -0,0 +1,116 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NodeFamilyByNameResponse", + "description": "Response to [`QueryMsg::GetFamilyByName`](crate::QueryMsg::GetFamilyByName).", + "type": "object", + "required": [ + "name" + ], + "properties": { + "family": { + "description": "The matching family, or `None` if no family with that name exists.", + "anyOf": [ + { + "$ref": "#/definitions/NodeFamily" + }, + { + "type": "null" + } + ] + }, + "name": { + "description": "The name that was queried, echoed back so callers can correlate.", + "type": "string" + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "NodeFamily": { + "description": "On-chain representation of a node family.", + "type": "object", + "required": [ + "created_at", + "description", + "id", + "members", + "name", + "normalised_name", + "owner", + "paid_fee" + ], + "properties": { + "created_at": { + "description": "Timestamp of the creation of the node family", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "description": "The optional description of the node family", + "type": "string" + }, + "id": { + "description": "The id of the node family", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "Memoized value of the current number of members in the node family Used to detect if the family is empty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "description": "The name of the node family", + "type": "string" + }, + "normalised_name": { + "description": "Normalised name of the node family used for uniqueness checks", + "type": "string" + }, + "owner": { + "description": "The owner of the node family", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "paid_fee": { + "description": "Records the fee paid when the family was created, so that the appropriate amount could be returned upon it getting disbanded.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_family_by_owner.json b/contracts/node-families/schema/raw/response_to_get_family_by_owner.json new file mode 100644 index 0000000000..74b0412a9d --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_family_by_owner.json @@ -0,0 +1,120 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NodeFamilyByOwnerResponse", + "description": "Response to [`QueryMsg::GetFamilyByOwner`](crate::QueryMsg::GetFamilyByOwner).", + "type": "object", + "required": [ + "owner" + ], + "properties": { + "family": { + "description": "The matching family, or `None` if `owner` does not currently own one.", + "anyOf": [ + { + "$ref": "#/definitions/NodeFamily" + }, + { + "type": "null" + } + ] + }, + "owner": { + "description": "The (validated) owner address that was queried, echoed back so callers can correlate.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "NodeFamily": { + "description": "On-chain representation of a node family.", + "type": "object", + "required": [ + "created_at", + "description", + "id", + "members", + "name", + "normalised_name", + "owner", + "paid_fee" + ], + "properties": { + "created_at": { + "description": "Timestamp of the creation of the node family", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "description": "The optional description of the node family", + "type": "string" + }, + "id": { + "description": "The id of the node family", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "Memoized value of the current number of members in the node family Used to detect if the family is empty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "description": "The name of the node family", + "type": "string" + }, + "normalised_name": { + "description": "Normalised name of the node family used for uniqueness checks", + "type": "string" + }, + "owner": { + "description": "The owner of the node family", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "paid_fee": { + "description": "Records the fee paid when the family was created, so that the appropriate amount could be returned upon it getting disbanded.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_family_members_paged.json b/contracts/node-families/schema/raw/response_to_get_family_members_paged.json new file mode 100644 index 0000000000..7dbb1c64a4 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_family_members_paged.json @@ -0,0 +1,85 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "FamilyMembersPagedResponse", + "description": "Response to [`QueryMsg::GetFamilyMembersPaged`](crate::QueryMsg::GetFamilyMembersPaged).", + "type": "object", + "required": [ + "family_id", + "members" + ], + "properties": { + "family_id": { + "description": "The family whose members were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "The members on this page, in ascending [`NodeId`] order.", + "type": "array", + "items": { + "$ref": "#/definitions/FamilyMemberRecord" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (which the caller should treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyMemberRecord": { + "description": "One entry in a [`FamilyMembersPagedResponse`] page β€” pairs a node id with its [`FamilyMembership`] record (notably its `joined_at` timestamp).", + "type": "object", + "required": [ + "membership", + "node_id" + ], + "properties": { + "membership": { + "description": "The membership record (carries `family_id` and `joined_at`).", + "allOf": [ + { + "$ref": "#/definitions/FamilyMembership" + } + ] + }, + "node_id": { + "description": "The node currently in the family.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyMembership": { + "description": "On-chain record of a node's current family membership.\n\nA node belongs to at most one family at a time, so this is keyed by `NodeId` alone β€” `family_id` is carried in the value to support reverse lookups (all nodes in a given family) via a secondary index.", + "type": "object", + "required": [ + "family_id", + "joined_at" + ], + "properties": { + "family_id": { + "description": "The family the node is currently a member of.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "joined_at": { + "description": "Block timestamp (unix seconds) at which the node accepted its invitation and joined the family.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_family_membership.json b/contracts/node-families/schema/raw/response_to_get_family_membership.json new file mode 100644 index 0000000000..838d916547 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_family_membership.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NodeFamilyMembershipResponse", + "description": "Response to [`QueryMsg::GetFamilyMembership`](crate::QueryMsg::GetFamilyMembership).", + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "family_id": { + "description": "The id of the family the node currently belongs to, or `None` if the node is not currently a member of any family.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node that was queried.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false +} diff --git a/contracts/node-families/schema/raw/response_to_get_past_invitations_for_family_paged.json b/contracts/node-families/schema/raw/response_to_get_past_invitations_for_family_paged.json new file mode 100644 index 0000000000..15e2271e61 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_past_invitations_for_family_paged.json @@ -0,0 +1,207 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PastFamilyInvitationsPagedResponse", + "description": "Response to [`QueryMsg::GetPastInvitationsForFamilyPaged`](crate::QueryMsg::GetPastInvitationsForFamilyPaged).", + "type": "object", + "required": [ + "family_id", + "invitations" + ], + "properties": { + "family_id": { + "description": "The family whose archived invitations were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "invitations": { + "description": "The archived invitations on this page, in ascending `(node_id, counter)` order across all terminal statuses.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyInvitation" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert β€” there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyInvitationStatus": { + "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here β€” they are simply left in the pending set (see `FamilyInvitation::expires_at`).", + "oneOf": [ + { + "description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.", + "type": "object", + "required": [ + "pending" + ], + "properties": { + "pending": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee accepted and joined the family at the given timestamp.", + "type": "object", + "required": [ + "accepted" + ], + "properties": { + "accepted": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee explicitly rejected the invitation at the given timestamp.", + "type": "object", + "required": [ + "rejected" + ], + "properties": { + "rejected": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The family revoked the invitation at the given timestamp before it could be accepted or rejected.", + "type": "object", + "required": [ + "revoked" + ], + "properties": { + "revoked": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "PastFamilyInvitation": { + "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here β€” they remain in the pending map until explicitly cleared.", + "type": "object", + "required": [ + "invitation", + "status" + ], + "properties": { + "invitation": { + "description": "The original invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + }, + "status": { + "description": "What ultimately happened to it.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitationStatus" + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_past_invitations_for_node_paged.json b/contracts/node-families/schema/raw/response_to_get_past_invitations_for_node_paged.json new file mode 100644 index 0000000000..b438371287 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_past_invitations_for_node_paged.json @@ -0,0 +1,207 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PastFamilyInvitationsForNodePagedResponse", + "description": "Response to [`QueryMsg::GetPastInvitationsForNodePaged`](crate::QueryMsg::GetPastInvitationsForNodePaged).", + "type": "object", + "required": [ + "invitations", + "node_id" + ], + "properties": { + "invitations": { + "description": "The archived invitations addressed to this node on this page, in ascending `(family_id, counter)` order across all terminal statuses.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyInvitation" + } + }, + "node_id": { + "description": "The node whose past invitations were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert β€” there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyInvitationStatus": { + "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here β€” they are simply left in the pending set (see `FamilyInvitation::expires_at`).", + "oneOf": [ + { + "description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.", + "type": "object", + "required": [ + "pending" + ], + "properties": { + "pending": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee accepted and joined the family at the given timestamp.", + "type": "object", + "required": [ + "accepted" + ], + "properties": { + "accepted": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee explicitly rejected the invitation at the given timestamp.", + "type": "object", + "required": [ + "rejected" + ], + "properties": { + "rejected": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The family revoked the invitation at the given timestamp before it could be accepted or rejected.", + "type": "object", + "required": [ + "revoked" + ], + "properties": { + "revoked": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "PastFamilyInvitation": { + "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here β€” they remain in the pending map until explicitly cleared.", + "type": "object", + "required": [ + "invitation", + "status" + ], + "properties": { + "invitation": { + "description": "The original invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + }, + "status": { + "description": "What ultimately happened to it.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitationStatus" + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_past_members_for_family_paged.json b/contracts/node-families/schema/raw/response_to_get_past_members_for_family_paged.json new file mode 100644 index 0000000000..2ad75d8dda --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_past_members_for_family_paged.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PastFamilyMembersPagedResponse", + "description": "Response to [`QueryMsg::GetPastMembersForFamilyPaged`](crate::QueryMsg::GetPastMembersForFamilyPaged).", + "type": "object", + "required": [ + "family_id", + "members" + ], + "properties": { + "family_id": { + "description": "The family whose archived memberships were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "The archived membership records on this page, in ascending `(node_id, counter)` order.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyMember" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "PastFamilyMember": { + "description": "Historical record of a node that used to be part of a family but has since been removed (kicked, left voluntarily, or because the family was disbanded).", + "type": "object", + "required": [ + "family_id", + "node_id", + "removed_at" + ], + "properties": { + "family_id": { + "description": "The family the node used to belong to.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node that was removed.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "removed_at": { + "description": "Block timestamp (unix seconds) at which the membership was terminated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_past_members_for_node_paged.json b/contracts/node-families/schema/raw/response_to_get_past_members_for_node_paged.json new file mode 100644 index 0000000000..379b9df988 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_past_members_for_node_paged.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PastFamilyMembersForNodePagedResponse", + "description": "Response to [`QueryMsg::GetPastMembersForNodePaged`](crate::QueryMsg::GetPastMembersForNodePaged).", + "type": "object", + "required": [ + "members", + "node_id" + ], + "properties": { + "members": { + "description": "The archived membership records for this node on this page, in ascending `(family_id, counter)` order.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyMember" + } + }, + "node_id": { + "description": "The node whose archived memberships were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "PastFamilyMember": { + "description": "Historical record of a node that used to be part of a family but has since been removed (kicked, left voluntarily, or because the family was disbanded).", + "type": "object", + "required": [ + "family_id", + "node_id", + "removed_at" + ], + "properties": { + "family_id": { + "description": "The family the node used to belong to.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node that was removed.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "removed_at": { + "description": "Block timestamp (unix seconds) at which the membership was terminated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_pending_invitation.json b/contracts/node-families/schema/raw/response_to_get_pending_invitation.json new file mode 100644 index 0000000000..c9ab63d67a --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_pending_invitation.json @@ -0,0 +1,91 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingFamilyInvitationResponse", + "description": "Response to [`QueryMsg::GetPendingInvitation`](crate::QueryMsg::GetPendingInvitation).", + "type": "object", + "required": [ + "family_id", + "node_id" + ], + "properties": { + "family_id": { + "description": "The family component of the queried `(family_id, node_id)` key.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "invitation": { + "description": "The matching pending invitation along with an explicit expiry flag, or `None` if no such invitation exists.", + "anyOf": [ + { + "$ref": "#/definitions/PendingFamilyInvitationDetails" + }, + { + "type": "null" + } + ] + }, + "node_id": { + "description": "The node component of the queried `(family_id, node_id)` key.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert β€” there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PendingFamilyInvitationDetails": { + "description": "A pending [`FamilyInvitation`] paired with whether it has already timed out at the time the query was served.", + "type": "object", + "required": [ + "expired", + "invitation" + ], + "properties": { + "expired": { + "description": "`true` iff `now >= invitation.expires_at` at query time, i.e. the invitation is still in the pending map but can no longer be acted on.", + "type": "boolean" + }, + "invitation": { + "description": "The stored invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_family_paged.json b/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_family_paged.json new file mode 100644 index 0000000000..9dc1b084d0 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_family_paged.json @@ -0,0 +1,90 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingFamilyInvitationsPagedResponse", + "description": "Response to [`QueryMsg::GetPendingInvitationsForFamilyPaged`](crate::QueryMsg::GetPendingInvitationsForFamilyPaged).", + "type": "object", + "required": [ + "family_id", + "invitations" + ], + "properties": { + "family_id": { + "description": "The family whose pending invitations were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "invitations": { + "description": "The pending invitations on this page, in ascending invitee [`NodeId`] order, each stamped with whether it had already timed out at the time the query was served.", + "type": "array", + "items": { + "$ref": "#/definitions/PendingFamilyInvitationDetails" + } + }, + "start_next_after": { + "description": "Cursor (last invitee node id) to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert β€” there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PendingFamilyInvitationDetails": { + "description": "A pending [`FamilyInvitation`] paired with whether it has already timed out at the time the query was served.", + "type": "object", + "required": [ + "expired", + "invitation" + ], + "properties": { + "expired": { + "description": "`true` iff `now >= invitation.expires_at` at query time, i.e. the invitation is still in the pending map but can no longer be acted on.", + "type": "boolean" + }, + "invitation": { + "description": "The stored invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_node_paged.json b/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_node_paged.json new file mode 100644 index 0000000000..6d59be893b --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_node_paged.json @@ -0,0 +1,90 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingInvitationsForNodePagedResponse", + "description": "Response to [`QueryMsg::GetPendingInvitationsForNodePaged`](crate::QueryMsg::GetPendingInvitationsForNodePaged).", + "type": "object", + "required": [ + "invitations", + "node_id" + ], + "properties": { + "invitations": { + "description": "The pending invitations addressed to this node on this page, in ascending [`NodeFamilyId`] order, each stamped with whether it had already timed out at the time the query was served.", + "type": "array", + "items": { + "$ref": "#/definitions/PendingFamilyInvitationDetails" + } + }, + "node_id": { + "description": "The node whose pending invitations were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_next_after": { + "description": "Cursor (last issuing family id) to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert β€” there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PendingFamilyInvitationDetails": { + "description": "A pending [`FamilyInvitation`] paired with whether it has already timed out at the time the query was served.", + "type": "object", + "required": [ + "expired", + "invitation" + ], + "properties": { + "expired": { + "description": "`true` iff `now >= invitation.expires_at` at query time, i.e. the invitation is still in the pending map but can no longer be acted on.", + "type": "boolean" + }, + "invitation": { + "description": "The stored invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/src/bin/schema.rs b/contracts/node-families/src/bin/schema.rs new file mode 100644 index 0000000000..9be22a0984 --- /dev/null +++ b/contracts/node-families/src/bin/schema.rs @@ -0,0 +1,14 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_schema::write_api; +use nym_node_families_contract_common::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/node-families/src/contract.rs b/contracts/node-families/src/contract.rs new file mode 100644 index 0000000000..64d37f6df8 --- /dev/null +++ b/contracts/node-families/src/contract.rs @@ -0,0 +1,355 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +//! CosmWasm entry points for the node families contract. + +use crate::queries::{ + query_all_family_members_paged, query_all_past_invitations_paged, + query_all_pending_invitations_paged, query_families_paged, query_family_by_id, + query_family_by_name, query_family_by_owner, query_family_members_paged, + query_family_membership, query_past_invitations_for_family_paged, + query_past_invitations_for_node_paged, query_past_members_for_family_paged, + query_past_members_for_node_paged, query_pending_invitation, + query_pending_invitations_for_family_paged, query_pending_invitations_for_node_paged, +}; +use crate::storage::NodeFamiliesStorage; +use crate::transactions::{ + try_accept_family_invitation, try_create_family, try_disband_family, try_handle_node_unbonding, + try_invite_to_family, try_kick_from_family, try_leave_family, try_reject_family_invitation, + try_revoke_family_invitation, try_update_config, +}; +use cosmwasm_std::{ + entry_point, to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, +}; +use nym_contracts_common::set_build_information; +use nym_node_families_contract_common::{ + ExecuteMsg, InstantiateMsg, MigrateMsg, NodeFamiliesContractError, QueryMsg, +}; + +const CONTRACT_NAME: &str = "crate:nym-node-families-contract"; + +/// Contract semver, taken from `Cargo.toml` at build time. Bumped on every +/// release; recorded in cw2 storage so migrations can detect the source version. +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// One-time initialisation of contract storage on code instantiation. +#[entry_point] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + set_build_information!(deps.storage)?; + + let mixnet_contract_address = deps.api.addr_validate(&msg.mixnet_contract_address)?; + + NodeFamiliesStorage::new().initialise( + deps, + info.sender, + mixnet_contract_address, + msg.config, + )?; + + Ok(Response::default()) +} + +/// State-mutating dispatcher. Concrete handlers live in [`crate::transactions`] +/// and are wired up here as variants are added to [`ExecuteMsg`]. +#[entry_point] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::UpdateConfig { config } => try_update_config(deps, env, info, config), + ExecuteMsg::CreateFamily { name, description } => { + try_create_family(deps, env, info, name, description) + } + ExecuteMsg::DisbandFamily {} => try_disband_family(deps, env, info), + ExecuteMsg::InviteToFamily { + node_id, + validity_secs, + } => try_invite_to_family(deps, env, info, node_id, validity_secs), + ExecuteMsg::RevokeFamilyInvitation { node_id } => { + try_revoke_family_invitation(deps, env, info, node_id) + } + ExecuteMsg::AcceptFamilyInvitation { family_id, node_id } => { + try_accept_family_invitation(deps, env, info, family_id, node_id) + } + ExecuteMsg::RejectFamilyInvitation { family_id, node_id } => { + try_reject_family_invitation(deps, env, info, family_id, node_id) + } + ExecuteMsg::LeaveFamily { node_id } => try_leave_family(deps, env, info, node_id), + ExecuteMsg::KickFromFamily { node_id } => try_kick_from_family(deps, env, info, node_id), + ExecuteMsg::OnNymNodeUnbond { node_id } => { + try_handle_node_unbonding(deps, env, info, node_id) + } + } +} + +/// Read-only dispatcher. Concrete handlers live in [`crate::queries`] and are +/// wired up here as variants are added to [`QueryMsg`]. +#[entry_point] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> Result { + match msg { + QueryMsg::GetFamilyById { family_id } => { + Ok(to_json_binary(&query_family_by_id(deps, family_id)?)?) + } + QueryMsg::GetFamilyByOwner { owner } => { + Ok(to_json_binary(&query_family_by_owner(deps, owner)?)?) + } + QueryMsg::GetFamilyByName { name } => { + Ok(to_json_binary(&query_family_by_name(deps, name)?)?) + } + QueryMsg::GetFamilyMembership { node_id } => { + Ok(to_json_binary(&query_family_membership(deps, node_id)?)?) + } + QueryMsg::GetFamilyMembersPaged { + family_id, + start_after, + limit, + } => Ok(to_json_binary(&query_family_members_paged( + deps, + family_id, + start_after, + limit, + )?)?), + QueryMsg::GetAllFamilyMembersPaged { start_after, limit } => Ok(to_json_binary( + &query_all_family_members_paged(deps, start_after, limit)?, + )?), + QueryMsg::GetPendingInvitation { family_id, node_id } => Ok(to_json_binary( + &query_pending_invitation(deps, env, family_id, node_id)?, + )?), + QueryMsg::GetPendingInvitationsForFamilyPaged { + family_id, + start_after, + limit, + } => Ok(to_json_binary( + &query_pending_invitations_for_family_paged(deps, env, family_id, start_after, limit)?, + )?), + QueryMsg::GetPendingInvitationsForNodePaged { + node_id, + start_after, + limit, + } => Ok(to_json_binary(&query_pending_invitations_for_node_paged( + deps, + env, + node_id, + start_after, + limit, + )?)?), + QueryMsg::GetAllPendingInvitationsPaged { start_after, limit } => Ok(to_json_binary( + &query_all_pending_invitations_paged(deps, env, start_after, limit)?, + )?), + QueryMsg::GetPastInvitationsForFamilyPaged { + family_id, + start_after, + limit, + } => Ok(to_json_binary(&query_past_invitations_for_family_paged( + deps, + family_id, + start_after, + limit, + )?)?), + QueryMsg::GetPastInvitationsForNodePaged { + node_id, + start_after, + limit, + } => Ok(to_json_binary(&query_past_invitations_for_node_paged( + deps, + node_id, + start_after, + limit, + )?)?), + QueryMsg::GetAllPastInvitationsPaged { start_after, limit } => Ok(to_json_binary( + &query_all_past_invitations_paged(deps, start_after, limit)?, + )?), + QueryMsg::GetPastMembersForFamilyPaged { + family_id, + start_after, + limit, + } => Ok(to_json_binary(&query_past_members_for_family_paged( + deps, + family_id, + start_after, + limit, + )?)?), + QueryMsg::GetPastMembersForNodePaged { + node_id, + start_after, + limit, + } => Ok(to_json_binary(&query_past_members_for_node_paged( + deps, + node_id, + start_after, + limit, + )?)?), + QueryMsg::GetFamiliesPaged { start_after, limit } => Ok(to_json_binary( + &query_families_paged(deps, start_after, limit)?, + )?), + } +} + +/// Migration entry point. +/// +/// Refreshes recorded build information and ensures the existing on-chain +/// contract version is at most the current `CONTRACT_VERSION` (i.e. forbids +/// downgrades). Any data migrations are dispatched via +/// [`crate::queued_migrations`]. +#[entry_point] +pub fn migrate( + deps: DepsMut, + _env: Env, + _msg: MigrateMsg, +) -> Result { + set_build_information!(deps.storage)?; + cw2::ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + Ok(Default::default()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(test)] + mod contract_instantiation { + use super::*; + use cosmwasm_std::coin; + use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env}; + use nym_node_families_contract_common::Config; + + fn mock_config() -> Config { + Config { + create_family_fee: coin(123, "unym"), + family_name_length_limit: 20, + family_description_length_limit: 100, + default_invitation_validity_secs: 24 * 60 * 60, + } + } + + #[test] + fn sets_contract_admin_to_the_message_sender() -> anyhow::Result<()> { + let mut deps = mock_dependencies(); + let env = mock_env(); + let mixnet_contract_address = deps.api.addr_make("mixnet-contract"); + let some_sender = deps.api.addr_make("some_sender"); + + instantiate( + deps.as_mut(), + env, + message_info(&some_sender, &[]), + InstantiateMsg { + config: mock_config(), + mixnet_contract_address: mixnet_contract_address.to_string(), + }, + )?; + + let deps = deps.as_ref(); + + NodeFamiliesStorage::new() + .contract_admin + .assert_admin(deps, &some_sender)?; + + Ok(()) + } + + #[test] + fn persists_the_provided_config() -> anyhow::Result<()> { + let mut deps = mock_dependencies(); + let env = mock_env(); + let mixnet_contract_address = deps.api.addr_make("mixnet-contract"); + let sender = deps.api.addr_make("some_sender"); + let config = mock_config(); + + instantiate( + deps.as_mut(), + env, + message_info(&sender, &[]), + InstantiateMsg { + config: config.clone(), + mixnet_contract_address: mixnet_contract_address.to_string(), + }, + )?; + + let stored = NodeFamiliesStorage::new() + .config + .load(deps.as_ref().storage)?; + assert_eq!(stored, config); + + Ok(()) + } + + #[test] + fn persists_the_validated_mixnet_contract_address() -> anyhow::Result<()> { + let mut deps = mock_dependencies(); + let env = mock_env(); + let mixnet_contract_address = deps.api.addr_make("mixnet-contract"); + let sender = deps.api.addr_make("some_sender"); + + instantiate( + deps.as_mut(), + env, + message_info(&sender, &[]), + InstantiateMsg { + config: mock_config(), + mixnet_contract_address: mixnet_contract_address.to_string(), + }, + )?; + + let stored = NodeFamiliesStorage::new() + .mixnet_contract_address + .load(deps.as_ref().storage)?; + assert_eq!(stored, mixnet_contract_address); + + Ok(()) + } + + #[test] + fn errors_on_invalid_mixnet_contract_address() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let sender = deps.api.addr_make("some_sender"); + + let res = instantiate( + deps.as_mut(), + env, + message_info(&sender, &[]), + InstantiateMsg { + config: mock_config(), + mixnet_contract_address: "not-a-valid-bech32-address".to_string(), + }, + ); + + assert!(res.is_err()); + } + + #[test] + fn records_the_cw2_contract_version() -> anyhow::Result<()> { + let mut deps = mock_dependencies(); + let env = mock_env(); + let mixnet_contract_address = deps.api.addr_make("mixnet-contract"); + let sender = deps.api.addr_make("some_sender"); + + instantiate( + deps.as_mut(), + env, + message_info(&sender, &[]), + InstantiateMsg { + config: mock_config(), + mixnet_contract_address: mixnet_contract_address.to_string(), + }, + )?; + + let version = cw2::get_contract_version(deps.as_ref().storage)?; + assert_eq!(version.contract, CONTRACT_NAME); + assert_eq!(version.version, CONTRACT_VERSION); + + Ok(()) + } + } +} diff --git a/contracts/node-families/src/helpers.rs b/contracts/node-families/src/helpers.rs new file mode 100644 index 0000000000..03ba312779 --- /dev/null +++ b/contracts/node-families/src/helpers.rs @@ -0,0 +1,178 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::storage::NodeFamiliesStorage; +use cosmwasm_std::{Addr, Deps}; +use nym_mixnet_contract_common::{MixnetContractQuerier, NodeId}; +use nym_node_families_contract_common::NodeFamiliesContractError; + +/// Normalise a family name into the canonical form used as the unique-index key. +/// +/// Drops every character that isn't an ASCII letter or digit and lowercases +/// the rest, so `" Foo-Bar! "`, `"foobar"` and `"FOO BAR"` all collide on +/// the storage layer's unique-name index. +pub fn normalise_family_name(name: &str) -> String { + name.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_lowercase()) + .collect() +} + +/// Ensure no node controlled by `address` is currently a member of any family. +pub(crate) fn ensure_address_holds_no_family_membership( + storage: &NodeFamiliesStorage, + deps: Deps, + address: &Addr, +) -> Result<(), NodeFamiliesContractError> { + let mixnet_contract = storage.mixnet_contract_address.load(deps.storage)?; + let Some(nym_node) = deps + .querier + .query_nymnode_ownership(&mixnet_contract, address)? + else { + // if the owner has no nym-node, it can't possibly be in a family + return Ok(()); + }; + + // check if that node is in a family + if let Some(family) = storage + .family_members + .may_load(deps.storage, nym_node.node_id)? + { + return Err(NodeFamiliesContractError::AlreadyInFamily { + address: address.clone(), + node_id: nym_node.node_id, + family_id: family.family_id, + }); + } + + Ok(()) +} + +/// Cross-contract query: ensure `node_id` is a currently-bonded node in the +/// mixnet contract. Returns [`NodeDoesntExist`] otherwise. +/// +/// [`NodeDoesntExist`]: NodeFamiliesContractError::NodeDoesntExist +pub(crate) fn ensure_node_is_bonded( + storage: &NodeFamiliesStorage, + deps: Deps, + node_id: NodeId, +) -> Result<(), NodeFamiliesContractError> { + let mixnet_contract = storage.mixnet_contract_address.load(deps.storage)?; + if !deps + .querier + .check_node_existence(&mixnet_contract, node_id)? + { + return Err(NodeFamiliesContractError::NodeDoesntExist { node_id }); + } + Ok(()) +} + +/// Ensure `address` is the controller of the bonded node `node_id` per the +/// mixnet contract. Errors with [`SenderDoesntControlNode`] when `address` +/// owns no bonded node, owns a node with a different id, or owns it but it +/// has entered the unbonding state. +/// +/// [`SenderDoesntControlNode`]: NodeFamiliesContractError::SenderDoesntControlNode +pub(crate) fn ensure_has_bonded_node( + storage: &NodeFamiliesStorage, + deps: Deps, + address: &Addr, + node_id: NodeId, +) -> Result<(), NodeFamiliesContractError> { + let mixnet_contract = storage.mixnet_contract_address.load(deps.storage)?; + match deps + .querier + .query_nymnode_ownership(&mixnet_contract, address)? + { + Some(bond) if bond.node_id == node_id && !bond.is_unbonding => Ok(()), + _ => Err(NodeFamiliesContractError::SenderDoesntControlNode { + address: address.clone(), + node_id, + }), + } +} + +/// Ensure `node_id` is not currently a member of any family. Returns +/// [`NodeAlreadyInFamily`] if it is. +/// +/// [`NodeAlreadyInFamily`]: NodeFamiliesContractError::NodeAlreadyInFamily +pub(crate) fn ensure_node_not_in_family( + storage: &NodeFamiliesStorage, + deps: Deps, + node_id: NodeId, +) -> Result<(), NodeFamiliesContractError> { + if let Some(membership) = storage.family_members.may_load(deps.storage, node_id)? { + return Err(NodeFamiliesContractError::NodeAlreadyInFamily { + node_id, + family_id: membership.family_id, + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod normalise_family_name { + use super::*; + + #[test] + fn empty_input_yields_empty() { + assert_eq!(normalise_family_name(""), ""); + } + + #[test] + fn already_canonical_is_unchanged() { + assert_eq!(normalise_family_name("foobar42"), "foobar42"); + } + + #[test] + fn lowercases_uppercase_letters() { + assert_eq!(normalise_family_name("FOOBAR"), "foobar"); + assert_eq!(normalise_family_name("FooBar"), "foobar"); + } + + #[test] + fn strips_whitespace() { + assert_eq!(normalise_family_name(" foo bar "), "foobar"); + assert_eq!(normalise_family_name("foo\tbar\nbaz"), "foobarbaz"); + } + + #[test] + fn strips_punctuation_and_symbols() { + assert_eq!(normalise_family_name("foo-bar!"), "foobar"); + assert_eq!(normalise_family_name("a.b_c@d"), "abcd"); + } + + #[test] + fn preserves_digits() { + assert_eq!(normalise_family_name("squad-2026"), "squad2026"); + assert_eq!(normalise_family_name("0123456789"), "0123456789"); + } + + #[test] + fn drops_non_ascii_letters() { + // is_ascii_alphanumeric is strict β€” accented and non-Latin chars are dropped. + assert_eq!(normalise_family_name("cafΓ©"), "caf"); + assert_eq!(normalise_family_name("Ξ©-team"), "team"); + assert_eq!(normalise_family_name("名前"), ""); + } + + #[test] + fn all_symbols_input_normalises_to_empty() { + // try_create_family relies on this to surface EmptyFamilyName. + assert_eq!(normalise_family_name(" "), ""); + assert_eq!(normalise_family_name("!!!---"), ""); + } + + #[test] + fn distinct_inputs_collide_under_normalisation() { + // The collision behaviour the unique-name index depends on. + let canonical = normalise_family_name("Foo Bar"); + assert_eq!(canonical, normalise_family_name("foobar")); + assert_eq!(canonical, normalise_family_name("FOO-BAR")); + assert_eq!(canonical, normalise_family_name(" f.o.o.b.a.r ")); + } + } +} diff --git a/contracts/node-families/src/lib.rs b/contracts/node-families/src/lib.rs new file mode 100644 index 0000000000..3325c5b687 --- /dev/null +++ b/contracts/node-families/src/lib.rs @@ -0,0 +1,28 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! CosmWasm contract that manages "node families" β€” owner-led groupings of +//! Nym nodes β€” including their members, pending invitations, and historical +//! records of past members and rejected/revoked invitations. +//! +//! The shared message and type surface lives in +//! [`node_families_contract_common`]; this crate contains only the on-chain logic +//! and storage layout. + +/// CosmWasm entry points (`instantiate`, `execute`, `query`, `migrate`). +pub mod contract; +/// One-shot data migrations executed by the `migrate` entry point. +pub mod queued_migrations; +/// `cw-storage-plus` definitions: typed maps, items and secondary indexes. +pub mod storage; + +mod helpers; +/// Read-only query handlers backing [`contract::query`]. +mod queries; +/// Test-only helpers β€” always compiled for this crate's own unit tests via +/// `cfg(test)`; downstream crates can pull them in for their own test +/// harnesses by enabling the `testable-node-families-contract` feature. +#[cfg(any(test, feature = "testable-node-families-contract"))] +pub mod testing; +/// State-mutating execute handlers backing [`contract::execute`]. +mod transactions; diff --git a/contracts/node-families/src/queries.rs b/contracts/node-families/src/queries.rs new file mode 100644 index 0000000000..9b75201fb5 --- /dev/null +++ b/contracts/node-families/src/queries.rs @@ -0,0 +1,2501 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::helpers::normalise_family_name; +use crate::storage::{retrieval_limits, NodeFamiliesStorage}; +use cosmwasm_std::{Deps, Env, Order, StdResult}; +use cw_storage_plus::Bound; +use nym_mixnet_contract_common::NodeId; +use nym_node_families_contract_common::{ + AllFamilyMembersPagedResponse, AllPastFamilyInvitationsPagedResponse, FamiliesPagedResponse, + FamilyMemberRecord, FamilyMembersPagedResponse, GlobalPastFamilyInvitationCursor, + NodeFamiliesContractError, NodeFamilyByNameResponse, NodeFamilyByOwnerResponse, NodeFamilyId, + NodeFamilyMembershipResponse, NodeFamilyResponse, PastFamilyInvitationCursor, + PastFamilyInvitationForNodeCursor, PastFamilyInvitationsForNodePagedResponse, + PastFamilyInvitationsPagedResponse, PastFamilyMemberCursor, PastFamilyMemberForNodeCursor, + PastFamilyMembersForNodePagedResponse, PastFamilyMembersPagedResponse, + PendingFamilyInvitationDetails, PendingFamilyInvitationResponse, + PendingFamilyInvitationsPagedResponse, PendingInvitationsForNodePagedResponse, + PendingInvitationsPagedResponse, +}; + +/// Resolve a single family by its id. Returns `family: None` if no family +/// with that id exists. +pub fn query_family_by_id( + deps: Deps, + family_id: NodeFamilyId, +) -> Result { + let family = NodeFamiliesStorage::new() + .families + .may_load(deps.storage, family_id)?; + Ok(NodeFamilyResponse { family_id, family }) +} + +/// Resolve the (at most one) family owned by `owner`. Returns `family: None` +/// if `owner` does not currently own a family. Backed by the `owner` +/// `UniqueIndex`, so cost is O(1). +pub fn query_family_by_owner( + deps: Deps, + owner: String, +) -> Result { + let owner = deps.api.addr_validate(&owner)?; + let family = NodeFamiliesStorage::new() + .families + .idx + .owner + .item(deps.storage, owner.clone())? + .map(|(_, family)| family); + Ok(NodeFamilyByOwnerResponse { owner, family }) +} + +/// Resolve a single family by its name. The lookup runs the input through +/// [`normalise_family_name`] before hitting the `name` `UniqueIndex`, so +/// e.g. `"foo"`, `"FoO"` and `" foo! "` all resolve to the same family. +/// Returns `family: None` if no family with that (normalised) name exists. +/// Backed by the `name` `UniqueIndex`, so cost is O(1). +pub fn query_family_by_name( + deps: Deps, + name: String, +) -> Result { + let normalised_name = normalise_family_name(&name); + let family = NodeFamiliesStorage::new() + .families + .idx + .normalised_name + .item(deps.storage, normalised_name)? + .map(|(_, family)| family); + Ok(NodeFamilyByNameResponse { name, family }) +} + +/// Report which family β€” if any β€” a node currently belongs to. +pub fn query_family_membership( + deps: Deps, + node_id: NodeId, +) -> Result { + let family_id = NodeFamiliesStorage::new() + .family_members + .may_load(deps.storage, node_id)? + .map(|m| m.family_id); + Ok(NodeFamilyMembershipResponse { node_id, family_id }) +} + +/// Resolve a pending invitation by its composite `(family_id, node_id)` key, +/// stamping it with whether it has already timed out at the current block +/// time so the caller doesn't have to do the comparison itself. +pub fn query_pending_invitation( + deps: Deps, + env: Env, + family_id: NodeFamilyId, + node_id: NodeId, +) -> Result { + let now = env.block.time.seconds(); + let invitation = NodeFamiliesStorage::new() + .pending_family_invitations + .may_load(deps.storage, (family_id, node_id))? + .map(|invitation| PendingFamilyInvitationDetails { + expired: now >= invitation.expires_at, + invitation, + }); + Ok(PendingFamilyInvitationResponse { + family_id, + node_id, + invitation, + }) +} + +/// Page through every node currently in `family_id`, in ascending +/// [`NodeId`] order. +/// +/// Backed by the `family` multi-index over [`crate::storage::NodeFamiliesStorage::family_members`], +/// so the cost is O(page size) regardless of how many other families exist. +/// Does not verify that `family_id` refers to an existing family β€” an +/// unknown id simply yields an empty page. +/// +/// `start_after` is exclusive β€” pass the previous page's `start_next_after` +/// to fetch the next page; pass `None` to start from the lowest-id member. +/// `limit` defaults to [`retrieval_limits::FAMILY_MEMBERS_DEFAULT_LIMIT`] +/// and is clamped to [`retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT`]. +pub fn query_family_members_paged( + deps: Deps, + family_id: NodeFamilyId, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::FAMILY_MEMBERS_DEFAULT_LIMIT) + .min(retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT) as usize; + + let start = start_after.map(Bound::exclusive); + + let storage = NodeFamiliesStorage::new(); + let members = storage + .family_members + .idx + .family + .prefix(family_id) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|res| { + res.map(|(node_id, membership)| FamilyMemberRecord { + node_id, + membership, + }) + }) + .collect::>>()?; + + let start_next_after = members.last().map(|record| record.node_id); + + Ok(FamilyMembersPagedResponse { + family_id, + members, + start_next_after, + }) +} + +/// Page through every current family member across all families, in ascending +/// [`NodeId`] order. Each entry carries the [`FamilyMembership`](node_families_contract_common::FamilyMembership) +/// record, which names the family the node belongs to. +/// +/// Cost is O(page size) β€” full range scan over the primary `family_members` +/// map without any prefix filter. Since each node belongs to at most one +/// family, [`NodeId`] alone is sufficient as a pagination cursor. +/// +/// `start_after` is exclusive β€” pass the previous page's `start_next_after` +/// to fetch the next page; pass `None` to start from the lowest-id member. +/// `limit` defaults to [`retrieval_limits::FAMILY_MEMBERS_DEFAULT_LIMIT`] +/// and is clamped to [`retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT`]. +pub fn query_all_family_members_paged( + deps: Deps, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::FAMILY_MEMBERS_DEFAULT_LIMIT) + .min(retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT) as usize; + + let start = start_after.map(Bound::exclusive); + + let storage = NodeFamiliesStorage::new(); + let members = storage + .family_members + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|res| { + res.map(|(node_id, membership)| FamilyMemberRecord { + node_id, + membership, + }) + }) + .collect::>>()?; + + let start_next_after = members.last().map(|record| record.node_id); + + Ok(AllFamilyMembersPagedResponse { + members, + start_next_after, + }) +} + +/// Page through every pending invitation issued by `family_id`, in ascending +/// invitee [`NodeId`] order. Each entry is stamped with `expired` based on +/// the current block time, so callers don't have to compare it themselves. +/// +/// Backed by a prefix scan on the composite primary key +/// `(family_id, node_id)` of `pending_family_invitations`, so cost is +/// O(page size). Does not verify that `family_id` refers to an existing +/// family β€” an unknown id simply yields an empty page. +/// +/// `start_after` is exclusive β€” pass the previous page's `start_next_after` +/// to fetch the next page; pass `None` to start from the lowest-id invitee. +/// `limit` defaults to [`retrieval_limits::PENDING_INVITATIONS_DEFAULT_LIMIT`] +/// and is clamped to [`retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT`]. +pub fn query_pending_invitations_for_family_paged( + deps: Deps, + env: Env, + family_id: NodeFamilyId, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::PENDING_INVITATIONS_DEFAULT_LIMIT) + .min(retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT) as usize; + + let now = env.block.time.seconds(); + let start = start_after.map(Bound::exclusive); + + let storage = NodeFamiliesStorage::new(); + let invitations = storage + .pending_family_invitations + .prefix(family_id) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|res| { + res.map(|(_node_id, invitation)| PendingFamilyInvitationDetails { + expired: now >= invitation.expires_at, + invitation, + }) + }) + .collect::>>()?; + + let start_next_after = invitations.last().map(|d| d.invitation.node_id); + + Ok(PendingFamilyInvitationsPagedResponse { + family_id, + invitations, + start_next_after, + }) +} + +/// Page through every pending invitation addressed to `node_id`, in ascending +/// issuing [`NodeFamilyId`] order. Each entry is stamped with `expired` based +/// on the current block time. +/// +/// Backed by the `node` multi-index over `pending_family_invitations`, so +/// cost is O(page size). `start_after` is exclusive β€” pass the previous +/// page's `start_next_after` to fetch the next page. `limit` defaults to +/// [`retrieval_limits::PENDING_INVITATIONS_DEFAULT_LIMIT`] and is clamped to +/// [`retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT`]. +pub fn query_pending_invitations_for_node_paged( + deps: Deps, + env: Env, + node_id: NodeId, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::PENDING_INVITATIONS_DEFAULT_LIMIT) + .min(retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT) as usize; + + let now = env.block.time.seconds(); + let start = start_after.map(|family_id| Bound::exclusive((family_id, node_id))); + + let storage = NodeFamiliesStorage::new(); + let invitations = storage + .pending_family_invitations + .idx + .node + .prefix(node_id) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|res| { + res.map(|(_pk, invitation)| PendingFamilyInvitationDetails { + expired: now >= invitation.expires_at, + invitation, + }) + }) + .collect::>>()?; + + let start_next_after = invitations.last().map(|d| d.invitation.family_id); + + Ok(PendingInvitationsForNodePagedResponse { + node_id, + invitations, + start_next_after, + }) +} + +/// Page through every pending invitation across all families, in ascending +/// `(family_id, node_id)` order. Each entry is stamped with `expired` based +/// on the current block time. +/// +/// Cost is O(page size) β€” full range scan over the +/// `pending_family_invitations` map without any prefix filter. +/// +/// `start_after` is exclusive β€” pass the previous page's `start_next_after` +/// to fetch the next page; pass `None` to start from the first entry. +/// `limit` defaults to [`retrieval_limits::PENDING_INVITATIONS_DEFAULT_LIMIT`] +/// and is clamped to [`retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT`]. +pub fn query_all_pending_invitations_paged( + deps: Deps, + env: Env, + start_after: Option<(NodeFamilyId, NodeId)>, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::PENDING_INVITATIONS_DEFAULT_LIMIT) + .min(retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT) as usize; + + let now = env.block.time.seconds(); + let start = start_after.map(Bound::exclusive); + + let storage = NodeFamiliesStorage::new(); + let invitations = storage + .pending_family_invitations + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|res| { + res.map(|(_key, invitation)| PendingFamilyInvitationDetails { + expired: now >= invitation.expires_at, + invitation, + }) + }) + .collect::>>()?; + + let start_next_after = invitations + .last() + .map(|d| (d.invitation.family_id, d.invitation.node_id)); + + Ok(PendingInvitationsPagedResponse { + invitations, + start_next_after, + }) +} + +/// Page through every archived (terminal-state) invitation issued by +/// `family_id`, in ascending `(node_id, counter)` order across all +/// `Accepted` / `Rejected` / `Revoked` statuses. +/// +/// Uses a direct bounds-based range scan on the primary map keyed by +/// `((family_id, node_id), counter)` β€” `family_id` is already the leftmost +/// key component, so this avoids the extra storage read per entry that +/// going through the `family` multi-index would incur. Cost is O(page +/// size). Does not verify that `family_id` refers to an existing +/// family β€” an unknown id simply yields an empty page. +/// +/// `start_after` is exclusive β€” pass the previous page's `start_next_after` +/// to fetch the next page; pass `None` to start from the first archived +/// entry. `limit` defaults to [`retrieval_limits::PAST_INVITATIONS_DEFAULT_LIMIT`] +/// and is clamped to [`retrieval_limits::PAST_INVITATIONS_MAX_LIMIT`]. +pub fn query_past_invitations_for_family_paged( + deps: Deps, + family_id: NodeFamilyId, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::PAST_INVITATIONS_DEFAULT_LIMIT) + .min(retrieval_limits::PAST_INVITATIONS_MAX_LIMIT) as usize; + + let lower = Some(match start_after { + Some((node_id, counter)) => Bound::exclusive(((family_id, node_id), counter)), + None => Bound::inclusive(((family_id, 0), 0)), + }); + + // upper bound = first key of next family; + let upper = Some(Bound::exclusive(((family_id + 1, 0), 0))); + + let storage = NodeFamiliesStorage::new(); + let entries = storage + .past_family_invitations + .range(deps.storage, lower, upper, Order::Ascending) + .take(limit) + .collect::>>()?; + + let start_next_after = entries + .last() + .map(|(((_, node_id), counter), _)| (*node_id, *counter)); + + let invitations = entries.into_iter().map(|(_, v)| v).collect(); + + Ok(PastFamilyInvitationsPagedResponse { + family_id, + invitations, + start_next_after, + }) +} + +/// Page through every archived (terminal-state) invitation addressed to +/// `node_id`, in ascending `(family_id, counter)` order across all +/// `Accepted` / `Rejected` / `Revoked` statuses. +/// +/// Backed by the `node` multi-index over `past_family_invitations`, so +/// cost is O(page size). `start_after` is exclusive β€” pass the previous +/// page's `start_next_after` to fetch the next page. `limit` defaults to +/// [`retrieval_limits::PAST_INVITATIONS_DEFAULT_LIMIT`] and is clamped to +/// [`retrieval_limits::PAST_INVITATIONS_MAX_LIMIT`]. +pub fn query_past_invitations_for_node_paged( + deps: Deps, + node_id: NodeId, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::PAST_INVITATIONS_DEFAULT_LIMIT) + .min(retrieval_limits::PAST_INVITATIONS_MAX_LIMIT) as usize; + + let start = + start_after.map(|(family_id, counter)| Bound::exclusive(((family_id, node_id), counter))); + + let storage = NodeFamiliesStorage::new(); + let entries = storage + .past_family_invitations + .idx + .node + .prefix(node_id) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .collect::>>()?; + + let start_next_after = entries + .last() + .map(|(((family_id, _), counter), _)| (*family_id, *counter)); + + let invitations = entries.into_iter().map(|(_, v)| v).collect(); + + Ok(PastFamilyInvitationsForNodePagedResponse { + node_id, + invitations, + start_next_after, + }) +} + +/// Page through every archived (terminal-state) invitation across all +/// families, in ascending `((family_id, node_id), counter)` order. +/// +/// Cost is O(page size) β€” full range scan over the +/// `past_family_invitations` map without any prefix filter. +/// +/// `start_after` is exclusive β€” pass the previous page's `start_next_after` +/// to fetch the next page; pass `None` to start from the first entry. +/// `limit` defaults to [`retrieval_limits::PAST_INVITATIONS_DEFAULT_LIMIT`] +/// and is clamped to [`retrieval_limits::PAST_INVITATIONS_MAX_LIMIT`]. +pub fn query_all_past_invitations_paged( + deps: Deps, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::PAST_INVITATIONS_DEFAULT_LIMIT) + .min(retrieval_limits::PAST_INVITATIONS_MAX_LIMIT) as usize; + + let start = start_after.map(Bound::exclusive); + + let storage = NodeFamiliesStorage::new(); + let entries = storage + .past_family_invitations + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .collect::>>()?; + + let start_next_after = entries.last().map(|(key, _)| *key); + + let invitations = entries.into_iter().map(|(_, v)| v).collect(); + + Ok(AllPastFamilyInvitationsPagedResponse { + invitations, + start_next_after, + }) +} + +/// Page through every archived membership record for `family_id` (nodes that +/// used to belong to it but have since been removed), in ascending +/// `(node_id, counter)` order. +/// +/// Uses a direct bounds-based range scan on the primary map keyed by +/// `((family_id, node_id), counter)` β€” `family_id` is already the leftmost +/// key component, so this avoids the extra storage read per entry that +/// going through the `family` multi-index would incur. Cost is O(page +/// size). Does not verify that `family_id` refers to an existing +/// family β€” an unknown id simply yields an empty page. +/// +/// `start_after` is exclusive β€” pass the previous page's `start_next_after` +/// to fetch the next page; pass `None` to start from the first archived +/// entry. `limit` defaults to [`retrieval_limits::PAST_MEMBERS_DEFAULT_LIMIT`] +/// and is clamped to [`retrieval_limits::PAST_MEMBERS_MAX_LIMIT`]. +pub fn query_past_members_for_family_paged( + deps: Deps, + family_id: NodeFamilyId, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::PAST_MEMBERS_DEFAULT_LIMIT) + .min(retrieval_limits::PAST_MEMBERS_MAX_LIMIT) as usize; + + let lower = Some(match start_after { + Some((node_id, counter)) => Bound::exclusive(((family_id, node_id), counter)), + None => Bound::inclusive(((family_id, 0), 0)), + }); + + // upper bound = first key of next family; + let upper = Some(Bound::exclusive(((family_id + 1, 0), 0))); + + let storage = NodeFamiliesStorage::new(); + let entries = storage + .past_family_members + .range(deps.storage, lower, upper, Order::Ascending) + .take(limit) + .collect::>>()?; + + let start_next_after = entries + .last() + .map(|(((_, node_id), counter), _)| (*node_id, *counter)); + + let members = entries.into_iter().map(|(_, v)| v).collect(); + + Ok(PastFamilyMembersPagedResponse { + family_id, + members, + start_next_after, + }) +} + +/// Page through every archived membership record for `node_id` (every family +/// the node used to belong to but has since been removed from), in ascending +/// `(family_id, counter)` order. +/// +/// Backed by the `node` multi-index over `past_family_members`, so cost is +/// O(page size). `start_after` is exclusive β€” pass the previous page's +/// `start_next_after` to fetch the next page. `limit` defaults to +/// [`retrieval_limits::PAST_MEMBERS_DEFAULT_LIMIT`] and is clamped to +/// [`retrieval_limits::PAST_MEMBERS_MAX_LIMIT`]. +pub fn query_past_members_for_node_paged( + deps: Deps, + node_id: NodeId, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::PAST_MEMBERS_DEFAULT_LIMIT) + .min(retrieval_limits::PAST_MEMBERS_MAX_LIMIT) as usize; + + let start = + start_after.map(|(family_id, counter)| Bound::exclusive(((family_id, node_id), counter))); + + let storage = NodeFamiliesStorage::new(); + let entries = storage + .past_family_members + .idx + .node + .prefix(node_id) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .collect::>>()?; + + let start_next_after = entries + .last() + .map(|(((family_id, _), counter), _)| (*family_id, *counter)); + + let members = entries.into_iter().map(|(_, v)| v).collect(); + + Ok(PastFamilyMembersForNodePagedResponse { + node_id, + members, + start_next_after, + }) +} + +/// Page through every existing family in ascending [`NodeFamilyId`] order. +/// +/// `start_after` is exclusive β€” pass the previous page's `start_next_after` +/// to fetch the next page; pass `None` to start from the first family. +/// `limit` defaults to [`retrieval_limits::FAMILIES_DEFAULT_LIMIT`] and is +/// clamped to [`retrieval_limits::FAMILIES_MAX_LIMIT`]. +pub fn query_families_paged( + deps: Deps, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::FAMILIES_DEFAULT_LIMIT) + .min(retrieval_limits::FAMILIES_MAX_LIMIT) as usize; + + let start = start_after.map(Bound::exclusive); + + let storage = NodeFamiliesStorage::new(); + let families = storage + .families + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|res| res.map(|item| item.1)) + .collect::>>()?; + + let start_next_after = families.last().map(|family| family.id); + + Ok(FamiliesPagedResponse { + families, + start_next_after, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{init_contract_tester, NodeFamiliesContractTesterExt}; + use nym_contracts_common_testing::{ChainOpts, ContractOpts}; + + #[cfg(test)] + mod family_by_id { + use super::*; + + #[test] + fn family_by_id_returns_none_when_missing() { + let tester = init_contract_tester(); + + let res = query_family_by_id(tester.deps(), 99).unwrap(); + assert_eq!(res.family_id, 99); + assert!(res.family.is_none()); + } + + #[test] + fn family_by_id_returns_persisted_family() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + let res = query_family_by_id(tester.deps(), f.id).unwrap(); + assert_eq!(res.family_id, f.id); + assert_eq!(res.family, Some(f)); + } + } + + #[cfg(test)] + mod family_by_owner { + use super::*; + + #[test] + fn returns_none_when_owner_owns_no_family() { + let tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + + let res = query_family_by_owner(tester.deps(), alice.to_string()).unwrap(); + assert_eq!(res.owner, alice); + assert!(res.family.is_none()); + } + + #[test] + fn returns_persisted_family_for_owner() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + + let res = query_family_by_owner(tester.deps(), alice.to_string()).unwrap(); + assert_eq!(res.owner, alice); + assert_eq!(res.family, Some(f)); + } + + #[test] + fn distinguishes_owners() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let bob = tester.addr_make("bob"); + let f_alice = tester.make_family(&alice); + let f_bob = tester.make_family(&bob); + + let res = query_family_by_owner(tester.deps(), alice.to_string()).unwrap(); + assert_eq!(res.family, Some(f_alice)); + let res = query_family_by_owner(tester.deps(), bob.to_string()).unwrap(); + assert_eq!(res.family, Some(f_bob)); + } + + #[test] + fn errors_on_invalid_address() { + let tester = init_contract_tester(); + + let res = query_family_by_owner(tester.deps(), "not a valid addr".to_string()); + assert!(res.is_err()); + } + } + + #[cfg(test)] + mod family_by_name { + use super::*; + use nym_contracts_common_testing::RandExt; + + #[test] + fn returns_none_when_name_does_not_exist() { + let tester = init_contract_tester(); + + let res = query_family_by_name(tester.deps(), "missing".to_string()).unwrap(); + assert_eq!(res.name, "missing"); + assert!(res.family.is_none()); + } + + #[test] + fn returns_persisted_family_by_exact_name() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let f = tester.make_named_family(&alice, "foo"); + + let res = query_family_by_name(tester.deps(), "foo".to_string()).unwrap(); + assert_eq!(res.name, "foo"); + assert_eq!(res.family, Some(f)); + } + + #[test] + fn lookup_normalises_name() { + let variants = ["foo", "FOO", "FoO", "fOo", "fooπŸš€", "πŸš€Foo", " foo-!"]; + + for family_name in variants { + let mut tester = init_contract_tester(); + let owner = tester.generate_account(); + let f = tester.make_named_family(&owner, family_name); + + for query_name in variants { + let res = query_family_by_name(tester.deps(), query_name.to_string()).unwrap(); + assert_eq!(res.name, query_name); + assert_eq!(res.family, Some(f.clone())); + let stored = res.family.unwrap(); + // user-submitted formatting is preserved on the record; + // the normalised form is what enforces uniqueness. + assert_eq!(stored.name, family_name); + assert_eq!(stored.normalised_name, normalise_family_name(family_name)); + } + } + } + } + + #[cfg(test)] + mod family_membership { + use super::*; + + #[test] + fn family_membership_returns_none_for_unaffiliated_node() { + let tester = init_contract_tester(); + + let res = query_family_membership(tester.deps(), 999).unwrap(); + assert_eq!(res.node_id, 999); + assert!(res.family_id.is_none()); + } + + #[test] + fn family_membership_returns_family_id_for_member() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + tester.add_to_family(f.id, 42); + + let res = query_family_membership(tester.deps(), 42).unwrap(); + assert_eq!(res.node_id, 42); + assert_eq!(res.family_id, Some(f.id)); + } + + #[test] + fn family_membership_returns_none_after_node_is_removed() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + tester.add_to_family(f.id, 42); + tester.remove_from_family(42); + + let res = query_family_membership(tester.deps(), 42).unwrap(); + assert!(res.family_id.is_none()); + } + } + + #[cfg(test)] + mod pending_invitation { + use super::*; + + #[test] + fn pending_invitation_returns_none_when_missing() { + let tester = init_contract_tester(); + let env = tester.env(); + + let res = query_pending_invitation(tester.deps(), env, 1, 42).unwrap(); + assert_eq!(res.family_id, 1); + assert_eq!(res.node_id, 42); + assert!(res.invitation.is_none()); + } + + #[test] + fn pending_invitation_returns_unexpired_when_in_future() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + let inv = tester.invite_to_family(f.id, 42); + let env = tester.env(); + + let res = query_pending_invitation(tester.deps(), env, f.id, 42).unwrap(); + let details = res.invitation.unwrap(); + assert_eq!(details.invitation, inv); + assert!(!details.expired); + } + + #[test] + fn pending_invitation_flagged_as_expired_after_block_time() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + let expires_at = tester.env().block.time.seconds() + 5; + tester.invite_to_family_with_expiration(f.id, 42, expires_at); + + // advance block time well past the expiry + tester.advance_time_by(60); + + let env = tester.env(); + assert!(env.block.time.seconds() >= expires_at); + + let res = query_pending_invitation(tester.deps(), env, f.id, 42).unwrap(); + let details = res.invitation.unwrap(); + assert_eq!(details.invitation.expires_at, expires_at); + assert!(details.expired); + } + } + + #[cfg(test)] + mod families_paged { + use super::*; + + #[test] + fn empty_when_no_families_exist() { + let tester = init_contract_tester(); + + let res = query_families_paged(tester.deps(), None, None).unwrap(); + assert!(res.families.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn returns_all_families_within_default_limit() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + + let res = query_families_paged(tester.deps(), None, None).unwrap(); + assert_eq!(res.families, vec![f1, f2, f3.clone()]); + assert_eq!(res.start_next_after, Some(f3.id)); + } + + #[test] + fn returns_families_in_ascending_id_order() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + + let res = query_families_paged(tester.deps(), None, None).unwrap(); + let ids: Vec<_> = res.families.iter().map(|f| f.id).collect(); + assert_eq!(ids, vec![f1.id, f2.id, f3.id]); + } + + #[test] + fn limit_caps_page_size() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let _f3 = tester.add_dummy_family(); + + let res = query_families_paged(tester.deps(), None, Some(2)).unwrap(); + assert_eq!(res.families, vec![f1, f2.clone()]); + assert_eq!(res.start_next_after, Some(f2.id)); + } + + #[test] + fn start_after_is_exclusive() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + + let res = query_families_paged(tester.deps(), Some(f1.id), None).unwrap(); + assert_eq!(res.families, vec![f2, f3.clone()]); + assert_eq!(res.start_next_after, Some(f3.id)); + } + + #[test] + fn paginates_through_all_families() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + let f4 = tester.add_dummy_family(); + let f5 = tester.add_dummy_family(); + + let page1 = query_families_paged(tester.deps(), None, Some(2)).unwrap(); + assert_eq!(page1.families, vec![f1, f2.clone()]); + assert_eq!(page1.start_next_after, Some(f2.id)); + + let page2 = + query_families_paged(tester.deps(), page1.start_next_after, Some(2)).unwrap(); + assert_eq!(page2.families, vec![f3, f4.clone()]); + assert_eq!(page2.start_next_after, Some(f4.id)); + + let page3 = + query_families_paged(tester.deps(), page2.start_next_after, Some(2)).unwrap(); + assert_eq!(page3.families, vec![f5.clone()]); + assert_eq!(page3.start_next_after, Some(f5.id)); + + let page4 = + query_families_paged(tester.deps(), page3.start_next_after, Some(2)).unwrap(); + assert!(page4.families.is_empty()); + assert!(page4.start_next_after.is_none()); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let total = retrieval_limits::FAMILIES_MAX_LIMIT as usize + 5; + for _ in 0..total { + tester.add_dummy_family(); + } + + let res = query_families_paged(tester.deps(), None, Some(u32::MAX)).unwrap(); + assert_eq!( + res.families.len(), + retrieval_limits::FAMILIES_MAX_LIMIT as usize + ); + } + + #[test] + fn default_limit_applied_when_unspecified() { + let mut tester = init_contract_tester(); + let total = retrieval_limits::FAMILIES_DEFAULT_LIMIT as usize + 5; + for _ in 0..total { + tester.add_dummy_family(); + } + + let res = query_families_paged(tester.deps(), None, None).unwrap(); + assert_eq!( + res.families.len(), + retrieval_limits::FAMILIES_DEFAULT_LIMIT as usize + ); + } + + #[test] + fn start_after_past_end_returns_empty() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + let res = query_families_paged(tester.deps(), Some(f.id), None).unwrap(); + assert!(res.families.is_empty()); + assert!(res.start_next_after.is_none()); + } + } + + #[cfg(test)] + mod family_members_paged { + use super::*; + + #[test] + fn empty_when_family_has_no_members() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + let res = query_family_members_paged(tester.deps(), f.id, None, None).unwrap(); + assert_eq!(res.family_id, f.id); + assert!(res.members.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn empty_for_unknown_family_id() { + let tester = init_contract_tester(); + + let res = query_family_members_paged(tester.deps(), 99, None, None).unwrap(); + assert_eq!(res.family_id, 99); + assert!(res.members.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn returns_only_members_of_requested_family() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + + tester.add_to_family(f1.id, 10); + tester.add_to_family(f1.id, 11); + tester.add_to_family(f2.id, 20); + + let res = query_family_members_paged(tester.deps(), f1.id, None, None).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![10, 11]); + for record in &res.members { + assert_eq!(record.membership.family_id, f1.id); + } + } + + #[test] + fn member_record_carries_joined_at_timestamp() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + tester.add_to_family(f.id, 42); + + let expected = tester.env().block.time.seconds(); + let res = query_family_members_paged(tester.deps(), f.id, None, None).unwrap(); + let record = res.members.into_iter().next().unwrap(); + assert_eq!(record.node_id, 42); + assert_eq!(record.membership.family_id, f.id); + assert_eq!(record.membership.joined_at, expected); + } + + #[test] + fn members_returned_in_ascending_node_id_order() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + // insert out of order to confirm ordering isn't insertion order + tester.add_to_family(f.id, 30); + tester.add_to_family(f.id, 10); + tester.add_to_family(f.id, 20); + + let res = query_family_members_paged(tester.deps(), f.id, None, None).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![10, 20, 30]); + } + + #[test] + fn limit_caps_page_size() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + for n in [10, 11, 12] { + tester.add_to_family(f.id, n); + } + + let res = query_family_members_paged(tester.deps(), f.id, None, Some(2)).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![10, 11]); + assert_eq!(res.start_next_after, Some(11)); + } + + #[test] + fn start_after_is_exclusive() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + for n in [10, 11, 12] { + tester.add_to_family(f.id, n); + } + + let res = query_family_members_paged(tester.deps(), f.id, Some(10), None).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![11, 12]); + assert_eq!(res.start_next_after, Some(12)); + } + + #[test] + fn paginates_through_all_members() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + for n in [10, 11, 12, 13, 14] { + tester.add_to_family(f.id, n); + } + + let p1 = query_family_members_paged(tester.deps(), f.id, None, Some(2)).unwrap(); + assert_eq!( + p1.members.iter().map(|m| m.node_id).collect::>(), + vec![10, 11] + ); + assert_eq!(p1.start_next_after, Some(11)); + + let p2 = query_family_members_paged(tester.deps(), f.id, p1.start_next_after, Some(2)) + .unwrap(); + assert_eq!( + p2.members.iter().map(|m| m.node_id).collect::>(), + vec![12, 13] + ); + assert_eq!(p2.start_next_after, Some(13)); + + let p3 = query_family_members_paged(tester.deps(), f.id, p2.start_next_after, Some(2)) + .unwrap(); + assert_eq!( + p3.members.iter().map(|m| m.node_id).collect::>(), + vec![14] + ); + assert_eq!(p3.start_next_after, Some(14)); + + let p4 = query_family_members_paged(tester.deps(), f.id, p3.start_next_after, Some(2)) + .unwrap(); + assert!(p4.members.is_empty()); + assert!(p4.start_next_after.is_none()); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let total = retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT + 5; + tester.add_n_family_members(f.id, total); + + let res = + query_family_members_paged(tester.deps(), f.id, None, Some(u32::MAX)).unwrap(); + assert_eq!( + res.members.len(), + retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT as usize + ); + } + + #[test] + fn excludes_node_after_it_leaves_family() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + tester.add_to_family(f.id, 10); + tester.add_to_family(f.id, 11); + + tester.remove_from_family(10); + + let res = query_family_members_paged(tester.deps(), f.id, None, None).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![11]); + } + } + + #[cfg(test)] + mod all_family_members_paged { + use super::*; + + #[test] + fn empty_when_no_families_exist() { + let tester = init_contract_tester(); + + let res = query_all_family_members_paged(tester.deps(), None, None).unwrap(); + assert!(res.members.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn empty_when_families_have_no_members() { + let mut tester = init_contract_tester(); + tester.add_dummy_family(); + tester.add_dummy_family(); + + let res = query_all_family_members_paged(tester.deps(), None, None).unwrap(); + assert!(res.members.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn returns_members_from_every_family() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + + tester.add_to_family(f1.id, 10); + tester.add_to_family(f2.id, 20); + tester.add_to_family(f3.id, 30); + + let res = query_all_family_members_paged(tester.deps(), None, None).unwrap(); + let pairs: Vec<_> = res + .members + .iter() + .map(|m| (m.node_id, m.membership.family_id)) + .collect(); + assert_eq!(pairs, vec![(10, f1.id), (20, f2.id), (30, f3.id)]); + } + + #[test] + fn members_returned_in_ascending_node_id_order_across_families() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + + // interleaved so insertion order does not match the requested ordering + tester.add_to_family(f2.id, 30); + tester.add_to_family(f1.id, 10); + tester.add_to_family(f2.id, 20); + tester.add_to_family(f1.id, 40); + + let res = query_all_family_members_paged(tester.deps(), None, None).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![10, 20, 30, 40]); + } + + #[test] + fn member_record_carries_correct_family_and_joined_at() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + + tester.add_to_family(f1.id, 7); + tester.add_to_family(f2.id, 99); + let expected_joined_at = tester.env().block.time.seconds(); + + let res = query_all_family_members_paged(tester.deps(), None, None).unwrap(); + let by_node: std::collections::HashMap<_, _> = res + .members + .into_iter() + .map(|m| (m.node_id, m.membership)) + .collect(); + + let m7 = &by_node[&7]; + assert_eq!(m7.family_id, f1.id); + assert_eq!(m7.joined_at, expected_joined_at); + + let m99 = &by_node[&99]; + assert_eq!(m99.family_id, f2.id); + assert_eq!(m99.joined_at, expected_joined_at); + } + + #[test] + fn limit_caps_page_size() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + for n in [10, 11, 12] { + tester.add_to_family(f.id, n); + } + + let res = query_all_family_members_paged(tester.deps(), None, Some(2)).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![10, 11]); + assert_eq!(res.start_next_after, Some(11)); + } + + #[test] + fn start_after_is_exclusive() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + for n in [10, 11, 12] { + tester.add_to_family(f.id, n); + } + + let res = query_all_family_members_paged(tester.deps(), Some(10), None).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![11, 12]); + assert_eq!(res.start_next_after, Some(12)); + } + + #[test] + fn paginates_through_all_members_across_families() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + + tester.add_to_family(f1.id, 10); + tester.add_to_family(f2.id, 11); + tester.add_to_family(f1.id, 12); + tester.add_to_family(f2.id, 13); + tester.add_to_family(f1.id, 14); + + let p1 = query_all_family_members_paged(tester.deps(), None, Some(2)).unwrap(); + assert_eq!( + p1.members.iter().map(|m| m.node_id).collect::>(), + vec![10, 11] + ); + assert_eq!(p1.start_next_after, Some(11)); + + let p2 = query_all_family_members_paged(tester.deps(), p1.start_next_after, Some(2)) + .unwrap(); + assert_eq!( + p2.members.iter().map(|m| m.node_id).collect::>(), + vec![12, 13] + ); + assert_eq!(p2.start_next_after, Some(13)); + + let p3 = query_all_family_members_paged(tester.deps(), p2.start_next_after, Some(2)) + .unwrap(); + assert_eq!( + p3.members.iter().map(|m| m.node_id).collect::>(), + vec![14] + ); + assert_eq!(p3.start_next_after, Some(14)); + + let p4 = query_all_family_members_paged(tester.deps(), p3.start_next_after, Some(2)) + .unwrap(); + assert!(p4.members.is_empty()); + assert!(p4.start_next_after.is_none()); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let total = retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT + 5; + tester.add_n_family_members(f.id, total); + + let res = query_all_family_members_paged(tester.deps(), None, Some(u32::MAX)).unwrap(); + assert_eq!( + res.members.len(), + retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT as usize + ); + } + + #[test] + fn default_limit_applied_when_unspecified() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let total = retrieval_limits::FAMILY_MEMBERS_DEFAULT_LIMIT + 5; + tester.add_n_family_members(f.id, total); + + let res = query_all_family_members_paged(tester.deps(), None, None).unwrap(); + assert_eq!( + res.members.len(), + retrieval_limits::FAMILY_MEMBERS_DEFAULT_LIMIT as usize + ); + } + + #[test] + fn excludes_node_after_it_leaves_family() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 10); + tester.add_to_family(f2.id, 11); + + tester.remove_from_family(10); + + let res = query_all_family_members_paged(tester.deps(), None, None).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![11]); + } + } + + #[cfg(test)] + mod pending_invitations_for_family_paged { + use super::*; + + #[test] + fn empty_when_family_has_no_pending_invitations() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let env = tester.env(); + + let res = + query_pending_invitations_for_family_paged(tester.deps(), env, f.id, None, None) + .unwrap(); + assert_eq!(res.family_id, f.id); + assert!(res.invitations.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn empty_for_unknown_family_id() { + let tester = init_contract_tester(); + let env = tester.env(); + + let res = + query_pending_invitations_for_family_paged(tester.deps(), env, 99, None, None) + .unwrap(); + assert_eq!(res.family_id, 99); + assert!(res.invitations.is_empty()); + } + + #[test] + fn returns_only_invitations_from_requested_family() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.invite_to_family(f1.id, 10); + tester.invite_to_family(f1.id, 11); + tester.invite_to_family(f2.id, 20); + + let env = tester.env(); + let res = + query_pending_invitations_for_family_paged(tester.deps(), env, f1.id, None, None) + .unwrap(); + let ids: Vec<_> = res + .invitations + .iter() + .map(|d| d.invitation.node_id) + .collect(); + assert_eq!(ids, vec![10, 11]); + for d in &res.invitations { + assert_eq!(d.invitation.family_id, f1.id); + } + } + + #[test] + fn returned_in_ascending_node_id_order() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + // out-of-order inserts + tester.invite_to_family(f.id, 30); + tester.invite_to_family(f.id, 10); + tester.invite_to_family(f.id, 20); + + let env = tester.env(); + let res = + query_pending_invitations_for_family_paged(tester.deps(), env, f.id, None, None) + .unwrap(); + let ids: Vec<_> = res + .invitations + .iter() + .map(|d| d.invitation.node_id) + .collect(); + assert_eq!(ids, vec![10, 20, 30]); + } + + #[test] + fn flags_expired_against_current_block_time() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let now = tester.env().block.time.seconds(); + + tester.invite_to_family_with_expiration(f.id, 10, now + 5); + tester.invite_to_family_with_expiration(f.id, 11, now + 1000); + + tester.advance_time_by(60); + let env = tester.env(); + let res = + query_pending_invitations_for_family_paged(tester.deps(), env, f.id, None, None) + .unwrap(); + + assert_eq!(res.invitations[0].invitation.node_id, 10); + assert!(res.invitations[0].expired); + assert_eq!(res.invitations[1].invitation.node_id, 11); + assert!(!res.invitations[1].expired); + } + + #[test] + fn limit_caps_page_size_and_start_after_is_exclusive() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + for n in [10, 11, 12] { + tester.invite_to_family(f.id, n); + } + + let env = tester.env(); + let p1 = query_pending_invitations_for_family_paged( + tester.deps(), + env.clone(), + f.id, + None, + Some(2), + ) + .unwrap(); + let ids: Vec<_> = p1 + .invitations + .iter() + .map(|d| d.invitation.node_id) + .collect(); + assert_eq!(ids, vec![10, 11]); + assert_eq!(p1.start_next_after, Some(11)); + + let p2 = query_pending_invitations_for_family_paged( + tester.deps(), + env, + f.id, + p1.start_next_after, + Some(2), + ) + .unwrap(); + let ids: Vec<_> = p2 + .invitations + .iter() + .map(|d| d.invitation.node_id) + .collect(); + assert_eq!(ids, vec![12]); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let total = retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT + 5; + for n in 1..=total { + tester.invite_to_family(f.id, n); + } + + let env = tester.env(); + let res = query_pending_invitations_for_family_paged( + tester.deps(), + env, + f.id, + None, + Some(u32::MAX), + ) + .unwrap(); + assert_eq!( + res.invitations.len(), + retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT as usize + ); + } + + #[test] + fn excludes_invitation_after_it_is_revoked() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + tester.invite_to_family(f.id, 10); + tester.invite_to_family(f.id, 11); + + // accepting moves the invitation out of the pending map + tester.accept_invitation(f.id, 10); + + let env = tester.env(); + let res = + query_pending_invitations_for_family_paged(tester.deps(), env, f.id, None, None) + .unwrap(); + let ids: Vec<_> = res + .invitations + .iter() + .map(|d| d.invitation.node_id) + .collect(); + assert_eq!(ids, vec![11]); + } + } + + #[cfg(test)] + mod all_pending_invitations_paged { + use super::*; + + #[test] + fn empty_when_no_pending_invitations() { + let tester = init_contract_tester(); + let env = tester.env(); + + let res = query_all_pending_invitations_paged(tester.deps(), env, None, None).unwrap(); + assert!(res.invitations.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn returns_invitations_across_all_families() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.invite_to_family(f1.id, 10); + tester.invite_to_family(f1.id, 20); + tester.invite_to_family(f2.id, 5); + + let env = tester.env(); + let res = query_all_pending_invitations_paged(tester.deps(), env, None, None).unwrap(); + + let pairs: Vec<_> = res + .invitations + .iter() + .map(|d| (d.invitation.family_id, d.invitation.node_id)) + .collect(); + // ordered by (family_id asc, node_id asc) + assert_eq!(pairs, vec![(f1.id, 10), (f1.id, 20), (f2.id, 5)]); + assert_eq!(res.start_next_after, Some((f2.id, 5))); + } + + #[test] + fn flags_expired_against_current_block_time() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let now = tester.env().block.time.seconds(); + tester.invite_to_family_with_expiration(f.id, 10, now + 5); + tester.invite_to_family_with_expiration(f.id, 11, now + 1000); + + tester.advance_time_by(60); + let env = tester.env(); + let res = query_all_pending_invitations_paged(tester.deps(), env, None, None).unwrap(); + + let by_node: std::collections::HashMap<_, _> = res + .invitations + .iter() + .map(|d| (d.invitation.node_id, d.expired)) + .collect(); + assert!(by_node[&10]); + assert!(!by_node[&11]); + } + + #[test] + fn paginates_with_composite_cursor() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.invite_to_family(f1.id, 10); + tester.invite_to_family(f1.id, 20); + tester.invite_to_family(f2.id, 5); + tester.invite_to_family(f2.id, 15); + + let env = tester.env(); + let p1 = query_all_pending_invitations_paged(tester.deps(), env.clone(), None, Some(2)) + .unwrap(); + let pairs: Vec<_> = p1 + .invitations + .iter() + .map(|d| (d.invitation.family_id, d.invitation.node_id)) + .collect(); + assert_eq!(pairs, vec![(f1.id, 10), (f1.id, 20)]); + assert_eq!(p1.start_next_after, Some((f1.id, 20))); + + let p2 = query_all_pending_invitations_paged( + tester.deps(), + env.clone(), + p1.start_next_after, + Some(2), + ) + .unwrap(); + let pairs: Vec<_> = p2 + .invitations + .iter() + .map(|d| (d.invitation.family_id, d.invitation.node_id)) + .collect(); + assert_eq!(pairs, vec![(f2.id, 5), (f2.id, 15)]); + + let p3 = query_all_pending_invitations_paged( + tester.deps(), + env, + p2.start_next_after, + Some(2), + ) + .unwrap(); + assert!(p3.invitations.is_empty()); + assert!(p3.start_next_after.is_none()); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let total = retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT + 5; + for n in 1..=total { + tester.invite_to_family(f.id, n); + } + + let env = tester.env(); + let res = query_all_pending_invitations_paged(tester.deps(), env, None, Some(u32::MAX)) + .unwrap(); + assert_eq!( + res.invitations.len(), + retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT as usize + ); + } + } + + #[cfg(test)] + mod past_invitations_for_family_paged { + use super::*; + use nym_node_families_contract_common::FamilyInvitationStatus; + + #[test] + fn empty_when_family_has_no_archive_entries() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + let res = + query_past_invitations_for_family_paged(tester.deps(), f.id, None, None).unwrap(); + assert_eq!(res.family_id, f.id); + assert!(res.invitations.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn empty_for_unknown_family_id() { + let tester = init_contract_tester(); + + let res = + query_past_invitations_for_family_paged(tester.deps(), 99, None, None).unwrap(); + assert_eq!(res.family_id, 99); + assert!(res.invitations.is_empty()); + } + + #[test] + fn returns_only_archived_invitations_from_requested_family() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + // produce one Accepted in each family, plus one Rejected in f1 + tester.add_to_family(f1.id, 10); + tester.invite_to_family(f1.id, 11); + tester.reject_invitation(f1.id, 11); + tester.add_to_family(f2.id, 20); + + let res = + query_past_invitations_for_family_paged(tester.deps(), f1.id, None, None).unwrap(); + assert_eq!(res.invitations.len(), 2); + for entry in &res.invitations { + assert_eq!(entry.invitation.family_id, f1.id); + } + } + + #[test] + fn covers_all_terminal_statuses() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + // Accepted + tester.add_to_family(f.id, 10); + // Rejected + tester.invite_to_family(f.id, 11); + tester.reject_invitation(f.id, 11); + // Revoked + tester.invite_to_family(f.id, 12); + tester.revoke_invitation(f.id, 12); + + let res = + query_past_invitations_for_family_paged(tester.deps(), f.id, None, None).unwrap(); + let by_node: std::collections::HashMap<_, _> = res + .invitations + .iter() + .map(|p| (p.invitation.node_id, p.status.clone())) + .collect(); + assert!(matches!( + by_node[&10], + FamilyInvitationStatus::Accepted { .. } + )); + assert!(matches!( + by_node[&11], + FamilyInvitationStatus::Rejected { .. } + )); + assert!(matches!( + by_node[&12], + FamilyInvitationStatus::Revoked { .. } + )); + } + + #[test] + fn ordered_by_node_id_then_counter() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + // node 42 joins and leaves twice β€” produces two Accepted entries with counters 0 and 1 + for _ in 0..2 { + tester.add_to_family(f.id, 42); + tester.remove_from_family(42); + } + // node 7 has one Accepted entry + tester.add_to_family(f.id, 7); + + let res = + query_past_invitations_for_family_paged(tester.deps(), f.id, None, None).unwrap(); + let pairs: Vec<_> = res + .invitations + .iter() + .map(|p| p.invitation.node_id) + .collect(); + // 7 comes before 42; 42's two entries come together (both with counter 0 and 1) + assert_eq!(pairs, vec![7, 42, 42]); + } + + #[test] + fn paginates_with_node_counter_cursor() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + for n in [10, 11, 12] { + tester.add_to_family(f.id, n); + } + + let p1 = query_past_invitations_for_family_paged(tester.deps(), f.id, None, Some(2)) + .unwrap(); + let ids: Vec<_> = p1 + .invitations + .iter() + .map(|p| p.invitation.node_id) + .collect(); + assert_eq!(ids, vec![10, 11]); + assert_eq!(p1.start_next_after, Some((11, 0))); + + let p2 = query_past_invitations_for_family_paged( + tester.deps(), + f.id, + p1.start_next_after, + Some(2), + ) + .unwrap(); + let ids: Vec<_> = p2 + .invitations + .iter() + .map(|p| p.invitation.node_id) + .collect(); + assert_eq!(ids, vec![12]); + assert_eq!(p2.start_next_after, Some((12, 0))); + + let p3 = query_past_invitations_for_family_paged( + tester.deps(), + f.id, + p2.start_next_after, + Some(2), + ) + .unwrap(); + assert!(p3.invitations.is_empty()); + assert!(p3.start_next_after.is_none()); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let total = retrieval_limits::PAST_INVITATIONS_MAX_LIMIT + 5; + tester.add_n_family_members(f.id, total); + + let res = + query_past_invitations_for_family_paged(tester.deps(), f.id, None, Some(u32::MAX)) + .unwrap(); + assert_eq!( + res.invitations.len(), + retrieval_limits::PAST_INVITATIONS_MAX_LIMIT as usize + ); + } + + #[test] + fn start_after_none_does_not_leak_earlier_families() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 10); + tester.add_to_family(f1.id, 11); + + let res = + query_past_invitations_for_family_paged(tester.deps(), f2.id, None, None).unwrap(); + + assert_eq!(res.family_id, f2.id); + assert!( + res.invitations.is_empty(), + "expected no entries for f2 but got: {:?}", + res.invitations + .iter() + .map(|e| (e.invitation.family_id, e.invitation.node_id)) + .collect::>() + ); + assert!(res.start_next_after.is_none()); + } + } + + #[cfg(test)] + mod all_past_invitations_paged { + use super::*; + + #[test] + fn empty_when_no_archive_entries() { + let tester = init_contract_tester(); + + let res = query_all_past_invitations_paged(tester.deps(), None, None).unwrap(); + assert!(res.invitations.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn returns_archives_across_all_families() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 10); + tester.add_to_family(f1.id, 20); + tester.add_to_family(f2.id, 5); + + let res = query_all_past_invitations_paged(tester.deps(), None, None).unwrap(); + let pairs: Vec<_> = res + .invitations + .iter() + .map(|p| (p.invitation.family_id, p.invitation.node_id)) + .collect(); + assert_eq!(pairs, vec![(f1.id, 10), (f1.id, 20), (f2.id, 5)]); + assert_eq!(res.start_next_after, Some(((f2.id, 5), 0))); + } + + #[test] + fn paginates_with_composite_cursor() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 10); + tester.add_to_family(f1.id, 20); + tester.add_to_family(f2.id, 5); + tester.add_to_family(f2.id, 15); + + let p1 = query_all_past_invitations_paged(tester.deps(), None, Some(2)).unwrap(); + let pairs: Vec<_> = p1 + .invitations + .iter() + .map(|p| (p.invitation.family_id, p.invitation.node_id)) + .collect(); + assert_eq!(pairs, vec![(f1.id, 10), (f1.id, 20)]); + assert_eq!(p1.start_next_after, Some(((f1.id, 20), 0))); + + let p2 = query_all_past_invitations_paged(tester.deps(), p1.start_next_after, Some(2)) + .unwrap(); + let pairs: Vec<_> = p2 + .invitations + .iter() + .map(|p| (p.invitation.family_id, p.invitation.node_id)) + .collect(); + assert_eq!(pairs, vec![(f2.id, 5), (f2.id, 15)]); + + let p3 = query_all_past_invitations_paged(tester.deps(), p2.start_next_after, Some(2)) + .unwrap(); + assert!(p3.invitations.is_empty()); + assert!(p3.start_next_after.is_none()); + } + + #[test] + fn per_pair_counter_disambiguates_repeat_archive_entries() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + // node 42 joins and leaves twice β€” two Accepted entries for (f, 42) with counters 0 and 1 + for _ in 0..2 { + tester.add_to_family(f.id, 42); + tester.remove_from_family(42); + } + + let res = query_all_past_invitations_paged(tester.deps(), None, None).unwrap(); + assert_eq!(res.invitations.len(), 2); + assert_eq!(res.start_next_after, Some(((f.id, 42), 1))); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let total = retrieval_limits::PAST_INVITATIONS_MAX_LIMIT + 5; + tester.add_n_family_members(f.id, total); + + let res = + query_all_past_invitations_paged(tester.deps(), None, Some(u32::MAX)).unwrap(); + assert_eq!( + res.invitations.len(), + retrieval_limits::PAST_INVITATIONS_MAX_LIMIT as usize + ); + } + } + + #[cfg(test)] + mod pending_invitations_for_node_paged { + use super::*; + + #[test] + fn empty_when_node_has_no_pending_invitations() { + let tester = init_contract_tester(); + let env = tester.env(); + + let res = query_pending_invitations_for_node_paged(tester.deps(), env, 42, None, None) + .unwrap(); + assert_eq!(res.node_id, 42); + assert!(res.invitations.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn returns_only_invitations_for_requested_node() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.invite_to_family(f1.id, 10); + tester.invite_to_family(f1.id, 11); + tester.invite_to_family(f2.id, 10); + + let env = tester.env(); + let res = query_pending_invitations_for_node_paged(tester.deps(), env, 10, None, None) + .unwrap(); + let pairs: Vec<_> = res + .invitations + .iter() + .map(|d| (d.invitation.family_id, d.invitation.node_id)) + .collect(); + assert_eq!(pairs, vec![(f1.id, 10), (f2.id, 10)]); + } + + #[test] + fn ordered_by_ascending_family_id() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + // out-of-order inserts + tester.invite_to_family(f3.id, 7); + tester.invite_to_family(f1.id, 7); + tester.invite_to_family(f2.id, 7); + + let env = tester.env(); + let res = query_pending_invitations_for_node_paged(tester.deps(), env, 7, None, None) + .unwrap(); + let ids: Vec<_> = res + .invitations + .iter() + .map(|d| d.invitation.family_id) + .collect(); + assert_eq!(ids, vec![f1.id, f2.id, f3.id]); + } + + #[test] + fn paginates_with_family_id_cursor() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + tester.invite_to_family(f1.id, 7); + tester.invite_to_family(f2.id, 7); + tester.invite_to_family(f3.id, 7); + + let env = tester.env(); + let p1 = query_pending_invitations_for_node_paged( + tester.deps(), + env.clone(), + 7, + None, + Some(2), + ) + .unwrap(); + let ids: Vec<_> = p1 + .invitations + .iter() + .map(|d| d.invitation.family_id) + .collect(); + assert_eq!(ids, vec![f1.id, f2.id]); + assert_eq!(p1.start_next_after, Some(f2.id)); + + let p2 = query_pending_invitations_for_node_paged( + tester.deps(), + env.clone(), + 7, + p1.start_next_after, + Some(2), + ) + .unwrap(); + let ids: Vec<_> = p2 + .invitations + .iter() + .map(|d| d.invitation.family_id) + .collect(); + assert_eq!(ids, vec![f3.id]); + assert_eq!(p2.start_next_after, Some(f3.id)); + + let p3 = query_pending_invitations_for_node_paged( + tester.deps(), + env, + 7, + p2.start_next_after, + Some(2), + ) + .unwrap(); + assert!(p3.invitations.is_empty()); + assert!(p3.start_next_after.is_none()); + } + + #[test] + fn flags_expired_against_current_block_time() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let now = tester.env().block.time.seconds(); + tester.invite_to_family_with_expiration(f1.id, 7, now + 5); + tester.invite_to_family_with_expiration(f2.id, 7, now + 1000); + + tester.advance_time_by(60); + let env = tester.env(); + let res = query_pending_invitations_for_node_paged(tester.deps(), env, 7, None, None) + .unwrap(); + let by_family: std::collections::HashMap<_, _> = res + .invitations + .iter() + .map(|d| (d.invitation.family_id, d.expired)) + .collect(); + assert!(by_family[&f1.id]); + assert!(!by_family[&f2.id]); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let total = retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT + 5; + for _ in 0..total { + let f = tester.add_dummy_family(); + tester.invite_to_family(f.id, 7); + } + + let env = tester.env(); + let res = query_pending_invitations_for_node_paged( + tester.deps(), + env, + 7, + None, + Some(u32::MAX), + ) + .unwrap(); + assert_eq!( + res.invitations.len(), + retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT as usize + ); + } + } + + #[cfg(test)] + mod past_invitations_for_node_paged { + use super::*; + use nym_node_families_contract_common::FamilyInvitationStatus; + + #[test] + fn empty_when_node_has_no_archive_entries() { + let tester = init_contract_tester(); + + let res = query_past_invitations_for_node_paged(tester.deps(), 42, None, None).unwrap(); + assert_eq!(res.node_id, 42); + assert!(res.invitations.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn returns_only_archives_for_requested_node() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 7); + tester.add_to_family(f1.id, 8); + tester.add_to_family(f2.id, 7); + + let res = query_past_invitations_for_node_paged(tester.deps(), 7, None, None).unwrap(); + assert_eq!(res.invitations.len(), 2); + for entry in &res.invitations { + assert_eq!(entry.invitation.node_id, 7); + } + } + + #[test] + fn covers_all_terminal_statuses() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + // Accepted in f1 + tester.add_to_family(f1.id, 7); + // Rejected in f2 + tester.invite_to_family(f2.id, 7); + tester.reject_invitation(f2.id, 7); + // Revoked in f3 + tester.invite_to_family(f3.id, 7); + tester.revoke_invitation(f3.id, 7); + + let res = query_past_invitations_for_node_paged(tester.deps(), 7, None, None).unwrap(); + let by_family: std::collections::HashMap<_, _> = res + .invitations + .iter() + .map(|p| (p.invitation.family_id, p.status.clone())) + .collect(); + assert!(matches!( + by_family[&f1.id], + FamilyInvitationStatus::Accepted { .. } + )); + assert!(matches!( + by_family[&f2.id], + FamilyInvitationStatus::Rejected { .. } + )); + assert!(matches!( + by_family[&f3.id], + FamilyInvitationStatus::Revoked { .. } + )); + } + + #[test] + fn ordered_by_family_id_then_counter() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + // node 7 joins and leaves f1 twice β€” counters 0 and 1 in f1 + for _ in 0..2 { + tester.add_to_family(f1.id, 7); + tester.remove_from_family(7); + } + // one Accepted in f2 + tester.add_to_family(f2.id, 7); + + let res = query_past_invitations_for_node_paged(tester.deps(), 7, None, None).unwrap(); + let pairs: Vec<_> = res + .invitations + .iter() + .map(|p| p.invitation.family_id) + .collect(); + assert_eq!(pairs, vec![f1.id, f1.id, f2.id]); + } + + #[test] + fn paginates_with_family_counter_cursor() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 7); + tester.add_to_family(f2.id, 7); + tester.add_to_family(f3.id, 7); + + let p1 = + query_past_invitations_for_node_paged(tester.deps(), 7, None, Some(2)).unwrap(); + let ids: Vec<_> = p1 + .invitations + .iter() + .map(|p| p.invitation.family_id) + .collect(); + assert_eq!(ids, vec![f1.id, f2.id]); + assert_eq!(p1.start_next_after, Some((f2.id, 0))); + + let p2 = query_past_invitations_for_node_paged( + tester.deps(), + 7, + p1.start_next_after, + Some(2), + ) + .unwrap(); + let ids: Vec<_> = p2 + .invitations + .iter() + .map(|p| p.invitation.family_id) + .collect(); + assert_eq!(ids, vec![f3.id]); + assert_eq!(p2.start_next_after, Some((f3.id, 0))); + + let p3 = query_past_invitations_for_node_paged( + tester.deps(), + 7, + p2.start_next_after, + Some(2), + ) + .unwrap(); + assert!(p3.invitations.is_empty()); + assert!(p3.start_next_after.is_none()); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let total = retrieval_limits::PAST_INVITATIONS_MAX_LIMIT + 5; + for _ in 0..total { + let f = tester.add_dummy_family(); + tester.add_to_family(f.id, 7); + tester.remove_from_family(7); + } + + let res = query_past_invitations_for_node_paged(tester.deps(), 7, None, Some(u32::MAX)) + .unwrap(); + assert_eq!( + res.invitations.len(), + retrieval_limits::PAST_INVITATIONS_MAX_LIMIT as usize + ); + } + } + + #[cfg(test)] + mod past_members_for_family_paged { + use super::*; + + #[test] + fn empty_when_no_archive_entries() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + let res = query_past_members_for_family_paged(tester.deps(), f.id, None, None).unwrap(); + assert_eq!(res.family_id, f.id); + assert!(res.members.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn empty_for_unknown_family_id() { + let tester = init_contract_tester(); + + let res = query_past_members_for_family_paged(tester.deps(), 99, None, None).unwrap(); + assert_eq!(res.family_id, 99); + assert!(res.members.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn returns_only_archives_from_requested_family() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 10); + tester.remove_from_family(10); + tester.add_to_family(f2.id, 20); + tester.remove_from_family(20); + + let res = + query_past_members_for_family_paged(tester.deps(), f1.id, None, None).unwrap(); + assert_eq!(res.members.len(), 1); + assert_eq!(res.members[0].family_id, f1.id); + assert_eq!(res.members[0].node_id, 10); + } + + #[test] + fn record_carries_removed_at_timestamp() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + tester.add_to_family(f.id, 42); + let removed_at = tester.env().block.time.seconds(); + tester.remove_from_family(42); + + let res = query_past_members_for_family_paged(tester.deps(), f.id, None, None).unwrap(); + let entry = res.members.into_iter().next().unwrap(); + assert_eq!(entry.family_id, f.id); + assert_eq!(entry.node_id, 42); + assert_eq!(entry.removed_at, removed_at); + } + + #[test] + fn ordered_by_node_id_then_counter() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + // node 42 joins/leaves twice β€” counters 0 and 1 + for _ in 0..2 { + tester.add_to_family(f.id, 42); + tester.remove_from_family(42); + } + // node 7 once + tester.add_to_family(f.id, 7); + tester.remove_from_family(7); + + let res = query_past_members_for_family_paged(tester.deps(), f.id, None, None).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + // 7 comes before 42; 42 appears twice + assert_eq!(ids, vec![7, 42, 42]); + } + + #[test] + fn paginates_with_node_counter_cursor() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + for n in [10, 11, 12] { + tester.add_to_family(f.id, n); + tester.remove_from_family(n); + } + + let p1 = + query_past_members_for_family_paged(tester.deps(), f.id, None, Some(2)).unwrap(); + let ids: Vec<_> = p1.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![10, 11]); + assert_eq!(p1.start_next_after, Some((11, 0))); + + let p2 = query_past_members_for_family_paged( + tester.deps(), + f.id, + p1.start_next_after, + Some(2), + ) + .unwrap(); + let ids: Vec<_> = p2.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![12]); + assert_eq!(p2.start_next_after, Some((12, 0))); + + let p3 = query_past_members_for_family_paged( + tester.deps(), + f.id, + p2.start_next_after, + Some(2), + ) + .unwrap(); + assert!(p3.members.is_empty()); + assert!(p3.start_next_after.is_none()); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let total = retrieval_limits::PAST_MEMBERS_MAX_LIMIT + 5; + for n in 1..=total { + tester.add_to_family(f.id, n); + tester.remove_from_family(n); + } + + let res = + query_past_members_for_family_paged(tester.deps(), f.id, None, Some(u32::MAX)) + .unwrap(); + assert_eq!( + res.members.len(), + retrieval_limits::PAST_MEMBERS_MAX_LIMIT as usize + ); + } + + #[test] + fn start_after_none_does_not_leak_earlier_families() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 10); + tester.add_to_family(f1.id, 11); + tester.remove_from_family(10); + tester.remove_from_family(11); + + let res = + query_past_members_for_family_paged(tester.deps(), f2.id, None, None).unwrap(); + + assert_eq!(res.family_id, f2.id); + assert!( + res.members.is_empty(), + "expected no entries for f2 but got: {:?}", + res.members + .iter() + .map(|m| (m.family_id, m.node_id)) + .collect::>() + ); + assert!(res.start_next_after.is_none()); + } + } + + #[cfg(test)] + mod past_members_for_node_paged { + use super::*; + + #[test] + fn empty_when_node_has_no_archive_entries() { + let tester = init_contract_tester(); + + let res = query_past_members_for_node_paged(tester.deps(), 42, None, None).unwrap(); + assert_eq!(res.node_id, 42); + assert!(res.members.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn returns_only_archives_for_requested_node() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 7); + tester.add_to_family(f1.id, 8); + tester.add_to_family(f2.id, 7); + tester.remove_from_family(7); + tester.remove_from_family(8); + + let res = query_past_members_for_node_paged(tester.deps(), 7, None, None).unwrap(); + assert_eq!(res.members.len(), 1); + for entry in &res.members { + assert_eq!(entry.node_id, 7); + } + } + + #[test] + fn record_carries_removed_at_timestamp() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + tester.add_to_family(f.id, 42); + let removed_at = tester.env().block.time.seconds(); + tester.remove_from_family(42); + + let res = query_past_members_for_node_paged(tester.deps(), 42, None, None).unwrap(); + let entry = res.members.into_iter().next().unwrap(); + assert_eq!(entry.family_id, f.id); + assert_eq!(entry.node_id, 42); + assert_eq!(entry.removed_at, removed_at); + } + + #[test] + fn ordered_by_family_id_then_counter() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + // node 7 joins/leaves f1 twice β€” counters 0 and 1 in f1 + for _ in 0..2 { + tester.add_to_family(f1.id, 7); + tester.remove_from_family(7); + } + // one record in f2 + tester.add_to_family(f2.id, 7); + tester.remove_from_family(7); + + let res = query_past_members_for_node_paged(tester.deps(), 7, None, None).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.family_id).collect(); + assert_eq!(ids, vec![f1.id, f1.id, f2.id]); + } + + #[test] + fn paginates_with_family_counter_cursor() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 7); + tester.remove_from_family(7); + tester.add_to_family(f2.id, 7); + tester.remove_from_family(7); + tester.add_to_family(f3.id, 7); + tester.remove_from_family(7); + + let p1 = query_past_members_for_node_paged(tester.deps(), 7, None, Some(2)).unwrap(); + let ids: Vec<_> = p1.members.iter().map(|m| m.family_id).collect(); + assert_eq!(ids, vec![f1.id, f2.id]); + assert_eq!(p1.start_next_after, Some((f2.id, 0))); + + let p2 = + query_past_members_for_node_paged(tester.deps(), 7, p1.start_next_after, Some(2)) + .unwrap(); + let ids: Vec<_> = p2.members.iter().map(|m| m.family_id).collect(); + assert_eq!(ids, vec![f3.id]); + assert_eq!(p2.start_next_after, Some((f3.id, 0))); + + let p3 = + query_past_members_for_node_paged(tester.deps(), 7, p2.start_next_after, Some(2)) + .unwrap(); + assert!(p3.members.is_empty()); + assert!(p3.start_next_after.is_none()); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let total = retrieval_limits::PAST_MEMBERS_MAX_LIMIT + 5; + for _ in 0..total { + let f = tester.add_dummy_family(); + tester.add_to_family(f.id, 7); + tester.remove_from_family(7); + } + + let res = + query_past_members_for_node_paged(tester.deps(), 7, None, Some(u32::MAX)).unwrap(); + assert_eq!( + res.members.len(), + retrieval_limits::PAST_MEMBERS_MAX_LIMIT as usize + ); + } + } +} diff --git a/contracts/node-families/src/queued_migrations.rs b/contracts/node-families/src/queued_migrations.rs new file mode 100644 index 0000000000..bdc05451cb --- /dev/null +++ b/contracts/node-families/src/queued_migrations.rs @@ -0,0 +1,2 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only diff --git a/contracts/node-families/src/storage/mod.rs b/contracts/node-families/src/storage/mod.rs new file mode 100644 index 0000000000..62ae93c82d --- /dev/null +++ b/contracts/node-families/src/storage/mod.rs @@ -0,0 +1,1386 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +// storage will be used in subsequent PRs/tickets +#![allow(dead_code)] + +use crate::storage::storage_indexes::{ + FamilyMembersIndex, NodeFamiliesIndex, NodeFamilyInvitationIndex, PastFamilyInvitationsIndex, + PastFamilyMembersIndex, +}; +use cosmwasm_std::{Addr, Coin, DepsMut, Env, Order, StdResult, Storage}; +use cw_controllers::Admin; +use cw_storage_plus::{IndexedMap, Item, Map}; +use nym_mixnet_contract_common::NodeId; +use nym_node_families_contract_common::constants::storage_keys; +use nym_node_families_contract_common::{ + Config, FamilyInvitation, FamilyInvitationStatus, FamilyMembership, NodeFamiliesContractError, + NodeFamily, NodeFamilyId, PastFamilyInvitation, PastFamilyMember, +}; + +pub(crate) mod retrieval_limits; +mod storage_indexes; + +/// Composite primary key for the invitation / past-member maps: +/// `(family id, node id)`. Only one pending invitation can exist for a given +/// `(family, node)` pair at a time. +pub(crate) type FamilyMember = (NodeFamilyId, NodeId); + +/// Container for every storage handle used by the contract. +/// +/// Constructed once via [`NodeFamiliesStorage::new`] and accessed through a +/// `lazy_static`-style singleton in the entry point modules. +pub struct NodeFamiliesStorage<'a> { + /// Admin of the contract; gates privileged operations. + pub(crate) contract_admin: Admin, + + /// Runtime configuration (fees, length limits) persisted at instantiation + /// and consulted by transaction handlers. + pub(crate) config: Item, + + /// Address of the mixnet contract; used to verify a node id refers to a + /// real, registered node. + pub(crate) mixnet_contract_address: Item, + + /// Monotonically increasing id assigned to every newly created family. + /// Ids start at `1` (see [`NodeFamiliesStorage::next_family_id`]); `0` is + /// reserved as a "no family" sentinel. + pub(crate) node_family_id_counter: Item, + + /// All existing families, keyed by id, with unique secondary indexes on + /// `owner` (one-family-per-owner-address) and on `normalised_name` + /// (family names are globally unique under their normalised form, so + /// `"MyFamily"` and `"myfamily"` collide while the original `name` field + /// preserves the user-submitted formatting). + pub(crate) families: IndexedMap>, + + /// Current family membership records, keyed by [`NodeId`]. A node + /// belongs to at most one family at a time, so the PK is the node id. + /// A `family` multi-index enables paginated listing of all nodes + /// belonging to a given family. + pub(crate) family_members: IndexedMap>, + + /// Currently outstanding family invitations, indexed by both family id + /// and node id (a single node can simultaneously hold invitations from + /// multiple families). + pub(crate) pending_family_invitations: + IndexedMap>, + + // ##### historical data ##### + // + // The two maps below archive terminal events. The trailing `u64` in the + // composite key is a per-`(family, node)` counter β€” a node can be removed + // from (or rejected by) the same family more than once, and we cannot use + // the block timestamp to disambiguate because multiple txs may share a + // block. + /// Archive of family memberships that have ended (kicked, left, or family + /// disbanded). Key: `((family_id, node_id), counter)`. + pub(crate) past_family_members: + IndexedMap<(FamilyMember, u64), PastFamilyMember, PastFamilyMembersIndex<'a>>, + + /// Per-`(family, node)` counter for the [`Self::past_family_members`] + /// archive β€” yields the next free `counter` slot when archiving a new + /// past-membership record. Stored explicitly (rather than derived via + /// range scan) to keep archival writes O(1). + pub(crate) past_family_member_counter: Map, + + /// Archive of invitations that reached a terminal `Accepted` / `Rejected` + /// / `Revoked` state. Timed-out invitations are **not** archived here β€” + /// there is no background process that sweeps expired entries out of + /// [`Self::pending_family_invitations`]. + pub(crate) past_family_invitations: + IndexedMap<(FamilyMember, u64), PastFamilyInvitation, PastFamilyInvitationsIndex<'a>>, + + /// Per-`(family, node)` counter for the [`Self::past_family_invitations`] + /// archive β€” yields the next free `counter` slot when archiving a + /// terminal invitation event. + pub(crate) past_family_invitation_counter: Map, +} + +impl NodeFamiliesStorage<'_> { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + NodeFamiliesStorage { + contract_admin: Admin::new(storage_keys::CONTRACT_ADMIN), + config: Item::new(storage_keys::CONFIG), + mixnet_contract_address: Item::new(storage_keys::MIXNET_CONTRACT_ADDRESS), + node_family_id_counter: Item::new(storage_keys::NODE_FAMILY_ID_COUNTER), + families: IndexedMap::new(storage_keys::FAMILIES_NAMESPACE, NodeFamiliesIndex::new()), + family_members: IndexedMap::new( + storage_keys::NODE_FAMILY_MEMBERS, + FamilyMembersIndex::new(), + ), + pending_family_invitations: IndexedMap::new( + storage_keys::INVITATIONS_NAMESPACE, + NodeFamilyInvitationIndex::new(), + ), + past_family_members: IndexedMap::new( + storage_keys::PAST_FAMILY_MEMBER_NAMESPACE, + PastFamilyMembersIndex::new(), + ), + past_family_member_counter: Map::new( + storage_keys::PAST_FAMILY_MEMBER_COUNTER_NAMESPACE, + ), + past_family_invitations: IndexedMap::new( + storage_keys::PAST_INVITATIONS_NAMESPACE, + PastFamilyInvitationsIndex::new(), + ), + past_family_invitation_counter: Map::new( + storage_keys::PAST_INVITATIONS_COUNTER_NAMESPACE, + ), + } + } + + /// One-time storage initialisation called from the contract's `instantiate` + /// entry point. Persists the runtime [`Config`] and mixnet contract + /// address, and sets `sender` as the contract admin. + pub(crate) fn initialise( + &self, + deps: DepsMut, + sender: Addr, + mixnet_contract_address: Addr, + config: Config, + ) -> Result<(), NodeFamiliesContractError> { + self.config.save(deps.storage, &config)?; + self.mixnet_contract_address + .save(deps.storage, &mixnet_contract_address)?; + + self.contract_admin.set(deps, Some(sender))?; + Ok(()) + } + + /// Allocate the next [`NodeFamilyId`] and persist the bumped counter. + /// + /// Ids are issued starting from `1`; `0` is reserved as a "no family" + /// sentinel value and must never be assigned to a real family. + pub(crate) fn next_family_id( + &self, + store: &mut dyn Storage, + ) -> Result { + let next_id = self + .node_family_id_counter + .may_load(store)? + .unwrap_or_default() + + 1; + self.node_family_id_counter.save(store, &next_id)?; + Ok(next_id) + } + + /// Allocate the next free archive slot for the [`Self::past_family_invitations`] + /// map under the given `(family, node)` key, and persist the bumped counter. + /// + /// Slots are issued starting from `0` and increase by 1 on every call. + pub(crate) fn next_past_invitation_counter( + &self, + store: &mut dyn Storage, + key: FamilyMember, + ) -> Result { + let counter = self + .past_family_invitation_counter + .may_load(store, key)? + .unwrap_or_default(); + self.past_family_invitation_counter + .save(store, key, &(counter + 1))?; + Ok(counter) + } + + /// Allocate the next free archive slot for the [`Self::past_family_members`] + /// map under the given `(family, node)` key, and persist the bumped counter. + /// + /// Slots are issued starting from `0` and increase by 1 on every call. + pub(crate) fn next_past_member_counter( + &self, + store: &mut dyn Storage, + key: FamilyMember, + ) -> Result { + let counter = self + .past_family_member_counter + .may_load(store, key)? + .unwrap_or_default(); + self.past_family_member_counter + .save(store, key, &(counter + 1))?; + Ok(counter) + } + + /// Persist a brand-new family in storage. + /// + /// Assigns a fresh [`NodeFamilyId`], stamps `created_at` from `env` + /// (unix seconds) and starts the membership counter at `0` β€” the owner + /// is **not** counted as a member. + /// + /// The caller (a transaction handler) is responsible for: + /// - validating `name`, `description` and `owner`; + /// - computing `normalised_name` from `name` (e.g. via + /// [`crate::helpers::normalise_family_name`]) β€” the unique-name index + /// keys on this field, so it is what enforces global uniqueness; + /// - ensuring `owner` does not already own a family **and** is not + /// currently a member of one. + /// + /// Returns the freshly persisted [`NodeFamily`]. The underlying + /// `IndexedMap` enforces the one-family-per-owner and unique-name + /// invariants via unique indexes on `owner` and `normalised_name` as a + /// defence-in-depth check, so this call will fail if either is already + /// taken β€” but the caller must not rely on it for the membership check. + #[allow(clippy::too_many_arguments)] + pub(crate) fn register_new_family( + &self, + store: &mut dyn Storage, + env: &Env, + fee: Coin, + owner: Addr, + name: String, + normalised_name: String, + description: String, + ) -> Result { + let id = self.next_family_id(store)?; + let family = NodeFamily { + id, + name, + normalised_name, + description, + owner, + paid_fee: fee, + members: 0, + created_at: env.block.time.seconds(), + }; + self.families.save(store, id, &family)?; + Ok(family) + } + + /// Persist a new pending invitation for `node_id` to join `family_id`. + /// + /// `expires_at` is taken as a unix-seconds absolute deadline (the caller + /// is expected to compute it from the current block time plus the + /// configured invitation duration). + /// + /// The caller (a transaction handler) is responsible for: + /// - verifying that `family_id` exists and that the transaction sender + /// is its owner; + /// - verifying that `node_id` refers to a real, registered node; + /// - ensuring `node_id` is not already a member of any family; + /// - ensuring `expires_at` is strictly in the future. + /// + /// As defence-in-depth, this method errors with [`FamilyNotFound`] if + /// `family_id` is unknown and with [`PendingInvitationAlreadyExists`] if + /// a pending invitation for the same `(family, node)` pair is already + /// stored β€” the underlying `IndexedMap` would otherwise silently + /// overwrite it. + /// + /// Returns the freshly persisted [`FamilyInvitation`]. + /// + /// [`FamilyNotFound`]: NodeFamiliesContractError::FamilyNotFound + /// [`PendingInvitationAlreadyExists`]: NodeFamiliesContractError::PendingInvitationAlreadyExists + pub(crate) fn add_pending_invitation( + &self, + store: &mut dyn Storage, + family_id: NodeFamilyId, + node_id: NodeId, + expires_at: u64, + ) -> Result { + let key: FamilyMember = (family_id, node_id); + + if !self.families.has(store, family_id) { + return Err(NodeFamiliesContractError::FamilyNotFound { family_id }); + } + + if self + .pending_family_invitations + .may_load(store, key)? + .is_some() + { + return Err(NodeFamiliesContractError::PendingInvitationAlreadyExists { + family_id, + node_id, + }); + } + + let invitation = FamilyInvitation { + family_id, + node_id, + expires_at, + }; + self.pending_family_invitations + .save(store, key, &invitation)?; + Ok(invitation) + } + + /// Accept a pending invitation for `node_id` to join `family_id`. + /// + /// Performs the full storage transition atomically: + /// 1. loads the pending invitation (errors with [`InvitationNotFound`] if + /// none exists for the given pair); + /// 2. verifies it has not expired (`now < expires_at`, errors with + /// [`InvitationExpired`] otherwise); + /// 3. removes it from the pending map; + /// 4. records `node_id -> family_id` in [`Self::family_members`]; + /// 5. increments the family's `members` counter (errors with + /// [`FamilyNotFound`] if the family has somehow been removed); + /// 6. archives the invitation in [`Self::past_family_invitations`] with + /// status [`FamilyInvitationStatus::Accepted`], using the next free + /// per-`(family, node)` counter. + /// + /// The caller is responsible for verifying that `node_id` is owned by + /// the transaction sender and is not already a member of any family. + /// + /// Returns the updated [`NodeFamily`] (with the bumped `members` count). + /// + /// [`InvitationNotFound`]: NodeFamiliesContractError::InvitationNotFound + /// [`InvitationExpired`]: NodeFamiliesContractError::InvitationExpired + /// [`FamilyNotFound`]: NodeFamiliesContractError::FamilyNotFound + pub(crate) fn accept_invitation( + &self, + store: &mut dyn Storage, + env: &Env, + family_id: NodeFamilyId, + node_id: NodeId, + ) -> Result { + let now = env.block.time.seconds(); + let key: FamilyMember = (family_id, node_id); + + let invitation = self + .pending_family_invitations + .may_load(store, key)? + .ok_or(NodeFamiliesContractError::InvitationNotFound { family_id, node_id })?; + + if now >= invitation.expires_at { + return Err(NodeFamiliesContractError::InvitationExpired { + family_id, + node_id, + expires_at: invitation.expires_at, + now, + }); + } + + self.pending_family_invitations.remove(store, key)?; + + self.family_members.save( + store, + node_id, + &FamilyMembership { + family_id, + joined_at: now, + }, + )?; + + let mut family = self + .families + .may_load(store, family_id)? + .ok_or(NodeFamiliesContractError::FamilyNotFound { family_id })?; + family.members += 1; + self.families.save(store, family_id, &family)?; + + let counter = self.next_past_invitation_counter(store, key)?; + self.past_family_invitations.save( + store, + (key, counter), + &PastFamilyInvitation { + invitation, + status: FamilyInvitationStatus::Accepted { at: now }, + }, + )?; + + Ok(family) + } + + /// Reject a pending invitation for `node_id` from `family_id`. + /// + /// Invitee-side counterpart to [`Self::revoke_pending_invitation`]: + /// removes the invitation from [`Self::pending_family_invitations`] and + /// archives it in [`Self::past_family_invitations`] with status + /// [`FamilyInvitationStatus::Rejected`], using the next free + /// per-`(family, node)` counter. Errors with [`InvitationNotFound`] if + /// no pending invitation exists for the given pair. + /// + /// Works regardless of whether the invitation has expired. + /// + /// The caller is responsible for verifying that the transaction sender + /// is the controller of `node_id`. + /// + /// Returns the rejected [`FamilyInvitation`]. + /// + /// [`InvitationNotFound`]: NodeFamiliesContractError::InvitationNotFound + pub(crate) fn reject_pending_invitation( + &self, + store: &mut dyn Storage, + env: &Env, + family_id: NodeFamilyId, + node_id: NodeId, + ) -> Result { + let now = env.block.time.seconds(); + let key: FamilyMember = (family_id, node_id); + + let invitation = self + .pending_family_invitations + .may_load(store, key)? + .ok_or(NodeFamiliesContractError::InvitationNotFound { family_id, node_id })?; + + self.pending_family_invitations.remove(store, key)?; + + let counter = self.next_past_invitation_counter(store, key)?; + self.past_family_invitations.save( + store, + (key, counter), + &PastFamilyInvitation { + invitation: invitation.clone(), + status: FamilyInvitationStatus::Rejected { at: now }, + }, + )?; + + Ok(invitation) + } + + /// Revoke a pending invitation for `node_id` from `family_id`. + /// + /// Removes the invitation from [`Self::pending_family_invitations`] and + /// archives it in [`Self::past_family_invitations`] with status + /// [`FamilyInvitationStatus::Revoked`], using the next free + /// per-`(family, node)` counter. Errors with [`InvitationNotFound`] if + /// no pending invitation exists for the given pair. + /// + /// Works regardless of whether the invitation has expired β€” this is the + /// only path that can clean expired entries out of the pending map, since + /// no background sweeper exists. + /// + /// The caller is responsible for verifying that the transaction sender + /// is the owner of `family_id`. + /// + /// Returns the revoked [`FamilyInvitation`]. + /// + /// [`InvitationNotFound`]: NodeFamiliesContractError::InvitationNotFound + pub(crate) fn revoke_pending_invitation( + &self, + store: &mut dyn Storage, + env: &Env, + family_id: NodeFamilyId, + node_id: NodeId, + ) -> Result { + let now = env.block.time.seconds(); + let key: FamilyMember = (family_id, node_id); + + let invitation = self + .pending_family_invitations + .may_load(store, key)? + .ok_or(NodeFamiliesContractError::InvitationNotFound { family_id, node_id })?; + + self.pending_family_invitations.remove(store, key)?; + + let counter = self.next_past_invitation_counter(store, key)?; + self.past_family_invitations.save( + store, + (key, counter), + &PastFamilyInvitation { + invitation: invitation.clone(), + status: FamilyInvitationStatus::Revoked { at: now }, + }, + )?; + + Ok(invitation) + } + + /// Remove `node_id` from whichever family it currently belongs to. + /// + /// Shared storage path for both routes that drop a member: + /// - **kick** β€” invoked by the family owner against another node; + /// - **leave** β€” invoked by the node's own controller. + /// + /// Looks up the node's family via [`Self::family_members`] (errors with + /// [`NodeNotInFamily`] if the node has no membership record), removes + /// the membership entry, decrements the family's `members` counter + /// (saturating at `0` as defence-in-depth β€” a underflow would indicate + /// an invariant break elsewhere), and archives a [`PastFamilyMember`] + /// record stamped with `removed_at = env.block.time.seconds()` using + /// the next per-`(family, node)` archive slot. + /// + /// The caller is responsible for verifying that the transaction sender + /// is authorised to remove this node β€” either as the family owner + /// (kick) or as the node's controller (leave). + /// + /// Returns the updated [`NodeFamily`] (with the decremented `members` + /// count). Errors with [`FamilyNotFound`] if the node's family has + /// somehow been removed. + /// + /// [`NodeNotInFamily`]: NodeFamiliesContractError::NodeNotInFamily + /// [`FamilyNotFound`]: NodeFamiliesContractError::FamilyNotFound + pub(crate) fn remove_family_member( + &self, + store: &mut dyn Storage, + env: &Env, + node_id: NodeId, + ) -> Result { + let now = env.block.time.seconds(); + + let family_id = self + .family_members + .may_load(store, node_id)? + .ok_or(NodeFamiliesContractError::NodeNotInFamily { node_id })? + .family_id; + + self.family_members.remove(store, node_id)?; + + let mut family = self + .families + .may_load(store, family_id)? + .ok_or(NodeFamiliesContractError::FamilyNotFound { family_id })?; + family.members = family.members.saturating_sub(1); + self.families.save(store, family_id, &family)?; + + let key: FamilyMember = (family_id, node_id); + let counter = self.next_past_member_counter(store, key)?; + self.past_family_members.save( + store, + (key, counter), + &PastFamilyMember { + family_id, + node_id, + removed_at: now, + }, + )?; + + Ok(family) + } + + /// Apply the family-side cleanup triggered when `node_id` initiates + /// unbonding from the mixnet contract. + /// + /// Idempotent over the membership half: drops the node's [`FamilyMembership`] + /// record (decrementing the family's `members` count and archiving a + /// [`PastFamilyMember`]) iff such a record exists, otherwise leaves the + /// state untouched. Then sweeps every pending invitation addressed to + /// `node_id` (iterating via [`NodeFamilyInvitationIndex::node`]), + /// removing each from the pending map and archiving it as + /// [`FamilyInvitationStatus::Rejected`] at `env.block.time` β€” the + /// auto-cleared invitations share the `Rejected` terminal state with + /// invitations the node controller would have explicitly declined. + /// + /// The caller is responsible for verifying that the transaction sender + /// is the configured mixnet contract address β€” there is no node-side + /// authority for this path and storage cannot tell the difference between + /// the legitimate callback and an arbitrary execute call. + /// + /// Returns the number of pending invitations that were swept (useful for + /// event attributes / telemetry); the membership half is observable via + /// the archived `PastFamilyMember`. + pub(crate) fn handle_node_unbonding( + &self, + store: &mut dyn Storage, + env: &Env, + node_id: NodeId, + ) -> Result { + if self.family_members.may_load(store, node_id)?.is_some() { + self.remove_family_member(store, env, node_id)?; + } + + let now = env.block.time.seconds(); + let pending: Vec<(FamilyMember, FamilyInvitation)> = self + .pending_family_invitations + .idx + .node + .prefix(node_id) + .range(store, None, None, Order::Ascending) + .collect::>>()?; + + let swept = pending.len() as u64; + for (key, invitation) in pending { + self.pending_family_invitations.remove(store, key)?; + let counter = self.next_past_invitation_counter(store, key)?; + self.past_family_invitations.save( + store, + (key, counter), + &PastFamilyInvitation { + invitation, + status: FamilyInvitationStatus::Rejected { at: now }, + }, + )?; + } + + Ok(swept) + } + + /// Disband (delete) `family_id`. + /// + /// Sweeps every still-pending invitation issued by the family + /// (iterating via the `family` multi-index over + /// [`Self::pending_family_invitations`]), removing each from the + /// pending map and archiving it as + /// [`FamilyInvitationStatus::Revoked`] at `env.block.time` β€” disbanding + /// the family is treated as the family withdrawing all of its + /// outstanding invitations. Gas cost therefore scales with the number + /// of leftover invitations; if that becomes a concern, the owner can + /// revoke them manually before disbanding. + /// + /// The caller (a transaction handler) is responsible for: + /// - verifying that the transaction sender is the owner of `family_id`; + /// - verifying that the family has zero current members (errors with + /// [`FamilyNotEmpty`] are raised at the transaction layer, not here) + /// β€” disbanding a family with members would otherwise leak orphaned + /// `FamilyMembership` records pointing at a removed family. + /// + /// Errors with [`FamilyNotFound`] if `family_id` does not exist. + /// Returns the disbanded [`NodeFamily`] (final snapshot) for use in + /// event attributes. + /// + /// [`FamilyNotEmpty`]: NodeFamiliesContractError::FamilyNotEmpty + /// [`FamilyNotFound`]: NodeFamiliesContractError::FamilyNotFound + pub(crate) fn disband_family( + &self, + store: &mut dyn Storage, + env: &Env, + family_id: NodeFamilyId, + ) -> Result { + let now = env.block.time.seconds(); + + let family = self + .families + .may_load(store, family_id)? + .ok_or(NodeFamiliesContractError::FamilyNotFound { family_id })?; + + // collect first, then mutate β€” iterating an IndexedMap while modifying it is unsafe + let pending: Vec<(FamilyMember, FamilyInvitation)> = self + .pending_family_invitations + .idx + .family + .prefix(family_id) + .range(store, None, None, Order::Ascending) + .collect::>>()?; + + for (key, invitation) in pending { + self.pending_family_invitations.remove(store, key)?; + let counter = self.next_past_invitation_counter(store, key)?; + self.past_family_invitations.save( + store, + (key, counter), + &PastFamilyInvitation { + invitation, + status: FamilyInvitationStatus::Revoked { at: now }, + }, + )?; + } + + self.families.remove(store, family_id)?; + + Ok(family) + } + + // helpers + + /// Look up the family owned by `owner` via the unique `owner` index. + /// + /// Returns `Ok(None)` if the address owns no family. The unique index + /// guarantees the lookup is `O(log n)` and that at most one family can + /// ever match. + pub(crate) fn may_get_owned_family( + &self, + storage: &dyn Storage, + owner: &Addr, + ) -> Result, NodeFamiliesContractError> { + Ok(self + .families + .idx + .owner + .item(storage, owner.clone())? + .map(|(_, owned)| owned)) + } + + /// Like [`Self::may_get_owned_family`] but errors with + /// [`SenderDoesntOwnAFamily`] when the address doesn't own one β€” meant + /// for owner-gated execute paths (`disband`, `invite`, …) where the + /// absence of a family is itself an error. + /// + /// [`SenderDoesntOwnAFamily`]: NodeFamiliesContractError::SenderDoesntOwnAFamily + pub(crate) fn must_get_owned_family( + &self, + storage: &dyn Storage, + owner: &Addr, + ) -> Result { + self.may_get_owned_family(storage, owner)?.ok_or_else(|| { + NodeFamiliesContractError::SenderDoesntOwnAFamily { + address: owner.clone(), + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{init_contract_tester, NodeFamiliesContractTesterExt}; + use nym_contracts_common_testing::ContractOpts; + + // ---- counters ---- + + #[test] + fn next_family_id_starts_at_1_and_increments() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + + assert_eq!(s.next_family_id(tester.storage_mut()).unwrap(), 1); + assert_eq!(s.next_family_id(tester.storage_mut()).unwrap(), 2); + assert_eq!(s.next_family_id(tester.storage_mut()).unwrap(), 3); + } + + #[test] + fn past_invitation_counter_starts_at_0_per_key() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let k1: FamilyMember = (1, 100); + let k2: FamilyMember = (2, 100); + + assert_eq!( + s.next_past_invitation_counter(tester.storage_mut(), k1) + .unwrap(), + 0 + ); + assert_eq!( + s.next_past_invitation_counter(tester.storage_mut(), k1) + .unwrap(), + 1 + ); + // independent counter for a different key + assert_eq!( + s.next_past_invitation_counter(tester.storage_mut(), k2) + .unwrap(), + 0 + ); + assert_eq!( + s.next_past_invitation_counter(tester.storage_mut(), k1) + .unwrap(), + 2 + ); + } + + #[test] + fn past_member_counter_starts_at_0_per_key() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let k: FamilyMember = (1, 100); + + assert_eq!( + s.next_past_member_counter(tester.storage_mut(), k).unwrap(), + 0 + ); + assert_eq!( + s.next_past_member_counter(tester.storage_mut(), k).unwrap(), + 1 + ); + } + + // ---- register_new_family ---- + + #[test] + fn register_new_family_persists_with_expected_fields() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let fee = tester.family_fee(); + let env = tester.env(); + let owner = tester.addr_make("alice"); + + let family = s + .register_new_family( + tester.storage_mut(), + &env, + fee, + owner.clone(), + "Fam!".into(), + "fam".into(), + "desc".into(), + ) + .unwrap(); + + assert_eq!(family.id, 1); + assert_eq!(family.owner, owner); + assert_eq!(family.name, "Fam!"); + assert_eq!(family.normalised_name, "fam"); + assert_eq!(family.description, "desc"); + assert_eq!(family.members, 0); + assert_eq!(family.created_at, tester.env().block.time.seconds()); + + let stored = s.families.load(tester.storage(), 1).unwrap(); + assert_eq!(stored, family); + } + + #[test] + fn register_new_family_assigns_sequential_ids() { + let mut tester = init_contract_tester(); + + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + + assert_eq!(f1.id, 1); + assert_eq!(f2.id, 2); + } + + #[test] + fn register_new_family_rejects_duplicate_name() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let bob = tester.addr_make("bob"); + let fee = tester.family_fee(); + + s.register_new_family( + tester.storage_mut(), + &env, + fee.clone(), + alice, + "Shared".into(), + "shared".into(), + "".into(), + ) + .unwrap(); + + // unique-index defence-in-depth check: same normalised_name even though + // the user-submitted `name` differs in casing/punctuation. + let res = s.register_new_family( + tester.storage_mut(), + &env, + fee, + bob, + "$$shared$$".into(), + "shared".into(), + "".into(), + ); + assert!(res.is_err()); + } + + #[test] + fn register_new_family_rejects_duplicate_owner() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let fee = tester.family_fee(); + + tester.make_family(&alice); + + // unique-index defence-in-depth check + let res = s.register_new_family( + tester.storage_mut(), + &env, + fee, + alice, + "second".into(), + "second".into(), + "".into(), + ); + assert!(res.is_err()); + } + + // ---- add_pending_invitation ---- + + #[test] + fn add_pending_invitation_persists() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + let expires_at = tester.env().block.time.seconds() + 100; + + let inv = s + .add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at) + .unwrap(); + + assert_eq!(inv.family_id, f.id); + assert_eq!(inv.node_id, 42); + assert_eq!(inv.expires_at, expires_at); + let stored = s + .pending_family_invitations + .load(tester.storage(), (f.id, 42)) + .unwrap(); + assert_eq!(stored, inv); + } + + #[test] + fn add_pending_invitation_errors_on_unknown_family() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let expires_at = env.block.time.seconds() + 100; + + let res = s.add_pending_invitation(tester.storage_mut(), 99, 42, expires_at); + assert_eq!( + res.unwrap_err(), + NodeFamiliesContractError::FamilyNotFound { family_id: 99 } + ); + } + + #[test] + fn add_pending_invitation_errors_on_duplicate() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + + tester.invite_to_family(f.id, 42); + + let expires_at = env.block.time.seconds() + 200; + let res = s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at); + assert_eq!( + res.unwrap_err(), + NodeFamiliesContractError::PendingInvitationAlreadyExists { + family_id: f.id, + node_id: 42, + } + ); + } + + // ---- accept_invitation ---- + + #[test] + fn accept_invitation_happy_path() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + let expires_at = env.block.time.seconds() + 100; + s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at) + .unwrap(); + + let updated = s + .accept_invitation(tester.storage_mut(), &env, f.id, 42) + .unwrap(); + + assert_eq!(updated.members, 1); + assert!(s + .pending_family_invitations + .may_load(tester.storage(), (f.id, 42)) + .unwrap() + .is_none()); + let membership = s.family_members.load(tester.storage(), 42).unwrap(); + assert_eq!(membership.family_id, f.id); + assert_eq!(membership.joined_at, tester.env().block.time.seconds()); + assert_eq!(s.families.load(tester.storage(), f.id).unwrap().members, 1); + + let past = s + .past_family_invitations + .load(tester.storage(), ((f.id, 42), 0)) + .unwrap(); + assert_eq!( + past.status, + FamilyInvitationStatus::Accepted { + at: tester.env().block.time.seconds() + } + ); + assert_eq!(past.invitation.family_id, f.id); + assert_eq!(past.invitation.node_id, 42); + } + + #[test] + fn accept_invitation_errors_when_no_pending() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + + let res = s.accept_invitation(tester.storage_mut(), &env, 1, 42); + assert_eq!( + res.unwrap_err(), + NodeFamiliesContractError::InvitationNotFound { + family_id: 1, + node_id: 42, + } + ); + } + + #[test] + fn accept_invitation_errors_when_expired() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + // expires at exactly `now` β€” `now >= expires_at` triggers + let expires_at = tester.env().block.time.seconds(); + s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at) + .unwrap(); + + let res = s.accept_invitation(tester.storage_mut(), &env, f.id, 42); + assert_eq!( + res.unwrap_err(), + NodeFamiliesContractError::InvitationExpired { + family_id: f.id, + node_id: 42, + expires_at, + now: tester.env().block.time.seconds(), + } + ); + } + + // ---- reject_pending_invitation ---- + + #[test] + fn reject_invitation_happy_path() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + tester.invite_to_family(f.id, 42); + + let inv = s + .reject_pending_invitation(tester.storage_mut(), &env, f.id, 42) + .unwrap(); + assert_eq!(inv.node_id, 42); + assert!(s + .pending_family_invitations + .may_load(tester.storage(), (f.id, 42)) + .unwrap() + .is_none()); + let past = s + .past_family_invitations + .load(tester.storage(), ((f.id, 42), 0)) + .unwrap(); + assert_eq!( + past.status, + FamilyInvitationStatus::Rejected { + at: tester.env().block.time.seconds() + } + ); + } + + #[test] + fn reject_invitation_works_on_expired() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + let expires_at = env.block.time.seconds(); + s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at) + .unwrap(); + + s.reject_pending_invitation(tester.storage_mut(), &env, f.id, 42) + .unwrap(); + } + + #[test] + fn reject_invitation_errors_when_no_pending() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + + let res = s.reject_pending_invitation(tester.storage_mut(), &env, 1, 42); + assert_eq!( + res.unwrap_err(), + NodeFamiliesContractError::InvitationNotFound { + family_id: 1, + node_id: 42, + } + ); + } + + // ---- revoke_pending_invitation ---- + + #[test] + fn revoke_invitation_happy_path() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + tester.invite_to_family(f.id, 42); + + s.revoke_pending_invitation(tester.storage_mut(), &env, f.id, 42) + .unwrap(); + let past = s + .past_family_invitations + .load(tester.storage(), ((f.id, 42), 0)) + .unwrap(); + assert_eq!( + past.status, + FamilyInvitationStatus::Revoked { + at: tester.env().block.time.seconds() + } + ); + } + + #[test] + fn revoke_invitation_errors_when_no_pending() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + + let res = s.revoke_pending_invitation(tester.storage_mut(), &env, 1, 42); + assert_eq!( + res.unwrap_err(), + NodeFamiliesContractError::InvitationNotFound { + family_id: 1, + node_id: 42, + } + ); + } + + // ---- remove_family_member ---- + + #[test] + fn remove_family_member_happy_path() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + tester.add_to_family(f.id, 42); + + let updated = s + .remove_family_member(tester.storage_mut(), &env, 42) + .unwrap(); + + assert_eq!(updated.members, 0); + assert!(s + .family_members + .may_load(tester.storage(), 42) + .unwrap() + .is_none()); + let past = s + .past_family_members + .load(tester.storage(), ((f.id, 42), 0)) + .unwrap(); + assert_eq!(past.family_id, f.id); + assert_eq!(past.node_id, 42); + assert_eq!(past.removed_at, tester.env().block.time.seconds()); + } + + #[test] + fn remove_family_member_errors_when_node_not_in_any_family() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + + let res = s.remove_family_member(tester.storage_mut(), &env, 999); + assert_eq!( + res.unwrap_err(), + NodeFamiliesContractError::NodeNotInFamily { node_id: 999 } + ); + } + + #[test] + fn remove_family_member_uses_per_pair_archive_counter() { + // joining and leaving the same family twice must not collide on the archive key + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + + let expires_at = env.block.time.seconds() + 100; + for _ in 0..2 { + s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at) + .unwrap(); + s.accept_invitation(tester.storage_mut(), &env, f.id, 42) + .unwrap(); + s.remove_family_member(tester.storage_mut(), &env, 42) + .unwrap(); + } + + // both archive slots present + s.past_family_members + .load(tester.storage(), ((f.id, 42), 0)) + .unwrap(); + s.past_family_members + .load(tester.storage(), ((f.id, 42), 1)) + .unwrap(); + } + + // ---- disband_family ---- + + #[test] + fn disband_family_happy_path_no_pending() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + + let snap = s.disband_family(tester.storage_mut(), &env, f.id).unwrap(); + assert_eq!(snap.id, f.id); + assert!(s + .families + .may_load(tester.storage(), f.id) + .unwrap() + .is_none()); + } + + #[test] + fn disband_family_sweeps_all_pending_invitations_as_revoked() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + tester.invite_to_family(f.id, 42); + tester.invite_to_family(f.id, 43); + + s.disband_family(tester.storage_mut(), &env, f.id).unwrap(); + + assert!(s + .pending_family_invitations + .may_load(tester.storage(), (f.id, 42)) + .unwrap() + .is_none()); + assert!(s + .pending_family_invitations + .may_load(tester.storage(), (f.id, 43)) + .unwrap() + .is_none()); + assert_eq!( + s.past_family_invitations + .load(tester.storage(), ((f.id, 42), 0)) + .unwrap() + .status, + FamilyInvitationStatus::Revoked { + at: tester.env().block.time.seconds() + } + ); + assert_eq!( + s.past_family_invitations + .load(tester.storage(), ((f.id, 43), 0)) + .unwrap() + .status, + FamilyInvitationStatus::Revoked { + at: tester.env().block.time.seconds() + } + ); + } + + #[test] + fn disband_family_errors_on_unknown_family() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + + let res = s.disband_family(tester.storage_mut(), &env, 99); + assert_eq!( + res.unwrap_err(), + NodeFamiliesContractError::FamilyNotFound { family_id: 99 } + ); + } + + #[test] + fn after_disband_owner_can_register_again_with_new_id() { + let mut tester = init_contract_tester(); + let fee = tester.family_fee(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + + let f1 = tester.make_family(&alice); + s.disband_family(tester.storage_mut(), &env, f1.id).unwrap(); + let f2 = s + .register_new_family( + tester.storage_mut(), + &env, + fee, + alice, + "2".into(), + "2".into(), + "".into(), + ) + .unwrap(); + + // ids monotonically increase, never recycled + assert_eq!(f1.id, 1); + assert_eq!(f2.id, 2); + } + + // ---- may_get_owned_family ---- + + #[test] + fn may_get_owned_family_returns_none_when_address_owns_nothing() { + let tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let alice = tester.addr_make("alice"); + + let res = s.may_get_owned_family(tester.storage(), &alice).unwrap(); + assert!(res.is_none()); + } + + #[test] + fn may_get_owned_family_returns_the_family_for_its_owner() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let res = s + .may_get_owned_family(tester.storage(), &alice) + .unwrap() + .unwrap(); + assert_eq!(res, family); + } + + #[test] + fn may_get_owned_family_does_not_leak_other_owners_family() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let alice = tester.addr_make("alice"); + let bob = tester.addr_make("bob"); + tester.make_family(&alice); + + let res = s.may_get_owned_family(tester.storage(), &bob).unwrap(); + assert!(res.is_none()); + } + + #[test] + fn may_get_owned_family_returns_none_after_disband() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let env = tester.env(); + s.disband_family(tester.storage_mut(), &env, family.id) + .unwrap(); + + let res = s.may_get_owned_family(tester.storage(), &alice).unwrap(); + assert!(res.is_none()); + } + + // ---- must_get_owned_family ---- + + #[test] + fn must_get_owned_family_returns_the_family_for_its_owner() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let res = s.must_get_owned_family(tester.storage(), &alice).unwrap(); + assert_eq!(res, family); + } + + #[test] + fn must_get_owned_family_errors_when_address_owns_nothing() { + let tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let alice = tester.addr_make("alice"); + + let err = s + .must_get_owned_family(tester.storage(), &alice) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntOwnAFamily { address: alice } + ); + } + + #[test] + fn must_get_owned_family_errors_after_disband() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let env = tester.env(); + s.disband_family(tester.storage_mut(), &env, family.id) + .unwrap(); + + let err = s + .must_get_owned_family(tester.storage(), &alice) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntOwnAFamily { address: alice } + ); + } +} diff --git a/contracts/node-families/src/storage/retrieval_limits.rs b/contracts/node-families/src/storage/retrieval_limits.rs new file mode 100644 index 0000000000..bb993fe289 --- /dev/null +++ b/contracts/node-families/src/storage/retrieval_limits.rs @@ -0,0 +1,34 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +/// Default page size for paginated family listings when the caller omits `limit`. +pub const FAMILIES_DEFAULT_LIMIT: u32 = 50; + +/// Hard cap on the page size for paginated family listings; larger values are clamped. +pub const FAMILIES_MAX_LIMIT: u32 = 100; + +/// Default page size for paginated family-member listings when the caller omits `limit`. +pub const FAMILY_MEMBERS_DEFAULT_LIMIT: u32 = 50; + +/// Hard cap on the page size for paginated family-member listings; larger values are clamped. +pub const FAMILY_MEMBERS_MAX_LIMIT: u32 = 100; + +/// Default page size for paginated pending-invitation listings (both per-family +/// and global) when the caller omits `limit`. +pub const PENDING_INVITATIONS_DEFAULT_LIMIT: u32 = 50; + +/// Hard cap on the page size for paginated pending-invitation listings; larger values are clamped. +pub const PENDING_INVITATIONS_MAX_LIMIT: u32 = 100; + +/// Default page size for paginated past-invitation (archive) listings (both +/// per-family and global) when the caller omits `limit`. +pub const PAST_INVITATIONS_DEFAULT_LIMIT: u32 = 50; + +/// Hard cap on the page size for paginated past-invitation listings; larger values are clamped. +pub const PAST_INVITATIONS_MAX_LIMIT: u32 = 100; + +/// Default page size for paginated past-member (archive) listings when the caller omits `limit`. +pub const PAST_MEMBERS_DEFAULT_LIMIT: u32 = 50; + +/// Hard cap on the page size for paginated past-member listings; larger values are clamped. +pub const PAST_MEMBERS_MAX_LIMIT: u32 = 100; diff --git a/contracts/node-families/src/storage/storage_indexes.rs b/contracts/node-families/src/storage/storage_indexes.rs new file mode 100644 index 0000000000..0bae76f32a --- /dev/null +++ b/contracts/node-families/src/storage/storage_indexes.rs @@ -0,0 +1,174 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::storage::FamilyMember; +use cosmwasm_std::Addr; +use cw_storage_plus::{Index, IndexList, MultiIndex, UniqueIndex}; +use nym_mixnet_contract_common::NodeId; +use nym_node_families_contract_common::constants::storage_keys; +use nym_node_families_contract_common::{ + FamilyInvitation, FamilyMembership, NodeFamily, NodeFamilyId, PastFamilyInvitation, + PastFamilyMember, +}; + +/// Secondary indexes over [`NodeFamily`]. Enforces one-family-per-owner and +/// globally-unique family names via `UniqueIndex`es on `owner` and `name`. +pub(crate) struct NodeFamiliesIndex<'a> { + /// Unique index: at most one family per owner [`Addr`]. + pub(crate) owner: UniqueIndex<'a, Addr, NodeFamily, NodeFamilyId>, + /// Unique index keyed on [`NodeFamily::normalised_name`]: enforces global + /// uniqueness on the normalised form, so families with the same + /// canonical name but different user-submitted formatting collide. + pub(crate) normalised_name: UniqueIndex<'a, String, NodeFamily, NodeFamilyId>, +} + +impl NodeFamiliesIndex<'_> { + #[allow(clippy::new_without_default)] + pub(crate) fn new() -> Self { + NodeFamiliesIndex { + owner: UniqueIndex::new( + |family| family.owner.clone(), + storage_keys::FAMILIES_OWNER_IDX_NAMESPACE, + ), + normalised_name: UniqueIndex::new( + |family| family.normalised_name.clone(), + storage_keys::FAMILIES_NAME_IDX_NAMESPACE, + ), + } + } +} + +impl IndexList for NodeFamiliesIndex<'_> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.owner, &self.normalised_name]; + Box::new(v.into_iter()) + } +} + +/// Secondary indexes over current [`FamilyMembership`] records. The PK is +/// `NodeId` (one family per node), and the family-id multi-index enables +/// paginated listing of all nodes belonging to a given family. +pub(crate) struct FamilyMembersIndex<'a> { + /// Multi-index: every node currently in a given family. + pub(crate) family: MultiIndex<'a, NodeFamilyId, FamilyMembership, NodeId>, +} + +impl FamilyMembersIndex<'_> { + #[allow(clippy::new_without_default)] + pub(crate) fn new() -> Self { + FamilyMembersIndex { + family: MultiIndex::new( + |_pk, m| m.family_id, + storage_keys::NODE_FAMILY_MEMBERS, + storage_keys::NODE_FAMILY_MEMBERS_FAMILY_IDX_NAMESPACE, + ), + } + } +} + +impl IndexList for FamilyMembersIndex<'_> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.family]; + Box::new(v.into_iter()) + } +} + +/// Secondary indexes over pending [`FamilyInvitation`]s, allowing lookup by +/// either family id or node id. +pub(crate) struct NodeFamilyInvitationIndex<'a> { + /// Multi-index: all pending invitations issued by a given family. + pub(crate) family: MultiIndex<'a, NodeFamilyId, FamilyInvitation, FamilyMember>, + /// Multi-index: all pending invitations addressed to a given node. + pub(crate) node: MultiIndex<'a, NodeId, FamilyInvitation, FamilyMember>, +} + +impl NodeFamilyInvitationIndex<'_> { + pub(crate) fn new() -> Self { + NodeFamilyInvitationIndex { + family: MultiIndex::new( + |_pk, inv| inv.family_id, + storage_keys::INVITATIONS_NAMESPACE, + storage_keys::INVITATIONS_FAMILY_IDX_NAMESPACE, + ), + node: MultiIndex::new( + |_pk, inv| inv.node_id, + storage_keys::INVITATIONS_NAMESPACE, + storage_keys::INVITATIONS_NODE_IDX_NAMESPACE, + ), + } + } +} + +impl IndexList for NodeFamilyInvitationIndex<'_> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.family, &self.node]; + Box::new(v.into_iter()) + } +} + +/// Secondary indexes over the [`PastFamilyMember`] archive. +pub(crate) struct PastFamilyMembersIndex<'a> { + /// Multi-index: every past membership record for a given family. + pub(crate) family: MultiIndex<'a, NodeFamilyId, PastFamilyMember, (FamilyMember, u64)>, + /// Multi-index: every past membership record for a given node. + pub(crate) node: MultiIndex<'a, NodeId, PastFamilyMember, (FamilyMember, u64)>, +} + +impl PastFamilyMembersIndex<'_> { + #[allow(clippy::new_without_default)] + pub(crate) fn new() -> Self { + PastFamilyMembersIndex { + family: MultiIndex::new( + |_pk, mem| mem.family_id, + storage_keys::PAST_FAMILY_MEMBER_NAMESPACE, + storage_keys::PAST_FAMILY_MEMBER_FAMILY_IDX_NAMESPACE, + ), + node: MultiIndex::new( + |_pk, mem| mem.node_id, + storage_keys::PAST_FAMILY_MEMBER_NAMESPACE, + storage_keys::PAST_FAMILY_MEMBER_NODE_IDX_NAMESPACE, + ), + } + } +} + +impl IndexList for PastFamilyMembersIndex<'_> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.family, &self.node]; + Box::new(v.into_iter()) + } +} + +/// Secondary indexes over the [`PastFamilyInvitation`] archive +/// (rejected / revoked invitations). +pub(crate) struct PastFamilyInvitationsIndex<'a> { + /// Multi-index: every archived invitation issued by a given family. + pub(crate) family: MultiIndex<'a, NodeFamilyId, PastFamilyInvitation, (FamilyMember, u64)>, + /// Multi-index: every archived invitation addressed to a given node. + pub(crate) node: MultiIndex<'a, NodeId, PastFamilyInvitation, (FamilyMember, u64)>, +} + +impl PastFamilyInvitationsIndex<'_> { + #[allow(clippy::new_without_default)] + pub(crate) fn new() -> Self { + PastFamilyInvitationsIndex { + family: MultiIndex::new( + |_pk, inv| inv.invitation.family_id, + storage_keys::PAST_INVITATIONS_NAMESPACE, + storage_keys::PAST_INVITATIONS_FAMILY_IDX_NAMESPACE, + ), + node: MultiIndex::new( + |_pk, inv| inv.invitation.node_id, + storage_keys::PAST_INVITATIONS_NAMESPACE, + storage_keys::PAST_INVITATIONS_NODE_IDX_NAMESPACE, + ), + } + } +} + +impl IndexList for PastFamilyInvitationsIndex<'_> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.family, &self.node]; + Box::new(v.into_iter()) + } +} diff --git a/contracts/node-families/src/testing.rs b/contracts/node-families/src/testing.rs new file mode 100644 index 0000000000..f76bbacf26 --- /dev/null +++ b/contracts/node-families/src/testing.rs @@ -0,0 +1,219 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +// fine in test code +#![allow(clippy::unwrap_used)] +#![allow(clippy::expect_used)] + +use crate::contract::{execute, instantiate, migrate, query}; +use crate::helpers::normalise_family_name; +use crate::storage::NodeFamiliesStorage; +use cosmwasm_std::{coin, Addr, Coin, Storage}; +use mixnet_contract::testable_mixnet_contract::{EmbeddedMixnetContractExt, MixnetContract}; +use nym_contracts_common_testing::{ + AdminExt, ArbitraryContractStorageReader, ArbitraryContractStorageWriter, BankExt, ChainOpts, + CommonStorageKeys, ContractFn, ContractOpts, ContractTester, ContractTesterBuilder, DenomExt, + PermissionedFn, QueryFn, RandExt, TestableNymContract, TEST_DENOM, +}; +use nym_mixnet_contract_common::ContractState; +use nym_mixnet_contract_common::NodeId; +use nym_node_families_contract_common::constants::storage_keys; +use nym_node_families_contract_common::{ + Config, ExecuteMsg, FamilyInvitation, InstantiateMsg, MigrateMsg, NodeFamiliesContractError, + NodeFamily, NodeFamilyId, QueryMsg, +}; + +pub struct NodeFamiliesContract; + +impl TestableNymContract for NodeFamiliesContract { + const NAME: &'static str = "node-families-contract"; + type InitMsg = InstantiateMsg; + type ExecuteMsg = ExecuteMsg; + type QueryMsg = QueryMsg; + type MigrateMsg = MigrateMsg; + type ContractError = NodeFamiliesContractError; + + fn instantiate() -> ContractFn { + instantiate + } + + fn execute() -> ContractFn { + execute + } + + fn query() -> QueryFn { + query + } + + fn migrate() -> PermissionedFn { + migrate + } + + fn init() -> ContractTester + where + Self: Sized, + { + let builder = ContractTesterBuilder::new().instantiate::(None); + + // we just instantiated it + let mixnet_address = builder + .well_known_contracts + .get(MixnetContract::NAME) + .unwrap() + .clone(); + + builder + .instantiate::(Some(InstantiateMsg { + config: Config { + create_family_fee: coin(100_000000, TEST_DENOM), + family_name_length_limit: 20, + family_description_length_limit: 200, + default_invitation_validity_secs: 24 * 60 * 60, + }, + mixnet_contract_address: mixnet_address.to_string(), + })) + .build() + } +} + +/// Storage key the mixnet contract uses for its `ContractState` `Item` +/// (mirrors `mixnet/src/constants.rs::CONTRACT_STATE_KEY`). +const MIXNET_CONTRACT_STATE_STORAGE_KEY: &str = "state"; + +pub fn init_contract_tester() -> ContractTester { + let mut tester = NodeFamiliesContract::init() + .with_common_storage_key(CommonStorageKeys::Admin, storage_keys::CONTRACT_ADMIN); + + // Chicken-and-egg: the mixnet contract is instantiated first and is given + // a placeholder `node_families_contract_address` because the families + // contract doesn't exist yet. Once the families contract has been + // instantiated we patch the mixnet's stored `ContractState` so that the + // unbond callback (`OnNymNodeUnbond`) actually dispatches to the right + // contract. In production this fixup happens via a contract migration; + // here we go straight to storage to avoid jumping through cw2 version + // checks that don't apply on a fresh tester. + let families_address = tester.contract_address.clone(); + let mut mixnet_state: ContractState = tester + .read_from_mixnet_contract_storage(MIXNET_CONTRACT_STATE_STORAGE_KEY) + .expect("mixnet contract state should be loadable"); + mixnet_state.node_families_contract_address = families_address; + tester + .write_to_mixnet_contract_storage_value(MIXNET_CONTRACT_STATE_STORAGE_KEY, &mixnet_state) + .expect("should be able to patch mixnet contract state"); + + tester +} + +pub trait NodeFamiliesContractTesterExt: + ContractOpts< + ExecuteMsg = ExecuteMsg, + QueryMsg = QueryMsg, + ContractError = NodeFamiliesContractError, + > + ChainOpts + + AdminExt + + DenomExt + + BankExt + + RandExt + + Storage + + ArbitraryContractStorageReader + + ArbitraryContractStorageWriter + + EmbeddedMixnetContractExt + + Sized +{ + fn family_fee(&self) -> Coin { + let s = NodeFamiliesStorage::new(); + s.config.load(self).unwrap().create_family_fee + } + + fn make_named_family(&mut self, owner: &Addr, name: &str) -> NodeFamily { + let normalised = normalise_family_name(name); + let env = self.env(); + let fee = self.family_fee(); + NodeFamiliesStorage::new() + .register_new_family( + self, + &env, + fee, + owner.clone(), + name.to_string(), + normalised, + "dummy".to_string(), + ) + .unwrap() + } + + fn make_family(&mut self, owner: &Addr) -> NodeFamily { + // names must be globally unique; derive from owner addr (also unique) + let name = format!("family-{owner}"); + self.make_named_family(owner, &name) + } + + fn disband_family(&mut self, family: NodeFamilyId) { + let env = self.env(); + NodeFamiliesStorage::new() + .disband_family(self, &env, family) + .unwrap(); + } + + fn add_dummy_family(&mut self) -> NodeFamily { + let owner = self.generate_account(); + self.make_family(&owner) + } + + fn invite_to_family_with_expiration( + &mut self, + family: NodeFamilyId, + node: NodeId, + expiration: u64, + ) -> FamilyInvitation { + NodeFamiliesStorage::new() + .add_pending_invitation(self, family, node, expiration) + .unwrap() + } + + fn invite_to_family(&mut self, family: NodeFamilyId, node: NodeId) -> FamilyInvitation { + let exp = self.env().block.time.seconds() + 100; + self.invite_to_family_with_expiration(family, node, exp) + } + + fn accept_invitation(&mut self, family: NodeFamilyId, node: NodeId) { + let env = self.env(); + NodeFamiliesStorage::new() + .accept_invitation(self, &env, family, node) + .unwrap(); + } + + fn reject_invitation(&mut self, family: NodeFamilyId, node: NodeId) { + let env = self.env(); + NodeFamiliesStorage::new() + .reject_pending_invitation(self, &env, family, node) + .unwrap(); + } + + fn revoke_invitation(&mut self, family: NodeFamilyId, node: NodeId) { + let env = self.env(); + NodeFamiliesStorage::new() + .revoke_pending_invitation(self, &env, family, node) + .unwrap(); + } + + fn add_to_family(&mut self, family: NodeFamilyId, node: NodeId) { + self.invite_to_family(family, node); + self.accept_invitation(family, node); + } + + fn remove_from_family(&mut self, node: NodeId) { + let env = self.env(); + NodeFamiliesStorage::new() + .remove_family_member(self, &env, node) + .unwrap(); + } + + fn add_n_family_members(&mut self, family: NodeFamilyId, count: u32) { + for n in 1..=count { + self.add_to_family(family, n); + } + } +} + +impl NodeFamiliesContractTesterExt for ContractTester {} diff --git a/contracts/node-families/src/transactions.rs b/contracts/node-families/src/transactions.rs new file mode 100644 index 0000000000..0237dcf3ab --- /dev/null +++ b/contracts/node-families/src/transactions.rs @@ -0,0 +1,2165 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +//! State-mutating execute handlers + +use crate::helpers::{ + ensure_address_holds_no_family_membership, ensure_has_bonded_node, ensure_node_is_bonded, + ensure_node_not_in_family, normalise_family_name, +}; +use crate::storage::NodeFamiliesStorage; +use cosmwasm_std::{BankMsg, DepsMut, Env, Event, MessageInfo, Response}; +use nym_mixnet_contract_common::NodeId; +use nym_node_families_contract_common::constants::events; +use nym_node_families_contract_common::{Config, NodeFamiliesContractError, NodeFamilyId}; + +/// Replace the contract's runtime [`Config`]. Restricted to the contract admin. +pub(crate) fn try_update_config( + deps: DepsMut, + _env: Env, + info: MessageInfo, + config: Config, +) -> Result { + let storage = NodeFamiliesStorage::new(); + storage + .contract_admin + .assert_admin(deps.as_ref(), &info.sender)?; + storage.config.save(deps.storage, &config)?; + Ok(Response::default()) +} + +/// Create a new family owned by `info.sender`. +/// +/// Performs the caller-side checks specified on +/// [`NodeFamiliesStorage::register_new_family`]: validates the attached fee +/// matches the configured `create_family_fee`, that name and description +/// are within their configured length limits, that the name normalises to a +/// non-empty string, that the sender doesn't already own a family or +/// collide with an existing family's normalised name, and that the sender's +/// bonded node (if any) isn't already in a family. The unique indexes on +/// `owner` and `normalised_name` provide defence-in-depth, but pre-checking +/// yields typed errors with useful context. +pub(crate) fn try_create_family( + deps: DepsMut, + env: Env, + info: MessageInfo, + name: String, + description: String, +) -> Result { + let storage = NodeFamiliesStorage::new(); + let config = storage.config.load(deps.storage)?; + + // check for the correct number of coins and denom + let submitted = cw_utils::must_pay(&info, &config.create_family_fee.denom)?; + + // verify the amount + if submitted != config.create_family_fee.amount { + return Err(NodeFamiliesContractError::InvalidFamilyCreationFee { + expected: config.create_family_fee, + received: info.funds, + }); + } + + // validate family name + if name.len() > config.family_name_length_limit { + return Err(NodeFamiliesContractError::FamilyNameTooLong { + length: name.len(), + limit: config.family_name_length_limit, + }); + } + let normalised = normalise_family_name(&name); + if normalised.is_empty() { + return Err(NodeFamiliesContractError::EmptyFamilyName); + } + + // validate family description + if description.len() > config.family_description_length_limit { + return Err(NodeFamiliesContractError::FamilyDescriptionTooLong { + length: description.len(), + limit: config.family_description_length_limit, + }); + } + + // check if the sender already owns a family + if let Some(existing) = storage.may_get_owned_family(deps.storage, &info.sender)? { + return Err(NodeFamiliesContractError::SenderAlreadyOwnsAFamily { + address: info.sender, + family_id: existing.id, + }); + } + + // explicitly verify duplicate family name for a better error message + if let Some((_, existing)) = storage + .families + .idx + .normalised_name + .item(deps.storage, normalised.clone())? + { + return Err(NodeFamiliesContractError::FamilyNameAlreadyTaken { + name: normalised, + family_id: existing.id, + }); + } + + // check whether this owner has a bonded node which belongs to a family + ensure_address_holds_no_family_membership(&storage, deps.as_ref(), &info.sender)?; + + let family = storage.register_new_family( + deps.storage, + &env, + config.create_family_fee, + info.sender, + name, + normalised, + description, + )?; + + Ok(Response::new().add_event( + Event::new(events::FAMILY_CREATION_EVENT_NAME) + .add_attribute(events::FAMILY_CREATION_EVENT_FAMILY_NAME, family.name) + .add_attribute(events::FAMILY_CREATION_EVENT_OWNER_ADDRESS, family.owner) + .add_attribute( + events::FAMILY_CREATION_EVENT_FAMILY_ID, + family.id.to_string(), + ) + .add_attribute( + events::FAMILY_CREATION_EVENT_PAID_FEE, + family.paid_fee.to_string(), + ), + )) +} + +/// Disband the family owned by `info.sender` and refund the original +/// creation fee. +/// +/// Looks up the sender's family via the `owner` unique index (errors with +/// [`SenderDoesntOwnAFamily`] if none). The storage layer enforces the +/// "family must have zero current members" precondition and sweeps any +/// still-pending invitations as `Revoked`. The originally paid creation fee +/// is returned to the sender via a [`BankMsg::Send`] attached to the +/// response. +/// +/// [`SenderDoesntOwnAFamily`]: NodeFamiliesContractError::SenderDoesntOwnAFamily +pub(crate) fn try_disband_family( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result { + let storage = NodeFamiliesStorage::new(); + + let owned = storage.must_get_owned_family(deps.storage, &info.sender)?; + + if owned.members != 0 { + return Err(NodeFamiliesContractError::FamilyNotEmpty { + family_id: owned.id, + members: owned.members, + }); + } + + let family = storage.disband_family(deps.storage, &env, owned.id)?; + + let refund = BankMsg::Send { + to_address: family.owner.to_string(), + amount: vec![family.paid_fee.clone()], + }; + + Ok(Response::new().add_message(refund).add_event( + Event::new(events::FAMILY_DISBAND_EVENT_NAME) + .add_attribute( + events::FAMILY_DISBAND_EVENT_FAMILY_ID, + family.id.to_string(), + ) + .add_attribute(events::FAMILY_DISBAND_EVENT_OWNER_ADDRESS, &family.owner) + .add_attribute( + events::FAMILY_DISBAND_EVENT_REFUNDED_FEE, + family.paid_fee.to_string(), + ), + )) +} + +/// Issue a pending invitation for `node_id` to join the family owned by +/// `info.sender`. +/// +/// `validity_secs` overrides the configured `default_invitation_validity_secs` +/// when supplied; a value of `Some(0)` is rejected with +/// [`ZeroInvitationValidity`] since the invitation would already be expired +/// the moment it landed in storage. +/// +/// [`ZeroInvitationValidity`]: NodeFamiliesContractError::ZeroInvitationValidity +pub(crate) fn try_invite_to_family( + deps: DepsMut, + env: Env, + info: MessageInfo, + node_id: NodeId, + validity_secs: Option, +) -> Result { + let storage = NodeFamiliesStorage::new(); + let config = storage.config.load(deps.storage)?; + + let validity = validity_secs.unwrap_or(config.default_invitation_validity_secs); + if validity == 0 { + return Err(NodeFamiliesContractError::ZeroInvitationValidity); + } + + let owned = storage.must_get_owned_family(deps.storage, &info.sender)?; + ensure_node_is_bonded(&storage, deps.as_ref(), node_id)?; + ensure_node_not_in_family(&storage, deps.as_ref(), node_id)?; + + let expires_at = env.block.time.seconds() + validity; + let invitation = storage.add_pending_invitation(deps.storage, owned.id, node_id, expires_at)?; + + Ok(Response::new().add_event( + Event::new(events::FAMILY_INVITATION_EVENT_NAME) + .add_attribute( + events::FAMILY_INVITATION_EVENT_FAMILY_ID, + owned.id.to_string(), + ) + .add_attribute(events::FAMILY_INVITATION_EVENT_NODE_ID, node_id.to_string()) + .add_attribute( + events::FAMILY_INVITATION_EVENT_EXPIRES_AT, + invitation.expires_at.to_string(), + ), + )) +} + +/// Revoke a still-pending invitation previously issued by the sender's +/// family. +/// +/// The sender must currently own a family β€” the `(family, node)` pair +/// targeted for revocation is derived from that ownership rather than passed +/// explicitly, so a sender cannot revoke another family's invitations. +/// Errors with [`SenderDoesntOwnAFamily`] if the sender owns no family, or +/// [`InvitationNotFound`] if no pending invitation for `node_id` exists in +/// the sender's family. Expired invitations *can* be revoked β€” this is the +/// only path that cleans them out of the pending map. +/// +/// [`SenderDoesntOwnAFamily`]: NodeFamiliesContractError::SenderDoesntOwnAFamily +/// [`InvitationNotFound`]: NodeFamiliesContractError::InvitationNotFound +pub(crate) fn try_revoke_family_invitation( + deps: DepsMut, + env: Env, + info: MessageInfo, + node_id: NodeId, +) -> Result { + let storage = NodeFamiliesStorage::new(); + let owned = storage.must_get_owned_family(deps.storage, &info.sender)?; + + storage.revoke_pending_invitation(deps.storage, &env, owned.id, node_id)?; + + Ok(Response::new().add_event( + Event::new(events::FAMILY_INVITATION_REVOKED_EVENT_NAME) + .add_attribute( + events::FAMILY_INVITATION_REVOKED_EVENT_FAMILY_ID, + owned.id.to_string(), + ) + .add_attribute( + events::FAMILY_INVITATION_REVOKED_EVENT_NODE_ID, + node_id.to_string(), + ), + )) +} + +/// Accept the pending invitation for `node_id` to join `family_id`. +/// +/// `info.sender` must be the bond controller of `node_id` per the mixnet +/// contract; the storage layer's `family_members` write would otherwise +/// silently overwrite the membership of an unrelated node, so the controller +/// check (and the defence-in-depth `ensure_node_not_in_family`) live here +/// rather than down in storage. Errors with [`SenderDoesntControlNode`] if +/// the sender's bonded node id doesn't match (or the node is unbonding), +/// [`NodeAlreadyInFamily`] if the node has somehow joined another family +/// since the invitation was issued, [`InvitationNotFound`] if no pending +/// invitation exists for the pair, and [`InvitationExpired`] if it has. +/// +/// [`SenderDoesntControlNode`]: NodeFamiliesContractError::SenderDoesntControlNode +/// [`NodeAlreadyInFamily`]: NodeFamiliesContractError::NodeAlreadyInFamily +/// [`InvitationNotFound`]: NodeFamiliesContractError::InvitationNotFound +/// [`InvitationExpired`]: NodeFamiliesContractError::InvitationExpired +pub(crate) fn try_accept_family_invitation( + deps: DepsMut, + env: Env, + info: MessageInfo, + family_id: NodeFamilyId, + node_id: NodeId, +) -> Result { + let storage = NodeFamiliesStorage::new(); + + ensure_has_bonded_node(&storage, deps.as_ref(), &info.sender, node_id)?; + ensure_node_not_in_family(&storage, deps.as_ref(), node_id)?; + + storage.accept_invitation(deps.storage, &env, family_id, node_id)?; + + Ok(Response::new().add_event( + Event::new(events::FAMILY_INVITATION_ACCEPTED_EVENT_NAME) + .add_attribute( + events::FAMILY_INVITATION_ACCEPTED_EVENT_FAMILY_ID, + family_id.to_string(), + ) + .add_attribute( + events::FAMILY_INVITATION_ACCEPTED_EVENT_NODE_ID, + node_id.to_string(), + ), + )) +} + +/// Reject the pending invitation for `node_id` to join `family_id`. +/// +/// `info.sender` must be the bond controller of `node_id` per the mixnet +/// contract β€” rejection is the invitee's choice. Errors with +/// [`SenderDoesntControlNode`] if the sender doesn't control `node_id` (or +/// the node is unbonding) and [`InvitationNotFound`] if no pending invitation +/// exists for the pair. Expired invitations *can* be rejected β€” symmetric +/// with revocation, this is also a path that cleans them out of the pending +/// map. +/// +/// [`SenderDoesntControlNode`]: NodeFamiliesContractError::SenderDoesntControlNode +/// [`InvitationNotFound`]: NodeFamiliesContractError::InvitationNotFound +pub(crate) fn try_reject_family_invitation( + deps: DepsMut, + env: Env, + info: MessageInfo, + family_id: NodeFamilyId, + node_id: NodeId, +) -> Result { + let storage = NodeFamiliesStorage::new(); + + ensure_has_bonded_node(&storage, deps.as_ref(), &info.sender, node_id)?; + + storage.reject_pending_invitation(deps.storage, &env, family_id, node_id)?; + + Ok(Response::new().add_event( + Event::new(events::FAMILY_INVITATION_REJECTED_EVENT_NAME) + .add_attribute( + events::FAMILY_INVITATION_REJECTED_EVENT_FAMILY_ID, + family_id.to_string(), + ) + .add_attribute( + events::FAMILY_INVITATION_REJECTED_EVENT_NODE_ID, + node_id.to_string(), + ), + )) +} + +/// Remove `node_id` from whichever family it currently belongs to, at the +/// request of the node's controller. +/// +/// `info.sender` must be the bond controller of `node_id` per the mixnet +/// contract β€” a node only leaves of its own accord. Errors with +/// [`SenderDoesntControlNode`] if the sender doesn't control `node_id` (or +/// the node is unbonding) and [`NodeNotInFamily`] if the node isn't currently +/// a member of any family. +/// +/// The mixnet-contract unbonding callback drops a node from its family +/// independently (see [`try_handle_node_unbonding`]); this handler is the +/// voluntary-leave path and is not the one fired on unbond. +/// +/// [`SenderDoesntControlNode`]: NodeFamiliesContractError::SenderDoesntControlNode +/// [`NodeNotInFamily`]: NodeFamiliesContractError::NodeNotInFamily +pub(crate) fn try_leave_family( + deps: DepsMut, + env: Env, + info: MessageInfo, + node_id: NodeId, +) -> Result { + let storage = NodeFamiliesStorage::new(); + + ensure_has_bonded_node(&storage, deps.as_ref(), &info.sender, node_id)?; + + let family = storage.remove_family_member(deps.storage, &env, node_id)?; + + Ok(Response::new().add_event( + Event::new(events::FAMILY_MEMBER_LEFT_EVENT_NAME) + .add_attribute( + events::FAMILY_MEMBER_LEFT_EVENT_FAMILY_ID, + family.id.to_string(), + ) + .add_attribute( + events::FAMILY_MEMBER_LEFT_EVENT_NODE_ID, + node_id.to_string(), + ), + )) +} + +/// Kick `node_id` out of the family owned by `info.sender`. +/// +/// Owner-gated: the family acted on is derived from `info.sender`'s ownership +/// rather than passed as an argument, so a sender cannot kick from another +/// family. Errors with [`SenderDoesntOwnAFamily`] if the sender owns no +/// family, [`NodeNotInFamily`] if the node has no membership at all, and +/// [`NodeNotMemberOfFamily`] if the node is in a different family β€” the +/// scope check happens at the tx layer because `family_members` is keyed by +/// node only and `remove_family_member` would otherwise silently strip a +/// node from someone else's family. +/// +/// [`SenderDoesntOwnAFamily`]: NodeFamiliesContractError::SenderDoesntOwnAFamily +/// [`NodeNotInFamily`]: NodeFamiliesContractError::NodeNotInFamily +/// [`NodeNotMemberOfFamily`]: NodeFamiliesContractError::NodeNotMemberOfFamily +pub(crate) fn try_kick_from_family( + deps: DepsMut, + env: Env, + info: MessageInfo, + node_id: NodeId, +) -> Result { + let storage = NodeFamiliesStorage::new(); + + let owned = storage.must_get_owned_family(deps.storage, &info.sender)?; + + let membership = storage + .family_members + .may_load(deps.storage, node_id)? + .ok_or(NodeFamiliesContractError::NodeNotInFamily { node_id })?; + if membership.family_id != owned.id { + return Err(NodeFamiliesContractError::NodeNotMemberOfFamily { + node_id, + family_id: owned.id, + }); + } + + storage.remove_family_member(deps.storage, &env, node_id)?; + + Ok(Response::new().add_event( + Event::new(events::FAMILY_MEMBER_KICKED_EVENT_NAME) + .add_attribute( + events::FAMILY_MEMBER_KICKED_EVENT_FAMILY_ID, + owned.id.to_string(), + ) + .add_attribute( + events::FAMILY_MEMBER_KICKED_EVENT_NODE_ID, + node_id.to_string(), + ), + )) +} + +/// Cross-contract callback fired by the mixnet contract the moment `node_id` +/// initiates unbonding. Unbonding is irreversible, so from the families +/// contract's perspective the node is already effectively gone β€” drop it +/// from any family it currently belongs to and clear every pending +/// invitation issued to it. +/// +/// Auth: `info.sender` must equal the configured `mixnet_contract_address`, +/// since the mixnet contract is the only authority that can attest a node +/// has unbonded. Errors with [`UnauthorisedMixnetCallback`] otherwise. +/// +/// The membership half is idempotent β€” a node that initiates unbonding +/// without ever joining a family is the common case and is not an error. +/// Swept invitations are archived as +/// [`FamilyInvitationStatus::Rejected`]: the auto-cleanup shares the +/// `Rejected` terminal state with invitations that would have been +/// explicitly declined by the node controller. +/// +/// [`UnauthorisedMixnetCallback`]: NodeFamiliesContractError::UnauthorisedMixnetCallback +pub(crate) fn try_handle_node_unbonding( + deps: DepsMut, + env: Env, + info: MessageInfo, + node_id: NodeId, +) -> Result { + let storage = NodeFamiliesStorage::new(); + + let mixnet_contract = storage.mixnet_contract_address.load(deps.storage)?; + if info.sender != mixnet_contract { + return Err(NodeFamiliesContractError::UnauthorisedMixnetCallback { + sender: info.sender, + }); + } + + storage.handle_node_unbonding(deps.storage, &env, node_id)?; + + Ok(Response::new().add_event( + Event::new(events::NODE_UNBOND_CLEANUP_EVENT_NAME).add_attribute( + events::NODE_UNBOND_CLEANUP_EVENT_NODE_ID, + node_id.to_string(), + ), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::init_contract_tester; + use cosmwasm_std::coin; + use cosmwasm_std::testing::message_info; + use cw_controllers::AdminError; + use nym_contracts_common_testing::{AdminExt, ContractOpts, RandExt}; + + fn updated_config() -> Config { + Config { + create_family_fee: coin(999, "unym"), + family_name_length_limit: 1, + family_description_length_limit: 2, + default_invitation_validity_secs: 60, + } + } + + #[test] + fn admin_can_replace_the_config() { + let mut tester = init_contract_tester(); + let admin = tester.admin_msg(); + let new_config = updated_config(); + let env = tester.env(); + let res = try_update_config(tester.deps_mut(), env, admin, new_config.clone()); + assert!(res.is_ok()); + + let stored = NodeFamiliesStorage::new() + .config + .load(tester.deps().storage) + .unwrap(); + assert_eq!(stored, new_config); + } + + #[test] + fn non_admin_cannot_update_the_config() { + let mut tester = init_contract_tester(); + let not_admin = tester.generate_account(); + let not_admin = message_info(¬_admin, &[]); + + let original = NodeFamiliesStorage::new() + .config + .load(tester.deps().storage) + .unwrap(); + + let env = tester.env(); + let err = + try_update_config(tester.deps_mut(), env, not_admin, updated_config()).unwrap_err(); + + assert_eq!( + err, + NodeFamiliesContractError::Admin(AdminError::NotAdmin {}) + ); + + // config left untouched + let stored = NodeFamiliesStorage::new() + .config + .load(tester.deps().storage) + .unwrap(); + assert_eq!(stored, original); + } + + mod create_family { + use super::*; + use crate::testing::NodeFamiliesContractTesterExt; + use cosmwasm_std::coins; + use cw_utils::PaymentError; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; + use nym_contracts_common_testing::TEST_DENOM; + + #[test] + fn happy_path_persists_family_preserving_submitted_name() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let fee = tester.family_fee(); + let alice = tester.make_sender_with_funds("alice", &[fee]); + let env = tester.env(); + let deps = tester.deps_mut(); + + // user-submitted formatting includes punctuation + casing that + // the normaliser strips; both forms should end up on the stored + // record. + try_create_family( + deps, + env, + alice.clone(), + "My Family!".to_string(), + "description".to_string(), + )?; + + let storage = NodeFamiliesStorage::new(); + let family = storage.families.load(tester.deps().storage, 1)?; + assert_eq!(family.id, 1); + assert_eq!(family.name, "My Family!"); + assert_eq!(family.normalised_name, "myfamily"); + assert_eq!(family.owner, alice.sender); + assert_eq!(family.description, "description"); + assert_eq!(family.paid_fee, alice.funds[0]); + assert_eq!(family.members, 0); + + Ok(()) + } + + #[test] + fn rejects_when_no_funds_attached() { + let mut tester = init_contract_tester(); + let alice = tester.make_sender_with_funds("alice", &[]); + let env = tester.env(); + let deps = tester.deps_mut(); + + let err = try_create_family( + deps, + env, + alice.clone(), + "name".to_string(), + "description".to_string(), + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::InvalidDeposit(PaymentError::NoFunds {}) + ); + } + + #[test] + fn rejects_when_fee_amount_mismatched() { + let mut tester = init_contract_tester(); + let fee = tester.family_fee(); + let too_little = coins(fee.amount.u128() - 1, fee.denom.clone()); + let alice = tester.make_sender_with_funds("alice", &too_little); + let env = tester.env(); + let deps = tester.deps_mut(); + + let err = try_create_family( + deps, + env, + alice.clone(), + "name".to_string(), + "description".to_string(), + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::InvalidFamilyCreationFee { + expected: fee, + received: too_little, + } + ); + } + + #[test] + fn rejects_when_fee_denom_mismatched() { + let mut tester = init_contract_tester(); + + let fee = tester.family_fee(); + let wrong_denom = coins(fee.amount.u128(), "uatom"); + let alice = tester.make_sender_with_funds("alice", &wrong_denom); + let env = tester.env(); + let deps = tester.deps_mut(); + + let err = try_create_family( + deps, + env, + alice.clone(), + "name".to_string(), + "description".to_string(), + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::InvalidDeposit(PaymentError::MissingDenom( + TEST_DENOM.to_string() + )) + ); + } + + #[test] + fn rejects_name_exceeding_length_limit() { + let mut tester = init_contract_tester(); + + let fee = tester.family_fee(); + let alice = tester.make_sender_with_funds("alice", &[fee]); + + let limit = NodeFamiliesStorage::new() + .config + .load(tester.deps().storage) + .unwrap() + .family_name_length_limit; + let too_long: String = "a".repeat(limit + 1); + let env = tester.env(); + let deps = tester.deps_mut(); + + let err = try_create_family( + deps, + env, + alice, + too_long.to_string(), + "description".to_string(), + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::FamilyNameTooLong { + length: limit + 1, + limit, + } + ); + } + + #[test] + fn rejects_description_exceeding_length_limit() { + let mut tester = init_contract_tester(); + + let fee = tester.family_fee(); + let alice = tester.make_sender_with_funds("alice", &[fee]); + + let limit = NodeFamiliesStorage::new() + .config + .load(tester.deps().storage) + .unwrap() + .family_description_length_limit; + let too_long: String = "a".repeat(limit + 1); + let env = tester.env(); + let deps = tester.deps_mut(); + + let err = try_create_family(deps, env, alice, "name".to_string(), too_long.to_string()) + .unwrap_err(); + + assert_eq!( + err, + NodeFamiliesContractError::FamilyDescriptionTooLong { + length: limit + 1, + limit, + } + ); + } + + #[test] + fn rejects_name_that_normalises_to_empty() { + let mut tester = init_contract_tester(); + let env = tester.env(); + let alice = tester.make_sender_with_funds("alice", &[tester.family_fee()]); + let deps = tester.deps_mut(); + + let err = try_create_family( + deps, + env, + alice.clone(), + "!!! ---".to_string(), + "".to_string(), + ) + .unwrap_err(); + + assert_eq!(err, NodeFamiliesContractError::EmptyFamilyName); + } + + #[test] + fn rejects_when_sender_already_owns_a_family() { + let mut tester = init_contract_tester(); + let env = tester.env(); + let alice = tester.make_sender_with_funds("alice", &[tester.family_fee()]); + + tester.make_family(&alice.sender); + let deps = tester.deps_mut(); + + let err = try_create_family( + deps, + env, + alice.clone(), + "name".to_string(), + "description".to_string(), + ) + .unwrap_err(); + + assert_eq!( + err, + NodeFamiliesContractError::SenderAlreadyOwnsAFamily { + address: alice.sender, + family_id: 1, + } + ); + } + + #[test] + fn rejects_when_normalised_name_is_already_taken() { + let mut tester = init_contract_tester(); + let fee = vec![tester.family_fee()]; + let alice = tester.make_sender_with_funds("alice", &fee); + let bob = tester.make_sender_with_funds("bob", &fee); + let env = tester.env(); + + tester.make_named_family(&alice.sender, "MyFamily"); + let deps = tester.deps_mut(); + + // different casing / punctuation, same normalised form + let err = try_create_family( + deps, + env, + bob.clone(), + "$$myFaMiLy$$".to_string(), + "description".to_string(), + ) + .unwrap_err(); + + assert_eq!( + err, + NodeFamiliesContractError::FamilyNameAlreadyTaken { + name: "myfamily".to_string(), + family_id: 1, + } + ); + } + + #[test] + fn rejects_when_owner_owns_node_in_different_family() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let fee = tester.family_fee(); + let alice = tester.generate_account_with_balance(); + let env = tester.env(); + + let other_family = tester.make_family(&tester.addr_make("bob")); + let node_id = tester.bond_dummy_nymnode_for(&alice)?; + + let alice = message_info(&alice, &[fee]); + + // has node which is not in a family - that's still allowed! + let deps = tester.deps_mut(); + try_create_family( + deps, + env.clone(), + alice.clone(), + "My Family!".to_string(), + "description".to_string(), + )?; + tester.disband_family(2); + + // after joining family we error out + tester.add_to_family(other_family.id, node_id); + + let deps = tester.deps_mut(); + let err = try_create_family( + deps, + env.clone(), + alice.clone(), + "My Family!".to_string(), + "description".to_string(), + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::AlreadyInFamily { + address: alice.sender.clone(), + node_id, + family_id: other_family.id, + } + ); + + // after unbonding it is fine again + tester.unbond_nymnode(node_id)?; + let deps = tester.deps_mut(); + try_create_family( + deps, + env.clone(), + alice.clone(), + "My Family!".to_string(), + "description".to_string(), + )?; + + Ok(()) + } + } + + mod disband_family { + use super::*; + use crate::testing::NodeFamiliesContractTesterExt; + use cosmwasm_std::{BankMsg, CosmosMsg, SubMsg}; + + #[test] + fn happy_path_removes_family_and_refunds_fee() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let info = message_info(&alice, &[]); + let env = tester.env(); + + let res = try_disband_family(tester.deps_mut(), env, info)?; + + // family is gone from storage + let storage = NodeFamiliesStorage::new(); + assert!(storage + .families + .may_load(tester.deps().storage, family.id)? + .is_none()); + + // single bank refund attached, going to the original owner with the + // exact paid fee + assert_eq!(res.messages.len(), 1); + assert_eq!( + res.messages[0], + SubMsg::new(CosmosMsg::Bank(BankMsg::Send { + to_address: alice.to_string(), + amount: vec![family.paid_fee.clone()], + })) + ); + + Ok(()) + } + + #[test] + fn rejects_when_sender_owns_no_family() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let info = message_info(&alice, &[]); + let env = tester.env(); + + let err = try_disband_family(tester.deps_mut(), env, info).unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntOwnAFamily { address: alice } + ); + } + + #[test] + fn rejects_when_family_still_has_members() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + tester.add_to_family(family.id, 42); + + let info = message_info(&alice, &[]); + let env = tester.env(); + let err = try_disband_family(tester.deps_mut(), env, info).unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::FamilyNotEmpty { + family_id: family.id, + members: 1, + } + ); + } + + #[test] + fn after_disband_owner_can_create_a_new_family() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + tester.make_family(&alice); + + let env = tester.env(); + try_disband_family(tester.deps_mut(), env.clone(), message_info(&alice, &[]))?; + + // owner-index slot freed β†’ a fresh create_family should succeed + let fee = tester.family_fee(); + let alice_with_fee = message_info(&alice, &[fee]); + try_create_family( + tester.deps_mut(), + env, + alice_with_fee, + "second".to_string(), + "".to_string(), + )?; + Ok(()) + } + } + + mod invite_to_family { + use super::*; + use crate::testing::NodeFamiliesContractTesterExt; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; + + #[test] + fn happy_path_persists_pending_invitation() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + + let env = tester.env(); + let info = message_info(&alice, &[]); + try_invite_to_family(tester.deps_mut(), env.clone(), info, node_id, None)?; + + let storage = NodeFamiliesStorage::new(); + let invitation = storage + .pending_family_invitations + .load(tester.deps().storage, (family.id, node_id))?; + assert_eq!(invitation.family_id, family.id); + assert_eq!(invitation.node_id, node_id); + + let default_validity = storage + .config + .load(tester.deps().storage)? + .default_invitation_validity_secs; + assert_eq!( + invitation.expires_at, + env.block.time.seconds() + default_validity + ); + Ok(()) + } + + #[test] + fn custom_validity_overrides_default() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + + let env = tester.env(); + let info = message_info(&alice, &[]); + try_invite_to_family(tester.deps_mut(), env.clone(), info, node_id, Some(5))?; + + let invitation = NodeFamiliesStorage::new() + .pending_family_invitations + .load(tester.deps().storage, (family.id, node_id))?; + assert_eq!(invitation.expires_at, env.block.time.seconds() + 5); + Ok(()) + } + + #[test] + fn rejects_zero_validity() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + + let env = tester.env(); + let info = message_info(&alice, &[]); + let err = + try_invite_to_family(tester.deps_mut(), env, info, node_id, Some(0)).unwrap_err(); + assert_eq!(err, NodeFamiliesContractError::ZeroInvitationValidity); + Ok(()) + } + + #[test] + fn rejects_when_sender_owns_no_family() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let node_id = tester.bond_dummy_nymnode()?; + + let env = tester.env(); + let info = message_info(&alice, &[]); + let err = + try_invite_to_family(tester.deps_mut(), env, info, node_id, None).unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntOwnAFamily { address: alice } + ); + Ok(()) + } + + #[test] + fn rejects_when_node_is_not_bonded() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + tester.make_family(&alice); + + let env = tester.env(); + let info = message_info(&alice, &[]); + let err = try_invite_to_family(tester.deps_mut(), env, info, 999, None).unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::NodeDoesntExist { node_id: 999 } + ); + Ok(()) + } + + #[test] + fn rejects_when_node_is_already_in_a_family() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let alice_family = tester.make_family(&alice); + let bob = tester.addr_make("bob"); + let bob_family = tester.make_family(&bob); + + let node_id = tester.bond_dummy_nymnode()?; + tester.add_to_family(bob_family.id, node_id); + + let env = tester.env(); + let info = message_info(&alice, &[]); + let err = + try_invite_to_family(tester.deps_mut(), env, info, node_id, None).unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::NodeAlreadyInFamily { + node_id, + family_id: bob_family.id, + } + ); + // alice's family is unchanged + assert!(NodeFamiliesStorage::new() + .pending_family_invitations + .may_load(tester.deps().storage, (alice_family.id, node_id))? + .is_none()); + Ok(()) + } + + #[test] + fn rejects_duplicate_pending_invitation() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + + let env = tester.env(); + try_invite_to_family( + tester.deps_mut(), + env.clone(), + message_info(&alice, &[]), + node_id, + None, + )?; + let err = try_invite_to_family( + tester.deps_mut(), + env, + message_info(&alice, &[]), + node_id, + None, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::PendingInvitationAlreadyExists { + family_id: family.id, + node_id, + } + ); + Ok(()) + } + } + + mod revoke_family_invitation { + use super::*; + use crate::testing::NodeFamiliesContractTesterExt; + use nym_node_families_contract_common::FamilyInvitationStatus; + + #[test] + fn happy_path_removes_pending_and_archives_revoked() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = 7; + tester.invite_to_family(family.id, node_id); + + let env = tester.env(); + try_revoke_family_invitation( + tester.deps_mut(), + env.clone(), + message_info(&alice, &[]), + node_id, + )?; + + let storage = NodeFamiliesStorage::new(); + assert!(storage + .pending_family_invitations + .may_load(tester.deps().storage, (family.id, node_id))? + .is_none()); + + let archived = storage + .past_family_invitations + .load(tester.deps().storage, ((family.id, node_id), 0))?; + assert!(matches!( + archived.status, + FamilyInvitationStatus::Revoked { at } if at == env.block.time.seconds() + )); + Ok(()) + } + + #[test] + fn rejects_when_sender_owns_no_family() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let env = tester.env(); + + let err = + try_revoke_family_invitation(tester.deps_mut(), env, message_info(&alice, &[]), 42) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntOwnAFamily { address: alice } + ); + } + + #[test] + fn rejects_when_no_pending_invitation_for_node() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let env = tester.env(); + + let err = + try_revoke_family_invitation(tester.deps_mut(), env, message_info(&alice, &[]), 42) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::InvitationNotFound { + family_id: family.id, + node_id: 42, + } + ); + } + + #[test] + fn cannot_revoke_another_familys_invitation() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let bob = tester.addr_make("bob"); + tester.make_family(&alice); + let bob_family = tester.make_family(&bob); + let node_id = 7; + tester.invite_to_family(bob_family.id, node_id); + + // alice is targeting node 7 β€” but there is no pending invitation + // in *her* family for it, so the lookup misses against alice's id + let env = tester.env(); + let err = try_revoke_family_invitation( + tester.deps_mut(), + env, + message_info(&alice, &[]), + node_id, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::InvitationNotFound { + family_id: 1, + node_id, + } + ); + + // bob's invitation is still pending and untouched + let still_pending = NodeFamiliesStorage::new() + .pending_family_invitations + .may_load(tester.deps().storage, (bob_family.id, node_id)) + .unwrap(); + assert!(still_pending.is_some()); + } + + #[test] + fn revoking_expired_invitation_is_allowed() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = 7; + // already-expired (expires_at = 1, well before block.time) + tester.invite_to_family_with_expiration(family.id, node_id, 1); + + let env = tester.env(); + try_revoke_family_invitation( + tester.deps_mut(), + env, + message_info(&alice, &[]), + node_id, + )?; + + // pending entry is gone + assert!(NodeFamiliesStorage::new() + .pending_family_invitations + .may_load(tester.deps().storage, (family.id, node_id))? + .is_none()); + Ok(()) + } + } + + mod accept_family_invitation { + use super::*; + use crate::testing::NodeFamiliesContractTesterExt; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; + use nym_node_families_contract_common::FamilyInvitationStatus; + + #[test] + fn happy_path_records_membership_and_archives_accepted() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + tester.invite_to_family(family.id, node_id); + + let env = tester.env(); + try_accept_family_invitation( + tester.deps_mut(), + env.clone(), + message_info(&bob, &[]), + family.id, + node_id, + )?; + + let storage = NodeFamiliesStorage::new(); + assert!(storage + .pending_family_invitations + .may_load(tester.deps().storage, (family.id, node_id))? + .is_none()); + + let membership = storage + .family_members + .load(tester.deps().storage, node_id)?; + assert_eq!(membership.family_id, family.id); + assert_eq!(membership.joined_at, env.block.time.seconds()); + + let updated = storage.families.load(tester.deps().storage, family.id)?; + assert_eq!(updated.members, 1); + + let archived = storage + .past_family_invitations + .load(tester.deps().storage, ((family.id, node_id), 0))?; + assert!(matches!( + archived.status, + FamilyInvitationStatus::Accepted { at } if at == env.block.time.seconds() + )); + Ok(()) + } + + #[test] + fn rejects_when_sender_controls_no_bonded_node() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + tester.invite_to_family(family.id, node_id); + + // mallory doesn't control any bonded node + let mallory = tester.addr_make("mallory"); + let env = tester.env(); + let err = try_accept_family_invitation( + tester.deps_mut(), + env, + message_info(&mallory, &[]), + family.id, + node_id, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntControlNode { + address: mallory, + node_id, + } + ); + Ok(()) + } + + #[test] + fn rejects_when_sender_controls_a_different_node() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let bob = tester.generate_account_with_balance(); + let bob_node = tester.bond_dummy_nymnode_for(&bob)?; + // invitation targets a different (also-bonded) node + let other_node = tester.bond_dummy_nymnode()?; + tester.invite_to_family(family.id, other_node); + + let env = tester.env(); + let err = try_accept_family_invitation( + tester.deps_mut(), + env, + message_info(&bob, &[]), + family.id, + other_node, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntControlNode { + address: bob, + node_id: other_node, + } + ); + // sanity: bob really does control bob_node + assert_ne!(bob_node, other_node); + Ok(()) + } + + #[test] + fn rejects_when_sender_node_is_unbonding() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + tester.invite_to_family(family.id, node_id); + + // unbond_nymnode advances the epoch, fully removing the bond from + // the mixnet store β€” the ownership query then returns None, which + // surfaces as the same SenderDoesntControlNode error as + // "no bonded node at all". + tester.unbond_nymnode(node_id)?; + + let env = tester.env(); + let err = try_accept_family_invitation( + tester.deps_mut(), + env, + message_info(&bob, &[]), + family.id, + node_id, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntControlNode { + address: bob, + node_id, + } + ); + Ok(()) + } + + #[test] + fn rejects_when_no_pending_invitation_exists() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + + let env = tester.env(); + let err = try_accept_family_invitation( + tester.deps_mut(), + env, + message_info(&bob, &[]), + family.id, + node_id, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::InvitationNotFound { + family_id: family.id, + node_id, + } + ); + Ok(()) + } + + #[test] + fn rejects_when_invitation_is_expired() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + + let env = tester.env(); + // expires_at == now triggers the `now >= expires_at` branch + tester.invite_to_family_with_expiration(family.id, node_id, env.block.time.seconds()); + + let err = try_accept_family_invitation( + tester.deps_mut(), + env.clone(), + message_info(&bob, &[]), + family.id, + node_id, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::InvitationExpired { + family_id: family.id, + node_id, + expires_at: env.block.time.seconds(), + now: env.block.time.seconds(), + } + ); + Ok(()) + } + + #[test] + fn rejects_when_node_already_in_another_family() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let alice_family = tester.make_family(&alice); + let bob = tester.addr_make("bob"); + let bob_family = tester.make_family(&bob); + + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + + // bob's node joins bob's family first, then tries to accept + // alice's still-pending invitation + tester.invite_to_family(alice_family.id, node_id); + tester.add_to_family(bob_family.id, node_id); + + let env = tester.env(); + let err = try_accept_family_invitation( + tester.deps_mut(), + env, + message_info(&bob, &[]), + alice_family.id, + node_id, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::NodeAlreadyInFamily { + node_id, + family_id: bob_family.id, + } + ); + + // membership is unchanged β€” still in bob's family + let membership = NodeFamiliesStorage::new() + .family_members + .load(tester.deps().storage, node_id)?; + assert_eq!(membership.family_id, bob_family.id); + Ok(()) + } + } + + mod reject_family_invitation { + use super::*; + use crate::testing::NodeFamiliesContractTesterExt; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; + use nym_node_families_contract_common::FamilyInvitationStatus; + + #[test] + fn happy_path_removes_pending_and_archives_rejected() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + tester.invite_to_family(family.id, node_id); + + let env = tester.env(); + try_reject_family_invitation( + tester.deps_mut(), + env.clone(), + message_info(&bob, &[]), + family.id, + node_id, + )?; + + let storage = NodeFamiliesStorage::new(); + assert!(storage + .pending_family_invitations + .may_load(tester.deps().storage, (family.id, node_id))? + .is_none()); + + let archived = storage + .past_family_invitations + .load(tester.deps().storage, ((family.id, node_id), 0))?; + assert!(matches!( + archived.status, + FamilyInvitationStatus::Rejected { at } if at == env.block.time.seconds() + )); + + // membership was never recorded + assert!(storage + .family_members + .may_load(tester.deps().storage, node_id)? + .is_none()); + Ok(()) + } + + #[test] + fn rejects_when_sender_controls_no_bonded_node() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + tester.invite_to_family(family.id, node_id); + + let mallory = tester.addr_make("mallory"); + let env = tester.env(); + let err = try_reject_family_invitation( + tester.deps_mut(), + env, + message_info(&mallory, &[]), + family.id, + node_id, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntControlNode { + address: mallory, + node_id, + } + ); + Ok(()) + } + + #[test] + fn rejects_when_sender_controls_a_different_node() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let bob = tester.generate_account_with_balance(); + let bob_node = tester.bond_dummy_nymnode_for(&bob)?; + let other_node = tester.bond_dummy_nymnode()?; + tester.invite_to_family(family.id, other_node); + + let env = tester.env(); + let err = try_reject_family_invitation( + tester.deps_mut(), + env, + message_info(&bob, &[]), + family.id, + other_node, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntControlNode { + address: bob, + node_id: other_node, + } + ); + assert_ne!(bob_node, other_node); + Ok(()) + } + + #[test] + fn rejects_when_sender_node_is_unbonding() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + tester.invite_to_family(family.id, node_id); + + tester.unbond_nymnode(node_id)?; + + let env = tester.env(); + let err = try_reject_family_invitation( + tester.deps_mut(), + env, + message_info(&bob, &[]), + family.id, + node_id, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntControlNode { + address: bob, + node_id, + } + ); + Ok(()) + } + + #[test] + fn rejects_when_no_pending_invitation_exists() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + + let env = tester.env(); + let err = try_reject_family_invitation( + tester.deps_mut(), + env, + message_info(&bob, &[]), + family.id, + node_id, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::InvitationNotFound { + family_id: family.id, + node_id, + } + ); + Ok(()) + } + + #[test] + fn rejecting_expired_invitation_is_allowed() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + + let env = tester.env(); + // already-expired (expires_at == now) + tester.invite_to_family_with_expiration(family.id, node_id, env.block.time.seconds()); + + try_reject_family_invitation( + tester.deps_mut(), + env.clone(), + message_info(&bob, &[]), + family.id, + node_id, + )?; + + let storage = NodeFamiliesStorage::new(); + assert!(storage + .pending_family_invitations + .may_load(tester.deps().storage, (family.id, node_id))? + .is_none()); + let archived = storage + .past_family_invitations + .load(tester.deps().storage, ((family.id, node_id), 0))?; + assert!(matches!( + archived.status, + FamilyInvitationStatus::Rejected { at } if at == env.block.time.seconds() + )); + Ok(()) + } + } + + mod leave_family { + use super::*; + use crate::testing::NodeFamiliesContractTesterExt; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; + + #[test] + fn happy_path_drops_membership_and_archives_past_member() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + tester.add_to_family(family.id, node_id); + + // sanity: family has the member, count is 1 + let storage = NodeFamiliesStorage::new(); + assert_eq!( + storage + .families + .load(tester.deps().storage, family.id)? + .members, + 1 + ); + + let env = tester.env(); + try_leave_family( + tester.deps_mut(), + env.clone(), + message_info(&bob, &[]), + node_id, + )?; + + // membership gone + assert!(storage + .family_members + .may_load(tester.deps().storage, node_id)? + .is_none()); + + // family count decremented + let updated = storage.families.load(tester.deps().storage, family.id)?; + assert_eq!(updated.members, 0); + + // archived as past member + let past = storage + .past_family_members + .load(tester.deps().storage, ((family.id, node_id), 0))?; + assert_eq!(past.family_id, family.id); + assert_eq!(past.node_id, node_id); + assert_eq!(past.removed_at, env.block.time.seconds()); + Ok(()) + } + + #[test] + fn rejects_when_sender_controls_no_bonded_node() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + tester.add_to_family(family.id, node_id); + + let mallory = tester.addr_make("mallory"); + let env = tester.env(); + let err = + try_leave_family(tester.deps_mut(), env, message_info(&mallory, &[]), node_id) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntControlNode { + address: mallory, + node_id, + } + ); + Ok(()) + } + + #[test] + fn rejects_when_sender_controls_a_different_node() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let bob = tester.generate_account_with_balance(); + let bob_node = tester.bond_dummy_nymnode_for(&bob)?; + let other_node = tester.bond_dummy_nymnode()?; + tester.add_to_family(family.id, other_node); + + let env = tester.env(); + let err = try_leave_family(tester.deps_mut(), env, message_info(&bob, &[]), other_node) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntControlNode { + address: bob, + node_id: other_node, + } + ); + assert_ne!(bob_node, other_node); + Ok(()) + } + + #[test] + fn rejects_when_sender_node_is_unbonding() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + tester.add_to_family(family.id, node_id); + + tester.unbond_nymnode(node_id)?; + + let env = tester.env(); + let err = try_leave_family(tester.deps_mut(), env, message_info(&bob, &[]), node_id) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntControlNode { + address: bob, + node_id, + } + ); + Ok(()) + } + + #[test] + fn rejects_when_node_is_not_in_any_family() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + + let env = tester.env(); + let err = try_leave_family(tester.deps_mut(), env, message_info(&bob, &[]), node_id) + .unwrap_err(); + assert_eq!(err, NodeFamiliesContractError::NodeNotInFamily { node_id }); + Ok(()) + } + } + + mod kick_from_family { + use super::*; + use crate::testing::NodeFamiliesContractTesterExt; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; + + #[test] + fn happy_path_drops_membership_and_archives_past_member() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + tester.add_to_family(family.id, node_id); + + let env = tester.env(); + try_kick_from_family( + tester.deps_mut(), + env.clone(), + message_info(&alice, &[]), + node_id, + )?; + + let storage = NodeFamiliesStorage::new(); + assert!(storage + .family_members + .may_load(tester.deps().storage, node_id)? + .is_none()); + + let updated = storage.families.load(tester.deps().storage, family.id)?; + assert_eq!(updated.members, 0); + + let past = storage + .past_family_members + .load(tester.deps().storage, ((family.id, node_id), 0))?; + assert_eq!(past.family_id, family.id); + assert_eq!(past.node_id, node_id); + assert_eq!(past.removed_at, env.block.time.seconds()); + Ok(()) + } + + #[test] + fn rejects_when_sender_owns_no_family() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let env = tester.env(); + + let err = try_kick_from_family(tester.deps_mut(), env, message_info(&alice, &[]), 42) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntOwnAFamily { address: alice } + ); + } + + #[test] + fn rejects_when_node_is_not_in_any_family() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + tester.make_family(&alice); + let env = tester.env(); + + let err = try_kick_from_family(tester.deps_mut(), env, message_info(&alice, &[]), 42) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::NodeNotInFamily { node_id: 42 } + ); + } + + #[test] + fn cannot_kick_member_of_another_family() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let bob = tester.addr_make("bob"); + let alice_family = tester.make_family(&alice); + let bob_family = tester.make_family(&bob); + + let node_id = tester.bond_dummy_nymnode()?; + tester.add_to_family(bob_family.id, node_id); + + // alice is targeting a node in bob's family β€” must error rather + // than silently strip the membership + let env = tester.env(); + let err = + try_kick_from_family(tester.deps_mut(), env, message_info(&alice, &[]), node_id) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::NodeNotMemberOfFamily { + node_id, + family_id: alice_family.id, + } + ); + + // bob's membership and family count are untouched + let storage = NodeFamiliesStorage::new(); + let mem = storage + .family_members + .load(tester.deps().storage, node_id)?; + assert_eq!(mem.family_id, bob_family.id); + let bob_fam = storage + .families + .load(tester.deps().storage, bob_family.id)?; + assert_eq!(bob_fam.members, 1); + Ok(()) + } + + #[test] + fn cannot_kick_member_already_cleared_by_unbond_callback() -> anyhow::Result<()> { + // The mixnet contract dispatches `OnNymNodeUnbond` to the families + // contract synchronously when a node initiates unbonding, so by + // the time `unbond_nymnode` returns the membership is already gone. + // A subsequent manual kick from the owner has nothing left to act + // on and surfaces `NodeNotInFamily`. + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + tester.add_to_family(family.id, node_id); + tester.unbond_nymnode(node_id)?; + + // sanity: the unbond callback already cleaned up the membership + let storage = NodeFamiliesStorage::new(); + assert!(storage + .family_members + .may_load(tester.deps().storage, node_id)? + .is_none()); + + let env = tester.env(); + let err = + try_kick_from_family(tester.deps_mut(), env, message_info(&alice, &[]), node_id) + .unwrap_err(); + assert_eq!(err, NodeFamiliesContractError::NodeNotInFamily { node_id }); + Ok(()) + } + } + + mod handle_node_unbonding { + use super::*; + use crate::testing::NodeFamiliesContractTesterExt; + use cosmwasm_std::Addr; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; + use nym_node_families_contract_common::FamilyInvitationStatus; + + fn mixnet_addr(tester: &impl NodeFamiliesContractTesterExt) -> Addr { + NodeFamiliesStorage::new() + .mixnet_contract_address + .load(tester.deps().storage) + .unwrap() + } + + #[test] + fn rejects_when_sender_is_not_the_mixnet_contract() { + let mut tester = init_contract_tester(); + let mallory = tester.addr_make("mallory"); + let env = tester.env(); + + let err = + try_handle_node_unbonding(tester.deps_mut(), env, message_info(&mallory, &[]), 42) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::UnauthorisedMixnetCallback { sender: mallory } + ); + } + + #[test] + fn no_op_when_node_has_no_membership_and_no_invitations() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let env = tester.env(); + let mixnet = mixnet_addr(&tester); + + // node 42 has nothing in the contract β€” callback succeeds anyway + try_handle_node_unbonding(tester.deps_mut(), env, message_info(&mixnet, &[]), 42)?; + Ok(()) + } + + #[test] + fn drops_membership_when_node_is_a_member() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + tester.add_to_family(family.id, node_id); + + let env = tester.env(); + let mixnet = mixnet_addr(&tester); + try_handle_node_unbonding( + tester.deps_mut(), + env.clone(), + message_info(&mixnet, &[]), + node_id, + )?; + + let storage = NodeFamiliesStorage::new(); + assert!(storage + .family_members + .may_load(tester.deps().storage, node_id)? + .is_none()); + let updated = storage.families.load(tester.deps().storage, family.id)?; + assert_eq!(updated.members, 0); + let past = storage + .past_family_members + .load(tester.deps().storage, ((family.id, node_id), 0))?; + assert_eq!(past.removed_at, env.block.time.seconds()); + Ok(()) + } + + #[test] + fn sweeps_pending_invitations_addressed_to_node() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let bob = tester.addr_make("bob"); + let alice_family = tester.make_family(&alice); + let bob_family = tester.make_family(&bob); + + let node_id = tester.bond_dummy_nymnode()?; + tester.invite_to_family(alice_family.id, node_id); + tester.invite_to_family(bob_family.id, node_id); + + let env = tester.env(); + let mixnet = mixnet_addr(&tester); + try_handle_node_unbonding( + tester.deps_mut(), + env.clone(), + message_info(&mixnet, &[]), + node_id, + )?; + + let storage = NodeFamiliesStorage::new(); + // both pending invitations are gone + assert!(storage + .pending_family_invitations + .may_load(tester.deps().storage, (alice_family.id, node_id))? + .is_none()); + assert!(storage + .pending_family_invitations + .may_load(tester.deps().storage, (bob_family.id, node_id))? + .is_none()); + + // both archived as Rejected + for fam_id in [alice_family.id, bob_family.id] { + let past = storage + .past_family_invitations + .load(tester.deps().storage, ((fam_id, node_id), 0))?; + assert!(matches!( + past.status, + FamilyInvitationStatus::Rejected { at } if at == env.block.time.seconds() + )); + } + Ok(()) + } + + #[test] + fn handles_membership_and_invitation_sweep_together() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let bob = tester.addr_make("bob"); + let alice_family = tester.make_family(&alice); + let bob_family = tester.make_family(&bob); + + let node_id = tester.bond_dummy_nymnode()?; + // node is a member of alice's family AND has a pending invite from bob's + tester.add_to_family(alice_family.id, node_id); + tester.invite_to_family(bob_family.id, node_id); + + let env = tester.env(); + let mixnet = mixnet_addr(&tester); + try_handle_node_unbonding( + tester.deps_mut(), + env.clone(), + message_info(&mixnet, &[]), + node_id, + )?; + + let storage = NodeFamiliesStorage::new(); + // membership gone + assert!(storage + .family_members + .may_load(tester.deps().storage, node_id)? + .is_none()); + assert_eq!( + storage + .families + .load(tester.deps().storage, alice_family.id)? + .members, + 0 + ); + // past member record stamped + let past_member = storage + .past_family_members + .load(tester.deps().storage, ((alice_family.id, node_id), 0))?; + assert_eq!(past_member.removed_at, env.block.time.seconds()); + + // pending invitation from bob's family is swept + assert!(storage + .pending_family_invitations + .may_load(tester.deps().storage, (bob_family.id, node_id))? + .is_none()); + let archived = storage + .past_family_invitations + .load(tester.deps().storage, ((bob_family.id, node_id), 0))?; + assert!(matches!( + archived.status, + FamilyInvitationStatus::Rejected { at } if at == env.block.time.seconds() + )); + Ok(()) + } + + #[test] + fn unrelated_invitations_are_left_untouched() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let alice_family = tester.make_family(&alice); + + let unbonding_node = tester.bond_dummy_nymnode()?; + let other_node = tester.bond_dummy_nymnode()?; + tester.invite_to_family(alice_family.id, unbonding_node); + tester.invite_to_family(alice_family.id, other_node); + + let env = tester.env(); + let mixnet = mixnet_addr(&tester); + try_handle_node_unbonding( + tester.deps_mut(), + env, + message_info(&mixnet, &[]), + unbonding_node, + )?; + + let storage = NodeFamiliesStorage::new(); + // unbonding node's invitation is gone + assert!(storage + .pending_family_invitations + .may_load(tester.deps().storage, (alice_family.id, unbonding_node))? + .is_none()); + // the unrelated invitation is still pending + assert!(storage + .pending_family_invitations + .may_load(tester.deps().storage, (alice_family.id, other_node))? + .is_some()); + Ok(()) + } + } +} diff --git a/contracts/performance/Cargo.toml b/contracts/performance/Cargo.toml index 78069a02de..18d434f578 100644 --- a/contracts/performance/Cargo.toml +++ b/contracts/performance/Cargo.toml @@ -36,6 +36,13 @@ nym-contracts-common-testing = { workspace = true } nym-mixnet-contract = { workspace = true, features = ["testable-mixnet-contract"] } nym-crypto = { workspace = true, features = ["asymmetric", "rand"] } +# Needed only by the test harness: the embedded mixnet contract dispatches an +# `OnNymNodeUnbond` WasmMsg on `try_remove_nym_node` and the target must be a +# real contract. We instantiate the families contract alongside so the call +# lands somewhere that knows how to handle it. +node-families = { workspace = true, features = ["testable-node-families-contract"] } +nym-node-families-contract-common = { workspace = true } + [features] schema-gen = ["nym-performance-contract-common/schema", "cosmwasm-schema"] diff --git a/contracts/performance/src/helpers.rs b/contracts/performance/src/helpers.rs deleted file mode 100644 index 8219910787..0000000000 --- a/contracts/performance/src/helpers.rs +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright 2025 - Nym Technologies SA -// SPDX-License-Identifier: Apache-2.0 - -use cosmwasm_std::{from_json, Binary, CustomQuery, QuerierWrapper, StdError, StdResult}; -use cw_storage_plus::{Key, Namespace, Path, PrimaryKey}; -use nym_mixnet_contract_common::{Interval, NymNodeBond}; -use nym_performance_contract_common::{EpochId, NodeId}; -use serde::de::DeserializeOwned; -use std::ops::Deref; - -pub(crate) trait MixnetContractQuerier { - #[allow(dead_code)] - fn query_mixnet_contract( - &self, - address: impl Into, - msg: &nym_mixnet_contract_common::QueryMsg, - ) -> StdResult; - - fn query_mixnet_contract_storage( - &self, - address: impl Into, - key: impl Into, - ) -> StdResult>>; - - fn query_mixnet_contract_storage_value( - &self, - address: impl Into, - key: impl Into, - ) -> StdResult> { - match self.query_mixnet_contract_storage(address, key)? { - None => Ok(None), - Some(value) => Ok(Some(from_json(&value)?)), - } - } - - fn query_current_mixnet_interval(&self, address: impl Into) -> StdResult { - self.query_mixnet_contract_storage_value(address, b"ci")? - .ok_or(StdError::not_found( - "unable to retrieve interval information from the mixnet contract storage", - )) - } - - fn query_current_absolute_mixnet_epoch_id( - &self, - address: impl Into, - ) -> StdResult { - self.query_current_mixnet_interval(address) - .map(|interval| interval.current_epoch_absolute_id()) - } - - fn check_node_existence(&self, address: impl Into, node_id: NodeId) -> StdResult { - let mixnet_contract_address = address.into(); - - if let Some(nym_node) = self.query_nymnode_bond(mixnet_contract_address.clone(), node_id)? { - return Ok(!nym_node.is_unbonding); - } - - Ok(false) - } - - fn query_nymnode_bond( - &self, - address: impl Into, - node_id: NodeId, - ) -> StdResult> { - // construct proper map key - let pk_namespace = "nn"; - let path: Path = Path::new( - Namespace::from_static_str(pk_namespace).as_slice(), - &node_id.key().iter().map(Key::as_ref).collect::>(), - ); - let storage_key = path.deref(); - - self.query_mixnet_contract_storage_value(address, storage_key) - } -} - -impl MixnetContractQuerier for QuerierWrapper<'_, C> -where - C: CustomQuery, -{ - fn query_mixnet_contract( - &self, - address: impl Into, - msg: &nym_mixnet_contract_common::QueryMsg, - ) -> StdResult { - self.query_wasm_smart(address, msg) - } - - fn query_mixnet_contract_storage( - &self, - address: impl Into, - key: impl Into, - ) -> StdResult>> { - self.query_wasm_raw(address, key) - } -} diff --git a/contracts/performance/src/lib.rs b/contracts/performance/src/lib.rs index 08a2b9651d..d97317410b 100644 --- a/contracts/performance/src/lib.rs +++ b/contracts/performance/src/lib.rs @@ -5,7 +5,6 @@ pub mod contract; pub mod queued_migrations; pub mod storage; -mod helpers; mod queries; mod transactions; diff --git a/contracts/performance/src/queries.rs b/contracts/performance/src/queries.rs index 5fe2c3ed91..c32cbd6a4c 100644 --- a/contracts/performance/src/queries.rs +++ b/contracts/performance/src/queries.rs @@ -316,6 +316,7 @@ pub fn query_last_submission(deps: Deps) -> Result // SPDX-License-Identifier: Apache-2.0 -use crate::helpers::MixnetContractQuerier; use cosmwasm_std::{Addr, Deps, DepsMut, Env, StdError, Storage}; use cw_controllers::Admin; use cw_storage_plus::{Item, Map}; use nym_contracts_common::Percent; +use nym_mixnet_contract_common::MixnetContractQuerier; use nym_performance_contract_common::constants::storage_keys; use nym_performance_contract_common::{ BatchSubmissionResult, EpochId, LastSubmission, LastSubmittedData, NetworkMonitorDetails, @@ -580,6 +580,7 @@ mod tests { mod performance_contract_storage { use super::*; use crate::testing::{init_contract_tester, PerformanceContractTesterExt, PreInitContract}; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; use nym_contracts_common_testing::{AdminExt, ContractOpts}; #[cfg(test)] @@ -2856,6 +2857,7 @@ mod tests { mod performance_storage { use super::*; use crate::testing::{init_contract_tester, PerformanceContractTesterExt}; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; use nym_contracts_common_testing::ContractOpts; use std::str::FromStr; diff --git a/contracts/performance/src/testing/mod.rs b/contracts/performance/src/testing/mod.rs index 33e90beaf3..bdf0df09f0 100644 --- a/contracts/performance/src/testing/mod.rs +++ b/contracts/performance/src/testing/mod.rs @@ -2,15 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 use crate::contract::{execute, instantiate, migrate, query}; -use crate::helpers::MixnetContractQuerier; use crate::storage::NYM_PERFORMANCE_CONTRACT_STORAGE; -use cosmwasm_std::testing::{message_info, mock_env, MockApi}; +use cosmwasm_std::testing::{mock_env, MockApi}; use cosmwasm_std::{ - coin, coins, Addr, ContractInfo, Deps, DepsMut, Env, MessageInfo, QuerierWrapper, StdError, - StdResult, + coin, Addr, ContractInfo, Deps, DepsMut, Env, QuerierWrapper, StdError, StdResult, }; -use mixnet_contract::testable_mixnet_contract::MixnetContract; -use nym_contracts_common::signing::{ContractMessageContent, MessageSignature}; +use mixnet_contract::testable_mixnet_contract::{EmbeddedMixnetContractExt, MixnetContract}; +use node_families_contract::testing::NodeFamiliesContract; use nym_contracts_common::Percent; use nym_contracts_common_testing::{ addr, AdminExt, ArbitraryContractStorageReader, ArbitraryContractStorageWriter, BankExt, @@ -18,19 +16,15 @@ use nym_contracts_common_testing::{ ContractTesterBuilder, DenomExt, PermissionedFn, QueryFn, RandExt, TestableNymContract, TEST_DENOM, }; -use nym_crypto::asymmetric::ed25519; -use nym_mixnet_contract_common::nym_node::{NodeDetailsResponse, NodeOwnershipResponse, Role}; -use nym_mixnet_contract_common::{ - CurrentIntervalResponse, EpochId, Interval, NodeCostParams, NymNode, NymNodeBondingPayload, - RoleAssignment, SignableNymNodeBondingMsg, DEFAULT_INTERVAL_OPERATING_COST_AMOUNT, - DEFAULT_PROFIT_MARGIN_PERCENT, +use nym_mixnet_contract_common::{ContractState, EpochId}; +use nym_node_families_contract_common::{ + Config as NodeFamiliesConfig, InstantiateMsg as NodeFamiliesInstantiateMsg, }; use nym_performance_contract_common::constants::storage_keys; use nym_performance_contract_common::{ ExecuteMsg, InstantiateMsg, MigrateMsg, NodeId, NodePerformance, NodeResults, NymPerformanceContractError, QueryMsg, }; -use serde::de::DeserializeOwned; use serde::Serialize; use std::str::FromStr; @@ -80,6 +74,22 @@ impl TestableNymContract for PerformanceContract { .unwrap() .clone(); + // The embedded mixnet's `try_remove_nym_node` always emits an + // `OnNymNodeUnbond` WasmMsg to the configured families contract; + // instantiate one alongside so that target exists and accepts the call. + // `init_contract_tester` patches the mixnet's stored families address + // to point at this instance after the build completes. + let builder = + builder.instantiate::(Some(NodeFamiliesInstantiateMsg { + config: NodeFamiliesConfig { + create_family_fee: coin(100_000000, TEST_DENOM), + family_name_length_limit: 20, + family_description_length_limit: 200, + default_invitation_validity_secs: 24 * 60 * 60, + }, + mixnet_contract_address: mixnet_address.to_string(), + })); + builder .instantiate::(Some(InstantiateMsg { mixnet_contract_address: mixnet_address.to_string(), @@ -89,9 +99,35 @@ impl TestableNymContract for PerformanceContract { } } +/// Storage key the mixnet contract uses for its `ContractState` `Item` +/// (mirrors `mixnet/src/constants.rs::CONTRACT_STATE_KEY`). +const MIXNET_CONTRACT_STATE_STORAGE_KEY: &str = "state"; + pub fn init_contract_tester() -> ContractTester { - PerformanceContract::init() - .with_common_storage_key(CommonStorageKeys::Admin, storage_keys::CONTRACT_ADMIN) + let mut tester = PerformanceContract::init() + .with_common_storage_key(CommonStorageKeys::Admin, storage_keys::CONTRACT_ADMIN); + + // Chicken-and-egg: the mixnet contract was instantiated first with a + // placeholder `node_families_contract_address`. Now that the families + // contract exists, patch the mixnet's `ContractState` so its + // `OnNymNodeUnbond` dispatch lands on the real contract instead of the + // placeholder. In production this fixup happens via a contract migration; + // here we go straight to storage to avoid the cw2 version check that + // blocks migrating a freshly-instantiated contract. + let families_address = tester + .well_known_contracts + .get(NodeFamiliesContract::NAME) + .expect("families contract should have been instantiated") + .clone(); + let mut mixnet_state: ContractState = tester + .read_from_mixnet_contract_storage(MIXNET_CONTRACT_STATE_STORAGE_KEY) + .expect("mixnet contract state should be loadable"); + mixnet_state.node_families_contract_address = families_address; + tester + .write_to_mixnet_contract_storage_value(MIXNET_CONTRACT_STATE_STORAGE_KEY, &mixnet_state) + .expect("should be able to patch mixnet contract state"); + + tester } // we need to be able to test instantiation, but for that we require @@ -214,136 +250,8 @@ pub(crate) trait PerformanceContractTesterExt: + BankExt + ArbitraryContractStorageReader + ArbitraryContractStorageWriter + + EmbeddedMixnetContractExt { - fn mixnet_contract_address(&self) -> StdResult { - NYM_PERFORMANCE_CONTRACT_STORAGE - .mixnet_contract_address - .load(self.deps().storage) - } - - fn execute_mixnet_contract( - &mut self, - sender: MessageInfo, - msg: &nym_mixnet_contract_common::ExecuteMsg, - ) -> StdResult<()> { - let address = self.mixnet_contract_address()?; - - self.execute_arbitrary_contract(address, sender, msg) - .map_err(|err| { - StdError::generic_err(format!("mixnet contract execution failure: {err}")) - })?; - Ok(()) - } - - fn read_from_mixnet_contract_storage( - &self, - key: impl AsRef<[u8]>, - ) -> StdResult { - let address = self.mixnet_contract_address()?; - - self.must_read_value_from_contract_storage(address, key) - } - - fn write_to_mixnet_contract_storage( - &mut self, - key: impl AsRef<[u8]>, - value: impl AsRef<[u8]>, - ) -> StdResult<()> { - let address = self.mixnet_contract_address()?; - - ::set_contract_storage(self, address, key, value); - Ok(()) - } - - fn write_to_mixnet_contract_storage_value( - &mut self, - key: impl AsRef<[u8]>, - value: &T, - ) -> StdResult<()> { - let address = self.mixnet_contract_address()?; - - self.set_contract_storage_value(address, key, value) - } - - fn current_mixnet_epoch(&self) -> StdResult { - let address = self.mixnet_contract_address()?; - - Ok(self - .deps() - .querier - .query_current_mixnet_interval(address.clone())? - .current_epoch_absolute_id()) - } - - fn advance_mixnet_epoch(&mut self) -> StdResult<()> { - let interval_details: CurrentIntervalResponse = self.query_arbitrary_contract( - self.mixnet_contract_address()?, - &nym_mixnet_contract_common::QueryMsg::GetCurrentIntervalDetails {}, - )?; - let until_end = interval_details.time_until_current_epoch_end().as_secs(); - let timestamp = self.env().block.time.plus_seconds(until_end + 1); - self.set_block_time(timestamp); - self.next_block(); - - // this was hardcoded in mixnet init - let mixnet_rewarder = self.addr_make("rewarder"); - let rewarder = message_info(&mixnet_rewarder, &[]); - self.execute_mixnet_contract( - rewarder.clone(), - &nym_mixnet_contract_common::ExecuteMsg::BeginEpochTransition {}, - )?; - self.execute_mixnet_contract( - rewarder.clone(), - &nym_mixnet_contract_common::ExecuteMsg::ReconcileEpochEvents { limit: None }, - )?; - - for role in [ - Role::ExitGateway, - Role::EntryGateway, - Role::Layer1, - Role::Layer2, - Role::Layer3, - Role::Standby, - ] { - self.execute_mixnet_contract( - rewarder.clone(), - &nym_mixnet_contract_common::ExecuteMsg::AssignRoles { - assignment: RoleAssignment { - role, - nodes: vec![], - }, - }, - )?; - } - Ok(()) - } - - fn set_mixnet_epoch(&mut self, epoch_id: EpochId) -> StdResult<()> { - let address = self.mixnet_contract_address()?; - - let interval = self - .deps() - .querier - .query_current_mixnet_interval(address.clone())?; - - let mut to_update = if interval.current_epoch_absolute_id() <= epoch_id { - interval - } else { - Interval::init_interval( - interval.epochs_in_interval(), - interval.epoch_length(), - &mock_env(), - ) - }; - - let current = to_update.current_epoch_absolute_id(); - let diff = epoch_id - current; - for _ in 0..diff { - to_update = to_update.advance_epoch(); - } - self.set_contract_storage_value(&address, b"ci", &to_update) - } - fn authorise_network_monitor( &mut self, addr: &Addr, @@ -435,68 +343,6 @@ pub(crate) trait PerformanceContractTesterExt: .load(self.deps().storage, (epoch_id, node_id))?; Ok(scores) } - - fn bond_dummy_nymnode(&mut self) -> Result { - let node_owner = self.generate_account_with_balance(); - let pledge = coins(100_000000, TEST_DENOM); - let keypair = ed25519::KeyPair::new(self.raw_rng()); - let identity_key = keypair.public_key().to_base58_string(); - - let node = NymNode { - host: "1.2.3.4".to_string(), - custom_http_port: None, - identity_key, - }; - let cost_params = NodeCostParams { - profit_margin_percent: Percent::from_percentage_value(DEFAULT_PROFIT_MARGIN_PERCENT) - .unwrap(), - interval_operating_cost: coin(DEFAULT_INTERVAL_OPERATING_COST_AMOUNT, TEST_DENOM), - }; - // initial signing nonce is 0 for a new address - let signing_nonce = 0; - - let payload = NymNodeBondingPayload::new(node.clone(), cost_params.clone()); - let content = ContractMessageContent::new(node_owner.clone(), pledge.clone(), payload); - let msg = SignableNymNodeBondingMsg::new(signing_nonce, content); - - let owner_signature = keypair.private_key().sign(msg.to_plaintext()?); - let owner_signature = MessageSignature::from(owner_signature.to_bytes().as_ref()); - - self.execute_mixnet_contract( - message_info(&node_owner, &pledge), - &nym_mixnet_contract_common::ExecuteMsg::BondNymNode { - node, - cost_params, - owner_signature, - }, - )?; - - let bond: NodeOwnershipResponse = self.query_arbitrary_contract( - self.mixnet_contract_address()?, - &nym_mixnet_contract_common::QueryMsg::GetOwnedNymNode { - address: node_owner.to_string(), - }, - )?; - - Ok(bond.details.unwrap().bond_information.node_id) - } - - fn unbond_nymnode(&mut self, node_id: NodeId) -> Result<(), NymPerformanceContractError> { - let bond: NodeDetailsResponse = self.query_arbitrary_contract( - self.mixnet_contract_address()?, - &nym_mixnet_contract_common::QueryMsg::GetNymNodeDetails { node_id }, - )?; - - let node_owner = bond.details.unwrap().bond_information.owner; - - self.execute_mixnet_contract( - message_info(&node_owner, &[]), - &nym_mixnet_contract_common::ExecuteMsg::UnbondNymNode {}, - )?; - - self.advance_mixnet_epoch()?; - Ok(()) - } } impl PerformanceContractTesterExt for ContractTester {} diff --git a/contracts/performance/src/transactions.rs b/contracts/performance/src/transactions.rs index 86a2d99772..3f9fe4aaed 100644 --- a/contracts/performance/src/transactions.rs +++ b/contracts/performance/src/transactions.rs @@ -132,6 +132,7 @@ mod tests { use crate::storage::retrieval_limits; use crate::testing::{init_contract_tester, PerformanceContractTesterExt}; use cosmwasm_std::from_json; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; use nym_contracts_common_testing::{AdminExt, ContractOpts}; use nym_performance_contract_common::RemoveEpochMeasurementsResponse; diff --git a/nym-api/Cargo.toml b/nym-api/Cargo.toml index 93f2e44db4..0435652210 100644 --- a/nym-api/Cargo.toml +++ b/nym-api/Cargo.toml @@ -104,6 +104,7 @@ nym-serde-helpers = { workspace = true, features = ["date"] } nym-ticketbooks-merkle = { workspace = true } nym-statistics-common = { workspace = true } nym-ecash-signer-check = { workspace = true } +nym-node-families-contract-common = { workspace = true } [features] diff --git a/nym-api/nym-api-requests/src/models/mod.rs b/nym-api/nym-api-requests/src/models/mod.rs index 6a0c523a52..46c1fc0820 100644 --- a/nym-api/nym-api-requests/src/models/mod.rs +++ b/nym-api/nym-api-requests/src/models/mod.rs @@ -15,6 +15,7 @@ pub mod legacy; pub mod mixnet; pub mod network; pub mod network_monitor; +pub mod node_families; pub mod node_status; pub mod schema_helpers; pub mod utility; diff --git a/nym-api/nym-api-requests/src/models/node_families.rs b/nym-api/nym-api-requests/src/models/node_families.rs new file mode 100644 index 0000000000..0cad61c59a --- /dev/null +++ b/nym-api/nym-api-requests/src/models/node_families.rs @@ -0,0 +1,130 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use super::CoinSchema; +use cosmwasm_std::Coin; +use nym_mixnet_contract_common::{NodeId, NodeRewarding}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use time::OffsetDateTime; +use utoipa::ToSchema; + +/// Pending family invitation as exposed by the nym-api node-families endpoints. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PendingFamilyInvitation { + /// Node the invitation is addressed to. + pub node_id: NodeId, + + /// Block-time after which the invitation can no longer be accepted. + #[serde(with = "time::serde::rfc3339")] + #[schema(value_type = String)] + pub expires_at: OffsetDateTime, +} + +/// Per-node stake snapshot derived from the mixnet contract's rewarding state. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct NodeStakeInformation { + /// Operator bond + all delegations, with accrued rewards applied. + #[schema(value_type = CoinSchema)] + pub stake: Coin, + + /// Operator pledge component of `stake`. + #[schema(value_type = CoinSchema)] + pub bond: Coin, + + /// Delegations component of `stake`. + #[schema(value_type = CoinSchema)] + pub delegations: Coin, + + /// Number of unique delegators backing this node. + pub delegators: usize, +} + +impl From<&NodeRewarding> for NodeStakeInformation { + fn from(rewarding: &NodeRewarding) -> Self { + let denom = &rewarding.cost_params.interval_operating_cost.denom; + + let bond = rewarding.operator_pledge_with_reward(denom); + let delegations = rewarding.delegations_with_reward(denom); + let mut stake = bond.clone(); + stake.amount += delegations.amount; + + NodeStakeInformation { + stake, + bond, + delegations, + delegators: rewarding.unique_delegations as usize, + } + } +} + +/// Family member view as exposed by the nym-api node-families endpoints. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct NodeFamilyMember { + pub node_id: NodeId, + + /// Block-time at which the node joined the family. + #[serde(with = "time::serde::rfc3339")] + #[schema(value_type = String)] + pub joined_at: OffsetDateTime, + + /// Stake/bond/delegation snapshot; `None` if the node was not in the + /// mixnet-contract cache at refresh time. + pub stake_information: Option, +} + +/// Family view as exposed by the nym-api node-families endpoints, carrying +/// current members, pending invitations and aggregated stats. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct NodeFamily { + /// Unique family identifier assigned by the contract. + pub id: u32, + + /// Display name (canonical form β€” see `normalise_family_name`). + pub name: String, + + /// Free-form family description. + pub description: String, + + /// Owner address (cosmos `Addr` rendered as a string). + pub owner: String, + + /// Average age of members. + #[serde(with = "humantime_serde")] + #[schema(value_type = String)] + pub average_node_age: Duration, + + /// Sum of member stakes; `None` when no member has reportable stake. + #[schema(value_type = Option)] + pub total_stake: Option, + + /// Block-time the family was created. + #[serde(with = "time::serde::rfc3339")] + #[schema(value_type = String)] + pub created_at: OffsetDateTime, + + /// Current members of the family. + pub members: Vec, + + /// Outstanding invitations issued by the family owner. + pub pending_invitations: Vec, +} + +/// Response wrapper for endpoints that look up a single family. `family` is +/// `None` when the lookup did not match (rather than returning a 404). +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct NodeFamilyResponse { + pub family: Option, +} + +/// Response wrapper for endpoints that look up the family a given node +/// belongs to. `family` is `None` when the node is not currently a member of +/// any cached family. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct NodeFamilyForNodeResponse { + /// The node the lookup was performed for. + pub node_id: NodeId, + + /// The family this node belongs to, if any. + pub family: Option, +} diff --git a/nym-api/src/ecash/tests/mod.rs b/nym-api/src/ecash/tests/mod.rs index 2640a25009..20781ccbaf 100644 --- a/nym-api/src/ecash/tests/mod.rs +++ b/nym-api/src/ecash/tests/mod.rs @@ -5,24 +5,11 @@ use crate::ecash::api_routes::handlers::ecash_routes; use crate::ecash::error::{EcashError, Result}; use crate::ecash::keys::KeyPairWithEpoch; use crate::ecash::state::EcashState; -use crate::mixnet_contract_cache::cache::MixnetContractCache; -use crate::network::models::NetworkDetails; -use crate::node_describe_cache::cache::DescribedNodes; -use crate::node_status_api::handlers::unstable; -use crate::node_status_api::NodeStatusCache; -use crate::status::ApiStatusState; +use crate::node_families::cache::NodeFamiliesCacheData; use crate::support::caching::cache::SharedCache; -use crate::support::caching::refresher::RefreshRequester; use crate::support::config; -use crate::support::http::state::chain_status::ChainStatusCache; -use crate::support::http::state::contract_details::ContractDetailsCache; -use crate::support::http::state::force_refresh::ForcedRefresh; -use crate::support::http::state::mixnet_contract_cache::MixnetContractCacheState; -use crate::support::http::state::node_annotations_cache::NodeAnnotationsCache; -use crate::support::http::state::AppState; use crate::support::nyxd::Client; use crate::support::storage::NymApiStorage; -use crate::unstable_routes::v1::account::cache::AddressInfoCache; use async_trait::async_trait; use axum::Router; use axum_test::http::StatusCode; @@ -78,8 +65,6 @@ use std::ops::Deref; use std::str::FromStr; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; -use std::time::Duration; -use tempfile::{tempdir, TempDir}; use time::Date; use tokio::sync::RwLock; @@ -88,7 +73,8 @@ pub(crate) mod helpers; mod issued_ticketbooks; const TEST_COIN_DENOM: &str = "unym"; -const TEST_REWARDING_VALIDATOR_ADDRESS: &str = "n19lc9u84cz0yz3fww5283nucc9yvr8gsjmgeul0"; +pub(crate) const TEST_REWARDING_VALIDATOR_ADDRESS: &str = + "n19lc9u84cz0yz3fww5283nucc9yvr8gsjmgeul0"; #[derive(Default, Debug)] struct InternalCounters { @@ -1271,133 +1257,124 @@ struct TestFixture { chain_state: SharedFakeChain, epoch: Arc, ecash_clients: Arc>>>, +} - _tmp_dir: TempDir, +/// Test-only bundle returned by [`build_dummy_ecash_state`]. Carries the +/// constructed [`EcashState`] plus the test handles the caller may want to +/// poke at directly (chain state, registered ecash clients, epoch counter). +pub(crate) struct DummyEcashBundle { + pub ecash_state: EcashState, + pub chain_state: SharedFakeChain, + pub ecash_clients: Arc>>>, + /// A real [`Client`] (not the [`DummyClient`]) suitable for the + /// `nyxd_client` field on [`AppState`]. Built from a global env-var dance + /// (see body), which is required because `AppState` is not generic. + pub real_client: Client, + pub epoch: Arc, +} + +/// Build a self-contained [`EcashState`] suitable for handler tests. Pulls +/// every dependency it needs (coconut keypair, identity, fake chain, dummy +/// comm channel, etc.) from a deterministic RNG seed so callers get +/// reproducible setups. +pub(crate) async fn build_dummy_ecash_state( + config: &config::Config, + storage: NymApiStorage, + rng_seed: [u8; 32], +) -> DummyEcashBundle { + let mut rng = crate::ecash::tests::fixtures::test_rng(rng_seed); + let coconut_keypair = ttp_keygen(1, 1).unwrap().remove(0); + let identity = ed25519::KeyPair::new(&mut rng); + let epoch = Arc::new(AtomicU64::new(1)); + let address = AccountId::from_str(TEST_REWARDING_VALIDATOR_ADDRESS).unwrap(); + let comm_channel = DummyCommunicationChannel::new_single_dummy( + coconut_keypair.verification_key().clone(), + address.clone(), + ) + .with_epoch(epoch.clone()); + let ecash_clients = comm_channel.clients_arc(); + + let staged_key_pair = crate::ecash::keys::KeyPair::new(); + staged_key_pair + .set(KeyPairWithEpoch { + keys: coconut_keypair, + issued_for_epoch: 1, + }) + .await; + staged_key_pair.validate(); + + let chain_state = SharedFakeChain::default(); + let nyxd_client = DummyClient::new(address, chain_state.clone()); + + let ecash_contract = chain_state + .lock() + .unwrap() + .ecash_contract + .address + .clone() + .as_str() + .parse() + .unwrap(); + + let ecash_state = EcashState::new( + config, + ecash_contract, + nyxd_client, + identity, + staged_key_pair, + comm_channel, + storage, + &ShutdownManager::empty_mock(), + ); + + // ideally this would have been generic, but that's way too much work + // since then `AppState` would have had to be made generic + // also, this is such a disgusting workaround to make it 'work'. yuck + let mut dummy = NymNetworkDetails::new_empty(); + dummy.endpoints = vec![ValidatorDetails::new( + "http://127.0.0.1:26657", + Some("http://why-do-we-even-need-api-url-set-here.wtf"), + None, + )]; + dummy.export_to_env(); + let real_client = Client::new(config).unwrap(); + + DummyEcashBundle { + ecash_state, + chain_state, + ecash_clients, + real_client, + epoch, + } } impl TestFixture { - fn build_app_state( - storage: NymApiStorage, - ecash_state: EcashState, - nyxd_client: Client, - tmp_dir: &TempDir, - ) -> AppState { - let mixnet_contract_paths = tmp_dir.path().join("mixnet_contract"); - let node_annotations_paths = tmp_dir.path().join("node_annotations"); - - let mixnet_contract_cache_state = - MixnetContractCache::new(&mixnet_contract_paths, Duration::from_secs(42)); - let mixnet_contract_cache = - MixnetContractCacheState::new(mixnet_contract_cache_state, RefreshRequester::default()); - - let node_status_cache_state = - NodeStatusCache::new(&node_annotations_paths, Duration::from_secs(42)); - let node_annotations_cache = - NodeAnnotationsCache::new(node_status_cache_state, RefreshRequester::default()); - - AppState { - nyxd_client, - chain_status_cache: ChainStatusCache::new(Duration::from_secs(42)), - ecash_signers_cache: Default::default(), - address_info_cache: AddressInfoCache::new(Duration::from_secs(42), 1000), - forced_refresh: ForcedRefresh::new(true), - mixnet_contract_cache, - node_annotations_cache, - storage, - described_nodes_cache: SharedCache::::new(), - network_details: NetworkDetails::new( - "localhost".to_string(), - NymNetworkDetails::new_empty(), - ), - node_info_cache: unstable::NodeInfoCache::default(), - contract_info_cache: ContractDetailsCache::new(Duration::from_secs(42)), - api_status: ApiStatusState::new(None), - ecash_state: Arc::new(ecash_state), - } - } - async fn new() -> Self { - let mut rng = crate::ecash::tests::fixtures::test_rng([69u8; 32]); - let coconut_keypair = ttp_keygen(1, 1).unwrap().remove(0); - let identity = ed25519::KeyPair::new(&mut rng); - let epoch = Arc::new(AtomicU64::new(1)); - let address = AccountId::from_str(TEST_REWARDING_VALIDATOR_ADDRESS).unwrap(); - let comm_channel = DummyCommunicationChannel::new_single_dummy( - coconut_keypair.verification_key().clone(), - address.clone(), - ) - .with_epoch(epoch.clone()); - let ecash_clients = comm_channel.clients_arc(); - - // TODO: it's AWFUL to test with actual storage, we should somehow abstract it away - let tmp_dir = tempdir().unwrap(); - let storage = NymApiStorage::init(tmp_dir.path().join("TESTING_STORAGE.db")) - .await - .unwrap(); - - let staged_key_pair = crate::ecash::keys::KeyPair::new(); - staged_key_pair - .set(KeyPairWithEpoch { - keys: coconut_keypair, - issued_for_epoch: 1, - }) - .await; - staged_key_pair.validate(); - - let chain_state = SharedFakeChain::default(); - let nyxd_client = DummyClient::new(address, chain_state.clone()); - - let ecash_contract = chain_state - .lock() - .unwrap() - .ecash_contract - .address - .clone() - .as_str() - .parse() - .unwrap(); + let storage = NymApiStorage::init_in_memory().await.unwrap(); let mut config = config::Config::new("test"); config.ecash_signer.enabled = true; - let ecash_state = EcashState::new( - &config, - ecash_contract, - nyxd_client, - identity, - staged_key_pair, - comm_channel, + let bundle = build_dummy_ecash_state(&config, storage.clone(), [69u8; 32]).await; + + let app_state = crate::support::http::state::test_helpers::build_app_state( storage.clone(), - &ShutdownManager::empty_mock(), + bundle.ecash_state, + bundle.real_client, + SharedCache::::new(), ); - // ideally this would have been generic, but that's way too much work - // since then `AppState` would have had to be made generic - // also, this is such a disgusting workaround to make it 'work'. yuck - let mut dummy = NymNetworkDetails::new_empty(); - dummy.endpoints = vec![ValidatorDetails::new( - "http://127.0.0.1:26657", - Some("http://why-do-we-even-need-api-url-set-here.wtf"), - None, - )]; - dummy.export_to_env(); - let another_fake_nyxd_client = Client::new(&config).unwrap(); - TestFixture { - axum: TestServer::new(Router::new().nest("/v1/ecash", ecash_routes()).with_state( - Self::build_app_state( - storage.clone(), - ecash_state, - another_fake_nyxd_client, - &tmp_dir, - ), - )) + axum: TestServer::new( + Router::new() + .nest("/v1/ecash", ecash_routes()) + .with_state(app_state), + ) .unwrap(), storage, - chain_state, - epoch, - ecash_clients, - _tmp_dir: tmp_dir, + chain_state: bundle.chain_state, + epoch: bundle.epoch, + ecash_clients: bundle.ecash_clients, } } @@ -1573,11 +1550,8 @@ mod credential_tests { let nyxd_client = DummyClient::new(address.clone(), Default::default()); let key_pair = ttp_keygen(1, 1).unwrap().remove(0); - let tmp_dir = tempdir().unwrap(); - let storage = NymApiStorage::init(tmp_dir.path().join("storage.db")) - .await - .unwrap(); + let storage = NymApiStorage::init_in_memory().await.unwrap(); let comm_channel = DummyCommunicationChannel::new_single_dummy( key_pair.verification_key().clone(), address, diff --git a/nym-api/src/main.rs b/nym-api/src/main.rs index b2f52320e0..5089edd2a3 100644 --- a/nym-api/src/main.rs +++ b/nym-api/src/main.rs @@ -17,6 +17,7 @@ pub(crate) mod mixnet_contract_cache; pub(crate) mod network; mod network_monitor; pub(crate) mod node_describe_cache; +mod node_families; mod node_performance; pub(crate) mod node_status_api; pub(crate) mod nym_nodes; diff --git a/nym-api/src/node_families/cache/mod.rs b/nym-api/src/node_families/cache/mod.rs new file mode 100644 index 0000000000..3d3e771eab --- /dev/null +++ b/nym-api/src/node_families/cache/mod.rs @@ -0,0 +1,238 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use cosmwasm_std::Coin; +use nym_api_requests::models::node_families::{ + NodeFamily, NodeFamilyMember, NodeStakeInformation, PendingFamilyInvitation, +}; +use nym_mixnet_contract_common::{NodeId, NymNodeDetails}; +use nym_node_families_contract_common::{FamilyMemberRecord, NodeFamilyId}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap}; +use std::time::Duration; +use time::OffsetDateTime; + +pub(crate) mod refresher; + +/// Cached view of a single family member, joining the contract membership +/// record with mixnet-contract node details (bond height + stake). +#[derive(Serialize, Deserialize, Debug)] +pub(crate) struct CachedFamilyMember { + pub(crate) node_id: NodeId, + + /// Block-time at which the node joined the family. + #[serde(with = "time::serde::rfc3339")] + pub(crate) joined_at: OffsetDateTime, + + /// Bonding height from the mixnet contract; `None` if the node is no + /// longer in the mixnet cache snapshot. + pub(crate) bonding_height: Option, + + /// Stake/bond/delegation snapshot; `None` if the node is no longer in the + /// mixnet cache snapshot. + pub(crate) node_stake_information: Option, +} + +impl CachedFamilyMember { + pub(crate) fn new( + record: FamilyMemberRecord, + node_information: Option<&NymNodeDetails>, + ) -> Self { + CachedFamilyMember { + node_id: record.node_id, + joined_at: OffsetDateTime::from_unix_timestamp(record.membership.joined_at as i64) + .unwrap_or(OffsetDateTime::UNIX_EPOCH), + bonding_height: node_information.map(|n| n.bond_information.bonding_height), + node_stake_information: node_information.map(|n| (&n.rewarding_details).into()), + } + } +} + +/// Cached pending invitation entry for a family. +#[derive(Serialize, Deserialize, Debug)] +pub(crate) struct CachedFamilyInvitation { + /// Node the invitation is addressed to. + pub(crate) node_id: NodeId, + + /// Block-time after which the invitation can no longer be accepted. + #[serde(with = "time::serde::rfc3339")] + pub(crate) expires_at: OffsetDateTime, +} + +impl From + for CachedFamilyInvitation +{ + fn from(invitation: nym_node_families_contract_common::PendingFamilyInvitationDetails) -> Self { + CachedFamilyInvitation { + node_id: invitation.invitation.node_id, + expires_at: OffsetDateTime::from_unix_timestamp( + invitation.invitation.expires_at as i64, + ) + .unwrap_or(OffsetDateTime::UNIX_EPOCH), + } + } +} + +/// Cached family record with its current members, pending invitations and +/// aggregated stats (`average_node_age`, `total_stake`). +#[derive(Serialize, Deserialize, Debug)] +pub(crate) struct CachedFamily { + /// Unique family identifier assigned by the contract. + pub(crate) id: NodeFamilyId, + + /// Display name (canonical form β€” see `normalise_family_name`). + pub(crate) name: String, + + /// Free-form family description. + pub(crate) description: String, + + /// Owner address (cosmos `Addr` rendered as a string). + pub(crate) owner: String, + + /// Average age of members, computed from cached per-height + /// block timestamps. + #[serde(with = "humantime_serde")] + pub(crate) average_node_age: Duration, + + /// Sum of member stakes; `None` when no member has reportable stake (e.g. + /// all bonds unbonding). + pub(crate) total_stake: Option, + + /// Block-time the family was created. + #[serde(with = "time::serde::rfc3339")] + pub(crate) created_at: OffsetDateTime, + + /// Current members of the family. + pub(crate) members: Vec, + + /// Outstanding invitations issued by the family owner. + pub(crate) pending_invitations: Vec, +} + +/// Full nym-api node-families cache snapshot β€” combined families-contract +/// state plus mixnet-contract stake/bond information. +#[derive(Default, Debug, Serialize, Deserialize)] +pub(crate) struct NodeFamiliesCacheData { + /// Every family known to the contract, keyed by family id. `BTreeMap` so + /// iteration order is deterministic across refreshes (stable list + /// endpoint output, deterministic on-disk bincode). + pub(crate) families: BTreeMap, + + /// Secondary index: member node id β†’ family id. Built alongside + /// `families` during refresh; lets `by-node` lookups avoid an O(families + /// Γ— members) scan. + pub(crate) family_by_member: HashMap, + + /// Persistent block-height β†’ block-time cache used by the refresher when + /// computing per-member age. Survives restarts via the on-disk cache file. + pub(crate) block_timestamps: HashMap, +} + +/// Intermediate accumulator used while folding contract data into a +/// [`CachedFamily`]; finalised via [`Self::build`] once `average_node_age` is +/// known. +pub(crate) struct CachedFamilyBuilder { + /// Unique family identifier assigned by the contract. + pub(crate) id: NodeFamilyId, + + /// Display name (canonical form β€” see `normalise_family_name`). + pub(crate) name: String, + + /// Free-form family description. + pub(crate) description: String, + + /// Owner address (cosmos `Addr` rendered as a string). + pub(crate) owner: String, + + /// Block-time the family was created. + pub(crate) created_at: OffsetDateTime, + + /// Members accumulated as the refresher iterates the contract response. + pub(crate) members: Vec, + + /// Pending invitations accumulated as the refresher iterates the contract + /// response. + pub(crate) pending_invitations: Vec, +} + +impl CachedFamilyBuilder { + pub(crate) fn build(self, average_node_age: Duration) -> CachedFamily { + let total_stake = self.total_family_stake(); + + CachedFamily { + id: self.id, + name: self.name, + description: self.description, + owner: self.owner, + created_at: self.created_at, + members: self.members, + pending_invitations: self.pending_invitations, + total_stake, + average_node_age, + } + } + + /// Sum the per-member stake into a single family total. Returns `None` if + /// no member has a known stake. + pub(crate) fn total_family_stake(&self) -> Option { + self.members + .iter() + .filter_map(|m| m.node_stake_information.as_ref().map(|s| s.stake.clone())) + .reduce(|acc, e| { + let mut updated = acc; + updated.amount += e.amount; + updated + }) + } +} + +impl From<&CachedFamilyInvitation> for PendingFamilyInvitation { + fn from(value: &CachedFamilyInvitation) -> Self { + PendingFamilyInvitation { + node_id: value.node_id, + expires_at: value.expires_at, + } + } +} + +impl From<&CachedFamilyMember> for NodeFamilyMember { + fn from(value: &CachedFamilyMember) -> Self { + NodeFamilyMember { + node_id: value.node_id, + joined_at: value.joined_at, + stake_information: value.node_stake_information.clone(), + } + } +} + +impl From<&CachedFamily> for NodeFamily { + fn from(value: &CachedFamily) -> Self { + NodeFamily { + id: value.id, + name: value.name.clone(), + description: value.description.clone(), + owner: value.owner.clone(), + average_node_age: value.average_node_age, + total_stake: value.total_stake.clone(), + created_at: value.created_at, + members: value.members.iter().map(Into::into).collect(), + pending_invitations: value.pending_invitations.iter().map(Into::into).collect(), + } + } +} + +// initial conversion with empty details +impl From for CachedFamilyBuilder { + fn from(value: nym_node_families_contract_common::NodeFamily) -> Self { + CachedFamilyBuilder { + id: value.id, + name: value.name, + description: value.description, + owner: value.owner.to_string(), + created_at: OffsetDateTime::from_unix_timestamp(value.created_at as i64) + .unwrap_or(OffsetDateTime::UNIX_EPOCH), + members: Vec::new(), + pending_invitations: Vec::new(), + } + } +} diff --git a/nym-api/src/node_families/cache/refresher.rs b/nym-api/src/node_families/cache/refresher.rs new file mode 100644 index 0000000000..12b40cc119 --- /dev/null +++ b/nym-api/src/node_families/cache/refresher.rs @@ -0,0 +1,213 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::mixnet_contract_cache::cache::MixnetContractCache; +use crate::node_families::cache::{CachedFamilyBuilder, CachedFamilyMember, NodeFamiliesCacheData}; +use crate::support::caching::cache::SharedCache; +use crate::support::caching::refresher::CacheItemProvider; +use crate::support::nyxd::Client; +use async_trait::async_trait; +use futures::{stream, StreamExt}; +use nym_mixnet_contract_common::NodeId; +use nym_validator_client::nyxd::contract_traits::PagedNodeFamiliesQueryClient; +use nym_validator_client::nyxd::error::NyxdError; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::time::Duration; +use time::OffsetDateTime; +use tracing::error; + +/// Periodic refresher feeding the [`NodeFamiliesCacheData`] cache from the +/// node-families contract, joined with mixnet-contract stake snapshots. +pub struct NodeFamiliesDataProvider { + /// Nyxd client used for contract queries and block timestamp lookups. + nyxd_client: Client, + + /// Source of per-node stake/delegation information. + mixnet_contract_cache: MixnetContractCache, + + /// Read-only handle to the cache this provider feeds. Used to recover the + /// previously-known block-height β†’ block-time map (rehydrated from disk on + /// startup) so we only RPC heights we haven't already seen. + shared_cache: SharedCache, + + /// Maximum number of `block_timestamp` lookups in flight in parallel during a + /// single refresh tick. + block_timestamp_fetch_concurrency: usize, +} + +#[async_trait] +impl CacheItemProvider for NodeFamiliesDataProvider { + type Item = NodeFamiliesCacheData; + type Error = NyxdError; + + async fn wait_until_ready(&self) { + self.mixnet_contract_cache + .naive_wait_for_initial_values() + .await + } + + async fn try_refresh(&mut self) -> Result, Self::Error> { + self.refresh().await.map(Some) + } +} + +impl NodeFamiliesDataProvider { + pub(crate) fn new( + block_timestamp_fetch_concurrency: usize, + nyxd_client: Client, + mixnet_contract_cache: MixnetContractCache, + shared_cache: SharedCache, + ) -> Self { + NodeFamiliesDataProvider { + nyxd_client, + mixnet_contract_cache, + shared_cache, + block_timestamp_fetch_concurrency, + } + } + + /// Snapshot of the previously-cached block timestamps (rehydrated from + /// disk on startup). Empty if the cache hasn't been initialised yet. + async fn previous_block_timestamps(&self) -> HashMap { + let Ok(prev) = self.shared_cache.get().await else { + return HashMap::new(); + }; + prev.block_timestamps.clone() + } + + /// Pull the full families/members/pending-invitations snapshot from the + /// node-families contract and join it with the latest mixnet-contract node + /// information for stake/bonding data. + async fn refresh(&self) -> Result { + // retrieve the base data from the contract + let raw_families = self.nyxd_client.get_all_families().await?; + let raw_members = self.nyxd_client.get_all_family_members().await?; + let pending_invites = self.nyxd_client.get_all_pending_invitations().await?; + + let nym_nodes = self + .mixnet_contract_cache + .nym_nodes() + .await + .into_iter() + .map(|node| (node.node_id(), node)) + .collect::>(); + + let mut families: HashMap<_, CachedFamilyBuilder> = raw_families + .into_iter() + .map(|family| (family.id, family.into())) + .collect(); + let mut family_by_member: HashMap = HashMap::new(); + + // insert all member information into appropriate families + for member_record in raw_members { + let family_id = member_record.membership.family_id; + let node_id = member_record.node_id; + let Some(family) = families.get_mut(&family_id) else { + error!( + "node {node_id} belongs to family {family_id}, but this family does not exist!", + ); + continue; + }; + let node_info = nym_nodes.get(&node_id); + family + .members + .push(CachedFamilyMember::new(member_record, node_info)); + family_by_member.insert(node_id, family_id); + } + + // insert all invitations into appropriate families + for invitation in pending_invites { + let family_id = invitation.invitation.family_id; + let node_id = invitation.invitation.node_id; + let Some(family) = families.get_mut(&family_id) else { + error!( + "node {node_id} has been invited to family {family_id}, but this family does not exist!", + ); + continue; + }; + family.pending_invitations.push(invitation.into()); + } + + let referenced_heights: HashSet = families + .values() + .flat_map(|f| f.members.iter().filter_map(|m| m.bonding_height)) + .collect(); + + let block_timestamps = self.resolve_block_timestamps(&referenced_heights).await; + + let family_details: BTreeMap<_, _> = families + .into_values() + .map(|family| { + let average_node_age = average_node_age(&family.members, &block_timestamps); + let built = family.build(average_node_age); + (built.id, built) + }) + .collect(); + + Ok(NodeFamiliesCacheData { + families: family_details, + family_by_member, + block_timestamps, + }) + } + + /// Build the block-height β†’ block-time map for this refresh: keep entries + /// from the previous cache that we still need, parallel-fetch the rest. + async fn resolve_block_timestamps( + &self, + referenced_heights: &HashSet, + ) -> HashMap { + let mut block_timestamps = self.previous_block_timestamps().await; + + let to_fetch: Vec = referenced_heights + .iter() + .filter(|h| !block_timestamps.contains_key(h)) + .copied() + .collect(); + + let fetched: Vec<(u64, OffsetDateTime)> = stream::iter(to_fetch) + .map(|h| async move { + match self.nyxd_client.block_timestamp(h as u32).await { + Ok(t) => Some((h, OffsetDateTime::from(t))), + Err(err) => { + error!("failed to retrieve block timestamp for height {h}: {err}"); + None + } + } + }) + .buffer_unordered(self.block_timestamp_fetch_concurrency) + .filter_map(|x| async move { x }) + .collect() + .await; + + block_timestamps.extend(fetched); + block_timestamps + } +} + +/// Average member age: for each member with a known bonding +/// height we have a cached block-time, take `now - t` and average. Heights we +/// failed to resolve are skipped rather than poisoning the average. +fn average_node_age( + members: &[CachedFamilyMember], + block_timestamps: &HashMap, +) -> Duration { + let now = OffsetDateTime::now_utc(); + let mut total_secs: i64 = 0; + let mut count: i64 = 0; + for height in members.iter().filter_map(|m| m.bonding_height) { + let Some(ts) = block_timestamps.get(&height) else { + continue; + }; + let age = (now - *ts).whole_seconds(); + if age < 0 { + continue; + } + total_secs += age; + count += 1; + } + if count == 0 { + return Duration::ZERO; + } + Duration::from_secs((total_secs / count) as u64) +} diff --git a/nym-api/src/node_families/handlers/mod.rs b/nym-api/src/node_families/handlers/mod.rs new file mode 100644 index 0000000000..f0a587c783 --- /dev/null +++ b/nym-api/src/node_families/handlers/mod.rs @@ -0,0 +1,139 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::node_status_api::models::AxumResult; +use crate::support::http::helpers::PaginationRequestV2; +use crate::support::http::state::AppState; +use axum::extract::{Path, Query, State}; +use axum::routing::get; +use axum::Router; +use nym_api_requests::models::node_families::{ + NodeFamily, NodeFamilyForNodeResponse, NodeFamilyResponse, +}; +use nym_api_requests::pagination::{PaginatedResponse, Pagination}; +use nym_http_api_common::{FormattedResponse, OutputParamsV2}; +use nym_mixnet_contract_common::NodeId; +use nym_node_families_contract_common::NodeFamilyId; +use std::cmp::min; + +const DEFAULT_FAMILIES_PAGE_SIZE: u32 = 50; +const MAX_FAMILIES_PAGE_SIZE: u32 = 200; + +// /v1/node-families +pub(crate) fn routes() -> Router { + Router::new() + .route("/", get(get_families)) + .route("/:family_id", get(get_family_by_id)) + .route("/by-node/:node_id", get(get_family_for_node)) +} + +#[utoipa::path( + tag = "Node Families", + get, + path = "", + context_path = "/v1/node-families", + responses( + (status = 200, content( + (PaginatedResponse = "application/json"), + (PaginatedResponse = "application/yaml"), + )) + ), + params(PaginationRequestV2) +)] +async fn get_families( + Query(pagination): Query, + State(state): State, +) -> AxumResult>> { + let page = pagination.page.unwrap_or_default(); + let per_page = min( + pagination.per_page.unwrap_or(DEFAULT_FAMILIES_PAGE_SIZE), + MAX_FAMILIES_PAGE_SIZE, + ); + let output = pagination.output.unwrap_or_default(); + + let cache = state.node_families_cache.get().await?; + let total = cache.families.len(); + let offset = (page as usize).saturating_mul(per_page as usize); + + // BTreeMap ascending-id iteration is stable across refreshes, so paging + // by offset is well-defined: page N is the same window of ids on every + // call (until the underlying set changes). + let data: Vec = cache + .families + .values() + .skip(offset) + .take(per_page as usize) + .map(Into::into) + .collect(); + + Ok(output.to_response(PaginatedResponse { + pagination: Pagination { + total, + page, + size: data.len(), + }, + data, + })) +} + +#[utoipa::path( + tag = "Node Families", + get, + path = "/{family_id}", + context_path = "/v1/node-families", + responses( + (status = 200, content( + (NodeFamilyResponse = "application/json"), + (NodeFamilyResponse = "application/yaml"), + )) + ), + params( + ("family_id" = u32, Path, description = "Identifier of the family"), + OutputParamsV2, + ) +)] +async fn get_family_by_id( + Path(family_id): Path, + Query(output): Query, + State(state): State, +) -> AxumResult> { + let output = output.get_output(); + + let cache = state.node_families_cache.get().await?; + let family = cache.families.get(&family_id).map(NodeFamily::from); + + Ok(output.to_response(NodeFamilyResponse { family })) +} + +#[utoipa::path( + tag = "Node Families", + get, + path = "/by-node/{node_id}", + context_path = "/v1/node-families", + responses( + (status = 200, content( + (NodeFamilyForNodeResponse = "application/json"), + (NodeFamilyForNodeResponse = "application/yaml"), + )) + ), + params( + ("node_id" = u32, Path, description = "Identifier of the member node"), + OutputParamsV2, + ) +)] +async fn get_family_for_node( + Path(node_id): Path, + Query(output): Query, + State(state): State, +) -> AxumResult> { + let output = output.get_output(); + + let cache = state.node_families_cache.get().await?; + let family = cache + .family_by_member + .get(&node_id) + .and_then(|family_id| cache.families.get(family_id)) + .map(NodeFamily::from); + + Ok(output.to_response(NodeFamilyForNodeResponse { node_id, family })) +} diff --git a/nym-api/src/node_families/mod.rs b/nym-api/src/node_families/mod.rs new file mode 100644 index 0000000000..c86c9dda3e --- /dev/null +++ b/nym-api/src/node_families/mod.rs @@ -0,0 +1,37 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::mixnet_contract_cache::cache::MixnetContractCache; +use crate::node_families::cache::refresher::NodeFamiliesDataProvider; +use crate::node_families::cache::NodeFamiliesCacheData; +use crate::support::caching::cache::SharedCache; +use crate::support::caching::refresher::CacheRefresher; +use crate::support::{config, nyxd}; +use nym_validator_client::nyxd::error::NyxdError; +use std::path::PathBuf; + +pub(crate) mod cache; +pub(crate) mod handlers; +#[cfg(test)] +mod tests; + +pub(crate) fn build_refresher( + config: &config::NodeFamiliesCache, + mixnet_contract_cache: &MixnetContractCache, + node_families_cache: &SharedCache, + nyxd_client: nyxd::Client, + on_disk_file: PathBuf, +) -> CacheRefresher { + CacheRefresher::new_with_initial_value( + Box::new(NodeFamiliesDataProvider::new( + config.debug.node_families_block_timestamp_fetch_concurrency, + nyxd_client, + mixnet_contract_cache.clone(), + node_families_cache.clone(), + )), + config.debug.caching_interval, + node_families_cache.clone(), + ) + .named("node-families-cache-refresher") + .with_persistent_cache(on_disk_file) +} diff --git a/nym-api/src/node_families/tests.rs b/nym-api/src/node_families/tests.rs new file mode 100644 index 0000000000..9db626acc8 --- /dev/null +++ b/nym-api/src/node_families/tests.rs @@ -0,0 +1,254 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::ecash::tests::build_dummy_ecash_state; +use crate::node_families::cache::{CachedFamily, CachedFamilyMember, NodeFamiliesCacheData}; +use crate::node_families::handlers; +use crate::support::caching::cache::SharedCache; +use crate::support::config; +use crate::support::http::state::test_helpers::build_app_state; +use crate::support::storage::NymApiStorage; +use axum::Router; +use axum_test::http::StatusCode; +use axum_test::TestServer; +use cosmwasm_std::Coin; +use nym_api_requests::models::node_families::{ + NodeFamily, NodeFamilyForNodeResponse, NodeFamilyResponse, NodeStakeInformation, +}; +use nym_api_requests::pagination::PaginatedResponse; +use nym_mixnet_contract_common::NodeId; +use nym_node_families_contract_common::NodeFamilyId; +use std::collections::{BTreeMap, HashMap}; +use std::time::Duration; +use time::OffsetDateTime; + +struct NodeFamiliesTestFixture { + axum: TestServer, + cache: SharedCache, +} + +impl NodeFamiliesTestFixture { + /// Build a test server with the node-families router mounted and an empty + /// (but initialised) shared cache. Use [`seed`] to populate it. + async fn new() -> Self { + let storage = NymApiStorage::init_in_memory().await.unwrap(); + + let cache = SharedCache::::new_with_value(Default::default()); + + // node-families handlers don't read `AppState.ecash_state`, but + // `AppState` requires one; we just need a valid construction. + let mut cfg = config::Config::new("test"); + cfg.ecash_signer.enabled = false; + let bundle = build_dummy_ecash_state(&cfg, storage.clone(), [7u8; 32]).await; + + let app_state = build_app_state( + storage, + bundle.ecash_state, + bundle.real_client, + cache.clone(), + ); + + let server = TestServer::new( + Router::new() + .nest("/v1/node-families", handlers::routes()) + .with_state(app_state), + ) + .unwrap(); + + NodeFamiliesTestFixture { + axum: server, + cache, + } + } + + /// Replace the cached data with the provided snapshot. + async fn seed(&self, data: NodeFamiliesCacheData) { + // try_overwrite_old_value swaps the entire cached value + let res = self + .cache + .try_overwrite_old_value(data, "node-families-test") + .await; + assert!(res.is_ok(), "failed to seed node-families cache: {res:?}"); + } +} + +// ---------- fixtures ---------- + +fn stake_coin(amount: u128) -> Coin { + Coin::new(amount, "unym") +} + +fn member(node_id: NodeId, with_stake: Option) -> CachedFamilyMember { + CachedFamilyMember { + node_id, + joined_at: OffsetDateTime::UNIX_EPOCH, + bonding_height: Some(1), + node_stake_information: with_stake.map(|amt| NodeStakeInformation { + stake: stake_coin(amt), + bond: stake_coin(amt), + delegations: stake_coin(0), + delegators: 0, + }), + } +} + +fn family(id: NodeFamilyId, name: &str, members: Vec) -> CachedFamily { + CachedFamily { + id, + name: name.to_string(), + description: format!("{name} description"), + owner: format!("n1owner{id}"), + average_node_age: Duration::ZERO, + total_stake: None, + created_at: OffsetDateTime::UNIX_EPOCH, + members, + pending_invitations: Vec::new(), + } +} + +fn snapshot(families: Vec) -> NodeFamiliesCacheData { + let mut family_by_member: HashMap = HashMap::new(); + let mut families_map: BTreeMap = BTreeMap::new(); + for family in families { + for m in &family.members { + family_by_member.insert(m.node_id, family.id); + } + families_map.insert(family.id, family); + } + NodeFamiliesCacheData { + families: families_map, + family_by_member, + block_timestamps: HashMap::new(), + } +} + +// ---------- /v1/node-families (list) ---------- + +#[tokio::test] +async fn list_returns_empty_when_cache_has_no_families() { + let fx = NodeFamiliesTestFixture::new().await; + + let res = fx.axum.get("/v1/node-families").await; + assert_eq!(res.status_code(), StatusCode::OK); + let body: PaginatedResponse = res.json(); + assert_eq!(body.pagination.total, 0); + assert_eq!(body.pagination.size, 0); + assert_eq!(body.pagination.page, 0); + assert!(body.data.is_empty()); +} + +#[tokio::test] +async fn list_returns_every_seeded_family() { + let fx = NodeFamiliesTestFixture::new().await; + fx.seed(snapshot(vec![ + family(1, "alpha", vec![member(10, Some(100))]), + family(2, "beta", vec![member(20, None)]), + ])) + .await; + + let res = fx.axum.get("/v1/node-families").await; + let body: PaginatedResponse = res.json(); + assert_eq!(body.pagination.total, 2); + let ids: Vec<_> = body.data.iter().map(|f| f.id).collect(); + assert_eq!(ids, vec![1, 2]); +} + +#[tokio::test] +async fn list_paginates_by_offset_in_ascending_id_order() { + let fx = NodeFamiliesTestFixture::new().await; + let families: Vec<_> = (1u32..=5) + .map(|i| family(i, &format!("f{i}"), vec![])) + .collect(); + fx.seed(snapshot(families)).await; + + // page 0 / per_page 2 β†’ ids [1, 2] + let res = fx.axum.get("/v1/node-families?page=0&per_page=2").await; + let page0: PaginatedResponse = res.json(); + assert_eq!(page0.pagination.total, 5); + assert_eq!(page0.pagination.page, 0); + assert_eq!(page0.pagination.size, 2); + assert_eq!( + page0.data.iter().map(|f| f.id).collect::>(), + vec![1, 2] + ); + + // page 1 / per_page 2 β†’ ids [3, 4] + let res = fx.axum.get("/v1/node-families?page=1&per_page=2").await; + let page1: PaginatedResponse = res.json(); + assert_eq!( + page1.data.iter().map(|f| f.id).collect::>(), + vec![3, 4] + ); + + // page 2 / per_page 2 β†’ just id 5 on the last page + let res = fx.axum.get("/v1/node-families?page=2&per_page=2").await; + let page2: PaginatedResponse = res.json(); + assert_eq!(page2.pagination.size, 1); + assert_eq!(page2.data.iter().map(|f| f.id).collect::>(), vec![5]); + + // page 3 / per_page 2 β†’ past the end; data empty but total still reported + let res = fx.axum.get("/v1/node-families?page=3&per_page=2").await; + let page3: PaginatedResponse = res.json(); + assert_eq!(page3.pagination.total, 5); + assert_eq!(page3.pagination.size, 0); + assert!(page3.data.is_empty()); +} + +// ---------- /v1/node-families/{family_id} ---------- + +#[tokio::test] +async fn get_family_by_id_returns_some_on_hit() { + let fx = NodeFamiliesTestFixture::new().await; + fx.seed(snapshot(vec![family(7, "seven", vec![])])).await; + + let res = fx.axum.get("/v1/node-families/7").await; + assert_eq!(res.status_code(), StatusCode::OK); + let body: NodeFamilyResponse = res.json(); + let family = body.family.expect("family should be present"); + assert_eq!(family.id, 7); + assert_eq!(family.name, "seven"); +} + +#[tokio::test] +async fn get_family_by_id_returns_none_on_miss() { + let fx = NodeFamiliesTestFixture::new().await; + fx.seed(snapshot(vec![family(1, "alpha", vec![])])).await; + + let res = fx.axum.get("/v1/node-families/999").await; + // still 200 β€” `family: None` signals absence + assert_eq!(res.status_code(), StatusCode::OK); + let body: NodeFamilyResponse = res.json(); + assert!(body.family.is_none()); +} + +// ---------- /v1/node-families/by-node/{node_id} ---------- + +#[tokio::test] +async fn get_family_for_node_returns_some_on_hit() { + let fx = NodeFamiliesTestFixture::new().await; + fx.seed(snapshot(vec![family( + 3, + "gamma", + vec![member(42, None), member(43, None)], + )])) + .await; + + let res = fx.axum.get("/v1/node-families/by-node/42").await; + let body: NodeFamilyForNodeResponse = res.json(); + assert_eq!(body.node_id, 42); + let family = body.family.expect("expected to find the family"); + assert_eq!(family.id, 3); +} + +#[tokio::test] +async fn get_family_for_node_returns_none_on_miss() { + let fx = NodeFamiliesTestFixture::new().await; + fx.seed(snapshot(vec![family(1, "alpha", vec![member(10, None)])])) + .await; + + let res = fx.axum.get("/v1/node-families/by-node/9999").await; + assert_eq!(res.status_code(), StatusCode::OK); + let body: NodeFamilyForNodeResponse = res.json(); + assert_eq!(body.node_id, 9999); + assert!(body.family.is_none()); +} diff --git a/nym-api/src/support/cli/run.rs b/nym-api/src/support/cli/run.rs index 2a120cc6fd..85574a4bde 100644 --- a/nym-api/src/support/cli/run.rs +++ b/nym-api/src/support/cli/run.rs @@ -13,6 +13,7 @@ use crate::key_rotation::KeyRotationController; use crate::mixnet_contract_cache::cache::MixnetContractCache; use crate::network::models::NetworkDetails; use crate::node_describe_cache::cache::DescribedNodes; +use crate::node_families::cache::NodeFamiliesCacheData; use crate::node_performance::provider::contract_provider::ContractPerformanceProvider; use crate::node_performance::provider::legacy_storage_provider::LegacyStoragePerformanceProvider; use crate::node_performance::provider::NodePerformanceProvider; @@ -36,7 +37,7 @@ use crate::support::storage::NymApiStorage; use crate::unstable_routes::v1::account::cache::AddressInfoCache; use crate::{ ecash, epoch_operations, mixnet_contract_cache, network_monitor, node_describe_cache, - node_performance, node_status_api, signers_cache, + node_families, node_performance, node_status_api, signers_cache, }; use anyhow::{bail, Context}; use nym_config::defaults::NymNetworkDetails; @@ -188,6 +189,19 @@ async fn start_nym_api_tasks(mut config: Config) -> anyhow::Result::new_with_persistent(&node_families_path, ttl, None); + let node_families_cache_refresher = node_families::build_refresher( + &config.node_families_cache, + &mixnet_contract_cache_state.clone(), + &node_families_cache, + nyxd_client.clone(), + node_families_path, + ); + // NODES ANNOTATIONS let annotations_path = storage_cfg.cache_file("node_annotations"); let ttl = config.node_status_api.debug.caching_interval; @@ -316,6 +330,8 @@ async fn start_nym_api_tasks(mut config: Config) -> anyhow::Result anyhow::Result Self { + NodeFamiliesCache { + debug: Default::default(), + } + } +} + +#[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(default)] +pub struct NodeFamiliesCacheDebug { + #[serde(with = "humantime_serde")] + pub caching_interval: Duration, + + pub node_families_block_timestamp_fetch_concurrency: usize, +} + +impl Default for NodeFamiliesCacheDebug { + fn default() -> Self { + NodeFamiliesCacheDebug { + caching_interval: DEFAULT_NODE_FAMILIES_CACHE_REFRESH_INTERVAL, + node_families_block_timestamp_fetch_concurrency: + DEFAULT_NODE_FAMILIES_BLOCK_TIMESTAMP_FETCH_CONCURRENCY, + } + } +} + #[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] pub struct PerformanceProvider { /// Specifies whether this nym-api should attempt to retrieve node performance diff --git a/nym-api/src/support/http/helpers.rs b/nym-api/src/support/http/helpers.rs index ed11746900..b73d5eb0a3 100644 --- a/nym-api/src/support/http/helpers.rs +++ b/nym-api/src/support/http/helpers.rs @@ -1,7 +1,7 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use nym_http_api_common::Output; +use nym_http_api_common::{Output, OutputV2}; use nym_mixnet_contract_common::NodeId; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; @@ -14,6 +14,14 @@ pub struct PaginationRequest { pub per_page: Option, } +#[derive(Serialize, Deserialize, Debug, ToSchema, IntoParams)] +#[into_params(parameter_in = Query)] +pub struct PaginationRequestV2 { + pub output: Option, + pub page: Option, + pub per_page: Option, +} + #[derive(Deserialize, IntoParams, ToSchema)] #[schema(title = "NodeId")] #[schema(as = NodeId)] diff --git a/nym-api/src/support/http/router.rs b/nym-api/src/support/http/router.rs index 00ab46c8af..a66ee02c72 100644 --- a/nym-api/src/support/http/router.rs +++ b/nym-api/src/support/http/router.rs @@ -5,6 +5,7 @@ use crate::circulating_supply_api::handlers::circulating_supply_routes; use crate::ecash::api_routes::handlers::ecash_routes; use crate::mixnet_contract_cache::handlers::{epoch_routes, legacy_nodes_routes}; use crate::network::handlers::nym_network_routes; +use crate::node_families::handlers as node_families_handlers; use crate::node_status_api::handlers::status_routes; use crate::support::http::openapi::ApiDoc; use crate::support::http::state::AppState; @@ -49,6 +50,7 @@ impl RouterBuilder { .nest("/network", nym_network_routes()) .nest("/api-status", status::handlers::api_status_routes()) .nest("/nym-nodes", nym_nodes::handlers::v1::routes()) + .nest("/node-families", node_families_handlers::routes()) .nest("/ecash", ecash_routes()) .nest("/unstable", unstable_routes_v1()) .nest("/legacy", legacy_nodes_routes()); // CORS layer needs to be "outside" of routes diff --git a/nym-api/src/support/http/state/contract_details.rs b/nym-api/src/support/http/state/contract_details.rs index 5ea1079993..8eac98acd3 100644 --- a/nym-api/src/support/http/state/contract_details.rs +++ b/nym-api/src/support/http/state/contract_details.rs @@ -55,6 +55,7 @@ async fn refresh(nyxd_client: &Client) -> Result let multisig = query_guard!(client_guard, multisig_contract_address()); let ecash = query_guard!(client_guard, ecash_contract_address()); let performance = query_guard!(client_guard, performance_contract_address()); + let node_families = query_guard!(client_guard, node_families_contract_address()); for (address, name) in [ (mixnet, "nym-mixnet-contract"), @@ -64,6 +65,7 @@ async fn refresh(nyxd_client: &Client) -> Result (multisig, "nym-cw3-multisig-contract"), (ecash, "nym-ecash-contract"), (performance, "nym-performance-contract"), + (node_families, "nym-node-families-contract"), ] { let (cw2, build_info) = if let Some(address) = address { let cw2 = query_guard!(client_guard, try_get_cw2_contract_version(address).await); diff --git a/nym-api/src/support/http/state/mod.rs b/nym-api/src/support/http/state/mod.rs index 35c2416f85..36ac22da6f 100644 --- a/nym-api/src/support/http/state/mod.rs +++ b/nym-api/src/support/http/state/mod.rs @@ -5,6 +5,7 @@ use crate::ecash::state::EcashState; use crate::mixnet_contract_cache::cache::MixnetContractCache; use crate::network::models::NetworkDetails; use crate::node_describe_cache::cache::DescribedNodes; +use crate::node_families::cache::NodeFamiliesCacheData; use crate::node_status_api::handlers::unstable; use crate::node_status_api::models::AxumErrorResponse; use crate::node_status_api::NodeStatusCache; @@ -33,6 +34,8 @@ pub(crate) mod force_refresh; pub(crate) mod helpers; pub(crate) mod mixnet_contract_cache; pub(crate) mod node_annotations_cache; +#[cfg(test)] +pub(crate) mod test_helpers; #[derive(Clone)] pub(crate) struct AppState { @@ -59,6 +62,9 @@ pub(crate) struct AppState { /// Holds cached state of the Nym Mixnet contract, e.g. bonded nym-nodes, rewarded set, current interval. pub(crate) mixnet_contract_cache: MixnetContractCacheState, + /// Hold cached state of node families, e.g. membership, invitations, age, etc. + pub(crate) node_families_cache: SharedCache, + /// Holds processed information on network nodes, i.e. performance, config scores, etc. // TODO: also perhaps redundant? pub(crate) node_annotations_cache: NodeAnnotationsCache, diff --git a/nym-api/src/support/http/state/test_helpers.rs b/nym-api/src/support/http/state/test_helpers.rs new file mode 100644 index 0000000000..5c693a293c --- /dev/null +++ b/nym-api/src/support/http/state/test_helpers.rs @@ -0,0 +1,67 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +//! Shared test-only helpers for assembling an [`AppState`] without going +//! through the full nym-api startup path. + +use crate::ecash::state::EcashState; +use crate::mixnet_contract_cache::cache::MixnetContractCache; +use crate::network::models::NetworkDetails; +use crate::node_describe_cache::cache::DescribedNodes; +use crate::node_families::cache::NodeFamiliesCacheData; +use crate::node_status_api::handlers::unstable; +use crate::node_status_api::NodeStatusCache; +use crate::status::ApiStatusState; +use crate::support::caching::cache::SharedCache; +use crate::support::caching::refresher::RefreshRequester; +use crate::support::http::state::chain_status::ChainStatusCache; +use crate::support::http::state::contract_details::ContractDetailsCache; +use crate::support::http::state::force_refresh::ForcedRefresh; +use crate::support::http::state::mixnet_contract_cache::MixnetContractCacheState; +use crate::support::http::state::node_annotations_cache::NodeAnnotationsCache; +use crate::support::http::state::AppState; +use crate::support::nyxd::Client; +use crate::support::storage::NymApiStorage; +use crate::unstable_routes::v1::account::cache::AddressInfoCache; +use nym_config::defaults::NymNetworkDetails; +use std::sync::Arc; +use std::time::Duration; + +/// Construct a default test [`AppState`]. All caches start empty +/// (in-memory `SharedCache::new()` for the disk-backed ones); seed +/// `node_families_cache` upstream of this call if the test needs data. +pub(crate) fn build_app_state( + storage: NymApiStorage, + ecash_state: EcashState, + nyxd_client: Client, + node_families_cache: SharedCache, +) -> AppState { + let mixnet_contract_cache: MixnetContractCache = SharedCache::new().into(); + let mixnet_contract_cache = + MixnetContractCacheState::new(mixnet_contract_cache, RefreshRequester::default()); + + let node_status_cache: NodeStatusCache = SharedCache::new().into(); + let node_annotations_cache = + NodeAnnotationsCache::new(node_status_cache, RefreshRequester::default()); + + AppState { + nyxd_client, + chain_status_cache: ChainStatusCache::new(Duration::from_secs(42)), + ecash_signers_cache: Default::default(), + address_info_cache: AddressInfoCache::new(Duration::from_secs(42), 1000), + forced_refresh: ForcedRefresh::new(true), + mixnet_contract_cache, + node_families_cache, + node_annotations_cache, + storage, + described_nodes_cache: SharedCache::::new(), + network_details: NetworkDetails::new( + "localhost".to_string(), + NymNetworkDetails::new_empty(), + ), + node_info_cache: unstable::NodeInfoCache::default(), + contract_info_cache: ContractDetailsCache::new(Duration::from_secs(42)), + api_status: ApiStatusState::new(None), + ecash_state: Arc::new(ecash_state), + } +} diff --git a/nym-api/src/support/nyxd/mod.rs b/nym-api/src/support/nyxd/mod.rs index 6de33c4738..59995ecd75 100644 --- a/nym-api/src/support/nyxd/mod.rs +++ b/nym-api/src/support/nyxd/mod.rs @@ -33,13 +33,15 @@ use nym_mixnet_contract_common::{ ExecuteMsg, GatewayBond, HistoricalNymNodeVersionEntry, IdentityKey, KeyRotationState, NymNodeDetails, RewardedSet, RoleAssignment, }; +use nym_node_families_contract_common::msg::QueryMsg as NodeFamiliesQueryMsg; use nym_validator_client::coconut::EcashApiError; use nym_validator_client::nyxd::contract_traits::mixnet_query_client::MixnetQueryClientExt; use nym_validator_client::nyxd::contract_traits::performance_query_client::{ LastSubmission, NodePerformance, }; use nym_validator_client::nyxd::contract_traits::{ - PagedDkgQueryClient, PagedPerformanceQueryClient, PerformanceQueryClient, + NodeFamiliesQueryClient, PagedDkgQueryClient, PagedPerformanceQueryClient, + PerformanceQueryClient, }; use nym_validator_client::nyxd::error::NyxdError; use nym_validator_client::nyxd::Coin; @@ -204,6 +206,13 @@ impl Client { Ok(time) } + /// Tendermint block timestamp at the given height. + pub(crate) async fn block_timestamp(&self, height: u32) -> Result { + let time = nyxd_query!(self, get_block_timestamp(Some(height)).await?); + + Ok(time) + } + /// Obtains the hash of a block specified by the provided height. /// If the resulting digest is empty, a `None` is returned instead. /// @@ -695,3 +704,16 @@ impl DkgQueryClient for Client { nyxd_query!(self, query_dkg_contract(query).await) } } + +#[async_trait] +impl NodeFamiliesQueryClient for Client { + async fn query_node_families_contract( + &self, + query: NodeFamiliesQueryMsg, + ) -> std::result::Result + where + for<'a> T: Deserialize<'a>, + { + nyxd_query!(self, query_node_families_contract(query).await) + } +} diff --git a/nym-api/src/support/storage/mod.rs b/nym-api/src/support/storage/mod.rs index 8bb14a697d..457b3dcd98 100644 --- a/nym-api/src/support/storage/mod.rs +++ b/nym-api/src/support/storage/mod.rs @@ -82,6 +82,33 @@ impl NymApiStorage { .max_connections(25) .acquire_timeout(Duration::from_secs(60)); + Self::from_options(connect_opts, pool_opts).await + } + + /// Build a [`NymApiStorage`] backed by an in-memory SQLite database. The + /// pool is pinned to a single connection so every query sees the same DB + /// (the standard "private :memory:" gotcha β€” multiple connections to + /// `:memory:` produce independent DBs unless shared-cache is used). + /// + /// Intended for tests; migrations run identically to the file-backed + /// constructor. + #[cfg(test)] + pub async fn init_in_memory() -> Result { + let connect_opts = sqlx::sqlite::SqliteConnectOptions::new() + .filename(":memory:") + .create_if_missing(true); + + let pool_opts = sqlx::sqlite::SqlitePoolOptions::new() + .min_connections(1) + .max_connections(1); + + Self::from_options(connect_opts, pool_opts).await + } + + async fn from_options( + connect_opts: sqlx::sqlite::SqliteConnectOptions, + pool_opts: sqlx::sqlite::SqlitePoolOptions, + ) -> Result { let connection_pool = match pool_opts.connect_with(connect_opts).await { Ok(db) => db, Err(err) => { @@ -97,12 +124,10 @@ impl NymApiStorage { info!("Database migration finished!"); - let storage = NymApiStorage { + Ok(NymApiStorage { manager: StorageManager { connection_pool }, db_id_cache: Arc::new(Default::default()), - }; - - Ok(storage) + }) } pub(crate) async fn get_mixnode_database_id( diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-56854f703321ff8d8f30628c7e5322024ea01b778ab55efa9c7c6b219ef36308.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-0d183e6ad37527d0f624580f702b56cdae6d3fe6b53a8ed5c37fdc4d9e3b7f7c.json similarity index 60% rename from nym-node-status-api/nym-node-status-api/.sqlx/query-56854f703321ff8d8f30628c7e5322024ea01b778ab55efa9c7c6b219ef36308.json rename to nym-node-status-api/nym-node-status-api/.sqlx/query-0d183e6ad37527d0f624580f702b56cdae6d3fe6b53a8ed5c37fdc4d9e3b7f7c.json index 4525f5cd77..7fcbfc7607 100644 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-56854f703321ff8d8f30628c7e5322024ea01b778ab55efa9c7c6b219ef36308.json +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-0d183e6ad37527d0f624580f702b56cdae6d3fe6b53a8ed5c37fdc4d9e3b7f7c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n nd.node_id,\n moniker,\n website,\n security_contact,\n details\n FROM\n nym_node_descriptions nd\n INNER JOIN\n nym_nodes nn on nd.node_id = nn.node_id\n WHERE\n bond_info IS NOT NULL\n ", + "query": "SELECT\n nd.node_id,\n moniker,\n website,\n security_contact,\n details\n FROM\n nym_node_descriptions nd\n INNER JOIN\n nym_nodes nn on nd.node_id = nn.node_id\n WHERE\n bond_info IS NOT NULL\n ", "describe": { "columns": [ { @@ -40,5 +40,5 @@ true ] }, - "hash": "56854f703321ff8d8f30628c7e5322024ea01b778ab55efa9c7c6b219ef36308" + "hash": "0d183e6ad37527d0f624580f702b56cdae6d3fe6b53a8ed5c37fdc4d9e3b7f7c" } diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-f25a4eb90c11957669cfad4800d7a0a384b672077abb123c3437962def194e8a.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-1668f5a5a0abc8a73454953c3f5b61d2afb1b37720f5756b9c6fb3aef55a3027.json similarity index 60% rename from nym-node-status-api/nym-node-status-api/.sqlx/query-f25a4eb90c11957669cfad4800d7a0a384b672077abb123c3437962def194e8a.json rename to nym-node-status-api/nym-node-status-api/.sqlx/query-1668f5a5a0abc8a73454953c3f5b61d2afb1b37720f5756b9c6fb3aef55a3027.json index 32c3613a08..1fee9eb103 100644 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-f25a4eb90c11957669cfad4800d7a0a384b672077abb123c3437962def194e8a.json +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-1668f5a5a0abc8a73454953c3f5b61d2afb1b37720f5756b9c6fb3aef55a3027.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n gw.gateway_identity_key as \"gateway_identity_key!\",\n gw.bonded as \"bonded: bool\",\n gw.performance as \"performance!\",\n gw.self_described as \"self_described?\",\n gw.explorer_pretty_bond as \"explorer_pretty_bond?\",\n gw.last_probe_result as \"last_probe_result?\",\n gw.last_probe_log as \"last_probe_log?\",\n gw.last_testrun_utc as \"last_testrun_utc?\",\n gw.last_updated_utc as \"last_updated_utc!\",\n gw.bridges as \"bridges?: serde_json::Value\",\n COALESCE(gd.moniker, 'NA') as \"moniker!\",\n COALESCE(gd.website, 'NA') as \"website!\",\n COALESCE(gd.security_contact, 'NA') as \"security_contact!\",\n COALESCE(gd.details, 'NA') as \"details!\"\n FROM gateways gw\n LEFT JOIN gateway_description gd\n ON gw.gateway_identity_key = gd.gateway_identity_key\n ORDER BY gw.gateway_identity_key", + "query": "SELECT\n gw.gateway_identity_key as \"gateway_identity_key!\",\n gw.bonded as \"bonded: bool\",\n gw.performance as \"performance!\",\n gw.self_described as \"self_described?\",\n gw.explorer_pretty_bond as \"explorer_pretty_bond?\",\n gw.last_probe_result as \"last_probe_result?\",\n gw.last_probe_log as \"last_probe_log?\",\n gw.last_testrun_utc as \"last_testrun_utc?\",\n gw.last_updated_utc as \"last_updated_utc!\",\n gw.bridges as \"bridges?: serde_json::Value\",\n COALESCE(gd.moniker, 'NA') as \"moniker!\",\n COALESCE(gd.website, 'NA') as \"website!\",\n COALESCE(gd.security_contact, 'NA') as \"security_contact!\",\n COALESCE(gd.details, 'NA') as \"details!\"\n FROM gateways gw\n LEFT JOIN gateway_description gd\n ON gw.gateway_identity_key = gd.gateway_identity_key\n ORDER BY gw.gateway_identity_key", "describe": { "columns": [ { @@ -94,5 +94,5 @@ null ] }, - "hash": "f25a4eb90c11957669cfad4800d7a0a384b672077abb123c3437962def194e8a" + "hash": "1668f5a5a0abc8a73454953c3f5b61d2afb1b37720f5756b9c6fb3aef55a3027" } diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-3120dc00831742e0981bdcf486a47c8d9e063f183c6da77295529b06495f6a5c.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-3120dc00831742e0981bdcf486a47c8d9e063f183c6da77295529b06495f6a5c.json new file mode 100644 index 0000000000..11a37364e8 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-3120dc00831742e0981bdcf486a47c8d9e063f183c6da77295529b06495f6a5c.json @@ -0,0 +1,56 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n family_id,\n name,\n description,\n owner,\n family_stake_unym,\n members_count,\n created_at\n FROM node_families\n ORDER BY family_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "family_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "owner", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "family_stake_unym", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "members_count", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "3120dc00831742e0981bdcf486a47c8d9e063f183c6da77295529b06495f6a5c" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-33e2ca569a31fd03fdc0b2cbf7840572c15063457bac682e938bee632bd0260f.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-33e2ca569a31fd03fdc0b2cbf7840572c15063457bac682e938bee632bd0260f.json new file mode 100644 index 0000000000..1f7d697eed --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-33e2ca569a31fd03fdc0b2cbf7840572c15063457bac682e938bee632bd0260f.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT node_id, family_id\n FROM node_family_members", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "node_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "family_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false + ] + }, + "hash": "33e2ca569a31fd03fdc0b2cbf7840572c15063457bac682e938bee632bd0260f" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-486bbf89adfd9343549263e84a1fce8994aa1d0b4ef4078a98f5a3efa4c70493.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-486bbf89adfd9343549263e84a1fce8994aa1d0b4ef4078a98f5a3efa4c70493.json new file mode 100644 index 0000000000..244026dc3c --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-486bbf89adfd9343549263e84a1fce8994aa1d0b4ef4078a98f5a3efa4c70493.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO node_families\n (family_id, name, description, owner, family_stake_unym, members_count, created_at, last_updated_utc)\n SELECT * FROM UNNEST(\n $1::BIGINT[], $2::TEXT[], $3::TEXT[], $4::TEXT[],\n $5::BIGINT[], $6::INTEGER[], $7::BIGINT[], $8::BIGINT[]\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "TextArray", + "TextArray", + "TextArray", + "Int8Array", + "Int4Array", + "Int8Array", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "486bbf89adfd9343549263e84a1fce8994aa1d0b4ef4078a98f5a3efa4c70493" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-227539374e7473f6f9642289c5b5d1bcd636315ab23537cb5f6d2f82a2bcb7bf.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-5834dcc61f5409f568bad0484395b565b2e258f66f4981ace2cfb36bd8b89991.json similarity index 50% rename from nym-node-status-api/nym-node-status-api/.sqlx/query-227539374e7473f6f9642289c5b5d1bcd636315ab23537cb5f6d2f82a2bcb7bf.json rename to nym-node-status-api/nym-node-status-api/.sqlx/query-5834dcc61f5409f568bad0484395b565b2e258f66f4981ace2cfb36bd8b89991.json index 63d48b0826..d06e186dd8 100644 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-227539374e7473f6f9642289c5b5d1bcd636315ab23537cb5f6d2f82a2bcb7bf.json +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-5834dcc61f5409f568bad0484395b565b2e258f66f4981ace2cfb36bd8b89991.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n node_id,\n bond_info as \"bond_info: serde_json::Value\"\n FROM\n nym_nodes\n WHERE\n bond_info IS NOT NULL\n AND\n self_described IS NOT NULL\n ", + "query": "SELECT\n node_id,\n bond_info as \"bond_info: serde_json::Value\"\n FROM\n nym_nodes\n WHERE\n bond_info IS NOT NULL\n AND\n self_described IS NOT NULL\n ", "describe": { "columns": [ { @@ -22,5 +22,5 @@ true ] }, - "hash": "227539374e7473f6f9642289c5b5d1bcd636315ab23537cb5f6d2f82a2bcb7bf" + "hash": "5834dcc61f5409f568bad0484395b565b2e258f66f4981ace2cfb36bd8b89991" } diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-8c94f3c3bf7b9c3b135dc4b12303419b43304c3c93389abfc4c1d53955b15dd0.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-8c94f3c3bf7b9c3b135dc4b12303419b43304c3c93389abfc4c1d53955b15dd0.json new file mode 100644 index 0000000000..31cdba1d44 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-8c94f3c3bf7b9c3b135dc4b12303419b43304c3c93389abfc4c1d53955b15dd0.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO node_family_members (node_id, family_id, joined_at)\n SELECT * FROM UNNEST($1::BIGINT[], $2::BIGINT[], $3::BIGINT[])", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "Int8Array", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "8c94f3c3bf7b9c3b135dc4b12303419b43304c3c93389abfc4c1d53955b15dd0" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-c7656b2b1b4328415772ce69d0568bd5438d6c8496ca9cbdcfb70bb5375b345e.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-a1bb7a40d073335db7d9061771dfe62b0d2cfa2f7404f4afb8c3d393f0927f04.json similarity index 51% rename from nym-node-status-api/nym-node-status-api/.sqlx/query-c7656b2b1b4328415772ce69d0568bd5438d6c8496ca9cbdcfb70bb5375b345e.json rename to nym-node-status-api/nym-node-status-api/.sqlx/query-a1bb7a40d073335db7d9061771dfe62b0d2cfa2f7404f4afb8c3d393f0927f04.json index 5cf589c5d7..d9cc36f899 100644 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-c7656b2b1b4328415772ce69d0568bd5438d6c8496ca9cbdcfb70bb5375b345e.json +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-a1bb7a40d073335db7d9061771dfe62b0d2cfa2f7404f4afb8c3d393f0927f04.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n node_id,\n self_described as \"self_described: serde_json::Value\"\n FROM\n nym_nodes\n WHERE\n self_described IS NOT NULL\n ORDER BY\n node_id\n ", + "query": "SELECT\n node_id,\n self_described as \"self_described: serde_json::Value\"\n FROM\n nym_nodes\n WHERE\n self_described IS NOT NULL\n ORDER BY\n node_id\n ", "describe": { "columns": [ { @@ -22,5 +22,5 @@ true ] }, - "hash": "c7656b2b1b4328415772ce69d0568bd5438d6c8496ca9cbdcfb70bb5375b345e" + "hash": "a1bb7a40d073335db7d9061771dfe62b0d2cfa2f7404f4afb8c3d393f0927f04" } diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-a470feaded5ceee869202ab3a8bd0122403d252c369ebbee7b64cba8eacc4e05.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-a470feaded5ceee869202ab3a8bd0122403d252c369ebbee7b64cba8eacc4e05.json new file mode 100644 index 0000000000..44af265514 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-a470feaded5ceee869202ab3a8bd0122403d252c369ebbee7b64cba8eacc4e05.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM node_families", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "a470feaded5ceee869202ab3a8bd0122403d252c369ebbee7b64cba8eacc4e05" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-3ddc12cc4e1796b787a50c40560d2bd71d1cfe5f5265e6f161b3122d1317a421.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-e5c75bbade89f9b1b7ddaad50f48be0cb679568a751c1b7e604a098a824b2a7c.json similarity index 65% rename from nym-node-status-api/nym-node-status-api/.sqlx/query-3ddc12cc4e1796b787a50c40560d2bd71d1cfe5f5265e6f161b3122d1317a421.json rename to nym-node-status-api/nym-node-status-api/.sqlx/query-e5c75bbade89f9b1b7ddaad50f48be0cb679568a751c1b7e604a098a824b2a7c.json index 2e19afef0d..977c28feb8 100644 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-3ddc12cc4e1796b787a50c40560d2bd71d1cfe5f5265e6f161b3122d1317a421.json +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-e5c75bbade89f9b1b7ddaad50f48be0cb679568a751c1b7e604a098a824b2a7c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n node_id,\n ed25519_identity_pubkey,\n total_stake,\n ip_addresses as \"ip_addresses!: serde_json::Value\",\n mix_port,\n x25519_sphinx_pubkey,\n node_role as \"node_role: serde_json::Value\",\n supported_roles as \"supported_roles: serde_json::Value\",\n entry as \"entry: serde_json::Value\",\n performance,\n self_described as \"self_described: serde_json::Value\",\n bond_info as \"bond_info: serde_json::Value\",\n http_api_port\n FROM\n nym_nodes\n ORDER BY\n node_id\n ", + "query": "SELECT\n node_id,\n ed25519_identity_pubkey,\n total_stake,\n ip_addresses as \"ip_addresses!: serde_json::Value\",\n mix_port,\n x25519_sphinx_pubkey,\n node_role as \"node_role: serde_json::Value\",\n supported_roles as \"supported_roles: serde_json::Value\",\n entry as \"entry: serde_json::Value\",\n performance,\n self_described as \"self_described: serde_json::Value\",\n bond_info as \"bond_info: serde_json::Value\",\n http_api_port\n FROM\n nym_nodes\n WHERE\n self_described IS NOT NULL\n AND\n bond_info IS NOT NULL\n ", "describe": { "columns": [ { @@ -88,5 +88,5 @@ true ] }, - "hash": "3ddc12cc4e1796b787a50c40560d2bd71d1cfe5f5265e6f161b3122d1317a421" + "hash": "e5c75bbade89f9b1b7ddaad50f48be0cb679568a751c1b7e604a098a824b2a7c" } diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-0b51df277ed66c6553f66af9b135342dee177abc1c92e4a89147de3c22d3d1a5.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-f2d5127e36621cb4dd3a7870e95447ee2ca1d3830ee0cc2237238f119ac50480.json similarity index 67% rename from nym-node-status-api/nym-node-status-api/.sqlx/query-0b51df277ed66c6553f66af9b135342dee177abc1c92e4a89147de3c22d3d1a5.json rename to nym-node-status-api/nym-node-status-api/.sqlx/query-f2d5127e36621cb4dd3a7870e95447ee2ca1d3830ee0cc2237238f119ac50480.json index a5cc377447..12b754855f 100644 --- a/nym-node-status-api/nym-node-status-api/.sqlx/query-0b51df277ed66c6553f66af9b135342dee177abc1c92e4a89147de3c22d3d1a5.json +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-f2d5127e36621cb4dd3a7870e95447ee2ca1d3830ee0cc2237238f119ac50480.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n node_id,\n ed25519_identity_pubkey,\n total_stake,\n ip_addresses as \"ip_addresses!: serde_json::Value\",\n mix_port,\n x25519_sphinx_pubkey,\n node_role as \"node_role: serde_json::Value\",\n supported_roles as \"supported_roles: serde_json::Value\",\n entry as \"entry: serde_json::Value\",\n performance,\n self_described as \"self_described: serde_json::Value\",\n bond_info as \"bond_info: serde_json::Value\",\n http_api_port\n FROM\n nym_nodes\n WHERE\n self_described IS NOT NULL\n AND\n bond_info IS NOT NULL\n ", + "query": "SELECT\n node_id,\n ed25519_identity_pubkey,\n total_stake,\n ip_addresses as \"ip_addresses!: serde_json::Value\",\n mix_port,\n x25519_sphinx_pubkey,\n node_role as \"node_role: serde_json::Value\",\n supported_roles as \"supported_roles: serde_json::Value\",\n entry as \"entry: serde_json::Value\",\n performance,\n self_described as \"self_described: serde_json::Value\",\n bond_info as \"bond_info: serde_json::Value\",\n http_api_port\n FROM\n nym_nodes\n ORDER BY\n node_id\n ", "describe": { "columns": [ { @@ -88,5 +88,5 @@ true ] }, - "hash": "0b51df277ed66c6553f66af9b135342dee177abc1c92e4a89147de3c22d3d1a5" + "hash": "f2d5127e36621cb4dd3a7870e95447ee2ca1d3830ee0cc2237238f119ac50480" } diff --git a/nym-node-status-api/nym-node-status-api/Cargo.toml b/nym-node-status-api/nym-node-status-api/Cargo.toml index 729404d8b6..94fd77efe1 100644 --- a/nym-node-status-api/nym-node-status-api/Cargo.toml +++ b/nym-node-status-api/nym-node-status-api/Cargo.toml @@ -40,6 +40,7 @@ nym-http-api-common = { workspace = true, features = ["middleware"] } nym-network-defaults = { workspace = true } nym-serde-helpers = { workspace = true } nym-statistics-common = { workspace = true } +nym-api-requests = { workspace = true } nym-validator-client = { workspace = true } nym-task = { workspace = true } nym-node-requests = { workspace = true, features = ["openapi", "client"] } diff --git a/nym-node-status-api/nym-node-status-api/migrations_pg/20260514120000_node_families.sql b/nym-node-status-api/nym-node-status-api/migrations_pg/20260514120000_node_families.sql new file mode 100644 index 0000000000..95f3f9bd41 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/migrations_pg/20260514120000_node_families.sql @@ -0,0 +1,24 @@ +/* + * Copyright 2026 - Nym Technologies SA + * SPDX-License-Identifier: GPL-3.0-only + */ + +CREATE TABLE IF NOT EXISTS node_families ( + family_id BIGINT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL, + owner TEXT NOT NULL, + family_stake_unym BIGINT, + members_count INTEGER NOT NULL, + created_at BIGINT NOT NULL, + last_updated_utc BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS node_family_members ( + node_id BIGINT PRIMARY KEY, + family_id BIGINT NOT NULL REFERENCES node_families (family_id) ON DELETE CASCADE, + joined_at BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_node_family_members_family_id + ON node_family_members (family_id); diff --git a/nym-node-status-api/nym-node-status-api/src/db/mod.rs b/nym-node-status-api/nym-node-status-api/src/db/mod.rs index 03fe202e3a..14f5281b63 100644 --- a/nym-node-status-api/nym-node-status-api/src/db/mod.rs +++ b/nym-node-status-api/nym-node-status-api/src/db/mod.rs @@ -71,4 +71,21 @@ impl Storage { pub fn pool_owned(&self) -> DbPool { self.pool.clone() } + + pub fn pool(&self) -> &DbPool { + &self.pool + } + + /// Build a `Storage` view from an existing pool. The wrapped pool is + /// `Clone`-cheap (shares the same underlying connection set), so this is + /// effectively free. + pub(crate) fn from_pool(pool: DbPool) -> Self { + pool.into() + } +} + +impl From for Storage { + fn from(pool: DbPool) -> Self { + Storage { pool } + } } diff --git a/nym-node-status-api/nym-node-status-api/src/db/models.rs b/nym-node-status-api/nym-node-status-api/src/db/models.rs index 809c9651e3..d9291462af 100644 --- a/nym-node-status-api/nym-node-status-api/src/db/models.rs +++ b/nym-node-status-api/nym-node-status-api/src/db/models.rs @@ -672,3 +672,48 @@ pub struct HostKeysDeHelper { #[serde(default)] pub x25519_versioned_noise: Option, } + +// ---- node families ---- + +// it's not dead code but clippy doesn't detect usage in sqlx macros +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub(crate) struct NodeFamilyInsertRecord { + pub family_id: i64, + pub name: String, + pub description: String, + pub owner: String, + pub family_stake_unym: Option, + /// Member count as reported by nym-api; stored denormalised so callers + /// don't have to aggregate over `node_family_members`. + pub members_count: i32, + pub created_at: i64, + pub last_updated_utc: i64, + pub members: Vec, +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub(crate) struct NodeFamilyMemberInsertRecord { + pub node_id: i64, + pub joined_at: i64, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, FromRow)] +pub(crate) struct NodeFamilyDto { + pub family_id: i64, + pub name: String, + pub description: String, + pub owner: String, + pub family_stake_unym: Option, + pub members_count: i32, + pub created_at: i64, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, FromRow)] +pub(crate) struct NodeFamilyMemberDto { + pub node_id: i64, + pub family_id: i64, +} diff --git a/nym-node-status-api/nym-node-status-api/src/db/queries/gateways.rs b/nym-node-status-api/nym-node-status-api/src/db/queries/gateways.rs index 196d8623a5..99e178086a 100644 --- a/nym-node-status-api/nym-node-status-api/src/db/queries/gateways.rs +++ b/nym-node-status-api/nym-node-status-api/src/db/queries/gateways.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use crate::{ db::{ - DbConnection, DbPool, + DbConnection, DbPool, Storage, models::{GatewayDto, GatewayInsertRecord}, }, http::models::Gateway, @@ -30,25 +30,26 @@ pub(crate) async fn select_gateway_identity( Ok(record.gateway_identity_key) } -pub(crate) async fn update_bonded_gateways( - pool: &DbPool, - gateways: Vec, -) -> anyhow::Result<()> { - let mut tx = pool.begin().await?; +impl Storage { + pub(crate) async fn update_bonded_gateways( + &self, + gateways: Vec, + ) -> anyhow::Result<()> { + let mut tx = self.pool.begin().await?; - sqlx::query!( - r#"UPDATE + sqlx::query!( + r#"UPDATE gateways SET bonded = false "#, - ) - .execute(&mut *tx) - .await?; + ) + .execute(&mut *tx) + .await?; - for record in gateways { - sqlx::query!( - "INSERT INTO gateways + for record in gateways { + sqlx::query!( + "INSERT INTO gateways (gateway_identity_key, bonded, self_described, explorer_pretty_bond, last_updated_utc, performance) @@ -59,57 +60,57 @@ pub(crate) async fn update_bonded_gateways( explorer_pretty_bond=excluded.explorer_pretty_bond, last_updated_utc=excluded.last_updated_utc, performance = excluded.performance;", - record.identity_key, - record.bonded, - record.self_described, - record.explorer_pretty_bond, - record.last_updated_utc, - record.performance as i32 - ) - .execute(&mut *tx) - .await?; + record.identity_key, + record.bonded, + record.self_described, + record.explorer_pretty_bond, + record.last_updated_utc, + record.performance as i32 + ) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + Ok(()) } - tx.commit().await?; + pub(crate) async fn get_all_gateways(&self) -> anyhow::Result> { + let items = sqlx::query_as!( + GatewayDto, + r#"SELECT + gw.gateway_identity_key as "gateway_identity_key!", + gw.bonded as "bonded: bool", + gw.performance as "performance!", + gw.self_described as "self_described?", + gw.explorer_pretty_bond as "explorer_pretty_bond?", + gw.last_probe_result as "last_probe_result?", + gw.last_probe_log as "last_probe_log?", + gw.last_testrun_utc as "last_testrun_utc?", + gw.last_updated_utc as "last_updated_utc!", + gw.bridges as "bridges?: serde_json::Value", + COALESCE(gd.moniker, 'NA') as "moniker!", + COALESCE(gd.website, 'NA') as "website!", + COALESCE(gd.security_contact, 'NA') as "security_contact!", + COALESCE(gd.details, 'NA') as "details!" + FROM gateways gw + LEFT JOIN gateway_description gd + ON gw.gateway_identity_key = gd.gateway_identity_key + ORDER BY gw.gateway_identity_key"#, + ) + .fetch(&self.pool) + .try_collect::>() + .await?; - Ok(()) -} - -pub(crate) async fn get_all_gateways(pool: &DbPool) -> anyhow::Result> { - let mut conn = pool.acquire().await?; - let items = sqlx::query_as!( - GatewayDto, - r#"SELECT - gw.gateway_identity_key as "gateway_identity_key!", - gw.bonded as "bonded: bool", - gw.performance as "performance!", - gw.self_described as "self_described?", - gw.explorer_pretty_bond as "explorer_pretty_bond?", - gw.last_probe_result as "last_probe_result?", - gw.last_probe_log as "last_probe_log?", - gw.last_testrun_utc as "last_testrun_utc?", - gw.last_updated_utc as "last_updated_utc!", - gw.bridges as "bridges?: serde_json::Value", - COALESCE(gd.moniker, 'NA') as "moniker!", - COALESCE(gd.website, 'NA') as "website!", - COALESCE(gd.security_contact, 'NA') as "security_contact!", - COALESCE(gd.details, 'NA') as "details!" - FROM gateways gw - LEFT JOIN gateway_description gd - ON gw.gateway_identity_key = gd.gateway_identity_key - ORDER BY gw.gateway_identity_key"#, - ) - .fetch(&mut *conn) - .try_collect::>() - .await?; - - let items: Vec = items - .into_iter() - .map(|item| item.try_into()) - .collect::>>() - .inspect_err(|e| error!("Conversion from DTO failed: {e}. Invalidly stored data?"))?; - tracing::trace!("Fetched {} gateways from DB", items.len()); - Ok(items) + let items: Vec = items + .into_iter() + .map(|item| item.try_into()) + .collect::>>() + .inspect_err(|e| error!("Conversion from DTO failed: {e}. Invalidly stored data?"))?; + tracing::trace!("Fetched {} gateways from DB", items.len()); + Ok(items) + } } pub(crate) async fn get_bonded_gateway_id_keys(pool: &DbPool) -> anyhow::Result> { diff --git a/nym-node-status-api/nym-node-status-api/src/db/queries/misc.rs b/nym-node-status-api/nym-node-status-api/src/db/queries/misc.rs index 0dcc6130b3..5240424060 100644 --- a/nym-node-status-api/nym-node-status-api/src/db/queries/misc.rs +++ b/nym-node-status-api/nym-node-status-api/src/db/queries/misc.rs @@ -1,90 +1,92 @@ use time::UtcDateTime; -use crate::db::{DbPool, models::NetworkSummary}; +use crate::db::{Storage, models::NetworkSummary}; -/// take `last_updated` instead of calculating it so that `summary` matches -/// `daily_summary` -pub(crate) async fn insert_summaries( - pool: &DbPool, - summaries: &Vec<(&str, usize)>, - summary: &NetworkSummary, - last_updated: UtcDateTime, -) -> anyhow::Result<()> { - insert_summary(pool, summaries, last_updated).await?; +impl Storage { + /// take `last_updated` instead of calculating it so that `summary` matches + /// `daily_summary` + pub(crate) async fn insert_summaries( + &self, + summaries: &Vec<(&str, usize)>, + summary: &NetworkSummary, + last_updated: UtcDateTime, + ) -> anyhow::Result<()> { + self.insert_summary(summaries, last_updated).await?; - insert_summary_history(pool, summary, last_updated).await?; + self.insert_summary_history(summary, last_updated).await?; - Ok(()) -} + Ok(()) + } -async fn insert_summary( - pool: &DbPool, - summaries: &Vec<(&str, usize)>, - last_updated: UtcDateTime, -) -> anyhow::Result<()> { - let timestamp = last_updated.unix_timestamp(); - let mut tx = pool.begin().await?; + async fn insert_summary( + &self, + summaries: &Vec<(&str, usize)>, + last_updated: UtcDateTime, + ) -> anyhow::Result<()> { + let timestamp = last_updated.unix_timestamp(); + let mut tx = self.pool.begin().await?; - for (kind, value) in summaries { - let value = value.to_string(); - sqlx::query!( - "INSERT INTO summary + for (kind, value) in summaries { + let value = value.to_string(); + sqlx::query!( + "INSERT INTO summary (key, value_json, last_updated_utc) VALUES ($1, $2, $3) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, last_updated_utc=excluded.last_updated_utc;", - kind, - value, - timestamp - ) - .execute(&mut *tx) - .await - .map_err(|err| { - tracing::error!("Failed to insert data for {kind}: {err}, aborting transaction",); - err - })?; + kind, + value, + timestamp + ) + .execute(&mut *tx) + .await + .map_err(|err| { + tracing::error!("Failed to insert data for {kind}: {err}, aborting transaction",); + err + })?; + } + + tx.commit().await?; + + Ok(()) } - tx.commit().await?; + /// For ``, `summary_history` is updated with fresh data on every + /// iteration. + /// + /// After UTC midnight, summary is inserted for `` and last entry for + /// `` stays there forever. + /// + /// This is not aggregate data, it's a set of latest data points + async fn insert_summary_history( + &self, + summary: &NetworkSummary, + last_updated: UtcDateTime, + ) -> anyhow::Result<()> { + let mut conn = self.pool.acquire().await?; - Ok(()) -} + let value_json = serde_json::to_string(&summary)?; + let timestamp = last_updated.unix_timestamp(); -/// For ``, `summary_history` is updated with fresh data on every -/// iteration. -/// -/// After UTC midnight, summary is inserted for `` and last entry for -/// `` stays there forever. -/// -/// This is not aggregate data, it's a set of latest data points -async fn insert_summary_history( - pool: &DbPool, - summary: &NetworkSummary, - last_updated: UtcDateTime, -) -> anyhow::Result<()> { - let mut conn = pool.acquire().await?; + let date = datetime_to_only_date_str(last_updated); - let value_json = serde_json::to_string(&summary)?; - let timestamp = last_updated.unix_timestamp(); - - let date = datetime_to_only_date_str(last_updated); - - sqlx::query!( - "INSERT INTO summary_history + sqlx::query!( + "INSERT INTO summary_history (date, timestamp_utc, value_json) VALUES ($1, $2, $3) ON CONFLICT(date) DO UPDATE SET timestamp_utc=excluded.timestamp_utc, value_json=excluded.value_json;", - date, - timestamp, - value_json - ) - .execute(&mut *conn) - .await?; + date, + timestamp, + value_json + ) + .execute(&mut *conn) + .await?; - Ok(()) + Ok(()) + } } /// YYYY-MM-DD, without time diff --git a/nym-node-status-api/nym-node-status-api/src/db/queries/mod.rs b/nym-node-status-api/nym-node-status-api/src/db/queries/mod.rs index cb0e79b549..cac16a6d89 100644 --- a/nym-node-status-api/nym-node-status-api/src/db/queries/mod.rs +++ b/nym-node-status-api/nym-node-status-api/src/db/queries/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod ecash_data; mod gateways; mod gateways_stats; mod misc; +mod node_families; mod nym_nodes; mod packet_stats; pub(crate) mod scraper; @@ -9,16 +10,10 @@ mod summary; pub(crate) mod testruns; pub(crate) use gateways::{ - get_all_gateways, get_bonded_gateway_id_keys, get_or_create_gateway, select_gateway_identity, - update_bonded_gateways, + get_bonded_gateway_id_keys, get_or_create_gateway, select_gateway_identity, }; pub(crate) use gateways_stats::{delete_old_records, get_sessions_stats, insert_session_records}; -pub(crate) use misc::insert_summaries; -pub(crate) use nym_nodes::{ - get_all_nym_nodes, get_bonded_node_description, get_daily_stats, - get_described_bonded_nym_nodes, get_described_node_bond_info, get_node_self_description, - update_nym_nodes, -}; +pub(crate) use nym_nodes::get_daily_stats; pub(crate) use packet_stats::{ batch_store_node_scraper_results, get_raw_node_stats, insert_daily_node_stats_uncommitted, }; diff --git a/nym-node-status-api/nym-node-status-api/src/db/queries/node_families.rs b/nym-node-status-api/nym-node-status-api/src/db/queries/node_families.rs new file mode 100644 index 0000000000..491a90408d --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/src/db/queries/node_families.rs @@ -0,0 +1,135 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::db::Storage; +use crate::db::models::{NodeFamilyDto, NodeFamilyInsertRecord, NodeFamilyMemberDto}; +use futures_util::TryStreamExt; +use tracing::instrument; + +impl Storage { + /// Replace the node-families snapshot atomically. Wipes both + /// `node_families` and `node_family_members` (cascade) and re-inserts + /// the provided records inside a single transaction so reads never + /// observe a partial state. + /// + /// Both inserts are batched via `UNNEST(..)`, so the whole refresh is a + /// constant number of round trips regardless of how many families/members + /// the snapshot contains. + #[instrument(level = "debug", skip_all, fields(family_records = family_records.len()))] + pub(crate) async fn update_node_families( + &self, + family_records: Vec, + ) -> anyhow::Result { + let inserted = family_records.len(); + + // Reshape the row-major records into column-major vectors so we can + // bind each column as a Postgres array and let `UNNEST` expand them + // back into rows. + let mut family_ids: Vec = Vec::with_capacity(inserted); + let mut names: Vec = Vec::with_capacity(inserted); + let mut descriptions: Vec = Vec::with_capacity(inserted); + let mut owners: Vec = Vec::with_capacity(inserted); + let mut family_stakes: Vec> = Vec::with_capacity(inserted); + let mut members_counts: Vec = Vec::with_capacity(inserted); + let mut created_ats: Vec = Vec::with_capacity(inserted); + let mut last_updated_utcs: Vec = Vec::with_capacity(inserted); + + let total_members: usize = family_records.iter().map(|f| f.members.len()).sum(); + let mut member_node_ids: Vec = Vec::with_capacity(total_members); + let mut member_family_ids: Vec = Vec::with_capacity(total_members); + let mut member_joined_ats: Vec = Vec::with_capacity(total_members); + + for record in family_records { + let family_id = record.family_id; + family_ids.push(family_id); + names.push(record.name); + descriptions.push(record.description); + owners.push(record.owner); + family_stakes.push(record.family_stake_unym); + members_counts.push(record.members_count); + created_ats.push(record.created_at); + last_updated_utcs.push(record.last_updated_utc); + + for member in record.members { + member_node_ids.push(member.node_id); + member_family_ids.push(family_id); + member_joined_ats.push(member.joined_at); + } + } + + let mut tx = self.pool.begin().await?; + + // ON DELETE CASCADE on the members table wipes both sides + sqlx::query!("DELETE FROM node_families") + .execute(&mut *tx) + .await?; + + sqlx::query!( + "INSERT INTO node_families + (family_id, name, description, owner, family_stake_unym, members_count, created_at, last_updated_utc) + SELECT * FROM UNNEST( + $1::BIGINT[], $2::TEXT[], $3::TEXT[], $4::TEXT[], + $5::BIGINT[], $6::INTEGER[], $7::BIGINT[], $8::BIGINT[] + )", + &family_ids[..], + &names[..], + &descriptions[..], + &owners[..], + &family_stakes[..] as &[Option], + &members_counts[..], + &created_ats[..], + &last_updated_utcs[..], + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + "INSERT INTO node_family_members (node_id, family_id, joined_at) + SELECT * FROM UNNEST($1::BIGINT[], $2::BIGINT[], $3::BIGINT[])", + &member_node_ids[..], + &member_family_ids[..], + &member_joined_ats[..], + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(inserted) + } + + /// Read every cached family. + pub(crate) async fn get_all_node_families(&self) -> anyhow::Result> { + sqlx::query_as!( + NodeFamilyDto, + r#"SELECT + family_id, + name, + description, + owner, + family_stake_unym, + members_count, + created_at + FROM node_families + ORDER BY family_id"#, + ) + .fetch(&self.pool) + .try_collect::>() + .await + .map_err(From::from) + } + + /// Read every cached `(node_id, family_id)` membership pair. + pub(crate) async fn get_all_node_family_members( + &self, + ) -> anyhow::Result> { + sqlx::query_as!( + NodeFamilyMemberDto, + r#"SELECT node_id, family_id + FROM node_family_members"#, + ) + .fetch(&self.pool) + .try_collect::>() + .await + .map_err(From::from) + } +} diff --git a/nym-node-status-api/nym-node-status-api/src/db/queries/nym_nodes.rs b/nym-node-status-api/nym-node-status-api/src/db/queries/nym_nodes.rs index 969fd087f6..a6cd26223c 100644 --- a/nym-node-status-api/nym-node-status-api/src/db/queries/nym_nodes.rs +++ b/nym-node-status-api/nym-node-status-api/src/db/queries/nym_nodes.rs @@ -1,4 +1,5 @@ use crate::db::DbConnection; +use crate::db::Storage; use crate::db::models::NymNodeDescriptionDeHelper; use crate::http::models::DailyStats; use crate::{ @@ -13,100 +14,95 @@ use nym_node_requests::api::v1::node::models::NodeDescription; use nym_validator_client::client::{NodeId, NymNodeDetails}; use nym_validator_client::models::NymNodeDescriptionV2; use std::collections::HashMap; -use tracing::{instrument, warn}; +use tracing::{error, instrument, warn}; -pub(crate) async fn get_all_nym_nodes(pool: &DbPool) -> anyhow::Result> { - let mut conn = pool.acquire().await?; +impl Storage { + pub(crate) async fn get_all_nym_nodes(&self) -> anyhow::Result> { + sqlx::query_as!( + NymNodeDto, + r#"SELECT + node_id, + ed25519_identity_pubkey, + total_stake, + ip_addresses as "ip_addresses!: serde_json::Value", + mix_port, + x25519_sphinx_pubkey, + node_role as "node_role: serde_json::Value", + supported_roles as "supported_roles: serde_json::Value", + entry as "entry: serde_json::Value", + performance, + self_described as "self_described: serde_json::Value", + bond_info as "bond_info: serde_json::Value", + http_api_port + FROM + nym_nodes + ORDER BY + node_id + "#, + ) + .fetch(&self.pool) + .try_collect::>() + .await + .map_err(From::from) + } - sqlx::query_as!( - NymNodeDto, - r#"SELECT - node_id, - ed25519_identity_pubkey, - total_stake, - ip_addresses as "ip_addresses!: serde_json::Value", - mix_port, - x25519_sphinx_pubkey, - node_role as "node_role: serde_json::Value", - supported_roles as "supported_roles: serde_json::Value", - entry as "entry: serde_json::Value", - performance, - self_described as "self_described: serde_json::Value", - bond_info as "bond_info: serde_json::Value", - http_api_port - FROM - nym_nodes - ORDER BY - node_id - "#, - ) - .fetch(&mut *conn) - .try_collect::>() - .await - .map_err(From::from) -} + /// if a node doesn't expose its self-described endpoint, it can't route traffic + /// - https://nym.com/docs/operators/nodes/nym-node/bonding + /// + /// same if it's not bonded in the mixnet smart contract + /// - https://nym.com/docs/operators/tokenomics/mixnet-rewards#rewarded-set-selection + pub(crate) async fn get_described_bonded_nym_nodes(&self) -> anyhow::Result> { + sqlx::query_as!( + NymNodeDto, + r#"SELECT + node_id, + ed25519_identity_pubkey, + total_stake, + ip_addresses as "ip_addresses!: serde_json::Value", + mix_port, + x25519_sphinx_pubkey, + node_role as "node_role: serde_json::Value", + supported_roles as "supported_roles: serde_json::Value", + entry as "entry: serde_json::Value", + performance, + self_described as "self_described: serde_json::Value", + bond_info as "bond_info: serde_json::Value", + http_api_port + FROM + nym_nodes + WHERE + self_described IS NOT NULL + AND + bond_info IS NOT NULL + "#, + ) + .fetch(&self.pool) + .try_collect::>() + .await + .map_err(From::from) + } -/// if a node doesn't expose its self-described endpoint, it can't route traffic -/// - https://nym.com/docs/operators/nodes/nym-node/bonding -/// -/// same if it's not bonded in the mixnet smart contract -/// - https://nym.com/docs/operators/tokenomics/mixnet-rewards#rewarded-set-selection -pub(crate) async fn get_described_bonded_nym_nodes( - pool: &DbPool, -) -> anyhow::Result> { - let mut conn = pool.acquire().await?; + #[instrument(level = "debug", skip_all, fields(node_records=node_records.len()))] + pub(crate) async fn update_nym_nodes( + &self, + node_records: Vec, + ) -> anyhow::Result { + let mut tx = self.pool.begin().await?; - sqlx::query_as!( - NymNodeDto, - r#"SELECT - node_id, - ed25519_identity_pubkey, - total_stake, - ip_addresses as "ip_addresses!: serde_json::Value", - mix_port, - x25519_sphinx_pubkey, - node_role as "node_role: serde_json::Value", - supported_roles as "supported_roles: serde_json::Value", - entry as "entry: serde_json::Value", - performance, - self_described as "self_described: serde_json::Value", - bond_info as "bond_info: serde_json::Value", - http_api_port - FROM - nym_nodes - WHERE - self_described IS NOT NULL - AND - bond_info IS NOT NULL - "#, - ) - .fetch(&mut *conn) - .try_collect::>() - .await - .map_err(From::from) -} - -#[instrument(level = "debug", skip_all, fields(node_records=node_records.len()))] -pub(crate) async fn update_nym_nodes( - pool: &DbPool, - node_records: Vec, -) -> anyhow::Result { - let mut tx = pool.begin().await?; - - sqlx::query!( - "UPDATE nym_nodes + sqlx::query!( + "UPDATE nym_nodes SET self_described = NULL, bond_info = NULL", - ) - .execute(&mut *tx) - .await?; + ) + .execute(&mut *tx) + .await?; - let inserted = node_records.len(); - for record in node_records { - // https://www.sqlite.org/lang_upsert.html - sqlx::query!( - "INSERT INTO nym_nodes + let inserted = node_records.len(); + for record in node_records { + // https://www.sqlite.org/lang_upsert.html + sqlx::query!( + "INSERT INTO nym_nodes (node_id, ed25519_identity_pubkey, total_stake, ip_addresses, mix_port, @@ -131,146 +127,148 @@ pub(crate) async fn update_nym_nodes( last_updated_utc=excluded.last_updated_utc, http_api_port=excluded.http_api_port ;", - record.node_id, - record.ed25519_identity_pubkey, - record.total_stake, - record.ip_addresses, - record.mix_port, - record.x25519_sphinx_pubkey, - record.node_role, - record.supported_roles, - record.entry, - record.self_described, - record.bond_info, - record.performance, - record.last_updated_utc as i32, - record.http_api_port, - ) - .execute(&mut *tx) - .await - .map_err(|e| anyhow::anyhow!("Failed to INSERT node_id={}: {}", record.node_id, e))?; + record.node_id, + record.ed25519_identity_pubkey, + record.total_stake, + record.ip_addresses, + record.mix_port, + record.x25519_sphinx_pubkey, + record.node_role, + record.supported_roles, + record.entry, + record.self_described, + record.bond_info, + record.performance, + record.last_updated_utc as i32, + record.http_api_port, + ) + .execute(&mut *tx) + .await + .map_err(|e| anyhow::anyhow!("Failed to INSERT node_id={}: {}", record.node_id, e))?; + } + + tx.commit().await?; + + Ok(inserted) } - tx.commit().await?; + pub(crate) async fn get_described_node_bond_info( + &self, + ) -> anyhow::Result> { + sqlx::query!( + r#"SELECT + node_id, + bond_info as "bond_info: serde_json::Value" + FROM + nym_nodes + WHERE + bond_info IS NOT NULL + AND + self_described IS NOT NULL + "#, + ) + .fetch_all(&self.pool) + .await + .map(|records| { + records + .into_iter() + .filter_map(|record| { + let node_id = record.node_id; + record + .bond_info + // only return details for nodes which have details stored + .and_then(|bond_info| { + serde_json::from_value::(bond_info) + .inspect_err(|err| { + error!("malformed bond_info for node {node_id}: {err}") + }) + .ok() + }) + .map(|res| (node_id as NodeId, res)) + }) + .collect::>() + }) + .map_err(From::from) + } - Ok(inserted) -} + pub(crate) async fn get_node_self_description( + &self, + ) -> anyhow::Result> { + sqlx::query!( + r#"SELECT + node_id, + self_described as "self_described: serde_json::Value" + FROM + nym_nodes + WHERE + self_described IS NOT NULL + ORDER BY + node_id + "#, + ) + .fetch_all(&self.pool) + .await + .map(|records| { + records + .into_iter() + .filter_map(|record| { + let node_id = record.node_id; + record + .self_described + // only return details for nodes which have details stored + .and_then(|description| { + serde_json::from_value::(description) + .inspect_err(|err| { + warn!("malformed description data for node {node_id}: {err}") + }) + .ok() + }) + .map(|res| (record.node_id as NodeId, res.into())) + }) + .collect::>() + }) + .map_err(From::from) + } -pub(crate) async fn get_described_node_bond_info( - pool: &DbPool, -) -> anyhow::Result> { - let mut conn = pool.acquire().await?; - - sqlx::query!( - r#"SELECT - node_id, - bond_info as "bond_info: serde_json::Value" - FROM - nym_nodes - WHERE - bond_info IS NOT NULL - AND - self_described IS NOT NULL - "#, - ) - .fetch_all(&mut *conn) - .await - .map(|records| { - records - .into_iter() - .filter_map(|record| { - record - .bond_info - // only return details for nodes which have details stored - .and_then(|bond_info| serde_json::from_value::(bond_info).ok()) - .map(|res| (record.node_id as NodeId, res)) - }) - .collect::>() - }) - .map_err(From::from) -} - -pub(crate) async fn get_node_self_description( - pool: &DbPool, -) -> anyhow::Result> { - let mut conn = pool.acquire().await?; - - sqlx::query!( - r#"SELECT - node_id, - self_described as "self_described: serde_json::Value" - FROM - nym_nodes - WHERE - self_described IS NOT NULL - ORDER BY - node_id - "#, - ) - .fetch_all(&mut *conn) - .await - .map(|records| { - records - .into_iter() - .filter_map(|record| { - let node_id = record.node_id; - record - .self_described - // only return details for nodes which have details stored - .and_then(|description| { - serde_json::from_value::(description) - .inspect_err(|err| { - warn!("malformed description data for node {node_id}: {err}") - }) - .ok() - }) - .map(|res| (record.node_id as NodeId, res.into())) - }) - .collect::>() - }) - .map_err(From::from) -} - -pub(crate) async fn get_bonded_node_description( - pool: &DbPool, -) -> anyhow::Result> { - let mut conn = pool.acquire().await?; - - sqlx::query!( - r#"SELECT - nd.node_id, - moniker, - website, - security_contact, - details - FROM - nym_node_descriptions nd - INNER JOIN - nym_nodes nn on nd.node_id = nn.node_id - WHERE - bond_info IS NOT NULL - "#, - ) - .fetch_all(&mut *conn) - .await - .map(|records| { - records - .into_iter() - .map(|elem| { - let node_id: NodeId = elem.node_id.try_into().unwrap_or_default(); - ( - node_id, - NodeDescription { - moniker: elem.moniker.unwrap_or_default(), - website: elem.website.unwrap_or_default(), - security_contact: elem.security_contact.unwrap_or_default(), - details: elem.details.unwrap_or_default(), - }, - ) - }) - .collect::>() - }) - .map_err(From::from) + pub(crate) async fn get_bonded_node_description( + &self, + ) -> anyhow::Result> { + sqlx::query!( + r#"SELECT + nd.node_id, + moniker, + website, + security_contact, + details + FROM + nym_node_descriptions nd + INNER JOIN + nym_nodes nn on nd.node_id = nn.node_id + WHERE + bond_info IS NOT NULL + "#, + ) + .fetch_all(&self.pool) + .await + .map(|records| { + records + .into_iter() + .map(|elem| { + let node_id: NodeId = elem.node_id.try_into().unwrap_or_default(); + ( + node_id, + NodeDescription { + moniker: elem.moniker.unwrap_or_default(), + website: elem.website.unwrap_or_default(), + security_contact: elem.security_contact.unwrap_or_default(), + details: elem.details.unwrap_or_default(), + }, + ) + }) + .collect::>() + }) + .map_err(From::from) + } } pub(crate) async fn insert_nym_node_description( diff --git a/nym-node-status-api/nym-node-status-api/src/db/queries/scraper.rs b/nym-node-status-api/nym-node-status-api/src/db/queries/scraper.rs index 8b962067e3..6e0aa76a24 100644 --- a/nym-node-status-api/nym-node-status-api/src/db/queries/scraper.rs +++ b/nym-node-status-api/nym-node-status-api/src/db/queries/scraper.rs @@ -18,7 +18,8 @@ pub(crate) async fn get_nodes_for_scraping(pool: &DbPool) -> Result) -> HttpResult) -> HttpResult) -> HttpResult) -> HttpResult) -> HttpResult MIN_SUPPORTED_VERSION.clone(), }; + let storage = state.storage(); Ok(Json( state .cache() - .get_dvpn_gateway_list(state.db_pool(), &min_node_version) + .get_dvpn_gateway_list(storage, &min_node_version) .await, )) } #[instrument(level = tracing::Level::INFO, skip(state))] pub async fn dvpn_gateway_ips(state: State) -> HttpResult>> { + let storage = state.storage(); Ok(Json( state .cache() - .get_gateway_ips(state.db_pool(), &MIN_SUPPORTED_VERSION) + .get_gateway_ips(storage, &MIN_SUPPORTED_VERSION) .await, )) } diff --git a/nym-node-status-api/nym-node-status-api/src/http/api/gateways.rs b/nym-node-status-api/nym-node-status-api/src/http/api/gateways.rs index 687e02ad34..ac24be2351 100644 --- a/nym-node-status-api/nym-node-status-api/src/http/api/gateways.rs +++ b/nym-node-status-api/nym-node-status-api/src/http/api/gateways.rs @@ -34,8 +34,8 @@ async fn gateways( Query(pagination): Query, State(state): State, ) -> HttpResult>> { - let db = state.db_pool(); - let res = state.cache().get_gateway_list(db).await; + let storage = state.storage(); + let res = state.cache().get_gateway_list(storage).await; Ok(Json(PagedResult::paginate(pagination, res))) } @@ -55,8 +55,8 @@ async fn gateways_skinny( Query(pagination): Query, State(state): State, ) -> HttpResult>> { - let db = state.db_pool(); - let res = state.cache().get_gateway_list(db).await; + let storage = state.storage(); + let res = state.cache().get_gateway_list(storage).await; let res: Vec = filter_bonded_gateways_to_skinny(res); Ok(Json(PagedResult::paginate(pagination, res))) @@ -83,8 +83,8 @@ async fn get_gateway( Path(IdentityKeyParam { identity_key }): Path, State(state): State, ) -> HttpResult> { - let db = state.db_pool(); - let res = state.cache().get_gateway_list(db).await; + let storage = state.storage(); + let res = state.cache().get_gateway_list(storage).await; match res .iter() diff --git a/nym-node-status-api/nym-node-status-api/src/http/api/nym_nodes.rs b/nym-node-status-api/nym-node-status-api/src/http/api/nym_nodes.rs index ae551685a4..ab8e4539c0 100644 --- a/nym-node-status-api/nym-node-status-api/src/http/api/nym_nodes.rs +++ b/nym-node-status-api/nym-node-status-api/src/http/api/nym_nodes.rs @@ -40,12 +40,12 @@ async fn nym_nodes( Query(pagination): Query, State(state): State, ) -> HttpResult>> { - let db = state.db_pool(); + let storage = state.storage(); let node_geocache = state.node_geocache(); let nodes = state .cache() - .get_nym_nodes_list(db, node_geocache) + .get_nym_nodes_list(storage, node_geocache) .await .map_err(|e| { tracing::error!("{e}"); diff --git a/nym-node-status-api/nym-node-status-api/src/http/api/services/mod.rs b/nym-node-status-api/nym-node-status-api/src/http/api/services/mod.rs index 9658ab3614..a1615289a0 100644 --- a/nym-node-status-api/nym-node-status-api/src/http/api/services/mod.rs +++ b/nym-node-status-api/nym-node-status-api/src/http/api/services/mod.rs @@ -43,14 +43,14 @@ async fn mixnodes( Query(params): Query, State(state): State, ) -> HttpResult>> { - let db = state.db_pool(); + let storage = state.storage(); let cache = state.cache(); let paths = ParseJsonPaths::new().map_err(|e| { tracing::error!("Invalidly configured ParseJsonPaths: {e}"); HttpError::internal() })?; - let res = cache.get_gateway_list(db).await; + let res = cache.get_gateway_list(storage).await; let services = gateway_list_to_services(&paths, res, params.clone()); Ok(Json(PagedResult::paginate( diff --git a/nym-node-status-api/nym-node-status-api/src/http/models/mod.rs b/nym-node-status-api/nym-node-status-api/src/http/models/mod.rs index e070a85ec3..dfb93f9c52 100644 --- a/nym-node-status-api/nym-node-status-api/src/http/models/mod.rs +++ b/nym-node-status-api/nym-node-status-api/src/http/models/mod.rs @@ -9,7 +9,7 @@ use crate::{ monitor::ExplorerPrettyBond, }; use cosmwasm_std::{Addr, Coin, Decimal}; -use nym_mixnet_contract_common::CoinSchema; +use nym_mixnet_contract_common::{CoinSchema, NodeRewarding}; use nym_node_requests::api::v1::node::models::NodeDescription; use nym_validator_client::{ client::NodeId, @@ -29,6 +29,7 @@ use utoipa::ToSchema; use crate::db::models::NymNodeDataDeHelper; use crate::node_scraper::models::BridgeInformation; +use crate::monitor::geodata; pub(crate) use nym_node_status_client::models::TestrunAssignment; pub(crate) mod gw_probe; @@ -50,6 +51,128 @@ pub struct Gateway { pub bridges: Option, } +impl Gateway { + fn geo_location(&self) -> anyhow::Result { + self.explorer_pretty_bond + .clone() + .ok_or_else(|| anyhow::anyhow!("Missing explorer_pretty_bond")) + .and_then(|value| { + serde_json::from_value::(value).map_err(From::from) + }) + .map(|bond| bond.location) + } + + pub(crate) fn location(&self) -> anyhow::Result { + let geolocation = self.geo_location()?; + Ok(Location { + latitude: geolocation.location.latitude, + longitude: geolocation.location.longitude, + two_letter_iso_country_code: geolocation.two_letter_iso_country_code, + org: geolocation.org, + city: geolocation.city, + region: geolocation.region, + postal: geolocation.postal, + timezone: geolocation.timezone, + asn: geolocation.asn.map(|a| { + let kind = if a.kind.eq_ignore_ascii_case("isp") { + // we consider anything that is "ISP" from ipinfo to be residential + AsnKind::Residential + } else { + // everything else is considered "other" + AsnKind::Other + }; + Asn { + asn: a.asn, + domain: a.domain, + kind, + name: a.name, + route: a.route, + } + }), + }) + } + + pub(crate) fn self_described(&self) -> anyhow::Result { + self.self_described + .clone() + .ok_or_else(|| anyhow::anyhow!("Missing self_described")) + .and_then(|value| { + serde_json::from_value::(value).map_err(From::from) + }) + } + + pub(crate) fn bridges(&self) -> Option { + self.bridges.clone().and_then(|v| { + serde_json::from_value::(v) + .inspect_err(|err| { + error!( + "Failed to deserialize bridges for gateway identity {}: {err}", + self.gateway_identity_key + ); + }) + .ok() + }) + } + + fn last_probe_result(&self) -> anyhow::Result> { + let Some(last_probe) = &self.last_probe_result else { + return Ok(None); + }; + let probe = + LastProbeResult::deserialize_with_fallback(last_probe.clone()).inspect_err(|err| { + error!("Failed to deserialize probe result: {err}"); + })?; + + tracing::trace!("🌈 gateway probe parsed: {probe:?}"); + Ok(Some(probe)) + } + + pub(crate) fn last_dvpn_probe_result( + &self, + socks5_score: Option<&ScoreValue>, + ) -> anyhow::Result> { + let Some(last_probe) = self.last_probe_result()? else { + return Ok(None); + }; + + let socks5_score = socks5_score.unwrap_or(&ScoreValue::Offline).to_owned(); + let dvpn_probe_result = + DvpnProbeOutcome::from_raw_probe_outcome(last_probe.outcome(), socks5_score); + + Ok(Some(dvpn_probe_result)) + } + + pub(crate) fn performance_v2(&self) -> anyhow::Result> { + let Some(last_probe) = self.last_probe_result()? else { + return Ok(None); + }; + + let last_updated_utc = self.last_testrun_utc.clone().unwrap_or_default(); + + let network_monitor_performance_mixnet_mode = self.performance as f32 / 100f32; + let mixnet_score = calculate_mixnet_score(self); + let score = calc_gateway_visual_score(self, &last_probe); + let mut load = calculate_load(&last_probe); + + // clamp the load value to offline, when the score is offline + if score == ScoreValue::Offline { + load = ScoreValue::Offline; + } + + let performance_v2 = DVpnGatewayPerformance { + last_updated_utc: last_updated_utc.to_string(), + load, + score, + mixnet_score, + + // the network monitor's measure is a good proxy for node uptime, it can be improved in the future + uptime_percentage_last_24_hours: network_monitor_performance_mixnet_mode, + }; + + Ok(Some(performance_v2)) + } +} + #[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] pub struct BuildInformation { pub build_version: String, @@ -157,6 +280,66 @@ impl From<&LewesProtocolDetailsDataV1Validator> for LewesProtocolDetailsDataV1 { } } +#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] +pub struct NodeFamilyInformation { + // family id + id: u32, + + // family name + name: String, + + // description + description: String, + + // amount in unym + family_stake: u128, + + // number of members + members: usize, +} + +impl NodeFamilyInformation { + pub(crate) fn new( + id: u32, + name: String, + description: String, + family_stake: u128, + members: usize, + ) -> Self { + Self { + id, + name, + description, + family_stake, + members, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] +pub struct NodeStakeInformation { + // delegations + bond + total_stake: u128, + total_delegations: u128, + total_bond: u128, + // number of delegations + delegations: usize, +} + +impl From<&NodeRewarding> for NodeStakeInformation { + fn from(rewarding: &NodeRewarding) -> Self { + let denom = &rewarding.cost_params.interval_operating_cost.denom; + let total_bond = rewarding.operator_pledge_with_reward(denom).amount.u128(); + let total_delegations = rewarding.delegations_with_reward(denom).amount.u128(); + NodeStakeInformation { + total_stake: total_bond + total_delegations, + total_bond, + total_delegations, + delegations: rewarding.unique_delegations as usize, + } + } +} + #[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] pub struct DVpnGateway { pub identity_key: String, @@ -183,6 +366,9 @@ pub struct DVpnGateway { pub lewes_protocol_details: Option, + pub family_data: Option, + pub staking_data: Option, + pub build_information: BinaryBuildInformationOwned, } @@ -208,73 +394,20 @@ impl DVpnGateway { gateway: Gateway, skimmed_node: &SkimmedNodeV1, socks5_score: Option<&ScoreValue>, + family_details: Option, + staking_details: Option, ) -> anyhow::Result { - let location = gateway - .explorer_pretty_bond - .clone() - .ok_or_else(|| anyhow::anyhow!("Missing explorer_pretty_bond")) - .and_then(|value| { - serde_json::from_value::(value).map_err(From::from) - }) - .map(|bond| bond.location)?; - - let self_described: NymNodeDataDeHelper = gateway - .self_described - .clone() - .ok_or_else(|| anyhow::anyhow!("Missing self_described")) - .and_then(|value| { - serde_json::from_value::(value).map_err(From::from) - })?; + let location = gateway.location()?; + let self_described = gateway.self_described()?; let last_updated_utc = gateway.last_testrun_utc.clone().unwrap_or_default(); let performance = to_percent(gateway.performance); - let network_monitor_performance_mixnet_mode = gateway.performance as f32 / 100f32; - let bridges = gateway.bridges.clone().and_then(|v| { - serde_json::from_value(v) - .inspect_err(|err| { - error!( - "Failed to deserialize bridges for gateway identity {}: {err}", - gateway.gateway_identity_key - ); - }) - .ok() - }); + let bridges = gateway.bridges(); tracing::debug!("🌈 gateway probe result: {:?}", gateway.last_probe_result); - let (last_probe_result, performance_v2) = match gateway.last_probe_result { - Some(ref value) => { - let parsed = LastProbeResult::deserialize_with_fallback(value.clone()) - .inspect_err(|err| { - error!("Failed to deserialize probe result: {err}"); - })?; - - tracing::trace!("🌈 gateway probe parsed: {:?}", parsed); - let mixnet_score = calculate_mixnet_score(&gateway); - let score = calc_gateway_visual_score(&gateway, &parsed); - let mut load = calculate_load(&parsed); - let socks5_score = socks5_score.unwrap_or(&ScoreValue::Offline).to_owned(); - let dvpn_probe_result = - DvpnProbeOutcome::from_raw_probe_outcome(parsed.outcome(), socks5_score); - - // clamp the load value to offline, when the score is offline - if score == ScoreValue::Offline { - load = ScoreValue::Offline; - } - - let performance_v2 = DVpnGatewayPerformance { - last_updated_utc: last_updated_utc.to_string(), - load, - score, - mixnet_score, - - // the network monitor's measure is a good proxy for node uptime, it can be improved in the future - uptime_percentage_last_24_hours: network_monitor_performance_mixnet_mode, - }; - (Some(dvpn_probe_result), Some(performance_v2)) - } - None => (None, None), - }; + let last_probe_result = gateway.last_dvpn_probe_result(socks5_score)?; + let performance_v2 = gateway.performance_v2()?; Ok(Self { identity_key: gateway.gateway_identity_key, @@ -282,32 +415,7 @@ impl DVpnGateway { description: Some(gateway.description.details), ip_packet_router: self_described.ip_packet_router, authenticator: self_described.authenticator, - location: Location { - latitude: location.location.latitude, - longitude: location.location.longitude, - two_letter_iso_country_code: location.two_letter_iso_country_code, - org: location.org, - city: location.city, - region: location.region, - postal: location.postal, - timezone: location.timezone, - asn: location.asn.map(|a| { - let kind = if a.kind.eq_ignore_ascii_case("isp") { - // we consider anything that is "ISP" from ipinfo to be residential - AsnKind::Residential - } else { - // everything else is considered "other" - AsnKind::Other - }; - Asn { - asn: a.asn, - domain: a.domain, - kind, - name: a.name, - route: a.route, - } - }), - }, + location, last_probe: last_probe_result .map(|res| DvpnGwProbe::from_outcome(res, last_updated_utc)), ip_addresses: skimmed_node.ip_addresses.clone(), @@ -321,6 +429,8 @@ impl DVpnGateway { .lewes_protocol .as_ref() .map(LewesProtocolDetailsV1::from), + family_data: family_details, + staking_data: staking_details, build_information: self_described.build_information, }) } @@ -664,6 +774,7 @@ pub(crate) struct ExtendedNymNode { pub(crate) rewarding_details: Option, pub(crate) description: NodeDescription, pub(crate) geoip: Option, + pub family_data: Option, } #[derive(Clone, Debug, utoipa::ToSchema, Deserialize, Serialize)] diff --git a/nym-node-status-api/nym-node-status-api/src/http/server.rs b/nym-node-status-api/nym-node-status-api/src/http/server.rs index 26106f3ba6..0127cc0fc1 100644 --- a/nym-node-status-api/nym-node-status-api/src/http/server.rs +++ b/nym-node-status-api/nym-node-status-api/src/http/server.rs @@ -1,6 +1,6 @@ use crate::ticketbook_manager::state::TicketbookManagerState; use crate::{ - db::DbPool, + db, http::{api::RouterBuilder, state::AppState}, monitor::{DelegationsCache, NodeGeoCache}, }; @@ -15,7 +15,7 @@ use tokio::{net::TcpListener, sync::RwLock}; /// background tokio task #[allow(clippy::too_many_arguments)] pub(crate) async fn start_http_api( - db_pool: DbPool, + storage: db::Storage, http_port: u16, nym_http_cache_ttl: u64, agent_key_list: Vec, @@ -29,7 +29,7 @@ pub(crate) async fn start_http_api( let router_builder = RouterBuilder::with_default_routes(); let state = AppState::new( - db_pool, + storage, nym_http_cache_ttl, agent_key_list, agent_max_count, diff --git a/nym-node-status-api/nym-node-status-api/src/http/state.rs b/nym-node-status-api/nym-node-status-api/src/http/state.rs index b630c17d40..4819c4ab80 100644 --- a/nym-node-status-api/nym-node-status-api/src/http/state.rs +++ b/nym-node-status-api/nym-node-status-api/src/http/state.rs @@ -15,9 +15,10 @@ use tokio::sync::RwLock; use tracing::{error, instrument, trace, warn}; use utoipa::ToSchema; -use super::models::SessionStats; +use super::models::{NodeFamilyInformation, NodeStakeInformation, SessionStats}; use crate::{ - db::{DbPool, queries}, + db, + db::DbPool, http::{ error::{HttpError, HttpResult}, models::{ @@ -30,10 +31,11 @@ use crate::{ use crate::ticketbook_manager::state::TicketbookManagerState; pub(crate) use nym_validator_client::models::BinaryBuildInformationOwned; +use nym_validator_client::nyxd::contract_traits::node_families_query_client::NodeFamilyId; #[derive(Clone)] pub(crate) struct AppState { - db_pool: DbPool, + storage: db::Storage, cache: HttpCache, agent_key_list: Vec, agent_max_count: i64, @@ -47,7 +49,7 @@ pub(crate) struct AppState { impl AppState { #[allow(clippy::too_many_arguments)] pub(crate) async fn new( - db_pool: DbPool, + storage: db::Storage, cache_ttl: u64, agent_key_list: Vec, agent_max_count: i64, @@ -57,7 +59,7 @@ impl AppState { ticketbook_manager_state: TicketbookManagerState, ) -> Self { Self { - db_pool, + storage, cache: HttpCache::new(cache_ttl).await, agent_key_list, agent_max_count, @@ -70,7 +72,11 @@ impl AppState { } pub(crate) fn db_pool(&self) -> &DbPool { - &self.db_pool + self.storage.pool() + } + + pub(crate) fn storage(&self) -> &db::Storage { + &self.storage } pub(crate) fn cache(&self) -> &HttpCache { @@ -225,7 +231,7 @@ impl HttpCache { .await } - pub async fn get_gateway_list(&self, db: &DbPool) -> Vec { + pub async fn get_gateway_list(&self, storage: &db::Storage) -> Vec { match self.gateways.get(GATEWAYS_LIST_KEY).await { Some(guard) => { tracing::trace!("Fetching from cache..."); @@ -236,7 +242,7 @@ impl HttpCache { // the key is missing so populate it tracing::trace!("No gateways in cache, refreshing cache from DB..."); - let gateways = match crate::db::queries::get_all_gateways(db).await { + let gateways = match storage.get_all_gateways().await { Ok(gws) => { tracing::info!("Successfully fetched {} gateways from database", gws.len()); if !gws.is_empty() { @@ -282,148 +288,185 @@ impl HttpCache { pub async fn get_dvpn_gateway_list( &self, - db: &DbPool, + storage: &db::Storage, min_node_version: &Version, ) -> Vec { - match self.dvpn_gateways.get(DVPN_GATEWAYS_LIST_KEY).await { + let gateways = match self.dvpn_gateways.get(DVPN_GATEWAYS_LIST_KEY).await { Some(guard) => { tracing::trace!("Fetching from cache..."); - let read_lock = guard.read().await; - read_lock.clone() + guard.read().await.clone() } None => { tracing::info!("No gateways (dVPN) in cache, refreshing from DB..."); - - let gateways = self.get_gateway_list(db).await; - tracing::info!("Found {} gateways in database", gateways.len()); - - let started_with = gateways.len(); - let skimmed_nodes = match crate::db::queries::get_described_bonded_nym_nodes(db) - .await - { - Ok(records) => { - let mut nodes = HashMap::new(); - for dto in records { - match SkimmedNodeV1::try_from(dto) { - Ok(skimmed_node) => { - let key = - skimmed_node.ed25519_identity_pubkey.to_base58_string(); - nodes.insert(key, skimmed_node); - } - Err(err) => { - error!( - "CRITICAL: Failed to convert NymNodeDto to SkimmedNode: {err}" - ); - panic!( - "Cannot convert database record to SkimmedNode - this should never happen! Error: {err}" - ); - } - } - } - nodes - } - Err(err) => { - error!("CRITICAL: Failed to query nym_nodes from database: {err}"); - panic!( - "Cannot read nym_nodes table - database connection issue? Error: {err}" - ); - } - }; - - let socks5_scores = calculate_socks5_percentiles(&gateways); - - let res_gws = gateways - .iter() - .filter(|gw| gw.bonded) - .filter_map(|gw| match skimmed_nodes.get(&gw.gateway_identity_key) { - Some(skimmed_node) => Some((gw, skimmed_node)), - None => { - error!( - "CRITICAL: Gateway {} exists in gateways table but not in nym_nodes table! This should not happen.", - gw.gateway_identity_key - ); - None - } - }) - .filter_map( - |(gw, skimmed_node)| match DVpnGateway::new(gw.clone(), skimmed_node, socks5_scores.get(&gw.gateway_identity_key)) { - Ok(gw) => Some(gw), - Err(err) => { - error!( - "CRITICAL: Failed to create DVpnGateway for node_id={}, identity_key={}: {}", - skimmed_node.node_id, - skimmed_node.ed25519_identity_pubkey.to_base58_string(), - err - ); - // Don't panic here as this might be due to missing fields, but log it loudly - None - } - }, - ) - .filter(|gw| { - let gw_version = &gw.build_information.build_version; - if let Ok(gw_version) = Version::parse(gw_version) { - &gw_version >= min_node_version - } else { - warn!("Failed to parse GW version {}", gw_version); - false - } - }) - .filter(|gw| { - // gateways must have a country - if gw.location.two_letter_iso_country_code.len() == 2 { - true - } else { - warn!( - "Invalid country code: {}", - gw.location.two_letter_iso_country_code - ); - false - } - }) - // sort by country, then by identity key - .sorted_by_key(|item| { - ( - item.location.two_letter_iso_country_code.clone(), - item.identity_key.clone(), - ) - }) - .collect::>(); - - let bonded_count = gateways.iter().filter(|gw| gw.bonded).count(); - tracing::info!( - "DVpn gateway filtering: {} total gateways, {} bonded, {} nym_nodes, {} final DVpn gateways", - started_with, - bonded_count, - skimmed_nodes.len(), - res_gws.len() - ); - - if res_gws.is_empty() && started_with > 0 { - tracing::error!( - "CRITICAL: Started with {} gateways but got 0 DVpn gateways! Min version: {}", - started_with, - min_node_version - ); - } else { - tracing::info!( - "Successfully loaded {} DVpn gateways into cache", - res_gws.len() - ); - self.upsert_dvpn_gateway_list(res_gws.clone()).await; + let built = self.build_dvpn_gateway_list(storage).await; + if !built.is_empty() { + self.upsert_dvpn_gateway_list(built.clone()).await; } + built + } + }; - res_gws + // version filter is applied at read time, not at cache-fill time, so + // requests with different `min_node_version` values do not poison each + // other's results. + gateways + .into_iter() + .filter(|gw| { + let gw_version = &gw.build_information.build_version; + match Version::parse(gw_version) { + Ok(parsed) => &parsed >= min_node_version, + Err(_) => { + warn!("Failed to parse GW version {gw_version}"); + false + } + } + }) + .collect() + } + + /// Rebuild the dVPN gateway list from DB. Does **not** apply any version + /// filter β€” that's done at read time. + async fn build_dvpn_gateway_list(&self, storage: &db::Storage) -> Vec { + let gateways = self.get_gateway_list(storage).await; + tracing::info!("Found {} gateways in database", gateways.len()); + + let started_with = gateways.len(); + + let records = match storage.get_described_bonded_nym_nodes().await { + Ok(records) => records, + Err(err) => { + error!("CRITICAL: Failed to query nym_nodes from database: {err}"); + panic!("Cannot read nym_nodes table - database connection issue? Error: {err}"); + } + }; + + let mut skimmed_nodes = HashMap::new(); + for dto in records { + match SkimmedNodeV1::try_from(dto) { + Ok(skimmed_node) => { + let key = skimmed_node.ed25519_identity_pubkey.to_base58_string(); + skimmed_nodes.insert(key, skimmed_node); + } + Err(err) => { + error!("CRITICAL: Failed to convert NymNodeDto to SkimmedNode: {err}"); + panic!( + "Cannot convert database record to SkimmedNode - this should never happen! Error: {err}" + ); + } } } + + let family_lookup = match load_family_lookup(storage).await { + Ok(lookup) => lookup, + Err(err) => { + error!("CRITICAL: Failed to load node-families lookup from database: {err}"); + panic!( + "Cannot read node_families tables - database connection issue? Error: {err}" + ); + } + }; + + let bond_info = match storage.get_described_node_bond_info().await { + Ok(map) => map, + Err(err) => { + error!("CRITICAL: Failed to load node bond info from database: {err}"); + panic!( + "Cannot read nym_nodes.bond_info column - database connection issue? Error: {err}" + ); + } + }; + + let socks5_scores = calculate_socks5_percentiles(&gateways); + + let res_gws = gateways + .iter() + .filter(|gw| gw.bonded) + .filter_map(|gw| match skimmed_nodes.get(&gw.gateway_identity_key) { + Some(skimmed_node) => Some((gw, skimmed_node)), + None => { + error!( + "CRITICAL: Gateway {} exists in gateways table but not in nym_nodes table! This should not happen.", + gw.gateway_identity_key + ); + None + } + }) + .filter_map(|(gw, skimmed_node)| { + let family = family_lookup.family_for_node(skimmed_node.node_id).cloned(); + let staking = bond_info + .get(&skimmed_node.node_id) + .map(|details| NodeStakeInformation::from(&details.rewarding_details)); + match DVpnGateway::new( + gw.clone(), + skimmed_node, + socks5_scores.get(&gw.gateway_identity_key), + family, + staking, + ) { + Ok(gw) => Some(gw), + Err(err) => { + error!( + "CRITICAL: Failed to create DVpnGateway for node_id={}, identity_key={}: {}", + skimmed_node.node_id, + skimmed_node.ed25519_identity_pubkey.to_base58_string(), + err + ); + // Don't panic here as this might be due to missing fields, but log it loudly + None + } + } + }) + .filter(|gw| { + // gateways must have a country + if gw.location.two_letter_iso_country_code.len() == 2 { + true + } else { + warn!( + "Invalid country code: {}", + gw.location.two_letter_iso_country_code + ); + false + } + }) + // sort by country, then by identity key + .sorted_by_key(|item| { + ( + item.location.two_letter_iso_country_code.clone(), + item.identity_key.clone(), + ) + }) + .collect::>(); + + let bonded_count = gateways.iter().filter(|gw| gw.bonded).count(); + tracing::info!( + "DVpn gateway filtering: {} total gateways, {} bonded, {} nym_nodes, {} final DVpn gateways", + started_with, + bonded_count, + skimmed_nodes.len(), + res_gws.len() + ); + + if res_gws.is_empty() && started_with > 0 { + tracing::error!( + "CRITICAL: Started with {} gateways but got 0 DVpn gateways!", + started_with + ); + } else { + tracing::info!( + "Successfully loaded {} DVpn gateways into cache", + res_gws.len() + ); + } + + res_gws } pub async fn get_entry_dvpn_gateways( &self, - db: &DbPool, + storage: &db::Storage, min_node_version: &Version, ) -> Vec { - self.get_dvpn_gateway_list(db, min_node_version) + self.get_dvpn_gateway_list(storage, min_node_version) .await .into_iter() .filter(DVpnGateway::can_route_entry) @@ -432,17 +475,21 @@ impl HttpCache { pub async fn get_exit_dvpn_gateways( &self, - db: &DbPool, + storage: &db::Storage, min_node_version: &Version, ) -> Vec { - self.get_dvpn_gateway_list(db, min_node_version) + self.get_dvpn_gateway_list(storage, min_node_version) .await .into_iter() .filter(DVpnGateway::can_route_exit) .collect() } - pub async fn get_gateway_ips(&self, db: &DbPool, min_node_version: &Version) -> Vec { + pub async fn get_gateway_ips( + &self, + storage: &db::Storage, + min_node_version: &Version, + ) -> Vec { match self.gateway_ips.get(DVPN_GATEWAY_IPS).await { Some(guard) => { let read_lock = guard.read().await; @@ -452,7 +499,7 @@ impl HttpCache { trace!("No exit gateway IPs in cache, refreshing..."); let ips: Vec = self - .get_dvpn_gateway_list(db, min_node_version) + .get_dvpn_gateway_list(storage, min_node_version) .await .into_iter() .flat_map(|gw| gw.ip_addresses) @@ -508,7 +555,7 @@ impl HttpCache { pub async fn get_nym_nodes_list( &self, - db: &DbPool, + storage: &db::Storage, node_geocache: NodeGeoCache, ) -> anyhow::Result> { match self.nym_nodes.get(NYM_NODES_LIST_KEY).await { @@ -520,7 +567,7 @@ impl HttpCache { None => { tracing::trace!("No nym nodes in cache, refreshing cache from DB..."); - let nym_nodes = aggregate_node_info_from_db(db, node_geocache).await?; + let nym_nodes = aggregate_node_info_from_db(storage, node_geocache).await?; if nym_nodes.is_empty() { tracing::warn!("Database contains 0 nym nodes"); @@ -650,13 +697,13 @@ impl HttpCache { #[instrument(level = "info", skip_all)] async fn aggregate_node_info_from_db( - pool: &DbPool, + storage: &db::Storage, node_geocache: NodeGeoCache, ) -> anyhow::Result> { - let node_bond_info = queries::get_described_node_bond_info(pool).await?; + let node_bond_info = storage.get_described_node_bond_info().await?; tracing::debug!("Described nodes with bond info: {}", node_bond_info.len()); - let skimmed_nodes = queries::get_all_nym_nodes(pool).await.map(|records| { + let skimmed_nodes = storage.get_all_nym_nodes().await.map(|records| { records .into_iter() .filter_map(|dto| SkimmedNodeV1::try_from(dto).ok()) @@ -665,10 +712,12 @@ async fn aggregate_node_info_from_db( })?; tracing::debug!("Skimmed nodes: {}", skimmed_nodes.len()); - let described_nodes = queries::get_node_self_description(pool).await?; + let described_nodes = storage.get_node_self_description().await?; tracing::debug!("Described nodes: {}", described_nodes.len()); - let node_descriptions = queries::get_bonded_node_description(pool).await?; + let node_descriptions = storage.get_bonded_node_description().await?; + + let families = load_family_lookup(storage).await?; let mut parsed_nym_nodes = Vec::new(); for (node_id, described_node) in described_nodes { @@ -722,6 +771,8 @@ async fn aggregate_node_info_from_db( }) }; + let family_data = families.family_for_node(node_id).cloned(); + parsed_nym_nodes.push(ExtendedNymNode { node_id, identity_key, @@ -737,6 +788,7 @@ async fn aggregate_node_info_from_db( rewarding_details: rewarding_details.to_owned(), description: node_description, geoip, + family_data, }); } @@ -762,3 +814,54 @@ impl BinaryInfo { pub(crate) struct HealthInfo { pub(crate) uptime: i64, } + +struct LoadedNodeFamilies { + member_lookup: HashMap, + family_lookup: HashMap, +} + +impl LoadedNodeFamilies { + /// Resolve the family `node_id` belongs to (if any) in one step. + fn family_for_node(&self, node_id: NodeId) -> Option<&NodeFamilyInformation> { + self.member_lookup + .get(&node_id) + .and_then(|fid| self.family_lookup.get(fid)) + } +} + +/// Load the families snapshot from DB and build two lookup maps: +/// `node_id β†’ family_id` for memberβ†’family resolution, and +/// `family_id β†’ NodeFamilyInformation` for hydrating gateway responses. +async fn load_family_lookup(storage: &db::Storage) -> anyhow::Result { + let families = storage.get_all_node_families().await?; + let members = storage.get_all_node_family_members().await?; + + let mut family_by_node = HashMap::new(); + for m in &members { + let node_id = m.node_id as NodeId; + let family_id = m.family_id as NodeFamilyId; + family_by_node.insert(node_id, family_id); + } + + let mut family_by_id: HashMap = HashMap::new(); + for f in families { + let family_id = f.family_id as u32; + let family_stake = f.family_stake_unym.unwrap_or_default() as u128; + let members_count = f.members_count as usize; + family_by_id.insert( + family_id, + NodeFamilyInformation::new( + family_id, + f.name, + f.description, + family_stake, + members_count, + ), + ); + } + + Ok(LoadedNodeFamilies { + member_lookup: family_by_node, + family_lookup: family_by_id, + }) +} diff --git a/nym-node-status-api/nym-node-status-api/src/main.rs b/nym-node-status-api/nym-node-status-api/src/main.rs index 352f310c01..0d504638a1 100644 --- a/nym-node-status-api/nym-node-status-api/src/main.rs +++ b/nym-node-status-api/nym-node-status-api/src/main.rs @@ -17,7 +17,7 @@ mod db; mod http; mod logging; mod metrics_scraper; -mod monitor; +pub(crate) mod monitor; mod node_scraper; mod testruns; mod ticketbook_manager; @@ -182,7 +182,7 @@ async fn main() -> anyhow::Result<()> { let shutdown_tracker = shutdown_manager.shutdown_tracker(); http::server::start_http_api( - storage.pool_owned(), + storage, args.http_port, args.nym_http_cache_ttl, agent_key_list.to_owned(), diff --git a/nym-node-status-api/nym-node-status-api/src/monitor/mod.rs b/nym-node-status-api/nym-node-status-api/src/monitor/mod.rs index ea95ae6cbb..7be4ea6368 100644 --- a/nym-node-status-api/nym-node-status-api/src/monitor/mod.rs +++ b/nym-node-status-api/nym-node-status-api/src/monitor/mod.rs @@ -3,9 +3,10 @@ use crate::db::models::{ ASSIGNED_ENTRY_COUNT, ASSIGNED_EXIT_COUNT, ASSIGNED_MIXING_COUNT, GATEWAYS_BONDED_COUNT, GATEWAYS_HISTORICAL_COUNT, GatewayInsertRecord, MIXNODES_HISTORICAL_COUNT, NYMNODE_COUNT, - NYMNODES_DESCRIBED_COUNT, NetworkSummary, NymNodeInsertRecord, gateway, mixnode, + NYMNODES_DESCRIBED_COUNT, NetworkSummary, NodeFamilyInsertRecord, NodeFamilyMemberInsertRecord, + NymNodeInsertRecord, gateway, mixnode, }; -use crate::db::{DbPool, queries}; +use crate::db::{DbPool, Storage}; use crate::utils::now_utc; use crate::utils::{LogError, NumericalCheckedCast}; use moka::future::Cache; @@ -23,14 +24,14 @@ use tracing::instrument; pub(crate) use geodata::{ExplorerPrettyBond, IpInfoClient, Location}; pub(crate) use node_delegations::DelegationsCache; -mod geodata; +pub(crate) mod geodata; mod node_delegations; const MONITOR_FAILURE_RETRY_DELAY: Duration = Duration::from_secs(60); pub(crate) type NodeGeoCache = Cache; struct Monitor { - db_pool: DbPool, + storage: Storage, network_details: NymNetworkDetails, nym_api_client_timeout: Duration, nyxd_client: QueryHttpRpcNyxdClient, @@ -54,7 +55,7 @@ pub(crate) async fn run_in_background( let ipinfo = IpInfoClient::new(ipinfo_api_token.clone()); let mut monitor = Monitor { - db_pool, + storage: Storage::from_pool(db_pool), network_details: nym_network_defaults::NymNetworkDetails::new_from_env(), nym_api_client_timeout, nyxd_client, @@ -94,7 +95,7 @@ pub(crate) async fn run_once( let ipinfo = IpInfoClient::new(ipinfo_api_token.clone()); let mut monitor = Monitor { - db_pool, + storage: Storage::from_pool(db_pool), network_details: nym_network_defaults::NymNetworkDetails::new_from_env(), nym_api_client_timeout, nyxd_client, @@ -170,12 +171,26 @@ impl Monitor { let nym_node_records = self.prepare_nym_node_data(nym_nodes.clone(), &bonded_nym_nodes, &described_nodes); - queries::update_nym_nodes(&self.db_pool, nym_node_records) + self.storage + .update_nym_nodes(nym_node_records) .await .map(|inserted| { tracing::debug!("{} nym nodes written to DB!", inserted); })?; + let node_families = nym_api + .get_all_node_families() + .await + .log_error("get_all_node_families")?; + tracing::info!("🟣 node families: {}", node_families.len()); + let family_records = prepare_node_family_data(node_families); + self.storage + .update_node_families(family_records) + .await + .map(|inserted| { + tracing::debug!("{inserted} node families written to DB!"); + })?; + // stop here if running once if exit_early { return Ok(()); @@ -209,9 +224,9 @@ impl Monitor { .prepare_gateway_data(&gateways, &nym_nodes, &bonded_nym_nodes) .await?; - let pool = self.db_pool.clone(); let gateways_count = gateway_records.len(); - queries::update_bonded_gateways(&pool, gateway_records) + self.storage + .update_bonded_gateways(gateway_records) .await .map(|_| { tracing::debug!("{} gateway records written to DB!", gateways_count); @@ -219,7 +234,7 @@ impl Monitor { self.refresh_node_delegations(&bonded_nym_nodes).await; - let (all_historical_gateways, all_historical_mixnodes) = historical_count(&pool).await?; + let (all_historical_gateways, all_historical_mixnodes) = self.historical_count().await?; // // write summary keys and values to table @@ -267,7 +282,9 @@ impl Monitor { }, }; - queries::insert_summaries(&pool, &nodes_summary, &network_summary, last_updated).await?; + self.storage + .insert_summaries(&nodes_summary, &network_summary, last_updated) + .await?; let mut log_lines: Vec = vec![]; for (key, value) in nodes_summary.iter() { @@ -402,22 +419,65 @@ impl Monitor { // update after refreshing all to avoid holding write lock for too long *self.node_delegations.write().await = delegations_per_node; } + + async fn historical_count(&self) -> anyhow::Result<(usize, usize)> { + let mut conn = self.storage.pool().acquire().await?; + + let all_historical_gateways = sqlx::query_scalar!(r#"SELECT count(id) FROM gateways"#) + .fetch_one(&mut *conn) + .await? + .unwrap_or(0) + .cast_checked()?; + + let all_historical_mixnodes = sqlx::query_scalar!(r#"SELECT count(id) FROM mixnodes"#) + .fetch_one(&mut *conn) + .await? + .unwrap_or(0) + .cast_checked()?; + + Ok((all_historical_gateways, all_historical_mixnodes)) + } } -async fn historical_count(pool: &DbPool) -> anyhow::Result<(usize, usize)> { - let mut conn = pool.acquire().await?; +/// Project a nym-api families snapshot into the shape stored in the +/// `node_families` / `node_family_members` tables. Members with stake info +/// missing on the nym-api side are still recorded β€” only the per-family +/// stake total drops to `NULL`. +fn prepare_node_family_data( + families: Vec, +) -> Vec { + let last_updated_utc = now_utc().unix_timestamp(); - let all_historical_gateways = sqlx::query_scalar!(r#"SELECT count(id) FROM gateways"#) - .fetch_one(&mut *conn) - .await? - .unwrap_or(0) - .cast_checked()?; + families + .into_iter() + .map(|family| { + let family_stake_unym = family + .total_stake + .as_ref() + .and_then(|coin| i64::try_from(coin.amount.u128()).ok()); - let all_historical_mixnodes = sqlx::query_scalar!(r#"SELECT count(id) FROM mixnodes"#) - .fetch_one(&mut *conn) - .await? - .unwrap_or(0) - .cast_checked()?; + let members_count = family.members.len() as i32; - Ok((all_historical_gateways, all_historical_mixnodes)) + let members: Vec<_> = family + .members + .into_iter() + .map(|m| NodeFamilyMemberInsertRecord { + node_id: m.node_id as i64, + joined_at: m.joined_at.unix_timestamp(), + }) + .collect(); + + NodeFamilyInsertRecord { + family_id: family.id as i64, + name: family.name, + description: family.description, + owner: family.owner, + family_stake_unym, + members_count, + created_at: family.created_at.unix_timestamp(), + last_updated_utc, + members, + } + }) + .collect() } diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index 1f2c0eddb3..fa332f465b 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -4615,7 +4615,7 @@ dependencies = [ [[package]] name = "nym-api-requests" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bs58", "celes", @@ -4655,7 +4655,7 @@ dependencies = [ [[package]] name = "nym-bin-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "const-str", "log", @@ -4686,7 +4686,7 @@ dependencies = [ [[package]] name = "nym-coconut-dkg-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -4699,7 +4699,7 @@ dependencies = [ [[package]] name = "nym-compact-ecash" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bincode", "bs58", @@ -4721,7 +4721,7 @@ dependencies = [ [[package]] name = "nym-config" -version = "1.20.4" +version = "1.21.0" dependencies = [ "dirs 6.0.0", "handlebars", @@ -4735,7 +4735,7 @@ dependencies = [ [[package]] name = "nym-contracts-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bs58", "cosmwasm-schema", @@ -4749,7 +4749,7 @@ dependencies = [ [[package]] name = "nym-credentials-interface" -version = "1.20.4" +version = "1.21.0" dependencies = [ "nym-bls12_381-fork", "nym-compact-ecash", @@ -4767,7 +4767,7 @@ dependencies = [ [[package]] name = "nym-crypto" -version = "1.20.4" +version = "1.21.0" dependencies = [ "base64 0.22.1", "bs58", @@ -4789,7 +4789,7 @@ dependencies = [ [[package]] name = "nym-ecash-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bs58", "cosmwasm-schema", @@ -4802,7 +4802,7 @@ dependencies = [ [[package]] name = "nym-ecash-signer-check-types" -version = "1.20.4" +version = "1.21.0" dependencies = [ "nym-coconut-dkg-common", "nym-crypto", @@ -4817,14 +4817,14 @@ dependencies = [ [[package]] name = "nym-ecash-time" -version = "1.20.4" +version = "1.21.0" dependencies = [ "time", ] [[package]] name = "nym-exit-policy" -version = "1.20.4" +version = "1.21.0" dependencies = [ "serde", "serde_json", @@ -4835,7 +4835,7 @@ dependencies = [ [[package]] name = "nym-group-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cosmwasm-schema", "cw-controllers", @@ -4846,7 +4846,7 @@ dependencies = [ [[package]] name = "nym-http-api-client" -version = "1.20.4" +version = "1.21.0" dependencies = [ "async-trait", "bincode", @@ -4878,7 +4878,7 @@ dependencies = [ [[package]] name = "nym-http-api-client-macro" -version = "1.20.4" +version = "1.21.0" dependencies = [ "proc-macro-crate 3.3.0", "proc-macro2", @@ -4889,7 +4889,7 @@ dependencies = [ [[package]] name = "nym-http-api-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bincode", "serde", @@ -4899,7 +4899,7 @@ dependencies = [ [[package]] name = "nym-kkt-ciphersuite" -version = "1.20.4" +version = "1.21.0" dependencies = [ "num_enum", "semver", @@ -4910,7 +4910,7 @@ dependencies = [ [[package]] name = "nym-mixnet-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bs58", "cosmwasm-schema", @@ -4931,7 +4931,7 @@ dependencies = [ [[package]] name = "nym-multisig-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -4946,7 +4946,7 @@ dependencies = [ [[package]] name = "nym-network-defaults" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cargo_metadata 0.19.2", "dotenvy", @@ -4960,9 +4960,24 @@ dependencies = [ "utoipa", ] +[[package]] +name = "nym-node-families-contract-common" +version = "1.21.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-utils", + "nym-contracts-common", + "nym-mixnet-contract-common", + "schemars", + "serde", + "thiserror 2.0.12", +] + [[package]] name = "nym-node-requests" -version = "1.20.4" +version = "1.21.0" dependencies = [ "async-trait", "celes", @@ -4988,7 +5003,7 @@ dependencies = [ [[package]] name = "nym-noise-keys" -version = "1.20.4" +version = "1.21.0" dependencies = [ "nym-crypto", "schemars", @@ -4998,7 +5013,7 @@ dependencies = [ [[package]] name = "nym-pemstore" -version = "1.20.4" +version = "1.21.0" dependencies = [ "pem", "tracing", @@ -5007,7 +5022,7 @@ dependencies = [ [[package]] name = "nym-performance-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -5020,7 +5035,7 @@ dependencies = [ [[package]] name = "nym-serde-helpers" -version = "1.20.4" +version = "1.21.0" dependencies = [ "base64 0.22.1", "bs58", @@ -5031,7 +5046,7 @@ dependencies = [ [[package]] name = "nym-store-cipher" -version = "1.20.4" +version = "1.21.0" dependencies = [ "aes-gcm", "argon2", @@ -5046,7 +5061,7 @@ dependencies = [ [[package]] name = "nym-ticketbooks-merkle" -version = "1.20.4" +version = "1.21.0" dependencies = [ "nym-credentials-interface", "nym-serde-helpers", @@ -5060,7 +5075,7 @@ dependencies = [ [[package]] name = "nym-types" -version = "1.20.4" +version = "1.21.0" dependencies = [ "base64 0.22.1", "cosmrs", @@ -5090,7 +5105,7 @@ dependencies = [ [[package]] name = "nym-upgrade-mode-check" -version = "1.20.4" +version = "1.21.0" dependencies = [ "jwt-simple", "nym-crypto", @@ -5106,7 +5121,7 @@ dependencies = [ [[package]] name = "nym-validator-client" -version = "1.20.4" +version = "1.21.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -5135,6 +5150,7 @@ dependencies = [ "nym-mixnet-contract-common", "nym-multisig-contract-common", "nym-network-defaults", + "nym-node-families-contract-common", "nym-performance-contract-common", "nym-serde-helpers", "nym-vesting-contract-common", @@ -5155,7 +5171,7 @@ dependencies = [ [[package]] name = "nym-vesting-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -5201,7 +5217,7 @@ dependencies = [ [[package]] name = "nym-wireguard-types" -version = "1.20.4" +version = "1.21.0" dependencies = [ "base64 0.22.1", "nym-crypto", diff --git a/nym-wallet/nym-wallet-types/src/network/sandbox.rs b/nym-wallet/nym-wallet-types/src/network/sandbox.rs index f8e66ebe3e..6b82c30932 100644 --- a/nym-wallet/nym-wallet-types/src/network/sandbox.rs +++ b/nym-wallet/nym-wallet-types/src/network/sandbox.rs @@ -28,6 +28,10 @@ pub(crate) const COCONUT_DKG_CONTRACT_ADDRESS: &str = pub(crate) const PERFORMANCE_CONTRACT_ADDRESS: &str = ""; // /\ TODO: this has to be updated once the contract is deployed +// \/ TODO: this has to be updated once the contract is deployed +pub(crate) const NODE_FAMILIES_CONTRACT_ADDRESS: &str = ""; +// /\ TODO: this has to be updated once the contract is deployed + // -- Constructor functions -- pub(crate) fn validators() -> Vec { @@ -51,6 +55,7 @@ pub(crate) fn network_details() -> nym_network_defaults::NymNetworkDetails { mixnet_contract_address: parse_optional_str(MIXNET_CONTRACT_ADDRESS), vesting_contract_address: parse_optional_str(VESTING_CONTRACT_ADDRESS), performance_contract_address: parse_optional_str(PERFORMANCE_CONTRACT_ADDRESS), + node_families_contract_address: parse_optional_str(NODE_FAMILIES_CONTRACT_ADDRESS), ecash_contract_address: parse_optional_str(ECASH_CONTRACT_ADDRESS), group_contract_address: parse_optional_str(GROUP_CONTRACT_ADDRESS), multisig_contract_address: parse_optional_str(MULTISIG_CONTRACT_ADDRESS), diff --git a/tools/internal/localnet-orchestrator/src/orchestrator/setup/cosmwasm_contracts.rs b/tools/internal/localnet-orchestrator/src/orchestrator/setup/cosmwasm_contracts.rs index 5962a36b7c..d43fa3b53c 100644 --- a/tools/internal/localnet-orchestrator/src/orchestrator/setup/cosmwasm_contracts.rs +++ b/tools/internal/localnet-orchestrator/src/orchestrator/setup/cosmwasm_contracts.rs @@ -28,7 +28,7 @@ use std::path::PathBuf; use std::time::Duration; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; -use tracing::{debug, info}; +use tracing::{debug, error, info}; pub(crate) struct Config { pub(crate) reproducible_builds: bool, @@ -92,6 +92,10 @@ impl LocalnetOrchestrator { Ok(nym_mixnet_contract_common::MigrateMsg { vesting_contract_address: Some(ctx.data.contracts.vesting.address()?.to_string()), unsafe_skip_state_updates: Some(true), + // currently the orchestrator is NOT instantiating the node families contract, + // however, because we have set `unsafe_skip_state_updates to true, + // the address will not actually be used, so we can put any placeholder value here + node_families_contract_address: ctx.data.contracts.vesting.address()?.to_string(), }) } @@ -109,6 +113,7 @@ impl LocalnetOrchestrator { &self, ctx: &LocalnetContext, ) -> anyhow::Result { + error!("unimplemented node families contract instantiation"); Ok(nym_mixnet_contract_common::InstantiateMsg { rewarding_validator_address: ctx .data @@ -124,6 +129,15 @@ impl LocalnetOrchestrator { .address() .to_string(), // PLACEHOLDER /\ + + // PLACEHOLDER \/ + node_families_contract_address: ctx + .data + .auxiliary_accounts + .mixnet_rewarder + .address() + .to_string(), + // PLACEHOLDER /\ rewarding_denom: "unym".to_string(), epochs_in_interval: 720, epoch_duration: Duration::from_secs(60 * 60),