Skip to content

Commit a99cbf2

Browse files
committed
added restriction on uniquness of family names
1 parent ea0774e commit a99cbf2

6 files changed

Lines changed: 72 additions & 16 deletions

File tree

common/cosmwasm-smart-contracts/node-families-contract/src/constants.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ pub mod storage_keys {
2020
pub const FAMILIES_NAMESPACE: &str = "families";
2121
/// Secondary unique index keyed by `owner` (one family per owner).
2222
pub const FAMILIES_OWNER_IDX_NAMESPACE: &str = "families__owner";
23+
/// Secondary unique index keyed by `name` (family names are globally unique).
24+
pub const FAMILIES_NAME_IDX_NAMESPACE: &str = "families__name";
2325

2426
/// Primary namespace for the pending invitations `IndexedMap`.
2527
pub const INVITATIONS_NAMESPACE: &str = "invitations";
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
2+
// SPDX-License-Identifier: GPL-3.0-only
3+
4+
/// Normalise a family name into the canonical form used as the unique-index key.
5+
///
6+
/// Drops every character that isn't an ASCII letter or digit and lowercases
7+
/// the rest, so `" Foo-Bar! "`, `"foobar"` and `"FOO BAR"` all collide on
8+
/// the storage layer's unique-name index. Callers should pass the normalised
9+
/// value to [`node_families_contract_common::NodeFamily::name`] when creating a family and when looking one
10+
/// up by name.
11+
pub fn normalise_family_name(name: &str) -> String {
12+
name.chars()
13+
.filter(|c| c.is_ascii_alphanumeric())
14+
.map(|c| c.to_ascii_lowercase())
15+
.collect()
16+
}

contracts/node-families/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub mod queued_migrations;
1616
/// `cw-storage-plus` definitions: typed maps, items and secondary indexes.
1717
pub mod storage;
1818

19+
mod helpers;
1920
/// Read-only query handlers backing [`contract::query`].
2021
mod queries;
2122
/// Test-only helpers — only compiled when running the contract's unit tests.

contracts/node-families/src/storage/mod.rs

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,10 @@ pub struct NodeFamiliesStorage<'a> {
4242
/// reserved as a "no family" sentinel.
4343
pub(crate) node_family_id_counter: Item<NodeFamilyId>,
4444

45-
/// All existing families, keyed by id, with a unique secondary index on
46-
/// `owner` enforcing the **one-family-per-owner-address** invariant.
45+
/// All existing families, keyed by id, with unique secondary indexes on
46+
/// `owner` (one-family-per-owner-address) and on `name`
47+
/// (family names are globally unique — compared by raw bytes, so
48+
/// callers normalise upstream if case-insensitive uniqueness is desired).
4749
pub(crate) families: IndexedMap<NodeFamilyId, NodeFamily, NodeFamiliesIndex<'a>>,
4850

4951
/// Mapping from a node id to the family it currently belongs to. A node
@@ -178,14 +180,16 @@ impl<'a> NodeFamiliesStorage<'a> {
178180
///
179181
/// The caller (a transaction handler) is responsible for:
180182
/// - validating `name`, `description` and `owner`;
183+
/// - normalising `name` (e.g. trim/lowercase) if case-insensitive
184+
/// uniqueness is desired — the storage layer compares raw bytes;
181185
/// - ensuring `owner` does not already own a family **and** is not
182186
/// currently a member of one.
183187
///
184188
/// Returns the freshly persisted [`NodeFamily`]. The underlying
185-
/// `IndexedMap` enforces the one-family-per-owner invariant via the
186-
/// unique index on `owner` as a defence-in-depth check, so this call
187-
/// will fail if `owner` already owns a family — but the caller must not
188-
/// rely on it for the membership check.
189+
/// `IndexedMap` enforces the one-family-per-owner and unique-name
190+
/// invariants via unique indexes on `owner` and `name` as a
191+
/// defence-in-depth check, so this call will fail if either is already
192+
/// taken — but the caller must not rely on it for the membership check.
189193
pub(crate) fn register_new_family(
190194
&self,
191195
store: &mut dyn Storage,
@@ -673,6 +677,34 @@ mod tests {
673677
assert_eq!(f2.id, 2);
674678
}
675679

680+
#[test]
681+
fn register_new_family_rejects_duplicate_name() {
682+
let mut tester = init_contract_tester();
683+
let s = NodeFamiliesStorage::new();
684+
let env = tester.env();
685+
let alice = tester.addr_make("alice");
686+
let bob = tester.addr_make("bob");
687+
688+
s.register_new_family(
689+
tester.storage_mut(),
690+
&env,
691+
alice,
692+
"shared".into(),
693+
"".into(),
694+
)
695+
.unwrap();
696+
697+
// unique-index defence-in-depth check
698+
let res = s.register_new_family(
699+
tester.storage_mut(),
700+
&env,
701+
bob,
702+
"shared".into(),
703+
"".into(),
704+
);
705+
assert!(res.is_err());
706+
}
707+
676708
#[test]
677709
fn register_new_family_rejects_duplicate_owner() {
678710
let mut tester = init_contract_tester();

contracts/node-families/src/storage/storage_indexes.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,15 @@ use node_families_contract_common::{
1010
};
1111
use nym_mixnet_contract_common::NodeId;
1212

13-
/// Secondary indexes over [`NodeFamily`]. Enforces one-family-per-owner via
14-
/// a `UniqueIndex` on the owner address.
13+
/// Secondary indexes over [`NodeFamily`]. Enforces one-family-per-owner and
14+
/// globally-unique family names via `UniqueIndex`es on `owner` and `name`.
1515
pub(crate) struct NodeFamiliesIndex<'a> {
1616
/// Unique index: at most one family per owner [`Addr`].
1717
pub(crate) owner: UniqueIndex<'a, Addr, NodeFamily, NodeFamilyId>,
18+
/// Unique index: family names are globally unique. Compares by raw bytes —
19+
/// callers must normalise (e.g. lowercase/trim) before insert if they
20+
/// want case-insensitive uniqueness.
21+
pub(crate) name: UniqueIndex<'a, String, NodeFamily, NodeFamilyId>,
1822
}
1923

2024
impl<'a> NodeFamiliesIndex<'a> {
@@ -25,13 +29,18 @@ impl<'a> NodeFamiliesIndex<'a> {
2529
|family| family.owner.clone(),
2630
storage_keys::FAMILIES_OWNER_IDX_NAMESPACE,
2731
),
32+
name: UniqueIndex::new(
33+
|family| family.name.clone(),
34+
storage_keys::FAMILIES_NAME_IDX_NAMESPACE,
35+
),
2836
}
2937
}
3038
}
3139

3240
impl IndexList<NodeFamily> for NodeFamiliesIndex<'_> {
3341
fn get_indexes(&'_ self) -> Box<dyn Iterator<Item = &'_ dyn Index<NodeFamily>> + '_> {
34-
Box::new(std::iter::once(&self.owner as &dyn Index<NodeFamily>))
42+
let v: Vec<&dyn Index<NodeFamily>> = vec![&self.owner, &self.name];
43+
Box::new(v.into_iter())
3544
}
3645
}
3746

contracts/node-families/src/testing.rs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -117,14 +117,10 @@ pub trait NodeFamiliesContractTesterExt:
117117

118118
fn make_family(&mut self, owner: &Addr) -> NodeFamily {
119119
let env = self.env();
120+
// names must be globally unique; derive from owner addr (also unique)
121+
let name = format!("family-{owner}");
120122
NodeFamiliesStorage::new()
121-
.register_new_family(
122-
self,
123-
&env,
124-
owner.clone(),
125-
"dummy".to_string(),
126-
"dummy".to_string(),
127-
)
123+
.register_new_family(self, &env, owner.clone(), name, "dummy".to_string())
128124
.unwrap()
129125
}
130126

0 commit comments

Comments
 (0)