From c85fb161d40169c4540176802fc4fbda60c5087e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Mon, 8 Jun 2026 10:45:30 +0100 Subject: [PATCH] feat: allow re-inviting a node whose family invitation has expired 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. --- .../node-families-contract/src/types.rs | 24 +++-- .../node-families/schema/node-families.json | 98 ++++++++++++++--- ...nse_to_get_all_past_invitations_paged.json | 30 +++++- ..._to_get_all_pending_invitations_paged.json | 2 +- ...get_past_invitations_for_family_paged.json | 30 +++++- ...o_get_past_invitations_for_node_paged.json | 30 +++++- .../response_to_get_pending_invitation.json | 2 +- ..._pending_invitations_for_family_paged.json | 2 +- ...et_pending_invitations_for_node_paged.json | 2 +- contracts/node-families/src/storage/mod.rs | 100 ++++++++++++++---- contracts/node-families/src/testing.rs | 3 +- contracts/node-families/src/transactions.rs | 97 ++++++++++++++++- openspec/specs/node-families-contract/spec.md | 19 ++-- 13 files changed, 377 insertions(+), 62 deletions(-) diff --git a/common/cosmwasm-smart-contracts/node-families-contract/src/types.rs b/common/cosmwasm-smart-contracts/node-families-contract/src/types.rs index 6fefaae4ba..439e237703 100644 --- a/common/cosmwasm-smart-contracts/node-families-contract/src/types.rs +++ b/common/cosmwasm-smart-contracts/node-families-contract/src/types.rs @@ -61,9 +61,12 @@ pub struct NodeFamily { /// 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). +/// Invitations are stored until they are accepted, rejected, or revoked. Once the +/// chain advances past `expires_at` an invitation becomes inert but stays in storage +/// — there is no background process clearing expired invitations. A timed-out +/// invitation is cleared either when explicitly revoked/rejected, or when the family +/// issues a fresh invitation for the same node, which archives the stale one as +/// `Expired` and supersedes it. #[cw_serde] pub struct FamilyInvitation { /// The family that issued the invitation. @@ -107,8 +110,10 @@ pub struct PastFamilyMember { /// 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`). +/// Note: an invitation that merely times out is **not** archived here on its own — +/// it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only +/// reaches `Expired` if the family issues a fresh invitation for the same node, which +/// supersedes and archives the stale one. #[cw_serde] pub enum FamilyInvitationStatus { /// Still awaiting a response. Recorded with a timestamp for completeness even @@ -121,11 +126,16 @@ pub enum FamilyInvitationStatus { /// The family revoked the invitation at the given timestamp before it could /// be accepted or rejected. Revoked { at: u64 }, + /// The invitation had already expired and was superseded by a fresh invitation + /// for the same node from the same family, issued at the given timestamp. This is + /// the only path that archives a timed-out invitation. + Expired { 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. +/// (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is +/// archived here only when a fresh invitation for the same node supersedes it +/// (status `Expired`); otherwise it stays in the pending map until explicitly cleared. #[cw_serde] pub struct PastFamilyInvitation { /// The original invitation as it was issued. diff --git a/contracts/node-families/schema/node-families.json b/contracts/node-families/schema/node-families.json index 9a731548ba..956456516a 100644 --- a/contracts/node-families/schema/node-families.json +++ b/contracts/node-families/schema/node-families.json @@ -1188,7 +1188,7 @@ "additionalProperties": false, "definitions": { "FamilyInvitation": { - "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.", "type": "object", "required": [ "expires_at", @@ -1218,7 +1218,7 @@ "additionalProperties": false }, "FamilyInvitationStatus": { - "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).", + "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: an invitation that merely times out is **not** archived here on its own — it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only reaches `Expired` if the family issues a fresh invitation for the same node, which supersedes and archives the stale one.", "oneOf": [ { "description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.", @@ -1315,11 +1315,35 @@ } }, "additionalProperties": false + }, + { + "description": "The invitation had already expired and was superseded by a fresh invitation for the same node from the same family, issued at the given timestamp. This is the only path that archives a timed-out invitation.", + "type": "object", + "required": [ + "expired" + ], + "properties": { + "expired": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } ] }, "PastFamilyInvitation": { - "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.", + "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is archived here only when a fresh invitation for the same node supersedes it (status `Expired`); otherwise it stays in the pending map until explicitly cleared.", "type": "object", "required": [ "invitation", @@ -1388,7 +1412,7 @@ "additionalProperties": false, "definitions": { "FamilyInvitation": { - "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.", "type": "object", "required": [ "expires_at", @@ -2073,7 +2097,7 @@ "additionalProperties": false, "definitions": { "FamilyInvitation": { - "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.", "type": "object", "required": [ "expires_at", @@ -2103,7 +2127,7 @@ "additionalProperties": false }, "FamilyInvitationStatus": { - "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).", + "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: an invitation that merely times out is **not** archived here on its own — it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only reaches `Expired` if the family issues a fresh invitation for the same node, which supersedes and archives the stale one.", "oneOf": [ { "description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.", @@ -2200,11 +2224,35 @@ } }, "additionalProperties": false + }, + { + "description": "The invitation had already expired and was superseded by a fresh invitation for the same node from the same family, issued at the given timestamp. This is the only path that archives a timed-out invitation.", + "type": "object", + "required": [ + "expired" + ], + "properties": { + "expired": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } ] }, "PastFamilyInvitation": { - "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.", + "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is archived here only when a fresh invitation for the same node supersedes it (status `Expired`); otherwise it stays in the pending map until explicitly cleared.", "type": "object", "required": [ "invitation", @@ -2280,7 +2328,7 @@ "additionalProperties": false, "definitions": { "FamilyInvitation": { - "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.", "type": "object", "required": [ "expires_at", @@ -2310,7 +2358,7 @@ "additionalProperties": false }, "FamilyInvitationStatus": { - "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).", + "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: an invitation that merely times out is **not** archived here on its own — it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only reaches `Expired` if the family issues a fresh invitation for the same node, which supersedes and archives the stale one.", "oneOf": [ { "description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.", @@ -2407,11 +2455,35 @@ } }, "additionalProperties": false + }, + { + "description": "The invitation had already expired and was superseded by a fresh invitation for the same node from the same family, issued at the given timestamp. This is the only path that archives a timed-out invitation.", + "type": "object", + "required": [ + "expired" + ], + "properties": { + "expired": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } ] }, "PastFamilyInvitation": { - "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.", + "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is archived here only when a fresh invitation for the same node supersedes it (status `Expired`); otherwise it stays in the pending map until explicitly cleared.", "type": "object", "required": [ "invitation", @@ -2634,7 +2706,7 @@ "additionalProperties": false, "definitions": { "FamilyInvitation": { - "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.", "type": "object", "required": [ "expires_at", @@ -2724,7 +2796,7 @@ "additionalProperties": false, "definitions": { "FamilyInvitation": { - "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.", "type": "object", "required": [ "expires_at", @@ -2814,7 +2886,7 @@ "additionalProperties": false, "definitions": { "FamilyInvitation": { - "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.", "type": "object", "required": [ "expires_at", diff --git a/contracts/node-families/schema/raw/response_to_get_all_past_invitations_paged.json b/contracts/node-families/schema/raw/response_to_get_all_past_invitations_paged.json index dcbb67974b..cdf9c39f48 100644 --- a/contracts/node-families/schema/raw/response_to_get_all_past_invitations_paged.json +++ b/contracts/node-families/schema/raw/response_to_get_all_past_invitations_paged.json @@ -51,7 +51,7 @@ "additionalProperties": false, "definitions": { "FamilyInvitation": { - "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.", "type": "object", "required": [ "expires_at", @@ -81,7 +81,7 @@ "additionalProperties": false }, "FamilyInvitationStatus": { - "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).", + "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: an invitation that merely times out is **not** archived here on its own — it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only reaches `Expired` if the family issues a fresh invitation for the same node, which supersedes and archives the stale one.", "oneOf": [ { "description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.", @@ -178,11 +178,35 @@ } }, "additionalProperties": false + }, + { + "description": "The invitation had already expired and was superseded by a fresh invitation for the same node from the same family, issued at the given timestamp. This is the only path that archives a timed-out invitation.", + "type": "object", + "required": [ + "expired" + ], + "properties": { + "expired": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } ] }, "PastFamilyInvitation": { - "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.", + "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is archived here only when a fresh invitation for the same node supersedes it (status `Expired`); otherwise it stays in the pending map until explicitly cleared.", "type": "object", "required": [ "invitation", diff --git a/contracts/node-families/schema/raw/response_to_get_all_pending_invitations_paged.json b/contracts/node-families/schema/raw/response_to_get_all_pending_invitations_paged.json index 90bffdabc9..22c0020b3a 100644 --- a/contracts/node-families/schema/raw/response_to_get_all_pending_invitations_paged.json +++ b/contracts/node-families/schema/raw/response_to_get_all_pending_invitations_paged.json @@ -39,7 +39,7 @@ "additionalProperties": false, "definitions": { "FamilyInvitation": { - "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.", "type": "object", "required": [ "expires_at", diff --git a/contracts/node-families/schema/raw/response_to_get_past_invitations_for_family_paged.json b/contracts/node-families/schema/raw/response_to_get_past_invitations_for_family_paged.json index 15e2271e61..2560e79b25 100644 --- a/contracts/node-families/schema/raw/response_to_get_past_invitations_for_family_paged.json +++ b/contracts/node-families/schema/raw/response_to_get_past_invitations_for_family_paged.json @@ -46,7 +46,7 @@ "additionalProperties": false, "definitions": { "FamilyInvitation": { - "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.", "type": "object", "required": [ "expires_at", @@ -76,7 +76,7 @@ "additionalProperties": false }, "FamilyInvitationStatus": { - "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).", + "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: an invitation that merely times out is **not** archived here on its own — it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only reaches `Expired` if the family issues a fresh invitation for the same node, which supersedes and archives the stale one.", "oneOf": [ { "description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.", @@ -173,11 +173,35 @@ } }, "additionalProperties": false + }, + { + "description": "The invitation had already expired and was superseded by a fresh invitation for the same node from the same family, issued at the given timestamp. This is the only path that archives a timed-out invitation.", + "type": "object", + "required": [ + "expired" + ], + "properties": { + "expired": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } ] }, "PastFamilyInvitation": { - "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.", + "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is archived here only when a fresh invitation for the same node supersedes it (status `Expired`); otherwise it stays in the pending map until explicitly cleared.", "type": "object", "required": [ "invitation", diff --git a/contracts/node-families/schema/raw/response_to_get_past_invitations_for_node_paged.json b/contracts/node-families/schema/raw/response_to_get_past_invitations_for_node_paged.json index b438371287..4b8a746278 100644 --- a/contracts/node-families/schema/raw/response_to_get_past_invitations_for_node_paged.json +++ b/contracts/node-families/schema/raw/response_to_get_past_invitations_for_node_paged.json @@ -46,7 +46,7 @@ "additionalProperties": false, "definitions": { "FamilyInvitation": { - "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.", "type": "object", "required": [ "expires_at", @@ -76,7 +76,7 @@ "additionalProperties": false }, "FamilyInvitationStatus": { - "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).", + "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: an invitation that merely times out is **not** archived here on its own — it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only reaches `Expired` if the family issues a fresh invitation for the same node, which supersedes and archives the stale one.", "oneOf": [ { "description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.", @@ -173,11 +173,35 @@ } }, "additionalProperties": false + }, + { + "description": "The invitation had already expired and was superseded by a fresh invitation for the same node from the same family, issued at the given timestamp. This is the only path that archives a timed-out invitation.", + "type": "object", + "required": [ + "expired" + ], + "properties": { + "expired": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } ] }, "PastFamilyInvitation": { - "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.", + "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is archived here only when a fresh invitation for the same node supersedes it (status `Expired`); otherwise it stays in the pending map until explicitly cleared.", "type": "object", "required": [ "invitation", diff --git a/contracts/node-families/schema/raw/response_to_get_pending_invitation.json b/contracts/node-families/schema/raw/response_to_get_pending_invitation.json index c9ab63d67a..8d74fa0ac7 100644 --- a/contracts/node-families/schema/raw/response_to_get_pending_invitation.json +++ b/contracts/node-families/schema/raw/response_to_get_pending_invitation.json @@ -35,7 +35,7 @@ "additionalProperties": false, "definitions": { "FamilyInvitation": { - "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.", "type": "object", "required": [ "expires_at", diff --git a/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_family_paged.json b/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_family_paged.json index 9dc1b084d0..dd88959532 100644 --- a/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_family_paged.json +++ b/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_family_paged.json @@ -34,7 +34,7 @@ "additionalProperties": false, "definitions": { "FamilyInvitation": { - "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.", "type": "object", "required": [ "expires_at", diff --git a/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_node_paged.json b/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_node_paged.json index 6d59be893b..a0b68a6846 100644 --- a/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_node_paged.json +++ b/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_node_paged.json @@ -34,7 +34,7 @@ "additionalProperties": false, "definitions": { "FamilyInvitation": { - "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.", "type": "object", "required": [ "expires_at", diff --git a/contracts/node-families/src/storage/mod.rs b/contracts/node-families/src/storage/mod.rs index 371631bac9..0a06732e46 100644 --- a/contracts/node-families/src/storage/mod.rs +++ b/contracts/node-families/src/storage/mod.rs @@ -292,11 +292,17 @@ impl NodeFamiliesStorage<'_> { /// - 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 + /// `family_id` is unknown and with [`PendingInvitationAlreadyExists`] if a + /// *still-valid* pending invitation for the same `(family, node)` pair is + /// already stored — the underlying `IndexedMap` would otherwise silently /// overwrite it. /// + /// If a pending invitation for the pair exists but has already expired + /// (`now >= expires_at`), it is archived in [`Self::past_family_invitations`] + /// with status [`FamilyInvitationStatus::Expired`] and the fresh invitation + /// supersedes it. Together with an explicit revoke/reject, this is the only + /// path that clears a timed-out invitation out of the pending map. + /// /// Returns the freshly persisted [`FamilyInvitation`]. /// /// [`FamilyNotFound`]: NodeFamiliesContractError::FamilyNotFound @@ -304,25 +310,37 @@ impl NodeFamiliesStorage<'_> { pub(crate) fn add_pending_invitation( &self, store: &mut dyn Storage, + env: &Env, family_id: NodeFamilyId, node_id: NodeId, expires_at: u64, ) -> Result { + let now = env.block.time.seconds(); 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, - }); + if let Some(existing) = self.pending_family_invitations.may_load(store, key)? { + // a still-valid invitation blocks a duplicate; an expired one is + // archived and superseded by the fresh invitation below. + if now < existing.expires_at { + return Err(NodeFamiliesContractError::PendingInvitationAlreadyExists { + family_id, + node_id, + }); + } + + let counter = self.next_past_invitation_counter(store, key)?; + self.past_family_invitations.save( + store, + (key, counter), + &PastFamilyInvitation { + invitation: existing, + status: FamilyInvitationStatus::Expired { at: now }, + }, + )?; } let invitation = FamilyInvitation { @@ -914,10 +932,11 @@ mod tests { 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 env = tester.env(); + let expires_at = env.block.time.seconds() + 100; let inv = s - .add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at) + .add_pending_invitation(tester.storage_mut(), &env, f.id, 42, expires_at) .unwrap(); assert_eq!(inv.family_id, f.id); @@ -937,7 +956,7 @@ mod tests { 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); + let res = s.add_pending_invitation(tester.storage_mut(), &env, 99, 42, expires_at); assert_eq!( res.unwrap_err(), NodeFamiliesContractError::FamilyNotFound { family_id: 99 } @@ -955,7 +974,7 @@ mod tests { 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); + let res = s.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, expires_at); assert_eq!( res.unwrap_err(), NodeFamiliesContractError::PendingInvitationAlreadyExists { @@ -965,6 +984,47 @@ mod tests { ); } + #[test] + fn add_pending_invitation_supersedes_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); + + // first invitation expires at exactly `now`, so it is immediately stale + let stale_exp = env.block.time.seconds(); + s.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, stale_exp) + .unwrap(); + + // re-inviting the same node supersedes the expired invitation + let fresh_exp = env.block.time.seconds() + 100; + let fresh = s + .add_pending_invitation(tester.storage_mut(), &env, f.id, 42, fresh_exp) + .unwrap(); + assert_eq!(fresh.expires_at, fresh_exp); + + // the fresh invitation is the one left pending + let pending = s + .pending_family_invitations + .load(tester.storage(), (f.id, 42)) + .unwrap(); + assert_eq!(pending.expires_at, fresh_exp); + + // the stale one is archived as Expired, stamped at `now` + let past = s + .past_family_invitations + .load(tester.storage(), ((f.id, 42), 0)) + .unwrap(); + assert_eq!( + past.status, + FamilyInvitationStatus::Expired { + at: env.block.time.seconds() + } + ); + assert_eq!(past.invitation.expires_at, stale_exp); + } + // ---- accept_invitation ---- #[test] @@ -975,7 +1035,7 @@ mod tests { 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) + s.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, expires_at) .unwrap(); let updated = s @@ -1032,7 +1092,7 @@ mod tests { 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) + s.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, expires_at) .unwrap(); let res = s.accept_invitation(tester.storage_mut(), &env, f.id, 42); @@ -1087,7 +1147,7 @@ mod tests { 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) + s.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, expires_at) .unwrap(); s.reject_pending_invitation(tester.storage_mut(), &env, f.id, 42) @@ -1205,7 +1265,7 @@ mod tests { let expires_at = env.block.time.seconds() + 100; for _ in 0..2 { - s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at) + s.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, expires_at) .unwrap(); s.accept_invitation(tester.storage_mut(), &env, f.id, 42) .unwrap(); diff --git a/contracts/node-families/src/testing.rs b/contracts/node-families/src/testing.rs index d68c357d45..f173a4b5f7 100644 --- a/contracts/node-families/src/testing.rs +++ b/contracts/node-families/src/testing.rs @@ -168,8 +168,9 @@ pub trait NodeFamiliesContractTesterExt: node: NodeId, expiration: u64, ) -> FamilyInvitation { + let env = self.env(); NodeFamiliesStorage::new() - .add_pending_invitation(self, family, node, expiration) + .add_pending_invitation(self, &env, family, node, expiration) .unwrap() } diff --git a/contracts/node-families/src/transactions.rs b/contracts/node-families/src/transactions.rs index e18f84b3d1..f53c5747b6 100644 --- a/contracts/node-families/src/transactions.rs +++ b/contracts/node-families/src/transactions.rs @@ -254,7 +254,8 @@ pub(crate) fn try_invite_to_family( ensure_node_not_in_family(&storage, deps.as_ref(), node_id)?; let expires_at = env.block.time.seconds() + validity; - let invitation = storage.add_pending_invitation(deps.storage, owned.id, node_id, expires_at)?; + let invitation = + storage.add_pending_invitation(deps.storage, &env, owned.id, node_id, expires_at)?; Ok(Response::new().add_event( Event::new(events::FAMILY_INVITATION_EVENT_NAME) @@ -1311,6 +1312,8 @@ mod tests { use super::*; use crate::testing::NodeFamiliesContractTesterExt; use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; + use nym_contracts_common_testing::ChainOpts; + use nym_node_families_contract_common::FamilyInvitationStatus; #[test] fn happy_path_persists_pending_invitation() -> anyhow::Result<()> { @@ -1469,6 +1472,98 @@ mod tests { ); Ok(()) } + + #[test] + fn allows_reinvite_once_previous_invitation_has_expired() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + + // first invitation with a short, explicit validity + let first_env = tester.env(); + try_invite_to_family( + tester.deps_mut(), + first_env.clone(), + message_info(&alice, &[]), + node_id, + Some(5), + )?; + let first_expires_at = first_env.block.time.seconds() + 5; + + // let it lapse + tester.advance_time_by(10); + + // re-inviting the same node now succeeds and refreshes the expiry + let second_env = tester.env(); + try_invite_to_family( + tester.deps_mut(), + second_env.clone(), + message_info(&alice, &[]), + node_id, + Some(5), + )?; + + let storage = NodeFamiliesStorage::new(); + let pending = storage + .pending_family_invitations + .load(tester.deps().storage, (family.id, node_id))?; + assert_eq!(pending.expires_at, second_env.block.time.seconds() + 5); + + // the lapsed invitation was archived as Expired at the re-invite time + let archived = storage + .past_family_invitations + .load(tester.deps().storage, ((family.id, node_id), 0))?; + assert!(matches!( + archived.status, + FamilyInvitationStatus::Expired { at } if at == second_env.block.time.seconds() + )); + assert_eq!(archived.invitation.expires_at, first_expires_at); + Ok(()) + } + + #[test] + fn rejects_reinvite_while_previous_invitation_is_still_valid() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + + let env = tester.env(); + try_invite_to_family( + tester.deps_mut(), + env, + message_info(&alice, &[]), + node_id, + Some(100), + )?; + + // some time passes, but the invitation has not yet expired + tester.advance_time_by(10); + + let env = tester.env(); + let err = try_invite_to_family( + tester.deps_mut(), + env, + message_info(&alice, &[]), + node_id, + Some(100), + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::PendingInvitationAlreadyExists { + family_id: family.id, + node_id, + } + ); + // nothing was archived — the still-valid invitation stays pending + assert!(NodeFamiliesStorage::new() + .past_family_invitations + .may_load(tester.deps().storage, ((family.id, node_id), 0))? + .is_none()); + Ok(()) + } } mod revoke_family_invitation { diff --git a/openspec/specs/node-families-contract/spec.md b/openspec/specs/node-families-contract/spec.md index 1d7a0ff674..6316e62f8f 100644 --- a/openspec/specs/node-families-contract/spec.md +++ b/openspec/specs/node-families-contract/spec.md @@ -214,9 +214,10 @@ The `family_members` map SHALL be keyed by `NodeId` alone; the value SHALL carry - reject `validity == 0` with `ZeroInvitationValidity`; - verify `node_id` refers to a currently-bonded, not-unbonding node in the mixnet contract via `MixnetContractQuerier::check_node_existence` (which returns `false` both when no bond exists and when the bond is in the unbonding state), failing with `NodeDoesntExist { 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`) with `PendingInvitationAlreadyExists { 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 as `PastFamilyInvitation { 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 `FamilyInvitation` with `expires_at = env.block.time.seconds() + validity`; -- reject `(family_id, node_id)` pairs that already have a pending invitation with `PendingInvitationAlreadyExists { family_id, node_id }`; -- emit a `family_invitation` event with `family_id`, `node_id`, `expires_at`. +- emit a `family_invitation` event with `family_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 @@ -235,9 +236,13 @@ The `family_members` map SHALL be keyed by `NodeId` alone; the value SHALL carry - **WHEN** `InviteToFamily { node_id }` targets a `node_id` for which the mixnet contract's `check_node_existence` returns `false` - **THEN** the call fails with `NodeDoesntExist { node_id }` -#### Scenario: Duplicate pending invitation is rejected -- **WHEN** family `F` already has a pending invitation for node `n` and `InviteToFamily { node_id: n }` is sent again by `F`'s owner -- **THEN** the call fails with `PendingInvitationAlreadyExists { family_id: F.id, node_id: n }` (the existing invitation is preserved) +#### Scenario: Duplicate still-valid pending invitation is rejected +- **WHEN** family `F` already has a still-valid (not yet expired) pending invitation for node `n` and `InviteToFamily { node_id: n }` is sent again by `F`'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 `F` has a pending invitation for node `n` whose `expires_at <= env.block.time.seconds()` and `InviteToFamily { node_id: n }` is sent again by `F`'s owner +- **THEN** the call succeeds: the stale invitation is archived under `past_family_invitations` with `status = Expired { at: env.block.time.seconds() }`, and a fresh `FamilyInvitation` for `(F.id, n)` is persisted with the newly computed `expires_at` ### Requirement: Acceptance and rejection of an invitation are gated on node control @@ -288,7 +293,7 @@ A successful `AcceptFamilyInvitation` SHALL: - emit `family_invitation_rejected` or `family_invitation_revoked` respectively, with `family_id` and `node_id` attributes; - fail with `InvitationNotFound { family_id, node_id }` if no pending invitation exists. -These two paths SHALL be the only ways to clear an expired invitation out of `pending_family_invitations` — the contract performs no background sweep of expired entries. +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 @@ -361,7 +366,7 @@ The auto-cleared invitations share the `Rejected` terminal state with invitation ### 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, 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. +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 `F` has a pending invitation for node `n` whose `expires_at` is in the past and `GetPendingInvitationsForFamilyPaged { family_id: F.id }` is queried