NYM-1199: extend sandbox smoke to the full 9-command families lifecycle

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) <noreply@anthropic.com>
This commit is contained in:
Yana Matrosova
2026-06-10 17:45:41 +03:00
parent 645b8c7abf
commit fcf24f0c33
2 changed files with 177 additions and 46 deletions
@@ -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 <addr>` (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
@@ -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<dyn Error>>;
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<NodeId> = 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<Coin> = 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<nym_node_families_contract_common::NodeFamily, Box<dyn Error>> {
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<NodeFamilyId>) -> 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");