NYM-1199: sandbox families read smoke + guarded write journey (real IPC verified)

Add a headless sandbox smoke (src-tauri/examples/sandbox_families_smoke.rs) that
exercises the node-families real-IPC layer against the contract deployed to
sandbox, via the same validator-client calls the Tauri commands wrap. 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 is the runnable
equivalent of the manual Tier-3 smoke.

- Read smoke (task 4.1): resolves the bundled sandbox families contract address,
  reads Config from raw state, lists live families/members/pending invites via
  real IPC. Passes against sandbox (2 families, fee 50 NYM, limits 30/50).
- Guarded write journey (task 5.2, `-- --write`): create -> rename -> disband on
  the funded test account, with on-chain-latency polling and cleanup; refuses to
  run if the account already owns a family. All three txs confirmed on sandbox.
  invite/accept/reject/kick/revoke/leave are wired but need a controllable,
  unaffiliated sandbox node id (5.3 / rest of 5.2 still pending that fixture).

The funded account mnemonic is read from .env (TAURI-WALLET-MNEMONIC, gitignored)
at runtime and never printed or committed. Run instructions added to e2e/README.md;
tasks 4.1/4.2 marked done, 5.2/5.4 partial, 5.3 still blocked on a node fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yana Matrosova
2026-06-10 17:25:19 +03:00
parent b0dbb174cb
commit 3a77eff16b
3 changed files with 285 additions and 5 deletions
+22
View File
@@ -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
@@ -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 <addr>` (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
@@ -0,0 +1,258 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// 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<dyn Error>>;
/// 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"]
.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<FamilyConfig, Box<dyn Error>> {
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<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);
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<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 {
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(())
}