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