InviteToFamily previously rejected any second invitation for a (family, node) pair with PendingInvitationAlreadyExists, even once the existing invitation had expired and was left inert in the pending map. Now a still-valid invitation still blocks a duplicate, but an expired one is archived under the new terminal status FamilyInvitationStatus::Expired and superseded by the fresh invitation. Regenerated the contract JSON schema and updated the openspec capability.
46 KiB
node-families-contract Specification
Purpose
TBD - created by archiving change node-families-contract-spec. Update Purpose after archive.
Requirements
Requirement: Contract instantiation persists the runtime config, mixnet contract address, and admin
The contract SHALL be instantiable exactly once via the standard CosmWasm instantiate entry point. The instantiation message SHALL carry a Config (creation fee, family-name length limit, family-description length limit, default invitation validity in seconds) and a string-form mixnet_contract_address. The handler MUST:
- bech32-validate
mixnet_contract_addressand reject malformed inputs; - persist the validated mixnet contract address, the
Config, andinfo.senderas the contract admin; - record the contract name and
CARGO_PKG_VERSIONviacw2::set_contract_version; - record build information via
set_build_information!.
Scenario: Valid instantiation persists config, mixnet address, and admin
- WHEN
instantiateis called with a valid bech32 mixnet contract address and a well-formedConfig - THEN the contract stores the
Configverbatim, the validatedAddrfor the mixnet contract, and setsinfo.senderas the contract admin queryable viacw-controllers::Admin::assert_admin - AND
cw2::get_contract_versionreturns the crate'sCARGO_PKG_VERSION
Scenario: Invalid mixnet contract address is rejected
- WHEN
instantiateis called with amixnet_contract_addressstring that failsAddr::validate - THEN the call returns an error and no state is persisted
Requirement: Migration entry point forbids version downgrades
The contract SHALL expose a migrate entry point that refreshes recorded build information and uses cw2::ensure_from_older_version to guarantee the on-chain contract version is at most the current CARGO_PKG_VERSION. Downgrade attempts MUST be rejected.
Scenario: Equal or older on-chain version is accepted
- WHEN
migrateis called against an on-chaincw2version less than or equal toCARGO_PKG_VERSION - THEN the call succeeds and
set_build_information!updates the stored build metadata
Scenario: Newer on-chain version is rejected
- WHEN
migrateis called against an on-chaincw2version strictly greater thanCARGO_PKG_VERSION - THEN the call returns an error and storage is unchanged
Requirement: Only the contract admin can replace the runtime config
The ExecuteMsg::UpdateConfig handler SHALL overwrite the stored Config with the value supplied by the caller. The handler MUST call Admin::assert_admin and return the underlying AdminError (wrapped in NodeFamiliesContractError::Admin) when the sender is not the admin.
Scenario: Admin replaces the config
- WHEN the contract admin sends
ExecuteMsg::UpdateConfig { config } - THEN the stored
Configequals the supplied value on subsequent reads
Scenario: Non-admin is rejected
- WHEN a non-admin sender sends
ExecuteMsg::UpdateConfig - THEN the call returns an
Adminerror and the storedConfigis unchanged
Requirement: Family names are normalised and globally unique under their normalised form
Family names SHALL be normalised to a canonical form by lowercasing ASCII letters, preserving ASCII digits, and dropping every other character (whitespace, punctuation, non-ASCII letters). The normalised form SHALL be stored alongside the user-supplied name and SHALL be the key of the families.normalised_name unique index. Inputs whose normalised form is the empty string SHALL be rejected with EmptyFamilyName. Inputs whose original length exceeds Config::family_name_length_limit (measured in bytes, via String::len) SHALL be rejected with FamilyNameTooLong { length, limit }. Creation of a family whose normalised name collides with an existing family SHALL fail with FamilyNameAlreadyTaken { name, family_id }.
Important: this normalisation is ASCII-only by design. Non-ASCII letters and emoji are stripped entirely ("café" → "caf", "名前" → "", "⭐stars" → "stars"). Names consisting solely of non-ASCII characters or symbols are rejected as EmptyFamilyName. Operators who want a non-ASCII brand cannot encode it in the on-chain family name; the user-supplied name field preserves the original formatting but only the normalised ASCII form is enforced for uniqueness and emptiness.
Scenario: Normalisation strips punctuation, whitespace, casing, and non-ASCII letters
- WHEN the contract normalises any of
"Foo Bar","foobar","FOO-BAR", or" f.o.o.b.a.r " - THEN every result equals
"foobar"
Scenario: All-symbol name is rejected
- WHEN
CreateFamilyis sent withname = "!!!---"(normalises to the empty string) - THEN the call fails with
EmptyFamilyNameand no family is persisted
Scenario: Name length is checked against the original string, not the normalised form
- WHEN
CreateFamilyis sent with anamewhosename.len()(byte length) exceedsConfig::family_name_length_limit - THEN the call fails with
FamilyNameTooLong { length, limit }
Scenario: Multi-byte characters count their full byte length toward the limit
- WHEN
Config::family_name_length_limit = 8andCreateFamilyis sent withname = "🚀rocket"(a 4-byte emoji followed by"rocket", totallingname.len() == 10bytes — but only 7 user-perceived characters) - THEN the call fails with
FamilyNameTooLong { length: 10, limit: 8 }even though the visible character count is within the limit - AND had the limit been
>= 10, the name would have been accepted and the normalised form would be"rocket"(the emoji dropped during normalisation)
Scenario: Two formattings of the same canonical name collide
- WHEN family
Ais created withname = "Shared"andBlater tries to create withname = "$$shared$$" - THEN the second call fails with
FamilyNameAlreadyTaken { name: "shared", family_id: A.id }
Requirement: An address may own at most one family at a time
A given owner address SHALL own at most one family at any time, enforced by the families.owner unique index. CreateFamily SHALL pre-check this and fail with SenderAlreadyOwnsAFamily { address, family_id } for better error context. The pre-check is in addition to (not instead of) the unique-index defence-in-depth check. Owner-gated handlers (DisbandFamily, InviteToFamily, RevokeFamilyInvitation, KickFromFamily) SHALL look up the family by owner and fail with SenderDoesntOwnAFamily { address } when none exists.
Scenario: Same address cannot create a second family while still owning one
- WHEN address
alicealready owns familyAandCreateFamilyis sent again withaliceas sender - THEN the call fails with
SenderAlreadyOwnsAFamily { address: alice, family_id: A.id }
Scenario: Address can create a family again after disbanding its previous one
- WHEN
alicecreates familyAthen disbands it, then sendsCreateFamilyagain - THEN a new family with a strictly greater id than
Ais created (ids are monotonic and never recycled)
Scenario: Owner-gated handler rejects a sender that owns no family
- WHEN any of
DisbandFamily,InviteToFamily,RevokeFamilyInvitation, orKickFromFamilyis sent by an address that owns no family - THEN the call fails with
SenderDoesntOwnAFamily { address }
Requirement: Family creation requires the configured fee and is rejected if the owner's bonded node is already in a family
ExecuteMsg::CreateFamily SHALL require the sender to attach exactly one coin matching Config::create_family_fee in both denom and amount. Payment validation MUST go through cw_utils::must_pay; mismatches in amount MUST surface as InvalidFamilyCreationFee { expected, received }. The handler MUST additionally check that any bonded node the sender controls (as reported by the mixnet contract via query_nymnode_ownership) is not currently a member of any family, failing with AlreadyInFamily { address, node_id, family_id } otherwise. The handler MUST validate the description length against Config::family_description_length_limit and reject overlong descriptions with FamilyDescriptionTooLong { length, limit }. On success the handler SHALL emit a family_creation event with attributes family_name, owner_address, family_id, paid_fee.
Scenario: Successful family creation persists the family and emits the event
- WHEN a sender with no bonded family-member node and no existing-owned family sends
CreateFamily { name, description }with the correct fee attached - THEN a new family is persisted with monotonically increasing
id, the suppliednameanddescription, the computednormalised_name,members = 0,created_at = env.block.time.seconds(), andpaid_feeequal to the configured fee - AND the response carries an event named
family_creationwith attributesfamily_name,owner_address,family_id,paid_fee
Scenario: Wrong fee denom or missing funds is rejected
- WHEN
CreateFamilyis sent with no funds, with multiple denoms, or with a denom different fromConfig::create_family_fee.denom - THEN the call fails with
InvalidDeposit(PaymentError)
Scenario: Wrong fee amount is rejected
- WHEN
CreateFamilyis sent with the correct denom but an amount different fromConfig::create_family_fee.amount - THEN the call fails with
InvalidFamilyCreationFee { expected, received }and the funds remain unspent
Scenario: Sender whose bonded node is already in a family is rejected
- WHEN
CreateFamilyis sent by an address whose bonded node (per the mixnet contract) is already a member of some familyF - THEN the call fails with
AlreadyInFamily { address, node_id, family_id: F.id }
Scenario: Overlong description is rejected
- WHEN
CreateFamilyis sent with adescriptionwhose byte length exceedsConfig::family_description_length_limit - THEN the call fails with
FamilyDescriptionTooLong { length, limit }
Requirement: A family's name and/or description can be updated by its owner
ExecuteMsg::UpdateFamily { updated_name: Option<String>, updated_description: Option<String> } SHALL allow the owner of an existing family to replace either or both fields independently. Each argument follows None = keep / Some(_) = replace semantics; a call carrying None for both fields SHALL be a silent no-op (empty Response, no event, no state change) and SHALL short-circuit BEFORE the owner-ownership check (so a sender that owns no family can also no-op without error). Otherwise the handler MUST look up the sender's family via the owner unique index and fail with SenderDoesntOwnAFamily { address } if none exists. Updated values MUST be validated against the same rules as CreateFamily: a Some(name) MUST satisfy Config::family_name_length_limit (else FamilyNameTooLong { length, limit }) and MUST normalise to a non-empty string (else EmptyFamilyName); a Some(description) MUST satisfy Config::family_description_length_limit (else FamilyDescriptionTooLong { length, limit }). The new normalised name MUST be globally unique against OTHER families (else FamilyNameAlreadyTaken { name, family_id }); a name change whose normalised form matches the family's own existing normalised_name (a case-only or punctuation-only rename) MUST be allowed. The handler MUST preserve id, owner, paid_fee, members, and created_at. On a state-changing success the response SHALL include a family_update event with family_id and owner_address attributes plus conditional updated_name / updated_description attributes for each field actually changed by the call.
Scenario: Owner updates the family name only
- WHEN the family owner sends
UpdateFamily { updated_name: Some(n), updated_description: None }with a validn - THEN the persisted family's
nameandnormalised_nameare replaced withnand its normalised form, whiledescription,id,owner,paid_fee,members, andcreated_atare unchanged - AND the response carries a
family_updateevent withfamily_id,owner_address, andupdated_name = n(noupdated_descriptionattribute)
Scenario: Owner updates the description only
- WHEN the family owner sends
UpdateFamily { updated_name: None, updated_description: Some(d) }with a validd - THEN the persisted family's
descriptionis replaced withdand every other field is unchanged - AND the response carries a
family_updateevent withfamily_id,owner_address, andupdated_description = d(noupdated_nameattribute)
Scenario: Owner updates both fields
- WHEN the family owner sends
UpdateFamily { updated_name: Some(n), updated_description: Some(d) }with both values valid - THEN both
name/normalised_nameanddescriptionare replaced, and the event carries bothupdated_nameandupdated_descriptionattributes
Scenario: A fully-empty call is a silent no-op for any sender
- WHEN any sender (whether or not they own a family) sends
UpdateFamily { updated_name: None, updated_description: None } - THEN the response is
Response::default()(no events, no messages) and storage is unchanged
Scenario: Sender that owns no family is rejected when at least one field is set
- WHEN an address that does not own any family sends
UpdateFamilywith at least oneSome(_)field - THEN the call fails with
SenderDoesntOwnAFamily { address }
Scenario: Overlong name is rejected
- WHEN
UpdateFamily { updated_name: Some(n), .. }is sent withn.len() > Config::family_name_length_limit - THEN the call fails with
FamilyNameTooLong { length, limit }and storage is unchanged
Scenario: Name normalising to empty is rejected
- WHEN
UpdateFamily { updated_name: Some(n), .. }is sent with annwhose normalisation yields the empty string - THEN the call fails with
EmptyFamilyNameand storage is unchanged
Scenario: Renaming to another family's normalised name is rejected
- WHEN family
A's owner sendsUpdateFamily { updated_name: Some(n), .. }whose normalisation equals a different familyB'snormalised_name - THEN the call fails with
FamilyNameAlreadyTaken { name, family_id: B.id }and storage is unchanged
Scenario: Case-only or punctuation-only rename is allowed
- WHEN the owner sends
UpdateFamily { updated_name: Some(n), .. }whose normalisation equals the family's own existingnormalised_name - THEN the display
nameis replaced withn, thenormalised_nameis unchanged, and the call succeeds
Scenario: Overlong description is rejected
- WHEN
UpdateFamily { updated_description: Some(d), .. }is sent withd.len() > Config::family_description_length_limit - THEN the call fails with
FamilyDescriptionTooLong { length, limit }and storage is unchanged
Requirement: Family ids are monotonic, never recycled, and start at 1
The contract SHALL assign family ids sequentially starting at 1. The id counter SHALL be persisted as an Item<NodeFamilyId> and incremented on each successful family creation. Disbanding a family MUST NOT free or recycle its id. 0 SHALL be reserved as a "no family" sentinel and never assigned.
Scenario: First-ever family receives id 1
- WHEN the contract is freshly instantiated and the first successful
CreateFamilyruns - THEN the persisted
NodeFamily.idequals1
Scenario: Ids are not reused after disband
- WHEN family with id
Nis disbanded and a new family is then created - THEN the new family's id is strictly greater than
N(it isN+1if no other families were created in between)
Requirement: A family can only be disbanded by its owner, must be empty, and refunds the creation fee
ExecuteMsg::DisbandFamily SHALL look up the sender's family via the owner unique index and fail with SenderDoesntOwnAFamily when none exists. The handler MUST reject disbanding while NodeFamily.members > 0, failing with FamilyNotEmpty { family_id, members }. On success the handler SHALL sweep every still-pending invitation issued by the family (archiving each as FamilyInvitationStatus::Revoked { at: now } with status timestamp = env.block.time.seconds()), remove the family record, and attach a BankMsg::Send { to_address: owner, amount: vec![family.paid_fee] } to the response as a CosmWasm sub-message. The response SHALL include a family_disband event with family_id, owner_address, refunded_fee attributes.
Operational note (non-normative): the pending-invitation sweep iterates every entry of pending_family_invitations keyed by family_id and archives each in a single transaction. Gas cost therefore scales linearly with the number of leftover pending invitations. An owner whose family has accumulated an unusually large number of pending invitations is expected to revoke them in batches (via RevokeFamilyInvitation) before invoking DisbandFamily, since a single disband call that exceeds the per-tx gas limit will fail and leave the family in place. There is no contract-side chunking; the responsibility lies with the caller.
Scenario: Owner disbands an empty family and is refunded
- WHEN family owner sends
DisbandFamilywhilemembers == 0 - THEN the family is removed from storage, the response carries a
BankMsg::Sendoffamily.paid_feeto the owner, and afamily_disbandevent is emitted
Scenario: Refund is attached as a BankMsg::Send sub-message, not a direct bank-module call
- WHEN a successful
DisbandFamilyreturns itsResponse - THEN the response's
messagesfield contains exactly oneCosmosMsg::Bank(BankMsg::Send { to_address: <owner bech32>, amount: vec![<family.paid_fee>] })and the contract performs no other balance-changing side effect for the refund - AND tx simulators that inspect outbound sub-messages observe the refund there (this is the contract's only avenue for returning funds; integrators rely on it)
Scenario: Non-empty family cannot be disbanded
- WHEN family owner sends
DisbandFamilywhilemembers > 0 - THEN the call fails with
FamilyNotEmpty { family_id, members }and storage is unchanged
Scenario: Disbanding sweeps still-pending invitations as Revoked
- WHEN family
Fhas pending invitations to nodesn1,n2and the owner sendsDisbandFamily - THEN the pending entries for
(F.id, n1)and(F.id, n2)are removed frompending_family_invitationsand archived underpast_family_invitationswithstatus = Revoked { at: env.block.time.seconds() }
Requirement: A node belongs to at most one family at any time
The family_members map SHALL be keyed by NodeId alone; the value SHALL carry the family_id so the storage layer enforces the one-family-per-node invariant by construction. Handlers that add a membership (AcceptFamilyInvitation) and handlers that issue an invitation (InviteToFamily) SHALL pre-check the absence of any existing membership for the node and fail with NodeAlreadyInFamily { node_id, family_id } otherwise.
Scenario: Inviting a node already in a family is rejected
- WHEN
InviteToFamily { node_id }targets a node that already has a membership record for familyF - THEN the call fails with
NodeAlreadyInFamily { node_id, family_id: F.id }
Scenario: Accepting a second invitation after joining a family is rejected
- WHEN node
nis a member of familyFandAcceptFamilyInvitation { family_id: G, node_id: n }is sent (for a different familyG) - THEN the call fails with
NodeAlreadyInFamily { node_id: n, family_id: F.id }
Requirement: Invitations require an existing family, a bonded target node, and strictly positive validity
ExecuteMsg::InviteToFamily SHALL be owner-gated (the family acted on is the sender's owned family, never an argument). The handler MUST:
- compute
validity = validity_secs.unwrap_or(Config::default_invitation_validity_secs); - reject
validity == 0withZeroInvitationValidity; - verify
node_idrefers to a currently-bonded, not-unbonding node in the mixnet contract viaMixnetContractQuerier::check_node_existence(which returnsfalseboth when no bond exists and when the bond is in the unbonding state), failing withNodeDoesntExist { node_id }otherwise; - verify the node is not already in any family (see "A node belongs to at most one family");
- reject
(family_id, node_id)pairs that already have a still-valid pending invitation (env.block.time.seconds() < existing.expires_at) withPendingInvitationAlreadyExists { family_id, node_id }; - when a pending invitation for the pair exists but has already expired (
env.block.time.seconds() >= existing.expires_at), archive it asPastFamilyInvitation { invitation, status: Expired { at: env.block.time.seconds() } }using the next free per-(family, node)archive slot, then let the fresh invitation supersede it; - persist a
FamilyInvitationwithexpires_at = env.block.time.seconds() + validity; - emit a
family_invitationevent withfamily_id,node_id,expires_at(the same event whether or not it superseded an expired invitation).
Scenario: Successful invitation persists with the computed expiry
- WHEN family owner sends
InviteToFamily { node_id, validity_secs: Some(v) }and all preconditions hold - THEN a pending invitation for
(owned.id, node_id)is persisted withexpires_at = env.block.time.seconds() + v - AND the response carries a
family_invitationevent withfamily_id = owned.id,node_id,expires_at
Scenario: Missing validity falls back to the configured default
- WHEN
InviteToFamily { node_id, validity_secs: None }is sent - THEN the persisted invitation has
expires_at = env.block.time.seconds() + Config::default_invitation_validity_secs
Scenario: Zero validity is rejected
- WHEN
InviteToFamily { node_id, validity_secs: Some(0) }is sent - THEN the call fails with
ZeroInvitationValidityand no invitation is persisted
Scenario: Inviting a non-bonded node is rejected
- WHEN
InviteToFamily { node_id }targets anode_idfor which the mixnet contract'scheck_node_existencereturnsfalse - THEN the call fails with
NodeDoesntExist { node_id }
Scenario: Duplicate still-valid pending invitation is rejected
- WHEN family
Falready has a still-valid (not yet expired) pending invitation for nodenandInviteToFamily { node_id: n }is sent again byF's owner - THEN the call fails with
PendingInvitationAlreadyExists { family_id: F.id, node_id: n }(the existing invitation is preserved and nothing is archived)
Scenario: Re-inviting after the previous invitation expired supersedes it
- WHEN family
Fhas a pending invitation for nodenwhoseexpires_at <= env.block.time.seconds()andInviteToFamily { node_id: n }is sent again byF's owner - THEN the call succeeds: the stale invitation is archived under
past_family_invitationswithstatus = Expired { at: env.block.time.seconds() }, and a freshFamilyInvitationfor(F.id, n)is persisted with the newly computedexpires_at
Requirement: Acceptance and rejection of an invitation are gated on node control
ExecuteMsg::AcceptFamilyInvitation and ExecuteMsg::RejectFamilyInvitation SHALL each verify that the sender is the controller of the bonded node node_id per the mixnet contract (query_nymnode_ownership returns a nym_node with node_id == node_id and is_unbonding == false). Failures MUST surface as SenderDoesntControlNode { address, node_id }. This single error covers the cases of: sender owns no bonded node, sender owns a different node id, and sender owns the node but it has entered unbonding.
Scenario: Non-controller cannot accept an invitation
- WHEN
AcceptFamilyInvitation { family_id, node_id }is sent by an address that does not controlnode_id(per mixnet contract) - THEN the call fails with
SenderDoesntControlNode { address, node_id }
Scenario: Unbonding node cannot accept an invitation
- WHEN the sender controls
node_idbut the mixnet contract reports the node as unbonding - THEN
AcceptFamilyInvitationfails withSenderDoesntControlNode { address, node_id }
Scenario: Non-controller cannot reject an invitation
- WHEN
RejectFamilyInvitation { family_id, node_id }is sent by an address that does not controlnode_id - THEN the call fails with
SenderDoesntControlNode { address, node_id }
Requirement: Accepting an invitation moves it from pending to archived and increments the family member count
A successful AcceptFamilyInvitation SHALL:
- load the pending invitation for
(family_id, node_id), failing withInvitationNotFound { family_id, node_id }if absent; - check
now < invitation.expires_at, failing withInvitationExpired { family_id, node_id, expires_at, now }otherwise (now == expires_atis considered expired); - remove the entry from
pending_family_invitations; - write
family_members[node_id] = FamilyMembership { family_id, joined_at: now }; - increment
NodeFamily::membersby 1 (failing withFamilyNotFound { family_id }if the family has somehow been removed); - archive a
PastFamilyInvitation { invitation, status: Accepted { at: now } }under((family_id, node_id), counter)wherecounteris the next free per-(family, node)archive slot; - emit a
family_invitation_acceptedevent withfamily_id,node_id.
Scenario: Happy-path acceptance
- WHEN node controller accepts a not-yet-expired pending invitation
- THEN the membership is recorded with
joined_at = env.block.time.seconds(), the family'smemberscount is incremented, the pending entry is removed, and the archive contains the invitation with statusAccepted { at: now }
Scenario: Acceptance of an already-expired invitation is rejected
- WHEN
AcceptFamilyInvitationis called withenv.block.time.seconds() >= invitation.expires_at - THEN the call fails with
InvitationExpired { family_id, node_id, expires_at, now }and storage is unchanged
Scenario: Acceptance with no pending invitation is rejected
- WHEN
AcceptFamilyInvitation { family_id, node_id }is called with no pending invitation stored for that pair - THEN the call fails with
InvitationNotFound { family_id, node_id }
Requirement: Rejection and revocation work on expired invitations and archive them with terminal status
RejectFamilyInvitation (sent by the node controller) and RevokeFamilyInvitation (sent by the family owner) SHALL each:
- remove the pending invitation;
- archive a
PastFamilyInvitationwith statusRejected { at: now }orRevoked { at: now }respectively, under((family_id, node_id), counter)using the next free per-(family, node)archive slot; - emit
family_invitation_rejectedorfamily_invitation_revokedrespectively, withfamily_idandnode_idattributes; - fail with
InvitationNotFound { family_id, node_id }if no pending invitation exists.
The contract performs no background sweep of expired entries. Reject and revoke are the two targeted ways to clear a specific pending invitation; an expired one is additionally cleared if the family owner re-invites the same node (archiving the stale entry as Expired { at: now } before superseding it — see "Invitations require an existing family …") or if the family is disbanded.
Scenario: Owner revokes a still-pending invitation
- WHEN family owner sends
RevokeFamilyInvitation { node_id }for a node currently in their pending invitations - THEN the pending entry is removed and the archive contains the invitation with status
Revoked { at: env.block.time.seconds() }
Scenario: Node controller rejects a still-pending invitation
- WHEN node controller sends
RejectFamilyInvitation { family_id, node_id }for a pending invitation - THEN the pending entry is removed and the archive contains the invitation with status
Rejected { at: env.block.time.seconds() }
Scenario: Expired invitations can still be rejected or revoked
- WHEN an invitation's
expires_atis at or beforeenv.block.time.seconds()and eitherRejectFamilyInvitationorRevokeFamilyInvitationis sent for it - THEN the call succeeds, the pending entry is removed, and the archive records the appropriate terminal status
Requirement: Leave and kick remove the membership and archive a past-member record
ExecuteMsg::LeaveFamily { node_id } SHALL require the sender to be the controller of node_id (failing with SenderDoesntControlNode otherwise). ExecuteMsg::KickFromFamily { node_id } SHALL require the sender to be the current owner of a family, and the node MUST be a member of that family — kicking a node belonging to a different family fails with NodeNotMemberOfFamily { node_id, family_id }; kicking a node that has no membership at all fails with NodeNotInFamily { node_id }. Both handlers SHALL share the same storage operation: remove family_members[node_id], decrement NodeFamily::members, and archive a PastFamilyMember { family_id, node_id, removed_at: env.block.time.seconds() } under ((family_id, node_id), counter) using the next free per-(family, node) slot. The respective events are family_member_left (carrying the leaver's family_id and node_id) and family_member_kicked (carrying the owner's family_id and the kicked node_id).
Scenario: Node controller leaves the family
- WHEN node controller sends
LeaveFamily { node_id }for a node currently in familyF - THEN
family_members[node_id]is removed,F.membersis decremented, the archive contains aPastFamilyMemberwithremoved_at = env.block.time.seconds(), and the response carries afamily_member_leftevent
Scenario: Owner kicks a member of their family
- WHEN family owner sends
KickFromFamily { node_id }for a member of their family - THEN the same storage transition as a leave occurs and the response carries a
family_member_kickedevent
Scenario: Owner cannot kick a node belonging to another family
- WHEN family owner sends
KickFromFamily { node_id }for a node whose membership is in a different familyG - THEN the call fails with
NodeNotMemberOfFamily { node_id, family_id: owned.id }and storage is unchanged
Scenario: Repeated join/leave of the same family uses sequential archive counters
- WHEN node
njoins, leaves, joins again, and leaves again the same familyF - THEN the archive contains
past_family_members[((F.id, n), 0)]andpast_family_members[((F.id, n), 1)]with the respectiveremoved_attimestamps
Requirement: Only the configured mixnet contract may invoke the unbonding callback
ExecuteMsg::OnNymNodeUnbond { node_id } SHALL be authorised solely by checking info.sender == stored mixnet_contract_address, failing with UnauthorisedMixnetCallback { sender } otherwise. There is no node-side or admin override.
Scenario: Mixnet contract triggers the callback
- WHEN the mixnet contract sends
OnNymNodeUnbond { node_id } - THEN the call proceeds and the cleanup (see next requirement) is applied
Scenario: Arbitrary sender is rejected
- WHEN any address other than the stored mixnet contract sends
OnNymNodeUnbond - THEN the call fails with
UnauthorisedMixnetCallback { sender }
Requirement: The unbonding callback is idempotent over membership and sweeps every pending invitation addressed to the node
A successful OnNymNodeUnbond { node_id } SHALL:
- if
family_members[node_id]exists, remove the membership and archive aPastFamilyMemberexactly as theleave/kickpath does (decrementing the family'smemberscount); - if no such membership exists, leave membership state untouched (this is the common case — most unbonding nodes were never in a family);
- iterate every entry of
pending_family_invitationskeyed bynode_id(via thenodemulti-index), remove each from the pending map, and archive each asPastFamilyInvitation { invitation, status: Rejected { at: env.block.time.seconds() } }using the next free per-(family, node)counter; - emit a
family_node_unbond_cleanupevent withnode_idattribute.
The auto-cleared invitations share the Rejected terminal state with invitations the node controller would have explicitly declined — Revoked is reserved for owner-side actions.
Operational note (non-normative): like the disband sweep, the per-node invitation sweep iterates every pending invitation addressed to node_id and archives each in a single transaction. Gas cost therefore scales linearly with the number of outstanding invitations the unbonding node holds. If a node has accumulated an unusually large number of pending invitations, the operator-initiated unbond transaction on the mixnet contract may fail because this callback exceeds the per-tx gas limit. The fix is operator-side: explicitly RejectFamilyInvitation the outstanding invitations (in batches if needed) before retrying the unbond. There is no contract-side chunking, and the mixnet contract has no path to bypass or partial-apply the callback.
Scenario: Unbonding node with no family and no pending invitations is a no-op
- WHEN
OnNymNodeUnbond { node_id }is invoked for a node with no membership and no pending invitations - THEN the call succeeds, no storage is changed (apart from emitting the cleanup event), and no error is returned
Scenario: Unbonding node in a family loses its membership
- WHEN
OnNymNodeUnbond { node_id }is invoked for a node currently in familyF - THEN
family_members[node_id]is removed,F.membersis decremented, and aPastFamilyMemberis archived withremoved_at = env.block.time.seconds()
Scenario: Unbonding node's pending invitations are archived as Rejected
- WHEN node
nhas pending invitations from familiesF1andF2andOnNymNodeUnbond { node_id: n }is invoked - THEN both pending entries are removed and
past_family_invitations[((F1.id, n), …)]andpast_family_invitations[((F2.id, n), …)]each carrystatus = Rejected { at: env.block.time.seconds() }
Requirement: Expired pending invitations remain in storage until explicitly cleared
The contract SHALL NOT run any background sweep of expired pending invitations. A FamilyInvitation whose expires_at <= env.block.time.seconds() SHALL remain in pending_family_invitations until either the family owner revokes it, the node controller rejects it, the family owner re-invites the same node (which archives the stale entry as Expired { at: now } and replaces it), or the family is disbanded. Accept attempts against such entries MUST fail with InvitationExpired. Read queries SHALL surface a boolean expired flag (now >= invitation.expires_at) on each returned PendingFamilyInvitationDetails so callers can filter without doing the comparison themselves.
Scenario: Expired invitation is still listed by pending queries
- WHEN family
Fhas a pending invitation for nodenwhoseexpires_atis in the past andGetPendingInvitationsForFamilyPaged { family_id: F.id }is queried - THEN the result includes the entry with
expired = true
Scenario: Expired invitation can be revoked or rejected but not accepted
- WHEN an expired invitation exists for
(F.id, n) - THEN
AcceptFamilyInvitationfails withInvitationExpired, butRevokeFamilyInvitation(byF's owner) andRejectFamilyInvitation(byn's controller) both succeed and archive the invitation with the corresponding terminal status
Requirement: Per-(family, node) archive slots use sequential counters starting at 0
Both past_family_invitations and past_family_members SHALL be keyed by ((family_id, node_id), counter: u64). The contract SHALL maintain explicit per-(family, node) counter maps (past_family_invitation_counter, past_family_member_counter), starting at 0 for each new pair and incrementing by 1 on each archival write. Counters MUST be independent across distinct (family, node) pairs and across the two archive types.
Scenario: First archive slot for a new pair is 0
- WHEN a node and family appear in either archive for the first time
- THEN the entry is keyed with
counter = 0
Scenario: Distinct pairs have independent counters
- WHEN archive entries are written under keys
(F1, n)and(F2, n)in any order - THEN each pair's counter starts independently at
0
Requirement: GetFamilyMembership returns the family id a node belongs to, or None
QueryMsg::GetFamilyMembership { node_id } SHALL return NodeFamilyMembershipResponse { node_id, family_id } where family_id is Some(NodeFamilyId) iff family_members[node_id] is populated, and None otherwise. The query MUST NOT fail when node_id is unknown — it returns family_id: None. The query is the canonical way for route-selection consumers to check whether two node ids belong to the same family without scanning members.
Scenario: Member node returns its family id
- WHEN node
nis currently a member of familyFandGetFamilyMembership { node_id: n }is queried - THEN the response carries
family_id = Some(F.id)
Scenario: Non-member node returns None
- WHEN
GetFamilyMembership { node_id }is queried for a node that has no membership record (never joined, or has since left/been kicked/unbonded) - THEN the response carries
family_id = Noneandnode_idechoed back
Requirement: Single-family-by-id, by-name, and by-owner queries return an Option<NodeFamily>
The contract SHALL expose three single-family lookups via QueryMsg::GetFamilyById, GetFamilyByName, and GetFamilyByOwner, each returning a response that echoes the queried key back to the caller for correlation and a family: Option<NodeFamily> field that is None when no match exists. GetFamilyByName MUST normalise the input via the same normalise_family_name function the create path uses before consulting the unique index. GetFamilyByOwner MUST bech32-validate the input.
Scenario: GetFamilyByName is invariant under input formatting
- WHEN family
Fexists withname = "MyFamily"andGetFamilyByName { name: "my family" }is queried - THEN the response carries
family = Some(F)(the lookup hits the unique normalised-name index)
Scenario: Missing match returns None
- WHEN any of the three single-family queries is called with a key that matches no family
- THEN the response carries
family = Noneand echoes the queried key
Scenario: GetFamilyByOwner rejects an invalid bech32 address
- WHEN
GetFamilyByOwner { owner }is called with a non-bech32 string - THEN the call returns an error sourced from
Addr::validate
Requirement: Paginated queries use exclusive start_after, default limit 50, max limit 100
Every paginated query (GetFamiliesPaged, GetFamilyMembersPaged, GetAllFamilyMembersPaged, GetPendingInvitationsForFamilyPaged, GetPendingInvitationsForNodePaged, GetAllPendingInvitationsPaged, GetPastInvitationsForFamilyPaged, GetPastInvitationsForNodePaged, GetAllPastInvitationsPaged, GetPastMembersForFamilyPaged, GetPastMembersForNodePaged) SHALL accept start_after: Option<Cursor> (exclusive) and limit: Option<u32>. The default limit SHALL be 50 and SHALL be clamped to 100 as a hard cap. Results SHALL be in ascending cursor order. Each response SHALL include a start_next_after: Option<Cursor> derived from the last entry of the page, with None indicating the page is empty (the caller treats this as end-of-list).
Per-family / per-node paginated queries SHALL NOT verify that the supplied family_id or node_id corresponds to an existing entity — an unknown id simply yields an empty page.
Scenario: Limit is defaulted and clamped
- WHEN any paginated query is called with
limit = None - THEN at most 50 entries are returned
- AND when called with
limit = Some(10_000)at most 100 entries are returned
Scenario: Empty page signals end-of-list
- WHEN a paginated query has no more entries past the supplied
start_after - THEN the response carries an empty entry list and
start_next_after = None
Scenario: Unknown scope id yields an empty page rather than an error
- WHEN
GetFamilyMembersPaged { family_id: 999_999, .. }is queried for a non-existent family id - THEN the response carries an empty
memberslist andstart_next_after = None(no error is returned)
Requirement: Pending-invitation queries stamp each entry with its expiry flag
Queries returning pending invitations (GetPendingInvitation, GetPendingInvitationsForFamilyPaged, GetPendingInvitationsForNodePaged, GetAllPendingInvitationsPaged) SHALL wrap each invitation in PendingFamilyInvitationDetails { invitation, expired } where expired = env.block.time.seconds() >= invitation.expires_at. Past-invitation and past-member queries do not carry this flag (they are terminal-state archives).
Scenario: Future-dated invitation is marked not expired
- WHEN a pending invitation has
expires_at = env.block.time.seconds() + 60and any pending-invitation query returns it - THEN the returned
PendingFamilyInvitationDetails.expiredisfalse
Scenario: Past-dated invitation is marked expired
- WHEN a pending invitation has
expires_at = env.block.time.seconds() - 1 - THEN any pending-invitation query that returns it sets
expired = true
Requirement: Storage-key constants are part of the public contract
Every constant exported under nym_node_families_contract_common::constants::storage_keys SHALL be treated as part of the public contract surface. Off-chain indexers and migration tooling may depend on these exact byte strings. Changing the value of any existing constant — including renaming a namespace — SHALL be considered a breaking change for already-deployed contracts and SHALL be accompanied by a data migration via queued_migrations. Adding new constants for new storage maps is non-breaking. The current set covers the contract-level items (admin, config, mixnet contract address, family id counter), the primary maps (families, node-family-members, invitations, past-invitations, past-family-member), every secondary index (__owner, __name, __family, __node), and the per-(family, node) archive counters — refer to constants.rs for the authoritative list.
Scenario: Storage key constants are reachable from the common crate
- WHEN an off-chain consumer imports
nym_node_families_contract_common::constants::storage_keys - THEN it observes the full set of storage-key constants the contract uses, without taking a dependency on the contract crate itself
Requirement: Emitted events form a stable public surface
Every event name and attribute key exported under nym_node_families_contract_common::constants::events SHALL be treated as part of the public contract surface. Each successful state-mutating user-facing execute path (every variant of ExecuteMsg except UpdateConfig, which is an admin handler that returns Response::default() without an event, and the no-op UpdateFamily { updated_name: None, updated_description: None } short-circuit, which also returns Response::default() because nothing changed) SHALL emit exactly one event whose name and attribute keys come from these constants. Renaming an event name constant, renaming an attribute-key constant, or changing the set of attributes a given event always carries SHALL be treated as breaking changes. Adding new constants for new events / attributes is non-breaking, as is making a previously-always-emitted attribute conditional only by extending the variant set that triggers it (see family_update below).
At the time of this spec the constant surface comprises (refer to constants.rs for the authoritative list):
family_creation— attributes:family_name,owner_address,family_id,paid_feefamily_update— required attributes:family_id,owner_address. Conditional attributes:updated_name(emitted only when the call carriedupdated_name = Some(_)),updated_description(emitted only when the call carriedupdated_description = Some(_)). At least one of the two conditional attributes is always present, because the no-op no-name-no-description case short-circuits toResponse::default()without emitting any event at all.family_disband— attributes:family_id,owner_address,refunded_feefamily_invitation— attributes:family_id,node_id,expires_atfamily_invitation_revoked— attributes:family_id,node_idfamily_invitation_accepted— attributes:family_id,node_idfamily_invitation_rejected— attributes:family_id,node_idfamily_member_left— attributes:family_id,node_idfamily_member_kicked— attributes:family_id,node_idfamily_node_unbond_cleanup— attributes:node_id
Note for indexer authors (non-normative): CosmWasm wraps user-emitted events in a wasm- prefix when they land in the tendermint event log. So family_creation appears as wasm-family_creation on the chain side, family_invitation as wasm-family_invitation, etc. The constants in constants::events are the unprefixed names — indexers querying the chain need to add the wasm- prefix themselves (or use the prefix-stripping helpers most Cosmos indexer SDKs already provide).
Scenario: Each successful state-mutating execute path emits exactly its named event
- WHEN any of the execute paths listed above succeeds (i.e. every
ExecuteMsgvariant other thanUpdateConfig) - THEN the response carries exactly one event whose
tyequals the corresponding constant fromconstants::eventsand whose attributes include each of the listed keys
Scenario: UpdateConfig is the exception — no event is emitted
- WHEN the admin sends
ExecuteMsg::UpdateConfigand the call succeeds - THEN the response is
Response::default()and carries no event (this is intentional —UpdateConfigis administrative metadata, not a tracked state transition)
Scenario: No-op UpdateFamily is the second exception — no event is emitted
- WHEN any sender sends
ExecuteMsg::UpdateFamily { updated_name: None, updated_description: None }and the call succeeds (the no-op short-circuit) - THEN the response is
Response::default()and carries no event — because nothing in storage changed, there is nothing for indexers to observe