From fcf24f0c33553bc26dbf7a6c45eefab644339f78 Mon Sep 17 00:00:00 2001 From: Yana Matrosova Date: Wed, 10 Jun 2026 17:45:41 +0300 Subject: [PATCH] NYM-1199: extend sandbox smoke to the full 9-command families lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand sandbox_families_smoke.rs from create→rename→disband to the complete owner+operator journey, exercising all 9 execute commands against sandbox: the account acts as both family owner and node operator, so one funded account drives create → invite → revoke / reject / accept → leave / kick → disband, each step gated on a state-poll assertion (pending present/absent, membership joined/left). The numeric node_id is resolved from the mixnet contract (owned nym-node, else legacy mixnode). When the account controls no node, the harness runs the owner-only subset (create/update/disband) and skips the 6 member-management commands with a notice instead of aborting — so it stays useful either way. Verified on sandbox: read smoke + owner subset green (real tx hashes, state cleaned up). The member commands are implemented and auto-run once a node is bonded to the account — currently the funded account `n13jtj2…` is an address, not a node, and a bond lookup (nym-node/mixnode/gateway) finds nothing for it, so there is no node_id to invite/accept/kick yet. Tasks 5.2-5.4 updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../changes/node-families-real-ipc/tasks.md | 6 +- .../examples/sandbox_families_smoke.rs | 217 ++++++++++++++---- 2 files changed, 177 insertions(+), 46 deletions(-) diff --git a/nym-wallet/openspec/changes/node-families-real-ipc/tasks.md b/nym-wallet/openspec/changes/node-families-real-ipc/tasks.md index 8d4b477e90..d277821093 100644 --- a/nym-wallet/openspec/changes/node-families-real-ipc/tasks.md +++ b/nym-wallet/openspec/changes/node-families-real-ipc/tasks.md @@ -29,9 +29,9 @@ ## 5. Sandbox execute flows (guarded) + iterate to green - [x] 5.1 **Sandbox test account provisioned + funded:** `n13jtj2unhhtryxllnuc8zkng3nl4xnnjvxe0tzv` (~101,000 NYM — ample for fees/repeat runs); mnemonic in vault secret `TAURI-WALLET-MNEMONIC` (item `95d3d842-90ad-4b6f-8b0c-10f5febce1c3`). Inject via CI secret only — never commit. Verify/inspect with `nym-cli -c sandbox.env account balance ` (also handy for pre/post-run state checks + cleanup). Tests target only this account and clean up after. -- [~] 5.2 **Owner write journey partially green on sandbox** (`sandbox_families_smoke.rs --write`): `create_family` (50 NYM funds attached + converted), `update_family` (rename), and `disband_family` all executed and confirmed on chain (3 real tx hashes), with on-chain-latency polling and full cleanup at the end (account owns no family after). The guard refuses to run if the account already owns a family. **Remaining:** `invite_to_family` → `accept_family_invitation`/`kick_from_family` need a controllable, currently-unaffiliated bonded node id on sandbox, which this account doesn't have spare — they're wired but unexercised. -- [ ] 5.3 Operator journey (accept → leave, reject) — blocked on the same missing controllable node id / seeded invites for this account's nodes. -- [~] 5.4 Iterated to green for the create/update/disband subset (funds, base-coin conversion, signing, latency all confirmed). The member-management commands (invite/accept/reject/kick/revoke/leave) still need a sandbox node fixture before they can be exercised; write tier stays manual until the mnemonic is a CI secret. +- [~] 5.2 **Owner write journey green on sandbox** (`sandbox_families_smoke.rs --write`): `create_family` (50 NYM funds attached + converted), `update_family` (rename), and `disband_family` all executed and confirmed on chain (real tx hashes), with on-chain-latency polling and full cleanup at the end (account owns no family after). The guard refuses to run if the account already owns a family. The full owner+operator journey (`create → invite → revoke / reject / accept → leave / kick → disband`, all 9 execute commands, each with a state-poll assertion) is **implemented and auto-runs once a node is bonded to the account**; it skips the 6 member-management steps with a notice when none is. +- [~] 5.3 Operator steps (accept → leave, reject) are implemented in the same harness (the account acts as both owner and node operator). **Blocked only on a bonded node:** the funded account `n13jtj2…` is an *address*, not a node — a mixnet-contract bond lookup (nym-node / mixnode / gateway) returns nothing for it on sandbox, so there's no numeric `node_id` to invite/accept/kick. Bonding any node to this account unblocks the full run with no code change. +- [~] 5.4 Iterated to green for the create/update/disband subset (funds, base-coin conversion, signing, latency all confirmed). The 6 member-management commands are wired + assertion-checked and will execute as soon as a node is bonded to the test account; write tier stays manual until the mnemonic is a CI secret. ## 6. Verify & docs diff --git a/nym-wallet/src-tauri/examples/sandbox_families_smoke.rs b/nym-wallet/src-tauri/examples/sandbox_families_smoke.rs index b75433dc61..ce621cce2a 100644 --- a/nym-wallet/src-tauri/examples/sandbox_families_smoke.rs +++ b/nym-wallet/src-tauri/examples/sandbox_families_smoke.rs @@ -30,15 +30,22 @@ use std::time::Duration; use bip39::Mnemonic; use nym_config::defaults::NymNetworkDetails; -use nym_node_families_contract_common::Config as FamilyConfig; +use nym_mixnet_contract_common::NodeId; +use nym_node_families_contract_common::{Config as FamilyConfig, NodeFamilyId}; use nym_validator_client::nyxd::contract_traits::{ - NodeFamiliesQueryClient, NodeFamiliesSigningClient, NymContractsProvider, + MixnetQueryClient, NodeFamiliesQueryClient, NodeFamiliesSigningClient, NymContractsProvider, PagedNodeFamiliesQueryClient, }; -use nym_validator_client::nyxd::{Coin, CosmWasmClient}; +use nym_validator_client::nyxd::cosmwasm_client::types::ExecuteResult; +use nym_validator_client::nyxd::{AccountId, Coin, CosmWasmClient}; use nym_wallet_types::network::Network as WalletNetwork; type Smoke = Result<(), Box>; +type Client = nym_validator_client::DirectSigningHttpRpcValidatorClient; + +fn tx(label: &str, res: ExecuteResult) { + println!(" {label} → tx {}", res.transaction_hash); +} /// Read the sandbox mnemonic from `.env` without ever surfacing its value. /// Parses the file directly because the key (`TAURI-WALLET-MNEMONIC`) contains @@ -138,10 +145,8 @@ async fn read_smoke(client: &nym_validator_client::DirectSigningHttpRpcValidator Ok(()) } -async fn write_journey( - client: &nym_validator_client::DirectSigningHttpRpcValidatorClient, -) -> Smoke { - println!("\n=== GUARDED WRITE JOURNEY (task 5.2: create → rename → disband) ==="); +async fn write_journey(client: &Client) -> Smoke { + println!("\n=== GUARDED WRITE JOURNEY (tasks 5.2 + 5.3: full owner + operator lifecycle) ==="); let me = client.nyxd.address(); @@ -155,52 +160,153 @@ async fn write_journey( .into()); } + // Resolve the numeric node_id this account controls. The account acts as + // BOTH family owner (invite/kick/revoke/disband) and node operator + // (accept/reject/leave), so a single funded account drives the whole + // lifecycle — covering all 9 execute commands. + // The member-management commands (invite/accept/reject/kick/revoke/leave) + // need a numeric node_id this account controls; the account is both family + // owner and node operator, so one account can drive the whole lifecycle. + // If no node is bonded we still run the owner-only subset (create/update/ + // disband) and skip the member steps with a clear notice. + let nym_node = client.nyxd.get_owned_nymnode(&me).await?.details; + let legacy_mixnode = client.nyxd.get_owned_mixnode(&me).await?.mixnode_details; + let controlled: Option = match (&nym_node, &legacy_mixnode) { + (Some(d), _) => Some(d.bond_information.node_id), + (_, Some(m)) => Some(m.bond_information.mix_id), + (None, None) => None, + }; + match controlled { + Some(id) => println!( + "controlled node_id = {id} ({})", + if nym_node.is_some() { + "nym-node" + } else { + "legacy mixnode" + } + ), + None => println!( + "⚠️ account controls no node on the sandbox mixnet contract — running the owner-only \ + subset (create/update/disband); skipping invite/accept/reject/kick/revoke/leave" + ), + } + let config = read_config(client).await?; let creation_fee: Vec = vec![config.create_family_fee.into()]; - let name = "smoke-test-family"; - println!("create_family({name:?}) attaching {} ...", creation_fee[0]); - let res = client - .nyxd - .create_family( - name.to_string(), - "throwaway family created by sandbox_families_smoke".to_string(), - None, - creation_fee, - ) - .await?; - println!(" tx hash = {}", res.transaction_hash); + println!("\n[1] create_family attaching {} ...", creation_fee[0]); + tx( + "create_family", + client + .nyxd + .create_family( + "smoke-test-family".to_string(), + "throwaway family created by sandbox_families_smoke".to_string(), + None, + creation_fee, + ) + .await?, + ); + let fid = poll_for_owned_family(client, &me).await?.id; + println!(" → family id={fid}"); - let family = poll_for_owned_family(client, &me).await?; - println!( - " created family id={} members={}", - family.id, family.members + println!("\n[2] update_family (rename) ..."); + tx( + "update_family", + client + .nyxd + .update_family(Some("smoke-test-renamed".to_string()), None, None) + .await?, ); - println!( - "update_family(rename → {:?}) ...", - "smoke-test-family-renamed" - ); - let res = client - .nyxd - .update_family(Some("smoke-test-family-renamed".to_string()), None, None) - .await?; - println!(" tx hash = {}", res.transaction_hash); + if let Some(node_id) = controlled { + println!("\n[3] invite_to_family → revoke_family_invitation (owner revokes a pending invite) ..."); + tx( + "invite_to_family", + client.nyxd.invite_to_family(node_id, None, None).await?, + ); + poll_pending(client, fid, node_id, true).await?; + tx( + "revoke_family_invitation", + client.nyxd.revoke_family_invitation(node_id, None).await?, + ); + poll_pending(client, fid, node_id, false).await?; - println!("disband_family() (cleanup) ..."); - let res = client.nyxd.disband_family(None).await?; - println!(" tx hash = {}", res.transaction_hash); + println!("\n[4] invite_to_family → reject_family_invitation (operator rejects) ..."); + tx( + "invite_to_family", + client.nyxd.invite_to_family(node_id, None, None).await?, + ); + poll_pending(client, fid, node_id, true).await?; + tx( + "reject_family_invitation", + client + .nyxd + .reject_family_invitation(fid, node_id, None) + .await?, + ); + poll_pending(client, fid, node_id, false).await?; - // confirm cleanup + println!("\n[5] invite → accept_family_invitation → leave_family (operator joins then leaves) ..."); + tx( + "invite_to_family", + client.nyxd.invite_to_family(node_id, None, None).await?, + ); + poll_pending(client, fid, node_id, true).await?; + tx( + "accept_family_invitation", + client + .nyxd + .accept_family_invitation(fid, node_id, None) + .await?, + ); + poll_membership(client, node_id, Some(fid)).await?; + tx( + "leave_family", + client.nyxd.leave_family(node_id, None).await?, + ); + poll_membership(client, node_id, None).await?; + + println!("\n[6] invite → accept → kick_from_family (owner kicks the member) ..."); + tx( + "invite_to_family", + client.nyxd.invite_to_family(node_id, None, None).await?, + ); + poll_pending(client, fid, node_id, true).await?; + tx( + "accept_family_invitation", + client + .nyxd + .accept_family_invitation(fid, node_id, None) + .await?, + ); + poll_membership(client, node_id, Some(fid)).await?; + tx( + "kick_from_family", + client.nyxd.kick_from_family(node_id, None).await?, + ); + poll_membership(client, node_id, None).await?; + } + + println!("\n[7] disband_family (cleanup) ..."); + tx("disband_family", client.nyxd.disband_family(None).await?); poll_until_no_owned_family(client, &me).await?; - println!("write journey OK ✅ (state cleaned up — account owns no family)"); + + if controlled.is_some() { + println!("\nwrite journey OK ✅ — all 9 execute commands exercised; state cleaned up"); + } else { + println!( + "\nwrite journey OK ✅ — owner subset (create/update/disband) exercised; \ + state cleaned up. Bond a node to this account to also cover the 6 member commands" + ); + } Ok(()) } /// On-chain state isn't readable until the block commits; poll briefly. async fn poll_for_owned_family( - client: &nym_validator_client::DirectSigningHttpRpcValidatorClient, - owner: &nym_validator_client::nyxd::AccountId, + client: &Client, + owner: &AccountId, ) -> Result> { for _ in 0..10 { if let Some(f) = client.nyxd.get_family_by_owner(owner).await?.family { @@ -211,10 +317,7 @@ async fn poll_for_owned_family( Err("timed out waiting for the created family to appear on chain".into()) } -async fn poll_until_no_owned_family( - client: &nym_validator_client::DirectSigningHttpRpcValidatorClient, - owner: &nym_validator_client::nyxd::AccountId, -) -> Smoke { +async fn poll_until_no_owned_family(client: &Client, owner: &AccountId) -> Smoke { for _ in 0..10 { if client .nyxd @@ -230,6 +333,34 @@ async fn poll_until_no_owned_family( Err("timed out waiting for the family to be disbanded".into()) } +/// Wait until a pending invitation for `(fid, node_id)` is present/absent. +async fn poll_pending(client: &Client, fid: NodeFamilyId, node_id: NodeId, want: bool) -> Smoke { + for _ in 0..10 { + let present = client + .nyxd + .get_pending_invitation(fid, node_id) + .await? + .invitation + .is_some(); + if present == want { + return Ok(()); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + Err(format!("timed out waiting for pending invitation present={want}").into()) +} + +/// Wait until `node_id`'s membership equals `want` (`Some(fid)` joined / `None` not a member). +async fn poll_membership(client: &Client, node_id: NodeId, want: Option) -> Smoke { + for _ in 0..10 { + if client.nyxd.get_family_membership(node_id).await?.family_id == want { + return Ok(()); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + Err(format!("timed out waiting for membership = {want:?}").into()) +} + #[tokio::main] async fn main() -> Smoke { let do_write = std::env::args().any(|a| a == "--write");