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.
This commit is contained in:
@@ -61,9 +61,12 @@ pub struct NodeFamily {
|
|||||||
|
|
||||||
/// A pending invitation for a node to join a particular family.
|
/// A pending invitation for a node to join a particular family.
|
||||||
///
|
///
|
||||||
/// Invitations are stored until they are accepted, rejected, revoked, or until the
|
/// Invitations are stored until they are accepted, rejected, or revoked. Once the
|
||||||
/// chain advances past `expires_at` (in which case they remain in storage but are
|
/// chain advances past `expires_at` an invitation becomes inert but stays in storage
|
||||||
/// treated as inert — there is no background process clearing expired invitations).
|
/// — 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]
|
#[cw_serde]
|
||||||
pub struct FamilyInvitation {
|
pub struct FamilyInvitation {
|
||||||
/// The family that issued the invitation.
|
/// 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.
|
/// 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
|
/// Note: an invitation that merely times out is **not** archived here on its own —
|
||||||
/// the pending set (see `FamilyInvitation::expires_at`).
|
/// 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]
|
#[cw_serde]
|
||||||
pub enum FamilyInvitationStatus {
|
pub enum FamilyInvitationStatus {
|
||||||
/// Still awaiting a response. Recorded with a timestamp for completeness even
|
/// 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
|
/// The family revoked the invitation at the given timestamp before it could
|
||||||
/// be accepted or rejected.
|
/// be accepted or rejected.
|
||||||
Revoked { at: u64 },
|
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
|
/// Historical record of an invitation that has reached a terminal state
|
||||||
/// (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not**
|
/// (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is
|
||||||
/// archived here — they remain in the pending map until explicitly cleared.
|
/// 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]
|
#[cw_serde]
|
||||||
pub struct PastFamilyInvitation {
|
pub struct PastFamilyInvitation {
|
||||||
/// The original invitation as it was issued.
|
/// The original invitation as it was issued.
|
||||||
|
|||||||
@@ -1188,7 +1188,7 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"FamilyInvitation": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"expires_at",
|
"expires_at",
|
||||||
@@ -1218,7 +1218,7 @@
|
|||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"FamilyInvitationStatus": {
|
"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": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
"description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.",
|
"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
|
"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": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"invitation",
|
"invitation",
|
||||||
@@ -1388,7 +1412,7 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"FamilyInvitation": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"expires_at",
|
"expires_at",
|
||||||
@@ -2073,7 +2097,7 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"FamilyInvitation": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"expires_at",
|
"expires_at",
|
||||||
@@ -2103,7 +2127,7 @@
|
|||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"FamilyInvitationStatus": {
|
"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": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
"description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.",
|
"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
|
"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": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"invitation",
|
"invitation",
|
||||||
@@ -2280,7 +2328,7 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"FamilyInvitation": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"expires_at",
|
"expires_at",
|
||||||
@@ -2310,7 +2358,7 @@
|
|||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"FamilyInvitationStatus": {
|
"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": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
"description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.",
|
"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
|
"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": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"invitation",
|
"invitation",
|
||||||
@@ -2634,7 +2706,7 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"FamilyInvitation": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"expires_at",
|
"expires_at",
|
||||||
@@ -2724,7 +2796,7 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"FamilyInvitation": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"expires_at",
|
"expires_at",
|
||||||
@@ -2814,7 +2886,7 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"FamilyInvitation": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"expires_at",
|
"expires_at",
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"FamilyInvitation": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"expires_at",
|
"expires_at",
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"FamilyInvitationStatus": {
|
"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": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
"description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.",
|
"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
|
"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": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"invitation",
|
"invitation",
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"FamilyInvitation": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"expires_at",
|
"expires_at",
|
||||||
|
|||||||
+27
-3
@@ -46,7 +46,7 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"FamilyInvitation": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"expires_at",
|
"expires_at",
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"FamilyInvitationStatus": {
|
"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": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
"description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.",
|
"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
|
"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": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"invitation",
|
"invitation",
|
||||||
|
|||||||
+27
-3
@@ -46,7 +46,7 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"FamilyInvitation": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"expires_at",
|
"expires_at",
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"FamilyInvitationStatus": {
|
"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": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
"description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.",
|
"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
|
"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": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"invitation",
|
"invitation",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"FamilyInvitation": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"expires_at",
|
"expires_at",
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"FamilyInvitation": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"expires_at",
|
"expires_at",
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"FamilyInvitation": {
|
"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",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"expires_at",
|
"expires_at",
|
||||||
|
|||||||
@@ -292,11 +292,17 @@ impl NodeFamiliesStorage<'_> {
|
|||||||
/// - ensuring `expires_at` is strictly in the future.
|
/// - ensuring `expires_at` is strictly in the future.
|
||||||
///
|
///
|
||||||
/// As defence-in-depth, this method errors with [`FamilyNotFound`] if
|
/// As defence-in-depth, this method errors with [`FamilyNotFound`] if
|
||||||
/// `family_id` is unknown and with [`PendingInvitationAlreadyExists`] if
|
/// `family_id` is unknown and with [`PendingInvitationAlreadyExists`] if a
|
||||||
/// a pending invitation for the same `(family, node)` pair is already
|
/// *still-valid* pending invitation for the same `(family, node)` pair is
|
||||||
/// stored — the underlying `IndexedMap` would otherwise silently
|
/// already stored — the underlying `IndexedMap` would otherwise silently
|
||||||
/// overwrite it.
|
/// 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`].
|
/// Returns the freshly persisted [`FamilyInvitation`].
|
||||||
///
|
///
|
||||||
/// [`FamilyNotFound`]: NodeFamiliesContractError::FamilyNotFound
|
/// [`FamilyNotFound`]: NodeFamiliesContractError::FamilyNotFound
|
||||||
@@ -304,25 +310,37 @@ impl NodeFamiliesStorage<'_> {
|
|||||||
pub(crate) fn add_pending_invitation(
|
pub(crate) fn add_pending_invitation(
|
||||||
&self,
|
&self,
|
||||||
store: &mut dyn Storage,
|
store: &mut dyn Storage,
|
||||||
|
env: &Env,
|
||||||
family_id: NodeFamilyId,
|
family_id: NodeFamilyId,
|
||||||
node_id: NodeId,
|
node_id: NodeId,
|
||||||
expires_at: u64,
|
expires_at: u64,
|
||||||
) -> Result<FamilyInvitation, NodeFamiliesContractError> {
|
) -> Result<FamilyInvitation, NodeFamiliesContractError> {
|
||||||
|
let now = env.block.time.seconds();
|
||||||
let key: FamilyMember = (family_id, node_id);
|
let key: FamilyMember = (family_id, node_id);
|
||||||
|
|
||||||
if !self.families.has(store, family_id) {
|
if !self.families.has(store, family_id) {
|
||||||
return Err(NodeFamiliesContractError::FamilyNotFound { family_id });
|
return Err(NodeFamiliesContractError::FamilyNotFound { family_id });
|
||||||
}
|
}
|
||||||
|
|
||||||
if self
|
if let Some(existing) = self.pending_family_invitations.may_load(store, key)? {
|
||||||
.pending_family_invitations
|
// a still-valid invitation blocks a duplicate; an expired one is
|
||||||
.may_load(store, key)?
|
// archived and superseded by the fresh invitation below.
|
||||||
.is_some()
|
if now < existing.expires_at {
|
||||||
{
|
return Err(NodeFamiliesContractError::PendingInvitationAlreadyExists {
|
||||||
return Err(NodeFamiliesContractError::PendingInvitationAlreadyExists {
|
family_id,
|
||||||
family_id,
|
node_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 {
|
let invitation = FamilyInvitation {
|
||||||
@@ -914,10 +932,11 @@ mod tests {
|
|||||||
let s = NodeFamiliesStorage::new();
|
let s = NodeFamiliesStorage::new();
|
||||||
let alice = tester.addr_make("alice");
|
let alice = tester.addr_make("alice");
|
||||||
let f = tester.make_family(&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
|
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();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(inv.family_id, f.id);
|
assert_eq!(inv.family_id, f.id);
|
||||||
@@ -937,7 +956,7 @@ mod tests {
|
|||||||
let env = tester.env();
|
let env = tester.env();
|
||||||
let expires_at = env.block.time.seconds() + 100;
|
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!(
|
assert_eq!(
|
||||||
res.unwrap_err(),
|
res.unwrap_err(),
|
||||||
NodeFamiliesContractError::FamilyNotFound { family_id: 99 }
|
NodeFamiliesContractError::FamilyNotFound { family_id: 99 }
|
||||||
@@ -955,7 +974,7 @@ mod tests {
|
|||||||
tester.invite_to_family(f.id, 42);
|
tester.invite_to_family(f.id, 42);
|
||||||
|
|
||||||
let expires_at = env.block.time.seconds() + 200;
|
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!(
|
assert_eq!(
|
||||||
res.unwrap_err(),
|
res.unwrap_err(),
|
||||||
NodeFamiliesContractError::PendingInvitationAlreadyExists {
|
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 ----
|
// ---- accept_invitation ----
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -975,7 +1035,7 @@ mod tests {
|
|||||||
let alice = tester.addr_make("alice");
|
let alice = tester.addr_make("alice");
|
||||||
let f = tester.make_family(&alice);
|
let f = tester.make_family(&alice);
|
||||||
let expires_at = env.block.time.seconds() + 100;
|
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();
|
.unwrap();
|
||||||
|
|
||||||
let updated = s
|
let updated = s
|
||||||
@@ -1032,7 +1092,7 @@ mod tests {
|
|||||||
let f = tester.make_family(&alice);
|
let f = tester.make_family(&alice);
|
||||||
// expires at exactly `now` — `now >= expires_at` triggers
|
// expires at exactly `now` — `now >= expires_at` triggers
|
||||||
let expires_at = tester.env().block.time.seconds();
|
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();
|
.unwrap();
|
||||||
|
|
||||||
let res = s.accept_invitation(tester.storage_mut(), &env, f.id, 42);
|
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 alice = tester.addr_make("alice");
|
||||||
let f = tester.make_family(&alice);
|
let f = tester.make_family(&alice);
|
||||||
let expires_at = env.block.time.seconds();
|
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();
|
.unwrap();
|
||||||
|
|
||||||
s.reject_pending_invitation(tester.storage_mut(), &env, f.id, 42)
|
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;
|
let expires_at = env.block.time.seconds() + 100;
|
||||||
for _ in 0..2 {
|
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();
|
.unwrap();
|
||||||
s.accept_invitation(tester.storage_mut(), &env, f.id, 42)
|
s.accept_invitation(tester.storage_mut(), &env, f.id, 42)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -168,8 +168,9 @@ pub trait NodeFamiliesContractTesterExt:
|
|||||||
node: NodeId,
|
node: NodeId,
|
||||||
expiration: u64,
|
expiration: u64,
|
||||||
) -> FamilyInvitation {
|
) -> FamilyInvitation {
|
||||||
|
let env = self.env();
|
||||||
NodeFamiliesStorage::new()
|
NodeFamiliesStorage::new()
|
||||||
.add_pending_invitation(self, family, node, expiration)
|
.add_pending_invitation(self, &env, family, node, expiration)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -254,7 +254,8 @@ pub(crate) fn try_invite_to_family(
|
|||||||
ensure_node_not_in_family(&storage, deps.as_ref(), node_id)?;
|
ensure_node_not_in_family(&storage, deps.as_ref(), node_id)?;
|
||||||
|
|
||||||
let expires_at = env.block.time.seconds() + validity;
|
let 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(
|
Ok(Response::new().add_event(
|
||||||
Event::new(events::FAMILY_INVITATION_EVENT_NAME)
|
Event::new(events::FAMILY_INVITATION_EVENT_NAME)
|
||||||
@@ -1311,6 +1312,8 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::testing::NodeFamiliesContractTesterExt;
|
use crate::testing::NodeFamiliesContractTesterExt;
|
||||||
use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt;
|
use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt;
|
||||||
|
use nym_contracts_common_testing::ChainOpts;
|
||||||
|
use nym_node_families_contract_common::FamilyInvitationStatus;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn happy_path_persists_pending_invitation() -> anyhow::Result<()> {
|
fn happy_path_persists_pending_invitation() -> anyhow::Result<()> {
|
||||||
@@ -1469,6 +1472,98 @@ mod tests {
|
|||||||
);
|
);
|
||||||
Ok(())
|
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 {
|
mod revoke_family_invitation {
|
||||||
|
|||||||
@@ -214,9 +214,10 @@ The `family_members` map SHALL be keyed by `NodeId` alone; the value SHALL carry
|
|||||||
- reject `validity == 0` with `ZeroInvitationValidity`;
|
- 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 `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");
|
- 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`;
|
- 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` (the same event whether or not it superseded an expired invitation).
|
||||||
- emit a `family_invitation` event with `family_id`, `node_id`, `expires_at`.
|
|
||||||
|
|
||||||
#### Scenario: Successful invitation persists with the computed expiry
|
#### Scenario: Successful invitation persists with the computed expiry
|
||||||
- **WHEN** family owner sends `InviteToFamily { node_id, validity_secs: Some(v) }` and all preconditions hold
|
- **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`
|
- **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 }`
|
- **THEN** the call fails with `NodeDoesntExist { node_id }`
|
||||||
|
|
||||||
#### Scenario: Duplicate pending invitation is rejected
|
#### Scenario: Duplicate still-valid 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
|
- **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)
|
- **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
|
### 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;
|
- 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.
|
- 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
|
#### Scenario: Owner revokes a still-pending invitation
|
||||||
- **WHEN** family owner sends `RevokeFamilyInvitation { node_id }` for a node currently in their pending invitations
|
- **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
|
### 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
|
#### 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
|
- **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
|
||||||
|
|||||||
Reference in New Issue
Block a user