experiment: attempt to retroactively generate specs for node families and ecash contracts (#6813)
* experiment: add openspec details for node families contract
* add openspec for the ecash contract
* fix(ecash): correct latest_deposit off-by-one
DepositStorage::latest_deposit() returned the counter value, but the
counter holds the *next* free id (after next_id() saves counter+1). The
GetLatestDeposit handler then tried try_load_by_id(counter), which
always returned None — meaning the query yielded { deposit: None }
both on a fresh contract and after every successful deposit.
Fix: return counter.checked_sub(1) so latest_deposit() yields the most
recently assigned id (or None on a fresh contract). The
getting_latest_deposit unit test is updated to assert Some(0) and
Some(1) after one and two next_id() calls respectively.
No downstream consumer was relying on the buggy semantics
(validator-client exposes the query as a passthrough trait method that
nothing currently calls).
* experiment: add openspec details for ecash contract
Reverse-engineered openspec change `ecash-contract-spec` documenting
the existing CosmWasm contract at `contracts/ecash/`. Mirrors the
node-families workflow: docs-only deliverable, no migration, no
dependency changes. Archived as
openspec/changes/archive/2026-05-21-ecash-contract-spec/ and promoted
to openspec/specs/ecash-contract/spec.md as the canonical reference.
The spec captures 25 normative requirements with 64 scenarios covering
instantiation, migration, deposit submission (default + reduced tier),
RequestRedemption + redemption-proposal reply, legacy RedeemTickets
(dead code retained), stubbed blacklist surface, the ticketbook-size
invariant tripwire, the full query surface, and the public storage /
event / error surface.
Key documented points the source-of-truth phrasing pins down:
- The contract stores claimed ed25519 pubkeys opaquely; ownership is
enforced off-chain by nym-api signers via `validate_deposit`.
- Per-signer-local de-duplication via `state.already_issued`; no
on-chain "issued" state.
- Raw 32-byte deposit storage under the `"deposit"` namespace; deposit
ids are sequential `u32` starting at 0.
- Statistics invariant: default_count + sum(custom_count) = total.
- `cw_controllers::Admin` is used as a generic address-equality helper
for the `multisig` slot (the wrapper's full admin semantics are not
exercised on that slot).
- `RedeemTickets` is dead code retained on the public surface; flagged
as a candidate for removal.
Stubbed-blacklist final disposition is the only Open Question left for
the redesign change owner.
* docs(ecash): add rustdoc derived from archived ecash-contract spec
Drop short doc-comments on the ecash contract surface — handlers,
storage slots, message variants, error variants, event constants,
shared types — derived from the canonical spec at
openspec/specs/ecash-contract/spec.md (archived 2026-05-21).
Coverage:
- contracts/ecash/src/*.rs: crate-root summary, both DepositStorage
and DepositStatsStorage with their invariants called out, every
#[sv::msg(...)] handler in contract/mod.rs, reply id constants,
Config + invariants snapshot, migration entry point.
- common/cosmwasm-smart-contracts/ecash-contract/src/*.rs: every
ExecuteMsg / QueryMsg variant, every reachable EcashContractError
variant (with unreachable-but-preserved variants flagged), every
event constant, every response type, Deposit + DepositId.
Explicitly out of scope (separate concerns):
- Removing event_attributes::BANDWIDTH_PROPOSAL_ID (dead constant,
documented as such for now).
- Removing ExecuteMsg::RedeemTickets (dead handler, documented as such;
removal is a breaking-schema change).
- contracts/ecash/Cargo.toml version bump (docs-only).
No behaviour change; all 38 contract tests pass and cargo doc emits
no warnings on the touched crates.
This commit is contained in:
committed by
GitHub
parent
f2e379f10a
commit
d2833c76c0
@@ -1,8 +1,14 @@
|
||||
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Blacklist types. **The blacklist surface is stubbed today** - the execute
|
||||
//! handlers always return `UnimplementedBlacklisting` and the storage map is
|
||||
//! never populated. These types are kept for the planned redesign.
|
||||
|
||||
use cosmwasm_schema::cw_serde;
|
||||
|
||||
/// Public-key + metadata pair surfaced by `GetBlacklistedAccount` /
|
||||
/// `GetBlacklistPaged`. Always empty on a freshly deployed contract.
|
||||
#[cw_serde]
|
||||
pub struct BlacklistedAccount {
|
||||
pub public_key: String,
|
||||
@@ -15,6 +21,8 @@ impl From<(String, Blacklisting)> for BlacklistedAccount {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-key blacklist record: the multisig proposal that approved it and the
|
||||
/// block height at which finalisation landed (None until finalised).
|
||||
#[cw_serde]
|
||||
pub struct Blacklisting {
|
||||
pub proposal_id: u64,
|
||||
@@ -36,6 +44,8 @@ impl BlacklistedAccount {
|
||||
}
|
||||
}
|
||||
|
||||
/// Page of blacklist entries returned by `GetBlacklistPaged`. Always empty on
|
||||
/// a freshly deployed contract.
|
||||
#[cw_serde]
|
||||
pub struct PagedBlacklistedAccountResponse {
|
||||
pub accounts: Vec<BlacklistedAccount>,
|
||||
@@ -59,6 +69,8 @@ impl PagedBlacklistedAccountResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/// Response shape for `GetBlacklistedAccount`. `account` is `None` for any
|
||||
/// key not present in the (currently always-empty) blacklist.
|
||||
#[cw_serde]
|
||||
pub struct BlacklistedAccountResponse {
|
||||
pub account: Option<Blacklisting>,
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
use cosmwasm_schema::cw_serde;
|
||||
use cosmwasm_std::Coin;
|
||||
|
||||
/// Pool-level deposit accounting. Updated by every successful
|
||||
/// `DepositTicketBookFunds` and (for the unredeemed-tickets counter) by every
|
||||
/// successful legacy `RedeemTickets`.
|
||||
#[cw_serde]
|
||||
pub struct PoolCounters {
|
||||
/// Represents the total amount of funds deposited into the contract.
|
||||
|
||||
@@ -5,8 +5,13 @@ use crate::error::EcashContractError;
|
||||
use cosmwasm_schema::cw_serde;
|
||||
use cosmwasm_std::{StdError, StdResult};
|
||||
|
||||
/// Sequential identifier assigned to every accepted deposit. Starts at 0 and
|
||||
/// is never recycled.
|
||||
pub type DepositId = u32;
|
||||
|
||||
/// Opaque on-chain record of a deposit: the depositor-claimed bs58-encoded
|
||||
/// ed25519 identity public key. The contract does not verify control of the
|
||||
/// corresponding private key.
|
||||
#[cw_serde]
|
||||
pub struct Deposit {
|
||||
pub bs58_encoded_ed25519_pubkey: String,
|
||||
@@ -19,6 +24,8 @@ impl Deposit {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a bs58-encoded ed25519 public key to its 32-byte raw form.
|
||||
/// Surfaces `MalformedEd25519Identity` on any bs58 / length failure.
|
||||
pub fn get_ed25519_pubkey_bytes(raw: &str) -> Result<[u8; 32], EcashContractError> {
|
||||
let mut ed25519_pubkey_bytes = [0u8; 32];
|
||||
bs58::decode(raw)
|
||||
@@ -32,10 +39,13 @@ impl Deposit {
|
||||
bs58::encode(raw).into_string()
|
||||
}
|
||||
|
||||
/// Decode this deposit's identity key to its 32-byte raw form for storage.
|
||||
pub fn to_bytes(&self) -> Result<[u8; 32], EcashContractError> {
|
||||
Self::get_ed25519_pubkey_bytes(&self.bs58_encoded_ed25519_pubkey)
|
||||
}
|
||||
|
||||
/// Reconstruct a `Deposit` from a raw 32-byte ed25519 pubkey as stored
|
||||
/// under the `"deposit"` namespace.
|
||||
pub fn try_from_bytes(bytes: &[u8]) -> StdResult<Self> {
|
||||
if bytes.len() != 32 {
|
||||
return Err(StdError::generic_err("malformed deposit data"));
|
||||
@@ -47,12 +57,16 @@ impl Deposit {
|
||||
}
|
||||
}
|
||||
|
||||
/// Response shape for `GetLatestDeposit`. `deposit` is `None` on a freshly
|
||||
/// deployed contract.
|
||||
#[cw_serde]
|
||||
#[derive(Default)]
|
||||
pub struct LatestDepositResponse {
|
||||
pub deposit: Option<DepositData>,
|
||||
}
|
||||
|
||||
/// Response shape for `GetDeposit { deposit_id }`. `deposit` is `None` when
|
||||
/// the id has not yet been assigned (`id >= total_deposits_made`).
|
||||
#[cw_serde]
|
||||
pub struct DepositResponse {
|
||||
pub id: DepositId,
|
||||
@@ -60,6 +74,8 @@ pub struct DepositResponse {
|
||||
pub deposit: Option<Deposit>,
|
||||
}
|
||||
|
||||
/// `(deposit_id, deposit)` pair surfaced by the latest-deposit and paginated
|
||||
/// deposit queries.
|
||||
#[cw_serde]
|
||||
pub struct DepositData {
|
||||
pub id: DepositId,
|
||||
@@ -73,6 +89,8 @@ impl From<(DepositId, Deposit)> for DepositData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Page of deposits returned by `GetDepositsPaged`. `start_next_after` is the
|
||||
/// id of the last returned entry; pass it as the next call's `start_after`.
|
||||
#[cw_serde]
|
||||
pub struct PagedDepositsResponse {
|
||||
pub deposits: Vec<DepositData>,
|
||||
|
||||
@@ -6,69 +6,108 @@ use cw_controllers::AdminError;
|
||||
use cw_utils::PaymentError;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors surfaced by the ecash contract. Each reachable variant is named in at
|
||||
/// least one scenario of `openspec/specs/ecash-contract/spec.md`.
|
||||
#[derive(Error, Debug, PartialEq)]
|
||||
pub enum EcashContractError {
|
||||
/// Wrapper for any underlying `cosmwasm_std::StdError` (storage faults,
|
||||
/// address validation, etc.).
|
||||
#[error(transparent)]
|
||||
Std(#[from] StdError),
|
||||
|
||||
/// Raised by `cw_utils::must_pay` on `DepositTicketBookFunds` when funds
|
||||
/// are missing, multi-denom, or in the wrong denom. Inner variants
|
||||
/// `NoFunds`, `MultipleDenoms`, `MissingDenom` are all reachable.
|
||||
#[error("Invalid deposit")]
|
||||
InvalidDeposit(#[from] PaymentError),
|
||||
|
||||
/// `DepositTicketBookFunds` with the right denom but a non-matching amount.
|
||||
/// `amount` is the reduced amount (if the sender is whitelisted) or the
|
||||
/// default amount.
|
||||
#[error("received wrong amount for deposit. got: {received}. required: {amount}")]
|
||||
WrongAmount { received: Coin, amount: Coin },
|
||||
|
||||
/// **Unreachable** - preserved for forward compatibility (no current
|
||||
/// execute path triggers this).
|
||||
#[error("There aren't enough funds in the contract")]
|
||||
NotEnoughFunds,
|
||||
|
||||
/// Wrapper for `cw_controllers::AdminError`. Raised by every admin-gated
|
||||
/// and multisig-gated handler when the sender is wrong.
|
||||
#[error(transparent)]
|
||||
Admin(#[from] AdminError),
|
||||
|
||||
/// Redemption-proposal reply could not find a `proposal_id` attribute on
|
||||
/// the multisig `wasm` event.
|
||||
#[error("could not find proposal id inside the multisig reply SubMsg")]
|
||||
MissingProposalId,
|
||||
|
||||
// realistically this should NEVER be thrown
|
||||
/// Redemption-proposal reply found a `proposal_id` attribute that could
|
||||
/// not be parsed as `u64`. Realistically unreachable.
|
||||
#[error("the proposal id returned by the multisig contract could not be parsed into an u64")]
|
||||
MalformedProposalId,
|
||||
|
||||
/// Instantiation given a `group_addr` that failed bech32 validation.
|
||||
#[error("Group contract invalid address '{addr}'")]
|
||||
InvalidGroup { addr: String },
|
||||
|
||||
/// **Unreachable** - no current execute path triggers this.
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized,
|
||||
|
||||
/// **Unreachable** - preserved for future SemVer comparisons during migration.
|
||||
#[error("Failed to parse {value} into a valid SemVer version: {error_message}")]
|
||||
SemVerFailure {
|
||||
value: String,
|
||||
error_message: String,
|
||||
},
|
||||
|
||||
/// Reply dispatcher saw an `id` that does not match
|
||||
/// `BLACKLIST_PROPOSAL_REPLY_ID` or `REDEMPTION_PROPOSAL_REPLY_ID`.
|
||||
#[error("received an invalid reply id: {id}. it does not correspond to any sent SubMsg")]
|
||||
InvalidReplyId { id: u64 },
|
||||
|
||||
/// **Unreachable** - preserved for the (future) typed-deposit-info feature.
|
||||
#[error("reached the maximum of 255 different deposit types")]
|
||||
MaximumDepositTypesReached,
|
||||
|
||||
/// **Unreachable** - preserved for the (future) typed-deposit-info feature.
|
||||
#[error("compressed deposit info {typ} does not corresponds to any known type")]
|
||||
UnknownCompressedDepositInfoType { typ: u8 },
|
||||
|
||||
/// **Unreachable** - preserved for the (future) typed-deposit-info feature.
|
||||
#[error("deposit info {typ} does not corresponds to any previously seen type")]
|
||||
UnknownDepositInfoType { typ: String },
|
||||
|
||||
/// `DepositTicketBookFunds` with an `identity_key` that fails to bs58-decode
|
||||
/// to exactly 32 bytes. Raised inside `Deposit::to_bytes` during
|
||||
/// `save_deposit`.
|
||||
#[error("the provided ed25519 identity was malformed")]
|
||||
MalformedEd25519Identity,
|
||||
|
||||
/// `nym_network_defaults::TICKETBOOK_SIZE` has diverged from the value
|
||||
/// snapshotted at instantiation in `Item<Invariants>`. Tripwire for
|
||||
/// uncoordinated network-defaults bumps.
|
||||
#[error("the ticket book size has changed since the contract was created! This was not expected! It used to be {at_init} but it's {current} now! Please let the developers know ASAP!")]
|
||||
TicketBookSizeChanged { at_init: u64, current: u64 },
|
||||
|
||||
/// `RequestRedemption` with a `commitment_bs58` that does not decode to a
|
||||
/// 32-byte sha256 digest.
|
||||
#[error("the provided tickets redemption commitment is malformed")]
|
||||
MalformedRedemptionCommitment,
|
||||
|
||||
/// Always thrown by `ProposeToBlacklist` and `AddToBlacklist` until the
|
||||
/// blacklist redesign lands.
|
||||
#[error("the account blacklisting hasn't been fully implemented yet")]
|
||||
UnimplementedBlacklisting,
|
||||
|
||||
/// `SetReducedDepositPrice` (or migration whitelist seeding) given a coin
|
||||
/// whose denom does not match `Config::deposit_amount.denom`.
|
||||
#[error("reduced deposit must use the same denom as the default deposit (expected '{expected}', got '{got}')")]
|
||||
InvalidReducedDepositDenom { expected: String, got: String },
|
||||
|
||||
/// `SetReducedDepositPrice` (or migration whitelist seeding) given a
|
||||
/// reduced amount not strictly less than the current default.
|
||||
#[error(
|
||||
"reduced deposit amount ({reduced}) must be strictly less than the default ({default})"
|
||||
)]
|
||||
@@ -77,9 +116,13 @@ pub enum EcashContractError {
|
||||
default: cosmwasm_std::Uint128,
|
||||
},
|
||||
|
||||
/// `RemoveReducedDepositPrice` invoked for an address with no current
|
||||
/// reduced-deposit entry.
|
||||
#[error("address '{address}' does not have a custom reduced deposit price set")]
|
||||
NoReducedDepositPrice { address: String },
|
||||
|
||||
/// `UpdateDefaultDepositValue` or `SetReducedDepositPrice` given an amount
|
||||
/// below `nym_network_defaults::TICKETBOOK_SIZE`.
|
||||
#[error(
|
||||
"deposit amount ({amount}) must be at least the ticket book size ({ticket_book_size})"
|
||||
)]
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// Duplicate of `events::PROPOSAL_ID_ATTRIBUTE_NAME`. **Dead code**: not
|
||||
/// referenced anywhere in the workspace today; preserved here pending a
|
||||
/// follow-on cleanup. Use `events::PROPOSAL_ID_ATTRIBUTE_NAME` instead.
|
||||
pub const BANDWIDTH_PROPOSAL_ID: &str = "proposal_id";
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
// Copyright 2021 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// event types
|
||||
//! Event names and attribute keys emitted by the ecash contract. Renaming any
|
||||
//! of these is a breaking change for indexers and downstream tooling.
|
||||
|
||||
/// Event type emitted by every successful `DepositTicketBookFunds`. Carries a
|
||||
/// single `deposit-id` attribute with the assigned id as a decimal string.
|
||||
pub const DEPOSITED_FUNDS_EVENT_TYPE: &str = "deposited-funds";
|
||||
|
||||
/// Attribute key on the `deposited-funds` event: the newly assigned deposit id.
|
||||
pub const DEPOSIT_ID: &str = "deposit-id";
|
||||
|
||||
/// Name of the cosmwasm-std auto-generated event that carries handler
|
||||
/// attributes (`updated_deposit`, `action`, `address`, `deposit`,
|
||||
/// `proposal_id`).
|
||||
pub const WASM_EVENT_NAME: &str = "wasm";
|
||||
|
||||
/// Attribute key carrying the multisig-issued `proposal_id` on the `wasm`
|
||||
/// event from the redemption-proposal reply handler.
|
||||
pub const PROPOSAL_ID_ATTRIBUTE_NAME: &str = "proposal_id";
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Shared types, messages, events, and errors for the ecash contract.
|
||||
//!
|
||||
//! Consumed by both the contract crate (`contracts/ecash`) and any off-chain
|
||||
//! client (gateways, nym-api signers, indexers, validator-client). See
|
||||
//! `openspec/specs/ecash-contract/spec.md` for the normative interface.
|
||||
|
||||
pub mod blacklist;
|
||||
pub mod counters;
|
||||
pub mod deposit;
|
||||
|
||||
@@ -15,100 +15,134 @@ use crate::reduced_deposit::WhitelistedAccountsResponse;
|
||||
#[cfg(feature = "schema")]
|
||||
use cosmwasm_schema::QueryResponses;
|
||||
|
||||
/// Instantiation payload. The sender of the instantiate transaction becomes the
|
||||
/// contract admin; the three addresses are bech32-validated and persisted as
|
||||
/// immutable cross-contract pointers (see spec requirement "Contract instantiation").
|
||||
#[cw_serde]
|
||||
pub struct InstantiateMsg {
|
||||
/// Cosmos SDK address reserved for the future pool-contract transition.
|
||||
/// Stored in `Config` but never debited by the current contract.
|
||||
pub holding_account: String,
|
||||
|
||||
/// cw3 multisig contract that gates `RedeemTickets` and (in the redesign)
|
||||
/// blacklist proposals. Not updatable through any execute path.
|
||||
pub multisig_addr: String,
|
||||
|
||||
/// cw4 group contract referenced by the (stubbed) blacklist proposal flow.
|
||||
pub group_addr: String,
|
||||
|
||||
/// Default per-deposit price. The denom of this coin is the contract's
|
||||
/// canonical denom for the rest of its lifetime.
|
||||
pub deposit_amount: Coin,
|
||||
}
|
||||
|
||||
#[cw_serde]
|
||||
pub enum ExecuteMsg {
|
||||
/// Used by clients to request ticket books from the signers
|
||||
DepositTicketBookFunds {
|
||||
identity_key: String,
|
||||
},
|
||||
/// Submitted by clients to escrow funds and register a claimed ed25519
|
||||
/// identity key. Mints a sequential `deposit_id`. The contract does not
|
||||
/// verify control of the identity key - that proof is checked off-chain by
|
||||
/// nym-api signers at blind-sign time.
|
||||
DepositTicketBookFunds { identity_key: String },
|
||||
|
||||
/// Used by gateways to batch redeem tokens from the spent tickets
|
||||
/// Submitted by gateways to request batch redemption of spent tickets.
|
||||
/// Dispatches a `Propose` SubMsg to the multisig contract; the actual
|
||||
/// transfer effect is gated behind multisig approval.
|
||||
RequestRedemption {
|
||||
commitment_bs58: String,
|
||||
number_of_tickets: u16,
|
||||
},
|
||||
|
||||
/// The actual message that gets executed, after multisig votes, that transfers the ticket tokens into gateway's (and the holding) account
|
||||
RedeemTickets {
|
||||
n: u16,
|
||||
gw: String,
|
||||
},
|
||||
/// **Legacy / dead code.** Only callable by the multisig; bumps the
|
||||
/// unredeemed-tickets counter and emits a `ticket_redemption` event with
|
||||
/// `moved_to_holding_account = "false"`. No known consumer depends on the
|
||||
/// side effects; candidate for removal in a follow-on breaking-schema
|
||||
/// change.
|
||||
RedeemTickets { n: u16, gw: String },
|
||||
|
||||
UpdateAdmin {
|
||||
admin: String,
|
||||
},
|
||||
/// Transfer the contract admin role. Only the current admin may sign.
|
||||
/// Dispatches via the cw_controllers `execute_update_admin` handshake.
|
||||
UpdateAdmin { admin: String },
|
||||
|
||||
/// Overwrite `Config::deposit_amount`. Only callable by the contract admin.
|
||||
/// Rejects values below `nym_network_defaults::TICKETBOOK_SIZE` and trips
|
||||
/// `TicketBookSizeChanged` if the snapshotted invariant has diverged from
|
||||
/// the current crate constant.
|
||||
#[serde(alias = "update_deposit_value")]
|
||||
UpdateDefaultDepositValue {
|
||||
new_deposit: Coin,
|
||||
},
|
||||
UpdateDefaultDepositValue { new_deposit: Coin },
|
||||
|
||||
/// Set (or overwrite) a reduced deposit price for a specific address.
|
||||
/// Only callable by the contract admin.
|
||||
SetReducedDepositPrice {
|
||||
address: String,
|
||||
deposit: Coin,
|
||||
},
|
||||
SetReducedDepositPrice { address: String, deposit: Coin },
|
||||
|
||||
/// Remove the reduced deposit price for a specific address, reverting them to
|
||||
/// the default price. Returns an error if the address has no custom price set.
|
||||
/// Only callable by the contract admin.
|
||||
RemoveReducedDepositPrice {
|
||||
address: String,
|
||||
},
|
||||
RemoveReducedDepositPrice { address: String },
|
||||
|
||||
// TODO: properly implement
|
||||
ProposeToBlacklist {
|
||||
public_key: String,
|
||||
},
|
||||
AddToBlacklist {
|
||||
public_key: String,
|
||||
},
|
||||
/// **Stubbed**: always returns `EcashContractError::UnimplementedBlacklisting`.
|
||||
/// Storage, reply handler, and helper paths exist but are unreachable from
|
||||
/// the public ExecuteMsg surface. Preserved for the redesign.
|
||||
ProposeToBlacklist { public_key: String },
|
||||
|
||||
/// **Stubbed**: always returns `EcashContractError::UnimplementedBlacklisting`.
|
||||
AddToBlacklist { public_key: String },
|
||||
}
|
||||
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "schema", derive(QueryResponses))]
|
||||
pub enum QueryMsg {
|
||||
/// Look up a blacklist entry by its bs58-encoded ed25519 public key. Always
|
||||
/// returns `None` on a freshly deployed contract because the blacklist
|
||||
/// execute paths are stubbed.
|
||||
#[cfg_attr(feature = "schema", returns(BlacklistedAccountResponse))]
|
||||
GetBlacklistedAccount { public_key: String },
|
||||
|
||||
/// Paginated listing of blacklist entries. Always empty today (see stubbed
|
||||
/// blacklist surface). Defaults: limit 50, max 75.
|
||||
#[cfg_attr(feature = "schema", returns(PagedBlacklistedAccountResponse))]
|
||||
GetBlacklistPaged {
|
||||
limit: Option<u32>,
|
||||
start_after: Option<String>,
|
||||
},
|
||||
|
||||
/// Default per-deposit price (`Config::deposit_amount`). The
|
||||
/// `GetRequiredDepositAmount` aliases are kept for backwards compatibility.
|
||||
#[cfg_attr(feature = "schema", returns(Coin))]
|
||||
#[serde(alias = "get_required_deposit_amount")]
|
||||
#[serde(alias = "GetRequiredDepositAmount")]
|
||||
GetDefaultDepositAmount {},
|
||||
|
||||
/// Per-address reduced deposit price override, if any. `None` for any
|
||||
/// non-whitelisted address.
|
||||
#[cfg_attr(feature = "schema", returns(Option<Coin>))]
|
||||
GetReducedDepositAmount { address: String },
|
||||
|
||||
/// Enumerate every reduced-deposit whitelist entry in ascending address
|
||||
/// order. Unpaginated by design (the whitelist is expected to stay small).
|
||||
#[cfg_attr(feature = "schema", returns(WhitelistedAccountsResponse))]
|
||||
GetAllWhitelistedAccounts {},
|
||||
|
||||
/// Look up a deposit by id. Returns `{ id, deposit: None }` when the id has
|
||||
/// not yet been assigned.
|
||||
#[cfg_attr(feature = "schema", returns(DepositResponse))]
|
||||
GetDeposit { deposit_id: u32 },
|
||||
|
||||
/// Most recently assigned deposit (or `{ deposit: None }` on a fresh
|
||||
/// contract). See `DepositStorage::latest_deposit`.
|
||||
#[cfg_attr(feature = "schema", returns(LatestDepositResponse))]
|
||||
GetLatestDeposit {},
|
||||
|
||||
/// Paginated listing of deposits in ascending id order. Defaults: limit 50,
|
||||
/// max 100.
|
||||
#[cfg_attr(feature = "schema", returns(PagedDepositsResponse))]
|
||||
GetDepositsPaged {
|
||||
limit: Option<u32>,
|
||||
start_after: Option<u32>,
|
||||
},
|
||||
|
||||
/// Aggregate statistics: global totals + per-account custom-price
|
||||
/// breakdowns. Reassembled in a single read pass from `PoolCounters` and
|
||||
/// `DepositStatsStorage`.
|
||||
#[cfg_attr(feature = "schema", returns(DepositsStatistics))]
|
||||
GetDepositsStatistics {},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// Title used for the cw3 `Propose` message dispatched by `RequestRedemption`.
|
||||
/// nym-api signers cross-check this exact string when validating that an
|
||||
/// in-flight proposal originated from the ecash contract.
|
||||
// TODO: to be moved to multisig
|
||||
pub const BATCH_REDEMPTION_PROPOSAL_TITLE: &str = "ecash-redemption";
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
use cosmwasm_schema::cw_serde;
|
||||
use cosmwasm_std::{Addr, Coin};
|
||||
|
||||
/// Whitelist entry: an address and the reduced deposit price it may pay.
|
||||
/// Persisted in the `"reduced_deposits"` storage map.
|
||||
#[cw_serde]
|
||||
pub struct WhitelistedAccount {
|
||||
pub address: Addr,
|
||||
pub deposit: Coin,
|
||||
}
|
||||
|
||||
/// Response shape for `GetAllWhitelistedAccounts`. Unpaginated - the whitelist
|
||||
/// is expected to stay small.
|
||||
#[cw_serde]
|
||||
pub struct WhitelistedAccountsResponse {
|
||||
pub whitelisted_accounts: Vec<WhitelistedAccount>,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"instantiate": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "InstantiateMsg",
|
||||
"description": "Instantiation payload. The sender of the instantiate transaction becomes the contract admin; the three addresses are bech32-validated and persisted as immutable cross-contract pointers (see spec requirement \"Contract instantiation\").",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"deposit_amount",
|
||||
@@ -14,15 +15,23 @@
|
||||
],
|
||||
"properties": {
|
||||
"deposit_amount": {
|
||||
"description": "Default per-deposit price. The denom of this coin is the contract's canonical denom for the rest of its lifetime.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Coin"
|
||||
}
|
||||
]
|
||||
},
|
||||
"group_addr": {
|
||||
"description": "cw4 group contract referenced by the (stubbed) blacklist proposal flow.",
|
||||
"type": "string"
|
||||
},
|
||||
"holding_account": {
|
||||
"description": "Cosmos SDK address reserved for the future pool-contract transition. Stored in `Config` but never debited by the current contract.",
|
||||
"type": "string"
|
||||
},
|
||||
"multisig_addr": {
|
||||
"description": "cw3 multisig contract that gates `RedeemTickets` and (in the redesign) blacklist proposals. Not updatable through any execute path.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
@@ -55,7 +64,7 @@
|
||||
"title": "ExecuteMsg",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Used by clients to request ticket books from the signers",
|
||||
"description": "Submitted by clients to escrow funds and register a claimed ed25519 identity key. Mints a sequential `deposit_id`. The contract does not verify control of the identity key - that proof is checked off-chain by nym-api signers at blind-sign time.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"deposit_ticket_book_funds"
|
||||
@@ -77,7 +86,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Used by gateways to batch redeem tokens from the spent tickets",
|
||||
"description": "Submitted by gateways to request batch redemption of spent tickets. Dispatches a `Propose` SubMsg to the multisig contract; the actual transfer effect is gated behind multisig approval.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"request_redemption"
|
||||
@@ -105,7 +114,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "The actual message that gets executed, after multisig votes, that transfers the ticket tokens into gateway's (and the holding) account",
|
||||
"description": "**Legacy / dead code.** Only callable by the multisig; bumps the unredeemed-tickets counter and emits a `ticket_redemption` event with `moved_to_holding_account = \"false\"`. No known consumer depends on the side effects; candidate for removal in a follow-on breaking-schema change.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"redeem_tickets"
|
||||
@@ -133,6 +142,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Transfer the contract admin role. Only the current admin may sign. Dispatches via the cw_controllers `execute_update_admin` handshake.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"update_admin"
|
||||
@@ -154,6 +164,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Overwrite `Config::deposit_amount`. Only callable by the contract admin. Rejects values below `nym_network_defaults::TICKETBOOK_SIZE` and trips `TicketBookSizeChanged` if the snapshotted invariant has diverged from the current crate constant.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"update_default_deposit_value"
|
||||
@@ -223,6 +234,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "**Stubbed**: always returns `EcashContractError::UnimplementedBlacklisting`. Storage, reply handler, and helper paths exist but are unreachable from the public ExecuteMsg surface. Preserved for the redesign.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"propose_to_blacklist"
|
||||
@@ -244,6 +256,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "*Stubbed**: always returns `EcashContractError::UnimplementedBlacklisting`.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"add_to_blacklist"
|
||||
@@ -293,6 +306,7 @@
|
||||
"title": "QueryMsg",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Look up a blacklist entry by its bs58-encoded ed25519 public key. Always returns `None` on a freshly deployed contract because the blacklist execute paths are stubbed.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"get_blacklisted_account"
|
||||
@@ -314,6 +328,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Paginated listing of blacklist entries. Always empty today (see stubbed blacklist surface). Defaults: limit 50, max 75.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"get_blacklist_paged"
|
||||
@@ -343,6 +358,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Default per-deposit price (`Config::deposit_amount`). The `GetRequiredDepositAmount` aliases are kept for backwards compatibility.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"get_default_deposit_amount"
|
||||
@@ -356,6 +372,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Per-address reduced deposit price override, if any. `None` for any non-whitelisted address.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"get_reduced_deposit_amount"
|
||||
@@ -377,6 +394,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Enumerate every reduced-deposit whitelist entry in ascending address order. Unpaginated by design (the whitelist is expected to stay small).",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"get_all_whitelisted_accounts"
|
||||
@@ -390,6 +408,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Look up a deposit by id. Returns `{ id, deposit: None }` when the id has not yet been assigned.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"get_deposit"
|
||||
@@ -413,6 +432,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Most recently assigned deposit (or `{ deposit: None }` on a fresh contract). See `DepositStorage::latest_deposit`.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"get_latest_deposit"
|
||||
@@ -426,6 +446,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Paginated listing of deposits in ascending id order. Defaults: limit 50, max 100.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"get_deposits_paged"
|
||||
@@ -457,6 +478,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Aggregate statistics: global totals + per-account custom-price breakdowns. Reassembled in a single read pass from `PoolCounters` and `DepositStatsStorage`.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"get_deposits_statistics"
|
||||
@@ -533,6 +555,7 @@
|
||||
"get_all_whitelisted_accounts": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "WhitelistedAccountsResponse",
|
||||
"description": "Response shape for `GetAllWhitelistedAccounts`. Unpaginated - the whitelist is expected to stay small.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"whitelisted_accounts"
|
||||
@@ -572,6 +595,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"WhitelistedAccount": {
|
||||
"description": "Whitelist entry: an address and the reduced deposit price it may pay. Persisted in the `\"reduced_deposits\"` storage map.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"address",
|
||||
@@ -592,6 +616,7 @@
|
||||
"get_blacklist_paged": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "PagedBlacklistedAccountResponse",
|
||||
"description": "Page of blacklist entries returned by `GetBlacklistPaged`. Always empty on a freshly deployed contract.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"accounts",
|
||||
@@ -620,6 +645,7 @@
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"BlacklistedAccount": {
|
||||
"description": "Public-key + metadata pair surfaced by `GetBlacklistedAccount` / `GetBlacklistPaged`. Always empty on a freshly deployed contract.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"info",
|
||||
@@ -636,6 +662,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Blacklisting": {
|
||||
"description": "Per-key blacklist record: the multisig proposal that approved it and the block height at which finalisation landed (None until finalised).",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"proposal_id"
|
||||
@@ -662,6 +689,7 @@
|
||||
"get_blacklisted_account": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "BlacklistedAccountResponse",
|
||||
"description": "Response shape for `GetBlacklistedAccount`. `account` is `None` for any key not present in the (currently always-empty) blacklist.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"account": {
|
||||
@@ -678,6 +706,7 @@
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"Blacklisting": {
|
||||
"description": "Per-key blacklist record: the multisig proposal that approved it and the block height at which finalisation landed (None until finalised).",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"proposal_id"
|
||||
@@ -728,6 +757,7 @@
|
||||
"get_deposit": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "DepositResponse",
|
||||
"description": "Response shape for `GetDeposit { deposit_id }`. `deposit` is `None` when the id has not yet been assigned (`id >= total_deposits_made`).",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id"
|
||||
@@ -752,6 +782,7 @@
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"Deposit": {
|
||||
"description": "Opaque on-chain record of a deposit: the depositor-claimed bs58-encoded ed25519 identity public key. The contract does not verify control of the corresponding private key.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"bs58_encoded_ed25519_pubkey"
|
||||
@@ -768,6 +799,7 @@
|
||||
"get_deposits_paged": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "PagedDepositsResponse",
|
||||
"description": "Page of deposits returned by `GetDepositsPaged`. `start_next_after` is the id of the last returned entry; pass it as the next call's `start_after`.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"deposits"
|
||||
@@ -792,6 +824,7 @@
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"Deposit": {
|
||||
"description": "Opaque on-chain record of a deposit: the depositor-claimed bs58-encoded ed25519 identity public key. The contract does not verify control of the corresponding private key.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"bs58_encoded_ed25519_pubkey"
|
||||
@@ -804,6 +837,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DepositData": {
|
||||
"description": "`(deposit_id, deposit)` pair surfaced by the latest-deposit and paginated deposit queries.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"deposit",
|
||||
@@ -919,6 +953,7 @@
|
||||
"get_latest_deposit": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "LatestDepositResponse",
|
||||
"description": "Response shape for `GetLatestDeposit`. `deposit` is `None` on a freshly deployed contract.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deposit": {
|
||||
@@ -935,6 +970,7 @@
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"Deposit": {
|
||||
"description": "Opaque on-chain record of a deposit: the depositor-claimed bs58-encoded ed25519 identity public key. The contract does not verify control of the corresponding private key.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"bs58_encoded_ed25519_pubkey"
|
||||
@@ -947,6 +983,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DepositData": {
|
||||
"description": "`(deposit_id, deposit)` pair surfaced by the latest-deposit and paginated deposit queries.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"deposit",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"title": "ExecuteMsg",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Used by clients to request ticket books from the signers",
|
||||
"description": "Submitted by clients to escrow funds and register a claimed ed25519 identity key. Mints a sequential `deposit_id`. The contract does not verify control of the identity key - that proof is checked off-chain by nym-api signers at blind-sign time.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"deposit_ticket_book_funds"
|
||||
@@ -25,7 +25,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Used by gateways to batch redeem tokens from the spent tickets",
|
||||
"description": "Submitted by gateways to request batch redemption of spent tickets. Dispatches a `Propose` SubMsg to the multisig contract; the actual transfer effect is gated behind multisig approval.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"request_redemption"
|
||||
@@ -53,7 +53,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "The actual message that gets executed, after multisig votes, that transfers the ticket tokens into gateway's (and the holding) account",
|
||||
"description": "**Legacy / dead code.** Only callable by the multisig; bumps the unredeemed-tickets counter and emits a `ticket_redemption` event with `moved_to_holding_account = \"false\"`. No known consumer depends on the side effects; candidate for removal in a follow-on breaking-schema change.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"redeem_tickets"
|
||||
@@ -81,6 +81,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Transfer the contract admin role. Only the current admin may sign. Dispatches via the cw_controllers `execute_update_admin` handshake.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"update_admin"
|
||||
@@ -102,6 +103,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Overwrite `Config::deposit_amount`. Only callable by the contract admin. Rejects values below `nym_network_defaults::TICKETBOOK_SIZE` and trips `TicketBookSizeChanged` if the snapshotted invariant has diverged from the current crate constant.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"update_default_deposit_value"
|
||||
@@ -171,6 +173,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "**Stubbed**: always returns `EcashContractError::UnimplementedBlacklisting`. Storage, reply handler, and helper paths exist but are unreachable from the public ExecuteMsg surface. Preserved for the redesign.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"propose_to_blacklist"
|
||||
@@ -192,6 +195,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "*Stubbed**: always returns `EcashContractError::UnimplementedBlacklisting`.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"add_to_blacklist"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "InstantiateMsg",
|
||||
"description": "Instantiation payload. The sender of the instantiate transaction becomes the contract admin; the three addresses are bech32-validated and persisted as immutable cross-contract pointers (see spec requirement \"Contract instantiation\").",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"deposit_amount",
|
||||
@@ -10,15 +11,23 @@
|
||||
],
|
||||
"properties": {
|
||||
"deposit_amount": {
|
||||
"description": "Default per-deposit price. The denom of this coin is the contract's canonical denom for the rest of its lifetime.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Coin"
|
||||
}
|
||||
]
|
||||
},
|
||||
"group_addr": {
|
||||
"description": "cw4 group contract referenced by the (stubbed) blacklist proposal flow.",
|
||||
"type": "string"
|
||||
},
|
||||
"holding_account": {
|
||||
"description": "Cosmos SDK address reserved for the future pool-contract transition. Stored in `Config` but never debited by the current contract.",
|
||||
"type": "string"
|
||||
},
|
||||
"multisig_addr": {
|
||||
"description": "cw3 multisig contract that gates `RedeemTickets` and (in the redesign) blacklist proposals. Not updatable through any execute path.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"title": "QueryMsg",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Look up a blacklist entry by its bs58-encoded ed25519 public key. Always returns `None` on a freshly deployed contract because the blacklist execute paths are stubbed.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"get_blacklisted_account"
|
||||
@@ -24,6 +25,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Paginated listing of blacklist entries. Always empty today (see stubbed blacklist surface). Defaults: limit 50, max 75.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"get_blacklist_paged"
|
||||
@@ -53,6 +55,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Default per-deposit price (`Config::deposit_amount`). The `GetRequiredDepositAmount` aliases are kept for backwards compatibility.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"get_default_deposit_amount"
|
||||
@@ -66,6 +69,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Per-address reduced deposit price override, if any. `None` for any non-whitelisted address.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"get_reduced_deposit_amount"
|
||||
@@ -87,6 +91,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Enumerate every reduced-deposit whitelist entry in ascending address order. Unpaginated by design (the whitelist is expected to stay small).",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"get_all_whitelisted_accounts"
|
||||
@@ -100,6 +105,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Look up a deposit by id. Returns `{ id, deposit: None }` when the id has not yet been assigned.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"get_deposit"
|
||||
@@ -123,6 +129,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Most recently assigned deposit (or `{ deposit: None }` on a fresh contract). See `DepositStorage::latest_deposit`.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"get_latest_deposit"
|
||||
@@ -136,6 +143,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Paginated listing of deposits in ascending id order. Defaults: limit 50, max 100.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"get_deposits_paged"
|
||||
@@ -167,6 +175,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"description": "Aggregate statistics: global totals + per-account custom-price breakdowns. Reassembled in a single read pass from `PoolCounters` and `DepositStatsStorage`.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"get_deposits_statistics"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "WhitelistedAccountsResponse",
|
||||
"description": "Response shape for `GetAllWhitelistedAccounts`. Unpaginated - the whitelist is expected to stay small.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"whitelisted_accounts"
|
||||
@@ -40,6 +41,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"WhitelistedAccount": {
|
||||
"description": "Whitelist entry: an address and the reduced deposit price it may pay. Persisted in the `\"reduced_deposits\"` storage map.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"address",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "PagedBlacklistedAccountResponse",
|
||||
"description": "Page of blacklist entries returned by `GetBlacklistPaged`. Always empty on a freshly deployed contract.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"accounts",
|
||||
@@ -29,6 +30,7 @@
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"BlacklistedAccount": {
|
||||
"description": "Public-key + metadata pair surfaced by `GetBlacklistedAccount` / `GetBlacklistPaged`. Always empty on a freshly deployed contract.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"info",
|
||||
@@ -45,6 +47,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Blacklisting": {
|
||||
"description": "Per-key blacklist record: the multisig proposal that approved it and the block height at which finalisation landed (None until finalised).",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"proposal_id"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "BlacklistedAccountResponse",
|
||||
"description": "Response shape for `GetBlacklistedAccount`. `account` is `None` for any key not present in the (currently always-empty) blacklist.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"account": {
|
||||
@@ -17,6 +18,7 @@
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"Blacklisting": {
|
||||
"description": "Per-key blacklist record: the multisig proposal that approved it and the block height at which finalisation landed (None until finalised).",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"proposal_id"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "DepositResponse",
|
||||
"description": "Response shape for `GetDeposit { deposit_id }`. `deposit` is `None` when the id has not yet been assigned (`id >= total_deposits_made`).",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id"
|
||||
@@ -25,6 +26,7 @@
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"Deposit": {
|
||||
"description": "Opaque on-chain record of a deposit: the depositor-claimed bs58-encoded ed25519 identity public key. The contract does not verify control of the corresponding private key.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"bs58_encoded_ed25519_pubkey"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "PagedDepositsResponse",
|
||||
"description": "Page of deposits returned by `GetDepositsPaged`. `start_next_after` is the id of the last returned entry; pass it as the next call's `start_after`.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"deposits"
|
||||
@@ -25,6 +26,7 @@
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"Deposit": {
|
||||
"description": "Opaque on-chain record of a deposit: the depositor-claimed bs58-encoded ed25519 identity public key. The contract does not verify control of the corresponding private key.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"bs58_encoded_ed25519_pubkey"
|
||||
@@ -37,6 +39,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DepositData": {
|
||||
"description": "`(deposit_id, deposit)` pair surfaced by the latest-deposit and paginated deposit queries.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"deposit",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "LatestDepositResponse",
|
||||
"description": "Response shape for `GetLatestDeposit`. `deposit` is `None` on a freshly deployed contract.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deposit": {
|
||||
@@ -17,6 +18,7 @@
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"Deposit": {
|
||||
"description": "Opaque on-chain record of a deposit: the depositor-claimed bs58-encoded ed25519 identity public key. The contract does not verify control of the corresponding private key.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"bs58_encoded_ed25519_pubkey"
|
||||
@@ -29,6 +31,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DepositData": {
|
||||
"description": "`(deposit_id, deposit)` pair surfaced by the latest-deposit and paginated deposit queries.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"deposit",
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Reply-id constants for the contract's reply dispatcher. Both ids are part
|
||||
//! of the public contract surface - changing them between versions invalidates
|
||||
//! any in-flight submessage.
|
||||
|
||||
/// Reply id for the cw3 propose dispatched by the (stubbed) blacklist flow.
|
||||
/// Wired but unreachable from the public ExecuteMsg surface today.
|
||||
pub const BLACKLIST_PROPOSAL_REPLY_ID: u64 = 7759;
|
||||
|
||||
/// Reply id for the cw3 propose dispatched by `RequestRedemption`. The handler
|
||||
/// captures the multisig-issued `proposal_id` and re-exposes it as the
|
||||
/// response data.
|
||||
pub const REDEMPTION_PROPOSAL_REPLY_ID: u64 = 2137;
|
||||
|
||||
@@ -11,12 +11,21 @@ use nym_multisig_contract_common::msg::QueryMsg as MultisigQueryMsg;
|
||||
use nym_network_defaults::TICKETBOOK_SIZE;
|
||||
use sylvia::ctx::ExecCtx;
|
||||
|
||||
/// Snapshot of network-defaults values that the contract considers immutable
|
||||
/// over its lifetime. Persisted at the `"expected_invariants"` storage key on
|
||||
/// instantiation; every priced operation cross-checks the snapshot against
|
||||
/// the current crate constant.
|
||||
#[cw_serde]
|
||||
pub(crate) struct Invariants {
|
||||
/// `nym_network_defaults::TICKETBOOK_SIZE` at instantiation time. Mismatch
|
||||
/// against the live constant trips `TicketBookSizeChanged`.
|
||||
pub(crate) ticket_book_size: u64,
|
||||
}
|
||||
|
||||
impl NymEcashContract {
|
||||
/// Return `nym_network_defaults::TICKETBOOK_SIZE` if it matches the value
|
||||
/// snapshotted at instantiation; otherwise surface `TicketBookSizeChanged`
|
||||
/// so the caller halts before any state mutation.
|
||||
pub(crate) fn get_ticketbook_size(
|
||||
&self,
|
||||
storage: &dyn Storage,
|
||||
|
||||
@@ -76,6 +76,9 @@ impl NymEcashContract {
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot contract setup. Persists the cross-contract pointers, snapshots
|
||||
/// the ticketbook-size invariant, zero-initialises the pool counters and
|
||||
/// default-tier stats, and records the cw2 version + build metadata.
|
||||
#[sv::msg(instantiate)]
|
||||
pub fn instantiate(
|
||||
&self,
|
||||
@@ -144,6 +147,8 @@ impl NymEcashContract {
|
||||
/*==================
|
||||
======QUERIES=======
|
||||
==================*/
|
||||
/// Paginated listing of blacklist entries. Always empty today - see the
|
||||
/// stubbed blacklist requirement in the spec.
|
||||
#[sv::msg(query)]
|
||||
pub fn get_blacklist_paged(
|
||||
&self,
|
||||
@@ -175,6 +180,8 @@ impl NymEcashContract {
|
||||
))
|
||||
}
|
||||
|
||||
/// Single-key blacklist lookup. Always returns `None` on a freshly deployed
|
||||
/// contract because the blacklist execute surface is stubbed.
|
||||
#[sv::msg(query)]
|
||||
pub fn get_blacklisted_account(
|
||||
&self,
|
||||
@@ -185,6 +192,7 @@ impl NymEcashContract {
|
||||
Ok(BlacklistedAccountResponse::new(account))
|
||||
}
|
||||
|
||||
/// Default per-deposit price (`Config::deposit_amount`).
|
||||
#[sv::msg(query)]
|
||||
pub fn get_default_deposit_amount(&self, ctx: QueryCtx) -> StdResult<Coin> {
|
||||
let deposit_amount = self.config.load(ctx.deps.storage)?.deposit_amount;
|
||||
@@ -192,12 +200,16 @@ impl NymEcashContract {
|
||||
Ok(deposit_amount)
|
||||
}
|
||||
|
||||
/// Backwards-compatible alias for `get_default_deposit_amount`. Returns the
|
||||
/// same value; clients picking either name see identical behaviour.
|
||||
// Poor man's alias for backwards compatibility as sv::attr didn't seem to work
|
||||
#[sv::msg(query)]
|
||||
pub fn get_required_deposit_amount(&self, ctx: QueryCtx) -> StdResult<Coin> {
|
||||
self.get_default_deposit_amount(ctx)
|
||||
}
|
||||
|
||||
/// Per-address reduced deposit price override. `None` for any
|
||||
/// non-whitelisted sender.
|
||||
#[sv::msg(query)]
|
||||
pub fn get_reduced_deposit_amount(
|
||||
&self,
|
||||
@@ -210,6 +222,8 @@ impl NymEcashContract {
|
||||
Ok(deposit_amount)
|
||||
}
|
||||
|
||||
/// Enumerate every reduced-deposit whitelist entry. Unpaginated - the
|
||||
/// whitelist is expected to stay small.
|
||||
#[sv::msg(query)]
|
||||
pub fn get_all_whitelisted_accounts(
|
||||
&self,
|
||||
@@ -229,6 +243,8 @@ impl NymEcashContract {
|
||||
})
|
||||
}
|
||||
|
||||
/// Look up a deposit by id. Returns `{ id, deposit: None }` when the id
|
||||
/// has not yet been assigned.
|
||||
#[sv::msg(query)]
|
||||
pub fn get_deposit(
|
||||
&self,
|
||||
@@ -241,6 +257,8 @@ impl NymEcashContract {
|
||||
})
|
||||
}
|
||||
|
||||
/// Most recently assigned deposit, or `{ deposit: None }` on a fresh
|
||||
/// contract. See `DepositStorage::latest_deposit`.
|
||||
#[sv::msg(query)]
|
||||
pub fn get_latest_deposit(
|
||||
&self,
|
||||
@@ -260,6 +278,8 @@ impl NymEcashContract {
|
||||
})
|
||||
}
|
||||
|
||||
/// Paginated listing of deposits in ascending id order. Defaults to a
|
||||
/// limit of 50, clamped at 100.
|
||||
#[sv::msg(query)]
|
||||
pub fn get_deposits_paged(
|
||||
&self,
|
||||
@@ -288,6 +308,8 @@ impl NymEcashContract {
|
||||
})
|
||||
}
|
||||
|
||||
/// Aggregate statistics - global totals plus per-account custom-price
|
||||
/// breakdowns. Single read pass over `PoolCounters` and the stats storage.
|
||||
#[sv::msg(query)]
|
||||
pub fn get_deposits_statistics(
|
||||
&self,
|
||||
@@ -326,6 +348,9 @@ impl NymEcashContract {
|
||||
======EXECUTIONS=======
|
||||
=====================*/
|
||||
|
||||
/// Submit a deposit. Classifies the sent amount (default → reduced →
|
||||
/// `WrongAmount`), bumps the relevant counters, persists the deposit, and
|
||||
/// emits a `deposited-funds` event with the assigned id.
|
||||
#[sv::msg(exec)]
|
||||
pub fn deposit_ticket_book_funds(
|
||||
&self,
|
||||
@@ -390,6 +415,10 @@ impl NymEcashContract {
|
||||
.set_data(deposit_id.to_be_bytes()))
|
||||
}
|
||||
|
||||
/// Dispatch a multisig `Propose` SubMsg for batch ticket redemption.
|
||||
/// Validates `commitment_bs58` decodes to a 32-byte sha256 digest; the
|
||||
/// actual transfer happens (via the embedded `RedeemTickets`) only after
|
||||
/// the multisig approves.
|
||||
#[sv::msg(exec)]
|
||||
pub fn request_redemption(
|
||||
&self,
|
||||
@@ -409,8 +438,10 @@ impl NymEcashContract {
|
||||
Ok(Response::new().add_submessage(msg))
|
||||
}
|
||||
|
||||
/// Old legacy method for requesting ticket redemption by moving them into the holding accounts
|
||||
/// currently only executed by legacy gateways
|
||||
/// **Dead code.** Legacy multisig-gated redemption that bumps a counter
|
||||
/// and emits a `ticket_redemption` event with `moved_to_holding_account =
|
||||
/// "false"`. No known consumer depends on the side effects; candidate for
|
||||
/// removal in a follow-on breaking-schema change.
|
||||
#[sv::msg(exec)]
|
||||
pub fn redeem_tickets(
|
||||
&self,
|
||||
@@ -438,6 +469,10 @@ impl NymEcashContract {
|
||||
))
|
||||
}
|
||||
|
||||
/// Transfer the contract admin role. Dispatches via the cw_controllers
|
||||
/// `execute_update_admin` handshake; the sender-equality check happens
|
||||
/// inside that call. The handler always forwards `Some(new_admin)` -
|
||||
/// admin renunciation is not exposed.
|
||||
#[sv::msg(exec)]
|
||||
pub fn update_admin(
|
||||
&self,
|
||||
@@ -452,6 +487,10 @@ impl NymEcashContract {
|
||||
.execute_update_admin(ctx.deps, ctx.info, Some(new_admin))?)
|
||||
}
|
||||
|
||||
/// Overwrite `Config::deposit_amount`. Admin-gated; trips
|
||||
/// `TicketBookSizeChanged` if the snapshotted invariant diverged from the
|
||||
/// crate constant, and `DepositBelowTicketBookSize` if the new amount is
|
||||
/// below `TICKETBOOK_SIZE`.
|
||||
#[sv::msg(exec)]
|
||||
pub fn update_default_deposit_value(
|
||||
&self,
|
||||
@@ -479,6 +518,10 @@ impl NymEcashContract {
|
||||
Ok(Response::new().add_attribute("updated_deposit", deposit_str))
|
||||
}
|
||||
|
||||
/// Validate and persist a reduced-deposit entry. Shared between
|
||||
/// `SetReducedDepositPrice` and migration whitelist seeding; enforces
|
||||
/// matching denom, strictly-less-than-default amount, and amount at least
|
||||
/// the snapshotted ticketbook size.
|
||||
pub(crate) fn add_reduced_deposit_address(
|
||||
&self,
|
||||
deps: DepsMut,
|
||||
@@ -513,6 +556,8 @@ impl NymEcashContract {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set or overwrite the reduced-deposit price for a single address.
|
||||
/// Admin-gated; delegates validation to `add_reduced_deposit_address`.
|
||||
#[sv::msg(exec)]
|
||||
pub fn set_reduced_deposit_price(
|
||||
&self,
|
||||
@@ -534,7 +579,7 @@ impl NymEcashContract {
|
||||
|
||||
/// Removes the reduced deposit price for a given address, reverting them to
|
||||
/// the default deposit amount. This is safe to call even if the address has
|
||||
/// already deposited at the reduced price — their next deposit will simply
|
||||
/// already deposited at the reduced price - their next deposit will simply
|
||||
/// use the default price. Historical deposit statistics are not affected.
|
||||
#[sv::msg(exec)]
|
||||
pub fn remove_reduced_deposit_price(
|
||||
@@ -558,6 +603,9 @@ impl NymEcashContract {
|
||||
.add_attribute("address", address))
|
||||
}
|
||||
|
||||
/// **Stubbed.** Always returns `UnimplementedBlacklisting`. The
|
||||
/// commented-out body shows the intended group-gated propose flow for the
|
||||
/// blacklist redesign.
|
||||
#[sv::msg(exec)]
|
||||
pub fn propose_to_blacklist(
|
||||
&self,
|
||||
@@ -587,6 +635,9 @@ impl NymEcashContract {
|
||||
// }
|
||||
}
|
||||
|
||||
/// **Stubbed.** Always returns `UnimplementedBlacklisting`. The
|
||||
/// commented-out body shows the intended multisig-gated finalisation for
|
||||
/// the blacklist redesign.
|
||||
#[sv::msg(exec)]
|
||||
pub fn add_to_blacklist(
|
||||
&self,
|
||||
@@ -611,6 +662,9 @@ impl NymEcashContract {
|
||||
/*=====================
|
||||
=========REPLY=========
|
||||
=====================*/
|
||||
/// Dispatch reply messages by id. Surfaces `InvalidReplyId` for any id
|
||||
/// not matching `BLACKLIST_PROPOSAL_REPLY_ID` or
|
||||
/// `REDEMPTION_PROPOSAL_REPLY_ID`.
|
||||
#[sv::msg(reply)]
|
||||
#[allow(deprecated)]
|
||||
pub fn reply(
|
||||
@@ -627,6 +681,9 @@ impl NymEcashContract {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reply handler for the (currently dead) blacklist propose flow.
|
||||
/// Reachable from the dispatcher in theory but no public ExecuteMsg path
|
||||
/// dispatches a SubMsg with `BLACKLIST_PROPOSAL_REPLY_ID` today.
|
||||
#[allow(deprecated)]
|
||||
fn handle_blacklist_proposal_reply(
|
||||
&self,
|
||||
@@ -647,6 +704,9 @@ impl NymEcashContract {
|
||||
Ok(Response::new().add_attribute(PROPOSAL_ID_ATTRIBUTE_NAME, proposal_id.to_string()))
|
||||
}
|
||||
|
||||
/// Reply handler for the redemption propose flow. Captures the multisig
|
||||
/// `proposal_id` from the SubMsg result and re-exposes it as the response
|
||||
/// `set_data` payload (big-endian `u64`).
|
||||
#[allow(deprecated)]
|
||||
fn handle_redemption_proposal_reply(
|
||||
&self,
|
||||
@@ -664,6 +724,10 @@ impl NymEcashContract {
|
||||
/*=====================
|
||||
=======MIGRATION=======
|
||||
=====================*/
|
||||
/// Migration entry point. Refreshes build metadata, gates against
|
||||
/// downgrades via `cw2::ensure_from_older_version`, and runs
|
||||
/// `add_tiered_pricing` to backfill the default-tier stats and seed the
|
||||
/// whitelist atomically.
|
||||
#[sv::msg(migrate)]
|
||||
pub fn migrate(
|
||||
&self,
|
||||
|
||||
@@ -6,6 +6,11 @@ use cosmwasm_std::DepsMut;
|
||||
use nym_ecash_contract_common::msg::WhitelistedDeposit;
|
||||
use nym_ecash_contract_common::EcashContractError;
|
||||
|
||||
/// One-way migration that introduces tiered pricing. Backfills the
|
||||
/// default-tier stats accumulators from the pre-migration totals (since every
|
||||
/// pre-migration deposit was at the single default price) and seeds the
|
||||
/// whitelist. Re-running on already-migrated state would clobber the default
|
||||
/// counters with figures that include custom-price deposits.
|
||||
pub fn add_tiered_pricing(
|
||||
mut deps: DepsMut,
|
||||
initial_whitelist: Vec<WhitelistedDeposit>,
|
||||
@@ -101,7 +106,7 @@ mod tests {
|
||||
fn migration_with_no_prior_deposits_initialises_stats_to_zero() {
|
||||
let mut deps = mock_dependencies();
|
||||
|
||||
// No deposit_id_counter saved — contract never had a deposit.
|
||||
// No deposit_id_counter saved - contract never had a deposit.
|
||||
save_pool_counters(deps.as_mut().storage, 0);
|
||||
|
||||
add_tiered_pricing(deps.as_mut(), vec![]).unwrap();
|
||||
@@ -249,7 +254,7 @@ mod tests {
|
||||
save_pool_counters(deps.as_mut().storage, 0);
|
||||
save_config_and_invariants(&mut deps);
|
||||
|
||||
// Equal to default — should fail
|
||||
// Equal to default - should fail
|
||||
let whitelist = vec![WhitelistedDeposit {
|
||||
address: deps.api.addr_make("alice").to_string(),
|
||||
deposit: coin(DEFAULT_DEPOSIT, DENOM),
|
||||
@@ -264,7 +269,7 @@ mod tests {
|
||||
}
|
||||
);
|
||||
|
||||
// Greater than default — should also fail
|
||||
// Greater than default - should also fail
|
||||
let whitelist = vec![WhitelistedDeposit {
|
||||
address: deps.api.addr_make("alice").to_string(),
|
||||
deposit: coin(DEFAULT_DEPOSIT + 1, DENOM),
|
||||
|
||||
@@ -475,7 +475,7 @@ mod tests {
|
||||
"GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK".to_string(),
|
||||
)?;
|
||||
|
||||
// alice deposits at the default price — should be treated as a normal deposit
|
||||
// alice deposits at the default price - should be treated as a normal deposit
|
||||
let alice_info = message_info(&alice, &[coin(75_000_000, TEST_DENOM)]);
|
||||
CONTRACT.deposit_ticket_book_funds(
|
||||
test.exec_ctx(alice_info),
|
||||
|
||||
@@ -7,6 +7,10 @@ use nym_ecash_contract_common::deposit::DepositId;
|
||||
use nym_ecash_contract_common::{deposit::Deposit, EcashContractError};
|
||||
use std::ops::Deref;
|
||||
|
||||
/// Sequential-id-keyed deposit store. Deposits live under the `"deposit"`
|
||||
/// raw-bytes namespace (32-byte ed25519 pubkeys, not a `Map`); the
|
||||
/// `"deposit_ids"` `Item<u32>` holds the **next** free id, which after `N`
|
||||
/// deposits equals `N`.
|
||||
pub(crate) struct DepositStorage {
|
||||
pub(crate) deposit_id_counter: Item<DepositId>,
|
||||
pub(crate) deposits: StoredDeposits,
|
||||
@@ -20,13 +24,17 @@ impl DepositStorage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the id of the most recently assigned deposit, or `None` if no deposit has ever been made.
|
||||
///
|
||||
/// The counter stores the *next* available id, so the latest assigned id is `counter - 1`.
|
||||
pub fn latest_deposit(
|
||||
&self,
|
||||
storage: &dyn Storage,
|
||||
) -> Result<Option<DepositId>, EcashContractError> {
|
||||
self.deposit_id_counter
|
||||
.may_load(storage)
|
||||
.map_err(Into::into)
|
||||
let Some(counter) = self.deposit_id_counter.may_load(storage)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(counter.checked_sub(1))
|
||||
}
|
||||
|
||||
/// Returns the total number of deposits ever made.
|
||||
@@ -44,6 +52,10 @@ impl DepositStorage {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Assign the next sequential id, persist the deposit's 32-byte raw ed25519
|
||||
/// pubkey under the `"deposit"` namespace, and return the new id. Surfaces
|
||||
/// `MalformedEd25519Identity` when the supplied bs58 string does not
|
||||
/// decode to exactly 32 bytes.
|
||||
pub fn save_deposit(
|
||||
&self,
|
||||
storage: &mut dyn Storage,
|
||||
@@ -61,6 +73,8 @@ impl DepositStorage {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Load the deposit at `id`. Returns `None` when the id has not yet been
|
||||
/// assigned.
|
||||
pub fn try_load_by_id(
|
||||
&self,
|
||||
storage: &dyn Storage,
|
||||
@@ -94,7 +108,9 @@ impl DepositStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// a helper structure for storing deposits to bypass json serialisation and use more efficient and compact representation
|
||||
/// Raw-bytes reader/writer for the `"deposit"` storage namespace. Bypasses
|
||||
/// `cw_storage_plus::Map` to store deposits as exactly 32 raw bytes per
|
||||
/// entry (vs. ~44 bytes for the JSON-serialised bs58 representation).
|
||||
pub(crate) struct StoredDeposits;
|
||||
|
||||
impl StoredDeposits {
|
||||
@@ -138,14 +154,14 @@ mod tests {
|
||||
|
||||
let _ = storage.next_id(deps.as_mut().storage)?;
|
||||
|
||||
// is correctly incremented for each subsequent id
|
||||
// first deposit got id 0; latest_deposit returns Some(0)
|
||||
let second = storage.latest_deposit(deps.as_ref().storage)?;
|
||||
assert_eq!(second, Some(1));
|
||||
assert_eq!(second, Some(0));
|
||||
|
||||
let _ = storage.next_id(deps.as_mut().storage)?;
|
||||
|
||||
let third = storage.latest_deposit(deps.as_ref().storage)?;
|
||||
assert_eq!(third, Some(2));
|
||||
assert_eq!(third, Some(1));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ use cw_storage_plus::{Item, Map};
|
||||
use nym_ecash_contract_common::EcashContractError;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Tier-stratified deposit accounting. Maintains the invariant
|
||||
/// `default_count + sum(custom_count_per_account) == total_deposits_made`.
|
||||
/// Any code that writes to the `"deposit"` namespace MUST go through
|
||||
/// `new_default_deposit` / `new_reduced_deposit` or it breaks the invariant.
|
||||
pub(crate) struct DepositStatsStorage {
|
||||
/// Total deposits performed with the default price
|
||||
pub(crate) deposits_with_default_price: Item<u32>,
|
||||
@@ -30,6 +34,9 @@ impl DepositStatsStorage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bump the global default-price counter + amount accumulator. Called by
|
||||
/// `DepositTicketBookFunds` whenever the sender paid the default amount
|
||||
/// (regardless of whether they are also whitelisted for a reduced price).
|
||||
pub(crate) fn new_default_deposit(
|
||||
&self,
|
||||
store: &mut dyn Storage,
|
||||
@@ -47,6 +54,9 @@ impl DepositStatsStorage {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bump the per-account custom-price counter + amount accumulator. Called
|
||||
/// by `DepositTicketBookFunds` when a whitelisted sender paid their
|
||||
/// configured reduced amount.
|
||||
pub(crate) fn new_reduced_deposit(
|
||||
&self,
|
||||
store: &mut dyn Storage,
|
||||
|
||||
@@ -18,28 +18,43 @@ use serde::{Deserialize, Serialize};
|
||||
pub(crate) const CONTRACT_NAME: &str = "crate:nym-ecash";
|
||||
pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Runtime configuration persisted at the `"config"` storage key. Set on
|
||||
/// instantiation; the only field mutable through an execute path is
|
||||
/// `deposit_amount` (via `UpdateDefaultDepositValue`).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Config {
|
||||
/// cw4 group contract referenced by the (stubbed) blacklist proposal flow.
|
||||
pub group_addr: Cw4Contract,
|
||||
|
||||
/// Cosmos SDK address reserved for the future pool-contract transition.
|
||||
/// Currently never debited.
|
||||
pub holding_account: Addr,
|
||||
|
||||
/// Specifies the expected default deposit amount if the sender is not in the whitelisted set.
|
||||
/// Default per-deposit price. Whitelisted senders may pay this *or* their
|
||||
/// per-account reduced amount; everyone else must pay this exact value.
|
||||
#[serde(alias = "default_deposit_amount")]
|
||||
pub deposit_amount: Coin,
|
||||
}
|
||||
|
||||
//type aliases for easier reasoning
|
||||
/// Blacklist storage key - a bs58-encoded ed25519 public key.
|
||||
pub(crate) type BlacklistKey = String;
|
||||
|
||||
/// Multisig-issued `proposal_id` returned via the reply pipeline.
|
||||
pub(crate) type ProposalId = u64;
|
||||
|
||||
// paged retrieval limits for all blacklist queries and transactions
|
||||
/// Hard upper bound on the `limit` accepted by paginated blacklist queries.
|
||||
pub(crate) const BLACKLIST_PAGE_MAX_LIMIT: u32 = 75;
|
||||
/// Default `limit` for paginated blacklist queries when the caller passes None.
|
||||
pub(crate) const BLACKLIST_PAGE_DEFAULT_LIMIT: u32 = 50;
|
||||
|
||||
// paged retrieval limits for all deposit queries and transactions
|
||||
/// Hard upper bound on the `limit` accepted by paginated deposit queries.
|
||||
pub(crate) const DEPOSITS_PAGE_MAX_LIMIT: u32 = 100;
|
||||
/// Default `limit` for paginated deposit queries when the caller passes None.
|
||||
pub(crate) const DEPOSITS_PAGE_DEFAULT_LIMIT: u32 = 50;
|
||||
|
||||
/// Build the cw3 `Propose` SubMsg dispatched by `RequestRedemption`. The
|
||||
/// embedded message is a self-targeted `RedeemTickets` call that the multisig
|
||||
/// will execute once the proposal passes.
|
||||
pub(crate) fn create_batch_redemption_proposal(
|
||||
tickets_digest: String,
|
||||
gw: String,
|
||||
@@ -70,6 +85,8 @@ pub(crate) fn create_batch_redemption_proposal(
|
||||
Ok(submsg)
|
||||
}
|
||||
|
||||
/// Build the cw3 `Propose` SubMsg for the blacklist flow. **Dead path**: not
|
||||
/// reachable from any public ExecuteMsg today; preserved for the redesign.
|
||||
pub(crate) fn create_blacklist_proposal(
|
||||
public_key: String,
|
||||
ecash_bandwidth_address: String,
|
||||
@@ -100,6 +117,9 @@ pub(crate) fn create_blacklist_proposal(
|
||||
Ok(submsg)
|
||||
}
|
||||
|
||||
/// Extract the multisig-issued `proposal_id` from a cw3 `Propose` reply.
|
||||
/// Surfaces `MissingProposalId` / `MalformedProposalId` for the typed-failure
|
||||
/// cases the reply handler distinguishes.
|
||||
pub(crate) trait MultisigReply {
|
||||
fn multisig_proposal_id(&self) -> Result<u64, EcashContractError>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! On-chain anchor of the ticketbook credential pipeline.
|
||||
//!
|
||||
//! Clients escrow funds via `ExecuteMsg::DepositTicketBookFunds`, which mints a
|
||||
//! sequential `deposit_id` and persists the depositor-claimed ed25519 identity
|
||||
//! key for off-chain nym-api signers to read at blind-sign time. The contract
|
||||
//! does **not** verify control of the ed25519 key - that proof is enforced
|
||||
//! off-chain by `nym-api/src/ecash/deposit.rs::validate_deposit`.
|
||||
//!
|
||||
//! See `openspec/specs/ecash-contract/spec.md` for the normative interface.
|
||||
|
||||
mod constants;
|
||||
pub mod contract;
|
||||
mod deposit;
|
||||
|
||||
@@ -211,7 +211,7 @@ fn reduced_price_deposit_end_to_end() {
|
||||
.call(&whitelisted)
|
||||
.unwrap();
|
||||
|
||||
// whitelisted address can also deposit at the default price —
|
||||
// whitelisted address can also deposit at the default price -
|
||||
// treated as a normal (non-reduced) deposit for statistics purposes
|
||||
contract
|
||||
.deposit_ticket_book_funds(vk.to_string())
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-20
|
||||
@@ -0,0 +1,3 @@
|
||||
# node-families-contract-spec
|
||||
|
||||
Reverse-engineer specifications for the existing node-families CosmWasm contract
|
||||
@@ -0,0 +1,149 @@
|
||||
## Context
|
||||
|
||||
The node-families CosmWasm contract is the on-chain authority for declaring that two or more Nym nodes belong to the same operator. Route-selection code is expected to consult this data so that the entry and exit gateways of a single NymVPN connection cannot both be in the same family — closing one of the two surveillance windows called out in the proposal (the other being same-subnet detection, which is out of scope here).
|
||||
|
||||
The contract is implemented in `contracts/node-families/` with the shared message/type surface in `common/cosmwasm-smart-contracts/node-families-contract/`. The entire feature landed in a single commit (`a21a01cf1a`, "node families (#6715)") on 2026-05-19. No previous CosmWasm contract in the workspace handled families; the design is a fresh build with no migration baseline. The implementation has shipped behind a network-defaults wiring (`NODE_FAMILIES_CONTRACT_ADDRESS` env var, mainnet defaults, sandbox wallet types) — it is live, not aspirational.
|
||||
|
||||
This document captures the architectural choices behind the contract as it exists today, so reviewers, integrators (nym-api caches, node-status-api indexers), and future maintainers have a single normative reference. There is no behaviour change being proposed.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Capture the trust boundary between the node-families contract and the mixnet contract — which checks each side owns and what each side relies on the other for.
|
||||
- Pin down the data model: storage maps, secondary indexes, and the rationale for the composite archive keys.
|
||||
- Pin down the invariants the contract guarantees externally (one family per owner, one family per node, unique normalised family names, monotonic family ids).
|
||||
- Pin down the cross-contract callback flow that handles node unbonding.
|
||||
- Document the policy choices around invitation expiry (no background sweeper, expired entries are inert).
|
||||
- Document the storage-key and event constants as part of the public contract surface, since indexers consume them.
|
||||
|
||||
**Non-Goals:**
|
||||
- Route-selection policy on the client or nym-api side. The contract is a data source; the consumers decide what to do with the data.
|
||||
- Operator verification ("verified families"). The contract has no notion of vetting beyond ownership of a bonded node.
|
||||
- Geographic / subnet-distinctness checks. These live entirely outside the contract.
|
||||
- Sybil resistance. The proposal explicitly accepts that a malicious operator can refuse to declare a family — the contract does not try to detect this.
|
||||
- Cryptographic family-key signing à la Tor. The Nym design substitutes chain-level identity (bond control via the mixnet contract) for Tor's per-family key signature — see Decision 1.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: Authority is delegated to the mixnet contract; there is no family key
|
||||
|
||||
**Choice.** Membership and invitation paths do not require any application-level signature beyond the chain's transaction signature. The contract proves "the sender controls this node" by cross-querying the mixnet contract (`query_nymnode_ownership` / `check_node_existence`), and relies on chain-level replay protection for every transaction.
|
||||
|
||||
**Why.** Tor's family-key construction protects against an off-chain attacker that can forge messages to a directory server. On Nym there is no off-chain channel — every state transition is a signed Cosmos SDK transaction whose sender address is authenticated by the chain itself. Bond ownership is already attested on-chain by the mixnet contract. Adding a second signing key per operator would duplicate the trust anchor at no security gain and complicate key management.
|
||||
|
||||
**Alternative considered.** A per-family signing key recorded on family creation, with every membership change requiring a signature from that key. Rejected as redundant given chain-level authentication.
|
||||
|
||||
**Consequence.** The contract is tightly coupled to the mixnet contract — its instantiate message *requires* the mixnet contract address, and that address is the sole authority for `OnNymNodeUnbond`. The address is bech32-validated at instantiation and persisted; it cannot be changed by a privileged update path (today). Replacing the mixnet contract would therefore require redeploying the families contract.
|
||||
|
||||
### Decision 2: Family names are normalised at the storage boundary
|
||||
|
||||
**Choice.** Family names are normalised by dropping every non-ASCII-alphanumeric character and lowercasing the rest (`crate::helpers::normalise_family_name`). The normalised form is stored alongside the user-supplied `name` and is what the unique-name index keys on.
|
||||
|
||||
**Why.** Users will submit `"My Family"`, `"my-family"`, `" MyFamily "` and expect them to be the same entity. A unique index on the raw `name` would let an adversary squat the canonical form with cosmetic variants. Doing the normalisation contract-side rather than client-side means every consumer sees the same uniqueness behaviour without trusting clients.
|
||||
|
||||
**Alternative considered.** Case-insensitive Unicode normalisation (NFKC + lowercasing). Rejected because CosmWasm contracts run in a deterministic Wasm sandbox where importing `unicode-normalization` materially grows the binary and increases gas cost; the operator population is small enough that ASCII-alphanumerics-only is acceptable.
|
||||
|
||||
**Consequence.** Non-ASCII names normalise to whatever ASCII letters remain (`"café"` → `"caf"`, `"名前"` → `""`). Names that normalise to the empty string are explicitly rejected with `EmptyFamilyName`. This is documented behaviour, not a bug.
|
||||
|
||||
### Decision 3: Family ids are monotonic and never recycled; `0` is the "no family" sentinel
|
||||
|
||||
**Choice.** The contract holds an `Item<NodeFamilyId>` counter that starts unset (treated as `0`) and is incremented to issue the next id. Disbanding a family does not free its id.
|
||||
|
||||
**Why.** Off-chain archives (the past-members and past-invitations maps) are keyed by `family_id`. Recycling ids would silently merge the history of two unrelated families and break correlation in downstream indexers. A monotonic counter is the simplest safe option.
|
||||
|
||||
**Alternative considered.** Hash-of-name ids. Rejected because the name can be normalised but the index must remain stable across renames (none today, but worth preserving headroom) and because numeric ids serialise more cheaply.
|
||||
|
||||
**Consequence.** The id space is `u32`. At realistic operator counts (single thousands) overflow is not a practical concern.
|
||||
|
||||
### Decision 4: Both archives use `((family_id, node_id), counter: u64)` keys
|
||||
|
||||
**Choice.** `past_family_members` and `past_family_invitations` are keyed by a composite of the `(family, node)` pair plus an explicit per-pair counter (`past_family_member_counter`, `past_family_invitation_counter` — both `Map<(NodeFamilyId, NodeId), u64>`).
|
||||
|
||||
**Why.** A node can be invited to (and join, leave, re-join, etc.) the same family multiple times. Using `env.block.time` as a disambiguator is unsafe because multiple transactions can share a block. Maintaining an explicit per-pair counter keeps archival writes O(1) (vs. an O(log n) range scan to find the next free slot) and gives the archive a stable, total order per pair.
|
||||
|
||||
**Alternative considered.** Storing every archive entry under a global sequence counter. Rejected because per-family and per-node listings (the dominant query shape) would then need to scan and filter rather than prefix-iterate.
|
||||
|
||||
**Consequence.** Archive cursors are composite (`(NodeId, u64)` per-family-scoped, `(NodeFamilyId, u64)` per-node-scoped, `((NodeFamilyId, NodeId), u64)` globally-scoped). They are publicly typed in the common crate so clients pass them back verbatim.
|
||||
|
||||
### Decision 5: No background sweeper for expired pending invitations
|
||||
|
||||
**Choice.** When an invitation's `expires_at` passes, the entry remains in `pending_family_invitations`. The only paths that clear it are explicit revoke (by the family owner), explicit reject (by the node controller), the unbonding callback (sweeps all invitations for a node), and disband (sweeps all invitations for a family). Read queries surface a boolean `expired` flag so consumers don't have to compare the timestamp themselves.
|
||||
|
||||
**Why.** A background sweeper would need either a CosmWasm cron extension (none available) or a per-block hook (no such hook exists in the contract — the only cross-contract entry is `OnNymNodeUnbond`). Triggering sweeps from the families contract's own execute paths only would still leave drift between expiry and removal. Surfacing `expired` lets read consumers decide their own policy without paying contract storage churn.
|
||||
|
||||
**Consequence.** `accept_invitation` is the only handler that refuses to act on an expired entry. Revoke and reject are explicit and idempotent under expiry — they are the operator's tool to clean storage. The expired-but-still-pending state is not pathological; it is the documented baseline.
|
||||
|
||||
### Decision 6: Owner-gated handlers derive the family from sender ownership, not from arguments
|
||||
|
||||
**Choice.** `DisbandFamily`, `InviteToFamily`, `RevokeFamilyInvitation`, and `KickFromFamily` do not accept a `family_id` argument — the family is resolved from the sender's ownership via the `families.owner` unique index.
|
||||
|
||||
**Why.** Passing the family id would force every handler to validate that the sender owns the supplied id, with one error path for "wrong family" and another for "no family". Deriving the family from ownership collapses both into the single `SenderDoesntOwnAFamily` error path and makes it impossible to act on someone else's family. The `KickFromFamily` case additionally checks that the target node's current family matches the owner's family, because `family_members` is keyed by node alone and the storage helper would otherwise silently strip a node from an unrelated family.
|
||||
|
||||
**Alternative considered.** Explicit `family_id` argument on every handler. Rejected as ergonomic noise that adds error surface without security gain.
|
||||
|
||||
**Consequence.** Accept and reject *do* carry `family_id` in the message, because the sender there is the *invitee* (node controller), not the family owner — they may simultaneously hold invitations from multiple families and must say which one they are acting on.
|
||||
|
||||
### Decision 7: Defence-in-depth pre-checks complement unique-index enforcement
|
||||
|
||||
**Choice.** `try_create_family` explicitly checks `may_get_owned_family` and `families.idx.normalised_name.item` *before* calling `register_new_family`, even though both invariants are also enforced by the underlying `IndexedMap` `UniqueIndex`. Likewise `add_pending_invitation` checks `may_load` before insert.
|
||||
|
||||
**Why.** A `UniqueIndex` violation surfaces as a generic CosmWasm storage error without any context (no family ids, no addresses). Pre-checking yields typed errors (`SenderAlreadyOwnsAFamily { address, family_id }`, `FamilyNameAlreadyTaken { name, family_id }`, `PendingInvitationAlreadyExists { family_id, node_id }`) that downstream wallets and tooling can surface meaningfully. The unique index is retained as a hard backstop so a bug in the pre-check cannot corrupt invariants.
|
||||
|
||||
**Consequence.** The cost is one extra storage read per checked invariant per call — negligible relative to the typical multi-write transaction body.
|
||||
|
||||
### Decision 8: The unbonding callback archives invitations as `Rejected`, not `Revoked`
|
||||
|
||||
**Choice.** When `OnNymNodeUnbond` sweeps a node's pending invitations, each is archived with `FamilyInvitationStatus::Rejected { at: now }`. The `Revoked` status is reserved exclusively for owner-side withdrawal (the explicit `RevokeFamilyInvitation` execute and the all-family sweep during `DisbandFamily`).
|
||||
|
||||
**Why.** From the *family's* point of view, an invitation that auto-clears because its target unbonded looks identical to one the target explicitly declined: both are invitee-side terminations beyond the family's control. Lumping them together as `Rejected` keeps the semantic boundary clean — `Revoked` means "the family changed its mind," `Rejected` means "the invitee said no (whether via explicit reject or by leaving the network)." Off-chain indexers that group by status get sensible buckets without needing a separate "auto-rejected" tier.
|
||||
|
||||
**Alternative considered.** A new `FamilyInvitationStatus::AutoExpired` (or `NodeUnbonded`) variant. Rejected because it adds enum surface for a distinction that no current consumer cares about, and because the historical record already preserves the *event* that triggered the archival (a `family_node_unbond_cleanup` event fires alongside, and on-chain tx history can be cross-referenced).
|
||||
|
||||
**Consequence.** Past-invitation queries cannot distinguish "node controller explicitly rejected" from "node unbonded and the invitation was swept" from the `status` field alone. Consumers that need the distinction must correlate with the emitted event or with the mixnet-contract unbonding history. Adding a new variant later would be a breaking schema change.
|
||||
|
||||
### Decision 9: Schema-feature-gated response types in the common crate
|
||||
|
||||
**Choice.** The common crate's `msg.rs` puts every `#[cfg_attr(feature = "schema", returns(...))]` annotation behind a `schema` feature, and the response types are only imported when the feature is active.
|
||||
|
||||
**Why.** The contract crate itself does not need `cosmwasm-schema` at runtime; only the `bin/schema.rs` schema-emitter (which writes `schema/node-families.json` and the per-message JSON files) does. Gating keeps the production Wasm binary lean.
|
||||
|
||||
**Consequence.** When generating schemas, builds must enable the `schema` feature on `nym_node_families_contract_common`. This is automated in the contract's `Makefile` and `bin/schema.rs`.
|
||||
|
||||
### Decision 10: Unknown scope ids on paginated queries return an empty page, not an error
|
||||
|
||||
**Choice.** Per-family and per-node paginated queries (`GetFamilyMembersPaged`, `GetPendingInvitationsForFamilyPaged`, `GetPastMembersForNodePaged`, etc.) do not verify that the supplied `family_id` or `node_id` corresponds to an existing entity. An unknown scope id silently yields an empty page (`entries: []`, `start_next_after: None`) rather than a `FamilyNotFound`-style error.
|
||||
|
||||
**Why.** The underlying `cw_storage_plus::IndexedMap::range` over a `MultiIndex` prefix has no native existence check on the prefix itself — an unknown prefix is simply a range that yields zero entries. Surfacing this as an error would require an extra storage read per query to confirm the scope exists, and the per-family/per-node membership maps don't carry a "this scope was ever populated" sentinel anyway (a disbanded family has zero remaining members, and so does one that never had any). Treating "no entries" identically in both cases collapses two paths into one. Callers who care about the distinction can pair the listing with `GetFamilyById { family_id }` (which *does* return `Option<NodeFamily>`).
|
||||
|
||||
**Alternative considered.** Returning `FamilyNotFound { family_id }` (or analogous) when the scope id is unknown. Rejected because (a) it inflates the cost of every paginated read by one extra storage check, (b) it makes paginate-then-filter consumer patterns more awkward without payoff, and (c) the "scope known but empty" and "scope unknown" cases are observationally identical to most consumers.
|
||||
|
||||
**Consequence.** Spec scenario "Unknown scope id yields an empty page rather than an error" makes this explicit. Tooling that surfaces "family not found" errors needs to perform its own existence check via the single-family query — the listing endpoints do not provide it.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Mixnet contract address is locked at instantiation]** → If the mixnet contract is ever redeployed at a different address, the families contract becomes orphaned (the unbonding callback path no longer fires). **Mitigation:** documented as an operational constraint; a future migration could add an `UpdateMixnetContractAddress` admin-only execute. Today there is no such path.
|
||||
- **[No sybil resistance]** → A malicious operator running multiple nodes can simply not declare a family. **Mitigation:** out of scope here; the proposal explicitly accepts this and points to operator verification as a follow-on.
|
||||
- **[Expired invitations bloat storage]** → A family that issues invitations and never sweeps them accrues dead entries. **Mitigation:** disband sweeps all of them at once; the family can also revoke individually. The `expired` flag on read queries means consumers don't need them gone to act correctly.
|
||||
- **[Counter overflow in archive maps]** → `u64` per pair counter cannot realistically overflow but is technically unbounded. **Mitigation:** not a real concern at any practical operator/node scale.
|
||||
- **[Cross-contract query gas cost]** → Every invite/accept/reject/leave/kick does at least one `query_nymnode_ownership` or `check_node_existence`. **Mitigation:** these are simple `Item`/`Map` reads on the mixnet contract side; cost is bounded.
|
||||
- **[Disband cost scales with pending invitation count]** → Disband sweeps all of a family's pending invitations in a single transaction. A family with thousands of pending invitations could push past gas limits. **Mitigation:** documented; in the worst case the owner can pre-revoke invitations in batches.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Not applicable to this change — this is a documentation-only artefact for code that has already shipped. The contract itself is initially deployed via the standard `cosmwasm_contracts.rs` orchestrator wiring (which the anchor commit added), and its `migrate` entry point uses `cw2::ensure_from_older_version` to gate version bumps. There is no state migration in the anchor commit; `queued_migrations.rs` is a stub awaiting future need.
|
||||
|
||||
Future spec deltas that *do* change behaviour should:
|
||||
|
||||
1. Document the storage-key change (if any) and add a corresponding entry to `queued_migrations`.
|
||||
2. Bump `CARGO_PKG_VERSION` in `contracts/node-families/Cargo.toml`.
|
||||
3. Coordinate the `MigrateMsg` invocation with the chain-governance migration tx that pushes the new code id.
|
||||
|
||||
## Resolved Questions
|
||||
|
||||
The three questions considered during the spec walk-through, with the team's resolutions on 2026-05-20:
|
||||
|
||||
- **Mixnet contract address updatability** → keep locked at instantiation. The mixnet contract is not expected to be redeployed under a new address; if it ever is, the families contract is redeployed alongside it. Avoiding the admin-gated `UpdateMixnetContractAddress` execute means admin compromise cannot hijack the unbonding callback authority.
|
||||
- **Length-limit units** → keep byte-counted via `String::len`. Operator-supplied family names are overwhelmingly ASCII in practice; pulling in `unicode-segmentation` or even paying the `chars().count()` cost is not justified for short identifiers.
|
||||
- **Rename / update handlers** → do not add. The only post-creation mutation remains the `members` counter. Owners who want to change a name or description disband and recreate (the fee is refunded). Keeps the execute surface, event surface, and test surface minimal.
|
||||
|
||||
No questions remain open.
|
||||
@@ -0,0 +1,36 @@
|
||||
## Why
|
||||
|
||||
The node-families CosmWasm contract (`contracts/node-families/`) ships and is live, but the design exists only as Rust source and inline doc comments — there is no externally-readable specification of *what* the contract guarantees, *who* is authorised to do *what*, or *how* it interacts with the mixnet contract. Without that artefact:
|
||||
|
||||
- Route-selection logic in clients and the nym-api currently treats node-families data as opaque inputs; reviewers and integrators have no normative reference to check assumptions against.
|
||||
- The single-commit origin (`a21a01cf1a`, "node families (#6715)") means there is no PR-by-PR trail of design decisions to reverse later — capturing the spec now is materially cheaper than reconstructing it from `git blame` once memory fades.
|
||||
- Follow-on work (operator verification, route-policy enforcement, indexer expansion in node-status-api) needs a stable interface to plan against.
|
||||
|
||||
The goal of this change is to **reverse-engineer specifications** for the on-chain contract that already exists at HEAD on `develop`. No behaviour change is proposed; this is a documentation/spec-only deliverable that ratifies the current implementation as the baseline.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Introduce a new capability spec `node-families-contract` covering the on-chain CosmWasm contract: instantiation, runtime config, family lifecycle (create/disband), invitation lifecycle (invite/accept/reject/revoke/expire), membership lifecycle (join/leave/kick), the mixnet-contract unbonding callback, and the full read-query surface.
|
||||
- Document the contract's external invariants (one family per owner, one family per node, globally unique normalised family names, monotonic family ids, sequential per-`(family,node)` archive counters, no background sweeper for expired invitations).
|
||||
- Document the cross-contract trust boundary with the mixnet contract: which checks the families contract delegates (node existence, node controller, unbonding state) and which it owns (family ownership, invitation state, membership archives).
|
||||
- Document the event surface emitted by each execute path, since indexers (node-status-api) and downstream tooling consume those names/attributes as a public API.
|
||||
|
||||
No code changes. No migrations. No new dependencies.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `node-families-contract`: the CosmWasm contract that lets node operators declare groupings of co-owned nodes ("families"), issue and resolve invitations, and exposes the queryable state that route-selection logic uses to disallow entry+exit pairs from the same family.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
_None — there are no existing specs in `openspec/specs/` and this change does not alter on-chain behaviour._
|
||||
|
||||
## Impact
|
||||
|
||||
- **Affected code**: none modified. The spec is derived from `contracts/node-families/` and `common/cosmwasm-smart-contracts/node-families-contract/` at HEAD on `develop` (anchor commit `a21a01cf1a`).
|
||||
- **Affected consumers** (documented for traceability, not changed): `validator-client` (`NodeFamiliesQueryClient` / `NodeFamiliesSigningClient` traits), `nym-api` (`src/node_families/` cache + HTTP routes), `nym-node-status-api` (`db/queries/node_families.rs` + DVpn gateway routes), and the mixnet contract's unbonding flow which fires `ExecuteMsg::OnNymNodeUnbond`.
|
||||
- **Dependencies**: none. CosmWasm storage layout (storage-key constants in `common/cosmwasm-smart-contracts/node-families-contract/src/constants.rs`) is part of the spec surface — changing those constants is a breaking change for already-deployed contracts and must be treated as such by any future delta.
|
||||
- **Non-goals**: route-policy enforcement (client-side or nym-api-side filtering using family data), operator verification, geographic/subnet-distinctness checks, indexer schema, HTTP API shapes. These all consume the contract but live outside its boundary and will get their own specs in follow-on changes.
|
||||
- **Known limitation — ASCII-only names**: family-name normalisation drops every character that is not an ASCII letter or digit. Non-ASCII letters (`"café"` → `"caf"`, `"Ω-team"` → `"team"`, `"名前"` → `""`) and emoji are stripped entirely; names that normalise to the empty string are rejected with `EmptyFamilyName`. This is intentional, deterministic, and gas-efficient (no Unicode segmentation dependency in the Wasm binary), but it does mean operators picking non-ASCII branding cannot use it as their family name. Documented here so it surfaces in the change log and not just buried inside `normalise_family_name`.
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Contract instantiation persists the runtime config, mixnet contract address, and admin
|
||||
|
||||
The contract SHALL be instantiable exactly once via the standard CosmWasm `instantiate` entry point. The instantiation message SHALL carry a `Config` (creation fee, family-name length limit, family-description length limit, default invitation validity in seconds) and a string-form `mixnet_contract_address`. The handler MUST:
|
||||
|
||||
- bech32-validate `mixnet_contract_address` and reject malformed inputs;
|
||||
- persist the validated mixnet contract address, the `Config`, and `info.sender` as the contract admin;
|
||||
- record the contract name and `CARGO_PKG_VERSION` via `cw2::set_contract_version`;
|
||||
- record build information via `set_build_information!`.
|
||||
|
||||
#### Scenario: Valid instantiation persists config, mixnet address, and admin
|
||||
- **WHEN** `instantiate` is called with a valid bech32 mixnet contract address and a well-formed `Config`
|
||||
- **THEN** the contract stores the `Config` verbatim, the validated `Addr` for the mixnet contract, and sets `info.sender` as the contract admin queryable via `cw-controllers::Admin::assert_admin`
|
||||
- **AND** `cw2::get_contract_version` returns the crate's `CARGO_PKG_VERSION`
|
||||
|
||||
#### Scenario: Invalid mixnet contract address is rejected
|
||||
- **WHEN** `instantiate` is called with a `mixnet_contract_address` string that fails `Addr::validate`
|
||||
- **THEN** the call returns an error and no state is persisted
|
||||
|
||||
### Requirement: Migration entry point forbids version downgrades
|
||||
|
||||
The contract SHALL expose a `migrate` entry point that refreshes recorded build information and uses `cw2::ensure_from_older_version` to guarantee the on-chain contract version is at most the current `CARGO_PKG_VERSION`. Downgrade attempts MUST be rejected.
|
||||
|
||||
#### Scenario: Equal or older on-chain version is accepted
|
||||
- **WHEN** `migrate` is called against an on-chain `cw2` version less than or equal to `CARGO_PKG_VERSION`
|
||||
- **THEN** the call succeeds and `set_build_information!` updates the stored build metadata
|
||||
|
||||
#### Scenario: Newer on-chain version is rejected
|
||||
- **WHEN** `migrate` is called against an on-chain `cw2` version strictly greater than `CARGO_PKG_VERSION`
|
||||
- **THEN** the call returns an error and storage is unchanged
|
||||
|
||||
### Requirement: Only the contract admin can replace the runtime config
|
||||
|
||||
The `ExecuteMsg::UpdateConfig` handler SHALL overwrite the stored `Config` with the value supplied by the caller. The handler MUST call `Admin::assert_admin` and return the underlying `AdminError` (wrapped in `NodeFamiliesContractError::Admin`) when the sender is not the admin.
|
||||
|
||||
#### Scenario: Admin replaces the config
|
||||
- **WHEN** the contract admin sends `ExecuteMsg::UpdateConfig { config }`
|
||||
- **THEN** the stored `Config` equals the supplied value on subsequent reads
|
||||
|
||||
#### Scenario: Non-admin is rejected
|
||||
- **WHEN** a non-admin sender sends `ExecuteMsg::UpdateConfig`
|
||||
- **THEN** the call returns an `Admin` error and the stored `Config` is unchanged
|
||||
|
||||
### Requirement: Family names are normalised and globally unique under their normalised form
|
||||
|
||||
Family names SHALL be normalised to a canonical form by lowercasing ASCII letters, preserving ASCII digits, and dropping every other character (whitespace, punctuation, non-ASCII letters). The normalised form SHALL be stored alongside the user-supplied `name` and SHALL be the key of the `families.normalised_name` unique index. Inputs whose normalised form is the empty string SHALL be rejected with `EmptyFamilyName`. Inputs whose original length exceeds `Config::family_name_length_limit` (measured in **bytes**, via `String::len`) SHALL be rejected with `FamilyNameTooLong { length, limit }`. Creation of a family whose normalised name collides with an existing family SHALL fail with `FamilyNameAlreadyTaken { name, family_id }`.
|
||||
|
||||
**Important**: this normalisation is ASCII-only by design. Non-ASCII letters and emoji are stripped entirely (`"café"` → `"caf"`, `"名前"` → `""`, `"⭐stars"` → `"stars"`). Names consisting solely of non-ASCII characters or symbols are rejected as `EmptyFamilyName`. Operators who want a non-ASCII brand cannot encode it in the on-chain family name; the user-supplied `name` field preserves the original formatting but only the normalised ASCII form is enforced for uniqueness and emptiness.
|
||||
|
||||
#### Scenario: Normalisation strips punctuation, whitespace, casing, and non-ASCII letters
|
||||
- **WHEN** the contract normalises any of `"Foo Bar"`, `"foobar"`, `"FOO-BAR"`, or `" f.o.o.b.a.r "`
|
||||
- **THEN** every result equals `"foobar"`
|
||||
|
||||
#### Scenario: All-symbol name is rejected
|
||||
- **WHEN** `CreateFamily` is sent with `name = "!!!---"` (normalises to the empty string)
|
||||
- **THEN** the call fails with `EmptyFamilyName` and no family is persisted
|
||||
|
||||
#### Scenario: Name length is checked against the original string, not the normalised form
|
||||
- **WHEN** `CreateFamily` is sent with a `name` whose `name.len()` (byte length) exceeds `Config::family_name_length_limit`
|
||||
- **THEN** the call fails with `FamilyNameTooLong { length, limit }`
|
||||
|
||||
#### Scenario: Multi-byte characters count their full byte length toward the limit
|
||||
- **WHEN** `Config::family_name_length_limit = 8` and `CreateFamily` is sent with `name = "🚀rocket"` (a 4-byte emoji followed by `"rocket"`, totalling `name.len() == 10` bytes — but only 7 user-perceived characters)
|
||||
- **THEN** the call fails with `FamilyNameTooLong { length: 10, limit: 8 }` even though the visible character count is within the limit
|
||||
- **AND** had the limit been `>= 10`, the name would have been accepted and the normalised form would be `"rocket"` (the emoji dropped during normalisation)
|
||||
|
||||
#### Scenario: Two formattings of the same canonical name collide
|
||||
- **WHEN** family `A` is created with `name = "Shared"` and `B` later tries to create with `name = "$$shared$$"`
|
||||
- **THEN** the second call fails with `FamilyNameAlreadyTaken { name: "shared", family_id: A.id }`
|
||||
|
||||
### Requirement: An address may own at most one family at a time
|
||||
|
||||
A given owner address SHALL own at most one family at any time, enforced by the `families.owner` unique index. `CreateFamily` SHALL pre-check this and fail with `SenderAlreadyOwnsAFamily { address, family_id }` for better error context. The pre-check is in addition to (not instead of) the unique-index defence-in-depth check. Owner-gated handlers (`DisbandFamily`, `InviteToFamily`, `RevokeFamilyInvitation`, `KickFromFamily`) SHALL look up the family by owner and fail with `SenderDoesntOwnAFamily { address }` when none exists.
|
||||
|
||||
#### Scenario: Same address cannot create a second family while still owning one
|
||||
- **WHEN** address `alice` already owns family `A` and `CreateFamily` is sent again with `alice` as sender
|
||||
- **THEN** the call fails with `SenderAlreadyOwnsAFamily { address: alice, family_id: A.id }`
|
||||
|
||||
#### Scenario: Address can create a family again after disbanding its previous one
|
||||
- **WHEN** `alice` creates family `A` then disbands it, then sends `CreateFamily` again
|
||||
- **THEN** a new family with a strictly greater id than `A` is created (ids are monotonic and never recycled)
|
||||
|
||||
#### Scenario: Owner-gated handler rejects a sender that owns no family
|
||||
- **WHEN** any of `DisbandFamily`, `InviteToFamily`, `RevokeFamilyInvitation`, or `KickFromFamily` is sent by an address that owns no family
|
||||
- **THEN** the call fails with `SenderDoesntOwnAFamily { address }`
|
||||
|
||||
### Requirement: Family creation requires the configured fee and is rejected if the owner's bonded node is already in a family
|
||||
|
||||
`ExecuteMsg::CreateFamily` SHALL require the sender to attach exactly one coin matching `Config::create_family_fee` in both denom and amount. Payment validation MUST go through `cw_utils::must_pay`; mismatches in amount MUST surface as `InvalidFamilyCreationFee { expected, received }`. The handler MUST additionally check that any bonded node the sender controls (as reported by the mixnet contract via `query_nymnode_ownership`) is not currently a member of any family, failing with `AlreadyInFamily { address, node_id, family_id }` otherwise. The handler MUST validate the description length against `Config::family_description_length_limit` and reject overlong descriptions with `FamilyDescriptionTooLong { length, limit }`. On success the handler SHALL emit a `family_creation` event with attributes `family_name`, `owner_address`, `family_id`, `paid_fee`.
|
||||
|
||||
#### Scenario: Successful family creation persists the family and emits the event
|
||||
- **WHEN** a sender with no bonded family-member node and no existing-owned family sends `CreateFamily { name, description }` with the correct fee attached
|
||||
- **THEN** a new family is persisted with monotonically increasing `id`, the supplied `name` and `description`, the computed `normalised_name`, `members = 0`, `created_at = env.block.time.seconds()`, and `paid_fee` equal to the configured fee
|
||||
- **AND** the response carries an event named `family_creation` with attributes `family_name`, `owner_address`, `family_id`, `paid_fee`
|
||||
|
||||
#### Scenario: Wrong fee denom or missing funds is rejected
|
||||
- **WHEN** `CreateFamily` is sent with no funds, with multiple denoms, or with a denom different from `Config::create_family_fee.denom`
|
||||
- **THEN** the call fails with `InvalidDeposit(PaymentError)`
|
||||
|
||||
#### Scenario: Wrong fee amount is rejected
|
||||
- **WHEN** `CreateFamily` is sent with the correct denom but an amount different from `Config::create_family_fee.amount`
|
||||
- **THEN** the call fails with `InvalidFamilyCreationFee { expected, received }` and the funds remain unspent
|
||||
|
||||
#### Scenario: Sender whose bonded node is already in a family is rejected
|
||||
- **WHEN** `CreateFamily` is sent by an address whose bonded node (per the mixnet contract) is already a member of some family `F`
|
||||
- **THEN** the call fails with `AlreadyInFamily { address, node_id, family_id: F.id }`
|
||||
|
||||
#### Scenario: Overlong description is rejected
|
||||
- **WHEN** `CreateFamily` is sent with a `description` whose byte length exceeds `Config::family_description_length_limit`
|
||||
- **THEN** the call fails with `FamilyDescriptionTooLong { length, limit }`
|
||||
|
||||
### Requirement: Family ids are monotonic, never recycled, and start at 1
|
||||
|
||||
The contract SHALL assign family ids sequentially starting at `1`. The id counter SHALL be persisted as an `Item<NodeFamilyId>` and incremented on each successful family creation. Disbanding a family MUST NOT free or recycle its id. `0` SHALL be reserved as a "no family" sentinel and never assigned.
|
||||
|
||||
#### Scenario: First-ever family receives id 1
|
||||
- **WHEN** the contract is freshly instantiated and the first successful `CreateFamily` runs
|
||||
- **THEN** the persisted `NodeFamily.id` equals `1`
|
||||
|
||||
#### Scenario: Ids are not reused after disband
|
||||
- **WHEN** family with id `N` is disbanded and a new family is then created
|
||||
- **THEN** the new family's id is strictly greater than `N` (it is `N+1` if no other families were created in between)
|
||||
|
||||
### Requirement: A family can only be disbanded by its owner, must be empty, and refunds the creation fee
|
||||
|
||||
`ExecuteMsg::DisbandFamily` SHALL look up the sender's family via the `owner` unique index and fail with `SenderDoesntOwnAFamily` when none exists. The handler MUST reject disbanding while `NodeFamily.members > 0`, failing with `FamilyNotEmpty { family_id, members }`. On success the handler SHALL sweep every still-pending invitation issued by the family (archiving each as `FamilyInvitationStatus::Revoked { at: now }` with status timestamp = `env.block.time.seconds()`), remove the family record, and attach a `BankMsg::Send { to_address: owner, amount: vec![family.paid_fee] }` to the response as a CosmWasm sub-message. The response SHALL include a `family_disband` event with `family_id`, `owner_address`, `refunded_fee` attributes.
|
||||
|
||||
**Operational note (non-normative)**: the pending-invitation sweep iterates every entry of `pending_family_invitations` keyed by `family_id` and archives each in a single transaction. Gas cost therefore scales linearly with the number of leftover pending invitations. An owner whose family has accumulated an unusually large number of pending invitations is expected to revoke them in batches (via `RevokeFamilyInvitation`) *before* invoking `DisbandFamily`, since a single disband call that exceeds the per-tx gas limit will fail and leave the family in place. There is no contract-side chunking; the responsibility lies with the caller.
|
||||
|
||||
#### Scenario: Owner disbands an empty family and is refunded
|
||||
- **WHEN** family owner sends `DisbandFamily` while `members == 0`
|
||||
- **THEN** the family is removed from storage, the response carries a `BankMsg::Send` of `family.paid_fee` to the owner, and a `family_disband` event is emitted
|
||||
|
||||
#### Scenario: Refund is attached as a BankMsg::Send sub-message, not a direct bank-module call
|
||||
- **WHEN** a successful `DisbandFamily` returns its `Response`
|
||||
- **THEN** the response's `messages` field contains exactly one `CosmosMsg::Bank(BankMsg::Send { to_address: <owner bech32>, amount: vec![<family.paid_fee>] })` and the contract performs no other balance-changing side effect for the refund
|
||||
- **AND** tx simulators that inspect outbound sub-messages observe the refund there (this is the contract's only avenue for returning funds; integrators rely on it)
|
||||
|
||||
#### Scenario: Non-empty family cannot be disbanded
|
||||
- **WHEN** family owner sends `DisbandFamily` while `members > 0`
|
||||
- **THEN** the call fails with `FamilyNotEmpty { family_id, members }` and storage is unchanged
|
||||
|
||||
#### Scenario: Disbanding sweeps still-pending invitations as Revoked
|
||||
- **WHEN** family `F` has pending invitations to nodes `n1`, `n2` and the owner sends `DisbandFamily`
|
||||
- **THEN** the pending entries for `(F.id, n1)` and `(F.id, n2)` are removed from `pending_family_invitations` and archived under `past_family_invitations` with `status = Revoked { at: env.block.time.seconds() }`
|
||||
|
||||
### Requirement: A node belongs to at most one family at any time
|
||||
|
||||
The `family_members` map SHALL be keyed by `NodeId` alone; the value SHALL carry the `family_id` so the storage layer enforces the one-family-per-node invariant by construction. Handlers that add a membership (`AcceptFamilyInvitation`) and handlers that issue an invitation (`InviteToFamily`) SHALL pre-check the absence of any existing membership for the node and fail with `NodeAlreadyInFamily { node_id, family_id }` otherwise.
|
||||
|
||||
#### Scenario: Inviting a node already in a family is rejected
|
||||
- **WHEN** `InviteToFamily { node_id }` targets a node that already has a membership record for family `F`
|
||||
- **THEN** the call fails with `NodeAlreadyInFamily { node_id, family_id: F.id }`
|
||||
|
||||
#### Scenario: Accepting a second invitation after joining a family is rejected
|
||||
- **WHEN** node `n` is a member of family `F` and `AcceptFamilyInvitation { family_id: G, node_id: n }` is sent (for a different family `G`)
|
||||
- **THEN** the call fails with `NodeAlreadyInFamily { node_id: n, family_id: F.id }`
|
||||
|
||||
### Requirement: Invitations require an existing family, a bonded target node, and strictly positive validity
|
||||
|
||||
`ExecuteMsg::InviteToFamily` SHALL be owner-gated (the family acted on is the sender's owned family, never an argument). The handler MUST:
|
||||
|
||||
- compute `validity = validity_secs.unwrap_or(Config::default_invitation_validity_secs)`;
|
||||
- reject `validity == 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");
|
||||
- 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`.
|
||||
|
||||
#### Scenario: Successful invitation persists with the computed expiry
|
||||
- **WHEN** family owner sends `InviteToFamily { node_id, validity_secs: Some(v) }` and all preconditions hold
|
||||
- **THEN** a pending invitation for `(owned.id, node_id)` is persisted with `expires_at = env.block.time.seconds() + v`
|
||||
- **AND** the response carries a `family_invitation` event with `family_id = owned.id`, `node_id`, `expires_at`
|
||||
|
||||
#### Scenario: Missing validity falls back to the configured default
|
||||
- **WHEN** `InviteToFamily { node_id, validity_secs: None }` is sent
|
||||
- **THEN** the persisted invitation has `expires_at = env.block.time.seconds() + Config::default_invitation_validity_secs`
|
||||
|
||||
#### Scenario: Zero validity is rejected
|
||||
- **WHEN** `InviteToFamily { node_id, validity_secs: Some(0) }` is sent
|
||||
- **THEN** the call fails with `ZeroInvitationValidity` and no invitation is persisted
|
||||
|
||||
#### Scenario: Inviting a non-bonded node is rejected
|
||||
- **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)
|
||||
|
||||
### Requirement: Acceptance and rejection of an invitation are gated on node control
|
||||
|
||||
`ExecuteMsg::AcceptFamilyInvitation` and `ExecuteMsg::RejectFamilyInvitation` SHALL each verify that the sender is the controller of the bonded node `node_id` per the mixnet contract (`query_nymnode_ownership` returns a `nym_node` with `node_id == node_id` and `is_unbonding == false`). Failures MUST surface as `SenderDoesntControlNode { address, node_id }`. This single error covers the cases of: sender owns no bonded node, sender owns a different node id, and sender owns the node but it has entered unbonding.
|
||||
|
||||
#### Scenario: Non-controller cannot accept an invitation
|
||||
- **WHEN** `AcceptFamilyInvitation { family_id, node_id }` is sent by an address that does not control `node_id` (per mixnet contract)
|
||||
- **THEN** the call fails with `SenderDoesntControlNode { address, node_id }`
|
||||
|
||||
#### Scenario: Unbonding node cannot accept an invitation
|
||||
- **WHEN** the sender controls `node_id` but the mixnet contract reports the node as unbonding
|
||||
- **THEN** `AcceptFamilyInvitation` fails with `SenderDoesntControlNode { address, node_id }`
|
||||
|
||||
#### Scenario: Non-controller cannot reject an invitation
|
||||
- **WHEN** `RejectFamilyInvitation { family_id, node_id }` is sent by an address that does not control `node_id`
|
||||
- **THEN** the call fails with `SenderDoesntControlNode { address, node_id }`
|
||||
|
||||
### Requirement: Accepting an invitation moves it from pending to archived and increments the family member count
|
||||
|
||||
A successful `AcceptFamilyInvitation` SHALL:
|
||||
|
||||
- load the pending invitation for `(family_id, node_id)`, failing with `InvitationNotFound { family_id, node_id }` if absent;
|
||||
- check `now < invitation.expires_at`, failing with `InvitationExpired { family_id, node_id, expires_at, now }` otherwise (`now == expires_at` is considered expired);
|
||||
- remove the entry from `pending_family_invitations`;
|
||||
- write `family_members[node_id] = FamilyMembership { family_id, joined_at: now }`;
|
||||
- increment `NodeFamily::members` by 1 (failing with `FamilyNotFound { family_id }` if the family has somehow been removed);
|
||||
- archive a `PastFamilyInvitation { invitation, status: Accepted { at: now } }` under `((family_id, node_id), counter)` where `counter` is the next free per-`(family, node)` archive slot;
|
||||
- emit a `family_invitation_accepted` event with `family_id`, `node_id`.
|
||||
|
||||
#### Scenario: Happy-path acceptance
|
||||
- **WHEN** node controller accepts a not-yet-expired pending invitation
|
||||
- **THEN** the membership is recorded with `joined_at = env.block.time.seconds()`, the family's `members` count is incremented, the pending entry is removed, and the archive contains the invitation with status `Accepted { at: now }`
|
||||
|
||||
#### Scenario: Acceptance of an already-expired invitation is rejected
|
||||
- **WHEN** `AcceptFamilyInvitation` is called with `env.block.time.seconds() >= invitation.expires_at`
|
||||
- **THEN** the call fails with `InvitationExpired { family_id, node_id, expires_at, now }` and storage is unchanged
|
||||
|
||||
#### Scenario: Acceptance with no pending invitation is rejected
|
||||
- **WHEN** `AcceptFamilyInvitation { family_id, node_id }` is called with no pending invitation stored for that pair
|
||||
- **THEN** the call fails with `InvitationNotFound { family_id, node_id }`
|
||||
|
||||
### Requirement: Rejection and revocation work on expired invitations and archive them with terminal status
|
||||
|
||||
`RejectFamilyInvitation` (sent by the node controller) and `RevokeFamilyInvitation` (sent by the family owner) SHALL each:
|
||||
|
||||
- remove the pending invitation;
|
||||
- archive a `PastFamilyInvitation` with status `Rejected { at: now }` or `Revoked { at: now }` respectively, under `((family_id, node_id), counter)` using the next free per-`(family, node)` archive slot;
|
||||
- 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.
|
||||
|
||||
#### Scenario: Owner revokes a still-pending invitation
|
||||
- **WHEN** family owner sends `RevokeFamilyInvitation { node_id }` for a node currently in their pending invitations
|
||||
- **THEN** the pending entry is removed and the archive contains the invitation with status `Revoked { at: env.block.time.seconds() }`
|
||||
|
||||
#### Scenario: Node controller rejects a still-pending invitation
|
||||
- **WHEN** node controller sends `RejectFamilyInvitation { family_id, node_id }` for a pending invitation
|
||||
- **THEN** the pending entry is removed and the archive contains the invitation with status `Rejected { at: env.block.time.seconds() }`
|
||||
|
||||
#### Scenario: Expired invitations can still be rejected or revoked
|
||||
- **WHEN** an invitation's `expires_at` is at or before `env.block.time.seconds()` and either `RejectFamilyInvitation` or `RevokeFamilyInvitation` is sent for it
|
||||
- **THEN** the call succeeds, the pending entry is removed, and the archive records the appropriate terminal status
|
||||
|
||||
### Requirement: Leave and kick remove the membership and archive a past-member record
|
||||
|
||||
`ExecuteMsg::LeaveFamily { node_id }` SHALL require the sender to be the controller of `node_id` (failing with `SenderDoesntControlNode` otherwise). `ExecuteMsg::KickFromFamily { node_id }` SHALL require the sender to be the current owner of a family, and the node MUST be a member of *that* family — kicking a node belonging to a different family fails with `NodeNotMemberOfFamily { node_id, family_id }`; kicking a node that has no membership at all fails with `NodeNotInFamily { node_id }`. Both handlers SHALL share the same storage operation: remove `family_members[node_id]`, decrement `NodeFamily::members`, and archive a `PastFamilyMember { family_id, node_id, removed_at: env.block.time.seconds() }` under `((family_id, node_id), counter)` using the next free per-`(family, node)` slot. The respective events are `family_member_left` (carrying the leaver's `family_id` and `node_id`) and `family_member_kicked` (carrying the owner's `family_id` and the kicked `node_id`).
|
||||
|
||||
#### Scenario: Node controller leaves the family
|
||||
- **WHEN** node controller sends `LeaveFamily { node_id }` for a node currently in family `F`
|
||||
- **THEN** `family_members[node_id]` is removed, `F.members` is decremented, the archive contains a `PastFamilyMember` with `removed_at = env.block.time.seconds()`, and the response carries a `family_member_left` event
|
||||
|
||||
#### Scenario: Owner kicks a member of their family
|
||||
- **WHEN** family owner sends `KickFromFamily { node_id }` for a member of their family
|
||||
- **THEN** the same storage transition as a leave occurs and the response carries a `family_member_kicked` event
|
||||
|
||||
#### Scenario: Owner cannot kick a node belonging to another family
|
||||
- **WHEN** family owner sends `KickFromFamily { node_id }` for a node whose membership is in a different family `G`
|
||||
- **THEN** the call fails with `NodeNotMemberOfFamily { node_id, family_id: owned.id }` and storage is unchanged
|
||||
|
||||
#### Scenario: Repeated join/leave of the same family uses sequential archive counters
|
||||
- **WHEN** node `n` joins, leaves, joins again, and leaves again the same family `F`
|
||||
- **THEN** the archive contains `past_family_members[((F.id, n), 0)]` and `past_family_members[((F.id, n), 1)]` with the respective `removed_at` timestamps
|
||||
|
||||
### Requirement: Only the configured mixnet contract may invoke the unbonding callback
|
||||
|
||||
`ExecuteMsg::OnNymNodeUnbond { node_id }` SHALL be authorised solely by checking `info.sender == stored mixnet_contract_address`, failing with `UnauthorisedMixnetCallback { sender }` otherwise. There is no node-side or admin override.
|
||||
|
||||
#### Scenario: Mixnet contract triggers the callback
|
||||
- **WHEN** the mixnet contract sends `OnNymNodeUnbond { node_id }`
|
||||
- **THEN** the call proceeds and the cleanup (see next requirement) is applied
|
||||
|
||||
#### Scenario: Arbitrary sender is rejected
|
||||
- **WHEN** any address other than the stored mixnet contract sends `OnNymNodeUnbond`
|
||||
- **THEN** the call fails with `UnauthorisedMixnetCallback { sender }`
|
||||
|
||||
### Requirement: The unbonding callback is idempotent over membership and sweeps every pending invitation addressed to the node
|
||||
|
||||
A successful `OnNymNodeUnbond { node_id }` SHALL:
|
||||
|
||||
- if `family_members[node_id]` exists, remove the membership and archive a `PastFamilyMember` exactly as the `leave` / `kick` path does (decrementing the family's `members` count);
|
||||
- if no such membership exists, leave membership state untouched (this is the common case — most unbonding nodes were never in a family);
|
||||
- iterate every entry of `pending_family_invitations` keyed by `node_id` (via the `node` multi-index), remove each from the pending map, and archive each as `PastFamilyInvitation { invitation, status: Rejected { at: env.block.time.seconds() } }` using the next free per-`(family, node)` counter;
|
||||
- emit a `family_node_unbond_cleanup` event with `node_id` attribute.
|
||||
|
||||
The auto-cleared invitations share the `Rejected` terminal state with invitations the node controller would have explicitly declined — `Revoked` is reserved for owner-side actions.
|
||||
|
||||
**Operational note (non-normative)**: like the disband sweep, the per-node invitation sweep iterates every pending invitation addressed to `node_id` and archives each in a single transaction. Gas cost therefore scales linearly with the number of outstanding invitations the unbonding node holds. If a node has accumulated an unusually large number of pending invitations, the operator-initiated unbond transaction on the mixnet contract may fail because *this* callback exceeds the per-tx gas limit. The fix is operator-side: explicitly `RejectFamilyInvitation` the outstanding invitations (in batches if needed) before retrying the unbond. There is no contract-side chunking, and the mixnet contract has no path to bypass or partial-apply the callback.
|
||||
|
||||
#### Scenario: Unbonding node with no family and no pending invitations is a no-op
|
||||
- **WHEN** `OnNymNodeUnbond { node_id }` is invoked for a node with no membership and no pending invitations
|
||||
- **THEN** the call succeeds, no storage is changed (apart from emitting the cleanup event), and no error is returned
|
||||
|
||||
#### Scenario: Unbonding node in a family loses its membership
|
||||
- **WHEN** `OnNymNodeUnbond { node_id }` is invoked for a node currently in family `F`
|
||||
- **THEN** `family_members[node_id]` is removed, `F.members` is decremented, and a `PastFamilyMember` is archived with `removed_at = env.block.time.seconds()`
|
||||
|
||||
#### Scenario: Unbonding node's pending invitations are archived as Rejected
|
||||
- **WHEN** node `n` has pending invitations from families `F1` and `F2` and `OnNymNodeUnbond { node_id: n }` is invoked
|
||||
- **THEN** both pending entries are removed and `past_family_invitations[((F1.id, n), …)]` and `past_family_invitations[((F2.id, n), …)]` each carry `status = Rejected { at: env.block.time.seconds() }`
|
||||
|
||||
### Requirement: Expired pending invitations remain in storage until explicitly cleared
|
||||
|
||||
The contract SHALL NOT run any background sweep of expired pending invitations. A `FamilyInvitation` whose `expires_at <= env.block.time.seconds()` SHALL remain in `pending_family_invitations` until either the family owner revokes it, the node controller rejects it, 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
|
||||
- **THEN** the result includes the entry with `expired = true`
|
||||
|
||||
#### Scenario: Expired invitation can be revoked or rejected but not accepted
|
||||
- **WHEN** an expired invitation exists for `(F.id, n)`
|
||||
- **THEN** `AcceptFamilyInvitation` fails with `InvitationExpired`, but `RevokeFamilyInvitation` (by `F`'s owner) and `RejectFamilyInvitation` (by `n`'s controller) both succeed and archive the invitation with the corresponding terminal status
|
||||
|
||||
### Requirement: Per-`(family, node)` archive slots use sequential counters starting at 0
|
||||
|
||||
Both `past_family_invitations` and `past_family_members` SHALL be keyed by `((family_id, node_id), counter: u64)`. The contract SHALL maintain explicit per-`(family, node)` counter maps (`past_family_invitation_counter`, `past_family_member_counter`), starting at `0` for each new pair and incrementing by `1` on each archival write. Counters MUST be independent across distinct `(family, node)` pairs and across the two archive types.
|
||||
|
||||
#### Scenario: First archive slot for a new pair is 0
|
||||
- **WHEN** a node and family appear in either archive for the first time
|
||||
- **THEN** the entry is keyed with `counter = 0`
|
||||
|
||||
#### Scenario: Distinct pairs have independent counters
|
||||
- **WHEN** archive entries are written under keys `(F1, n)` and `(F2, n)` in any order
|
||||
- **THEN** each pair's counter starts independently at `0`
|
||||
|
||||
### Requirement: GetFamilyMembership returns the family id a node belongs to, or None
|
||||
|
||||
`QueryMsg::GetFamilyMembership { node_id }` SHALL return `NodeFamilyMembershipResponse { node_id, family_id }` where `family_id` is `Some(NodeFamilyId)` iff `family_members[node_id]` is populated, and `None` otherwise. The query MUST NOT fail when `node_id` is unknown — it returns `family_id: None`. The query is the canonical way for route-selection consumers to check whether two node ids belong to the same family without scanning members.
|
||||
|
||||
#### Scenario: Member node returns its family id
|
||||
- **WHEN** node `n` is currently a member of family `F` and `GetFamilyMembership { node_id: n }` is queried
|
||||
- **THEN** the response carries `family_id = Some(F.id)`
|
||||
|
||||
#### Scenario: Non-member node returns None
|
||||
- **WHEN** `GetFamilyMembership { node_id }` is queried for a node that has no membership record (never joined, or has since left/been kicked/unbonded)
|
||||
- **THEN** the response carries `family_id = None` and `node_id` echoed back
|
||||
|
||||
### Requirement: Single-family-by-id, by-name, and by-owner queries return an `Option<NodeFamily>`
|
||||
|
||||
The contract SHALL expose three single-family lookups via `QueryMsg::GetFamilyById`, `GetFamilyByName`, and `GetFamilyByOwner`, each returning a response that echoes the queried key back to the caller for correlation and a `family: Option<NodeFamily>` field that is `None` when no match exists. `GetFamilyByName` MUST normalise the input via the same `normalise_family_name` function the create path uses before consulting the unique index. `GetFamilyByOwner` MUST bech32-validate the input.
|
||||
|
||||
#### Scenario: GetFamilyByName is invariant under input formatting
|
||||
- **WHEN** family `F` exists with `name = "MyFamily"` and `GetFamilyByName { name: "my family" }` is queried
|
||||
- **THEN** the response carries `family = Some(F)` (the lookup hits the unique normalised-name index)
|
||||
|
||||
#### Scenario: Missing match returns None
|
||||
- **WHEN** any of the three single-family queries is called with a key that matches no family
|
||||
- **THEN** the response carries `family = None` and echoes the queried key
|
||||
|
||||
#### Scenario: GetFamilyByOwner rejects an invalid bech32 address
|
||||
- **WHEN** `GetFamilyByOwner { owner }` is called with a non-bech32 string
|
||||
- **THEN** the call returns an error sourced from `Addr::validate`
|
||||
|
||||
### Requirement: Paginated queries use exclusive `start_after`, default limit 50, max limit 100
|
||||
|
||||
Every paginated query (`GetFamiliesPaged`, `GetFamilyMembersPaged`, `GetAllFamilyMembersPaged`, `GetPendingInvitationsForFamilyPaged`, `GetPendingInvitationsForNodePaged`, `GetAllPendingInvitationsPaged`, `GetPastInvitationsForFamilyPaged`, `GetPastInvitationsForNodePaged`, `GetAllPastInvitationsPaged`, `GetPastMembersForFamilyPaged`, `GetPastMembersForNodePaged`) SHALL accept `start_after: Option<Cursor>` (exclusive) and `limit: Option<u32>`. The default `limit` SHALL be `50` and SHALL be clamped to `100` as a hard cap. Results SHALL be in ascending cursor order. Each response SHALL include a `start_next_after: Option<Cursor>` derived from the last entry of the page, with `None` indicating the page is empty (the caller treats this as end-of-list).
|
||||
|
||||
Per-family / per-node paginated queries SHALL NOT verify that the supplied `family_id` or `node_id` corresponds to an existing entity — an unknown id simply yields an empty page.
|
||||
|
||||
#### Scenario: Limit is defaulted and clamped
|
||||
- **WHEN** any paginated query is called with `limit = None`
|
||||
- **THEN** at most 50 entries are returned
|
||||
- **AND** when called with `limit = Some(10_000)` at most 100 entries are returned
|
||||
|
||||
#### Scenario: Empty page signals end-of-list
|
||||
- **WHEN** a paginated query has no more entries past the supplied `start_after`
|
||||
- **THEN** the response carries an empty entry list and `start_next_after = None`
|
||||
|
||||
#### Scenario: Unknown scope id yields an empty page rather than an error
|
||||
- **WHEN** `GetFamilyMembersPaged { family_id: 999_999, .. }` is queried for a non-existent family id
|
||||
- **THEN** the response carries an empty `members` list and `start_next_after = None` (no error is returned)
|
||||
|
||||
### Requirement: Pending-invitation queries stamp each entry with its expiry flag
|
||||
|
||||
Queries returning pending invitations (`GetPendingInvitation`, `GetPendingInvitationsForFamilyPaged`, `GetPendingInvitationsForNodePaged`, `GetAllPendingInvitationsPaged`) SHALL wrap each invitation in `PendingFamilyInvitationDetails { invitation, expired }` where `expired = env.block.time.seconds() >= invitation.expires_at`. Past-invitation and past-member queries do not carry this flag (they are terminal-state archives).
|
||||
|
||||
#### Scenario: Future-dated invitation is marked not expired
|
||||
- **WHEN** a pending invitation has `expires_at = env.block.time.seconds() + 60` and any pending-invitation query returns it
|
||||
- **THEN** the returned `PendingFamilyInvitationDetails.expired` is `false`
|
||||
|
||||
#### Scenario: Past-dated invitation is marked expired
|
||||
- **WHEN** a pending invitation has `expires_at = env.block.time.seconds() - 1`
|
||||
- **THEN** any pending-invitation query that returns it sets `expired = true`
|
||||
|
||||
### Requirement: Storage-key constants are part of the public contract
|
||||
|
||||
Every constant exported under `nym_node_families_contract_common::constants::storage_keys` SHALL be treated as part of the public contract surface. Off-chain indexers and migration tooling may depend on these exact byte strings. Changing the value of any existing constant — including renaming a namespace — SHALL be considered a breaking change for already-deployed contracts and SHALL be accompanied by a data migration via `queued_migrations`. Adding new constants for new storage maps is non-breaking. The current set covers the contract-level items (admin, config, mixnet contract address, family id counter), the primary maps (`families`, `node-family-members`, `invitations`, `past-invitations`, `past-family-member`), every secondary index (`__owner`, `__name`, `__family`, `__node`), and the per-`(family, node)` archive counters — refer to `constants.rs` for the authoritative list.
|
||||
|
||||
#### Scenario: Storage key constants are reachable from the common crate
|
||||
- **WHEN** an off-chain consumer imports `nym_node_families_contract_common::constants::storage_keys`
|
||||
- **THEN** it observes the full set of storage-key constants the contract uses, without taking a dependency on the contract crate itself
|
||||
|
||||
### Requirement: Emitted events form a stable public surface
|
||||
|
||||
Every event name and attribute key exported under `nym_node_families_contract_common::constants::events` SHALL be treated as part of the public contract surface. Each successful **state-mutating user-facing execute path** (every variant of `ExecuteMsg` except `UpdateConfig`, which is an admin handler that returns `Response::default()` without an event) SHALL emit exactly one event whose name and attribute keys come from these constants. Renaming an event name constant, renaming an attribute-key constant, or changing the set of attributes a given event carries SHALL be treated as breaking changes. Adding new constants for new events / attributes is non-breaking.
|
||||
|
||||
At the time of this spec the constant surface comprises (refer to `constants.rs` for the authoritative list):
|
||||
|
||||
- `family_creation` — attributes: `family_name`, `owner_address`, `family_id`, `paid_fee`
|
||||
- `family_disband` — attributes: `family_id`, `owner_address`, `refunded_fee`
|
||||
- `family_invitation` — attributes: `family_id`, `node_id`, `expires_at`
|
||||
- `family_invitation_revoked` — attributes: `family_id`, `node_id`
|
||||
- `family_invitation_accepted` — attributes: `family_id`, `node_id`
|
||||
- `family_invitation_rejected` — attributes: `family_id`, `node_id`
|
||||
- `family_member_left` — attributes: `family_id`, `node_id`
|
||||
- `family_member_kicked` — attributes: `family_id`, `node_id`
|
||||
- `family_node_unbond_cleanup` — attributes: `node_id`
|
||||
|
||||
**Note for indexer authors (non-normative)**: CosmWasm wraps user-emitted events in a `wasm-` prefix when they land in the tendermint event log. So `family_creation` appears as `wasm-family_creation` on the chain side, `family_invitation` as `wasm-family_invitation`, etc. The constants in `constants::events` are the *unprefixed* names — indexers querying the chain need to add the `wasm-` prefix themselves (or use the prefix-stripping helpers most Cosmos indexer SDKs already provide).
|
||||
|
||||
#### Scenario: Each successful state-mutating execute path emits exactly its named event
|
||||
- **WHEN** any of the execute paths listed above succeeds (i.e. every `ExecuteMsg` variant other than `UpdateConfig`)
|
||||
- **THEN** the response carries exactly one event whose `ty` equals the corresponding constant from `constants::events` and whose attributes include each of the listed keys
|
||||
|
||||
#### Scenario: UpdateConfig is the exception — no event is emitted
|
||||
- **WHEN** the admin sends `ExecuteMsg::UpdateConfig` and the call succeeds
|
||||
- **THEN** the response is `Response::default()` and carries no event (this is intentional — `UpdateConfig` is administrative metadata, not a tracked state transition)
|
||||
@@ -0,0 +1,33 @@
|
||||
## 1. Verify spec coverage against the live contract
|
||||
|
||||
- [x] 1.1 Compare each `ExecuteMsg` variant in `common/cosmwasm-smart-contracts/node-families-contract/src/msg.rs` against the spec to confirm every execute path has a corresponding requirement with scenarios.
|
||||
- [x] 1.2 Compare each `QueryMsg` variant against the spec to confirm every read path is covered (single-family lookups, current-membership listings, pending-invitation listings, past-invitation archive listings, past-member archive listings).
|
||||
- [x] 1.3 Compare each `NodeFamiliesContractError` variant against the spec to confirm every error has a scenario that triggers it, and that the error name in the scenario matches the enum variant exactly.
|
||||
- [x] 1.4 Confirm every event name and attribute key in `nym_node_families_contract_common::constants::events` is named verbatim in the spec's events requirement.
|
||||
- [x] 1.5 Confirm every storage-key constant in `nym_node_families_contract_common::constants::storage_keys` is named verbatim in the spec's storage-keys requirement.
|
||||
|
||||
## 2. Cross-check against the contract unit tests
|
||||
|
||||
- [x] 2.1 For each `#[test]` in `contracts/node-families/src/storage/mod.rs`, identify the requirement(s) and scenario(s) in the spec that it exercises. Flag any test that asserts behaviour not yet captured in the spec.
|
||||
- [x] 2.2 Do the same for `contracts/node-families/src/transactions.rs` tests (handler-level).
|
||||
- [x] 2.3 Do the same for `contracts/node-families/src/queries.rs` tests.
|
||||
- [x] 2.4 Do the same for `contracts/node-families/src/helpers.rs` tests (notably `normalise_family_name`).
|
||||
- [x] 2.5 For each spec scenario that has no corresponding contract unit test, decide whether to add a test or annotate the scenario as covered indirectly.
|
||||
|
||||
## 3. Validate via openspec tooling
|
||||
|
||||
- [x] 3.1 Run `openspec validate node-families-contract-spec` and confirm it reports "valid".
|
||||
- [x] 3.2 Run `openspec show node-families-contract-spec` and review the rendered output for readability and section ordering.
|
||||
- [x] 3.3 Run `openspec status --change node-families-contract-spec` and confirm `applyRequires` is satisfied (`tasks` artifact present, all dependencies done).
|
||||
|
||||
## 4. Reviewer pass
|
||||
|
||||
- [x] 4.1 Walk through `proposal.md` with a reviewer to confirm the "Why" and "Capabilities" sections reflect the team's understanding of what node families is for.
|
||||
- [x] 4.2 Walk through `design.md` Decisions 1–10 with a reviewer to confirm each rationale matches the team's reasoning at the time the contract was built (anchor commit `a21a01cf1a`).
|
||||
- [x] 4.3 Walk through `specs/node-families-contract/spec.md` requirement by requirement; for each disagreement, decide whether the spec is wrong (update the spec) or the implementation is wrong (open a follow-on change).
|
||||
- [x] 4.4 Resolve the three Open Questions in `design.md` or move them to a follow-on change.
|
||||
|
||||
## 5. Archive the change
|
||||
|
||||
- [x] 5.1 Once reviewed and accepted, run `openspec archive node-families-contract-spec` to promote `specs/node-families-contract/spec.md` into `openspec/specs/node-families-contract/spec.md` as the canonical spec.
|
||||
- [x] 5.2 Confirm the archived spec is the one referenced by future delta specs (route-policy, operator-verification, etc.).
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-20
|
||||
@@ -0,0 +1,186 @@
|
||||
## Context
|
||||
|
||||
The ecash CosmWasm contract is the on-chain leg of the ticketbook credential protocol. It receives deposits from clients, mints a globally-unique sequential `deposit_id`, persists the depositor-claimed ed25519 identity public key, and exposes that record to off-chain nym-api signers that perform the blind-signing protocol. The contract is implemented in `contracts/ecash/` with the shared message / type / event surface in `common/cosmwasm-smart-contracts/ecash-contract/`, and is built on the [`sylvia`](https://docs.cosmwasm.com/sylvia) macro framework rather than hand-rolled `instantiate`/`execute`/`query` dispatchers.
|
||||
|
||||
The contract has shipped, is in active use, and integrates with two other contracts in the workspace: a `cw4` group contract (referenced via `Config::group_addr`) and a `cw3`-style multisig contract (referenced via the `multisig` `Admin` slot). The mixnet contract is **not** in the trust path here — that is a node-families concern.
|
||||
|
||||
This document captures the architectural choices behind the contract as it exists today, so reviewers, integrators (nym-api signers, gateways, indexers), and future maintainers have a single normative reference. There is no behaviour change being proposed.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Capture the trust boundary between the contract and nym-api signers: which checks the contract owns (deposit price, deposit-id uniqueness, persistence of claimed pubkey) and which it relies on signers to perform off-chain (ed25519 ownership proof, double-issue prevention, blacklist enforcement).
|
||||
- Document the **authorisation model**: `contract_admin` is a real admin (pricing / whitelist mutations, replaceable via `UpdateAdmin`); `multisig` is a stored cw3 contract pointer that gates `RedeemTickets`. Both happen to live in `cw_controllers::Admin` slots, but the wrapper is used only as a generic address-equality helper for the second.
|
||||
- Document the **tiered-pricing data model** (default deposit + per-address `reduced_deposits` overrides) and the statistics invariant tying them together.
|
||||
- Document the **raw-bytes deposit storage** shortcut and its trade-offs.
|
||||
- Document the **stubbed blacklist surface** as part of the public schema, including the dead-but-wired code paths.
|
||||
- Document the **storage-key, event, and reply-id constants** that form the contract's external interface for indexers and upgrades.
|
||||
|
||||
**Non-Goals:**
|
||||
- The off-chain blind-signature protocol or nym-api signer state machine (`already_issued`, blacklist enforcement, DKG epoch gating, partial-signature aggregation).
|
||||
- The cw3 multisig contract internals (proposal voting, threshold logic).
|
||||
- The cw4 group contract internals.
|
||||
- The ticketbook protocol itself (zkSNARK construction, partial vs aggregate signature combination, expiration date signatures, coin-indices signatures).
|
||||
- The eventual pool contract that `holding_account` is reserved for — its scope and storage transition is out of scope here.
|
||||
- Re-enabling the blacklist. The redesign is acknowledged as future work; the spec captures the *current* surface, which is stubbed.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: `cw_controllers::Admin` is used as a generic address-equality helper, not as a "two admins" model
|
||||
|
||||
**Choice.** The contract has two `cw_controllers::Admin` slots, but only one is an admin in the operational sense:
|
||||
|
||||
- `contract_admin` — a real admin. A single Cosmos SDK address that gates pricing mutations (`UpdateDefaultDepositValue`, `SetReducedDepositPrice`, `RemoveReducedDepositPrice`) and admin transfer (`UpdateAdmin`). Set on instantiation to the message sender. Replaceable via the `cw_controllers::Admin::execute_update_admin` handshake (driven by `UpdateAdmin`).
|
||||
- `multisig` — a stored pointer to the cw3 multisig contract. Used only for its address-equality mechanics: `assert_admin(deps, sender)` reduces to "is the caller equal to the stored address?", which on the ecash contract's side is the entire gating logic for `RedeemTickets`. There is no admin-transfer handshake on this slot and no execute path that mutates it.
|
||||
|
||||
**Why this shape.** Putting `multisig` in an `Admin` slot is a misuse of the wrapper's name — `cw_controllers::Admin` was designed for full admin semantics (set / get / assert / transfer), but the contract uses only `set` (once, at instantiation), `get` (in `must_get_multisig_addr`), and `assert_admin` (in `RedeemTickets`). The wrapper happened to be a convenient "remember-this-address-and-let-us-check-equality-later" helper. A reviewer reading `self.multisig.assert_admin(...)` should mentally substitute "is the caller our configured cw3 contract?" — there is no internal multi-signature scheme on the ecash contract itself.
|
||||
|
||||
**Alternative considered.** A plain `Item<Addr>` for the multisig pointer with a small inline `assert_caller_is_multisig` helper. Equivalent at runtime; would have made the naming honest. Not changed because storage key `"multisig"` is part of the public surface and renaming it would require a coordinated migration.
|
||||
|
||||
**Consequence.** The `"multisig"` storage key is preserved (renaming would break already-deployed contracts) and the spec describes the actual mechanics rather than the suggestive wrapper name. The `multisig` slot's `Admin`-shape transfer machinery is reachable in code but is never exercised by any execute path. New reviewers should read the wrapper choice as historical, not as a statement about authorisation design.
|
||||
|
||||
### Decision 2: The contract stores the claimed ed25519 pubkey but never verifies control of the private key
|
||||
|
||||
**Choice.** `DepositTicketBookFunds { identity_key }` accepts a bs58-encoded ed25519 public key as an opaque string and persists it under the deposit's id. The contract performs only schema-level validation (the bs58 string must decode to exactly 32 bytes when read back via `Deposit::to_bytes` / `try_from_bytes`).
|
||||
|
||||
**Why.** Verifying possession of the corresponding private key on-chain would require either a signature over a contract-supplied challenge (round-trip + chain-state machinery that CosmWasm does not provide ergonomically) or trusting the depositor's Cosmos SDK transaction signature, which is keyed on a separate address and proves nothing about the supplied ed25519 key. The ownership proof is naturally produced where the key is *used*: in the `BlindSignRequestBody` that the depositor later sends to nym-api signers, where they bind a request-specific signature to the claimed key.
|
||||
|
||||
**Consequence.** Anyone willing to pay the deposit can submit a deposit claiming any ed25519 pubkey. nym-api signers MUST verify the ed25519 ownership proof at `post_blind_sign` time before issuing a partial blind signature; the contract is not the enforcement point. Spec scenarios make this explicit so that future readers do not mistake the contract for an authentication boundary.
|
||||
|
||||
### Decision 3: Deposit-issue de-duplication is per-signer-local, not on-chain
|
||||
|
||||
**Choice.** Each `deposit_id` is unique globally (monotonic `u32` counter), but the contract has no notion of "issued" or "consumed." It is the responsibility of each nym-api signer to maintain its own ledger (`state.already_issued(deposit_id)`) and return the cached blinded signature instead of re-signing.
|
||||
|
||||
**Why.** The blind-signature protocol is partial per signer — different signers operate independently and must each refuse to re-issue against the same deposit. A contract-side "issued" flag would either need every signer to write back (cross-contract write storm, signer-trust assumption) or would only enforce single-issuance globally without preventing a malicious signer from re-issuing locally. Local ledgers solve the actual threat (a signer being tricked into issuing twice) at the right layer.
|
||||
|
||||
**Consequence.** A deposit can be queried any number of times and its `(deposit_id, identity_key)` pair is always retrievable. Replay protection at the credential layer is entirely a signer concern. The spec calls this out as a deliberate boundary so that the absence of an "issued" state on the contract is not read as a bug.
|
||||
|
||||
### Decision 4: Deposits are stored as raw 32-byte ed25519 pubkeys under a custom namespace, bypassing `cw_storage_plus` JSON serialisation
|
||||
|
||||
**Choice.** `DepositStorage` writes deposits via `storage.set(&storage_key, &bytes)` where `bytes` is the 32-byte raw ed25519 pubkey extracted from the bs58 string. The namespace is `b"deposit"`, keyed by the big-endian `u32` deposit id. Reads use a matching custom `StoredDeposits` reader. This sits alongside a normal `cw_storage_plus::Item<DepositId>` for the counter (`"deposit_ids"`).
|
||||
|
||||
**Why.** A bs58-encoded ed25519 public key is ~44 bytes; raw is exactly 32 bytes. The JSON-serialised `Deposit { bs58_encoded_ed25519_pubkey: "..." }` adds the field-name overhead on top. Over a contract lifetime that may accumulate hundreds of thousands of deposits, the savings compound and meaningfully reduce storage gas for paginated reads.
|
||||
|
||||
**Alternative considered.** A standard `Map<DepositId, Deposit>` keyed on `DepositId`. Rejected for storage cost; the duplication of a thin custom reader/writer is judged worth the gas savings.
|
||||
|
||||
**Consequence.** The `deposit` namespace is *not* a `cw_storage_plus::Map` — it cannot be iterated via `Map::range` and its key encoding is bespoke (`Path::new(b"deposit", ...)`). Any future migration that needs to walk deposits must use `StoredDeposits::deserialize_deposit_record`. Audit notes: this is the one spot where the contract steps outside the framework, deliberately. The spec lists `"deposit"` as a public storage namespace alongside `"deposit_ids"`.
|
||||
|
||||
### Decision 5: Deposit ids are sequential `u32`, start at 0, and the counter holds the *next* id
|
||||
|
||||
**Choice.** The counter `Item<DepositId>` (`"deposit_ids"`) is unset on a fresh contract and treated as zero. `next_id` returns the current value (the id assigned to the new deposit) and persists `current + 1`. `total_deposits_made` reads the counter directly — it always equals the number of deposits already performed. `latest_deposit` returns the counter as-is too, which is "the *next* id" — currently consumed only via `GetLatestDeposit`, which re-loads the deposit at that id (yielding `None` if the contract has not yet seen a deposit).
|
||||
|
||||
**Why.** Counting via the "next id" convention costs zero extra storage compared to a separate count field. The first deposit getting id `0` (rather than `1`) is convenient for protobuf-style optional defaults but is otherwise immaterial.
|
||||
|
||||
**Consequence.** A consumer that calls `GetLatestDeposit` on a fresh contract receives `LatestDepositResponse { deposit: None }`. The id space is `u32`; overflow at billions of deposits is not a practical concern but is technically unbounded.
|
||||
|
||||
### Decision 6: Three-tier pricing with explicit fall-back rules
|
||||
|
||||
**Choice.** Each `DepositTicketBookFunds` is classified at deposit time:
|
||||
|
||||
1. If the sent amount equals the configured **default** deposit, the deposit is treated as a default-price deposit (regardless of whether the sender is in the reduced-deposit whitelist).
|
||||
2. Otherwise, if the sender has a reduced-deposit entry and the sent amount equals that reduced price, the deposit is treated as a reduced-price deposit and attributed to the sender's per-account counters.
|
||||
3. Otherwise, the transaction errors with `EcashContractError::WrongAmount { received, amount }`, where `amount` is the reduced amount (if the sender is whitelisted) or the default amount.
|
||||
|
||||
**Why.** Whitelisted accounts often run automation that pre-dates their whitelist entry, sending the default amount. Treating that as "wrong amount" would break in-flight integrations; treating it as a default-price deposit gracefully degrades. Conversely, a whitelisted account that pays the *wrong* reduced amount is almost certainly misconfigured and should be told so explicitly.
|
||||
|
||||
**Consequence.** The statistics buckets (`deposits_with_default_price` vs `deposits_with_custom_price`) reflect what was actually paid, not the account's tier. The reduced-price storage map (`reduced_deposits`) is consulted only when classifying a non-default amount; it is never auto-applied. The invariant captured under Decision 7 binds these together.
|
||||
|
||||
### Decision 7: Statistics invariant: `default_count + sum(custom_count_per_account) == total_deposits_made`
|
||||
|
||||
**Choice.** Three accumulators run in parallel on every deposit:
|
||||
|
||||
- `PoolCounters.total_deposited` (`Item<Coin>`) — global value across all tiers; used by the eventual pool-contract migration.
|
||||
- `DepositStatsStorage.deposits_with_default_price` (`Item<u32>`) and `..._amounts` (`Item<Coin>`) — global default-price totals.
|
||||
- `DepositStatsStorage.deposits_with_custom_price` (`Map<Addr, u32>`) and `..._amounts` (`Map<Addr, Coin>`) — per-account custom-price totals.
|
||||
|
||||
The contract test suite asserts `default_count + custom_total_count == deposits.total_deposits_made()` after every deposit. `total_deposits_made` is derived from the deposit-id counter (Decision 5), tying everything back to a single source of truth.
|
||||
|
||||
**Why.** The query `GetDepositsStatistics` reassembles the picture by joining all three; without the invariant, indexers could observe inconsistencies (default + custom < total, or > total) during multi-tx blocks. Maintaining the invariant requires that *all* deposit writes go through `deposit_ticket_book_funds`. Raw storage writes (`storage.set(...)`) on the `deposit` namespace would silently break this.
|
||||
|
||||
**Consequence.** Future code that touches the `deposits` storage *must* also update the corresponding stats counter, or it violates the invariant. The migration path (`queued_migrations::add_tiered_pricing`) handles this by reading the pre-migration totals and backfilling them into `deposits_with_default_price[_amounts]` (since all pre-migration deposits were at the default price). The `assert_counts_consistent` test helper is shipped under `#[cfg(test)]` as the canonical post-deposit assertion.
|
||||
|
||||
### Decision 8: `expected_invariants.ticket_book_size` as a coordination tripwire
|
||||
|
||||
**Choice.** On instantiation, the contract snapshots `nym_network_defaults::TICKETBOOK_SIZE` into `Item<Invariants> { ticket_book_size }` (`"expected_invariants"`). Every code path that consults the ticketbook size (`get_ticketbook_size`) re-reads the stored value and compares it to the *current* crate constant; on mismatch it errors with `TicketBookSizeChanged { at_init, current }` — a guaranteed-loud failure intended to be impossible to silently ignore.
|
||||
|
||||
**Why.** The ticketbook size is a network-wide protocol parameter that pricing decisions bake in (default deposit min, reduced deposit min). If `nym-network-defaults` ever ships a new constant without a coordinated contract migration, every priced operation will fail loudly rather than silently mis-price. The check is a one-storage-read tax per priced operation in exchange for a hard-to-misconfigure deploy.
|
||||
|
||||
**Consequence.** Bumping `TICKETBOOK_SIZE` in network-defaults is a coordinated migration: deploy a new contract version, run `MigrateMsg`, which must overwrite `expected_invariants` with the new constant. The current `migrate` handler does *not* do this — it would need an additional queued migration. The spec captures the storage key and the error variant so the failure mode is discoverable.
|
||||
|
||||
### Decision 9: Reply IDs are hard-coded numeric constants with no schema metadata
|
||||
|
||||
**Choice.** `BLACKLIST_PROPOSAL_REPLY_ID = 7759` and `REDEMPTION_PROPOSAL_REPLY_ID = 2137`. The reply dispatcher returns `EcashContractError::InvalidReplyId { id }` for any unknown id.
|
||||
|
||||
**Why.** Reply IDs are private contract metadata — they carry no semantic content for external observers. Hard-coding keeps the dispatch path branchless and is consistent with the rest of the contract codebase. The arbitrary chosen integers do not need to be stable across redeploys, but *are* stable for any given deployed version.
|
||||
|
||||
**Consequence.** Tests that mock submessage replies must use these exact integers. Changing them between contract versions would invalidate any in-flight submessage (the in-flight reply would arrive after a chain restart with a different code id and dispatch to "unknown"). The spec lists them as part of the public surface even though no off-chain consumer reads them.
|
||||
|
||||
### Decision 10: Stubbed blacklist remains in the public schema as wired-but-unreachable code
|
||||
|
||||
**Choice.** `ExecuteMsg::ProposeToBlacklist`, `ExecuteMsg::AddToBlacklist`, `QueryMsg::GetBlacklistedAccount`, `QueryMsg::GetBlacklistPaged`, the `blacklist: Map<BlacklistKey, Blacklisting>` storage, the `Blacklisting` type, the `create_blacklist_proposal` helper, and the `handle_blacklist_proposal_reply` reply handler are all present in source. Both execute handlers short-circuit with `EcashContractError::UnimplementedBlacklisting`; the queries succeed but always return empty results on a freshly deployed contract.
|
||||
|
||||
**Why.** The blacklist is an acknowledged-incomplete feature. Removing the schema entries would be a breaking change for any client that already encodes them; removing the storage would obstruct future migrations. Keeping the wiring with explicit `Err(UnimplementedBlacklisting)` plus commented-out original implementations makes the redesign starting point obvious without exposing partial behaviour.
|
||||
|
||||
**Alternative considered.** Delete the schema variants outright. Rejected because (a) the schema is consumed by `cosmwasm-schema`-generated TypeScript clients and removal would cascade through gateway / indexer codebases, and (b) keeping the entries documents the intended future shape of the contract.
|
||||
|
||||
**Consequence.** The spec captures the stubbed handlers as first-class scenarios that always error, the blacklist queries as scenarios that always succeed with empty data, and the storage map as a public-surface namespace that exists but is unused. Any consumer treating an empty blacklist as a security guarantee is misreading the contract; the only enforcement is the off-chain `aux.ensure_not_blacklisted` check inside nym-api.
|
||||
|
||||
### Decision 11: `RedeemTickets` is a legacy multisig-gated path; `RequestRedemption` is the current entry point
|
||||
|
||||
**Choice.** Two redemption paths coexist:
|
||||
|
||||
- `RequestRedemption { commitment_bs58, number_of_tickets }` — callable by any gateway. Validates `commitment_bs58` is a 32-byte sha256 digest (bs58-decoded). Creates a `Propose` SubMsg to the multisig contract under the title `BATCH_REDEMPTION_PROPOSAL_TITLE = "ecash-redemption"` with `commitment_bs58` as the description. The reply handler records the multisig-issued `proposal_id` and exposes it via `Response::set_data(proposal_id.to_be_bytes())` so the gateway sees it back.
|
||||
- `RedeemTickets { n, gw }` — callable only by the multisig (`self.multisig.assert_admin(...)`). Bumps `pool_counters.tickets_requested_and_not_redeemed += n`. Emits `Event::new("ticket_redemption").add_attribute("moved_to_holding_account", "false")`. The `gw` argument is intentionally unused at runtime — preserved in source comments as `"_ = gw;"` so that chain scrapers can attribute the redemption to a gateway by reading the raw transaction body.
|
||||
|
||||
**Why.** Originally `RedeemTickets` was the only redemption path and moved funds into the holding account. That mechanism was deprecated; the live path is the commitment-anchored `RequestRedemption` flow, which writes the proposal id and lets the multisig run the actual transfer logic. **`RedeemTickets` is dead code that has not been cleaned up yet.** It is still callable from any held multisig grant, still emits a recognisable event, and still bumps `tickets_requested_and_not_redeemed` — but no active code path or operational workflow relies on those side effects. The "_ = gw;" pattern and the `moved_to_holding_account="false"` attribute are remnants of the deprecated semantics, not load-bearing surface.
|
||||
|
||||
**Consequence.** New gateway code should call `RequestRedemption`. `RedeemTickets` is documented in the spec as "legacy, multisig-only, near-noop" to describe current behaviour accurately, without claiming any consumer depends on it. A follow-on cleanup change should consider removing the handler (breaking-schema for any client still encoding `RedeemTickets`, but no live consumer is known to do so). Until then, the spec describes the handler honestly and the rustdoc follow-on can flag it for deletion.
|
||||
|
||||
### Decision 12: Migration backfills default-price statistics from pre-tiered counters
|
||||
|
||||
**Choice.** `MigrateMsg { initial_whitelist }` runs `queued_migrations::add_tiered_pricing`, which:
|
||||
|
||||
1. Reads `deposits.total_deposits_made(storage)` and `pool_counters.total_deposited` from the pre-migration state.
|
||||
2. Writes those values verbatim into `deposit_stats.deposits_with_default_price` and `deposits_with_default_price_amounts`.
|
||||
3. Validates each entry in `initial_whitelist` (denom matches, amount strictly less than default, amount at least the ticketbook size) and saves it into `reduced_deposits`.
|
||||
|
||||
`cw2::ensure_from_older_version` and `set_build_information!` run alongside.
|
||||
|
||||
**Why.** Before the tiered-pricing migration, every deposit was made at the (single) default price. The migration formalises that invariant by populating the default-tier counters with the historical totals, so `GetDepositsStatistics` returns coherent numbers immediately after migration. Validating whitelist entries during migration prevents instantly-broken state on the new code path.
|
||||
|
||||
**Consequence.** The migration is **one-way** — it assumes the pre-migration state had no custom-price counters. Re-running it on an already-migrated state would clobber the default-tier accumulators with the *current* `total_deposits_made` (which now includes custom-price deposits). The spec calls this out; `queued_migrations.rs` is named with the expectation that future deltas will add new entries (each guarded by a version check) rather than mutate `add_tiered_pricing`.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Pubkey-claim spoofing on deposit]** → Anyone with the configured funds can submit a deposit claiming any ed25519 pubkey, exhausting that pubkey's ability to be re-deposited at the same id later. **Mitigation:** nym-api signers verify ownership in `post_blind_sign`; a spoofed deposit is unredeemable. Documented as the trust boundary in Decision 2.
|
||||
- **[Statistics invariant relies on entry-point discipline]** → Any code path that bypasses `deposit_ticket_book_funds` (raw storage writes, direct `next_id` calls in migrations) silently breaks `default + custom == total`. **Mitigation:** invariant enforced by the `assert_counts_consistent` test helper; documented as a maintenance contract.
|
||||
- **[Stubbed blacklist creates a false expectation]** → Operators reading the schema may think the contract enforces blacklisting. **Mitigation:** spec scenarios marked explicit; `UnimplementedBlacklisting` error variant exists specifically to be a discoverable runtime indicator.
|
||||
- **[Multisig address is locked at instantiation]** → If the cw3 contract is redeployed, the ecash contract orphans (redemption proposals can no longer be created against the new multisig). **Mitigation:** documented as an operational constraint; a future migration could add `UpdateMultisigAddress`. Today there is no such path.
|
||||
- **[Holding account is reserved but unused]** → Funds never transfer to `holding_account` in the current contract; the field exists for the future pool-contract transition. **Mitigation:** documented; `Config` query exposes it, so off-chain monitoring can verify it remains the expected address.
|
||||
- **[`RequestRedemption` gas grows with commitment churn]** → Each call creates a multisig proposal. A gateway issuing high-frequency redemptions accrues many open proposals. **Mitigation:** out of scope here; multisig-side concern.
|
||||
- **[Reply-id collisions across upgrades]** → Hard-coded `7759` / `2137` cannot collide today but could if a future upgrade adds a third reply variant. **Mitigation:** noted; new reply ids should pick distinct integers and never reuse old ones.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Not applicable to this change — this is a documentation-only artefact for code that has already shipped. The contract's most recent state-altering migration was `add_tiered_pricing` (Decision 12), which has already been executed on live deployments.
|
||||
|
||||
Future spec deltas that *do* change behaviour should:
|
||||
|
||||
1. Add a new function to `queued_migrations.rs` rather than amending `add_tiered_pricing`.
|
||||
2. Bump `CARGO_PKG_VERSION` in `contracts/ecash/Cargo.toml`.
|
||||
3. Coordinate the `MigrateMsg` invocation with the chain-governance migration transaction that pushes the new code id.
|
||||
4. Document any new storage namespace, event, or reply id under the corresponding spec requirement.
|
||||
|
||||
## Resolved Questions
|
||||
|
||||
Three of the four questions considered during the spec walk-through were resolved on 2026-05-21 by keeping current behaviour:
|
||||
|
||||
- **`holding_account` updatability** → keep locked at instantiation. The eventual pool-contract transition is handled as a fresh contract deploy rather than a mid-life mutation; matches the node-families precedent of treating cross-contract pointers as redeploy events. Avoiding an admin-gated update means admin compromise cannot retarget where future redemption funds would flow.
|
||||
- **`multisig` address updatability** → keep locked at instantiation. Same rationale: if the cw3 multisig is redeployed under a new address, the ecash contract is redeployed alongside it. Not exposing an update path prevents admin compromise from hijacking redemption-finalisation authority.
|
||||
- **`update_admin` renunciation** → keep the handler's mandatory-`Some(new_admin)` shape. Renunciation would leave pricing, whitelist, and admin-gated paths permanently unreachable — a one-way foot-gun with no operational benefit at the contract's current maturity.
|
||||
|
||||
## Open Questions
|
||||
|
||||
One question remains open and is deferred to a follow-on change rather than to this spec:
|
||||
|
||||
- **Stubbed blacklist final disposition.** `ProposeToBlacklist`, `AddToBlacklist`, `GetBlacklistedAccount`, and `GetBlacklistPaged` remain in the public schema; the execute handlers always return `UnimplementedBlacklisting`; the `blacklist` storage map, `Blacklisting` type, `create_blacklist_proposal` helper, and `handle_blacklist_proposal_reply` reply handler are wired but unreachable. The choice between (a) finalising the redesign, (b) removing the stubbed schema entries (breaking change), or (c) leaving them as-is is left to the blacklist redesign change owner. This spec captures the current surface — the stubbed schema is part of the contract surface today and the spec describes that faithfully (Decision 10, Requirement "Stubbed blacklist execute handlers", Requirement "Blacklist queries succeed and return empty").
|
||||
@@ -0,0 +1,48 @@
|
||||
## Why
|
||||
|
||||
The ecash CosmWasm contract (`contracts/ecash/`) is the on-chain anchor of the ticketbook credential pipeline. Clients escrow funds with the contract, which mints a sequential `deposit_id` and persists the ed25519 identity public key that the depositor will later use when requesting a blind signature from nym-api signers (`post_blind_sign` at `nym-api/src/ecash/api_routes/partial_signing.rs:55`). The contract itself does **not** verify ed25519 ownership — that proof lives in `BlindSignRequestBody` and is enforced off-chain; the contract's job is to provide a tamper-evident, gas-efficient on-chain record that signers can read by id. Gateways do not escrow funds here; they only participate on the redemption side (see `RequestRedemption` / `RedeemTickets`).
|
||||
|
||||
The contract has shipped, is live, and exists only as Rust source plus inline comments. Several design choices are non-obvious from reading the code alone and have a habit of being re-derived during incident triage:
|
||||
|
||||
- The use of `cw_controllers::Admin` as a generic address-equality helper for two distinct slots (`contract_admin` and `multisig`). The wrapper is named for the role it was designed for, but the contract uses it only for its `set` / `assert_admin` / `get` mechanics — `multisig` is not an "admin" in any operational sense, it is a stored pointer to the cw3 contract that gates `RedeemTickets`. Only `contract_admin` carries actual admin semantics (replaceable via `UpdateAdmin`).
|
||||
- The deposit-storage shortcut that bypasses `cw_storage_plus` JSON serialisation in favour of raw 32-byte ed25519 representation under the `deposit` namespace.
|
||||
- The tier-stratified bookkeeping (`PoolCounters` + `DepositStatsStorage`) that maintains a global total **and** a per-account custom-price total, while preserving the invariant `default_count + sum(custom_count) == total_deposits_made`.
|
||||
- The blacklist surface is **fully wired but stubbed**: storage, helpers, reply handler, and event attribute exist; both `ProposeToBlacklist` and `AddToBlacklist` short-circuit to `UnimplementedBlacklisting`. This is intentional and the dead branches are preserved in source as commented-out reference for the eventual redesign.
|
||||
- The `RedeemTickets` path is legacy dead code — still callable by the multisig, still bumps a counter and emits an event with `moved_to_holding_account="false"`, but no active code path or consumer depends on those side effects. Retained because it has not been cleaned up yet, not because anything requires it. A follow-on cleanup change should consider removing it (breaking-schema, but no known live consumer).
|
||||
|
||||
Capturing the spec now — while the original author and reviewers are still around — is materially cheaper than reconstructing it from `git blame` later.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Introduce a new capability spec `ecash-contract` covering the on-chain CosmWasm contract: instantiation, deposit submission (default + reduced tier), legacy redemption (`RedeemTickets`), redemption-proposal creation (`RequestRedemption`) and reply handling, admin operations (admin transfer, default deposit value, set/remove reduced price), the stubbed blacklist surface, the full query surface, migration (tiered-pricing backfill), and the storage / event / error surface that downstream tooling treats as a public contract.
|
||||
- Document the **authorisation model**: `contract_admin` (a real admin, replaceable via `UpdateAdmin`) gates pricing / whitelist mutations; `multisig` (a stored cw3 contract pointer using the same `cw_controllers::Admin` wrapper for address-equality only) gates `RedeemTickets`. Per-handler authorisation is enumerated.
|
||||
- Document the **deposit identity semantics**: the contract stores the claimed bs58-encoded ed25519 public key but does not verify control of the corresponding private key — that proof is performed off-chain by nym-api signers when honoring the `post_blind_sign` request.
|
||||
- Document the **anti-double-issue boundary**: the contract guarantees globally-unique sequential `deposit_id`s, but de-duplication of credential issuance per deposit is enforced by each nym-api signer's local store (`state.already_issued`), not by the contract.
|
||||
- Document the **stubbed blacklist surface** as a public-surface fact (current versions always return `UnimplementedBlacklisting`).
|
||||
- Document the **storage layout** (raw key namespaces, `Item`/`Map` keys, the custom raw-bytes encoding used for the `deposit` namespace) and **event surface** (`deposited-funds`, `ticket_redemption`, plus attributes on auto-generated `wasm` events) since indexers and signers consume these as a stable interface.
|
||||
|
||||
No code changes. No migrations. No new dependencies. This is a documentation-only deliverable that ratifies the current implementation as the baseline. A follow-on change `ecash-contract-rustdoc` will tighten in-source rustdoc to mirror the captured spec.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `ecash-contract`: the CosmWasm contract that escrows deposits for ticketbook credentials, mints sequential deposit ids, stores claimed ed25519 identity keys for off-chain signer validation, gates redemption finalisation behind a multisig, and tracks tier-stratified deposit statistics.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
_None — there are no existing specs in `openspec/specs/` for the ecash contract and this change does not alter on-chain behaviour._
|
||||
|
||||
## Impact
|
||||
|
||||
- **Affected code**: none modified. The spec is derived from `contracts/ecash/` and `common/cosmwasm-smart-contracts/ecash-contract/` at HEAD on `develop`.
|
||||
- **Affected consumers** (documented for traceability, not changed):
|
||||
- `nym-api/src/ecash/` — reads deposits by id via `state.get_deposit(deposit_id)`, then at `post_blind_sign` (`partial_signing.rs:55`) enforces (a) per-signer-local double-issue prevention by checking `state.already_issued(deposit_id)` against its local `issued_partial_signature` store and returning the cached signature on a hit, and (b) ed25519 ownership by parsing `deposit.bs58_encoded_ed25519_pubkey` and verifying `request.signature` against the plaintext `IssuanceTicketBook::request_plaintext(&request.inner_sign_request, request.deposit_id)` (`nym-api/src/ecash/deposit.rs::validate_deposit`). Additional gating performed at the same entry point — signer-epoch eligibility (`ensure_signer`), expiration-date sanity, DKG-not-in-progress (`ensure_dkg_not_in_progress`), and an off-chain blacklist check (`aux.ensure_not_blacklisted`) — is independent of contract state.
|
||||
- Clients — submit `DepositTicketBookFunds` to escrow funds and register a claimed ed25519 identity key.
|
||||
- Gateways (legacy + current) — request batch redemption via `RequestRedemption` (current) or receive the transfer via `RedeemTickets` (legacy multisig-gated path); they do not call `DepositTicketBookFunds`.
|
||||
- The cw3 multisig contract — receives `Propose` messages for batch redemption and (eventually) blacklisting; the ecash contract reads back the resulting `proposal_id` in its reply handler.
|
||||
- The cw4 group contract — referenced via `Config::group_addr`; intended to gate the (currently stubbed) blacklist proposal flow to voting members.
|
||||
- Chain indexers — consume the `deposited-funds` event (`deposit-id` attribute), the `ticket_redemption` event, and the `proposal_id` attribute on the auto-generated `wasm` event from redemption-proposal replies.
|
||||
- **Dependencies**: none. CosmWasm storage layout (raw key strings: `"contract_admin"`, `"multisig"`, `"config"`, `"pool_counters"`, `"expected_invariants"`, `"deposit_ids"`, `"deposit"`, `"reduced_deposits"`, `"blacklist"`, `"deposits_with_default_price"`, `"deposits_with_default_price_amounts"`, `"deposits_with_custom_price"`, `"deposits_with_custom_price_amounts"`) is part of the spec surface — changing those keys is a breaking change for already-deployed contracts and must be treated as such by any future delta. The two reply IDs (`BLACKLIST_PROPOSAL_REPLY_ID = 7759`, `REDEMPTION_PROPOSAL_REPLY_ID = 2137`) are similarly load-bearing.
|
||||
- **Non-goals**: the off-chain blind-signature protocol, nym-api signer state machine (`already_issued`, blacklist enforcement, DKG epoch gating), the ticketbook protocol itself, gateway-side commitment construction, the multisig contract internals, and the (currently stubbed) blacklist redesign. These all consume the contract or are consumed by it but live outside its boundary and will get their own specs in follow-on changes.
|
||||
- **Known limitation — blacklist is stubbed**: `ExecuteMsg::ProposeToBlacklist` and `ExecuteMsg::AddToBlacklist` are part of the public schema but always return `EcashContractError::UnimplementedBlacklisting`. The reply handler for `BLACKLIST_PROPOSAL_REPLY_ID`, the `blacklist` storage map, and the `Blacklisting` type are all wired in code but unreachable from the public ExecuteMsg surface. Existing queries (`GetBlacklistedAccount`, `GetBlacklistPaged`) succeed but will return empty results on a freshly deployed contract. The spec documents this as the current contract surface; the redesign is out of scope.
|
||||
@@ -0,0 +1,501 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Contract instantiation persists the runtime config, multisig and admin addresses, and the ticketbook-size invariant
|
||||
|
||||
The contract SHALL be instantiable exactly once via the standard CosmWasm `instantiate` entry point. The instantiation message SHALL carry `holding_account` (a string-form Cosmos SDK address reserved for the future pool-contract transition), `multisig_addr` (the cw3 multisig contract address that gates redemption finalisation), `group_addr` (the cw4 group contract referenced by future blacklist proposals), and `deposit_amount` (the default per-deposit price). The handler MUST:
|
||||
|
||||
- bech32-validate `multisig_addr`, `holding_account`, and `group_addr`; an invalid `group_addr` MUST surface as `InvalidGroup { addr }`, an invalid `multisig_addr` or `holding_account` MUST surface as the underlying `StdError` from `addr_validate`;
|
||||
- persist `info.sender` as the `contract_admin` via `cw_controllers::Admin::set` (the message itself does not carry an admin field; the sender becomes admin by convention);
|
||||
- persist the validated `multisig_addr` as the `multisig` `Admin` slot;
|
||||
- snapshot `nym_network_defaults::TICKETBOOK_SIZE` into `Item<Invariants> { ticket_book_size }` under the storage key `"expected_invariants"`;
|
||||
- zero-initialise `PoolCounters` (`total_deposited`, `total_redeemed`, `tickets_requested_and_not_redeemed`) using the denom of the supplied `deposit_amount`;
|
||||
- persist the assembled `Config { group_addr (as cw4::Cw4Contract), holding_account, deposit_amount }`;
|
||||
- zero-initialise the default-price statistics accumulators (`deposits_with_default_price` and `deposits_with_default_price_amounts`);
|
||||
- record the contract name and `CARGO_PKG_VERSION` via `cw2::set_contract_version`;
|
||||
- record build information via `set_build_information!`.
|
||||
|
||||
The instantiation handler SHALL return `Response::default()` (no events, no attributes, no data).
|
||||
|
||||
#### Scenario: Valid instantiation persists every state slot
|
||||
- **WHEN** `instantiate` is called with valid bech32 strings for `holding_account`, `multisig_addr`, `group_addr` and a well-formed `deposit_amount`
|
||||
- **THEN** the contract stores `Config` verbatim, the validated multisig address (queryable via `multisig.assert_admin`), `info.sender` as the contract admin, the snapshotted `Invariants { ticket_book_size: TICKETBOOK_SIZE }`, zeroed `PoolCounters`, and zeroed default-price stats
|
||||
- **AND** `cw2::get_contract_version` returns the crate's `CARGO_PKG_VERSION`
|
||||
- **AND** the returned `Response` carries no events, attributes, or data
|
||||
|
||||
#### Scenario: Invalid group address is rejected with a typed error
|
||||
- **WHEN** `instantiate` is called with a `group_addr` that fails `Addr::validate`
|
||||
- **THEN** the call returns `EcashContractError::InvalidGroup { addr }` and no state is persisted
|
||||
|
||||
#### Scenario: Invalid multisig address propagates the `StdError`
|
||||
- **WHEN** `instantiate` is called with a `multisig_addr` that fails `Addr::validate`
|
||||
- **THEN** the call returns an `EcashContractError::Std` wrapping the underlying validation error and no state is persisted
|
||||
|
||||
### Requirement: Migration handles version gating, default-price backfill, and whitelist seeding atomically
|
||||
|
||||
The contract SHALL expose a `migrate` entry point taking `MigrateMsg { initial_whitelist: Vec<WhitelistedDeposit> }`. The handler MUST:
|
||||
|
||||
- call `set_build_information!` to refresh stored build metadata;
|
||||
- call `cw2::ensure_from_older_version` against `CONTRACT_NAME` and `CONTRACT_VERSION`; an on-chain version strictly greater than `CARGO_PKG_VERSION` MUST be rejected;
|
||||
- run `queued_migrations::add_tiered_pricing(deps, initial_whitelist)`, which (a) reads `DepositStorage::total_deposits_made` and `PoolCounters::total_deposited` from the pre-migration state, (b) writes those values into `deposits_with_default_price` and `deposits_with_default_price_amounts` respectively (because every pre-migration deposit was at the default price), and (c) iterates `initial_whitelist`, validating each entry's denom and amount (see Requirement "Setting a reduced deposit price"), persisting each into `reduced_deposits`;
|
||||
- return `Response::default()`.
|
||||
|
||||
The migration MUST fail atomically if any whitelist entry fails validation — partial whitelist seeding MUST NOT be persisted.
|
||||
|
||||
#### Scenario: Migration on contract with prior default-price deposits backfills both counters
|
||||
- **WHEN** `migrate` is called with `initial_whitelist = []` on a contract that has performed `N > 0` deposits totalling `T` units
|
||||
- **THEN** `deposits_with_default_price` reads `N` and `deposits_with_default_price_amounts` reads `T` (same denom as the contract config)
|
||||
|
||||
#### Scenario: Migration with a valid whitelist persists every entry
|
||||
- **WHEN** `migrate` is called with two well-formed `WhitelistedDeposit` entries
|
||||
- **THEN** `reduced_deposits` returns the matching `Coin` for each address on subsequent reads
|
||||
|
||||
#### Scenario: Migration with a single invalid whitelist entry rolls back the entire transaction
|
||||
- **WHEN** `migrate` is called with `initial_whitelist = [valid_entry, invalid_entry]` where `invalid_entry` uses the wrong denom
|
||||
- **THEN** the call returns `InvalidReducedDepositDenom { expected, got }` and `reduced_deposits` contains no entries from this migration
|
||||
|
||||
#### Scenario: Newer on-chain version is rejected
|
||||
- **WHEN** `migrate` is called against an on-chain `cw2` version strictly greater than `CARGO_PKG_VERSION`
|
||||
- **THEN** the call returns an error and storage is unchanged
|
||||
|
||||
### Requirement: Authorisation — contract admin and multisig pointer
|
||||
|
||||
The contract SHALL maintain two `cw_controllers::Admin` slots at storage keys `"contract_admin"` and `"multisig"`. Only the first carries admin semantics in the operational sense; the second is a stored cw3 contract pointer that uses the same wrapper purely as a generic address-equality helper.
|
||||
|
||||
- `contract_admin` SHALL gate `UpdateAdmin`, `UpdateDefaultDepositValue`, `SetReducedDepositPrice`, `RemoveReducedDepositPrice`. It is replaceable through `UpdateAdmin`, which dispatches `cw_controllers::Admin::execute_update_admin` (requiring the current admin to sign the transaction).
|
||||
- `multisig` SHALL gate `RedeemTickets` via `assert_admin` (which on this slot is effectively "is the caller equal to the stored cw3 contract address?"). It SHALL NOT be updatable through any execute path; replacing it requires redeploying the contract.
|
||||
|
||||
The wrapper's full admin-transfer machinery is reachable in code on both slots but is exercised only on `contract_admin`.
|
||||
|
||||
#### Scenario: Non-admin call to a contract-admin-gated handler is rejected
|
||||
- **WHEN** any of `UpdateDefaultDepositValue`, `SetReducedDepositPrice`, `RemoveReducedDepositPrice` is sent by an address other than the current `contract_admin`
|
||||
- **THEN** the call returns `EcashContractError::Admin` wrapping `AdminError::NotAdmin` and state is unchanged
|
||||
|
||||
#### Scenario: Non-multisig call to `RedeemTickets` is rejected
|
||||
- **WHEN** `RedeemTickets { n, gw }` is sent by an address other than the configured `multisig`
|
||||
- **THEN** the call returns `EcashContractError::Admin` wrapping `AdminError::NotAdmin` and `PoolCounters::tickets_requested_and_not_redeemed` is unchanged
|
||||
|
||||
### Requirement: `DepositTicketBookFunds` classifies the sent amount and updates the correct statistics bucket
|
||||
|
||||
`ExecuteMsg::DepositTicketBookFunds { identity_key }` SHALL accept exactly one coin in the configured denom (validated via `cw_utils::must_pay`) and classify the sender at deposit time:
|
||||
|
||||
1. If the sent amount equals `Config::deposit_amount.amount`, the deposit is treated as a **default-price** deposit. The handler MUST call `DepositStatsStorage::new_default_deposit`, which increments `deposits_with_default_price` by 1 and adds the deposited amount to `deposits_with_default_price_amounts`. This branch fires *regardless* of whether the sender has a reduced-deposit entry.
|
||||
2. Otherwise, if the sender has a `reduced_deposits[sender]` entry whose `amount` equals the sent amount, the deposit is treated as a **reduced-price** deposit. The handler MUST call `DepositStatsStorage::new_reduced_deposit`, which increments `deposits_with_custom_price[sender]` by 1 and adds the deposited amount to `deposits_with_custom_price_amounts[sender]`.
|
||||
3. Otherwise, the call MUST fail with `EcashContractError::WrongAmount { received, amount }`, where `amount` is the reduced amount if the sender is whitelisted, else the default amount.
|
||||
|
||||
After successful classification the handler MUST:
|
||||
|
||||
- add the deposited amount to `PoolCounters.total_deposited.amount`;
|
||||
- assign and persist a sequential `deposit_id` via `DepositStorage::save_deposit` (which decodes `identity_key` from bs58 to a 32-byte raw representation and writes it under the `"deposit"` namespace);
|
||||
- emit a `deposited-funds` event with attribute `deposit-id` carrying the assigned id as a decimal string;
|
||||
- set the response data to `deposit_id.to_be_bytes()` so callers can recover the id from the transaction result.
|
||||
|
||||
The handler MUST NOT verify that the sender controls the private key matching the supplied `identity_key`. The identity-ownership proof is delegated to off-chain nym-api signers (`post_blind_sign` at `nym-api/src/ecash/api_routes/partial_signing.rs:55`).
|
||||
|
||||
#### Scenario: Default-price deposit increments default counters and emits event with deposit id
|
||||
- **WHEN** a sender with no reduced-deposit entry sends `DepositTicketBookFunds { identity_key }` with funds matching the default amount
|
||||
- **THEN** `deposits_with_default_price` increases by 1, `deposits_with_default_price_amounts` increases by the deposited amount, `PoolCounters.total_deposited` increases by the deposited amount, a new `deposit_id` is persisted with the supplied `identity_key`, a `deposited-funds` event is emitted with the `deposit-id` attribute set to the new id, and the response data is the big-endian byte representation of the new id
|
||||
|
||||
#### Scenario: Whitelisted sender paying the default amount is bucketed as default-price
|
||||
- **WHEN** a whitelisted sender (entry in `reduced_deposits`) sends `DepositTicketBookFunds` with funds matching the **default** amount (not their reduced amount)
|
||||
- **THEN** the deposit is recorded under `deposits_with_default_price` (not under `deposits_with_custom_price[sender]`), and the deposit is persisted normally
|
||||
|
||||
#### Scenario: Whitelisted sender paying the reduced amount is bucketed as custom-price
|
||||
- **WHEN** a whitelisted sender sends `DepositTicketBookFunds` with funds matching their reduced amount
|
||||
- **THEN** `deposits_with_custom_price[sender]` increases by 1, `deposits_with_custom_price_amounts[sender]` increases by the reduced amount, and the deposit is persisted normally
|
||||
|
||||
#### Scenario: Whitelisted sender paying neither default nor their reduced amount is rejected with the reduced amount as the expected
|
||||
- **WHEN** a whitelisted sender sends an amount equal to neither `Config::deposit_amount.amount` nor `reduced_deposits[sender].amount`
|
||||
- **THEN** the call returns `EcashContractError::WrongAmount { received, amount }` where `amount` is the sender's reduced amount
|
||||
|
||||
#### Scenario: Non-whitelisted sender paying the wrong amount is rejected with the default amount as the expected
|
||||
- **WHEN** a sender with no reduced-deposit entry sends an amount different from `Config::deposit_amount.amount`
|
||||
- **THEN** the call returns `EcashContractError::WrongAmount { received, amount }` where `amount` is `Config::deposit_amount`
|
||||
|
||||
#### Scenario: Missing, mismatched, or multi-denom funds are rejected by `must_pay` before amount classification
|
||||
- **WHEN** `DepositTicketBookFunds` is sent with no attached coins, with multiple denom coins, or with a single coin in a denom different from `Config::deposit_amount.denom`
|
||||
- **THEN** the call returns `EcashContractError::InvalidDeposit(PaymentError::NoFunds)`, `EcashContractError::InvalidDeposit(PaymentError::MultipleDenoms)`, or `EcashContractError::InvalidDeposit(PaymentError::MissingDenom(<expected-denom>))` respectively, before any classification, counter, or storage write
|
||||
|
||||
### Requirement: Deposit identity-key payload is opaque to the contract; ownership is enforced off-chain
|
||||
|
||||
The contract SHALL accept `identity_key: String` as an opaque payload at deposit submission time. The handler MUST NOT verify control of the corresponding ed25519 private key. The handler MUST persist the payload such that it round-trips losslessly through `Deposit::to_bytes` → 32-byte raw storage → `Deposit::try_from_bytes` → bs58 string; any payload that fails to decode to exactly 32 bytes via `bs58::decode` MUST surface as `EcashContractError::MalformedEd25519Identity` (raised by `Deposit::to_bytes` during the `save_deposit` flow). The proof-of-control check is performed off-chain by nym-api signers when honouring `post_blind_sign`.
|
||||
|
||||
#### Scenario: Sender can claim any well-formed ed25519 pubkey
|
||||
- **WHEN** sender `A` submits `DepositTicketBookFunds { identity_key: B_pubkey }` where `B_pubkey` is sender `B`'s ed25519 public key, paying the correct amount
|
||||
- **THEN** the deposit is accepted and `GetDeposit { deposit_id }` returns `Deposit { bs58_encoded_ed25519_pubkey: B_pubkey }`
|
||||
|
||||
#### Scenario: Malformed bs58 ed25519 payload is rejected at save time
|
||||
- **WHEN** `DepositTicketBookFunds { identity_key: "not-a-valid-bs58-key" }` is submitted with the correct funds
|
||||
- **THEN** the call returns `EcashContractError::MalformedEd25519Identity` and no deposit is persisted, no counters are incremented
|
||||
|
||||
### Requirement: Deposit ids are sequential `u32` starting at 0, never recycled
|
||||
|
||||
The contract SHALL maintain a single `Item<u32>` counter at the storage key `"deposit_ids"`. The counter SHALL be unset on a freshly instantiated contract and treated as zero. Each successful `DepositTicketBookFunds` invocation SHALL:
|
||||
|
||||
- read the current counter value (defaulting to 0 when absent), which becomes the new deposit's id;
|
||||
- persist `counter + 1` as the new counter value;
|
||||
- write the 32-byte raw ed25519 pubkey for that id under the `"deposit"` storage namespace (bypassing JSON serialisation for storage efficiency — see `StoredDeposits`).
|
||||
|
||||
`DepositStorage::total_deposits_made(storage)` and `DepositStorage::latest_deposit(storage)` SHALL both read the counter directly — `total_deposits_made` returns `unwrap_or(0)` (count of deposits ever performed), `latest_deposit` returns `may_load` (the *next* unused id, which is also the count). Deposit ids SHALL NOT be recycled even if the deposit can later be invalidated off-chain.
|
||||
|
||||
#### Scenario: First deposit on a fresh contract gets id 0
|
||||
- **WHEN** the first-ever `DepositTicketBookFunds` is processed
|
||||
- **THEN** the assigned `deposit_id` is `0`, the persisted counter becomes `1`, and `total_deposits_made` returns `1`
|
||||
|
||||
#### Scenario: Subsequent deposits get strictly increasing ids
|
||||
- **WHEN** three deposits are processed in sequence
|
||||
- **THEN** they receive ids `0`, `1`, `2` and the counter becomes `3`
|
||||
|
||||
### Requirement: Deposit statistics invariant — default count plus per-account custom counts equals total
|
||||
|
||||
The contract SHALL maintain the invariant `DepositStatsStorage::get_total_deposits_made_with_default_price(storage) + sum(deposits_with_custom_price[a] for a in addrs) == DepositStorage::total_deposits_made(storage)` at all times after instantiation. This invariant MUST hold across every successful and failed transaction (a failed deposit MUST NOT touch any counter). The `#[cfg(test)] assert_counts_consistent` helper in `DepositStatsStorage` SHALL exist solely to validate this invariant in tests and is part of the maintenance contract for any code that writes to the `"deposit"` namespace.
|
||||
|
||||
#### Scenario: Mixed default and reduced deposits maintain the invariant
|
||||
- **WHEN** two default-price deposits and one reduced-price deposit by address `alice` are processed in any order
|
||||
- **THEN** `deposits_with_default_price` reads `2`, `deposits_with_custom_price[alice]` reads `1`, `total_deposits_made` reads `3`, and `2 + 1 == 3`
|
||||
|
||||
#### Scenario: A rejected `DepositTicketBookFunds` does not touch any counter
|
||||
- **WHEN** `DepositTicketBookFunds` is rejected with `WrongAmount`
|
||||
- **THEN** none of `deposit_ids`, `deposits_with_default_price`, `deposits_with_custom_price` change
|
||||
|
||||
### Requirement: `UpdateDefaultDepositValue` is admin-gated and refuses values below the ticketbook size
|
||||
|
||||
`ExecuteMsg::UpdateDefaultDepositValue { new_deposit: Coin }` SHALL be callable only by the contract admin (`contract_admin.assert_admin`). The handler MUST consult the on-chain `Invariants::ticket_book_size` via `get_ticketbook_size`; if the snapshotted value differs from the current crate `TICKETBOOK_SIZE`, the call MUST fail with `TicketBookSizeChanged { at_init, current }` *before* any further work. If `new_deposit.amount < TICKETBOOK_SIZE`, the call MUST fail with `DepositBelowTicketBookSize { amount, ticket_book_size }`. On success the handler SHALL overwrite `Config::deposit_amount` with `new_deposit` and emit a `wasm` event attribute `updated_deposit` carrying the new value as its `Coin::to_string()` form.
|
||||
|
||||
Note: the handler does *not* validate that `new_deposit.denom` matches the existing config's denom. Changing the denom is permitted by the current handler but is operationally hazardous (existing reduced-deposit entries and statistics use the old denom). Reviewers should treat denom changes as a coordinated migration concern, not an admin operation.
|
||||
|
||||
#### Scenario: Admin successfully bumps the default price
|
||||
- **WHEN** the contract admin sends `UpdateDefaultDepositValue { new_deposit }` with `new_deposit.amount >= TICKETBOOK_SIZE` and the network-defaults `TICKETBOOK_SIZE` matches the snapshotted invariant
|
||||
- **THEN** `Config::deposit_amount` equals `new_deposit` and the response contains a `wasm` attribute `updated_deposit = new_deposit.to_string()`
|
||||
|
||||
#### Scenario: Non-admin call is rejected
|
||||
- **WHEN** any non-admin sends `UpdateDefaultDepositValue`
|
||||
- **THEN** the call returns `EcashContractError::Admin` and `Config::deposit_amount` is unchanged
|
||||
|
||||
#### Scenario: Value below ticketbook size is rejected
|
||||
- **WHEN** the admin sends `UpdateDefaultDepositValue { new_deposit }` with `new_deposit.amount < TICKETBOOK_SIZE`
|
||||
- **THEN** the call returns `DepositBelowTicketBookSize { amount: new_deposit.amount, ticket_book_size: TICKETBOOK_SIZE }`
|
||||
|
||||
### Requirement: `SetReducedDepositPrice` is admin-gated with denom, strict-less-than, and ticketbook-size validation
|
||||
|
||||
`ExecuteMsg::SetReducedDepositPrice { address, deposit }` SHALL be callable only by the contract admin. The handler MUST validate `address` via `addr_validate`, then call `add_reduced_deposit_address`, which MUST:
|
||||
|
||||
- reject `deposit.denom != Config::deposit_amount.denom` with `InvalidReducedDepositDenom { expected, got }`;
|
||||
- reject `deposit.amount >= Config::deposit_amount.amount` with `ReducedDepositNotReduced { reduced, default }` (the reduced amount must be **strictly less than** the default);
|
||||
- reject `deposit.amount < TICKETBOOK_SIZE` with `DepositBelowTicketBookSize { amount, ticket_book_size }` (subject to the same `get_ticketbook_size` tripwire as Requirement "UpdateDefaultDepositValue");
|
||||
- persist `reduced_deposits[address] = deposit` (overwriting any existing entry).
|
||||
|
||||
On success the handler SHALL emit `wasm` attributes `action = "set_reduced_deposit_price"`, `address`, and `deposit` (as `Coin::to_string()`).
|
||||
|
||||
#### Scenario: Admin sets a valid reduced price
|
||||
- **WHEN** the admin sends `SetReducedDepositPrice { address, deposit }` with matching denom, amount strictly less than default, and amount at least the ticketbook size
|
||||
- **THEN** `reduced_deposits[address] = deposit` and the response carries attributes `action = "set_reduced_deposit_price"`, `address`, `deposit`
|
||||
|
||||
#### Scenario: Overwriting an existing entry succeeds
|
||||
- **WHEN** the admin sends `SetReducedDepositPrice` for an address that already has a reduced entry
|
||||
- **THEN** the existing entry is replaced with the new value (no error, no archiving of the old value)
|
||||
|
||||
#### Scenario: Reduced amount not strictly less than default is rejected
|
||||
- **WHEN** the admin sends `SetReducedDepositPrice` with `deposit.amount == Config::deposit_amount.amount`
|
||||
- **THEN** the call returns `ReducedDepositNotReduced { reduced, default }` with both fields equal
|
||||
|
||||
#### Scenario: Mismatched denom is rejected
|
||||
- **WHEN** the admin sends `SetReducedDepositPrice` with a denom different from `Config::deposit_amount.denom`
|
||||
- **THEN** the call returns `InvalidReducedDepositDenom { expected, got }`
|
||||
|
||||
### Requirement: `RemoveReducedDepositPrice` is admin-gated and requires an existing entry
|
||||
|
||||
`ExecuteMsg::RemoveReducedDepositPrice { address }` SHALL be callable only by the contract admin. The handler MUST validate `address`, then check `reduced_deposits.has(storage, address)`; if no entry exists, the call MUST fail with `NoReducedDepositPrice { address }`. On success the entry is removed from `reduced_deposits` and the response carries `wasm` attributes `action = "remove_reduced_deposit_price"` and `address`.
|
||||
|
||||
Removal does not retroactively affect historical statistics for the removed address — past `deposits_with_custom_price[address]` and `..._amounts[address]` entries remain.
|
||||
|
||||
#### Scenario: Admin removes an existing entry
|
||||
- **WHEN** the admin sends `RemoveReducedDepositPrice` for an address with an existing reduced entry
|
||||
- **THEN** `reduced_deposits[address]` is absent, historical `deposits_with_custom_price[address]` is unchanged, and the response carries `action` and `address` attributes
|
||||
|
||||
#### Scenario: Removing a non-existent entry is rejected
|
||||
- **WHEN** the admin sends `RemoveReducedDepositPrice` for an address with no reduced entry
|
||||
- **THEN** the call returns `NoReducedDepositPrice { address }`
|
||||
|
||||
### Requirement: `UpdateAdmin` requires the current admin and validates the new address
|
||||
|
||||
`ExecuteMsg::UpdateAdmin { admin }` SHALL be callable only by the current `contract_admin`. The handler MUST `addr_validate` the supplied string, then call `cw_controllers::Admin::execute_update_admin(deps, info, Some(new_admin))`. The cw_controllers handshake performs the sender-equality check internally. On success the new address replaces the current `contract_admin` slot, and the response carries the cw_controllers-standard attributes (`action = "update_admin"`, `admin = <new>`, `sender = <old>`).
|
||||
|
||||
The handler always passes `Some(new_admin)` — admin renunciation (passing `None`) is not exposed through the public surface today.
|
||||
|
||||
#### Scenario: Current admin transfers admin rights
|
||||
- **WHEN** the current admin sends `UpdateAdmin { admin: new_admin }` with a valid bech32 string
|
||||
- **THEN** subsequent `contract_admin.assert_admin` checks succeed only for `new_admin`, and the old admin's calls to admin-gated handlers fail
|
||||
|
||||
#### Scenario: Non-admin call is rejected
|
||||
- **WHEN** an address other than the current admin sends `UpdateAdmin`
|
||||
- **THEN** the call returns `EcashContractError::Admin` wrapping `AdminError::NotAdmin`
|
||||
|
||||
#### Scenario: Invalid bech32 address is rejected
|
||||
- **WHEN** the current admin sends `UpdateAdmin { admin: "not-bech32" }`
|
||||
- **THEN** the call returns an `EcashContractError::Std` wrapping the underlying `addr_validate` failure
|
||||
|
||||
### Requirement: `RequestRedemption` validates the commitment and dispatches a multisig propose SubMsg
|
||||
|
||||
`ExecuteMsg::RequestRedemption { commitment_bs58, number_of_tickets }` SHALL be callable by any address. The handler MUST:
|
||||
|
||||
- decode `commitment_bs58` via `bs58::decode(...).into_vec()`; on decode failure or if the decoded length is not exactly 32 bytes (sha256 digest length), the call MUST fail with `MalformedRedemptionCommitment`;
|
||||
- construct a `cw3` `Propose` message via `create_batch_redemption_proposal`, with title `BATCH_REDEMPTION_PROPOSAL_TITLE = "ecash-redemption"`, description equal to `commitment_bs58`, and a single embedded `ExecuteMsg::RedeemTickets { n: number_of_tickets, gw: <sender> }` targeting the ecash contract itself;
|
||||
- wrap that proposal in a `SubMsg::reply_always(..., REDEMPTION_PROPOSAL_REPLY_ID)`;
|
||||
- return `Response::new().add_submessage(submsg)`.
|
||||
|
||||
The handler does NOT itself transfer funds, mark tickets as redeemed, or modify any storage on the ecash contract directly. The actual redemption effect (incrementing `tickets_requested_and_not_redeemed`) only fires when the multisig contract subsequently executes the embedded `RedeemTickets` after a successful vote (see Requirement "Legacy `RedeemTickets`").
|
||||
|
||||
#### Scenario: Valid commitment dispatches a multisig propose with the right shape
|
||||
- **WHEN** any sender sends `RequestRedemption` with a 32-byte sha256 digest encoded in bs58 and `number_of_tickets = N`
|
||||
- **THEN** the response carries a single `SubMsg` with `id == REDEMPTION_PROPOSAL_REPLY_ID`, targeting the multisig contract, encoding a `Propose` with title `"ecash-redemption"`, description equal to the input `commitment_bs58`, and a single inner `WasmMsg::Execute` calling `RedeemTickets { n: N, gw: <sender> }` on the ecash contract
|
||||
|
||||
#### Scenario: Non-bs58 commitment is rejected
|
||||
- **WHEN** `RequestRedemption { commitment_bs58: "!!!" }` is sent
|
||||
- **THEN** the call returns `MalformedRedemptionCommitment` and no SubMsg is dispatched
|
||||
|
||||
#### Scenario: Bs58-decodable but wrong-length commitment is rejected
|
||||
- **WHEN** `RequestRedemption` is sent with a `commitment_bs58` whose decoded bytes have length != 32
|
||||
- **THEN** the call returns `MalformedRedemptionCommitment`
|
||||
|
||||
### Requirement: The redemption-proposal reply handler captures the multisig proposal id and surfaces it as response data
|
||||
|
||||
The contract SHALL register a single `reply` entry point that dispatches by `Reply::id`. For `REDEMPTION_PROPOSAL_REPLY_ID`, the handler MUST call `Reply::multisig_proposal_id()`, which extracts the `proposal_id` attribute (`PROPOSAL_ID_ATTRIBUTE_NAME = "proposal_id"`) from the `wasm` event of the embedded multisig result. On success the handler MUST return `Response::new().set_data(proposal_id.to_be_bytes())` so callers can recover the multisig-issued id from the transaction result. On failure of the embedded SubMsg, the handler MUST return `EcashContractError::Std(StdError::generic_err(reply_err))`. If the `proposal_id` attribute is missing or malformed, the handler MUST return `MissingProposalId` or `MalformedProposalId` respectively.
|
||||
|
||||
#### Scenario: Successful multisig propose flows the proposal id back to the gateway
|
||||
- **WHEN** the redemption-proposal SubMsg succeeds and the multisig contract emits a `wasm` event with attribute `proposal_id = "42"`
|
||||
- **THEN** the reply handler returns `Response::new().set_data([0,0,0,0,0,0,0,42])` (big-endian u64)
|
||||
|
||||
#### Scenario: Missing proposal_id attribute is reported as a typed error
|
||||
- **WHEN** the SubMsg succeeds but no `wasm` event carries a `proposal_id` attribute
|
||||
- **THEN** the reply handler returns `MissingProposalId`
|
||||
|
||||
#### Scenario: Failed SubMsg propagates as a generic StdError
|
||||
- **WHEN** the SubMsg fails with error string `e`
|
||||
- **THEN** the reply handler returns `EcashContractError::Std(StdError::generic_err(e))`
|
||||
|
||||
#### Scenario: Unknown reply id is rejected
|
||||
- **WHEN** the reply entry point is invoked with `Reply::id` not equal to `REDEMPTION_PROPOSAL_REPLY_ID` or `BLACKLIST_PROPOSAL_REPLY_ID`
|
||||
- **THEN** the handler returns `InvalidReplyId { id }`
|
||||
|
||||
### Requirement: Legacy `RedeemTickets` is multisig-gated, bumps the unredeemed-tickets counter, and emits a single event
|
||||
|
||||
`ExecuteMsg::RedeemTickets { n, gw }` is **dead code retained on the public surface for backwards compatibility only**. It SHALL be callable **only** by the configured `multisig` address (`multisig.assert_admin`). The handler MUST:
|
||||
|
||||
- ignore `gw` at runtime (the argument is preserved in the transaction body but not consumed);
|
||||
- increment `PoolCounters.tickets_requested_and_not_redeemed` by `n` (as `u64`);
|
||||
- emit `Event::new("ticket_redemption").add_attribute("moved_to_holding_account", "false")`.
|
||||
|
||||
The handler does not move funds. No known active consumer depends on the counter increment or the event; both are remnants of the deprecated semantics. A follow-on change may remove this variant entirely.
|
||||
|
||||
#### Scenario: Multisig successfully redeems n tickets
|
||||
- **WHEN** the configured multisig sends `RedeemTickets { n: 5, gw: "<gateway-addr>" }`
|
||||
- **THEN** `PoolCounters.tickets_requested_and_not_redeemed` increases by 5, and the response carries a `ticket_redemption` event with `moved_to_holding_account = "false"`
|
||||
|
||||
#### Scenario: Non-multisig caller is rejected
|
||||
- **WHEN** an address other than the configured multisig sends `RedeemTickets`
|
||||
- **THEN** the call returns `EcashContractError::Admin` and the counter is unchanged
|
||||
|
||||
### Requirement: Stubbed blacklist execute handlers always return `UnimplementedBlacklisting`
|
||||
|
||||
`ExecuteMsg::ProposeToBlacklist { public_key }` and `ExecuteMsg::AddToBlacklist { public_key }` SHALL be present in the public schema but SHALL always return `EcashContractError::UnimplementedBlacklisting`. The handlers MUST NOT touch storage, dispatch SubMsgs, or emit events.
|
||||
|
||||
The blacklist storage map (`blacklist: Map<BlacklistKey, Blacklisting>` at storage key `"blacklist"`), the reply handler for `BLACKLIST_PROPOSAL_REPLY_ID`, and the `create_blacklist_proposal` helper remain wired in source. They are preserved as the starting point for the redesign and are NOT reachable from the current public ExecuteMsg surface.
|
||||
|
||||
#### Scenario: `ProposeToBlacklist` always errors
|
||||
- **WHEN** any sender (admin, multisig, or other) sends `ProposeToBlacklist { public_key }`
|
||||
- **THEN** the call returns `EcashContractError::UnimplementedBlacklisting` and the `blacklist` storage is unchanged
|
||||
|
||||
#### Scenario: `AddToBlacklist` always errors
|
||||
- **WHEN** any sender sends `AddToBlacklist { public_key }`
|
||||
- **THEN** the call returns `EcashContractError::UnimplementedBlacklisting` and the `blacklist` storage is unchanged
|
||||
|
||||
### Requirement: The ticketbook-size invariant tripwire catches uncoordinated network-defaults bumps
|
||||
|
||||
The contract SHALL persist `Item<Invariants> { ticket_book_size }` at storage key `"expected_invariants"` on instantiation, snapshotting `nym_network_defaults::TICKETBOOK_SIZE`. Every priced operation that consults the ticketbook size (currently `UpdateDefaultDepositValue` and `SetReducedDepositPrice` / migration whitelist seeding via `add_reduced_deposit_address`) SHALL call `NymEcashContract::get_ticketbook_size`, which compares the stored value to the crate's current `TICKETBOOK_SIZE`. A mismatch MUST surface as `TicketBookSizeChanged { at_init, current }`, halting the operation before any state mutation.
|
||||
|
||||
#### Scenario: Snapshot equals current crate constant — operation proceeds
|
||||
- **WHEN** the snapshotted `Invariants::ticket_book_size` equals `nym_network_defaults::TICKETBOOK_SIZE` and an admin sends a valid `UpdateDefaultDepositValue`
|
||||
- **THEN** the operation succeeds normally
|
||||
|
||||
#### Scenario: Snapshot differs from current crate constant — operation halted
|
||||
- **WHEN** the snapshotted `Invariants::ticket_book_size` is `T_init` and the contract has been redeployed with a new crate constant `T_current != T_init`, then an admin sends `UpdateDefaultDepositValue` or migration seeds a reduced-deposit entry
|
||||
- **THEN** the call returns `TicketBookSizeChanged { at_init: T_init, current: T_current }` before any further work
|
||||
|
||||
### Requirement: Default-deposit queries
|
||||
|
||||
The contract SHALL expose two equivalent queries for the default deposit amount, kept aliased for backwards compatibility:
|
||||
|
||||
- `QueryMsg::GetDefaultDepositAmount {}` — returns `Coin = Config::deposit_amount`.
|
||||
- `QueryMsg::GetRequiredDepositAmount {}` (also accepted via the `get_required_deposit_amount` `serde` alias on `GetDefaultDepositAmount`) — equivalent to the above; the handler delegates to `get_default_deposit_amount`.
|
||||
|
||||
Both queries SHALL succeed unconditionally for any sender (queries are read-only).
|
||||
|
||||
#### Scenario: Both query variants return identical data
|
||||
- **WHEN** both `GetDefaultDepositAmount {}` and `GetRequiredDepositAmount {}` are queried on the same contract state
|
||||
- **THEN** both return the same `Coin`, equal to `Config::deposit_amount`
|
||||
|
||||
### Requirement: Reduced-deposit queries
|
||||
|
||||
The contract SHALL expose two queries for tier-pricing state:
|
||||
|
||||
- `QueryMsg::GetReducedDepositAmount { address }` — validates `address` via `addr_validate`, then returns `Option<Coin> = reduced_deposits.may_load(storage, addr)`. An invalid bech32 input MUST return an `EcashContractError::Std` wrapping the underlying validation error.
|
||||
- `QueryMsg::GetAllWhitelistedAccounts {}` — returns `WhitelistedAccountsResponse { whitelisted_accounts }` enumerating all entries in `reduced_deposits` in ascending address order.
|
||||
|
||||
`GetAllWhitelistedAccounts` is unpaginated by design (the whitelist is expected to remain small).
|
||||
|
||||
#### Scenario: Reduced amount is returned when an entry exists
|
||||
- **WHEN** address `alice` has `reduced_deposits[alice] = Coin { 10, "unym" }` and `GetReducedDepositAmount { address: alice }` is queried
|
||||
- **THEN** the response is `Some(Coin { 10, "unym" })`
|
||||
|
||||
#### Scenario: Absent entry returns None
|
||||
- **WHEN** address `bob` has no entry and `GetReducedDepositAmount { address: bob }` is queried
|
||||
- **THEN** the response is `None`
|
||||
|
||||
#### Scenario: All whitelisted accounts are enumerated
|
||||
- **WHEN** the contract has entries for `alice` and `bob` and `GetAllWhitelistedAccounts {}` is queried
|
||||
- **THEN** the response contains both entries
|
||||
|
||||
### Requirement: Deposit-by-id and latest-deposit queries
|
||||
|
||||
The contract SHALL expose:
|
||||
|
||||
- `QueryMsg::GetDeposit { deposit_id }` — returns `DepositResponse { id: deposit_id, deposit: Option<Deposit> }`. The `deposit` field is `Some` if a deposit was ever persisted under that id (deposits are not deletable). It is `None` if the id has not yet been issued (i.e. `id >= total_deposits_made`).
|
||||
- `QueryMsg::GetLatestDeposit {}` — returns `LatestDepositResponse { deposit: Option<DepositData> }`. The handler MUST consult `DepositStorage::latest_deposit`, which returns the id of the most recently assigned deposit (`counter - 1` when the counter has been incremented at least once, else `None`), and then load that id. On a fresh contract with no prior deposit, the response is `LatestDepositResponse { deposit: None }`; after any successful deposit, the response is `LatestDepositResponse { deposit: Some(DepositData { id, deposit }) }` where `id` is the most recent assignment.
|
||||
|
||||
#### Scenario: Existing deposit is returned by id
|
||||
- **WHEN** a deposit was persisted at id `0` with `identity_key = K` and `GetDeposit { deposit_id: 0 }` is queried
|
||||
- **THEN** the response is `DepositResponse { id: 0, deposit: Some(Deposit { bs58_encoded_ed25519_pubkey: K }) }`
|
||||
|
||||
#### Scenario: Unknown id returns None
|
||||
- **WHEN** `GetDeposit { deposit_id: 999 }` is queried and the counter is less than 999
|
||||
- **THEN** the response is `DepositResponse { id: 999, deposit: None }`
|
||||
|
||||
#### Scenario: Fresh contract latest-deposit query returns None
|
||||
- **WHEN** `GetLatestDeposit {}` is queried on a contract with no deposits
|
||||
- **THEN** the response is `LatestDepositResponse { deposit: None }`
|
||||
|
||||
#### Scenario: After deposits exist, latest-deposit query returns the most recent assignment
|
||||
- **WHEN** two deposits have been processed (ids `0` and `1`) and `GetLatestDeposit {}` is queried
|
||||
- **THEN** the response is `LatestDepositResponse { deposit: Some(DepositData { id: 1, deposit: <the deposit persisted at id 1> }) }`
|
||||
|
||||
### Requirement: Paginated deposits query
|
||||
|
||||
`QueryMsg::GetDepositsPaged { limit, start_after }` SHALL return at most `limit.unwrap_or(DEPOSITS_PAGE_DEFAULT_LIMIT).min(DEPOSITS_PAGE_MAX_LIMIT)` deposits in ascending id order, starting strictly after `start_after`. The defaults `DEPOSITS_PAGE_DEFAULT_LIMIT = 50` and `DEPOSITS_PAGE_MAX_LIMIT = 100` are part of the public surface and changing them is a behavioural change. The response SHALL be `PagedDepositsResponse { deposits, start_next_after }`, where `start_next_after` is the id of the last entry returned (or `None` if zero entries were returned).
|
||||
|
||||
#### Scenario: Default limits clamp to the maximum
|
||||
- **WHEN** `GetDepositsPaged { limit: Some(1000), start_after: None }` is queried on a contract with 200 deposits
|
||||
- **THEN** the response contains 100 entries (ids 0..=99) and `start_next_after = Some(99)`
|
||||
|
||||
#### Scenario: Pagination via `start_after`
|
||||
- **WHEN** the previous query returned `start_next_after = Some(99)` and a follow-up sends `GetDepositsPaged { limit: None, start_after: Some(99) }`
|
||||
- **THEN** the response starts at id 100
|
||||
|
||||
### Requirement: `GetDepositsStatistics` reassembles the global and per-account picture
|
||||
|
||||
`QueryMsg::GetDepositsStatistics {}` SHALL return `DepositsStatistics` populated from:
|
||||
|
||||
- `total_deposits_made` ← `DepositStorage::total_deposits_made(storage)`;
|
||||
- `total_deposited` ← `PoolCounters::total_deposited`;
|
||||
- `total_deposits_made_with_default_price` ← `deposits_with_default_price`;
|
||||
- `total_deposited_with_default_price` ← `deposits_with_default_price_amounts`;
|
||||
- `total_deposits_made_with_custom_price` ← `sum(deposits_with_custom_price[a])` across all addresses;
|
||||
- `total_deposited_with_custom_price` ← `sum(deposits_with_custom_price_amounts[a])` across all addresses (using `Config::deposit_amount.denom`);
|
||||
- `deposits_made_with_custom_price: HashMap<String, u32>` and `deposited_with_custom_price: HashMap<String, Coin>` ← per-account aggregates from the custom-price maps, keyed by the stringified `Addr`.
|
||||
|
||||
The query MUST be a single read pass (no execute-side write) and is callable by any sender.
|
||||
|
||||
#### Scenario: Statistics reflect the post-mixed-deposit state
|
||||
- **WHEN** two default-price deposits of value 75 each, and one reduced-price deposit by `alice` of value 10, have been processed; then `GetDepositsStatistics {}` is queried
|
||||
- **THEN** the response has `total_deposits_made = 3`, `total_deposits_made_with_default_price = 2`, `total_deposited_with_default_price.amount = 150`, `total_deposits_made_with_custom_price = 1`, `total_deposited_with_custom_price.amount = 10`, and `deposits_made_with_custom_price["alice"] = 1`
|
||||
|
||||
### Requirement: Blacklist queries succeed and return empty on a freshly deployed contract
|
||||
|
||||
`QueryMsg::GetBlacklistedAccount { public_key }` and `QueryMsg::GetBlacklistPaged { limit, start_after }` SHALL be callable by any sender and SHALL NOT error on a contract that has no blacklist entries.
|
||||
|
||||
- `GetBlacklistedAccount` returns `BlacklistedAccountResponse { account: Option<Blacklisting> }`. On a contract where no entry exists for `public_key`, the response is `BlacklistedAccountResponse { account: None }`.
|
||||
- `GetBlacklistPaged` returns `PagedBlacklistedAccountResponse` with at most `limit.unwrap_or(BLACKLIST_PAGE_DEFAULT_LIMIT).min(BLACKLIST_PAGE_MAX_LIMIT)` entries (`BLACKLIST_PAGE_DEFAULT_LIMIT = 50`, `BLACKLIST_PAGE_MAX_LIMIT = 75`). On a contract with no entries, the response is empty (`accounts: []`, `start_next_after: None`).
|
||||
|
||||
Because the blacklist execute handlers always error (Requirement "Stubbed blacklist execute handlers"), the only way for blacklist storage to contain entries on a live deployment is through the reply path for `BLACKLIST_PROPOSAL_REPLY_ID` — which is itself unreachable from the public ExecuteMsg surface today. Consumers MUST NOT treat an empty blacklist as a security guarantee.
|
||||
|
||||
#### Scenario: Querying an absent blacklist entry returns None
|
||||
- **WHEN** `GetBlacklistedAccount { public_key: K }` is queried on a contract that has never blacklisted `K`
|
||||
- **THEN** the response is `BlacklistedAccountResponse { account: None }`
|
||||
|
||||
#### Scenario: Paginated blacklist on empty contract returns empty
|
||||
- **WHEN** `GetBlacklistPaged { limit: None, start_after: None }` is queried on a freshly deployed contract
|
||||
- **THEN** the response is `PagedBlacklistedAccountResponse { accounts: [], per_page: 50, start_next_after: None }`
|
||||
|
||||
### Requirement: Public storage layout
|
||||
|
||||
The following raw CosmWasm storage keys SHALL be considered part of the public contract surface. Any change to these keys is a breaking change for already-deployed contracts and MUST be accompanied by a coordinated migration:
|
||||
|
||||
- `"contract_admin"` — `cw_controllers::Admin` slot for the contract admin (single Cosmos SDK address).
|
||||
- `"multisig"` — `cw_controllers::Admin` slot for the multisig contract address.
|
||||
- `"config"` — `Item<Config> { group_addr, holding_account, deposit_amount }`.
|
||||
- `"pool_counters"` — `Item<PoolCounters> { total_deposited, total_redeemed, tickets_requested_and_not_redeemed }`.
|
||||
- `"expected_invariants"` — `Item<Invariants> { ticket_book_size }`.
|
||||
- `"deposit_ids"` — `Item<u32>` deposit-id counter (next free id).
|
||||
- `"deposit"` — custom raw-bytes namespace for stored deposits (32-byte ed25519 pubkeys), keyed by big-endian `u32`. **Not** a `cw_storage_plus::Map`.
|
||||
- `"reduced_deposits"` — `Map<Addr, Coin>` per-address reduced-deposit price.
|
||||
- `"blacklist"` — `Map<String, Blacklisting>` reserved for the future blacklist redesign; unreachable through public execute paths today.
|
||||
- `"deposits_with_default_price"` — `Item<u32>` count of default-price deposits.
|
||||
- `"deposits_with_default_price_amounts"` — `Item<Coin>` total amount of default-price deposits.
|
||||
- `"deposits_with_custom_price"` — `Map<Addr, u32>` per-account count of custom-price deposits.
|
||||
- `"deposits_with_custom_price_amounts"` — `Map<Addr, Coin>` per-account total amount of custom-price deposits.
|
||||
|
||||
In addition, the two reply-id constants `BLACKLIST_PROPOSAL_REPLY_ID = 7759` and `REDEMPTION_PROPOSAL_REPLY_ID = 2137` form part of the public surface for any contract upgrade — changing them invalidates in-flight submessage replies.
|
||||
|
||||
#### Scenario: Reading any storage key listed above on a v1 deployment succeeds with the documented type
|
||||
- **WHEN** a CosmWasm raw-state inspection tool reads any of the listed keys on a contract deployed at the spec's anchor version
|
||||
- **THEN** the deserialised value matches the documented type (`Item`, `Map`, raw bytes, or `Admin` slot as listed)
|
||||
|
||||
### Requirement: Public event surface
|
||||
|
||||
The following events SHALL form the public contract surface consumed by chain indexers and downstream tooling. Renaming events or attribute keys MUST be treated as a breaking change:
|
||||
|
||||
- `deposited-funds` event, emitted by `DepositTicketBookFunds` on success. Carries attribute `deposit-id` (decimal string form of the new `u32`). Event type constant: `DEPOSITED_FUNDS_EVENT_TYPE`. Attribute constant: `DEPOSIT_ID`.
|
||||
- `ticket_redemption` event, emitted by `RedeemTickets` on success. Carries attribute `moved_to_holding_account = "false"` (the literal string `"false"`). This event is a remnant of the deprecated `RedeemTickets` flow (see the "Legacy `RedeemTickets`" requirement) and is preserved by the contract today but not consumed by any known live indexer.
|
||||
- The auto-generated `wasm` event emitted by the redemption-proposal reply handler — carries attribute `proposal_id` (decimal string form of the multisig-issued `u64`). Attribute constant: `PROPOSAL_ID_ATTRIBUTE_NAME`. The `wasm` event name itself is the cosmwasm-std auto-event; the attribute is added via `Response::add_attribute`.
|
||||
- The auto-generated `wasm` event emitted by `UpdateDefaultDepositValue` — carries attribute `updated_deposit = Coin::to_string()`.
|
||||
- The auto-generated `wasm` event emitted by `SetReducedDepositPrice` — carries attributes `action = "set_reduced_deposit_price"`, `address`, `deposit = Coin::to_string()`.
|
||||
- The auto-generated `wasm` event emitted by `RemoveReducedDepositPrice` — carries attributes `action = "remove_reduced_deposit_price"`, `address`.
|
||||
- The auto-generated `wasm` events from `UpdateAdmin` and the embedded multisig propose carry standard cw_controllers / cw3 attributes; the ecash contract does not add additional attributes on those paths.
|
||||
|
||||
Handlers that explicitly return `Response::default()` (instantiation, migration) MUST NOT emit any events.
|
||||
|
||||
#### Scenario: Successful default-price deposit emits exactly one `deposited-funds` event with the new id
|
||||
- **WHEN** a default-price `DepositTicketBookFunds` succeeds and assigns id `42`
|
||||
- **THEN** the response carries exactly one `deposited-funds` event with attribute `deposit-id = "42"`
|
||||
|
||||
#### Scenario: Successful `RedeemTickets` carries the legacy `moved_to_holding_account = "false"` attribute
|
||||
- **WHEN** a `RedeemTickets { n: 3, gw: "..." }` succeeds
|
||||
- **THEN** the response carries exactly one `ticket_redemption` event with attribute `moved_to_holding_account = "false"`
|
||||
|
||||
#### Scenario: Instantiation emits no events
|
||||
- **WHEN** the contract is instantiated successfully
|
||||
- **THEN** the response has no events and no attributes
|
||||
|
||||
### Requirement: Public error variants
|
||||
|
||||
The `EcashContractError` enum forms part of the public surface — its variants are observable through transaction failure messages and are encoded in JSON schema artefacts. The following variants MUST be reachable through the current public execute / migration surface and MUST NOT be removed without a coordinated schema bump:
|
||||
|
||||
- `Std`, `Admin` — wrapper variants over `StdError` and `AdminError`.
|
||||
- `InvalidDeposit(PaymentError)` — wrapper variant raised by `cw_utils::must_pay` on `DepositTicketBookFunds` when funds are missing, multi-denom, or in the wrong denom. The inner `PaymentError` variants `NoFunds`, `MultipleDenoms`, `MissingDenom(<denom>)` are all reachable.
|
||||
- `WrongAmount { received, amount }` — `DepositTicketBookFunds` with the right denom but a non-matching amount.
|
||||
- `InvalidGroup { addr }` — instantiation with an unparseable group address.
|
||||
- `MalformedRedemptionCommitment` — `RequestRedemption` with a non-32-byte commitment.
|
||||
- `MalformedEd25519Identity` — `DepositTicketBookFunds` with a non-32-byte bs58 identity payload at save time.
|
||||
- `InvalidReducedDepositDenom { expected, got }` — denom mismatch in `SetReducedDepositPrice` or migration whitelist seeding.
|
||||
- `ReducedDepositNotReduced { reduced, default }` — reduced amount not strictly less than default.
|
||||
- `DepositBelowTicketBookSize { amount, ticket_book_size }` — reduced or default amount below the ticketbook-size floor.
|
||||
- `NoReducedDepositPrice { address }` — `RemoveReducedDepositPrice` against an absent entry.
|
||||
- `TicketBookSizeChanged { at_init, current }` — invariant tripwire mismatch.
|
||||
- `UnimplementedBlacklisting` — `ProposeToBlacklist` or `AddToBlacklist` (always thrown).
|
||||
- `InvalidReplyId { id }` — reply dispatcher with an unknown id.
|
||||
- `MissingProposalId` / `MalformedProposalId` — multisig-reply parsing failures.
|
||||
|
||||
`NotEnoughFunds`, `MaximumDepositTypesReached`, `UnknownCompressedDepositInfoType { typ }`, `UnknownDepositInfoType { typ }`, `Unauthorized`, and `SemVerFailure { value, error_message }` are present in the enum but are **not reachable** through any current public execute path. They are preserved for forward compatibility (notably for the eventual blacklist redesign and deposit-info-typing feature work) and SHOULD NOT be relied upon by downstream tooling.
|
||||
|
||||
#### Scenario: Each reachable variant has at least one triggering scenario in this spec
|
||||
- **WHEN** a reviewer cross-references the reachable variants listed above against the requirements in this spec
|
||||
- **THEN** every reachable variant appears as the documented error of at least one scenario
|
||||
@@ -0,0 +1,35 @@
|
||||
## 1. Verify spec coverage against the live contract
|
||||
|
||||
- [x] 1.1 Compare each `ExecuteMsg` variant in `common/cosmwasm-smart-contracts/ecash-contract/src/msg.rs` against the spec to confirm every execute path has a corresponding requirement with scenarios (Deposit, RequestRedemption, RedeemTickets, UpdateAdmin, UpdateDefaultDepositValue, SetReducedDepositPrice, RemoveReducedDepositPrice, ProposeToBlacklist, AddToBlacklist).
|
||||
- [x] 1.2 Compare each `QueryMsg` variant against the spec to confirm every read path is covered (GetBlacklistedAccount, GetBlacklistPaged, GetDefaultDepositAmount, GetRequiredDepositAmount alias, GetReducedDepositAmount, GetAllWhitelistedAccounts, GetDeposit, GetLatestDeposit, GetDepositsPaged, GetDepositsStatistics).
|
||||
- [x] 1.3 Compare each `EcashContractError` variant against the spec to confirm every reachable error has a scenario that triggers it, and that the error name in the scenario matches the enum variant exactly. Annotate the unreachable variants (`NotEnoughFunds`, `MaximumDepositTypesReached`, `UnknownCompressedDepositInfoType`, `UnknownDepositInfoType`, `Unauthorized`, `SemVerFailure`) as preserved-but-unreachable. **Finding**: added `InvalidDeposit(PaymentError)` to the public-error requirement and a triggering scenario under the Deposit requirement; multitest::invalid_deposit exercises `NoFunds`, `MultipleDenoms`, `MissingDenom`.
|
||||
- [x] 1.4 Confirm every event name and attribute key in `nym_ecash_contract_common::events` (`DEPOSITED_FUNDS_EVENT_TYPE`, `DEPOSIT_ID`, `WASM_EVENT_NAME`, `PROPOSAL_ID_ATTRIBUTE_NAME`) is named verbatim in the events requirement. Cross-check the `"ticket_redemption"` event and its `"moved_to_holding_account"` attribute (defined inline, not via a constant) and verify the `"updated_deposit"`, `"action"`, `"address"`, `"deposit"` attributes from the admin handlers. **Finding**: `event_attributes::BANDWIDTH_PROPOSAL_ID = "proposal_id"` is dead code (no in-tree references); duplicates `events::PROPOSAL_ID_ATTRIBUTE_NAME`. Worth removing in the rustdoc follow-on; no spec impact.
|
||||
- [x] 1.5 Confirm every storage key (raw string literal in `Item::new(...)` / `Map::new(...)` / `StoredDeposits::NAMESPACE`) is named verbatim in the storage-layout requirement. Specifically: `"contract_admin"`, `"multisig"`, `"config"`, `"pool_counters"`, `"expected_invariants"`, `"deposit_ids"`, `"deposit"`, `"reduced_deposits"`, `"blacklist"`, `"deposits_with_default_price"`, `"deposits_with_default_price_amounts"`, `"deposits_with_custom_price"`, `"deposits_with_custom_price_amounts"`.
|
||||
- [x] 1.6 Confirm both reply-id constants (`BLACKLIST_PROPOSAL_REPLY_ID = 7759`, `REDEMPTION_PROPOSAL_REPLY_ID = 2137`) are listed verbatim in the storage-layout requirement.
|
||||
|
||||
## 2. Cross-check against the contract unit tests
|
||||
|
||||
- [x] 2.1 For each `#[test]` in `contracts/ecash/src/deposit.rs`, identify the requirement(s) and scenario(s) in the spec that it exercises. Flag any test that asserts behaviour not yet captured in the spec. Tests: `getting_latest_deposit`, `total_deposits_made_tracks_count`, `iterating_over_deposits` — all covered by "Deposit ids are sequential u32" + "Deposit-by-id and latest-deposit queries" + "Paginated deposits query" requirements.
|
||||
- [x] 2.2 Do the same for `contracts/ecash/src/deposit_stats.rs` tests (statistics invariant and per-account bookkeeping). Six tests on default/reduced accumulation and per-address independence — all covered by the "Statistics invariant" requirement and the Deposit classification scenarios.
|
||||
- [x] 2.3 Do the same for `contracts/ecash/src/contract/test.rs` (handler-level integration tests). ~15 tests covering query handlers, set/remove reduced price (admin gating, validations, overwrite), and `deposit_stats_invariant_holds_after_mixed_deposits` — all map to existing requirements.
|
||||
- [x] 2.4 Do the same for `contracts/ecash/src/contract/queued_migrations.rs` tests (migration backfill + whitelist seeding). Six tests covering empty-stats init, deposit-count backfill, whitelist seeding, denom/amount/ticketbook-size rejections — all map to the Migration requirement.
|
||||
- [x] 2.5 Do the same for `contracts/ecash/src/multitest.rs` tests (multi-contract integration with the multisig harness). Four tests: `invalid_deposit` (drove the spec edit in 1.3), `wrong_deposit_amount`, `correct_default_deposit_succeeds`, `reduced_price_deposit_end_to_end` — all covered.
|
||||
- [x] 2.6 For each spec scenario that has no corresponding contract unit test, decide whether to add a test or annotate the scenario as covered indirectly (e.g. by the sylvia-generated dispatcher). **Annotation**: the `MalformedEd25519Identity` scenario is reachable only when funds are correct AND `identity_key` is a malformed bs58 string — currently no test exercises this directly (the `must_pay` check fires first in the invalid-deposit test). Documented in the rustdoc follow-on as a candidate for a new test. The `TicketBookSizeChanged` tripwire is also indirectly covered (no test redeploys with a different `TICKETBOOK_SIZE`); acceptable since the assertion guards a deployment-time invariant.
|
||||
|
||||
## 3. Validate via openspec tooling
|
||||
|
||||
- [x] 3.1 Run `openspec validate ecash-contract-spec` and confirm it reports "valid".
|
||||
- [x] 3.2 Run `openspec show ecash-contract-spec` and review the rendered output for readability and section ordering.
|
||||
- [x] 3.3 Run `openspec status --change ecash-contract-spec` and confirm `applyRequires` is satisfied (`tasks` artifact present, all dependencies done).
|
||||
|
||||
## 4. Reviewer pass
|
||||
|
||||
- [x] 4.1 Walk through `proposal.md` with a reviewer to confirm the "Why" section accurately captures the design intent of the ecash contract (deposit anchor for the blind-signature pipeline) and that the "Known limitation — blacklist is stubbed" note matches operational understanding. **Resolved 2026-05-21**: contract role confirmed accurate; blacklist limitation note confirmed OK. Two changes landed: (a) the "two-admin model" framing was wrong — the team uses `cw_controllers::Admin` only as a generic address-equality helper for the `multisig` slot, not as an admin in the operational sense. Reworded the bullet, mirrored in design.md "What to document" + Decision 1 + spec.md authorisation requirement. (b) The "off-chain ownership/double-issue checks" wording was vague — replaced with a precise bullet citing `state.already_issued(deposit_id)` for the per-signer cache and the specific ed25519 verification path in `validate_deposit` (signs the plaintext `IssuanceTicketBook::request_plaintext(&request.inner_sign_request, request.deposit_id)`), plus the surrounding signer-eligibility / expiration / DKG / off-chain-blacklist gates.
|
||||
- [x] 4.2 Walk through `design.md` Decisions 1–12 with a reviewer to confirm each rationale matches the team's reasoning at the time the contract was built. Pay particular attention to Decision 2 (off-chain ed25519 ownership), Decision 3 (per-signer-local de-duplication), Decision 4 (raw-bytes storage), and Decision 11 (legacy `RedeemTickets` retention). **Resolved 2026-05-21**: Decision 1 already rewritten in 4.1 (Admin-wrapper misuse). Decisions 3 (adversarial framing), 4 (storage-gas framing), 6 (graceful-degradation framing) confirmed as-is. Decisions 2, 5, 7, 8, 9, 10, 12 confirmed without changes. **Decision 11 substantively rewritten**: `RedeemTickets` is dead code that hasn't been cleaned up — not retained for indexer compatibility or held multisig grants. Removed the "load-bearing for chain scrapers" framing from design.md Decision 11, proposal.md non-obvious-choices bullet, and spec.md (events requirement + the legacy-RedeemTickets requirement title and scenario name). Spec still validates at 25 reqs / 63 scenarios.
|
||||
- [x] 4.3 Walk through `specs/ecash-contract/spec.md` requirement by requirement; for each disagreement, decide whether the spec is wrong (update the spec) or the implementation is wrong (open a follow-on change). Specifically confirm the client-vs-gateway role split (clients deposit, gateways redeem) is correctly worded throughout. **Resolved 2026-05-21**: all 25 requirements walked in six batches (1–3, 4–7, 8–11, 12–16, 17–22, 23–25). One real bug found in Req 19: `DepositStorage::latest_deposit()` returned the counter (the next free id), so `GetLatestDeposit` always returned `{ deposit: None }` even after deposits. Fixed in `contracts/ecash/src/deposit.rs` (`counter.checked_sub(1)` semantics) and the existing `getting_latest_deposit` test updated; all 38 contract tests pass. Spec Req 19 rewritten to describe the corrected semantics and gained a new "After deposits exist..." scenario. Final shape: 25 requirements, 64 scenarios, valid.
|
||||
- [x] 4.4 Resolve the four Open Questions in `design.md` (holding_account updatability, multisig address updatability, admin renunciation, stubbed-blacklist final disposition) or move them to follow-on changes. **Resolved 2026-05-21**: three kept current behaviour (locked holding_account, locked multisig address, no admin renunciation) and folded into a new "Resolved Questions" section in design.md; stubbed-blacklist final disposition deferred to the blacklist redesign change owner, kept as the sole entry in "Open Questions".
|
||||
|
||||
## 5. Archive the change
|
||||
|
||||
- [x] 5.1 Once reviewed and accepted, run `openspec archive ecash-contract-spec` to promote `specs/ecash-contract/spec.md` into `openspec/specs/ecash-contract/spec.md` as the canonical spec.
|
||||
- [x] 5.2 Open the follow-on change `ecash-contract-rustdoc` once the canonical spec is archived, so that the rustdoc pass references the archived spec for normative phrasing. **Plan**: deferred to a fresh session (per the apply-loop decision on 2026-05-21). The rustdoc change will start with `/opsx:propose ecash-contract-rustdoc` and reference `openspec/specs/ecash-contract/spec.md` for normative phrasing.
|
||||
@@ -0,0 +1,20 @@
|
||||
schema: spec-driven
|
||||
|
||||
# Project context (optional)
|
||||
# This is shown to AI when creating artifacts.
|
||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
||||
# Example:
|
||||
# context: |
|
||||
# Tech stack: TypeScript, React, Node.js
|
||||
# We use conventional commits
|
||||
# Domain: e-commerce platform
|
||||
|
||||
# Per-artifact rules (optional)
|
||||
# Add custom rules for specific artifacts.
|
||||
# Example:
|
||||
# rules:
|
||||
# proposal:
|
||||
# - Keep proposals under 500 words
|
||||
# - Always include a "Non-goals" section
|
||||
# tasks:
|
||||
# - Break tasks into chunks of max 2 hours
|
||||
@@ -0,0 +1,505 @@
|
||||
# ecash-contract Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change ecash-contract-spec. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Contract instantiation persists the runtime config, multisig and admin addresses, and the ticketbook-size invariant
|
||||
|
||||
The contract SHALL be instantiable exactly once via the standard CosmWasm `instantiate` entry point. The instantiation message SHALL carry `holding_account` (a string-form Cosmos SDK address reserved for the future pool-contract transition), `multisig_addr` (the cw3 multisig contract address that gates redemption finalisation), `group_addr` (the cw4 group contract referenced by future blacklist proposals), and `deposit_amount` (the default per-deposit price). The handler MUST:
|
||||
|
||||
- bech32-validate `multisig_addr`, `holding_account`, and `group_addr`; an invalid `group_addr` MUST surface as `InvalidGroup { addr }`, an invalid `multisig_addr` or `holding_account` MUST surface as the underlying `StdError` from `addr_validate`;
|
||||
- persist `info.sender` as the `contract_admin` via `cw_controllers::Admin::set` (the message itself does not carry an admin field; the sender becomes admin by convention);
|
||||
- persist the validated `multisig_addr` as the `multisig` `Admin` slot;
|
||||
- snapshot `nym_network_defaults::TICKETBOOK_SIZE` into `Item<Invariants> { ticket_book_size }` under the storage key `"expected_invariants"`;
|
||||
- zero-initialise `PoolCounters` (`total_deposited`, `total_redeemed`, `tickets_requested_and_not_redeemed`) using the denom of the supplied `deposit_amount`;
|
||||
- persist the assembled `Config { group_addr (as cw4::Cw4Contract), holding_account, deposit_amount }`;
|
||||
- zero-initialise the default-price statistics accumulators (`deposits_with_default_price` and `deposits_with_default_price_amounts`);
|
||||
- record the contract name and `CARGO_PKG_VERSION` via `cw2::set_contract_version`;
|
||||
- record build information via `set_build_information!`.
|
||||
|
||||
The instantiation handler SHALL return `Response::default()` (no events, no attributes, no data).
|
||||
|
||||
#### Scenario: Valid instantiation persists every state slot
|
||||
- **WHEN** `instantiate` is called with valid bech32 strings for `holding_account`, `multisig_addr`, `group_addr` and a well-formed `deposit_amount`
|
||||
- **THEN** the contract stores `Config` verbatim, the validated multisig address (queryable via `multisig.assert_admin`), `info.sender` as the contract admin, the snapshotted `Invariants { ticket_book_size: TICKETBOOK_SIZE }`, zeroed `PoolCounters`, and zeroed default-price stats
|
||||
- **AND** `cw2::get_contract_version` returns the crate's `CARGO_PKG_VERSION`
|
||||
- **AND** the returned `Response` carries no events, attributes, or data
|
||||
|
||||
#### Scenario: Invalid group address is rejected with a typed error
|
||||
- **WHEN** `instantiate` is called with a `group_addr` that fails `Addr::validate`
|
||||
- **THEN** the call returns `EcashContractError::InvalidGroup { addr }` and no state is persisted
|
||||
|
||||
#### Scenario: Invalid multisig address propagates the `StdError`
|
||||
- **WHEN** `instantiate` is called with a `multisig_addr` that fails `Addr::validate`
|
||||
- **THEN** the call returns an `EcashContractError::Std` wrapping the underlying validation error and no state is persisted
|
||||
|
||||
### Requirement: Migration handles version gating, default-price backfill, and whitelist seeding atomically
|
||||
|
||||
The contract SHALL expose a `migrate` entry point taking `MigrateMsg { initial_whitelist: Vec<WhitelistedDeposit> }`. The handler MUST:
|
||||
|
||||
- call `set_build_information!` to refresh stored build metadata;
|
||||
- call `cw2::ensure_from_older_version` against `CONTRACT_NAME` and `CONTRACT_VERSION`; an on-chain version strictly greater than `CARGO_PKG_VERSION` MUST be rejected;
|
||||
- run `queued_migrations::add_tiered_pricing(deps, initial_whitelist)`, which (a) reads `DepositStorage::total_deposits_made` and `PoolCounters::total_deposited` from the pre-migration state, (b) writes those values into `deposits_with_default_price` and `deposits_with_default_price_amounts` respectively (because every pre-migration deposit was at the default price), and (c) iterates `initial_whitelist`, validating each entry's denom and amount (see Requirement "Setting a reduced deposit price"), persisting each into `reduced_deposits`;
|
||||
- return `Response::default()`.
|
||||
|
||||
The migration MUST fail atomically if any whitelist entry fails validation — partial whitelist seeding MUST NOT be persisted.
|
||||
|
||||
#### Scenario: Migration on contract with prior default-price deposits backfills both counters
|
||||
- **WHEN** `migrate` is called with `initial_whitelist = []` on a contract that has performed `N > 0` deposits totalling `T` units
|
||||
- **THEN** `deposits_with_default_price` reads `N` and `deposits_with_default_price_amounts` reads `T` (same denom as the contract config)
|
||||
|
||||
#### Scenario: Migration with a valid whitelist persists every entry
|
||||
- **WHEN** `migrate` is called with two well-formed `WhitelistedDeposit` entries
|
||||
- **THEN** `reduced_deposits` returns the matching `Coin` for each address on subsequent reads
|
||||
|
||||
#### Scenario: Migration with a single invalid whitelist entry rolls back the entire transaction
|
||||
- **WHEN** `migrate` is called with `initial_whitelist = [valid_entry, invalid_entry]` where `invalid_entry` uses the wrong denom
|
||||
- **THEN** the call returns `InvalidReducedDepositDenom { expected, got }` and `reduced_deposits` contains no entries from this migration
|
||||
|
||||
#### Scenario: Newer on-chain version is rejected
|
||||
- **WHEN** `migrate` is called against an on-chain `cw2` version strictly greater than `CARGO_PKG_VERSION`
|
||||
- **THEN** the call returns an error and storage is unchanged
|
||||
|
||||
### Requirement: Authorisation — contract admin and multisig pointer
|
||||
|
||||
The contract SHALL maintain two `cw_controllers::Admin` slots at storage keys `"contract_admin"` and `"multisig"`. Only the first carries admin semantics in the operational sense; the second is a stored cw3 contract pointer that uses the same wrapper purely as a generic address-equality helper.
|
||||
|
||||
- `contract_admin` SHALL gate `UpdateAdmin`, `UpdateDefaultDepositValue`, `SetReducedDepositPrice`, `RemoveReducedDepositPrice`. It is replaceable through `UpdateAdmin`, which dispatches `cw_controllers::Admin::execute_update_admin` (requiring the current admin to sign the transaction).
|
||||
- `multisig` SHALL gate `RedeemTickets` via `assert_admin` (which on this slot is effectively "is the caller equal to the stored cw3 contract address?"). It SHALL NOT be updatable through any execute path; replacing it requires redeploying the contract.
|
||||
|
||||
The wrapper's full admin-transfer machinery is reachable in code on both slots but is exercised only on `contract_admin`.
|
||||
|
||||
#### Scenario: Non-admin call to a contract-admin-gated handler is rejected
|
||||
- **WHEN** any of `UpdateDefaultDepositValue`, `SetReducedDepositPrice`, `RemoveReducedDepositPrice` is sent by an address other than the current `contract_admin`
|
||||
- **THEN** the call returns `EcashContractError::Admin` wrapping `AdminError::NotAdmin` and state is unchanged
|
||||
|
||||
#### Scenario: Non-multisig call to `RedeemTickets` is rejected
|
||||
- **WHEN** `RedeemTickets { n, gw }` is sent by an address other than the configured `multisig`
|
||||
- **THEN** the call returns `EcashContractError::Admin` wrapping `AdminError::NotAdmin` and `PoolCounters::tickets_requested_and_not_redeemed` is unchanged
|
||||
|
||||
### Requirement: `DepositTicketBookFunds` classifies the sent amount and updates the correct statistics bucket
|
||||
|
||||
`ExecuteMsg::DepositTicketBookFunds { identity_key }` SHALL accept exactly one coin in the configured denom (validated via `cw_utils::must_pay`) and classify the sender at deposit time:
|
||||
|
||||
1. If the sent amount equals `Config::deposit_amount.amount`, the deposit is treated as a **default-price** deposit. The handler MUST call `DepositStatsStorage::new_default_deposit`, which increments `deposits_with_default_price` by 1 and adds the deposited amount to `deposits_with_default_price_amounts`. This branch fires *regardless* of whether the sender has a reduced-deposit entry.
|
||||
2. Otherwise, if the sender has a `reduced_deposits[sender]` entry whose `amount` equals the sent amount, the deposit is treated as a **reduced-price** deposit. The handler MUST call `DepositStatsStorage::new_reduced_deposit`, which increments `deposits_with_custom_price[sender]` by 1 and adds the deposited amount to `deposits_with_custom_price_amounts[sender]`.
|
||||
3. Otherwise, the call MUST fail with `EcashContractError::WrongAmount { received, amount }`, where `amount` is the reduced amount if the sender is whitelisted, else the default amount.
|
||||
|
||||
After successful classification the handler MUST:
|
||||
|
||||
- add the deposited amount to `PoolCounters.total_deposited.amount`;
|
||||
- assign and persist a sequential `deposit_id` via `DepositStorage::save_deposit` (which decodes `identity_key` from bs58 to a 32-byte raw representation and writes it under the `"deposit"` namespace);
|
||||
- emit a `deposited-funds` event with attribute `deposit-id` carrying the assigned id as a decimal string;
|
||||
- set the response data to `deposit_id.to_be_bytes()` so callers can recover the id from the transaction result.
|
||||
|
||||
The handler MUST NOT verify that the sender controls the private key matching the supplied `identity_key`. The identity-ownership proof is delegated to off-chain nym-api signers (`post_blind_sign` at `nym-api/src/ecash/api_routes/partial_signing.rs:55`).
|
||||
|
||||
#### Scenario: Default-price deposit increments default counters and emits event with deposit id
|
||||
- **WHEN** a sender with no reduced-deposit entry sends `DepositTicketBookFunds { identity_key }` with funds matching the default amount
|
||||
- **THEN** `deposits_with_default_price` increases by 1, `deposits_with_default_price_amounts` increases by the deposited amount, `PoolCounters.total_deposited` increases by the deposited amount, a new `deposit_id` is persisted with the supplied `identity_key`, a `deposited-funds` event is emitted with the `deposit-id` attribute set to the new id, and the response data is the big-endian byte representation of the new id
|
||||
|
||||
#### Scenario: Whitelisted sender paying the default amount is bucketed as default-price
|
||||
- **WHEN** a whitelisted sender (entry in `reduced_deposits`) sends `DepositTicketBookFunds` with funds matching the **default** amount (not their reduced amount)
|
||||
- **THEN** the deposit is recorded under `deposits_with_default_price` (not under `deposits_with_custom_price[sender]`), and the deposit is persisted normally
|
||||
|
||||
#### Scenario: Whitelisted sender paying the reduced amount is bucketed as custom-price
|
||||
- **WHEN** a whitelisted sender sends `DepositTicketBookFunds` with funds matching their reduced amount
|
||||
- **THEN** `deposits_with_custom_price[sender]` increases by 1, `deposits_with_custom_price_amounts[sender]` increases by the reduced amount, and the deposit is persisted normally
|
||||
|
||||
#### Scenario: Whitelisted sender paying neither default nor their reduced amount is rejected with the reduced amount as the expected
|
||||
- **WHEN** a whitelisted sender sends an amount equal to neither `Config::deposit_amount.amount` nor `reduced_deposits[sender].amount`
|
||||
- **THEN** the call returns `EcashContractError::WrongAmount { received, amount }` where `amount` is the sender's reduced amount
|
||||
|
||||
#### Scenario: Non-whitelisted sender paying the wrong amount is rejected with the default amount as the expected
|
||||
- **WHEN** a sender with no reduced-deposit entry sends an amount different from `Config::deposit_amount.amount`
|
||||
- **THEN** the call returns `EcashContractError::WrongAmount { received, amount }` where `amount` is `Config::deposit_amount`
|
||||
|
||||
#### Scenario: Missing, mismatched, or multi-denom funds are rejected by `must_pay` before amount classification
|
||||
- **WHEN** `DepositTicketBookFunds` is sent with no attached coins, with multiple denom coins, or with a single coin in a denom different from `Config::deposit_amount.denom`
|
||||
- **THEN** the call returns `EcashContractError::InvalidDeposit(PaymentError::NoFunds)`, `EcashContractError::InvalidDeposit(PaymentError::MultipleDenoms)`, or `EcashContractError::InvalidDeposit(PaymentError::MissingDenom(<expected-denom>))` respectively, before any classification, counter, or storage write
|
||||
|
||||
### Requirement: Deposit identity-key payload is opaque to the contract; ownership is enforced off-chain
|
||||
|
||||
The contract SHALL accept `identity_key: String` as an opaque payload at deposit submission time. The handler MUST NOT verify control of the corresponding ed25519 private key. The handler MUST persist the payload such that it round-trips losslessly through `Deposit::to_bytes` → 32-byte raw storage → `Deposit::try_from_bytes` → bs58 string; any payload that fails to decode to exactly 32 bytes via `bs58::decode` MUST surface as `EcashContractError::MalformedEd25519Identity` (raised by `Deposit::to_bytes` during the `save_deposit` flow). The proof-of-control check is performed off-chain by nym-api signers when honouring `post_blind_sign`.
|
||||
|
||||
#### Scenario: Sender can claim any well-formed ed25519 pubkey
|
||||
- **WHEN** sender `A` submits `DepositTicketBookFunds { identity_key: B_pubkey }` where `B_pubkey` is sender `B`'s ed25519 public key, paying the correct amount
|
||||
- **THEN** the deposit is accepted and `GetDeposit { deposit_id }` returns `Deposit { bs58_encoded_ed25519_pubkey: B_pubkey }`
|
||||
|
||||
#### Scenario: Malformed bs58 ed25519 payload is rejected at save time
|
||||
- **WHEN** `DepositTicketBookFunds { identity_key: "not-a-valid-bs58-key" }` is submitted with the correct funds
|
||||
- **THEN** the call returns `EcashContractError::MalformedEd25519Identity` and no deposit is persisted, no counters are incremented
|
||||
|
||||
### Requirement: Deposit ids are sequential `u32` starting at 0, never recycled
|
||||
|
||||
The contract SHALL maintain a single `Item<u32>` counter at the storage key `"deposit_ids"`. The counter SHALL be unset on a freshly instantiated contract and treated as zero. Each successful `DepositTicketBookFunds` invocation SHALL:
|
||||
|
||||
- read the current counter value (defaulting to 0 when absent), which becomes the new deposit's id;
|
||||
- persist `counter + 1` as the new counter value;
|
||||
- write the 32-byte raw ed25519 pubkey for that id under the `"deposit"` storage namespace (bypassing JSON serialisation for storage efficiency — see `StoredDeposits`).
|
||||
|
||||
`DepositStorage::total_deposits_made(storage)` and `DepositStorage::latest_deposit(storage)` SHALL both read the counter directly — `total_deposits_made` returns `unwrap_or(0)` (count of deposits ever performed), `latest_deposit` returns `may_load` (the *next* unused id, which is also the count). Deposit ids SHALL NOT be recycled even if the deposit can later be invalidated off-chain.
|
||||
|
||||
#### Scenario: First deposit on a fresh contract gets id 0
|
||||
- **WHEN** the first-ever `DepositTicketBookFunds` is processed
|
||||
- **THEN** the assigned `deposit_id` is `0`, the persisted counter becomes `1`, and `total_deposits_made` returns `1`
|
||||
|
||||
#### Scenario: Subsequent deposits get strictly increasing ids
|
||||
- **WHEN** three deposits are processed in sequence
|
||||
- **THEN** they receive ids `0`, `1`, `2` and the counter becomes `3`
|
||||
|
||||
### Requirement: Deposit statistics invariant — default count plus per-account custom counts equals total
|
||||
|
||||
The contract SHALL maintain the invariant `DepositStatsStorage::get_total_deposits_made_with_default_price(storage) + sum(deposits_with_custom_price[a] for a in addrs) == DepositStorage::total_deposits_made(storage)` at all times after instantiation. This invariant MUST hold across every successful and failed transaction (a failed deposit MUST NOT touch any counter). The `#[cfg(test)] assert_counts_consistent` helper in `DepositStatsStorage` SHALL exist solely to validate this invariant in tests and is part of the maintenance contract for any code that writes to the `"deposit"` namespace.
|
||||
|
||||
#### Scenario: Mixed default and reduced deposits maintain the invariant
|
||||
- **WHEN** two default-price deposits and one reduced-price deposit by address `alice` are processed in any order
|
||||
- **THEN** `deposits_with_default_price` reads `2`, `deposits_with_custom_price[alice]` reads `1`, `total_deposits_made` reads `3`, and `2 + 1 == 3`
|
||||
|
||||
#### Scenario: A rejected `DepositTicketBookFunds` does not touch any counter
|
||||
- **WHEN** `DepositTicketBookFunds` is rejected with `WrongAmount`
|
||||
- **THEN** none of `deposit_ids`, `deposits_with_default_price`, `deposits_with_custom_price` change
|
||||
|
||||
### Requirement: `UpdateDefaultDepositValue` is admin-gated and refuses values below the ticketbook size
|
||||
|
||||
`ExecuteMsg::UpdateDefaultDepositValue { new_deposit: Coin }` SHALL be callable only by the contract admin (`contract_admin.assert_admin`). The handler MUST consult the on-chain `Invariants::ticket_book_size` via `get_ticketbook_size`; if the snapshotted value differs from the current crate `TICKETBOOK_SIZE`, the call MUST fail with `TicketBookSizeChanged { at_init, current }` *before* any further work. If `new_deposit.amount < TICKETBOOK_SIZE`, the call MUST fail with `DepositBelowTicketBookSize { amount, ticket_book_size }`. On success the handler SHALL overwrite `Config::deposit_amount` with `new_deposit` and emit a `wasm` event attribute `updated_deposit` carrying the new value as its `Coin::to_string()` form.
|
||||
|
||||
Note: the handler does *not* validate that `new_deposit.denom` matches the existing config's denom. Changing the denom is permitted by the current handler but is operationally hazardous (existing reduced-deposit entries and statistics use the old denom). Reviewers should treat denom changes as a coordinated migration concern, not an admin operation.
|
||||
|
||||
#### Scenario: Admin successfully bumps the default price
|
||||
- **WHEN** the contract admin sends `UpdateDefaultDepositValue { new_deposit }` with `new_deposit.amount >= TICKETBOOK_SIZE` and the network-defaults `TICKETBOOK_SIZE` matches the snapshotted invariant
|
||||
- **THEN** `Config::deposit_amount` equals `new_deposit` and the response contains a `wasm` attribute `updated_deposit = new_deposit.to_string()`
|
||||
|
||||
#### Scenario: Non-admin call is rejected
|
||||
- **WHEN** any non-admin sends `UpdateDefaultDepositValue`
|
||||
- **THEN** the call returns `EcashContractError::Admin` and `Config::deposit_amount` is unchanged
|
||||
|
||||
#### Scenario: Value below ticketbook size is rejected
|
||||
- **WHEN** the admin sends `UpdateDefaultDepositValue { new_deposit }` with `new_deposit.amount < TICKETBOOK_SIZE`
|
||||
- **THEN** the call returns `DepositBelowTicketBookSize { amount: new_deposit.amount, ticket_book_size: TICKETBOOK_SIZE }`
|
||||
|
||||
### Requirement: `SetReducedDepositPrice` is admin-gated with denom, strict-less-than, and ticketbook-size validation
|
||||
|
||||
`ExecuteMsg::SetReducedDepositPrice { address, deposit }` SHALL be callable only by the contract admin. The handler MUST validate `address` via `addr_validate`, then call `add_reduced_deposit_address`, which MUST:
|
||||
|
||||
- reject `deposit.denom != Config::deposit_amount.denom` with `InvalidReducedDepositDenom { expected, got }`;
|
||||
- reject `deposit.amount >= Config::deposit_amount.amount` with `ReducedDepositNotReduced { reduced, default }` (the reduced amount must be **strictly less than** the default);
|
||||
- reject `deposit.amount < TICKETBOOK_SIZE` with `DepositBelowTicketBookSize { amount, ticket_book_size }` (subject to the same `get_ticketbook_size` tripwire as Requirement "UpdateDefaultDepositValue");
|
||||
- persist `reduced_deposits[address] = deposit` (overwriting any existing entry).
|
||||
|
||||
On success the handler SHALL emit `wasm` attributes `action = "set_reduced_deposit_price"`, `address`, and `deposit` (as `Coin::to_string()`).
|
||||
|
||||
#### Scenario: Admin sets a valid reduced price
|
||||
- **WHEN** the admin sends `SetReducedDepositPrice { address, deposit }` with matching denom, amount strictly less than default, and amount at least the ticketbook size
|
||||
- **THEN** `reduced_deposits[address] = deposit` and the response carries attributes `action = "set_reduced_deposit_price"`, `address`, `deposit`
|
||||
|
||||
#### Scenario: Overwriting an existing entry succeeds
|
||||
- **WHEN** the admin sends `SetReducedDepositPrice` for an address that already has a reduced entry
|
||||
- **THEN** the existing entry is replaced with the new value (no error, no archiving of the old value)
|
||||
|
||||
#### Scenario: Reduced amount not strictly less than default is rejected
|
||||
- **WHEN** the admin sends `SetReducedDepositPrice` with `deposit.amount == Config::deposit_amount.amount`
|
||||
- **THEN** the call returns `ReducedDepositNotReduced { reduced, default }` with both fields equal
|
||||
|
||||
#### Scenario: Mismatched denom is rejected
|
||||
- **WHEN** the admin sends `SetReducedDepositPrice` with a denom different from `Config::deposit_amount.denom`
|
||||
- **THEN** the call returns `InvalidReducedDepositDenom { expected, got }`
|
||||
|
||||
### Requirement: `RemoveReducedDepositPrice` is admin-gated and requires an existing entry
|
||||
|
||||
`ExecuteMsg::RemoveReducedDepositPrice { address }` SHALL be callable only by the contract admin. The handler MUST validate `address`, then check `reduced_deposits.has(storage, address)`; if no entry exists, the call MUST fail with `NoReducedDepositPrice { address }`. On success the entry is removed from `reduced_deposits` and the response carries `wasm` attributes `action = "remove_reduced_deposit_price"` and `address`.
|
||||
|
||||
Removal does not retroactively affect historical statistics for the removed address — past `deposits_with_custom_price[address]` and `..._amounts[address]` entries remain.
|
||||
|
||||
#### Scenario: Admin removes an existing entry
|
||||
- **WHEN** the admin sends `RemoveReducedDepositPrice` for an address with an existing reduced entry
|
||||
- **THEN** `reduced_deposits[address]` is absent, historical `deposits_with_custom_price[address]` is unchanged, and the response carries `action` and `address` attributes
|
||||
|
||||
#### Scenario: Removing a non-existent entry is rejected
|
||||
- **WHEN** the admin sends `RemoveReducedDepositPrice` for an address with no reduced entry
|
||||
- **THEN** the call returns `NoReducedDepositPrice { address }`
|
||||
|
||||
### Requirement: `UpdateAdmin` requires the current admin and validates the new address
|
||||
|
||||
`ExecuteMsg::UpdateAdmin { admin }` SHALL be callable only by the current `contract_admin`. The handler MUST `addr_validate` the supplied string, then call `cw_controllers::Admin::execute_update_admin(deps, info, Some(new_admin))`. The cw_controllers handshake performs the sender-equality check internally. On success the new address replaces the current `contract_admin` slot, and the response carries the cw_controllers-standard attributes (`action = "update_admin"`, `admin = <new>`, `sender = <old>`).
|
||||
|
||||
The handler always passes `Some(new_admin)` — admin renunciation (passing `None`) is not exposed through the public surface today.
|
||||
|
||||
#### Scenario: Current admin transfers admin rights
|
||||
- **WHEN** the current admin sends `UpdateAdmin { admin: new_admin }` with a valid bech32 string
|
||||
- **THEN** subsequent `contract_admin.assert_admin` checks succeed only for `new_admin`, and the old admin's calls to admin-gated handlers fail
|
||||
|
||||
#### Scenario: Non-admin call is rejected
|
||||
- **WHEN** an address other than the current admin sends `UpdateAdmin`
|
||||
- **THEN** the call returns `EcashContractError::Admin` wrapping `AdminError::NotAdmin`
|
||||
|
||||
#### Scenario: Invalid bech32 address is rejected
|
||||
- **WHEN** the current admin sends `UpdateAdmin { admin: "not-bech32" }`
|
||||
- **THEN** the call returns an `EcashContractError::Std` wrapping the underlying `addr_validate` failure
|
||||
|
||||
### Requirement: `RequestRedemption` validates the commitment and dispatches a multisig propose SubMsg
|
||||
|
||||
`ExecuteMsg::RequestRedemption { commitment_bs58, number_of_tickets }` SHALL be callable by any address. The handler MUST:
|
||||
|
||||
- decode `commitment_bs58` via `bs58::decode(...).into_vec()`; on decode failure or if the decoded length is not exactly 32 bytes (sha256 digest length), the call MUST fail with `MalformedRedemptionCommitment`;
|
||||
- construct a `cw3` `Propose` message via `create_batch_redemption_proposal`, with title `BATCH_REDEMPTION_PROPOSAL_TITLE = "ecash-redemption"`, description equal to `commitment_bs58`, and a single embedded `ExecuteMsg::RedeemTickets { n: number_of_tickets, gw: <sender> }` targeting the ecash contract itself;
|
||||
- wrap that proposal in a `SubMsg::reply_always(..., REDEMPTION_PROPOSAL_REPLY_ID)`;
|
||||
- return `Response::new().add_submessage(submsg)`.
|
||||
|
||||
The handler does NOT itself transfer funds, mark tickets as redeemed, or modify any storage on the ecash contract directly. The actual redemption effect (incrementing `tickets_requested_and_not_redeemed`) only fires when the multisig contract subsequently executes the embedded `RedeemTickets` after a successful vote (see Requirement "Legacy `RedeemTickets`").
|
||||
|
||||
#### Scenario: Valid commitment dispatches a multisig propose with the right shape
|
||||
- **WHEN** any sender sends `RequestRedemption` with a 32-byte sha256 digest encoded in bs58 and `number_of_tickets = N`
|
||||
- **THEN** the response carries a single `SubMsg` with `id == REDEMPTION_PROPOSAL_REPLY_ID`, targeting the multisig contract, encoding a `Propose` with title `"ecash-redemption"`, description equal to the input `commitment_bs58`, and a single inner `WasmMsg::Execute` calling `RedeemTickets { n: N, gw: <sender> }` on the ecash contract
|
||||
|
||||
#### Scenario: Non-bs58 commitment is rejected
|
||||
- **WHEN** `RequestRedemption { commitment_bs58: "!!!" }` is sent
|
||||
- **THEN** the call returns `MalformedRedemptionCommitment` and no SubMsg is dispatched
|
||||
|
||||
#### Scenario: Bs58-decodable but wrong-length commitment is rejected
|
||||
- **WHEN** `RequestRedemption` is sent with a `commitment_bs58` whose decoded bytes have length != 32
|
||||
- **THEN** the call returns `MalformedRedemptionCommitment`
|
||||
|
||||
### Requirement: The redemption-proposal reply handler captures the multisig proposal id and surfaces it as response data
|
||||
|
||||
The contract SHALL register a single `reply` entry point that dispatches by `Reply::id`. For `REDEMPTION_PROPOSAL_REPLY_ID`, the handler MUST call `Reply::multisig_proposal_id()`, which extracts the `proposal_id` attribute (`PROPOSAL_ID_ATTRIBUTE_NAME = "proposal_id"`) from the `wasm` event of the embedded multisig result. On success the handler MUST return `Response::new().set_data(proposal_id.to_be_bytes())` so callers can recover the multisig-issued id from the transaction result. On failure of the embedded SubMsg, the handler MUST return `EcashContractError::Std(StdError::generic_err(reply_err))`. If the `proposal_id` attribute is missing or malformed, the handler MUST return `MissingProposalId` or `MalformedProposalId` respectively.
|
||||
|
||||
#### Scenario: Successful multisig propose flows the proposal id back to the gateway
|
||||
- **WHEN** the redemption-proposal SubMsg succeeds and the multisig contract emits a `wasm` event with attribute `proposal_id = "42"`
|
||||
- **THEN** the reply handler returns `Response::new().set_data([0,0,0,0,0,0,0,42])` (big-endian u64)
|
||||
|
||||
#### Scenario: Missing proposal_id attribute is reported as a typed error
|
||||
- **WHEN** the SubMsg succeeds but no `wasm` event carries a `proposal_id` attribute
|
||||
- **THEN** the reply handler returns `MissingProposalId`
|
||||
|
||||
#### Scenario: Failed SubMsg propagates as a generic StdError
|
||||
- **WHEN** the SubMsg fails with error string `e`
|
||||
- **THEN** the reply handler returns `EcashContractError::Std(StdError::generic_err(e))`
|
||||
|
||||
#### Scenario: Unknown reply id is rejected
|
||||
- **WHEN** the reply entry point is invoked with `Reply::id` not equal to `REDEMPTION_PROPOSAL_REPLY_ID` or `BLACKLIST_PROPOSAL_REPLY_ID`
|
||||
- **THEN** the handler returns `InvalidReplyId { id }`
|
||||
|
||||
### Requirement: Legacy `RedeemTickets` is multisig-gated, bumps the unredeemed-tickets counter, and emits a single event
|
||||
|
||||
`ExecuteMsg::RedeemTickets { n, gw }` is **dead code retained on the public surface for backwards compatibility only**. It SHALL be callable **only** by the configured `multisig` address (`multisig.assert_admin`). The handler MUST:
|
||||
|
||||
- ignore `gw` at runtime (the argument is preserved in the transaction body but not consumed);
|
||||
- increment `PoolCounters.tickets_requested_and_not_redeemed` by `n` (as `u64`);
|
||||
- emit `Event::new("ticket_redemption").add_attribute("moved_to_holding_account", "false")`.
|
||||
|
||||
The handler does not move funds. No known active consumer depends on the counter increment or the event; both are remnants of the deprecated semantics. A follow-on change may remove this variant entirely.
|
||||
|
||||
#### Scenario: Multisig successfully redeems n tickets
|
||||
- **WHEN** the configured multisig sends `RedeemTickets { n: 5, gw: "<gateway-addr>" }`
|
||||
- **THEN** `PoolCounters.tickets_requested_and_not_redeemed` increases by 5, and the response carries a `ticket_redemption` event with `moved_to_holding_account = "false"`
|
||||
|
||||
#### Scenario: Non-multisig caller is rejected
|
||||
- **WHEN** an address other than the configured multisig sends `RedeemTickets`
|
||||
- **THEN** the call returns `EcashContractError::Admin` and the counter is unchanged
|
||||
|
||||
### Requirement: Stubbed blacklist execute handlers always return `UnimplementedBlacklisting`
|
||||
|
||||
`ExecuteMsg::ProposeToBlacklist { public_key }` and `ExecuteMsg::AddToBlacklist { public_key }` SHALL be present in the public schema but SHALL always return `EcashContractError::UnimplementedBlacklisting`. The handlers MUST NOT touch storage, dispatch SubMsgs, or emit events.
|
||||
|
||||
The blacklist storage map (`blacklist: Map<BlacklistKey, Blacklisting>` at storage key `"blacklist"`), the reply handler for `BLACKLIST_PROPOSAL_REPLY_ID`, and the `create_blacklist_proposal` helper remain wired in source. They are preserved as the starting point for the redesign and are NOT reachable from the current public ExecuteMsg surface.
|
||||
|
||||
#### Scenario: `ProposeToBlacklist` always errors
|
||||
- **WHEN** any sender (admin, multisig, or other) sends `ProposeToBlacklist { public_key }`
|
||||
- **THEN** the call returns `EcashContractError::UnimplementedBlacklisting` and the `blacklist` storage is unchanged
|
||||
|
||||
#### Scenario: `AddToBlacklist` always errors
|
||||
- **WHEN** any sender sends `AddToBlacklist { public_key }`
|
||||
- **THEN** the call returns `EcashContractError::UnimplementedBlacklisting` and the `blacklist` storage is unchanged
|
||||
|
||||
### Requirement: The ticketbook-size invariant tripwire catches uncoordinated network-defaults bumps
|
||||
|
||||
The contract SHALL persist `Item<Invariants> { ticket_book_size }` at storage key `"expected_invariants"` on instantiation, snapshotting `nym_network_defaults::TICKETBOOK_SIZE`. Every priced operation that consults the ticketbook size (currently `UpdateDefaultDepositValue` and `SetReducedDepositPrice` / migration whitelist seeding via `add_reduced_deposit_address`) SHALL call `NymEcashContract::get_ticketbook_size`, which compares the stored value to the crate's current `TICKETBOOK_SIZE`. A mismatch MUST surface as `TicketBookSizeChanged { at_init, current }`, halting the operation before any state mutation.
|
||||
|
||||
#### Scenario: Snapshot equals current crate constant — operation proceeds
|
||||
- **WHEN** the snapshotted `Invariants::ticket_book_size` equals `nym_network_defaults::TICKETBOOK_SIZE` and an admin sends a valid `UpdateDefaultDepositValue`
|
||||
- **THEN** the operation succeeds normally
|
||||
|
||||
#### Scenario: Snapshot differs from current crate constant — operation halted
|
||||
- **WHEN** the snapshotted `Invariants::ticket_book_size` is `T_init` and the contract has been redeployed with a new crate constant `T_current != T_init`, then an admin sends `UpdateDefaultDepositValue` or migration seeds a reduced-deposit entry
|
||||
- **THEN** the call returns `TicketBookSizeChanged { at_init: T_init, current: T_current }` before any further work
|
||||
|
||||
### Requirement: Default-deposit queries
|
||||
|
||||
The contract SHALL expose two equivalent queries for the default deposit amount, kept aliased for backwards compatibility:
|
||||
|
||||
- `QueryMsg::GetDefaultDepositAmount {}` — returns `Coin = Config::deposit_amount`.
|
||||
- `QueryMsg::GetRequiredDepositAmount {}` (also accepted via the `get_required_deposit_amount` `serde` alias on `GetDefaultDepositAmount`) — equivalent to the above; the handler delegates to `get_default_deposit_amount`.
|
||||
|
||||
Both queries SHALL succeed unconditionally for any sender (queries are read-only).
|
||||
|
||||
#### Scenario: Both query variants return identical data
|
||||
- **WHEN** both `GetDefaultDepositAmount {}` and `GetRequiredDepositAmount {}` are queried on the same contract state
|
||||
- **THEN** both return the same `Coin`, equal to `Config::deposit_amount`
|
||||
|
||||
### Requirement: Reduced-deposit queries
|
||||
|
||||
The contract SHALL expose two queries for tier-pricing state:
|
||||
|
||||
- `QueryMsg::GetReducedDepositAmount { address }` — validates `address` via `addr_validate`, then returns `Option<Coin> = reduced_deposits.may_load(storage, addr)`. An invalid bech32 input MUST return an `EcashContractError::Std` wrapping the underlying validation error.
|
||||
- `QueryMsg::GetAllWhitelistedAccounts {}` — returns `WhitelistedAccountsResponse { whitelisted_accounts }` enumerating all entries in `reduced_deposits` in ascending address order.
|
||||
|
||||
`GetAllWhitelistedAccounts` is unpaginated by design (the whitelist is expected to remain small).
|
||||
|
||||
#### Scenario: Reduced amount is returned when an entry exists
|
||||
- **WHEN** address `alice` has `reduced_deposits[alice] = Coin { 10, "unym" }` and `GetReducedDepositAmount { address: alice }` is queried
|
||||
- **THEN** the response is `Some(Coin { 10, "unym" })`
|
||||
|
||||
#### Scenario: Absent entry returns None
|
||||
- **WHEN** address `bob` has no entry and `GetReducedDepositAmount { address: bob }` is queried
|
||||
- **THEN** the response is `None`
|
||||
|
||||
#### Scenario: All whitelisted accounts are enumerated
|
||||
- **WHEN** the contract has entries for `alice` and `bob` and `GetAllWhitelistedAccounts {}` is queried
|
||||
- **THEN** the response contains both entries
|
||||
|
||||
### Requirement: Deposit-by-id and latest-deposit queries
|
||||
|
||||
The contract SHALL expose:
|
||||
|
||||
- `QueryMsg::GetDeposit { deposit_id }` — returns `DepositResponse { id: deposit_id, deposit: Option<Deposit> }`. The `deposit` field is `Some` if a deposit was ever persisted under that id (deposits are not deletable). It is `None` if the id has not yet been issued (i.e. `id >= total_deposits_made`).
|
||||
- `QueryMsg::GetLatestDeposit {}` — returns `LatestDepositResponse { deposit: Option<DepositData> }`. The handler MUST consult `DepositStorage::latest_deposit`, which returns the id of the most recently assigned deposit (`counter - 1` when the counter has been incremented at least once, else `None`), and then load that id. On a fresh contract with no prior deposit, the response is `LatestDepositResponse { deposit: None }`; after any successful deposit, the response is `LatestDepositResponse { deposit: Some(DepositData { id, deposit }) }` where `id` is the most recent assignment.
|
||||
|
||||
#### Scenario: Existing deposit is returned by id
|
||||
- **WHEN** a deposit was persisted at id `0` with `identity_key = K` and `GetDeposit { deposit_id: 0 }` is queried
|
||||
- **THEN** the response is `DepositResponse { id: 0, deposit: Some(Deposit { bs58_encoded_ed25519_pubkey: K }) }`
|
||||
|
||||
#### Scenario: Unknown id returns None
|
||||
- **WHEN** `GetDeposit { deposit_id: 999 }` is queried and the counter is less than 999
|
||||
- **THEN** the response is `DepositResponse { id: 999, deposit: None }`
|
||||
|
||||
#### Scenario: Fresh contract latest-deposit query returns None
|
||||
- **WHEN** `GetLatestDeposit {}` is queried on a contract with no deposits
|
||||
- **THEN** the response is `LatestDepositResponse { deposit: None }`
|
||||
|
||||
#### Scenario: After deposits exist, latest-deposit query returns the most recent assignment
|
||||
- **WHEN** two deposits have been processed (ids `0` and `1`) and `GetLatestDeposit {}` is queried
|
||||
- **THEN** the response is `LatestDepositResponse { deposit: Some(DepositData { id: 1, deposit: <the deposit persisted at id 1> }) }`
|
||||
|
||||
### Requirement: Paginated deposits query
|
||||
|
||||
`QueryMsg::GetDepositsPaged { limit, start_after }` SHALL return at most `limit.unwrap_or(DEPOSITS_PAGE_DEFAULT_LIMIT).min(DEPOSITS_PAGE_MAX_LIMIT)` deposits in ascending id order, starting strictly after `start_after`. The defaults `DEPOSITS_PAGE_DEFAULT_LIMIT = 50` and `DEPOSITS_PAGE_MAX_LIMIT = 100` are part of the public surface and changing them is a behavioural change. The response SHALL be `PagedDepositsResponse { deposits, start_next_after }`, where `start_next_after` is the id of the last entry returned (or `None` if zero entries were returned).
|
||||
|
||||
#### Scenario: Default limits clamp to the maximum
|
||||
- **WHEN** `GetDepositsPaged { limit: Some(1000), start_after: None }` is queried on a contract with 200 deposits
|
||||
- **THEN** the response contains 100 entries (ids 0..=99) and `start_next_after = Some(99)`
|
||||
|
||||
#### Scenario: Pagination via `start_after`
|
||||
- **WHEN** the previous query returned `start_next_after = Some(99)` and a follow-up sends `GetDepositsPaged { limit: None, start_after: Some(99) }`
|
||||
- **THEN** the response starts at id 100
|
||||
|
||||
### Requirement: `GetDepositsStatistics` reassembles the global and per-account picture
|
||||
|
||||
`QueryMsg::GetDepositsStatistics {}` SHALL return `DepositsStatistics` populated from:
|
||||
|
||||
- `total_deposits_made` ← `DepositStorage::total_deposits_made(storage)`;
|
||||
- `total_deposited` ← `PoolCounters::total_deposited`;
|
||||
- `total_deposits_made_with_default_price` ← `deposits_with_default_price`;
|
||||
- `total_deposited_with_default_price` ← `deposits_with_default_price_amounts`;
|
||||
- `total_deposits_made_with_custom_price` ← `sum(deposits_with_custom_price[a])` across all addresses;
|
||||
- `total_deposited_with_custom_price` ← `sum(deposits_with_custom_price_amounts[a])` across all addresses (using `Config::deposit_amount.denom`);
|
||||
- `deposits_made_with_custom_price: HashMap<String, u32>` and `deposited_with_custom_price: HashMap<String, Coin>` ← per-account aggregates from the custom-price maps, keyed by the stringified `Addr`.
|
||||
|
||||
The query MUST be a single read pass (no execute-side write) and is callable by any sender.
|
||||
|
||||
#### Scenario: Statistics reflect the post-mixed-deposit state
|
||||
- **WHEN** two default-price deposits of value 75 each, and one reduced-price deposit by `alice` of value 10, have been processed; then `GetDepositsStatistics {}` is queried
|
||||
- **THEN** the response has `total_deposits_made = 3`, `total_deposits_made_with_default_price = 2`, `total_deposited_with_default_price.amount = 150`, `total_deposits_made_with_custom_price = 1`, `total_deposited_with_custom_price.amount = 10`, and `deposits_made_with_custom_price["alice"] = 1`
|
||||
|
||||
### Requirement: Blacklist queries succeed and return empty on a freshly deployed contract
|
||||
|
||||
`QueryMsg::GetBlacklistedAccount { public_key }` and `QueryMsg::GetBlacklistPaged { limit, start_after }` SHALL be callable by any sender and SHALL NOT error on a contract that has no blacklist entries.
|
||||
|
||||
- `GetBlacklistedAccount` returns `BlacklistedAccountResponse { account: Option<Blacklisting> }`. On a contract where no entry exists for `public_key`, the response is `BlacklistedAccountResponse { account: None }`.
|
||||
- `GetBlacklistPaged` returns `PagedBlacklistedAccountResponse` with at most `limit.unwrap_or(BLACKLIST_PAGE_DEFAULT_LIMIT).min(BLACKLIST_PAGE_MAX_LIMIT)` entries (`BLACKLIST_PAGE_DEFAULT_LIMIT = 50`, `BLACKLIST_PAGE_MAX_LIMIT = 75`). On a contract with no entries, the response is empty (`accounts: []`, `start_next_after: None`).
|
||||
|
||||
Because the blacklist execute handlers always error (Requirement "Stubbed blacklist execute handlers"), the only way for blacklist storage to contain entries on a live deployment is through the reply path for `BLACKLIST_PROPOSAL_REPLY_ID` — which is itself unreachable from the public ExecuteMsg surface today. Consumers MUST NOT treat an empty blacklist as a security guarantee.
|
||||
|
||||
#### Scenario: Querying an absent blacklist entry returns None
|
||||
- **WHEN** `GetBlacklistedAccount { public_key: K }` is queried on a contract that has never blacklisted `K`
|
||||
- **THEN** the response is `BlacklistedAccountResponse { account: None }`
|
||||
|
||||
#### Scenario: Paginated blacklist on empty contract returns empty
|
||||
- **WHEN** `GetBlacklistPaged { limit: None, start_after: None }` is queried on a freshly deployed contract
|
||||
- **THEN** the response is `PagedBlacklistedAccountResponse { accounts: [], per_page: 50, start_next_after: None }`
|
||||
|
||||
### Requirement: Public storage layout
|
||||
|
||||
The following raw CosmWasm storage keys SHALL be considered part of the public contract surface. Any change to these keys is a breaking change for already-deployed contracts and MUST be accompanied by a coordinated migration:
|
||||
|
||||
- `"contract_admin"` — `cw_controllers::Admin` slot for the contract admin (single Cosmos SDK address).
|
||||
- `"multisig"` — `cw_controllers::Admin` slot for the multisig contract address.
|
||||
- `"config"` — `Item<Config> { group_addr, holding_account, deposit_amount }`.
|
||||
- `"pool_counters"` — `Item<PoolCounters> { total_deposited, total_redeemed, tickets_requested_and_not_redeemed }`.
|
||||
- `"expected_invariants"` — `Item<Invariants> { ticket_book_size }`.
|
||||
- `"deposit_ids"` — `Item<u32>` deposit-id counter (next free id).
|
||||
- `"deposit"` — custom raw-bytes namespace for stored deposits (32-byte ed25519 pubkeys), keyed by big-endian `u32`. **Not** a `cw_storage_plus::Map`.
|
||||
- `"reduced_deposits"` — `Map<Addr, Coin>` per-address reduced-deposit price.
|
||||
- `"blacklist"` — `Map<String, Blacklisting>` reserved for the future blacklist redesign; unreachable through public execute paths today.
|
||||
- `"deposits_with_default_price"` — `Item<u32>` count of default-price deposits.
|
||||
- `"deposits_with_default_price_amounts"` — `Item<Coin>` total amount of default-price deposits.
|
||||
- `"deposits_with_custom_price"` — `Map<Addr, u32>` per-account count of custom-price deposits.
|
||||
- `"deposits_with_custom_price_amounts"` — `Map<Addr, Coin>` per-account total amount of custom-price deposits.
|
||||
|
||||
In addition, the two reply-id constants `BLACKLIST_PROPOSAL_REPLY_ID = 7759` and `REDEMPTION_PROPOSAL_REPLY_ID = 2137` form part of the public surface for any contract upgrade — changing them invalidates in-flight submessage replies.
|
||||
|
||||
#### Scenario: Reading any storage key listed above on a v1 deployment succeeds with the documented type
|
||||
- **WHEN** a CosmWasm raw-state inspection tool reads any of the listed keys on a contract deployed at the spec's anchor version
|
||||
- **THEN** the deserialised value matches the documented type (`Item`, `Map`, raw bytes, or `Admin` slot as listed)
|
||||
|
||||
### Requirement: Public event surface
|
||||
|
||||
The following events SHALL form the public contract surface consumed by chain indexers and downstream tooling. Renaming events or attribute keys MUST be treated as a breaking change:
|
||||
|
||||
- `deposited-funds` event, emitted by `DepositTicketBookFunds` on success. Carries attribute `deposit-id` (decimal string form of the new `u32`). Event type constant: `DEPOSITED_FUNDS_EVENT_TYPE`. Attribute constant: `DEPOSIT_ID`.
|
||||
- `ticket_redemption` event, emitted by `RedeemTickets` on success. Carries attribute `moved_to_holding_account = "false"` (the literal string `"false"`). This event is a remnant of the deprecated `RedeemTickets` flow (see the "Legacy `RedeemTickets`" requirement) and is preserved by the contract today but not consumed by any known live indexer.
|
||||
- The auto-generated `wasm` event emitted by the redemption-proposal reply handler — carries attribute `proposal_id` (decimal string form of the multisig-issued `u64`). Attribute constant: `PROPOSAL_ID_ATTRIBUTE_NAME`. The `wasm` event name itself is the cosmwasm-std auto-event; the attribute is added via `Response::add_attribute`.
|
||||
- The auto-generated `wasm` event emitted by `UpdateDefaultDepositValue` — carries attribute `updated_deposit = Coin::to_string()`.
|
||||
- The auto-generated `wasm` event emitted by `SetReducedDepositPrice` — carries attributes `action = "set_reduced_deposit_price"`, `address`, `deposit = Coin::to_string()`.
|
||||
- The auto-generated `wasm` event emitted by `RemoveReducedDepositPrice` — carries attributes `action = "remove_reduced_deposit_price"`, `address`.
|
||||
- The auto-generated `wasm` events from `UpdateAdmin` and the embedded multisig propose carry standard cw_controllers / cw3 attributes; the ecash contract does not add additional attributes on those paths.
|
||||
|
||||
Handlers that explicitly return `Response::default()` (instantiation, migration) MUST NOT emit any events.
|
||||
|
||||
#### Scenario: Successful default-price deposit emits exactly one `deposited-funds` event with the new id
|
||||
- **WHEN** a default-price `DepositTicketBookFunds` succeeds and assigns id `42`
|
||||
- **THEN** the response carries exactly one `deposited-funds` event with attribute `deposit-id = "42"`
|
||||
|
||||
#### Scenario: Successful `RedeemTickets` carries the legacy `moved_to_holding_account = "false"` attribute
|
||||
- **WHEN** a `RedeemTickets { n: 3, gw: "..." }` succeeds
|
||||
- **THEN** the response carries exactly one `ticket_redemption` event with attribute `moved_to_holding_account = "false"`
|
||||
|
||||
#### Scenario: Instantiation emits no events
|
||||
- **WHEN** the contract is instantiated successfully
|
||||
- **THEN** the response has no events and no attributes
|
||||
|
||||
### Requirement: Public error variants
|
||||
|
||||
The `EcashContractError` enum forms part of the public surface — its variants are observable through transaction failure messages and are encoded in JSON schema artefacts. The following variants MUST be reachable through the current public execute / migration surface and MUST NOT be removed without a coordinated schema bump:
|
||||
|
||||
- `Std`, `Admin` — wrapper variants over `StdError` and `AdminError`.
|
||||
- `InvalidDeposit(PaymentError)` — wrapper variant raised by `cw_utils::must_pay` on `DepositTicketBookFunds` when funds are missing, multi-denom, or in the wrong denom. The inner `PaymentError` variants `NoFunds`, `MultipleDenoms`, `MissingDenom(<denom>)` are all reachable.
|
||||
- `WrongAmount { received, amount }` — `DepositTicketBookFunds` with the right denom but a non-matching amount.
|
||||
- `InvalidGroup { addr }` — instantiation with an unparseable group address.
|
||||
- `MalformedRedemptionCommitment` — `RequestRedemption` with a non-32-byte commitment.
|
||||
- `MalformedEd25519Identity` — `DepositTicketBookFunds` with a non-32-byte bs58 identity payload at save time.
|
||||
- `InvalidReducedDepositDenom { expected, got }` — denom mismatch in `SetReducedDepositPrice` or migration whitelist seeding.
|
||||
- `ReducedDepositNotReduced { reduced, default }` — reduced amount not strictly less than default.
|
||||
- `DepositBelowTicketBookSize { amount, ticket_book_size }` — reduced or default amount below the ticketbook-size floor.
|
||||
- `NoReducedDepositPrice { address }` — `RemoveReducedDepositPrice` against an absent entry.
|
||||
- `TicketBookSizeChanged { at_init, current }` — invariant tripwire mismatch.
|
||||
- `UnimplementedBlacklisting` — `ProposeToBlacklist` or `AddToBlacklist` (always thrown).
|
||||
- `InvalidReplyId { id }` — reply dispatcher with an unknown id.
|
||||
- `MissingProposalId` / `MalformedProposalId` — multisig-reply parsing failures.
|
||||
|
||||
`NotEnoughFunds`, `MaximumDepositTypesReached`, `UnknownCompressedDepositInfoType { typ }`, `UnknownDepositInfoType { typ }`, `Unauthorized`, and `SemVerFailure { value, error_message }` are present in the enum but are **not reachable** through any current public execute path. They are preserved for forward compatibility (notably for the eventual blacklist redesign and deposit-info-typing feature work) and SHOULD NOT be relied upon by downstream tooling.
|
||||
|
||||
#### Scenario: Each reachable variant has at least one triggering scenario in this spec
|
||||
- **WHEN** a reviewer cross-references the reachable variants listed above against the requirements in this spec
|
||||
- **THEN** every reachable variant appears as the documented error of at least one scenario
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
# node-families-contract Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change node-families-contract-spec. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Contract instantiation persists the runtime config, mixnet contract address, and admin
|
||||
|
||||
The contract SHALL be instantiable exactly once via the standard CosmWasm `instantiate` entry point. The instantiation message SHALL carry a `Config` (creation fee, family-name length limit, family-description length limit, default invitation validity in seconds) and a string-form `mixnet_contract_address`. The handler MUST:
|
||||
|
||||
- bech32-validate `mixnet_contract_address` and reject malformed inputs;
|
||||
- persist the validated mixnet contract address, the `Config`, and `info.sender` as the contract admin;
|
||||
- record the contract name and `CARGO_PKG_VERSION` via `cw2::set_contract_version`;
|
||||
- record build information via `set_build_information!`.
|
||||
|
||||
#### Scenario: Valid instantiation persists config, mixnet address, and admin
|
||||
- **WHEN** `instantiate` is called with a valid bech32 mixnet contract address and a well-formed `Config`
|
||||
- **THEN** the contract stores the `Config` verbatim, the validated `Addr` for the mixnet contract, and sets `info.sender` as the contract admin queryable via `cw-controllers::Admin::assert_admin`
|
||||
- **AND** `cw2::get_contract_version` returns the crate's `CARGO_PKG_VERSION`
|
||||
|
||||
#### Scenario: Invalid mixnet contract address is rejected
|
||||
- **WHEN** `instantiate` is called with a `mixnet_contract_address` string that fails `Addr::validate`
|
||||
- **THEN** the call returns an error and no state is persisted
|
||||
|
||||
### Requirement: Migration entry point forbids version downgrades
|
||||
|
||||
The contract SHALL expose a `migrate` entry point that refreshes recorded build information and uses `cw2::ensure_from_older_version` to guarantee the on-chain contract version is at most the current `CARGO_PKG_VERSION`. Downgrade attempts MUST be rejected.
|
||||
|
||||
#### Scenario: Equal or older on-chain version is accepted
|
||||
- **WHEN** `migrate` is called against an on-chain `cw2` version less than or equal to `CARGO_PKG_VERSION`
|
||||
- **THEN** the call succeeds and `set_build_information!` updates the stored build metadata
|
||||
|
||||
#### Scenario: Newer on-chain version is rejected
|
||||
- **WHEN** `migrate` is called against an on-chain `cw2` version strictly greater than `CARGO_PKG_VERSION`
|
||||
- **THEN** the call returns an error and storage is unchanged
|
||||
|
||||
### Requirement: Only the contract admin can replace the runtime config
|
||||
|
||||
The `ExecuteMsg::UpdateConfig` handler SHALL overwrite the stored `Config` with the value supplied by the caller. The handler MUST call `Admin::assert_admin` and return the underlying `AdminError` (wrapped in `NodeFamiliesContractError::Admin`) when the sender is not the admin.
|
||||
|
||||
#### Scenario: Admin replaces the config
|
||||
- **WHEN** the contract admin sends `ExecuteMsg::UpdateConfig { config }`
|
||||
- **THEN** the stored `Config` equals the supplied value on subsequent reads
|
||||
|
||||
#### Scenario: Non-admin is rejected
|
||||
- **WHEN** a non-admin sender sends `ExecuteMsg::UpdateConfig`
|
||||
- **THEN** the call returns an `Admin` error and the stored `Config` is unchanged
|
||||
|
||||
### Requirement: Family names are normalised and globally unique under their normalised form
|
||||
|
||||
Family names SHALL be normalised to a canonical form by lowercasing ASCII letters, preserving ASCII digits, and dropping every other character (whitespace, punctuation, non-ASCII letters). The normalised form SHALL be stored alongside the user-supplied `name` and SHALL be the key of the `families.normalised_name` unique index. Inputs whose normalised form is the empty string SHALL be rejected with `EmptyFamilyName`. Inputs whose original length exceeds `Config::family_name_length_limit` (measured in **bytes**, via `String::len`) SHALL be rejected with `FamilyNameTooLong { length, limit }`. Creation of a family whose normalised name collides with an existing family SHALL fail with `FamilyNameAlreadyTaken { name, family_id }`.
|
||||
|
||||
**Important**: this normalisation is ASCII-only by design. Non-ASCII letters and emoji are stripped entirely (`"café"` → `"caf"`, `"名前"` → `""`, `"⭐stars"` → `"stars"`). Names consisting solely of non-ASCII characters or symbols are rejected as `EmptyFamilyName`. Operators who want a non-ASCII brand cannot encode it in the on-chain family name; the user-supplied `name` field preserves the original formatting but only the normalised ASCII form is enforced for uniqueness and emptiness.
|
||||
|
||||
#### Scenario: Normalisation strips punctuation, whitespace, casing, and non-ASCII letters
|
||||
- **WHEN** the contract normalises any of `"Foo Bar"`, `"foobar"`, `"FOO-BAR"`, or `" f.o.o.b.a.r "`
|
||||
- **THEN** every result equals `"foobar"`
|
||||
|
||||
#### Scenario: All-symbol name is rejected
|
||||
- **WHEN** `CreateFamily` is sent with `name = "!!!---"` (normalises to the empty string)
|
||||
- **THEN** the call fails with `EmptyFamilyName` and no family is persisted
|
||||
|
||||
#### Scenario: Name length is checked against the original string, not the normalised form
|
||||
- **WHEN** `CreateFamily` is sent with a `name` whose `name.len()` (byte length) exceeds `Config::family_name_length_limit`
|
||||
- **THEN** the call fails with `FamilyNameTooLong { length, limit }`
|
||||
|
||||
#### Scenario: Multi-byte characters count their full byte length toward the limit
|
||||
- **WHEN** `Config::family_name_length_limit = 8` and `CreateFamily` is sent with `name = "🚀rocket"` (a 4-byte emoji followed by `"rocket"`, totalling `name.len() == 10` bytes — but only 7 user-perceived characters)
|
||||
- **THEN** the call fails with `FamilyNameTooLong { length: 10, limit: 8 }` even though the visible character count is within the limit
|
||||
- **AND** had the limit been `>= 10`, the name would have been accepted and the normalised form would be `"rocket"` (the emoji dropped during normalisation)
|
||||
|
||||
#### Scenario: Two formattings of the same canonical name collide
|
||||
- **WHEN** family `A` is created with `name = "Shared"` and `B` later tries to create with `name = "$$shared$$"`
|
||||
- **THEN** the second call fails with `FamilyNameAlreadyTaken { name: "shared", family_id: A.id }`
|
||||
|
||||
### Requirement: An address may own at most one family at a time
|
||||
|
||||
A given owner address SHALL own at most one family at any time, enforced by the `families.owner` unique index. `CreateFamily` SHALL pre-check this and fail with `SenderAlreadyOwnsAFamily { address, family_id }` for better error context. The pre-check is in addition to (not instead of) the unique-index defence-in-depth check. Owner-gated handlers (`DisbandFamily`, `InviteToFamily`, `RevokeFamilyInvitation`, `KickFromFamily`) SHALL look up the family by owner and fail with `SenderDoesntOwnAFamily { address }` when none exists.
|
||||
|
||||
#### Scenario: Same address cannot create a second family while still owning one
|
||||
- **WHEN** address `alice` already owns family `A` and `CreateFamily` is sent again with `alice` as sender
|
||||
- **THEN** the call fails with `SenderAlreadyOwnsAFamily { address: alice, family_id: A.id }`
|
||||
|
||||
#### Scenario: Address can create a family again after disbanding its previous one
|
||||
- **WHEN** `alice` creates family `A` then disbands it, then sends `CreateFamily` again
|
||||
- **THEN** a new family with a strictly greater id than `A` is created (ids are monotonic and never recycled)
|
||||
|
||||
#### Scenario: Owner-gated handler rejects a sender that owns no family
|
||||
- **WHEN** any of `DisbandFamily`, `InviteToFamily`, `RevokeFamilyInvitation`, or `KickFromFamily` is sent by an address that owns no family
|
||||
- **THEN** the call fails with `SenderDoesntOwnAFamily { address }`
|
||||
|
||||
### Requirement: Family creation requires the configured fee and is rejected if the owner's bonded node is already in a family
|
||||
|
||||
`ExecuteMsg::CreateFamily` SHALL require the sender to attach exactly one coin matching `Config::create_family_fee` in both denom and amount. Payment validation MUST go through `cw_utils::must_pay`; mismatches in amount MUST surface as `InvalidFamilyCreationFee { expected, received }`. The handler MUST additionally check that any bonded node the sender controls (as reported by the mixnet contract via `query_nymnode_ownership`) is not currently a member of any family, failing with `AlreadyInFamily { address, node_id, family_id }` otherwise. The handler MUST validate the description length against `Config::family_description_length_limit` and reject overlong descriptions with `FamilyDescriptionTooLong { length, limit }`. On success the handler SHALL emit a `family_creation` event with attributes `family_name`, `owner_address`, `family_id`, `paid_fee`.
|
||||
|
||||
#### Scenario: Successful family creation persists the family and emits the event
|
||||
- **WHEN** a sender with no bonded family-member node and no existing-owned family sends `CreateFamily { name, description }` with the correct fee attached
|
||||
- **THEN** a new family is persisted with monotonically increasing `id`, the supplied `name` and `description`, the computed `normalised_name`, `members = 0`, `created_at = env.block.time.seconds()`, and `paid_fee` equal to the configured fee
|
||||
- **AND** the response carries an event named `family_creation` with attributes `family_name`, `owner_address`, `family_id`, `paid_fee`
|
||||
|
||||
#### Scenario: Wrong fee denom or missing funds is rejected
|
||||
- **WHEN** `CreateFamily` is sent with no funds, with multiple denoms, or with a denom different from `Config::create_family_fee.denom`
|
||||
- **THEN** the call fails with `InvalidDeposit(PaymentError)`
|
||||
|
||||
#### Scenario: Wrong fee amount is rejected
|
||||
- **WHEN** `CreateFamily` is sent with the correct denom but an amount different from `Config::create_family_fee.amount`
|
||||
- **THEN** the call fails with `InvalidFamilyCreationFee { expected, received }` and the funds remain unspent
|
||||
|
||||
#### Scenario: Sender whose bonded node is already in a family is rejected
|
||||
- **WHEN** `CreateFamily` is sent by an address whose bonded node (per the mixnet contract) is already a member of some family `F`
|
||||
- **THEN** the call fails with `AlreadyInFamily { address, node_id, family_id: F.id }`
|
||||
|
||||
#### Scenario: Overlong description is rejected
|
||||
- **WHEN** `CreateFamily` is sent with a `description` whose byte length exceeds `Config::family_description_length_limit`
|
||||
- **THEN** the call fails with `FamilyDescriptionTooLong { length, limit }`
|
||||
|
||||
### Requirement: Family ids are monotonic, never recycled, and start at 1
|
||||
|
||||
The contract SHALL assign family ids sequentially starting at `1`. The id counter SHALL be persisted as an `Item<NodeFamilyId>` and incremented on each successful family creation. Disbanding a family MUST NOT free or recycle its id. `0` SHALL be reserved as a "no family" sentinel and never assigned.
|
||||
|
||||
#### Scenario: First-ever family receives id 1
|
||||
- **WHEN** the contract is freshly instantiated and the first successful `CreateFamily` runs
|
||||
- **THEN** the persisted `NodeFamily.id` equals `1`
|
||||
|
||||
#### Scenario: Ids are not reused after disband
|
||||
- **WHEN** family with id `N` is disbanded and a new family is then created
|
||||
- **THEN** the new family's id is strictly greater than `N` (it is `N+1` if no other families were created in between)
|
||||
|
||||
### Requirement: A family can only be disbanded by its owner, must be empty, and refunds the creation fee
|
||||
|
||||
`ExecuteMsg::DisbandFamily` SHALL look up the sender's family via the `owner` unique index and fail with `SenderDoesntOwnAFamily` when none exists. The handler MUST reject disbanding while `NodeFamily.members > 0`, failing with `FamilyNotEmpty { family_id, members }`. On success the handler SHALL sweep every still-pending invitation issued by the family (archiving each as `FamilyInvitationStatus::Revoked { at: now }` with status timestamp = `env.block.time.seconds()`), remove the family record, and attach a `BankMsg::Send { to_address: owner, amount: vec![family.paid_fee] }` to the response as a CosmWasm sub-message. The response SHALL include a `family_disband` event with `family_id`, `owner_address`, `refunded_fee` attributes.
|
||||
|
||||
**Operational note (non-normative)**: the pending-invitation sweep iterates every entry of `pending_family_invitations` keyed by `family_id` and archives each in a single transaction. Gas cost therefore scales linearly with the number of leftover pending invitations. An owner whose family has accumulated an unusually large number of pending invitations is expected to revoke them in batches (via `RevokeFamilyInvitation`) *before* invoking `DisbandFamily`, since a single disband call that exceeds the per-tx gas limit will fail and leave the family in place. There is no contract-side chunking; the responsibility lies with the caller.
|
||||
|
||||
#### Scenario: Owner disbands an empty family and is refunded
|
||||
- **WHEN** family owner sends `DisbandFamily` while `members == 0`
|
||||
- **THEN** the family is removed from storage, the response carries a `BankMsg::Send` of `family.paid_fee` to the owner, and a `family_disband` event is emitted
|
||||
|
||||
#### Scenario: Refund is attached as a BankMsg::Send sub-message, not a direct bank-module call
|
||||
- **WHEN** a successful `DisbandFamily` returns its `Response`
|
||||
- **THEN** the response's `messages` field contains exactly one `CosmosMsg::Bank(BankMsg::Send { to_address: <owner bech32>, amount: vec![<family.paid_fee>] })` and the contract performs no other balance-changing side effect for the refund
|
||||
- **AND** tx simulators that inspect outbound sub-messages observe the refund there (this is the contract's only avenue for returning funds; integrators rely on it)
|
||||
|
||||
#### Scenario: Non-empty family cannot be disbanded
|
||||
- **WHEN** family owner sends `DisbandFamily` while `members > 0`
|
||||
- **THEN** the call fails with `FamilyNotEmpty { family_id, members }` and storage is unchanged
|
||||
|
||||
#### Scenario: Disbanding sweeps still-pending invitations as Revoked
|
||||
- **WHEN** family `F` has pending invitations to nodes `n1`, `n2` and the owner sends `DisbandFamily`
|
||||
- **THEN** the pending entries for `(F.id, n1)` and `(F.id, n2)` are removed from `pending_family_invitations` and archived under `past_family_invitations` with `status = Revoked { at: env.block.time.seconds() }`
|
||||
|
||||
### Requirement: A node belongs to at most one family at any time
|
||||
|
||||
The `family_members` map SHALL be keyed by `NodeId` alone; the value SHALL carry the `family_id` so the storage layer enforces the one-family-per-node invariant by construction. Handlers that add a membership (`AcceptFamilyInvitation`) and handlers that issue an invitation (`InviteToFamily`) SHALL pre-check the absence of any existing membership for the node and fail with `NodeAlreadyInFamily { node_id, family_id }` otherwise.
|
||||
|
||||
#### Scenario: Inviting a node already in a family is rejected
|
||||
- **WHEN** `InviteToFamily { node_id }` targets a node that already has a membership record for family `F`
|
||||
- **THEN** the call fails with `NodeAlreadyInFamily { node_id, family_id: F.id }`
|
||||
|
||||
#### Scenario: Accepting a second invitation after joining a family is rejected
|
||||
- **WHEN** node `n` is a member of family `F` and `AcceptFamilyInvitation { family_id: G, node_id: n }` is sent (for a different family `G`)
|
||||
- **THEN** the call fails with `NodeAlreadyInFamily { node_id: n, family_id: F.id }`
|
||||
|
||||
### Requirement: Invitations require an existing family, a bonded target node, and strictly positive validity
|
||||
|
||||
`ExecuteMsg::InviteToFamily` SHALL be owner-gated (the family acted on is the sender's owned family, never an argument). The handler MUST:
|
||||
|
||||
- compute `validity = validity_secs.unwrap_or(Config::default_invitation_validity_secs)`;
|
||||
- reject `validity == 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");
|
||||
- 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`.
|
||||
|
||||
#### Scenario: Successful invitation persists with the computed expiry
|
||||
- **WHEN** family owner sends `InviteToFamily { node_id, validity_secs: Some(v) }` and all preconditions hold
|
||||
- **THEN** a pending invitation for `(owned.id, node_id)` is persisted with `expires_at = env.block.time.seconds() + v`
|
||||
- **AND** the response carries a `family_invitation` event with `family_id = owned.id`, `node_id`, `expires_at`
|
||||
|
||||
#### Scenario: Missing validity falls back to the configured default
|
||||
- **WHEN** `InviteToFamily { node_id, validity_secs: None }` is sent
|
||||
- **THEN** the persisted invitation has `expires_at = env.block.time.seconds() + Config::default_invitation_validity_secs`
|
||||
|
||||
#### Scenario: Zero validity is rejected
|
||||
- **WHEN** `InviteToFamily { node_id, validity_secs: Some(0) }` is sent
|
||||
- **THEN** the call fails with `ZeroInvitationValidity` and no invitation is persisted
|
||||
|
||||
#### Scenario: Inviting a non-bonded node is rejected
|
||||
- **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)
|
||||
|
||||
### Requirement: Acceptance and rejection of an invitation are gated on node control
|
||||
|
||||
`ExecuteMsg::AcceptFamilyInvitation` and `ExecuteMsg::RejectFamilyInvitation` SHALL each verify that the sender is the controller of the bonded node `node_id` per the mixnet contract (`query_nymnode_ownership` returns a `nym_node` with `node_id == node_id` and `is_unbonding == false`). Failures MUST surface as `SenderDoesntControlNode { address, node_id }`. This single error covers the cases of: sender owns no bonded node, sender owns a different node id, and sender owns the node but it has entered unbonding.
|
||||
|
||||
#### Scenario: Non-controller cannot accept an invitation
|
||||
- **WHEN** `AcceptFamilyInvitation { family_id, node_id }` is sent by an address that does not control `node_id` (per mixnet contract)
|
||||
- **THEN** the call fails with `SenderDoesntControlNode { address, node_id }`
|
||||
|
||||
#### Scenario: Unbonding node cannot accept an invitation
|
||||
- **WHEN** the sender controls `node_id` but the mixnet contract reports the node as unbonding
|
||||
- **THEN** `AcceptFamilyInvitation` fails with `SenderDoesntControlNode { address, node_id }`
|
||||
|
||||
#### Scenario: Non-controller cannot reject an invitation
|
||||
- **WHEN** `RejectFamilyInvitation { family_id, node_id }` is sent by an address that does not control `node_id`
|
||||
- **THEN** the call fails with `SenderDoesntControlNode { address, node_id }`
|
||||
|
||||
### Requirement: Accepting an invitation moves it from pending to archived and increments the family member count
|
||||
|
||||
A successful `AcceptFamilyInvitation` SHALL:
|
||||
|
||||
- load the pending invitation for `(family_id, node_id)`, failing with `InvitationNotFound { family_id, node_id }` if absent;
|
||||
- check `now < invitation.expires_at`, failing with `InvitationExpired { family_id, node_id, expires_at, now }` otherwise (`now == expires_at` is considered expired);
|
||||
- remove the entry from `pending_family_invitations`;
|
||||
- write `family_members[node_id] = FamilyMembership { family_id, joined_at: now }`;
|
||||
- increment `NodeFamily::members` by 1 (failing with `FamilyNotFound { family_id }` if the family has somehow been removed);
|
||||
- archive a `PastFamilyInvitation { invitation, status: Accepted { at: now } }` under `((family_id, node_id), counter)` where `counter` is the next free per-`(family, node)` archive slot;
|
||||
- emit a `family_invitation_accepted` event with `family_id`, `node_id`.
|
||||
|
||||
#### Scenario: Happy-path acceptance
|
||||
- **WHEN** node controller accepts a not-yet-expired pending invitation
|
||||
- **THEN** the membership is recorded with `joined_at = env.block.time.seconds()`, the family's `members` count is incremented, the pending entry is removed, and the archive contains the invitation with status `Accepted { at: now }`
|
||||
|
||||
#### Scenario: Acceptance of an already-expired invitation is rejected
|
||||
- **WHEN** `AcceptFamilyInvitation` is called with `env.block.time.seconds() >= invitation.expires_at`
|
||||
- **THEN** the call fails with `InvitationExpired { family_id, node_id, expires_at, now }` and storage is unchanged
|
||||
|
||||
#### Scenario: Acceptance with no pending invitation is rejected
|
||||
- **WHEN** `AcceptFamilyInvitation { family_id, node_id }` is called with no pending invitation stored for that pair
|
||||
- **THEN** the call fails with `InvitationNotFound { family_id, node_id }`
|
||||
|
||||
### Requirement: Rejection and revocation work on expired invitations and archive them with terminal status
|
||||
|
||||
`RejectFamilyInvitation` (sent by the node controller) and `RevokeFamilyInvitation` (sent by the family owner) SHALL each:
|
||||
|
||||
- remove the pending invitation;
|
||||
- archive a `PastFamilyInvitation` with status `Rejected { at: now }` or `Revoked { at: now }` respectively, under `((family_id, node_id), counter)` using the next free per-`(family, node)` archive slot;
|
||||
- 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.
|
||||
|
||||
#### Scenario: Owner revokes a still-pending invitation
|
||||
- **WHEN** family owner sends `RevokeFamilyInvitation { node_id }` for a node currently in their pending invitations
|
||||
- **THEN** the pending entry is removed and the archive contains the invitation with status `Revoked { at: env.block.time.seconds() }`
|
||||
|
||||
#### Scenario: Node controller rejects a still-pending invitation
|
||||
- **WHEN** node controller sends `RejectFamilyInvitation { family_id, node_id }` for a pending invitation
|
||||
- **THEN** the pending entry is removed and the archive contains the invitation with status `Rejected { at: env.block.time.seconds() }`
|
||||
|
||||
#### Scenario: Expired invitations can still be rejected or revoked
|
||||
- **WHEN** an invitation's `expires_at` is at or before `env.block.time.seconds()` and either `RejectFamilyInvitation` or `RevokeFamilyInvitation` is sent for it
|
||||
- **THEN** the call succeeds, the pending entry is removed, and the archive records the appropriate terminal status
|
||||
|
||||
### Requirement: Leave and kick remove the membership and archive a past-member record
|
||||
|
||||
`ExecuteMsg::LeaveFamily { node_id }` SHALL require the sender to be the controller of `node_id` (failing with `SenderDoesntControlNode` otherwise). `ExecuteMsg::KickFromFamily { node_id }` SHALL require the sender to be the current owner of a family, and the node MUST be a member of *that* family — kicking a node belonging to a different family fails with `NodeNotMemberOfFamily { node_id, family_id }`; kicking a node that has no membership at all fails with `NodeNotInFamily { node_id }`. Both handlers SHALL share the same storage operation: remove `family_members[node_id]`, decrement `NodeFamily::members`, and archive a `PastFamilyMember { family_id, node_id, removed_at: env.block.time.seconds() }` under `((family_id, node_id), counter)` using the next free per-`(family, node)` slot. The respective events are `family_member_left` (carrying the leaver's `family_id` and `node_id`) and `family_member_kicked` (carrying the owner's `family_id` and the kicked `node_id`).
|
||||
|
||||
#### Scenario: Node controller leaves the family
|
||||
- **WHEN** node controller sends `LeaveFamily { node_id }` for a node currently in family `F`
|
||||
- **THEN** `family_members[node_id]` is removed, `F.members` is decremented, the archive contains a `PastFamilyMember` with `removed_at = env.block.time.seconds()`, and the response carries a `family_member_left` event
|
||||
|
||||
#### Scenario: Owner kicks a member of their family
|
||||
- **WHEN** family owner sends `KickFromFamily { node_id }` for a member of their family
|
||||
- **THEN** the same storage transition as a leave occurs and the response carries a `family_member_kicked` event
|
||||
|
||||
#### Scenario: Owner cannot kick a node belonging to another family
|
||||
- **WHEN** family owner sends `KickFromFamily { node_id }` for a node whose membership is in a different family `G`
|
||||
- **THEN** the call fails with `NodeNotMemberOfFamily { node_id, family_id: owned.id }` and storage is unchanged
|
||||
|
||||
#### Scenario: Repeated join/leave of the same family uses sequential archive counters
|
||||
- **WHEN** node `n` joins, leaves, joins again, and leaves again the same family `F`
|
||||
- **THEN** the archive contains `past_family_members[((F.id, n), 0)]` and `past_family_members[((F.id, n), 1)]` with the respective `removed_at` timestamps
|
||||
|
||||
### Requirement: Only the configured mixnet contract may invoke the unbonding callback
|
||||
|
||||
`ExecuteMsg::OnNymNodeUnbond { node_id }` SHALL be authorised solely by checking `info.sender == stored mixnet_contract_address`, failing with `UnauthorisedMixnetCallback { sender }` otherwise. There is no node-side or admin override.
|
||||
|
||||
#### Scenario: Mixnet contract triggers the callback
|
||||
- **WHEN** the mixnet contract sends `OnNymNodeUnbond { node_id }`
|
||||
- **THEN** the call proceeds and the cleanup (see next requirement) is applied
|
||||
|
||||
#### Scenario: Arbitrary sender is rejected
|
||||
- **WHEN** any address other than the stored mixnet contract sends `OnNymNodeUnbond`
|
||||
- **THEN** the call fails with `UnauthorisedMixnetCallback { sender }`
|
||||
|
||||
### Requirement: The unbonding callback is idempotent over membership and sweeps every pending invitation addressed to the node
|
||||
|
||||
A successful `OnNymNodeUnbond { node_id }` SHALL:
|
||||
|
||||
- if `family_members[node_id]` exists, remove the membership and archive a `PastFamilyMember` exactly as the `leave` / `kick` path does (decrementing the family's `members` count);
|
||||
- if no such membership exists, leave membership state untouched (this is the common case — most unbonding nodes were never in a family);
|
||||
- iterate every entry of `pending_family_invitations` keyed by `node_id` (via the `node` multi-index), remove each from the pending map, and archive each as `PastFamilyInvitation { invitation, status: Rejected { at: env.block.time.seconds() } }` using the next free per-`(family, node)` counter;
|
||||
- emit a `family_node_unbond_cleanup` event with `node_id` attribute.
|
||||
|
||||
The auto-cleared invitations share the `Rejected` terminal state with invitations the node controller would have explicitly declined — `Revoked` is reserved for owner-side actions.
|
||||
|
||||
**Operational note (non-normative)**: like the disband sweep, the per-node invitation sweep iterates every pending invitation addressed to `node_id` and archives each in a single transaction. Gas cost therefore scales linearly with the number of outstanding invitations the unbonding node holds. If a node has accumulated an unusually large number of pending invitations, the operator-initiated unbond transaction on the mixnet contract may fail because *this* callback exceeds the per-tx gas limit. The fix is operator-side: explicitly `RejectFamilyInvitation` the outstanding invitations (in batches if needed) before retrying the unbond. There is no contract-side chunking, and the mixnet contract has no path to bypass or partial-apply the callback.
|
||||
|
||||
#### Scenario: Unbonding node with no family and no pending invitations is a no-op
|
||||
- **WHEN** `OnNymNodeUnbond { node_id }` is invoked for a node with no membership and no pending invitations
|
||||
- **THEN** the call succeeds, no storage is changed (apart from emitting the cleanup event), and no error is returned
|
||||
|
||||
#### Scenario: Unbonding node in a family loses its membership
|
||||
- **WHEN** `OnNymNodeUnbond { node_id }` is invoked for a node currently in family `F`
|
||||
- **THEN** `family_members[node_id]` is removed, `F.members` is decremented, and a `PastFamilyMember` is archived with `removed_at = env.block.time.seconds()`
|
||||
|
||||
#### Scenario: Unbonding node's pending invitations are archived as Rejected
|
||||
- **WHEN** node `n` has pending invitations from families `F1` and `F2` and `OnNymNodeUnbond { node_id: n }` is invoked
|
||||
- **THEN** both pending entries are removed and `past_family_invitations[((F1.id, n), …)]` and `past_family_invitations[((F2.id, n), …)]` each carry `status = Rejected { at: env.block.time.seconds() }`
|
||||
|
||||
### Requirement: Expired pending invitations remain in storage until explicitly cleared
|
||||
|
||||
The contract SHALL NOT run any background sweep of expired pending invitations. A `FamilyInvitation` whose `expires_at <= env.block.time.seconds()` SHALL remain in `pending_family_invitations` until either the family owner revokes it, the node controller rejects it, 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
|
||||
- **THEN** the result includes the entry with `expired = true`
|
||||
|
||||
#### Scenario: Expired invitation can be revoked or rejected but not accepted
|
||||
- **WHEN** an expired invitation exists for `(F.id, n)`
|
||||
- **THEN** `AcceptFamilyInvitation` fails with `InvitationExpired`, but `RevokeFamilyInvitation` (by `F`'s owner) and `RejectFamilyInvitation` (by `n`'s controller) both succeed and archive the invitation with the corresponding terminal status
|
||||
|
||||
### Requirement: Per-`(family, node)` archive slots use sequential counters starting at 0
|
||||
|
||||
Both `past_family_invitations` and `past_family_members` SHALL be keyed by `((family_id, node_id), counter: u64)`. The contract SHALL maintain explicit per-`(family, node)` counter maps (`past_family_invitation_counter`, `past_family_member_counter`), starting at `0` for each new pair and incrementing by `1` on each archival write. Counters MUST be independent across distinct `(family, node)` pairs and across the two archive types.
|
||||
|
||||
#### Scenario: First archive slot for a new pair is 0
|
||||
- **WHEN** a node and family appear in either archive for the first time
|
||||
- **THEN** the entry is keyed with `counter = 0`
|
||||
|
||||
#### Scenario: Distinct pairs have independent counters
|
||||
- **WHEN** archive entries are written under keys `(F1, n)` and `(F2, n)` in any order
|
||||
- **THEN** each pair's counter starts independently at `0`
|
||||
|
||||
### Requirement: GetFamilyMembership returns the family id a node belongs to, or None
|
||||
|
||||
`QueryMsg::GetFamilyMembership { node_id }` SHALL return `NodeFamilyMembershipResponse { node_id, family_id }` where `family_id` is `Some(NodeFamilyId)` iff `family_members[node_id]` is populated, and `None` otherwise. The query MUST NOT fail when `node_id` is unknown — it returns `family_id: None`. The query is the canonical way for route-selection consumers to check whether two node ids belong to the same family without scanning members.
|
||||
|
||||
#### Scenario: Member node returns its family id
|
||||
- **WHEN** node `n` is currently a member of family `F` and `GetFamilyMembership { node_id: n }` is queried
|
||||
- **THEN** the response carries `family_id = Some(F.id)`
|
||||
|
||||
#### Scenario: Non-member node returns None
|
||||
- **WHEN** `GetFamilyMembership { node_id }` is queried for a node that has no membership record (never joined, or has since left/been kicked/unbonded)
|
||||
- **THEN** the response carries `family_id = None` and `node_id` echoed back
|
||||
|
||||
### Requirement: Single-family-by-id, by-name, and by-owner queries return an `Option<NodeFamily>`
|
||||
|
||||
The contract SHALL expose three single-family lookups via `QueryMsg::GetFamilyById`, `GetFamilyByName`, and `GetFamilyByOwner`, each returning a response that echoes the queried key back to the caller for correlation and a `family: Option<NodeFamily>` field that is `None` when no match exists. `GetFamilyByName` MUST normalise the input via the same `normalise_family_name` function the create path uses before consulting the unique index. `GetFamilyByOwner` MUST bech32-validate the input.
|
||||
|
||||
#### Scenario: GetFamilyByName is invariant under input formatting
|
||||
- **WHEN** family `F` exists with `name = "MyFamily"` and `GetFamilyByName { name: "my family" }` is queried
|
||||
- **THEN** the response carries `family = Some(F)` (the lookup hits the unique normalised-name index)
|
||||
|
||||
#### Scenario: Missing match returns None
|
||||
- **WHEN** any of the three single-family queries is called with a key that matches no family
|
||||
- **THEN** the response carries `family = None` and echoes the queried key
|
||||
|
||||
#### Scenario: GetFamilyByOwner rejects an invalid bech32 address
|
||||
- **WHEN** `GetFamilyByOwner { owner }` is called with a non-bech32 string
|
||||
- **THEN** the call returns an error sourced from `Addr::validate`
|
||||
|
||||
### Requirement: Paginated queries use exclusive `start_after`, default limit 50, max limit 100
|
||||
|
||||
Every paginated query (`GetFamiliesPaged`, `GetFamilyMembersPaged`, `GetAllFamilyMembersPaged`, `GetPendingInvitationsForFamilyPaged`, `GetPendingInvitationsForNodePaged`, `GetAllPendingInvitationsPaged`, `GetPastInvitationsForFamilyPaged`, `GetPastInvitationsForNodePaged`, `GetAllPastInvitationsPaged`, `GetPastMembersForFamilyPaged`, `GetPastMembersForNodePaged`) SHALL accept `start_after: Option<Cursor>` (exclusive) and `limit: Option<u32>`. The default `limit` SHALL be `50` and SHALL be clamped to `100` as a hard cap. Results SHALL be in ascending cursor order. Each response SHALL include a `start_next_after: Option<Cursor>` derived from the last entry of the page, with `None` indicating the page is empty (the caller treats this as end-of-list).
|
||||
|
||||
Per-family / per-node paginated queries SHALL NOT verify that the supplied `family_id` or `node_id` corresponds to an existing entity — an unknown id simply yields an empty page.
|
||||
|
||||
#### Scenario: Limit is defaulted and clamped
|
||||
- **WHEN** any paginated query is called with `limit = None`
|
||||
- **THEN** at most 50 entries are returned
|
||||
- **AND** when called with `limit = Some(10_000)` at most 100 entries are returned
|
||||
|
||||
#### Scenario: Empty page signals end-of-list
|
||||
- **WHEN** a paginated query has no more entries past the supplied `start_after`
|
||||
- **THEN** the response carries an empty entry list and `start_next_after = None`
|
||||
|
||||
#### Scenario: Unknown scope id yields an empty page rather than an error
|
||||
- **WHEN** `GetFamilyMembersPaged { family_id: 999_999, .. }` is queried for a non-existent family id
|
||||
- **THEN** the response carries an empty `members` list and `start_next_after = None` (no error is returned)
|
||||
|
||||
### Requirement: Pending-invitation queries stamp each entry with its expiry flag
|
||||
|
||||
Queries returning pending invitations (`GetPendingInvitation`, `GetPendingInvitationsForFamilyPaged`, `GetPendingInvitationsForNodePaged`, `GetAllPendingInvitationsPaged`) SHALL wrap each invitation in `PendingFamilyInvitationDetails { invitation, expired }` where `expired = env.block.time.seconds() >= invitation.expires_at`. Past-invitation and past-member queries do not carry this flag (they are terminal-state archives).
|
||||
|
||||
#### Scenario: Future-dated invitation is marked not expired
|
||||
- **WHEN** a pending invitation has `expires_at = env.block.time.seconds() + 60` and any pending-invitation query returns it
|
||||
- **THEN** the returned `PendingFamilyInvitationDetails.expired` is `false`
|
||||
|
||||
#### Scenario: Past-dated invitation is marked expired
|
||||
- **WHEN** a pending invitation has `expires_at = env.block.time.seconds() - 1`
|
||||
- **THEN** any pending-invitation query that returns it sets `expired = true`
|
||||
|
||||
### Requirement: Storage-key constants are part of the public contract
|
||||
|
||||
Every constant exported under `nym_node_families_contract_common::constants::storage_keys` SHALL be treated as part of the public contract surface. Off-chain indexers and migration tooling may depend on these exact byte strings. Changing the value of any existing constant — including renaming a namespace — SHALL be considered a breaking change for already-deployed contracts and SHALL be accompanied by a data migration via `queued_migrations`. Adding new constants for new storage maps is non-breaking. The current set covers the contract-level items (admin, config, mixnet contract address, family id counter), the primary maps (`families`, `node-family-members`, `invitations`, `past-invitations`, `past-family-member`), every secondary index (`__owner`, `__name`, `__family`, `__node`), and the per-`(family, node)` archive counters — refer to `constants.rs` for the authoritative list.
|
||||
|
||||
#### Scenario: Storage key constants are reachable from the common crate
|
||||
- **WHEN** an off-chain consumer imports `nym_node_families_contract_common::constants::storage_keys`
|
||||
- **THEN** it observes the full set of storage-key constants the contract uses, without taking a dependency on the contract crate itself
|
||||
|
||||
### Requirement: Emitted events form a stable public surface
|
||||
|
||||
Every event name and attribute key exported under `nym_node_families_contract_common::constants::events` SHALL be treated as part of the public contract surface. Each successful **state-mutating user-facing execute path** (every variant of `ExecuteMsg` except `UpdateConfig`, which is an admin handler that returns `Response::default()` without an event) SHALL emit exactly one event whose name and attribute keys come from these constants. Renaming an event name constant, renaming an attribute-key constant, or changing the set of attributes a given event carries SHALL be treated as breaking changes. Adding new constants for new events / attributes is non-breaking.
|
||||
|
||||
At the time of this spec the constant surface comprises (refer to `constants.rs` for the authoritative list):
|
||||
|
||||
- `family_creation` — attributes: `family_name`, `owner_address`, `family_id`, `paid_fee`
|
||||
- `family_disband` — attributes: `family_id`, `owner_address`, `refunded_fee`
|
||||
- `family_invitation` — attributes: `family_id`, `node_id`, `expires_at`
|
||||
- `family_invitation_revoked` — attributes: `family_id`, `node_id`
|
||||
- `family_invitation_accepted` — attributes: `family_id`, `node_id`
|
||||
- `family_invitation_rejected` — attributes: `family_id`, `node_id`
|
||||
- `family_member_left` — attributes: `family_id`, `node_id`
|
||||
- `family_member_kicked` — attributes: `family_id`, `node_id`
|
||||
- `family_node_unbond_cleanup` — attributes: `node_id`
|
||||
|
||||
**Note for indexer authors (non-normative)**: CosmWasm wraps user-emitted events in a `wasm-` prefix when they land in the tendermint event log. So `family_creation` appears as `wasm-family_creation` on the chain side, `family_invitation` as `wasm-family_invitation`, etc. The constants in `constants::events` are the *unprefixed* names — indexers querying the chain need to add the `wasm-` prefix themselves (or use the prefix-stripping helpers most Cosmos indexer SDKs already provide).
|
||||
|
||||
#### Scenario: Each successful state-mutating execute path emits exactly its named event
|
||||
- **WHEN** any of the execute paths listed above succeeds (i.e. every `ExecuteMsg` variant other than `UpdateConfig`)
|
||||
- **THEN** the response carries exactly one event whose `ty` equals the corresponding constant from `constants::events` and whose attributes include each of the listed keys
|
||||
|
||||
#### Scenario: UpdateConfig is the exception — no event is emitted
|
||||
- **WHEN** the admin sends `ExecuteMsg::UpdateConfig` and the call succeeds
|
||||
- **THEN** the response is `Response::default()` and carries no event (this is intentional — `UpdateConfig` is administrative metadata, not a tracked state transition)
|
||||
|
||||
Reference in New Issue
Block a user