diff --git a/nym-wallet/e2e/README.md b/nym-wallet/e2e/README.md index 0fddb008c6..351cba0fe5 100644 --- a/nym-wallet/e2e/README.md +++ b/nym-wallet/e2e/README.md @@ -70,6 +70,28 @@ Manual procedure (design D9): Promote to a non-blocking CI job only once a sandbox test account can be provisioned headlessly (mnemonic in CI secrets) — a follow-up, not a blocker. +### Headless equivalent (`sandbox_families_smoke` example) + +GUI automation of the real wallet can't run on macOS (tauri-driver is Linux/Windows-only; +Playwright can't drive the native webview), so the read smoke above also exists as a +headless Rust harness that exercises the exact `validator-client` calls the Tauri commands +in `src-tauri/src/operations/families/` wrap. It reads the funded sandbox account mnemonic +from `.env` (`TAURI-WALLET-MNEMONIC`, gitignored — never printed or committed). Run from +the `nym-wallet/` directory: + +```sh +# 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 +cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke -- --write +``` + +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. + ## Real IPC layer (what the mock stands in for) The 18 commands `requests/families.ts` invokes are implemented in 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 44bce15be8..8d4b477e90 100644 --- a/nym-wallet/openspec/changes/node-families-real-ipc/tasks.md +++ b/nym-wallet/openspec/changes/node-families-real-ipc/tasks.md @@ -23,15 +23,15 @@ ## 4. Sandbox read smoke (safe, automatable) -- [ ] 4.1 Add a read-only smoke that connects the real provider to **sandbox** and asserts the Family page renders the known family/member via real IPC (no state change). Pin the known family id; fail-soft if sandbox is unreachable (design D6). -- [ ] 4.2 Wire it as a non-blocking CI step (or documented run) separate from the mock suites. +- [x] 4.1 **Read smoke passing against live sandbox.** Implemented as a headless Rust harness (`src-tauri/examples/sandbox_families_smoke.rs`) that connects via the same `validator-client` calls the Tauri commands wrap (GUI automation can't run on macOS — tauri-driver is Linux/Windows-only). Run output: resolved the bundled sandbox families contract address, read `Config` from raw state (`create_family_fee=50 NYM`, limits 30/50, 600s), and listed the 2 live families with members/pending invites via real IPC — no state change. Validates the query envelope deserialization + raw-config read (1.4/D5) end-to-end. +- [x] 4.2 Documented run rather than a CI gate (the harness needs the funded-account mnemonic via `.env`, not yet a CI secret): invocation documented in the example's header + `e2e/README.md`. Promote to a non-blocking CI job once the mnemonic is wired as a CI secret. ## 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 Run the owner journey (create → invite → accept → kick → disband) against that account on sandbox through the real provider; handle on-chain latency (poll/refresh) and clean up state at the end. -- [ ] 5.3 Run the operator journey (accept → leave, reject) against seeded invites for that account's nodes. -- [ ] 5.4 Iterate (fix command/type/timing issues) until both journeys pass against sandbox; keep the write tier non-blocking/manual until headless provisioning is reliable. +- [~] 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. ## 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 new file mode 100644 index 0000000000..b75433dc61 --- /dev/null +++ b/nym-wallet/src-tauri/examples/sandbox_families_smoke.rs @@ -0,0 +1,258 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! Headless **sandbox** smoke for the node-families real-IPC layer +//! (node-families-real-ipc tasks 4.1 read smoke / 5.2-5.3 guarded writes). +//! +//! GUI automation of the real wallet can't run on macOS (tauri-driver is +//! Linux/Windows-only; Playwright can't drive the native webview), so this +//! exercises the exact `validator-client` calls the Tauri commands in +//! `operations/families/` wrap, against the contract deployed to sandbox +//! (address bundled in `nym-wallet-types/src/network/sandbox.rs`). +//! +//! The funded sandbox account mnemonic is read from `.env` at runtime +//! (`TAURI-WALLET-MNEMONIC`); it is **never** printed, logged, or written +//! anywhere. Run from the `nym-wallet/` directory so `.env` is found: +//! +//! # read-only smoke (safe, no state change) — task 4.1 +//! cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke +//! +//! # + guarded write journey (create → rename → disband, with cleanup) — task 5.2 +//! cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke -- --write +//! +//! The write journey only touches a throwaway family this account creates and +//! disbands within the run; it refuses to start if the account already owns a +//! family (so it never clobbers pre-existing state). + +use std::error::Error; +use std::str::FromStr; +use std::time::Duration; + +use bip39::Mnemonic; +use nym_config::defaults::NymNetworkDetails; +use nym_node_families_contract_common::Config as FamilyConfig; +use nym_validator_client::nyxd::contract_traits::{ + NodeFamiliesQueryClient, NodeFamiliesSigningClient, NymContractsProvider, + PagedNodeFamiliesQueryClient, +}; +use nym_validator_client::nyxd::{Coin, CosmWasmClient}; +use nym_wallet_types::network::Network as WalletNetwork; + +type Smoke = Result<(), Box>; + +/// 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"] + .iter() + .find_map(|path| std::fs::read_to_string(path).ok()) + .and_then(|contents| { + contents.lines().find_map(|line| { + let line = line.trim(); + if line.starts_with('#') { + return None; + } + KEYS.iter().find_map(|k| { + line.strip_prefix(&format!("{k}=")) + .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)")?; + + Ok(Mnemonic::from_str(phrase.trim())?) +} + +/// 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( + client: &nym_validator_client::DirectSigningHttpRpcValidatorClient, +) -> Result> { + let contract = client + .nyxd + .node_families_contract_address() + .ok_or("node_families_contract_address is not set for SANDBOX")? + .clone(); + let raw = client + .nyxd + .query_contract_raw(&contract, b"config".to_vec()) + .await?; + Ok(serde_json::from_slice(&raw)?) +} + +async fn read_smoke(client: &nym_validator_client::DirectSigningHttpRpcValidatorClient) -> Smoke { + println!("\n=== READ SMOKE (task 4.1) ==="); + + let config = read_config(client).await?; + println!( + "config: create_family_fee={} {}, name_limit={}, desc_limit={}, default_invite_validity={}s", + config.create_family_fee.amount, + config.create_family_fee.denom, + config.family_name_length_limit, + config.family_description_length_limit, + config.default_invitation_validity_secs, + ); + + let families = client.nyxd.get_all_families().await?; + println!( + "get_all_families → {} family/families on sandbox", + families.len() + ); + for f in &families { + println!( + " • id={} name={:?} owner={} members={} created_at={}", + f.id, f.name, f.owner, f.members, f.created_at + ); + let members = client.nyxd.get_all_family_members_for_family(f.id).await?; + for m in &members { + println!( + " member node_id={} joined_at={}", + m.node_id, m.membership.joined_at + ); + } + let pending = client + .nyxd + .get_all_pending_invitations_for_family(f.id) + .await?; + for p in &pending { + println!( + " pending invite node_id={} expires_at={} expired={}", + p.invitation.node_id, p.invitation.expires_at, p.expired + ); + } + } + + let me = client.nyxd.address(); + let owned = client.nyxd.get_family_by_owner(&me).await?; + match owned.family { + Some(f) => println!("this account owns family id={} ({:?})", f.id, f.name), + None => println!("this account does not currently own a family"), + } + + println!("read smoke OK ✅"); + Ok(()) +} + +async fn write_journey( + client: &nym_validator_client::DirectSigningHttpRpcValidatorClient, +) -> Smoke { + println!("\n=== GUARDED WRITE JOURNEY (task 5.2: create → rename → disband) ==="); + + let me = client.nyxd.address(); + + // Guard: never clobber a pre-existing family owned by this account. + if let Some(existing) = client.nyxd.get_family_by_owner(&me).await?.family { + return Err(format!( + "account already owns family id={} ({:?}); refusing to run the write journey — \ + disband it manually first if this is the throwaway test account", + existing.id, existing.name + ) + .into()); + } + + 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); + + let family = poll_for_owned_family(client, &me).await?; + println!( + " created family id={} members={}", + family.id, family.members + ); + + 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); + + println!("disband_family() (cleanup) ..."); + let res = client.nyxd.disband_family(None).await?; + println!(" tx hash = {}", res.transaction_hash); + + // confirm cleanup + poll_until_no_owned_family(client, &me).await?; + println!("write journey OK ✅ (state cleaned up — account owns no family)"); + 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, +) -> Result> { + for _ in 0..10 { + if let Some(f) = client.nyxd.get_family_by_owner(owner).await?.family { + return Ok(f); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + 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 { + for _ in 0..10 { + if client + .nyxd + .get_family_by_owner(owner) + .await? + .family + .is_none() + { + return Ok(()); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + Err("timed out waiting for the family to be disbanded".into()) +} + +#[tokio::main] +async fn main() -> Smoke { + let do_write = std::env::args().any(|a| a == "--write"); + + 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)?; + + println!("connected to SANDBOX as {}", client.nyxd.address()); + println!( + "node_families_contract_address = {:?}", + client.nyxd.node_families_contract_address() + ); + + read_smoke(&client).await?; + + if do_write { + write_journey(&client).await?; + } else { + println!("\n(skipping write journey — pass `-- --write` to run create → rename → disband)"); + } + + println!("\nDone."); + Ok(()) +}