NYM-1199: full member/operator families journey green on sandbox (all 9 cmds)

Add a two-account member/operator journey (`--member`) to the sandbox smoke and
verify the remaining 6 execute commands end-to-end on chain. owner=FAMILY_OWNER
(controls family 1), operator=ACCOUNT_WITH_BONDED_NODE (controls node_id 31):
kick → invite/reject → invite/revoke → invite/accept/leave, each with per-step
state assertions, then restores node 31's membership (verified via --accounts:
family 1 back to 1 member). A real pre-existing family is left exactly as found.

Also adds read-only `--accounts` (prints owner/operator on-chain state) and
generalizes the .env loader to multiple keys (TAURI-WALLET / FAMILY_OWNER /
ACCOUNT_WITH_BONDED_NODE mnemonics) — all read at runtime, never printed.

With the earlier `--write` owner subset (create/update/disband), all 9 families
execute commands + the queries are now confirmed against the deployed sandbox
contract. Tasks 5.2/5.3/5.4 marked done; e2e/README updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yana Matrosova
2026-06-10 18:34:40 +03:00
parent 56cafe2370
commit fcc4cbceea
3 changed files with 238 additions and 27 deletions
+16 -5
View File
@@ -83,14 +83,25 @@ the `nym-wallet/` directory:
# read-only smoke (no state change) — lists the live families/members/config via real IPC
cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke
# + guarded write journey (create → rename → disband, with cleanup) on the test account
# owner subset (create → rename → disband, with cleanup) on the funded account
cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke -- --write
# full member/operator journey against FAMILY_OWNER's existing family, using
# ACCOUNT_WITH_BONDED_NODE as the node operator (kick/invite/reject/revoke/accept/leave),
# restoring the node's membership at the end
cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke -- --member
# read-only diagnostics
cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke -- --accounts
cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke -- --bond-check <n1-address>
```
The write journey refuses to start if the account already owns a family, and disbands its
throwaway family at the end. The member-management commands (invite/accept/reject/kick/
revoke/leave) need a controllable, unaffiliated bonded node id on sandbox and are wired but
not yet exercised here.
`--write` uses `TAURI-WALLET-MNEMONIC` (refuses to start if that account already owns a
family; disbands its throwaway family at the end). `--member` uses `FAMILY_OWNER_MNEMONIC`
(owner) + `ACCOUNT_WITH_BONDED_NODE_MNEMONIC` (operator) to drive all 6 member commands
against the owner's existing family and restore the node's original membership, so a real
family is left unchanged. All mnemonics are read from `.env` (gitignored) and never printed.
Together the two runs cover all 9 execute commands end-to-end on sandbox.
## Real IPC layer (what the mock stands in for)
@@ -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 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 → inviterevoke / 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.
- [x] 5.2 **Owner journey green on sandbox** (`sandbox_families_smoke.rs --write`): `create_family` (50 NYM funds attached + converted), `update_family` (rename), `disband_family` all executed + confirmed on chain (real tx hashes), on-chain-latency polling, full cleanup. The owner-side member ops (`invite`/`revoke`/`kick`) are covered by the §5.3 run below.
- [x] 5.3 **Operator journey green on sandbox** (`--member`, two-account flow): owner=`FAMILY_OWNER` (`n18cuqlr…`, owns family 1), operator=`ACCOUNT_WITH_BONDED_NODE` (`n1nu7zg8…`, controls node_id 31). Exercised all 6 member commands against family 1 with per-step state assertions — `kick`, `invite`(×4), `reject`, `revoke`, `accept`(×2), `leave` — then **restored** node 31's membership (verified via `--accounts`: family 1 back to 1 member). A real pre-existing family was left exactly as found.
- [x] 5.4 **Both journeys pass against sandbox.** Across the two runs all 9 execute commands are confirmed end-to-end (funds, base-coin conversion, signing, two-account owner/operator separation, on-chain latency). Write tier stays manual (not a CI gate) until the mnemonics are wired as CI secrets; `.env` (gitignored) holds them locally and they are never printed/committed.
## 6. Verify & docs
@@ -47,13 +47,11 @@ 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
/// hyphens, which not every dotenv loader exports into the process env.
fn load_mnemonic() -> Result<Mnemonic, Box<dyn Error>> {
const KEYS: [&str; 2] = ["TAURI-WALLET-MNEMONIC", "TAURI_WALLET_MNEMONIC"];
let phrase = ([".env", "nym-wallet/.env", "../.env"]
/// Read a value for `key` from `.env` (handles hyphenated keys, which not every
/// dotenv loader exports into the process env) or fall back to the process env.
/// Secret values are returned for use but never printed by this harness.
fn read_env(key: &str) -> Option<String> {
[".env", "nym-wallet/.env", "../.env"]
.iter()
.find_map(|path| std::fs::read_to_string(path).ok())
.and_then(|contents| {
@@ -62,19 +60,72 @@ fn load_mnemonic() -> Result<Mnemonic, Box<dyn Error>> {
if line.starts_with('#') {
return None;
}
KEYS.iter().find_map(|k| {
line.strip_prefix(&format!("{k}="))
.map(|v| v.trim().trim_matches(['"', '\'']).to_string())
})
line.strip_prefix(&format!("{key}="))
.map(|v| v.trim().trim_matches(['"', '\'']).to_string())
})
}))
.or_else(|| KEYS.iter().find_map(|k| std::env::var(k).ok()))
.filter(|v| !v.is_empty())
.ok_or("TAURI-WALLET-MNEMONIC not found in .env (run from the nym-wallet/ directory)")?;
})
.or_else(|| std::env::var(key).ok())
.filter(|v| !v.is_empty())
}
/// Load a mnemonic from the first of `keys` present in `.env`.
fn mnemonic_from(keys: &[&str]) -> Result<Mnemonic, Box<dyn Error>> {
let phrase = keys
.iter()
.find_map(|k| read_env(k))
.ok_or_else(|| format!("none of {keys:?} found in .env (run from the nym-wallet/ dir)"))?;
Ok(Mnemonic::from_str(phrase.trim())?)
}
fn build_client(mnemonic: Mnemonic) -> Result<Client, Box<dyn Error>> {
let network: NymNetworkDetails = WalletNetwork::SANDBOX.into();
let config = nym_validator_client::Config::try_from_nym_network_details(&network)?;
Ok(nym_validator_client::Client::new_signing(config, mnemonic)?)
}
/// The numeric node_id an account controls (nym-node, else legacy mixnode), if any.
async fn controlled_node(client: &Client) -> Result<Option<NodeId>, Box<dyn Error>> {
let me = client.nyxd.address();
if let Some(d) = client.nyxd.get_owned_nymnode(&me).await?.details {
return Ok(Some(d.bond_information.node_id));
}
if let Some(m) = client.nyxd.get_owned_mixnode(&me).await?.mixnode_details {
return Ok(Some(m.bond_information.mix_id));
}
Ok(None)
}
/// Print the on-chain family/node state of the owner + operator accounts so we
/// can pick the right write flow before mutating anything.
async fn accounts_state() -> Smoke {
let owner = build_client(mnemonic_from(&["FAMILY_OWNER_MNEMONIC"])?)?;
let operator = build_client(mnemonic_from(&["ACCOUNT_WITH_BONDED_NODE_MNEMONIC"])?)?;
let o_addr = owner.nyxd.address();
let o_family = owner.nyxd.get_family_by_owner(&o_addr).await?.family;
println!("\n=== ACCOUNTS STATE ===");
println!("FAMILY_OWNER = {o_addr}");
match &o_family {
Some(f) => println!(
" owns family id={} name={:?} members={}",
f.id, f.name, f.members
),
None => println!(" owns no family"),
}
let p_addr = operator.nyxd.address();
let node = controlled_node(&operator).await?;
println!("ACCOUNT_WITH_BONDED_NODE = {p_addr}");
match node {
Some(id) => {
let membership = operator.nyxd.get_family_membership(id).await?.family_id;
println!(" controls node_id={id}, current family membership = {membership:?}");
}
None => println!(" controls no node"),
}
Ok(())
}
/// Read the contract `Config` straight from raw state (the same path
/// `get_family_config` uses — there is no `GetConfig` smart query).
async fn read_config(
@@ -361,6 +412,143 @@ async fn poll_membership(client: &Client, node_id: NodeId, want: Option<NodeFami
Err(format!("timed out waiting for membership = {want:?}").into())
}
/// Two-account member/operator journey (task 5.3) against the owner's EXISTING
/// family — owner = `FAMILY_OWNER` (invite/revoke/kick), operator =
/// `ACCOUNT_WITH_BONDED_NODE` (accept/reject/leave). Exercises all 6 member
/// commands and restores the node's original membership at the end, so a
/// real pre-existing family is left exactly as it was found.
async fn member_journey(owner: &Client, operator: &Client) -> Smoke {
println!("\n=== MEMBER / OPERATOR JOURNEY (task 5.3) ===");
let owner_addr = owner.nyxd.address();
let fid = owner
.nyxd
.get_family_by_owner(&owner_addr)
.await?
.family
.ok_or("FAMILY_OWNER owns no family to run the member journey against")?
.id;
let node_id = controlled_node(operator)
.await?
.ok_or("ACCOUNT_WITH_BONDED_NODE controls no node")?;
let initial = operator
.nyxd
.get_family_membership(node_id)
.await?
.family_id;
println!("owner={owner_addr}\noperator node_id={node_id}, family={fid}, initial membership={initial:?} (restored at end)");
// Baseline: clear any stray pending invite, then make node a clean member of `fid`.
if owner
.nyxd
.get_pending_invitation(fid, node_id)
.await?
.invitation
.is_some()
{
tx(
"revoke (baseline)",
owner.nyxd.revoke_family_invitation(node_id, None).await?,
);
poll_pending(owner, fid, node_id, false).await?;
}
if operator
.nyxd
.get_family_membership(node_id)
.await?
.family_id
!= Some(fid)
{
tx(
"invite (baseline)",
owner.nyxd.invite_to_family(node_id, None, None).await?,
);
poll_pending(owner, fid, node_id, true).await?;
tx(
"accept (baseline)",
operator
.nyxd
.accept_family_invitation(fid, node_id, None)
.await?,
);
poll_membership(operator, node_id, Some(fid)).await?;
}
println!("\n[a] kick_from_family (owner removes the member) ...");
tx(
"kick_from_family",
owner.nyxd.kick_from_family(node_id, None).await?,
);
poll_membership(operator, node_id, None).await?;
println!("\n[b] invite_to_family → reject_family_invitation (operator rejects) ...");
tx(
"invite_to_family",
owner.nyxd.invite_to_family(node_id, None, None).await?,
);
poll_pending(owner, fid, node_id, true).await?;
tx(
"reject_family_invitation",
operator
.nyxd
.reject_family_invitation(fid, node_id, None)
.await?,
);
poll_pending(owner, fid, node_id, false).await?;
println!("\n[c] invite_to_family → revoke_family_invitation (owner revokes) ...");
tx(
"invite_to_family",
owner.nyxd.invite_to_family(node_id, None, None).await?,
);
poll_pending(owner, fid, node_id, true).await?;
tx(
"revoke_family_invitation",
owner.nyxd.revoke_family_invitation(node_id, None).await?,
);
poll_pending(owner, fid, node_id, false).await?;
println!(
"\n[d] invite → accept_family_invitation → leave_family (operator joins then leaves) ..."
);
tx(
"invite_to_family",
owner.nyxd.invite_to_family(node_id, None, None).await?,
);
poll_pending(owner, fid, node_id, true).await?;
tx(
"accept_family_invitation",
operator
.nyxd
.accept_family_invitation(fid, node_id, None)
.await?,
);
poll_membership(operator, node_id, Some(fid)).await?;
tx(
"leave_family",
operator.nyxd.leave_family(node_id, None).await?,
);
poll_membership(operator, node_id, None).await?;
// Restore the node's original membership so a real family is left unchanged.
println!("\n[restore] returning node {node_id} to its initial membership {initial:?} ...");
match initial {
Some(f) if f == fid => {
tx("invite_to_family", owner.nyxd.invite_to_family(node_id, None, None).await?);
poll_pending(owner, fid, node_id, true).await?;
tx("accept_family_invitation", operator.nyxd.accept_family_invitation(fid, node_id, None).await?);
poll_membership(operator, node_id, Some(fid)).await?;
}
None => poll_membership(operator, node_id, None).await?,
Some(other) => println!(
" ⚠️ node was originally in family {other} (not {fid}); leaving it free — re-add manually if needed"
),
}
println!("\nmember journey OK ✅ — all 6 member commands exercised; node membership restored");
Ok(())
}
/// Read-only: report which (if any) node an arbitrary account controls on the
/// sandbox mixnet contract. Signing identity is irrelevant — these are queries.
async fn bond_check(client: &Client, addr: &str) -> Smoke {
@@ -408,16 +596,28 @@ async fn bond_check(client: &Client, addr: &str) -> Smoke {
async fn main() -> Smoke {
let args: Vec<String> = std::env::args().collect();
let do_write = args.iter().any(|a| a == "--write");
let do_accounts = args.iter().any(|a| a == "--accounts");
let do_member = args.iter().any(|a| a == "--member");
let bond_check_addr = args
.iter()
.position(|a| a == "--bond-check")
.and_then(|i| args.get(i + 1))
.cloned();
let mnemonic = load_mnemonic()?;
let network: NymNetworkDetails = WalletNetwork::SANDBOX.into();
let config = nym_validator_client::Config::try_from_nym_network_details(&network)?;
let client = nym_validator_client::Client::new_signing(config, mnemonic)?;
// These only need the owner/operator keys, not the primary account.
if do_accounts {
return accounts_state().await;
}
if do_member {
let owner = build_client(mnemonic_from(&["FAMILY_OWNER_MNEMONIC"])?)?;
let operator = build_client(mnemonic_from(&["ACCOUNT_WITH_BONDED_NODE_MNEMONIC"])?)?;
return member_journey(&owner, &operator).await;
}
let client = build_client(mnemonic_from(&[
"TAURI-WALLET-MNEMONIC",
"TAURI_WALLET_MNEMONIC",
])?)?;
println!("connected to SANDBOX as {}", client.nyxd.address());
println!(