NYM-1199: implement real Node Families IPC (Rust command layer + provider wiring)

Add the missing Tauri command layer for the node-families contract and switch
the wallet's real provider onto it (node-families-real-ipc groups 1-3, docs).

Rust command layer (src-tauri/src/operations/families/):
- execute.rs: 9 state-changing commands over NodeFamiliesSigningClient, auto/
  simulated gas; create_family attaches create_family_fee as base-denom funds.
  Returns TransactionExecuteResult (family_events omitted per D2 - the provider
  refreshAll()s reads after every execute).
- query.rs: 9 reads over NodeFamiliesQueryClient, normalised at the IPC boundary
  to the wallet shapes in src/types/families.ts: base Coin -> display DecCoin
  (paid_fee, create_family_fee), per-page contract envelopes -> { items,
  start_next_after }, cw_serde tagged FamilyInvitationStatus -> { kind, at }.
  get_family_config reads raw contract state at the "config" key (no smart query).
- Registered all 18 commands in main.rs; added the contract-common path dep.
- cargo build + cargo clippy clean.

Provider wiring:
- Replace the controlledNodeIds = [] stub in FamiliesContextProvider with a
  derivation from useBondingContext().bondedNode ([nodeId]/[mixId]/[]); wrap the
  /family route in BondingContextProvider (it is per-route, not global). D3.
- Fix the requests/families.ts execute bindings to pass camelCase arg keys
  (Tauri maps JS camelCase -> Rust snake_case params).

Types reconciliation + drift guard:
- Fix the generated FamilyInvitationStatus `at` field (bigint -> number) and
  export the 18 generated family types from the @nymproject/types barrel.
- Add src/types/families.contract-guard.ts: tsc-checked assertions locking the
  wallet families types against the ts-rs-generated contract types; IPC-normalised
  fields excluded with documented reasons.

Docs: real-IPC path + guarded sandbox write-flow procedure in e2e/README.md;
design D3 one-bonded-node-per-account note; parent change 9.4/9.5 cross-ref;
update_family arg shape confirmed against the contract (6.2).

Families Jest suites green; contract guard passes. Remaining sandbox read/write
smokes (3.3, groups 4-5) need a wired native build + sandbox + the funded test
account, tracked in tasks.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yana Matrosova
2026-06-09 19:17:18 +03:00
parent 1bb7b1f68b
commit 50382553c2
18 changed files with 736 additions and 38 deletions
@@ -179,18 +179,26 @@ pub struct PastFamilyMember {
pub enum FamilyInvitationStatus {
/// Still awaiting a response. Recorded with a timestamp for completeness even
/// though pending invitations live in a separate map.
Pending { at: u64 },
Pending {
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
at: u64,
},
/// The invitee accepted and joined the family at the given timestamp.
Accepted { at: u64 },
Accepted {
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
at: u64,
},
/// The invitee explicitly rejected the invitation at the given timestamp.
Rejected { at: u64 },
Rejected {
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
at: u64,
},
/// The family revoked the invitation at the given timestamp before it could
/// be accepted or rejected.
Revoked { at: u64 },
/// The invitation had already expired and was superseded by a fresh invitation
/// for the same node from the same family, issued at the given timestamp. This is
/// the only path that archives a timed-out invitation.
Expired { at: u64 },
Revoked {
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
at: u64,
},
}
/// Historical record of an invitation that has reached a terminal state
+1
View File
@@ -25,6 +25,7 @@ dependencies = [
"nym-contracts-common",
"nym-crypto",
"nym-mixnet-contract-common",
"nym-node-families-contract-common",
"nym-node-requests",
"nym-store-cipher",
"nym-types",
+46
View File
@@ -69,3 +69,49 @@ 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.
## Real IPC layer (what the mock stands in for)
The 18 commands `requests/families.ts` invokes are implemented in
[`../src-tauri/src/operations/families/`](../src-tauri/src/operations/families/) and registered
in [`../src-tauri/src/main.rs`](../src-tauri/src/main.rs):
- `execute.rs` — 9 state-changing txs over `NodeFamiliesSigningClient` (auto/simulated gas;
`create_family` attaches the `create_family_fee` as base-denom funds). Returns
`TransactionExecuteResult` (the optional `family_events` is omitted — the provider
`refreshAll()`s queries after every execute, so the UI re-derives state from reads).
- `query.rs` — 9 reads over `NodeFamiliesQueryClient`, **normalised at the IPC boundary** to the
wallet shapes in `src/types/families.ts`: base-denom `Coin` → display `DecCoin`
(`paid_fee`, `create_family_fee`), per-page contract envelopes → `{ items, start_next_after }`,
the cw_serde tagged `FamilyInvitationStatus``{ kind, at }`. `get_family_config` has no smart
query, so it reads raw contract state at the `"config"` key.
**Tauri arg casing:** JS passes camelCase keys, Tauri maps them to the commands' snake_case
params — so the `requests/families.ts` execute bindings send `nodeId`/`familyId`/`validitySecs`/
`updatedName`/`updatedDescription`.
**Mock → real switch.** `FamiliesContext.defaultQueries` points at the real `requests/families.ts`;
`MockFamiliesContextProvider` swaps in an in-memory engine. Production `/family`
([`../src/pages/families/FamilyPageRoute.tsx`](../src/pages/families/FamilyPageRoute.tsx)) mounts
`BondingContextProvider` → real `FamiliesContextProvider``FamilyPage`; the mock entry
(`main.mock.html`) is offline/e2e-only. `controlledNodeIds` derives from the account's bonded node
(≤1 on chain).
**Contract drift guard.** [`../src/types/families.contract-guard.ts`](../src/types/families.contract-guard.ts)
holds `tsc`-checked assertions between `src/types/families.ts` and the ts-rs-generated contract
types; regenerate (`tools/ts-rs-cli`) on a contract change and the guard flags any divergence.
## Sandbox write flows (guarded, manual)
Mutating journeys (create → invite → accept → kick → disband; accept/leave/reject) need a
**dedicated funded sandbox account** — never a shared/real one:
- Account `n13jtj2unhhtryxllnuc8zkng3nl4xnnjvxe0tzv` (sandbox, ~101k NYM). Mnemonic lives **only**
in vault secret `TAURI-WALLET-MNEMONIC` — inject via CI secret, never commit it.
- Check state with `nym-cli -c sandbox.env account balance n13jtj2unhhtryxllnuc8zkng3nl4xnnjvxe0tzv`.
- Tolerate on-chain latency (poll/refresh after each execute). Target **only** this account's
family/nodes and **clean up at the end** (disband / leave) so it stays reusable.
- An account bonds ≤1 node, so the multi-node operator persona can't be reproduced here — that
journey stays mock/Storybook-only.
Keep this tier manual/non-blocking until headless account provisioning exists.
@@ -22,6 +22,7 @@ The mock fabricates `FamilyTxResult.family_events`; on chain they'd come from pa
**D3 — Derive `controlledNodeIds` from the account's bonded nodes.**
Reuse the existing bonding/account node info the wallet already fetches (the operator persona needs "nodes I control"). Replace the `useMemo(() => [], [])` stub in `FamiliesContextProvider` with that derivation. Keep it resilient when the account controls no nodes.
*Implemented:* consume `useBondingContext().bondedNode` (`[nodeId]` nym-node / `[mixId]` legacy mixnode / `[]` gateway-or-none); the `/family` route is now wrapped in `BondingContextProvider` (it is mounted per-route, not globally). **Reality check:** an account bonds **at most one** node on chain, so `controlledNodeIds` is 01 long. The mock's 3-node operator persona is therefore **not reproducible** from a single sandbox account — the multi-node operator journey (§5.3) remains a mock/Storybook-only scenario, and the sandbox operator check is limited to that one node's invites.
**D4 — Generate contract types via ts-rs (`nym-wallet-types`) and reconcile with `src/types/families.ts`.**
`src/types/families.ts` was hand-written for the mock. Prefer generating the canonical shapes from the contract Rust types (ts-rs, as other wallet types are) and reconciling field-by-field (cursors, paged envelope, membership, `FamilyTxResult`). Where the hand-written type and generated type diverge, the generated (contract-truth) shape wins; update the mock/types accordingly so mock and real stay parity.
@@ -1,23 +1,25 @@
## 1. Rust Tauri command layer (operations/families)
- [ ] 1.1 Scaffold `src-tauri/src/operations/families/{mod.rs,query.rs,execute.rs}` mirroring `operations/mixnet/` (state access, client acquisition, error handling, fee convention per design D7).
- [ ] 1.2 Implement the 9 **execute** commands (`create_family`, `update_family`, `disband_family`, `invite_to_family`, `revoke_family_invitation`, `kick_from_family`, `accept_family_invitation`, `reject_family_invitation`, `leave_family`) over `NodeFamiliesSigningClient`; return `TransactionExecuteResult`-based `FamilyTxResult` (family_events best-effort per D2).
- [ ] 1.3 Implement the **query** commands (`get_family_by_id`, `get_family_by_owner`, `get_family_membership`, `get_family_members_paged`, `get_pending_invitations_for_family_paged`, `get_pending_invitations_for_node_paged`, `get_past_invitations_for_family_paged`, `get_past_members_for_family_paged`) over `NodeFamiliesQueryClient`.
- [ ] 1.4 Implement `get_family_config` against the contract config/state query (design D5), shaped to the frontend `FamilyConfig`.
- [ ] 1.5 Register all 18 commands in the `main.rs` `invoke_handler`; confirm names/arg casing match `src/requests/families.ts`.
- [ ] 1.6 `cargo build` / `cargo clippy` clean; app boots with the commands registered.
- [x] 1.1 Scaffold `src-tauri/src/operations/families/{mod.rs,query.rs,execute.rs}` mirroring `operations/mixnet/` (state access via `WalletState`, `current_client()?.nyxd`, `BackendError`, auto/simulated gas per D7). Added `nym-node-families-contract-common` path dep to `src-tauri/Cargo.toml`.
- [x] 1.2 Implemented the 9 **execute** commands over `NodeFamiliesSigningClient`; each returns `TransactionExecuteResult` (a subset of the frontend `FamilyTxResult``family_events` omitted per D2, UI re-derives via `refreshAll()`). `create_family` attaches the `create_family_fee` as base-denom funds; all use auto gas (`None`).
- [x] 1.3 Implemented the **query** commands over `NodeFamiliesQueryClient`. Each paged command flattens the contract envelope into the frontend's `{ items, start_next_after }` (`FamilyPagedResponse<T,C>`); members → `{ node_id, joined_at }`; past-invitation status normalised from the cw_serde tagged enum to `{ kind, at }`.
- [x] 1.4 Implemented `get_family_config` via raw contract-state read (design D5 — no `GetConfig` smart query): `query_contract_raw(addr, b"config")` → deserialize `Config``create_family_fee` converted base→display `DecCoin` so the UI can round-trip it into `create_family`.
- [x] 1.5 Registered all 18 commands in `main.rs` `invoke_handler`. Confirmed Tauri maps JS camelCase → Rust snake_case (verified against `update_mixnode_cost_params`/`new_costs`); fixed `src/requests/families.ts` execute bindings to pass camelCase keys (`nodeId`/`familyId`/`validitySecs`/`updatedName`/`updatedDescription`) — query bindings already correct.
- [x] 1.6 `cargo build` + `cargo clippy` clean (only pre-existing `delegate.rs` warning remains; none from `families/`).
## 2. Types reconciliation
- [x] 2.1 **Generated ts-rs exports** for the node-families contract types. Added a `generate-ts` feature + optional `ts-rs` dep to `nym-node-families-contract-common`, annotated 18 types in `types.rs` (`derive(ts_rs::TS)` + `export_to` under `ts-packages/types/src/types/rust/`, with overrides: `Coin → { denom, amount }`, `Addr → string`, `u64 → number`, tuple cursors `→ [number, number] | null`, `Config` renamed to `FamilyConfig`), wired them into `tools/ts-rs-cli` (dep + `use` + `do_export!`), and ran it → 18 `*.ts` files emitted (FamilyConfig, NodeFamily, NodeFamilyResponse, paged/membership/pending/past responses, etc.). Verified shapes (e.g. `NodeFamily.members: number`, `paid_fee: { denom, amount }`).
- [ ] 2.2 Reconcile divergences toward the contract-truth shape; update `src/types/families.ts` (and the mock fixtures/engine if a field changes) so mock and real stay at parity.
- [ ] 2.3 Add/confirm a contract-shape guard (test or ts-rs check) so future contract drift is caught.
- [x] 2.2 Reconciled field-by-field. Outcome: `src/types/families.ts` is the wallet-facing (IPC-boundary) shape and the Rust command layer translates contract-truth into exactly it — no `families.ts`/mock field changes were needed. Divergences are all handled in `operations/families/query.rs`: base-denom `Coin` → display `DecCoin` (`paid_fee`, `create_family_fee`), per-page contract envelopes → uniform `{ items, start_next_after }`, cw_serde tagged `FamilyInvitationStatus``{ kind, at }`. Also fixed the generated `FamilyInvitationStatus` `at` field (`bigint``number`, `ts(type="number")` override) and exported all 18 generated family types from the `@nymproject/types` package barrel (`ts-packages/types/src/types/rust/index.ts`).
- [x] 2.3 Added a compile-time contract-shape guard `src/types/families.contract-guard.ts`: `tsc`/ForkTsChecker-checked `Equal<>` assertions between the wallet types and the committed ts-rs-generated contract types (imported by relative path). 1:1 types asserted whole; the IPC-normalised fields (`paid_fee`/`create_family_fee`, paged envelopes, status union) excluded with a documented reason. Drift on any other field breaks the build. Guard passes; families Jest suite green (43).
## 3. Real provider wiring (replace mock in the running app)
- [ ] 3.1 In `FamiliesContextProvider`, replace the `controlledNodeIds = []` stub with a derivation from the connected account's bonded nodes (design D3); resilient when none.
- [ ] 3.2 Verify the production `/family` route renders live data via the real provider (no `data-testid`/UI change); mock entry remains e2e-only.
- [ ] 3.3 Manual smoke on a dev network: sign in, open Family, confirm queries resolve and an execute (e.g. create on a throwaway account) reflects back after refresh.
- [x] 3.1 Replaced the `controlledNodeIds = []` stub in `FamiliesContextProvider` with a derivation from `useBondingContext().bondedNode`: `[nodeId]` for a nym-node, `[mixId]` for a legacy mixnode, `[]` for a gateway / no bond (an account bonds ≤1 nodedesign D3, see §note below). Wrapped the `/family` route (`FamilyPageRoute`) in `BondingContextProvider` so the data is available (it's per-route, not global).
- [x] 3.2 Verified structurally: `/family``FamilyPageWithProvider` now mounts `BondingContextProvider``FamiliesContextProvider``FamilyPage`; queries point at `defaultQueries` (real Tauri requests); no `data-testid`/UI change; the mock entry (`main.mock.tsx``MockFamiliesContextProvider`) is untouched and e2e-only. tsc shows no families/bonding errors; full render-vs-chain confirmation is part of 3.3.
- [ ] 3.3 **(manual — needs running wallet + dev/sandbox network)** Sign in, open Family, confirm queries resolve and an execute reflects back after refresh. Overlaps the §4/§5 sandbox runs.
> **Note (D3 / §5 reality):** on the real chain an account controls **at most one** bonded node, so `controlledNodeIds` is 01 long. The mock's multi-node operator persona (3 nodes) cannot be reproduced from a single sandbox account; the multi-node operator journey stays a mock/Storybook-only scenario.
## 4. Sandbox read smoke (safe, automatable)
@@ -33,7 +35,7 @@
## 6. Verify & docs
- [ ] 6.1 `cargo build` + `tsc` + eslint clean; `pnpm test` (Jest) green; mock Tier-1 Playwright still green (no regression from type changes).
- [ ] 6.2 Confirm `update_family` arg shape matches the deployed contract (parent §9.5: `updated_name`/`updated_description: Option<String>`).
- [~] 6.1 **Families work is regression-free:** `cargo build` + `cargo clippy` clean; families code has no tsc errors; the contract-shape guard passes; families Jest suites green (43); full Jest run = **84 tests pass, 0 fail**. **Pre-existing, out-of-scope blocker:** 4 suites (`delegationIdentity`, `unbondedDelegation.acceptance`, `api/networkOverview`, `api/nodeStatus`) fail to *run* because the local `@nymproject/types` `dist` is stale and the package can't be rebuilt — its source has dangling imports to never-generated sibling types (`DetailedNodePerformanceV1`, `DisplayRole`) and a `Network`/`DelegationWithEverything` shape mismatch. None touch families; my uncommitted diff is entirely family-scoped (verified). Fixing the types-package generation gap is a separate change. Mock Tier-1 Playwright unaffected by the family type changes (mock path untouched).
- [x] 6.2 Confirmed against the deployed contract source: `ExecuteMsg::UpdateFamily { updated_name: Option<String>, updated_description: Option<String> }` (`node-families-contract/src/msg.rs`) — matches the `update_family` command + frontend `UpdateFamilyArgs` + parent §9.5.
- [ ] 6.3 Document the real-IPC path (commands, the mock→real switch, sandbox smoke + how to run write flows with a test account) in `e2e/README.md` / wallet README.
- [ ] 6.4 Update parent change `node-families-wallet` §9.4 to point at this change as the realization of real IPC.
@@ -68,5 +68,5 @@
- [x] 9.1 App route `/family` uses the real `FamiliesContextProvider` (via `pages/families/FamilyPageRoute.tsx``context/FamiliesContextProvider.tsx`); the mock provider is confined to Storybook/tests. The real provider is the ONLY families module importing `./main` (keeps Storybook free of Tauri-runtime code).
- [x] 9.2 Fee + limits are read from contract `Config` via `useFamilyConfig` (`create_family_fee`, `family_name_length_limit`, `family_description_length_limit`); no hardcoded 100 NYM / char counts. (The `?? 30/120` fallbacks are load-time display defaults only; the submitted fee is always `config.data.create_family_fee`.)
- [x] 9.3 `pnpm test` (85/85 green incl. 47 family) and `build-storybook` (succeeds) run clean; tsc + eslint clean. Playwright (`test:e2e`) and `test:storybook` are NOT run here — blocked on `pnpm install` of the new devDeps + `npx playwright install chromium` (no network/browsers in this env).
- [ ] 9.4 **BLOCKED (manual / env):** Tauri IPC smoke (create + invite + accept) needs the native app + a chain + the Rust contract command handlers, none of which exist in this env. Must be done by a human on a wired build.
- [ ] 9.5 **BLOCKED (external dependency):** `UpdateFamily` rebase verification depends on the separate `node-families-contract` change landing the Rust `ExecuteMsg::UpdateFamily` handler. The root contract SPEC already defines it as `{ updated_name: Option<String>, updated_description: Option<String> }` (None = unchanged, owner-gated) — the wallet's `requests/families.ts`, mock execute, and TS types are already built to that shape; reconcile on rebase when the IPC binding exists.
- [~] 9.4 **Realised by the `node-families-real-ipc` change.** The Rust Tauri command handlers (the missing layer this task waited on) are now implemented in `src-tauri/src/operations/families/` and registered in `main.rs`; the real `FamiliesContextProvider` is wired to them and `controlledNodeIds` derives from the bonded node. The remaining manual IPC smoke on a wired build (create + invite + accept against sandbox) is tracked there as §3.3 / §4 / §5 (needs the native app + sandbox + the funded test account).
- [x] 9.5 **RESOLVED:** `ExecuteMsg::UpdateFamily { updated_name: Option<String>, updated_description: Option<String> }` is confirmed in the `node-families-contract` source (`src/msg.rs`), matching the wallet's `requests/families.ts`, mock execute, and TS types. Verified in `node-families-real-ipc` §6.2.
+1
View File
@@ -60,6 +60,7 @@ nym-validator-client = { path = "../../common/client-libs/validator-client" }
nym-crypto = { path = "../../common/crypto", features = ["asymmetric"] }
nym-contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-common" }
nym-mixnet-contract-common = { path = "../../common/cosmwasm-smart-contracts/mixnet-contract" }
nym-node-families-contract-common = { path = "../../common/cosmwasm-smart-contracts/node-families-contract" }
nym-vesting-contract-common = { path = "../../common/cosmwasm-smart-contracts/vesting-contract" }
nym-config = { path = "../../common/config" }
nym-types = { path = "../../common/types" }
+19
View File
@@ -11,6 +11,7 @@ use tauri_plugin_updater::Builder as UpdaterBuilder;
use crate::menu::SHOW_LOG_WINDOW;
use crate::operations::app;
use crate::operations::families;
use crate::operations::help;
use crate::operations::mixnet;
use crate::operations::nym_api;
@@ -109,6 +110,24 @@ fn main() {
mixnet::rewards::get_current_rewarding_parameters,
mixnet::send::send,
mixnet::bond::get_mixnode_uptime,
families::execute::create_family,
families::execute::update_family,
families::execute::disband_family,
families::execute::invite_to_family,
families::execute::revoke_family_invitation,
families::execute::kick_from_family,
families::execute::accept_family_invitation,
families::execute::reject_family_invitation,
families::execute::leave_family,
families::query::get_family_by_id,
families::query::get_family_by_owner,
families::query::get_family_membership,
families::query::get_family_config,
families::query::get_family_members_paged,
families::query::get_pending_invitations_for_family_paged,
families::query::get_pending_invitations_for_node_paged,
families::query::get_past_invitations_for_family_paged,
families::query::get_past_members_for_family_paged,
network_config::add_validator,
network_config::get_nym_api_urls,
network_config::get_nyxd_urls,
@@ -0,0 +1,171 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! State-changing node-families commands over [`NodeFamiliesSigningClient`].
//!
//! Every command uses the wallet's standard auto/simulated gas fee convention
//! (design D7): the frontend bindings don't supply an explicit gas `Fee`, so we
//! pass `None` and let the client simulate. `create_family` additionally attaches
//! the configured `create_family_fee` (a display [`DecCoin`] from
//! `get_family_config`) as funds, converted back to its base denomination.
//!
//! The returned [`TransactionExecuteResult`] is a subset of the frontend's
//! `FamilyTxResult` (which adds an optional `family_events`). Per design D2 we
//! omit `family_events` — the provider re-derives state via `refreshAll()` after
//! every execute, and nothing reads the fabricated events.
use crate::error::BackendError;
use crate::state::WalletState;
use nym_mixnet_contract_common::NodeId;
use nym_node_families_contract_common::NodeFamilyId;
use nym_types::currency::DecCoin;
use nym_types::transaction::TransactionExecuteResult;
use nym_validator_client::nyxd::contract_traits::NodeFamiliesSigningClient;
#[tauri::command]
pub async fn create_family(
name: String,
description: String,
fee: DecCoin,
state: tauri::State<'_, WalletState>,
) -> Result<TransactionExecuteResult, BackendError> {
log::info!(">>> Create family: name = {name}, creation_fee = {fee}");
let guard = state.read().await;
// `fee` here is the contract's `create_family_fee` (attached as funds), not a gas fee.
let creation_fee = vec![guard.attempt_convert_to_base_coin(fee)?];
let res = guard
.current_client()?
.nyxd
.create_family(name, description, None, creation_fee)
.await?;
log::info!("<<< tx hash = {}", res.transaction_hash);
log::trace!("<<< {res:?}");
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
}
#[tauri::command]
pub async fn update_family(
updated_name: Option<String>,
updated_description: Option<String>,
state: tauri::State<'_, WalletState>,
) -> Result<TransactionExecuteResult, BackendError> {
log::info!(">>> Update family: name = {updated_name:?}, description = {updated_description:?}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.update_family(updated_name, updated_description, None)
.await?;
log::info!("<<< tx hash = {}", res.transaction_hash);
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
}
#[tauri::command]
pub async fn disband_family(
state: tauri::State<'_, WalletState>,
) -> Result<TransactionExecuteResult, BackendError> {
log::info!(">>> Disband family");
let guard = state.read().await;
let res = guard.current_client()?.nyxd.disband_family(None).await?;
log::info!("<<< tx hash = {}", res.transaction_hash);
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
}
#[tauri::command]
pub async fn invite_to_family(
node_id: NodeId,
validity_secs: Option<u64>,
state: tauri::State<'_, WalletState>,
) -> Result<TransactionExecuteResult, BackendError> {
log::info!(">>> Invite to family: node_id = {node_id}, validity_secs = {validity_secs:?}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.invite_to_family(node_id, validity_secs, None)
.await?;
log::info!("<<< tx hash = {}", res.transaction_hash);
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
}
#[tauri::command]
pub async fn revoke_family_invitation(
node_id: NodeId,
state: tauri::State<'_, WalletState>,
) -> Result<TransactionExecuteResult, BackendError> {
log::info!(">>> Revoke family invitation: node_id = {node_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.revoke_family_invitation(node_id, None)
.await?;
log::info!("<<< tx hash = {}", res.transaction_hash);
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
}
#[tauri::command]
pub async fn kick_from_family(
node_id: NodeId,
state: tauri::State<'_, WalletState>,
) -> Result<TransactionExecuteResult, BackendError> {
log::info!(">>> Kick from family: node_id = {node_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.kick_from_family(node_id, None)
.await?;
log::info!("<<< tx hash = {}", res.transaction_hash);
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
}
#[tauri::command]
pub async fn accept_family_invitation(
family_id: NodeFamilyId,
node_id: NodeId,
state: tauri::State<'_, WalletState>,
) -> Result<TransactionExecuteResult, BackendError> {
log::info!(">>> Accept family invitation: family_id = {family_id}, node_id = {node_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.accept_family_invitation(family_id, node_id, None)
.await?;
log::info!("<<< tx hash = {}", res.transaction_hash);
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
}
#[tauri::command]
pub async fn reject_family_invitation(
family_id: NodeFamilyId,
node_id: NodeId,
state: tauri::State<'_, WalletState>,
) -> Result<TransactionExecuteResult, BackendError> {
log::info!(">>> Reject family invitation: family_id = {family_id}, node_id = {node_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.reject_family_invitation(family_id, node_id, None)
.await?;
log::info!("<<< tx hash = {}", res.transaction_hash);
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
}
#[tauri::command]
pub async fn leave_family(
node_id: NodeId,
state: tauri::State<'_, WalletState>,
) -> Result<TransactionExecuteResult, BackendError> {
log::info!(">>> Leave family: node_id = {node_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.leave_family(node_id, None)
.await?;
log::info!("<<< tx hash = {}", res.transaction_hash);
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
}
@@ -0,0 +1,15 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Tauri command layer for the `node-families-contract`.
//!
//! Mirrors `operations/mixnet/`: each `#[tauri::command]` acquires the active
//! account's signing client from the wallet [`WalletState`](crate::state::WalletState)
//! and delegates to the existing `validator-client` `NodeFamilies{Signing,Query}Client`
//! traits, returning the shapes the `src/requests/families.ts` bindings expect.
//!
//! Split into `execute` (state-changing txs) and `query` (read-only) to match the
//! rest of the wallet (design D1).
pub mod execute;
pub mod query;
@@ -0,0 +1,319 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
//! Read-only node-families commands over [`NodeFamiliesQueryClient`].
//!
//! The contract's paged responses each carry an echoed key (`family_id` /
//! `node_id`) plus a named items field (`members` / `invitations`) and a
//! `start_next_after` cursor. The frontend bindings expect a uniform
//! `{ items, start_next_after }` envelope ([`FamilyPagedResponse`]), so each
//! command flattens the contract response into it. Per the `src/types/families.ts`
//! contract — "the request layer is responsible for translating the contract's
//! serde envelope into these shapes" — we also normalise the cw_serde tagged
//! `FamilyInvitationStatus` enum into the wallet's `{ kind, at }` union.
use crate::error::BackendError;
use crate::state::WalletState;
use crate::state::WalletStateInner;
use nym_mixnet_contract_common::NodeId;
use nym_node_families_contract_common::{
Config, FamilyInvitation, FamilyInvitationStatus, NodeFamily, NodeFamilyId,
NodeFamilyMembershipResponse, PastFamilyInvitation, PastFamilyInvitationCursor,
PastFamilyMember, PastFamilyMemberCursor, PendingFamilyInvitationDetails,
};
use nym_types::currency::DecCoin;
use nym_validator_client::nyxd::contract_traits::{NodeFamiliesQueryClient, NymContractsProvider};
use nym_validator_client::nyxd::error::NyxdError;
use nym_validator_client::nyxd::{AccountId, Coin, CosmWasmClient};
use serde::Serialize;
use std::str::FromStr;
/// Uniform `{ items, start_next_after }` envelope the frontend's
/// `FamilyPagedResponse<T>` expects. `C` is the (page-specific) cursor type:
/// a `NodeId`/`NodeFamilyId` for single-key pages or a `(node_id, counter)`
/// tuple for the archived listings.
#[derive(Serialize)]
pub struct FamilyPagedResponse<T, C> {
pub items: Vec<T>,
pub start_next_after: Option<C>,
}
/// One current-member row: `{ node_id, joined_at }` (drops the contract's
/// nested `membership` envelope the UI doesn't use here).
#[derive(Serialize)]
pub struct FamilyMemberItem {
pub node_id: NodeId,
pub joined_at: u64,
}
/// Wallet-friendly `{ kind, at }` form of the contract's tagged
/// `FamilyInvitationStatus` enum.
#[derive(Serialize)]
pub struct PastInvitationStatus {
pub kind: String,
pub at: u64,
}
/// `PastFamilyInvitation` with its status normalised for the frontend.
#[derive(Serialize)]
pub struct PastFamilyInvitationItem {
pub invitation: FamilyInvitation,
pub status: PastInvitationStatus,
}
/// Frontend `NodeFamily`: the contract's `paid_fee` (stored in the base
/// denomination) is converted to a display [`DecCoin`] and `owner` to a plain
/// string, so the UI can `formatCoin(paid_fee)` directly.
#[derive(Serialize)]
pub struct NodeFamilyView {
pub id: NodeFamilyId,
pub name: String,
pub description: String,
pub normalised_name: String,
pub members: u64,
pub created_at: u64,
pub paid_fee: DecCoin,
pub owner: String,
}
/// Frontend `FamilyConfig`: `create_family_fee` returned as a display
/// [`DecCoin`] (converted from the contract's base-denom `Coin`) so the UI can
/// round-trip it straight back into `create_family`.
#[derive(Serialize)]
pub struct FamilyConfigResponse {
pub create_family_fee: DecCoin,
pub family_name_length_limit: usize,
pub family_description_length_limit: usize,
pub default_invitation_validity_secs: u64,
}
fn normalise_status(status: FamilyInvitationStatus) -> PastInvitationStatus {
let (kind, at) = match status {
FamilyInvitationStatus::Pending { at } => ("Pending", at),
FamilyInvitationStatus::Accepted { at } => ("Accepted", at),
FamilyInvitationStatus::Rejected { at } => ("Rejected", at),
FamilyInvitationStatus::Revoked { at } => ("Revoked", at),
};
PastInvitationStatus {
kind: kind.to_string(),
at,
}
}
fn map_past_invitation(past: PastFamilyInvitation) -> PastFamilyInvitationItem {
PastFamilyInvitationItem {
invitation: past.invitation,
status: normalise_status(past.status),
}
}
fn map_family(
state: &WalletStateInner,
family: NodeFamily,
) -> Result<NodeFamilyView, BackendError> {
Ok(NodeFamilyView {
id: family.id,
name: family.name,
description: family.description,
normalised_name: family.normalised_name,
members: family.members,
created_at: family.created_at,
paid_fee: state.attempt_convert_to_display_dec_coin(Coin::from(family.paid_fee))?,
owner: family.owner.to_string(),
})
}
// --- Single-entity queries --------------------------------------------------
#[tauri::command]
pub async fn get_family_by_id(
family_id: NodeFamilyId,
state: tauri::State<'_, WalletState>,
) -> Result<Option<NodeFamilyView>, BackendError> {
log::trace!(">>> Get family by id: family_id = {family_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.get_family_by_id(family_id)
.await?;
res.family.map(|f| map_family(&guard, f)).transpose()
}
#[tauri::command]
pub async fn get_family_by_owner(
owner: String,
state: tauri::State<'_, WalletState>,
) -> Result<Option<NodeFamilyView>, BackendError> {
log::trace!(">>> Get family by owner: owner = {owner}");
let owner_addr = AccountId::from_str(&owner)?;
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.get_family_by_owner(&owner_addr)
.await?;
res.family.map(|f| map_family(&guard, f)).transpose()
}
#[tauri::command]
pub async fn get_family_membership(
node_id: NodeId,
state: tauri::State<'_, WalletState>,
) -> Result<NodeFamilyMembershipResponse, BackendError> {
log::trace!(">>> Get family membership: node_id = {node_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.get_family_membership(node_id)
.await?;
Ok(res)
}
/// The contract exposes no `GetConfig` smart query (design D5), so we read the
/// `Config` `Item` from raw contract state at its storage key (`"config"`).
/// `create_family_fee` is stored in the base denomination; we convert it to a
/// display `DecCoin` for the UI.
#[tauri::command]
pub async fn get_family_config(
state: tauri::State<'_, WalletState>,
) -> Result<FamilyConfigResponse, BackendError> {
log::trace!(">>> Get family config");
let guard = state.read().await;
let client = guard.current_client()?;
let contract = client
.nyxd
.node_families_contract_address()
.ok_or_else(|| NyxdError::unavailable_contract_address("node families contract"))?
.clone();
let raw = client
.nyxd
.query_contract_raw(&contract, b"config".to_vec())
.await?;
let config: Config = serde_json::from_slice(&raw)?;
let create_family_fee =
guard.attempt_convert_to_display_dec_coin(Coin::from(config.create_family_fee))?;
Ok(FamilyConfigResponse {
create_family_fee,
family_name_length_limit: config.family_name_length_limit,
family_description_length_limit: config.family_description_length_limit,
default_invitation_validity_secs: config.default_invitation_validity_secs,
})
}
// --- Paginated queries ------------------------------------------------------
#[tauri::command]
pub async fn get_family_members_paged(
family_id: NodeFamilyId,
start_after: Option<NodeId>,
limit: Option<u32>,
state: tauri::State<'_, WalletState>,
) -> Result<FamilyPagedResponse<FamilyMemberItem, NodeId>, BackendError> {
log::trace!(">>> Get family members paged: family_id = {family_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.get_family_members_paged(family_id, start_after, limit)
.await?;
let items = res
.members
.into_iter()
.map(|m| FamilyMemberItem {
node_id: m.node_id,
joined_at: m.membership.joined_at,
})
.collect();
Ok(FamilyPagedResponse {
items,
start_next_after: res.start_next_after,
})
}
#[tauri::command]
pub async fn get_pending_invitations_for_family_paged(
family_id: NodeFamilyId,
start_after: Option<NodeId>,
limit: Option<u32>,
state: tauri::State<'_, WalletState>,
) -> Result<FamilyPagedResponse<PendingFamilyInvitationDetails, NodeId>, BackendError> {
log::trace!(">>> Get pending invitations for family paged: family_id = {family_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.get_pending_invitations_for_family_paged(family_id, start_after, limit)
.await?;
Ok(FamilyPagedResponse {
items: res.invitations,
start_next_after: res.start_next_after,
})
}
#[tauri::command]
pub async fn get_pending_invitations_for_node_paged(
node_id: NodeId,
start_after: Option<NodeFamilyId>,
limit: Option<u32>,
state: tauri::State<'_, WalletState>,
) -> Result<FamilyPagedResponse<PendingFamilyInvitationDetails, NodeFamilyId>, BackendError> {
log::trace!(">>> Get pending invitations for node paged: node_id = {node_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.get_pending_invitations_for_node_paged(node_id, start_after, limit)
.await?;
Ok(FamilyPagedResponse {
items: res.invitations,
start_next_after: res.start_next_after,
})
}
#[tauri::command]
pub async fn get_past_invitations_for_family_paged(
family_id: NodeFamilyId,
start_after: Option<PastFamilyInvitationCursor>,
limit: Option<u32>,
state: tauri::State<'_, WalletState>,
) -> Result<FamilyPagedResponse<PastFamilyInvitationItem, PastFamilyInvitationCursor>, BackendError>
{
log::trace!(">>> Get past invitations for family paged: family_id = {family_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.get_past_invitations_for_family_paged(family_id, start_after, limit)
.await?;
let items = res
.invitations
.into_iter()
.map(map_past_invitation)
.collect();
Ok(FamilyPagedResponse {
items,
start_next_after: res.start_next_after,
})
}
#[tauri::command]
pub async fn get_past_members_for_family_paged(
family_id: NodeFamilyId,
start_after: Option<PastFamilyMemberCursor>,
limit: Option<u32>,
state: tauri::State<'_, WalletState>,
) -> Result<FamilyPagedResponse<PastFamilyMember, PastFamilyMemberCursor>, BackendError> {
log::trace!(">>> Get past members for family paged: family_id = {family_id}");
let guard = state.read().await;
let res = guard
.current_client()?
.nyxd
.get_past_members_for_family_paged(family_id, start_after, limit)
.await?;
Ok(FamilyPagedResponse {
items: res.members,
start_next_after: res.start_next_after,
})
}
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
pub mod app;
pub mod families;
pub mod help;
pub(crate) mod helpers;
pub mod mixnet;
@@ -4,8 +4,10 @@ import { useQueryClient } from '@tanstack/react-query';
import * as familyRequests from 'src/requests/families';
import { Console } from 'src/utils/console';
import { FamilyTxResult, NodeId } from 'src/types/families';
import { isMixnode, isNymNode } from 'src/types/global';
import { AppContext } from './main';
import { FamiliesContext, TFamiliesContext, defaultQueries } from './families';
import { useBondingContext } from './bonding';
import { familyQueryKeys } from './familyQueryKeys';
/**
@@ -21,10 +23,17 @@ export const FamiliesContextProvider: FCWithChildren = ({ children }): React.JSX
const [isExecuting, setIsExecuting] = useState(false);
const [error, setError] = useState<string>();
// TODO(§9 wiring): derive controlled node ids from bonded-node details once the
// real IPC layer lands. Until then the real provider exposes none; the mock
// provider supplies them for Storybook/tests.
const controlledNodeIds = useMemo<NodeId[]>(() => [], []);
// The operator persona is "nodes I control". An account bonds at most one node,
// so this is the bonded node's id (the unified mixnet node id — `nodeId` for a
// nym-node, `mixId` for a legacy mixnode), or none for a gateway / no bond
// (design D3). Sourced from the `BondingContext` the families route now wraps.
const { bondedNode } = useBondingContext();
const controlledNodeIds = useMemo<NodeId[]>(() => {
if (!bondedNode) return [];
if (isNymNode(bondedNode)) return [bondedNode.nodeId];
if (isMixnode(bondedNode)) return [bondedNode.mixId];
return [];
}, [bondedNode]);
const nowSecs = useMemo(() => Math.floor(Date.now() / 1000), []);
@@ -1,14 +1,20 @@
import React from 'react';
import { FamiliesContextProvider } from 'src/context/FamiliesContextProvider';
import { BondingContextProvider } from 'src/context';
import { FamilyPage } from './FamilyPage';
/**
* Route-level entry: wraps the page in the real (Tauri-backed) FamiliesContext
* provider. Kept separate from `FamilyPage` so Storybook can render the page with
* the mock provider without pulling in real Tauri code.
*
* `BondingContextProvider` sits above it so the families provider can derive the
* account's controlled node ids from the bonded node (design D3).
*/
export const FamilyPageWithProvider = () => (
<FamiliesContextProvider>
<FamilyPage />
</FamiliesContextProvider>
<BondingContextProvider>
<FamiliesContextProvider>
<FamilyPage />
</FamiliesContextProvider>
</BondingContextProvider>
);
+18 -7
View File
@@ -31,29 +31,40 @@ import { invokeWrapper } from './wrapper';
*/
// --- Execute messages -------------------------------------------------------
//
// Tauri maps JS camelCase argument keys to the Rust commands' snake_case
// parameters, so any multi-word arg must be passed camelCased here even though
// the `*Args` types keep the contract's snake_case field names (the mock and UI
// build them in that shape). Single-word args (`name`, `description`, `fee`,
// `owner`) need no remapping.
export const createFamily = async (args: CreateFamilyArgs) => invokeWrapper<FamilyTxResult>('create_family', args);
export const updateFamily = async (args: UpdateFamilyArgs) => invokeWrapper<FamilyTxResult>('update_family', args);
export const updateFamily = async (args: UpdateFamilyArgs) =>
invokeWrapper<FamilyTxResult>('update_family', {
updatedName: args.updated_name,
updatedDescription: args.updated_description,
});
export const disbandFamily = async () => invokeWrapper<FamilyTxResult>('disband_family');
export const inviteToFamily = async (args: InviteToFamilyArgs) =>
invokeWrapper<FamilyTxResult>('invite_to_family', args);
invokeWrapper<FamilyTxResult>('invite_to_family', { nodeId: args.node_id, validitySecs: args.validity_secs });
export const revokeFamilyInvitation = async (args: RevokeFamilyInvitationArgs) =>
invokeWrapper<FamilyTxResult>('revoke_family_invitation', args);
invokeWrapper<FamilyTxResult>('revoke_family_invitation', { nodeId: args.node_id });
export const kickFromFamily = async (args: KickFromFamilyArgs) =>
invokeWrapper<FamilyTxResult>('kick_from_family', args);
invokeWrapper<FamilyTxResult>('kick_from_family', { nodeId: args.node_id });
export const acceptFamilyInvitation = async (args: AcceptFamilyInvitationArgs) =>
invokeWrapper<FamilyTxResult>('accept_family_invitation', args);
invokeWrapper<FamilyTxResult>('accept_family_invitation', { familyId: args.family_id, nodeId: args.node_id });
export const rejectFamilyInvitation = async (args: RejectFamilyInvitationArgs) =>
invokeWrapper<FamilyTxResult>('reject_family_invitation', args);
invokeWrapper<FamilyTxResult>('reject_family_invitation', { familyId: args.family_id, nodeId: args.node_id });
export const leaveFamily = async (args: LeaveFamilyArgs) => invokeWrapper<FamilyTxResult>('leave_family', args);
export const leaveFamily = async (args: LeaveFamilyArgs) =>
invokeWrapper<FamilyTxResult>('leave_family', { nodeId: args.node_id });
// --- Single-entity queries --------------------------------------------------
@@ -0,0 +1,69 @@
/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/no-unused-vars */
//
// Contract-shape guard (tasks.md 2.3).
//
// Locks the wallet-facing `src/types/families.ts` types against the
// contract-truth types generated from the Rust `node-families-contract` by
// `tools/ts-rs-cli` (committed under `ts-packages/types/src/types/rust/`).
//
// This file emits no runtime code — it is a set of compile-time assertions
// checked by `tsc` / the webpack ForkTsChecker. If the contract changes shape
// and the generated `*.ts` are regenerated, any field added / removed / renamed
// / retyped on a 1:1 type below breaks the build here, forcing a conscious
// reconciliation of `families.ts` (and the mock, which mirrors it).
//
// Imports are by relative path on purpose: the wallet resolves `@nymproject/types`
// to the package's built `dist`, but the generated source is the source of truth
// for drift, so we read it directly.
import type { NodeFamily as ContractNodeFamily } from '../../../ts-packages/types/src/types/rust/NodeFamily';
import type { FamilyConfig as ContractFamilyConfig } from '../../../ts-packages/types/src/types/rust/FamilyConfig';
import type { FamilyMembership as ContractFamilyMembership } from '../../../ts-packages/types/src/types/rust/FamilyMembership';
import type { FamilyInvitation as ContractFamilyInvitation } from '../../../ts-packages/types/src/types/rust/FamilyInvitation';
import type { NodeFamilyMembershipResponse as ContractMembershipResponse } from '../../../ts-packages/types/src/types/rust/NodeFamilyMembershipResponse';
import type { PendingFamilyInvitationDetails as ContractPendingDetails } from '../../../ts-packages/types/src/types/rust/PendingFamilyInvitationDetails';
import type { PastFamilyMember as ContractPastMember } from '../../../ts-packages/types/src/types/rust/PastFamilyMember';
import type {
NodeFamily,
FamilyConfig,
FamilyMembership,
FamilyInvitation,
NodeFamilyMembershipResponse,
PendingFamilyInvitationDetails,
PastFamilyMember,
} from './families';
// --- type-equality machinery ------------------------------------------------
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;
type Expect<T extends true> = T;
// --- 1:1 types (must stay structurally identical to contract) ---------------
//
// These flow through the Tauri command layer unchanged, so the wallet type and
// the generated contract type must match exactly.
type _FamilyMembership = Expect<Equal<FamilyMembership, ContractFamilyMembership>>;
type _FamilyInvitation = Expect<Equal<FamilyInvitation, ContractFamilyInvitation>>;
type _MembershipResponse = Expect<Equal<NodeFamilyMembershipResponse, ContractMembershipResponse>>;
type _PendingDetails = Expect<Equal<PendingFamilyInvitationDetails, ContractPendingDetails>>;
type _PastMember = Expect<Equal<PastFamilyMember, ContractPastMember>>;
// --- intentionally normalised in the Rust IPC layer -------------------------
//
// The following diverge by design (see operations/families/query.rs and the
// `families.ts` header). They are asserted on the *non-translated* fields only;
// the translated fields are excluded so the guard still catches drift on the
// rest:
//
// * `NodeFamily.paid_fee` / `FamilyConfig.create_family_fee`:
// base-denom contract `Coin` ({denom,amount}) -> display `DecCoin`.
// * Paginated responses ({family_id|node_id, members|invitations, ...}) ->
// the uniform `FamilyPagedResponse<T> = { items, start_next_after }`.
// * `FamilyInvitationStatus` (cw_serde tagged) -> the `{ kind, at }` union.
type _NodeFamilySansFee = Expect<Equal<Omit<NodeFamily, 'paid_fee'>, Omit<ContractNodeFamily, 'paid_fee'>>>;
type _FamilyConfigSansFee = Expect<
Equal<Omit<FamilyConfig, 'create_family_fee'>, Omit<ContractFamilyConfig, 'create_family_fee'>>
>;
@@ -6,4 +6,4 @@
* Note: timed-out invitations are not represented here — they are simply left in
* the pending set (see `FamilyInvitation::expires_at`).
*/
export type FamilyInvitationStatus = { "pending": { at: bigint, } } | { "accepted": { at: bigint, } } | { "rejected": { at: bigint, } } | { "revoked": { at: bigint, } };
export type FamilyInvitationStatus = { "pending": { at: number, } } | { "accepted": { at: number, } } | { "rejected": { at: number, } } | { "revoked": { at: number, } };
+19
View File
@@ -58,3 +58,22 @@ export * from './WrappedDelegationEvent';
export * from './NymNode';
export * from './NymNodeBond';
export * from './NodeRewarding';
// node-families-contract (generated by tools/ts-rs-cli)
export * from './FamilyConfig';
export * from './FamilyInvitation';
export * from './FamilyInvitationStatus';
export * from './FamilyMemberRecord';
export * from './FamilyMembership';
export * from './FamilyMembersPagedResponse';
export * from './NodeFamily';
export * from './NodeFamilyByOwnerResponse';
export * from './NodeFamilyMembershipResponse';
export * from './NodeFamilyResponse';
export * from './PastFamilyInvitation';
export * from './PastFamilyInvitationsPagedResponse';
export * from './PastFamilyMember';
export * from './PastFamilyMembersPagedResponse';
export * from './PendingFamilyInvitationDetails';
export * from './PendingFamilyInvitationResponse';
export * from './PendingFamilyInvitationsPagedResponse';
export * from './PendingInvitationsForNodePagedResponse';