diff --git a/nym-wallet/e2e/README.md b/nym-wallet/e2e/README.md index 351cba0fe5..d7cd18aea6 100644 --- a/nym-wallet/e2e/README.md +++ b/nym-wallet/e2e/README.md @@ -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 ``` -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) 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 d277821093..dd989e4d6f 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 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. +- [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 diff --git a/nym-wallet/src-tauri/examples/sandbox_families_smoke.rs b/nym-wallet/src-tauri/examples/sandbox_families_smoke.rs index c5bd0d9b8c..3be85b1dc7 100644 --- a/nym-wallet/src-tauri/examples/sandbox_families_smoke.rs +++ b/nym-wallet/src-tauri/examples/sandbox_families_smoke.rs @@ -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> { - 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 { + [".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> { 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> { + 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> { + 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, Box> { + 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 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 = 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!(