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