Files
nym/openspec/changes/archive/2026-05-21-ecash-contract-spec/specs/ecash-contract/spec.md
T
Jędrzej Stuczyński d2833c76c0 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.
2026-05-22 15:30:08 +01:00

46 KiB

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_madeDepositStorage::total_deposits_made(storage);
  • total_depositedPoolCounters::total_deposited;
  • total_deposits_made_with_default_pricedeposits_with_default_price;
  • total_deposited_with_default_pricedeposits_with_default_price_amounts;
  • total_deposits_made_with_custom_pricesum(deposits_with_custom_price[a]) across all addresses;
  • total_deposited_with_custom_pricesum(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.
  • MalformedRedemptionCommitmentRequestRedemption with a non-32-byte commitment.
  • MalformedEd25519IdentityDepositTicketBookFunds 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.
  • UnimplementedBlacklistingProposeToBlacklist 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