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:
@@ -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
|
||||
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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 0–1 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 node — design 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 0–1 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.
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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, } };
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user