diff --git a/Cargo.lock b/Cargo.lock index 970150dff0b..9955037c694 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5487,6 +5487,20 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65" +[[package]] +name = "node-families-contract-common" +version = "1.20.4" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "nym-contracts-common", + "nym-mixnet-contract-common", + "schemars 0.8.22", + "serde", + "thiserror 2.0.12", +] + [[package]] name = "nom" version = "7.1.3" diff --git a/Cargo.toml b/Cargo.toml index 1d1db9f1dac..c9f4f9bc32f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -175,7 +175,7 @@ members = [ "nym-gateway-probe", "integration-tests", "common/nym-kkt-ciphersuite", - "common/nym-kkt-context", + "common/nym-kkt-context", "common/cosmwasm-smart-contracts/node-families-contract", ] default-members = [ 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 00000000000..f941072c50f --- /dev/null +++ b/common/cosmwasm-smart-contracts/node-families-contract/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "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 } + +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 00000000000..23486b230f4 --- /dev/null +++ b/common/cosmwasm-smart-contracts/node-families-contract/src/constants.rs @@ -0,0 +1,55 @@ +// 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"; + /// `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"; + /// `Map`: lookup of which family a node currently belongs to. + pub const NODE_FAMILY_MEMBERS: &str = "node-family-members"; + + /// 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"; +} 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 00000000000..ef363304d51 --- /dev/null +++ b/common/cosmwasm-smart-contracts/node-families-contract/src/error.rs @@ -0,0 +1,67 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::NodeFamilyId; +use cw_controllers::AdminError; +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 }, + + /// 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, + }, + + /// 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 00000000000..265794d3e81 --- /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 00000000000..13e743f5456 --- /dev/null +++ b/common/cosmwasm-smart-contracts/node-families-contract/src/msg.rs @@ -0,0 +1,29 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use cosmwasm_schema::cw_serde; + +/// Message used to instantiate the node families contract. +#[cw_serde] +pub struct InstantiateMsg { + // +} + +/// Execute messages accepted by the contract. +#[cw_serde] +pub enum ExecuteMsg { + // +} + +/// Query messages accepted by the contract. +#[cw_serde] +#[cfg_attr(feature = "schema", derive(cosmwasm_schema::QueryResponses))] +pub enum QueryMsg { + // +} + +/// 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 00000000000..5a7c5075d63 --- /dev/null +++ b/common/cosmwasm-smart-contracts/node-families-contract/src/types.rs @@ -0,0 +1,101 @@ +// 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. +pub struct Config { + /// Fee charged on each successful `create_family` execution. + pub create_family_fee: Coin, +} + +/// 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, + + /// The optional description of the node family + pub description: String, + + /// The owner of the node family + pub owner: Addr, + + /// 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, +} + +/// 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, +} diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 1fb526afa4d..a38f1675428 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -706,7 +706,7 @@ dependencies = [ "cw4", "cw4-group", "easy-addr", - "nym-contracts-common", + "nym-contracts-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", "nym-contracts-common-testing", "nym-group-contract-common", "nym-multisig-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", @@ -737,7 +737,7 @@ dependencies = [ "cw2", "cw4", "easy-addr", - "nym-contracts-common", + "nym-contracts-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", "nym-contracts-common-testing", "nym-group-contract-common", "schemars", @@ -1171,15 +1171,46 @@ version = "0.1.0" dependencies = [ "cosmwasm-std", "cw-multi-test", - "nym-contracts-common", + "nym-contracts-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", "nym-crypto", "nym-mixnet-contract", - "nym-mixnet-contract-common", + "nym-mixnet-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", "nym-vesting-contract", "nym-vesting-contract-common", "rand_chacha", ] +[[package]] +name = "node-families" +version = "0.1.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-storage-plus", + "cw2", + "node-families-contract-common", + "nym-contracts-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", + "nym-contracts-common-testing", + "nym-mixnet-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", + "serde", +] + +[[package]] +name = "node-families-contract-common" +version = "1.20.4" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "nym-contracts-common 1.20.4", + "nym-mixnet-contract-common 1.20.4", + "schemars", + "serde", + "thiserror 2.0.12", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1240,7 +1271,7 @@ dependencies = [ "cw4-group", "easy-addr", "nym-coconut-dkg-common", - "nym-contracts-common", + "nym-contracts-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", "nym-contracts-common-testing", "nym-group-contract-common", "thiserror 2.0.12", @@ -1257,10 +1288,24 @@ dependencies = [ "cw-utils", "cw2", "cw4", - "nym-contracts-common", + "nym-contracts-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", "nym-multisig-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "nym-contracts-common" +version = "1.20.4" +dependencies = [ + "bs58", + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus", + "schemars", + "serde", + "thiserror 2.0.12", + "vergen", +] + [[package]] name = "nym-contracts-common" version = "1.20.4" @@ -1287,7 +1332,7 @@ dependencies = [ "cosmwasm-std", "cw-multi-test", "cw-storage-plus", - "nym-contracts-common", + "nym-contracts-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", "rand", "rand_chacha", "serde", @@ -1328,7 +1373,7 @@ dependencies = [ "cw2", "cw3", "cw4", - "nym-contracts-common", + "nym-contracts-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", "nym-contracts-common-testing", "nym-crypto", "nym-ecash-contract-common", @@ -1380,11 +1425,11 @@ dependencies = [ "cw-storage-plus", "cw2", "easy-addr", - "nym-contracts-common", + "nym-contracts-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", "nym-contracts-common-testing", "nym-crypto", "nym-mixnet-contract", - "nym-mixnet-contract-common", + "nym-mixnet-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", "nym-vesting-contract-common", "rand", "rand_chacha", @@ -1392,6 +1437,25 @@ dependencies = [ "serde", ] +[[package]] +name = "nym-mixnet-contract-common" +version = "1.20.4" +dependencies = [ + "bs58", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-storage-plus", + "humantime-serde", + "nym-contracts-common 1.20.4", + "schemars", + "semver", + "serde", + "serde_repr", + "thiserror 2.0.12", + "time", +] + [[package]] name = "nym-mixnet-contract-common" version = "1.20.4" @@ -1405,7 +1469,7 @@ dependencies = [ "cw-storage-plus", "cw2", "humantime-serde", - "nym-contracts-common", + "nym-contracts-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", "schemars", "semver", "serde", @@ -1477,11 +1541,11 @@ dependencies = [ "cw-controllers", "cw-storage-plus", "cw2", - "nym-contracts-common", + "nym-contracts-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", "nym-contracts-common-testing", "nym-crypto", "nym-mixnet-contract", - "nym-mixnet-contract-common", + "nym-mixnet-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", "nym-performance-contract-common", "serde", ] @@ -1495,7 +1559,7 @@ dependencies = [ "cosmwasm-schema", "cosmwasm-std", "cw-controllers", - "nym-contracts-common", + "nym-contracts-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", "schemars", "serde", "thiserror 2.0.12", @@ -1511,7 +1575,7 @@ dependencies = [ "cw-controllers", "cw-storage-plus", "cw2", - "nym-contracts-common", + "nym-contracts-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", "nym-contracts-common-testing", "nym-pool-contract-common", ] @@ -1552,8 +1616,8 @@ dependencies = [ "cw-storage-plus", "cw2", "hex", - "nym-contracts-common", - "nym-mixnet-contract-common", + "nym-contracts-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", + "nym-mixnet-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", "nym-vesting-contract-common", "rand_chacha", "serde", @@ -1570,8 +1634,8 @@ dependencies = [ "cosmwasm-schema", "cosmwasm-std", "cw2", - "nym-contracts-common", - "nym-mixnet-contract-common", + "nym-contracts-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", + "nym-mixnet-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", "serde", "thiserror 2.0.12", ] diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 0b230997715..4c5250401e2 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -9,7 +9,7 @@ members = [ "multisig/cw3-flex-multisig", "multisig/cw4-group", "vesting", - "performance", + "performance", "node-families", ] [workspace.package] @@ -19,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 @@ -76,6 +78,7 @@ nym-vesting-contract-common = "1.20.4" 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" } +node-families-contract-common = { version = "1.20.4", package = "node-families-contract-common" } # Internal contract workspace members (for cross-contract testing) cw3-flex-multisig = { version = "2.0.0", path = "multisig/cw3-flex-multisig" } @@ -99,3 +102,4 @@ unreachable = "deny" [patch.crates-io] nym-ecash-contract-common = { path = "../common/cosmwasm-smart-contracts/ecash-contract" } +node-families-contract-common = { path = "../common/cosmwasm-smart-contracts/node-families-contract" } diff --git a/contracts/node-families/.cargo/config b/contracts/node-families/.cargo/config new file mode 100644 index 00000000000..2fb2c1afdbc --- /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 00000000000..af76f8a3bee --- /dev/null +++ b/contracts/node-families/Cargo.toml @@ -0,0 +1,44 @@ +[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 } + +nym-contracts-common = { workspace = true } +node-families-contract-common = { workspace = true } +nym-mixnet-contract-common = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +nym-contracts-common-testing = { workspace = true } + +[features] +schema-gen = ["node-families-contract-common/schema", "cosmwasm-schema"] + + +[lints] +workspace = true diff --git a/contracts/node-families/Makefile b/contracts/node-families/Makefile new file mode 100644 index 00000000000..086fa71ad3c --- /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 00000000000..a11d420899e --- /dev/null +++ b/contracts/node-families/schema/node-families.json @@ -0,0 +1,31 @@ +{ + "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", + "type": "object", + "additionalProperties": false + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "type": "string", + "enum": [] + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "type": "string", + "enum": [] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "additionalProperties": false + }, + "sudo": null, + "responses": {} +} diff --git a/contracts/node-families/schema/raw/execute.json b/contracts/node-families/schema/raw/execute.json new file mode 100644 index 00000000000..b3d18b4768a --- /dev/null +++ b/contracts/node-families/schema/raw/execute.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "type": "string", + "enum": [] +} diff --git a/contracts/node-families/schema/raw/instantiate.json b/contracts/node-families/schema/raw/instantiate.json new file mode 100644 index 00000000000..1352613d579 --- /dev/null +++ b/contracts/node-families/schema/raw/instantiate.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "additionalProperties": false +} diff --git a/contracts/node-families/schema/raw/migrate.json b/contracts/node-families/schema/raw/migrate.json new file mode 100644 index 00000000000..7fbe8c5708e --- /dev/null +++ b/contracts/node-families/schema/raw/migrate.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "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 00000000000..0f592a1af0d --- /dev/null +++ b/contracts/node-families/schema/raw/query.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "type": "string", + "enum": [] +} diff --git a/contracts/node-families/src/bin/schema.rs b/contracts/node-families/src/bin/schema.rs new file mode 100644 index 00000000000..e95170040ae --- /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 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 00000000000..70ec4be82d0 --- /dev/null +++ b/contracts/node-families/src/contract.rs @@ -0,0 +1,80 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +//! CosmWasm entry points for the node families contract. + +use cosmwasm_std::{entry_point, Binary, Deps, DepsMut, Env, MessageInfo, Response}; +use node_families_contract_common::{ + ExecuteMsg, InstantiateMsg, MigrateMsg, NodeFamiliesContractError, QueryMsg, +}; +use nym_contracts_common::set_build_information; + +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 _ = env; + let _ = info; + let _ = msg; + + // NodeFamiliesStorage::new().initialise(deps, env, info.sender, &msg)?; + + 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 { + let _ = deps; + let _ = env; + let _ = info; + let _ = msg; + Ok(Response::default()) +} + +/// 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 { + let _ = deps; + let _ = env; + let _ = msg; + Ok(Binary::default()) +} + +/// 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()) +} diff --git a/contracts/node-families/src/helpers.rs b/contracts/node-families/src/helpers.rs new file mode 100644 index 00000000000..c4459f1a925 --- /dev/null +++ b/contracts/node-families/src/helpers.rs @@ -0,0 +1,17 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +/// 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. Callers should pass the normalised +/// value to [`node_families_contract_common::NodeFamily::name`] when creating a family and when looking one +/// up by name. +#[allow(dead_code)] +pub fn normalise_family_name(name: &str) -> String { + name.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_lowercase()) + .collect() +} diff --git a/contracts/node-families/src/lib.rs b/contracts/node-families/src/lib.rs new file mode 100644 index 00000000000..3d3cd5dc6e1 --- /dev/null +++ b/contracts/node-families/src/lib.rs @@ -0,0 +1,26 @@ +// 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 — only compiled when running the contract's unit tests. +#[cfg(test)] +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 00000000000..bdc05451cb3 --- /dev/null +++ b/contracts/node-families/src/queries.rs @@ -0,0 +1,2 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only diff --git a/contracts/node-families/src/queued_migrations.rs b/contracts/node-families/src/queued_migrations.rs new file mode 100644 index 00000000000..bdc05451cb3 --- /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 00000000000..2949957ee41 --- /dev/null +++ b/contracts/node-families/src/storage/mod.rs @@ -0,0 +1,1143 @@ +// 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::{ + NodeFamiliesIndex, NodeFamilyInvitationIndex, PastFamilyInvitationsIndex, + PastFamilyMembersIndex, +}; +use cosmwasm_std::{Addr, Env, Order, StdResult, Storage}; +use cw_controllers::Admin; +use cw_storage_plus::{IndexedMap, Item, Map}; +use node_families_contract_common::constants::storage_keys; +use node_families_contract_common::{ + FamilyInvitation, FamilyInvitationStatus, NodeFamiliesContractError, NodeFamily, NodeFamilyId, + PastFamilyInvitation, PastFamilyMember, +}; +use nym_mixnet_contract_common::NodeId; + +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, + + /// 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 `name` + /// (family names are globally unique — compared by raw bytes, so + /// callers normalise upstream if case-insensitive uniqueness is desired). + pub(crate) families: IndexedMap>, + + /// Mapping from a node id to the family it currently belongs to. A node + /// belongs to at most one family at a time, so this is a plain `Map`. + pub(crate) family_members: Map, + + /// 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), + 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: Map::new(storage_keys::NODE_FAMILY_MEMBERS), + 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, + ), + } + } + + /// 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`; + /// - normalising `name` (e.g. trim/lowercase) if case-insensitive + /// uniqueness is desired — the storage layer compares raw bytes; + /// - 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 `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. + pub(crate) fn register_new_family( + &self, + store: &mut dyn Storage, + env: &Env, + owner: Addr, + name: String, + description: String, + ) -> Result { + let id = self.next_family_id(store)?; + let family = NodeFamily { + id, + name, + description, + owner, + 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, &family_id)?; + + 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 })?; + + 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) + } + + /// Disband (delete) `family_id`. + /// + /// Only succeeds when the family has **zero current members** — errors + /// with [`FamilyNotEmpty`] otherwise. The owner is responsible for + /// kicking any remaining members first. + /// + /// 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 is responsible for verifying that the transaction sender + /// is the owner of `family_id`. + /// + /// 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 })?; + + if family.members != 0 { + return Err(NodeFamiliesContractError::FamilyNotEmpty { + family_id, + members: family.members, + }); + } + + // 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) + } +} + +#[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 env = tester.env(); + let owner = tester.addr_make("alice"); + + let family = s + .register_new_family( + tester.storage_mut(), + &env, + owner.clone(), + "fam".into(), + "desc".into(), + ) + .unwrap(); + + assert_eq!(family.id, 1); + assert_eq!(family.owner, owner); + assert_eq!(family.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"); + + s.register_new_family( + tester.storage_mut(), + &env, + alice, + "shared".into(), + "".into(), + ) + .unwrap(); + + // unique-index defence-in-depth check + let res = + s.register_new_family(tester.storage_mut(), &env, bob, "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"); + + tester.make_family(&alice); + + // unique-index defence-in-depth check + let res = s.register_new_family( + tester.storage_mut(), + &env, + alice, + "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()); + assert_eq!(s.family_members.load(tester.storage(), 42).unwrap(), f.id); + 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_when_not_empty() { + 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 res = s.disband_family(tester.storage_mut(), &env, f.id); + assert_eq!( + res.unwrap_err(), + NodeFamiliesContractError::FamilyNotEmpty { + family_id: f.id, + members: 1, + } + ); + } + + #[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 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, alice, "2".into(), "".into()) + .unwrap(); + + // ids monotonically increase, never recycled + assert_eq!(f1.id, 1); + assert_eq!(f2.id, 2); + } +} 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 00000000000..7970bb643f5 --- /dev/null +++ b/contracts/node-families/src/storage/storage_indexes.rs @@ -0,0 +1,145 @@ +// 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 node_families_contract_common::constants::storage_keys; +use node_families_contract_common::{ + FamilyInvitation, NodeFamily, NodeFamilyId, PastFamilyInvitation, PastFamilyMember, +}; +use nym_mixnet_contract_common::NodeId; + +/// 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: family names are globally unique. Compares by raw bytes — + /// callers must normalise (e.g. lowercase/trim) before insert if they + /// want case-insensitive uniqueness. + pub(crate) 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, + ), + name: UniqueIndex::new( + |family| family.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.name]; + 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 00000000000..e311aa41603 --- /dev/null +++ b/contracts/node-families/src/testing.rs @@ -0,0 +1,161 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::contract::{execute, instantiate, migrate, query}; +use crate::storage::NodeFamiliesStorage; +use cosmwasm_std::{Addr, MessageInfo, StdError, StdResult, Storage}; +use node_families_contract_common::{ + ExecuteMsg, FamilyInvitation, InstantiateMsg, MigrateMsg, NodeFamiliesContractError, + NodeFamily, NodeFamilyId, QueryMsg, +}; +use nym_contracts_common_testing::{ + AdminExt, ArbitraryContractStorageReader, ArbitraryContractStorageWriter, ChainOpts, + ContractFn, ContractOpts, ContractTester, DenomExt, PermissionedFn, QueryFn, RandExt, + TestableNymContract, +}; +use nym_mixnet_contract_common::NodeId; +use serde::{de::DeserializeOwned, Serialize}; + +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 base_init_msg() -> Self::InitMsg { + InstantiateMsg {} + } +} + +pub fn init_contract_tester() -> ContractTester { + NodeFamiliesContract::init() +} + +pub trait NodeFamiliesContractTesterExt: + ContractOpts< + ExecuteMsg = ExecuteMsg, + QueryMsg = QueryMsg, + ContractError = NodeFamiliesContractError, + > + ChainOpts + + AdminExt + + DenomExt + + RandExt + + Storage + + ArbitraryContractStorageReader + + ArbitraryContractStorageWriter + + Sized +{ + fn mixnet_contract_address(&self) -> StdResult { + NodeFamiliesStorage::new() + .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 make_family(&mut self, owner: &Addr) -> NodeFamily { + let env = self.env(); + // names must be globally unique; derive from owner addr (also unique) + let name = format!("family-{owner}"); + NodeFamiliesStorage::new() + .register_new_family(self, &env, owner.clone(), name, "dummy".to_string()) + .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 add_to_family(&mut self, family: NodeFamilyId, node: NodeId) { + self.invite_to_family(family, node); + self.accept_invitation(family, node); + } +} + +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 00000000000..bdc05451cb3 --- /dev/null +++ b/contracts/node-families/src/transactions.rs @@ -0,0 +1,2 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only