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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user