diff --git a/CHANGELOG.md b/CHANGELOG.md index 1184768..ee4b708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,3 +23,36 @@ standalone JSON-RPC client. - File-based logging so output does not corrupt the terminal display, written to the platform log directory by default (overridable with `--log-file`). - Fluent-based localization. +- Ironwood pool support (Zallet v0.1.0-beta.2 / NU6.3): an Ironwood column in the + Balances view, and a notice flagging at-risk funds remaining in the sealed Orchard + pool. +- Sending via `z_sendfromaccount`: spends from the account UUID with an explicit fund + source selector (Orchard + Ironwood / Sapling / Transparent) and reports the broadcast + txids directly. `z_sendmany` remains only for ZIP 320 (TEX) recipients. +- A hint appended to privacy-policy send rejections, explaining that spending sealed + Orchard funds crosses into Ironwood and needs `AllowRevealedAmounts` or weaker. +- Cookie-file authentication (`--cookie-file` / `cookie_file`), using the `.cookie` + credential Zallet writes into its data directory on startup. +- `check` subcommand that queries the server's `rpc.discover` schema and reports any RPC + method the TUI needs that the server does not expose. +- Block datetimes (RFC 3339) in the transaction detail pane, falling back to the raw + Unix timestamp for older servers. + +### Changed + +- The TUI now targets Zallet v0.1.0-beta.2: the `z_getbalances` response models the + regular/coinbase transparent split (previously any account with transparent funds + failed to deserialize and broke the Balances view) and the `ironwood` pool. +- Zallet's global sync lock errors (`-10` catching up, `-2` reorg recovery) are treated + as the existing "still syncing" state instead of surfacing as error toasts, and the + new `getwalletstatus.locked` field is checked before submitting a send. +- `z_listtransactions` failures are shown in the Transactions view instead of being + silently swallowed as an empty list. +- Connection flags are global, so they may be given after a subcommand. + +### Fixed + +- Send operations are collected with `z_getoperationresult` once finished, so they no + longer accumulate in (and eventually overflow) Zallet's bounded operation queue; an + opid the wallet no longer tracks stops the polling loop instead of spinning forever. +- The README's example RPC port and username now match the built-in defaults. diff --git a/README.md b/README.md index 98d34be..2ce8d97 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ wallet in the TUI. It assumes you have both `zallet` and `zallet-tui` installed. ```toml [rpc] - bind = ["127.0.0.1:28232"] + bind = ["127.0.0.1:8232"] [[rpc.auth]] user = "zallet-tui" @@ -35,15 +35,20 @@ wallet in the TUI. It assumes you have both `zallet` and `zallet-tui` installed. Note the username and the password you chose (NOT the pwhash); `zallet-tui` needs the plaintext to authenticate. + Alternatively, skip creating an RPC user: Zallet writes a fresh `.cookie` credential + file into its data directory on every startup, and `zallet-tui --cookie-file + /.cookie` (or `cookie_file` in `zallet-tui.toml`) authenticates with it + directly. + 2. **Give `zallet-tui` matching credentials and the RPC URL.** Generate a starter config with `zallet-tui generate-config`, then edit it so `rpc_url` matches the `[rpc] bind` address above and the `[[auth]]` user/password match what you configured in step 1: ```toml - rpc_url = "http://127.0.0.1:28232" + rpc_url = "http://127.0.0.1:8232" [[auth]] - user = "zallet" + user = "zallet-tui" password = "the-password-you-used-in-step-1" ``` @@ -74,7 +79,27 @@ wallet in the TUI. It assumes you have both `zallet` and `zallet-tui` installed. transactions, and sending become available. If the wallet is still syncing when you launch, the dashboard shows the sync progress; -balances and history populate once it catches up. +balances and history populate once it catches up. (Newer Zallet releases refuse balance +and spend calls entirely while far behind the chain tip; the TUI shows this as the same +syncing state.) + +## Compatibility + +`zallet-tui` targets Zallet v0.1.0-beta.2 or later. Zallet's JSON-RPC surface is still +pre-1.0 and changes between releases; after upgrading Zallet, run + +```sh +zallet-tui check +``` + +to verify that the server still exposes every RPC method the TUI uses. + +Since the NU6.3 network upgrade ("Ironwood", July 2026), the Orchard shielded pool is +sealed and a new Ironwood pool receives funds sent to Orchard receivers. The TUI shows +Ironwood as its own balance column, flags remaining (at-risk) Orchard funds, and sends +with an explicit fund source — note that moving sealed Orchard funds crosses into +Ironwood, which publicly reveals the crossing amount and therefore requires the +`AllowRevealedAmounts` privacy policy or weaker. ## Usage diff --git a/i18n/en-US/zallet_tui.ftl b/i18n/en-US/zallet_tui.ftl index f561c89..1cdcfe5 100644 --- a/i18n/en-US/zallet_tui.ftl +++ b/i18n/en-US/zallet_tui.ftl @@ -64,6 +64,7 @@ tui-toast-account-created = Created account '{$name}' tui-toast-send-completed = Send completed tui-toast-send-cancelled = Send cancelled tui-err-send-failed = Send failed: {$error} +tui-err-send-op-lost = Operation {$opid} is no longer tracked by the wallet; check the Transactions view for the outcome. tui-err-unknown = unknown error # Logs view (toasts / placeholders) @@ -163,15 +164,18 @@ tui-bal-header-account = Account tui-bal-header-transparent = Transparent tui-bal-header-sapling = Sapling tui-bal-header-orchard = Orchard +tui-bal-header-ironwood = Ironwood tui-bal-header-pending = Pending tui-bal-header-total = Total tui-bal-title = Balances (minconf = {$minconf} [+/-] to change) +tui-bal-orchard-sealed = ⚠ The Orchard pool is sealed (NU6.3): these funds can only move to your own Ironwood pool, and the crossing amount is public (needs the AllowRevealedAmounts policy). tui-bal-syncing = Balances are not available yet — the wallet is still syncing. tui-bal-empty = No balances to display. # Transactions view tui-tx-title = Transactions (page {$page}, [ / ] to page · experimental) tui-tx-empty = No transactions to display. +tui-tx-error = Could not load transactions (z_listtransactions is experimental): {$error} tui-tx-unmined = unmined tui-tx-detail-title = Detail tui-tx-field-txid = txid @@ -186,6 +190,7 @@ tui-tx-select = Select a transaction. # Send view tui-send-cancelled = Send cancelled tui-send-err-no-accounts = No accounts available to send from +tui-send-err-syncing = Wallet is still catching up to the chain tip; sending is unavailable until it is synced tui-send-err-no-source = Select a source account tui-send-err-no-spendable = Selected account has no spendable address tui-send-err-recipient-required = Recipient address is required @@ -193,7 +198,16 @@ tui-send-err-amount-required = Amount is required tui-send-err-amount-nan = Amount must be a number tui-send-err-no-source-selected = No source account selected tui-send-submitted = Submitted (op {$opid}) +tui-send-err-in-flight = A send is already in progress; wait for it to finish +tui-toast-send-started = Send submitted; building and proving the transaction… +tui-err-send-task-failed = The send task ended unexpectedly; check the Transactions view for the outcome. +tui-send-sending = Sending… +tui-send-sending-hint = Building, proving, and broadcasting can take a while; the wallet confirms when done. tui-send-from = From +tui-send-fund-source = Funds +tui-send-fund-orchard = Shielded (Orchard + Ironwood) +tui-send-fund-sapling = Shielded (Sapling) +tui-send-fund-transparent = Transparent tui-send-to = To tui-send-amount = Amount (ZEC) tui-send-memo = Memo @@ -202,6 +216,7 @@ tui-send-review = [ Review & send ] tui-send-no-spendable-suffix = (no spendable address) tui-send-fees-note = Fees are computed automatically (ZIP-317). tui-send-privacy-warning = ⚠ This policy reduces privacy. Only proceed if you understand the implications. +tui-send-policy-hint = Tip: since NU6.3, spending sealed Orchard funds crosses into Ironwood and reveals the amount — select AllowRevealedAmounts (or weaker) in the Privacy policy field to allow it. tui-send-hint-editing = EDITING — type to enter text · Enter/Esc to finish tui-send-hint-text = ↑↓ move · Enter to edit this field · Esc to tabs tui-send-hint-submit = ↑↓ move · Enter to review & send · Esc to tabs @@ -210,6 +225,7 @@ tui-send-title = Send tui-send-operation-title = Operation tui-send-confirm = Confirm send? tui-send-confirm-summary = {$amount} ZEC from {$from} → {$to} +tui-send-confirm-tex = TEX recipient (ZIP 320): sent from the account's address via z_sendmany; the Funds selection does not apply. tui-send-confirm-hint = [y] yes [n] no tui-send-queued = queued tui-send-operation = Operation {$opid} diff --git a/src/app.rs b/src/app.rs index 32b06a8..5b0d5cf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,8 +6,8 @@ use anyhow::{Context as _, Result}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::client::{ - Account, Balances, CallResult, LockState, OperationStatus, TotalBalance, WalletClient, - WalletStatus, WalletTx, + Account, Balances, CallResult, LockState, OperationStatus, SendResult, TotalBalance, + WalletClient, WalletStatus, WalletTx, }; use crate::event::{Event, EventSource}; use crate::terminal::Tui; @@ -127,6 +127,8 @@ pub(crate) struct WalletData { pub(crate) struct SendForm { /// The source account, as an index into [`WalletData::accounts`]. pub(crate) from_account: usize, + /// The fund source within the account, as an index into [`FUND_SOURCES`]. + pub(crate) fund_source: usize, pub(crate) to: String, pub(crate) amount: String, pub(crate) memo: String, @@ -139,7 +141,16 @@ pub(crate) struct SendForm { /// Text fields only capture keystrokes while editing; otherwise navigation keys /// (`j`/`k`) move between fields. Editing is entered with `Enter` and left with `Esc`. pub(crate) editing: bool, - /// A submitted operation that is being polled, if any. + /// An in-flight `z_sendfromaccount` call, if any. + /// + /// The call is synchronous on the wallet side — it blocks through proving and + /// broadcast, which can take tens of seconds — so it runs on its own task rather + /// than on the UI event loop, and is checked for completion on each tick. + pub(crate) pending_task: Option>>, + /// The result of the last completed send, for display. + pub(crate) last_result: Option, + /// A submitted `z_sendmany` operation that is being polled, if any (TEX recipients + /// only; every other send goes through `pending_task`). pub(crate) pending_opid: Option, /// The latest known status of the pending operation. pub(crate) pending_status: Option, @@ -151,6 +162,7 @@ pub(crate) struct SendForm { pub(crate) enum SendField { #[default] From, + FundSource, To, Amount, Memo, @@ -166,6 +178,23 @@ impl SendField { } } +/// The fund sources offered in the send form, as accepted by `z_sendfromaccount`. +/// +/// Each source is isolating: a source that cannot cover the payment reports insufficient +/// funds rather than reaching into other funds. `"orchard"` covers the Ironwood pool as +/// well — that is the lever by which sealed Orchard funds migrate. +pub(crate) const FUND_SOURCES: [&str; 3] = ["orchard", "sapling", "any_transparent"]; + +/// A human-readable label for a [`FUND_SOURCES`] entry. +pub(crate) fn fund_source_label(index: usize) -> String { + match FUND_SOURCES.get(index).copied() { + Some("orchard") => crate::fl!("tui-send-fund-orchard"), + Some("sapling") => crate::fl!("tui-send-fund-sapling"), + Some("any_transparent") => crate::fl!("tui-send-fund-transparent"), + _ => String::new(), + } +} + /// The privacy policies offered in the send form, weakest-last. pub(crate) const PRIVACY_POLICIES: [&str; 7] = [ "FullPrivacy", @@ -256,6 +285,20 @@ impl App { self.lock_state == LockState::Locked } + /// Whether the wallet's global sync lock is engaged, meaning balance and spend + /// methods are unavailable until it catches up to the chain tip. + /// + /// Only reported by Zallet releases after v0.1.0-beta.2; absent means not locked. + /// Not every spend method is gated server-side (`z_sendfromaccount` is not), so this + /// is also checked client-side before submitting a send. + pub(crate) fn wallet_locked_for_sync(&self) -> bool { + self.data + .status + .as_ref() + .and_then(|s| s.locked) + .unwrap_or(false) + } + /// Whether a text field is currently capturing keystrokes, in which case global /// keyboard shortcuts must be suppressed so the characters can be typed. fn is_text_input_active(&self) -> bool { @@ -403,6 +446,18 @@ impl App { self.load_logs(); } + // Collect a finished send task even if the wallet has since locked: the + // transaction was in flight server-side either way, and its outcome must be + // reported. `is_finished()` guarantees the await returns immediately. + if self + .send + .pending_task + .as_ref() + .is_some_and(|t| t.is_finished()) + { + self.finish_send_task().await; + } + // Keep the lock state current (it can change when the unlock timeout elapses). self.refresh_lock_state().await; if self.is_gated() { @@ -436,10 +491,11 @@ impl App { self.data.balances = Some(b); self.data.balances_syncing = false; } - // InWarmup means the wallet summary isn't ready yet because the wallet is still - // syncing/scanning. This is expected, not an error: surface it as a "syncing" - // state rather than a scary toast. - Ok(Err(e)) if e.is_warming_up() => { + // The wallet summary isn't ready yet because the wallet is still syncing: + // warming up (-28), catching up to the chain tip (-10), or recovering from a + // reorg (-2). This is expected, not an error: surface it as a "syncing" state + // rather than a scary toast. + Ok(Err(e)) if e.is_unavailable_while_syncing() => { self.data.balances = None; self.data.balances_syncing = true; } @@ -465,12 +521,23 @@ impl App { { Ok(Ok(txs)) => { self.data.transactions = txs; + self.transactions.error = None; if self.transactions.selected >= self.data.transactions.len() { self.transactions.selected = self.data.transactions.len().saturating_sub(1); } } - // z_listtransactions is experimental; don't spam errors on empty wallets. - Ok(Err(_)) => self.data.transactions.clear(), + // The wallet is still syncing; an empty list with no error is the right state. + Ok(Err(e)) if e.is_unavailable_while_syncing() => { + self.data.transactions.clear(); + self.transactions.error = None; + } + // z_listtransactions is experimental, so don't spam toasts on every tick; + // record the error for the view to display in place of the list instead of + // silently showing an empty history. + Ok(Err(e)) => { + self.data.transactions.clear(); + self.transactions.error = Some(e.to_string()); + } Err(e) => self.error(e.to_string()), } } @@ -779,6 +846,37 @@ impl App { // --- Send polling ----------------------------------------------------------------- + /// Collects the outcome of a completed `z_sendfromaccount` task. + async fn finish_send_task(&mut self) { + let Some(task) = self.send.pending_task.take() else { + return; + }; + match task.await { + Ok(Ok(Ok(result))) => { + self.info(crate::fl!("tui-toast-send-completed")); + self.send.last_result = Some(result); + // No refresh here: the tick handler that collected this task refreshes + // right afterwards (unless the wallet has locked, hiding the data anyway). + } + Ok(Ok(Err(e))) if e.is_unlock_needed() => { + self.error(crate::fl!("tui-err-locked-press-u-upper")); + } + Ok(Ok(Err(e))) => { + self.error(crate::fl!( + "tui-err-send-failed", + error = with_privacy_policy_hint(e.to_string()) + )); + } + Ok(Err(e)) => self.error(e.to_string()), + // The task itself panicked or was cancelled; neither should happen, and the + // send's fate is unknown — tell the user to check the transaction list. + Err(e) => { + tracing::error!(error = %e, "send task failed to complete"); + self.error(crate::fl!("tui-err-send-task-failed")); + } + } + } + /// Polls the pending send operation and updates its status. pub(crate) async fn poll_send(&mut self) { let Some(opid) = self.send.pending_opid.clone() else { @@ -789,6 +887,13 @@ impl App { other => return self.report_call_error("z_getoperationstatus", other), }; let Some(status) = statuses.pop() else { + // The wallet no longer knows this operation: its bounded operation queue can + // evict finished (but uncollected) operations, and the queue does not survive + // a wallet restart. Polling further would spin forever, so give up and tell + // the user to check the transaction list for the outcome. + self.send.pending_opid = None; + self.send.pending_status = None; + self.error(crate::fl!("tui-err-send-op-lost", opid = opid)); return; }; @@ -798,22 +903,55 @@ impl App { if matches!(status.status.as_str(), "success" | "failed" | "cancelled") { self.send.pending_opid = None; match status.status.as_str() { - "success" => self.info(crate::fl!("tui-toast-send-completed")), + "success" => { + self.info(crate::fl!("tui-toast-send-completed")); + // Surface the broadcast txid(s) the same way the `z_sendfromaccount` + // path does: a TEX payment takes two transactions, and only the + // `txids` list in the operation result carries both. + self.send.last_result = status + .result + .as_ref() + .and_then(|r| serde_json::from_value(r.clone()).ok()); + } "failed" => { let msg = status .error .as_ref() .and_then(|e| e.message.clone()) .unwrap_or_else(|| crate::fl!("tui-err-unknown")); - self.error(crate::fl!("tui-err-send-failed", error = msg)); + self.error(crate::fl!( + "tui-err-send-failed", + error = with_privacy_policy_hint(msg) + )); } _ => self.info(crate::fl!("tui-toast-send-cancelled")), } + // Collect the finished operation so it does not linger in (and eventually + // overflow) the wallet's bounded operation queue. The status in hand already + // carries the result, so the response is not needed; failure to collect is + // harmless and will resolve on wallet restart. + let _ = self.client.operation_result(&opid).await; } self.send.pending_status = Some(status); } } +/// Appends a hint about the send form's privacy-policy selector to a wallet error that +/// rejected a send for privacy-policy reasons. +/// +/// Since NU6.3 sealed the Orchard pool, this rejection is much more common: spending +/// Orchard funds crosses into Ironwood, which reveals the crossing amount and so needs +/// `AllowRevealedAmounts` or weaker — a surprise to users whose sends previously worked +/// under `FullPrivacy`. The wallet's message names the `privacyPolicy` parameter, which +/// the TUI surfaces as a form field rather than a parameter, hence the extra pointer. +pub(crate) fn with_privacy_policy_hint(msg: String) -> String { + if msg.to_ascii_lowercase().contains("privacypolicy") { + format!("{msg} {}", crate::fl!("tui-send-policy-hint")) + } else { + msg + } +} + /// Reads up to the last `max_lines` lines of the file at `path`. /// /// Only the final `MAX_TAIL_BYTES` of the file are read, so the work is bounded no matter @@ -911,4 +1049,27 @@ mod tests { ); } } + + use super::with_privacy_policy_hint; + + /// A wallet error naming `privacyPolicy` (in any casing) gets the form-field hint + /// appended after the original message. + #[test] + fn privacy_policy_rejections_get_a_hint() { + crate::i18n::load_languages(&["en-US".parse().unwrap()]); + for msg in [ + "Insufficient funds permitted by privacyPolicy", + "the privacypolicy parameter forbids this", + ] { + let hinted = with_privacy_policy_hint(msg.to_string()); + assert!(hinted.starts_with(msg), "{hinted}"); + assert!(hinted.len() > msg.len(), "{hinted}"); + } + } + + #[test] + fn other_errors_are_left_unhinted() { + let msg = "Insufficient funds, have 0.00, need 1.00".to_string(); + assert_eq!(with_privacy_policy_hint(msg.clone()), msg); + } } diff --git a/src/cli.rs b/src/cli.rs index cf542e3..269a1ec 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -28,6 +28,13 @@ pub(crate) struct Cli { pub(crate) enum Command { /// Write a default `zallet-tui.toml` configuration file. GenerateConfig(GenerateConfigCmd), + + /// Check that the server exposes every RPC method this TUI uses. + /// + /// Queries the server's machine-readable schema (`rpc.discover`) and reports any + /// required method that is missing — useful as a quick compatibility probe after + /// upgrading Zallet. Uses the same connection options as the TUI. + Check, } /// Options for the interactive terminal UI (the default command). @@ -36,25 +43,33 @@ pub(crate) struct TuiCmd { /// The Zallet JSON-RPC server URL (e.g. `http://127.0.0.1:8232`). /// /// Defaults to `http://127.0.0.1:8232`. - #[arg(long, value_name = "URL")] + #[arg(long, value_name = "URL", global = true)] pub(crate) rpc_url: Option, /// Username for JSON-RPC authentication (a configured `[[rpc.auth]]` user). - #[arg(long, value_name = "USER")] + #[arg(long, value_name = "USER", global = true)] pub(crate) rpc_user: Option, /// Password for JSON-RPC authentication. - #[arg(long, value_name = "PASSWORD")] + #[arg(long, value_name = "PASSWORD", global = true)] pub(crate) rpc_password: Option, + /// Path to a Zallet RPC cookie file to authenticate with. + /// + /// Zallet writes a fresh `.cookie` file (containing `__cookie__:`) into its + /// data directory on every startup; pointing at it avoids configuring a `[[rpc.auth]]` + /// user by hand. Ignored when `--rpc-user` is given. + #[arg(long, value_name = "PATH", global = true)] + pub(crate) cookie_file: Option, + /// Path to a `zallet-tui.toml` configuration file. - #[arg(long, value_name = "PATH")] + #[arg(long, value_name = "PATH", global = true)] pub(crate) config: Option, /// Client timeout in seconds for HTTP requests, or `0` for no timeout. /// /// Defaults to 900 seconds. - #[arg(long)] + #[arg(long, global = true)] pub(crate) timeout: Option, /// Path to write the client log file to. @@ -63,7 +78,7 @@ pub(crate) struct TuiCmd { /// standard error. Defaults to a `zallet-tui.log` in the platform log directory /// (e.g. `~/.local/state/zallet-tui/` on Linux, `~/Library/Logs/zallet-tui/` on macOS, /// `%LOCALAPPDATA%\zallet-tui\logs\` on Windows). - #[arg(long, value_name = "PATH")] + #[arg(long, value_name = "PATH", global = true)] pub(crate) log_file: Option, } diff --git a/src/client.rs b/src/client.rs index 3214b47..4591a48 100644 --- a/src/client.rs +++ b/src/client.rs @@ -18,6 +18,28 @@ use serde_json::{Value, json}; use crate::config::RpcAuth; +/// Every RPC method the TUI can call. +/// +/// `zallet-tui check` compares this list against the server's `rpc.discover` schema, so a +/// Zallet release that drops or renames a method is caught explicitly rather than as a +/// runtime failure. Keep it in sync with the methods used in this module. +pub(crate) const REQUIRED_METHODS: [&str; 14] = [ + "getwalletstatus", + "getwalletinfo", + "z_getbalances", + "z_gettotalbalance", + "z_listaccounts", + "z_getnewaccount", + "z_getaddressforaccount", + "z_listtransactions", + "walletpassphrase", + "walletlock", + "z_sendmany", + "z_sendfromaccount", + "z_getoperationstatus", + "z_getoperationresult", +]; + /// Errors that can arise when building or using the TUI's RPC client. #[derive(Debug)] pub(crate) enum ClientError { @@ -68,10 +90,14 @@ impl RpcError { self.code == -15 } - /// Whether the wallet is still warming up (scanning/syncing) and cannot answer yet - /// (`LegacyCode::InWarmup`). - pub(crate) fn is_warming_up(&self) -> bool { - self.code == -28 + /// Whether the wallet cannot answer yet because it is still syncing. + /// + /// Covers `LegacyCode::InWarmup` (`-28`, warming up / scanning), and the two codes + /// Zallet's global sync lock returns from balance and spend methods: + /// `ClientInInitialDownload` (`-10`, catching up to the chain tip) and + /// `ForbiddenBySafeMode` (`-2`, recovering from a chain reorganization). + pub(crate) fn is_unavailable_while_syncing(&self) -> bool { + matches!(self.code, -28 | -10 | -2) } } @@ -202,6 +228,13 @@ impl WalletClient { } } + // --- Introspection ---------------------------------------------------------------- + + /// Fetches the server's OpenRPC schema (`rpc.discover`). + pub(crate) async fn discover(&self) -> CallResult { + self.request("rpc.discover", ArrayParams::new()).await + } + // --- Status & balances ------------------------------------------------------------ pub(crate) async fn get_wallet_status(&self) -> CallResult { @@ -271,6 +304,11 @@ impl WalletClient { // --- Sending ---------------------------------------------------------------------- /// Submits a `z_sendmany`, returning the async operation id. + /// + /// This is the legacy send path, kept because it is the only method that can pay a + /// ZIP 320 (TEX) recipient — that takes two transactions, which the PCZT pipeline + /// behind `z_sendfromaccount` cannot express. All other sends use + /// [`Self::send_from_account`]. pub(crate) async fn send_many( &self, from: &str, @@ -279,18 +317,7 @@ impl WalletClient { memo: Option<&str>, privacy_policy: &str, ) -> CallResult { - let mut recipient = serde_json::Map::new(); - recipient.insert("address".into(), json!(to)); - recipient.insert("amount".into(), json!(amount)); - if let Some(memo) = memo { - // The wallet expects the memo as hex-encoded bytes, but the form captures it as - // human-readable text, so encode the UTF-8 bytes here. - recipient.insert( - "memo".into(), - json!(crate::format::hex_encode(memo.as_bytes())), - ); - } - let recipients = Value::Array(vec![Value::Object(recipient)]); + let recipients = Value::Array(vec![recipient_object(to, amount, memo)]); // from, [recipient], minconf = 1, fee = null (ZIP-317 automatic), privacy_policy. self.request( @@ -300,11 +327,66 @@ impl WalletClient { .await } + /// Submits a `z_sendfromaccount`, returning the broadcast transaction ids. + /// + /// Unlike `z_sendmany`, this spends directly from an account (by UUID) rather than + /// from an address, takes an explicit fund source (each source is isolating: it does + /// not reach into other funds), and completes synchronously — the call blocks through + /// proving and broadcast, so it must not be awaited on the UI event loop. + pub(crate) async fn send_from_account( + &self, + account_uuid: &str, + fund_source: &str, + to: &str, + amount: &str, + memo: Option<&str>, + privacy_policy: &str, + ) -> CallResult { + let recipients = Value::Array(vec![recipient_object(to, amount, memo)]); + + // account, fund_source, [recipient], minconf = 1, privacy_policy (required). + self.request( + "z_sendfromaccount", + rpc_params![account_uuid, fund_source, recipients, 1, privacy_policy], + ) + .await + } + /// Polls the status of one async operation. pub(crate) async fn operation_status(&self, opid: &str) -> CallResult> { self.request("z_getoperationstatus", rpc_params![[opid]]) .await } + + /// Collects (and thereby removes) one finished async operation. + /// + /// The wallet's operation queue is bounded: uncollected finished operations become + /// eviction candidates once the queue fills, and a queue full of *unfinished* + /// operations rejects new sends outright. Collecting each operation once it reaches a + /// terminal status keeps the queue clear. + pub(crate) async fn operation_result(&self, opid: &str) -> CallResult> { + self.request("z_getoperationresult", rpc_params![[opid]]) + .await + } +} + +/// Builds one entry of a send's `amounts`/`recipients` parameter. +/// +/// The wallet rejects unknown keys in these objects (`deny_unknown_fields`), so only the +/// three defined keys are ever set, and `memo` only when present. +fn recipient_object(to: &str, amount: &str, memo: Option<&str>) -> Value { + let mut recipient = serde_json::Map::new(); + recipient.insert("address".into(), json!(to)); + recipient.insert("amount".into(), json!(amount)); + if let Some(memo) = memo { + // The wallet expects the memo as hex-encoded bytes, but the form captures it as + // human-readable text, so encode the UTF-8 bytes here. + recipient.insert( + "memo".into(), + json!(crate::format::hex_encode(memo.as_bytes())), + ); + } + Value::Object(recipient) } // --- Response types ------------------------------------------------------------------- @@ -312,6 +394,18 @@ impl WalletClient { // These mirror the JSON shapes produced by the wallet RPC. They are intentionally // tolerant: unknown fields are ignored, and optional fields are modelled as `Option`. +/// The subset of an OpenRPC schema document (`rpc.discover`) the TUI cares about. +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct OpenRpcSchema { + #[serde(default)] + pub(crate) methods: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct OpenRpcMethod { + pub(crate) name: String, +} + #[derive(Clone, Debug, Deserialize)] pub(crate) struct WalletStatus { pub(crate) node_tip: ChainTip, @@ -321,6 +415,12 @@ pub(crate) struct WalletStatus { pub(crate) fully_synced_height: Option, #[serde(default)] pub(crate) sync_work_remaining: Option, + /// Whether the wallet's global sync lock is engaged, meaning balance and spend + /// methods will fail until the wallet catches up to the chain tip. + /// + /// Absent on Zallet releases up to v0.1.0-beta.2; present on later versions. + #[serde(default)] + pub(crate) locked: Option, } #[derive(Clone, Debug, Deserialize)] @@ -369,28 +469,40 @@ pub(crate) struct Balances { pub(crate) struct AccountBalance { pub(crate) account_uuid: String, #[serde(default)] - pub(crate) transparent: Option, + pub(crate) transparent: Option, #[serde(default)] pub(crate) sapling: Option, #[serde(default)] pub(crate) orchard: Option, #[serde(default)] + pub(crate) ironwood: Option, + #[serde(default)] pub(crate) total: Option, } impl AccountBalance { - /// Spendable balance in the transparent pool, in zatoshi. + /// Spendable balance in the transparent pool (regular and coinbase), in zatoshi. pub(crate) fn transparent_zat(&self) -> i64 { - spendable(self.transparent.as_ref()) + self.transparent + .as_ref() + .map_or(0, TransparentBalance::spendable_zat) } /// Spendable balance in the Sapling pool, in zatoshi. pub(crate) fn sapling_zat(&self) -> i64 { spendable(self.sapling.as_ref()) } /// Spendable balance in the Orchard pool, in zatoshi. + /// + /// Once NU6.3 (Ironwood) is active, the Orchard pool is sealed: funds received at + /// Orchard receivers land in the Ironwood pool instead, so this balance can only + /// shrink as it is spent or migrated. pub(crate) fn orchard_zat(&self) -> i64 { spendable(self.orchard.as_ref()) } + /// Spendable balance in the Ironwood pool, in zatoshi. + pub(crate) fn ironwood_zat(&self) -> i64 { + spendable(self.ironwood.as_ref()) + } /// The account's not-yet-spendable (pending) balance across all pools, in zatoshi. pub(crate) fn pending_total_zat(&self) -> i64 { @@ -407,6 +519,26 @@ fn spendable(pool: Option<&PoolBalance>) -> i64 { pool.map_or(0, PoolBalance::spendable_zat) } +/// The transparent portion of an account balance. +/// +/// Unlike the shielded pools, `z_getbalances` splits transparent funds into `regular` and +/// `coinbase` buckets (each a category map like the shielded pools), so that coinbase +/// outputs — which have their own spendability rules — can be reported separately. +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct TransparentBalance { + #[serde(default)] + pub(crate) regular: Option, + #[serde(default)] + pub(crate) coinbase: Option, +} + +impl TransparentBalance { + /// The spendable balance across the regular and coinbase buckets, in zatoshi. + pub(crate) fn spendable_zat(&self) -> i64 { + spendable(self.regular.as_ref()) + spendable(self.coinbase.as_ref()) + } +} + /// A per-pool (or whole-account `total`) balance, keyed by spendability category. /// /// `z_getbalances` reports each pool as a map of category to amount, e.g. @@ -546,10 +678,37 @@ pub(crate) struct WalletTx { pub(crate) fee_paid: Option, #[serde(default)] pub(crate) block_time: Option, + /// The block time as an RFC 3339 datetime string, when mined. + #[serde(default)] + pub(crate) block_datetime: Option, #[serde(default)] pub(crate) expired_unmined: bool, } +/// The result of a completed send: the transaction ids that were broadcast. +/// +/// Returned directly by `z_sendfromaccount`, and as the `result` of a successful +/// `z_sendmany` operation. `txid` is only present when the payment took a single +/// transaction; `txids` always lists every transaction. +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct SendResult { + #[serde(default)] + pub(crate) txid: Option, + #[serde(default)] + pub(crate) txids: Vec, +} + +impl SendResult { + /// A display string for the broadcast transaction id(s). + pub(crate) fn txids_label(&self) -> String { + match &self.txid { + Some(txid) => txid.clone(), + None if self.txids.is_empty() => crate::fl!("tui-value-unknown"), + None => self.txids.join(", "), + } + } +} + #[derive(Clone, Debug, Deserialize)] pub(crate) struct OperationStatus { pub(crate) status: String, @@ -659,13 +818,19 @@ mod tests { assert_eq!(handle.join().unwrap(), None); } - /// Regression test against a real `z_getbalances` response: pools are nested objects - /// (`{"spendable": {"valueZat": N}}`) and absent when empty. + /// Regression test against a real `z_getbalances` response (Zallet v0.1.0-beta.2): + /// pools are nested objects (`{"spendable": {"valueZat": N}}`) and absent when empty, + /// the `ironwood` pool appears alongside `sapling`/`orchard` once NU6.3 is active, and + /// `transparent` is split into `regular`/`coinbase` buckets. #[test] fn balances_deserialize_from_real_response() { let json = r#"{"accounts":[ {"account_uuid":"4e7543e5-1c82-4b5b-8c7e-1113383b763e","total":{"spendable":{"valueZat":0}}}, - {"account_uuid":"5f24cb93-dcbc-4915-b815-c6ddfa018b86","orchard":{"spendable":{"valueZat":10000000000}},"total":{"spendable":{"valueZat":10000000000}}} + {"account_uuid":"5f24cb93-dcbc-4915-b815-c6ddfa018b86", + "transparent":{"regular":{"spendable":{"valueZat":50}},"coinbase":{"spendable":{"valueZat":25}}}, + "orchard":{"spendable":{"valueZat":10000000000}}, + "ironwood":{"spendable":{"valueZat":300}}, + "total":{"spendable":{"valueZat":10000000375}}} ]}"#; let balances: Balances = serde_json::from_str(json).expect("parse z_getbalances response"); @@ -674,13 +839,22 @@ mod tests { let empty = &balances.accounts[0]; assert_eq!(empty.transparent_zat(), 0); assert_eq!(empty.orchard_zat(), 0); + assert_eq!(empty.ironwood_zat(), 0); assert_eq!(empty.total_zat(), 0); let funded = &balances.accounts[1]; assert_eq!(funded.orchard_zat(), 10_000_000_000); - assert_eq!(funded.total_zat(), 10_000_000_000); - assert_eq!(funded.transparent_zat(), 0); + assert_eq!(funded.ironwood_zat(), 300); + assert_eq!(funded.transparent_zat(), 75); assert_eq!(funded.sapling_zat(), 0); + assert_eq!(funded.total_zat(), 10_000_000_375); + } + + /// An empty wallet's `z_getbalances` response omits the `accounts` key entirely. + #[test] + fn balances_tolerate_missing_accounts_key() { + let balances: Balances = serde_json::from_str("{}").expect("parse empty response"); + assert!(balances.accounts.is_empty()); } /// Non-`spendable` categories are summed into the pending total, and the grand total @@ -701,4 +875,37 @@ mod tests { assert_eq!(account.pending_total_zat(), 40); assert_eq!(account.total_zat(), 140); // spendable + pending } + + /// The txid label prefers the single `txid`, joins a multi-transaction `txids` list + /// (e.g. a ZIP 320 TEX payment), and falls back to a placeholder when both are empty. + #[test] + fn send_result_txid_labels() { + crate::i18n::load_languages(&["en-US".parse().unwrap()]); + let result = |txid: Option<&str>, txids: &[&str]| SendResult { + txid: txid.map(str::to_string), + txids: txids.iter().map(|s| s.to_string()).collect(), + }; + assert_eq!(result(Some("aa"), &["aa"]).txids_label(), "aa"); + assert_eq!(result(None, &["aa", "bb"]).txids_label(), "aa, bb"); + assert_eq!( + result(None, &[]).txids_label(), + crate::fl!("tui-value-unknown") + ); + } + + /// Exactly the warmup (`-28`) and global-sync-lock (`-10`, `-2`) codes count as + /// "still syncing"; other wallet errors must keep surfacing as errors. + #[test] + fn syncing_codes_are_recognized() { + let err = |code| RpcError { + code, + message: String::new(), + }; + for code in [-28, -10, -2] { + assert!(err(code).is_unavailable_while_syncing(), "{code}"); + } + for code in [0, -1, -5, -13, -15, -32601] { + assert!(!err(code).is_unavailable_while_syncing(), "{code}"); + } + } } diff --git a/src/config.rs b/src/config.rs index fa2827d..0f85f2f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -47,6 +47,13 @@ pub(crate) struct Config { #[serde(default)] pub(crate) auth: Vec, + /// Path to a Zallet RPC cookie file (`__cookie__:`) to authenticate with. + /// + /// Takes precedence over the `[[auth]]` entries; re-read on every connection attempt + /// because Zallet rewrites the cookie on each startup. + #[serde(default)] + pub(crate) cookie_file: Option, + /// The client request timeout, in seconds (`0` means no timeout). #[serde(default)] pub(crate) timeout: Option, @@ -79,6 +86,8 @@ impl Config { pub(crate) struct Connection { url: String, auth: Vec, + /// A cookie file to read credentials from, taking precedence over `auth`. + cookie_file: Option, timeout: Duration, } @@ -103,26 +112,71 @@ impl Connection { .unwrap_or_else(|| DEFAULT_RPC_URL.to_string()); // A `--rpc-user`/`--rpc-password` pair on the command line overrides the configured - // `[[auth]]` credentials entirely. - let auth = match &cmd.rpc_user { - Some(user) => vec![RpcAuth { - user: user.clone(), - password: cmd.rpc_password.clone().map(SecretString::new), - }], - None => config.auth.clone(), + // `[[auth]]` credentials entirely — including any cookie file. Without one, a + // cookie file (CLI over config) is preferred over `[[auth]]` entries. + let (auth, cookie_file) = match &cmd.rpc_user { + Some(user) => ( + vec![RpcAuth { + user: user.clone(), + password: cmd.rpc_password.clone().map(SecretString::new), + }], + None, + ), + None => ( + config.auth.clone(), + cmd.cookie_file + .clone() + .or_else(|| config.cookie_file.clone()), + ), }; - Connection { url, auth, timeout } + Connection { + url, + auth, + cookie_file, + timeout, + } } /// Builds a connected [`WalletClient`] for this connection. + /// + /// The cookie file (when configured) is read here, at connection time, because Zallet + /// rewrites it with a fresh credential on every startup. pub(crate) fn connect(&self) -> Result { - Ok(WalletClient::connect_remote( - &self.url, - &self.auth, - self.timeout, - )?) + let cookie_auth = match &self.cookie_file { + Some(path) => Some(read_cookie(path)?), + None => None, + }; + let auth = match &cookie_auth { + Some(auth) => std::slice::from_ref(auth), + None => &self.auth, + }; + Ok(WalletClient::connect_remote(&self.url, auth, self.timeout)?) + } +} + +/// Reads an RPC credential from a Zallet cookie file. +fn read_cookie(path: &Path) -> Result { + let contents = std::fs::read_to_string(path) + .with_context(|| format!("failed to read cookie file {}", path.display()))?; + parse_cookie(&contents) + .with_context(|| format!("cookie file {} is not `user:password`", path.display())) +} + +/// Parses the `user:password` line of an RPC cookie file. +/// +/// The password may itself contain `:`; only the first separator delimits the user. Empty +/// users or passwords are rejected — Zallet itself refuses empty RPC passwords. +fn parse_cookie(contents: &str) -> Option { + let line = contents.lines().next()?.trim(); + let (user, password) = line.split_once(':')?; + if user.is_empty() || password.is_empty() { + return None; } + Some(RpcAuth { + user: user.to_string(), + password: Some(SecretString::new(password.to_string())), + }) } /// The platform-default location for `zallet-tui.toml`. @@ -146,6 +200,11 @@ const DEFAULT_CONFIG_TEMPLATE: &str = "\ # Client request timeout, in seconds. Use 0 for no timeout. Defaults to 900. # timeout = 900 +# Path to a Zallet RPC cookie file. Zallet writes a fresh `.cookie` (containing +# `__cookie__:`) into its data directory on every startup; pointing at it here +# avoids configuring a `[[rpc.auth]]` user by hand. Takes precedence over `[[auth]]`. +# cookie_file = \"/path/to/zallet/datadir/.cookie\" + # RPC authentication credentials. These must match a `[[rpc.auth]]` user configured in the # Zallet server's own config. Repeat the `[[auth]]` block for multiple credentials. # [[auth]] @@ -236,3 +295,30 @@ pub(crate) fn default_log_path() -> PathBuf { None => PathBuf::from("zallet-tui.log"), } } + +#[cfg(test)] +mod tests { + use secrecy::ExposeSecret as _; + + use super::parse_cookie; + + #[test] + fn cookie_parses_user_and_password() { + let auth = parse_cookie("__cookie__:sekrit\n").expect("valid cookie"); + assert_eq!(auth.user, "__cookie__"); + assert_eq!(auth.password.unwrap().expose_secret(), "sekrit"); + } + + #[test] + fn cookie_password_may_contain_separators() { + let auth = parse_cookie("__cookie__:se:kr:it").expect("valid cookie"); + assert_eq!(auth.password.unwrap().expose_secret(), "se:kr:it"); + } + + #[test] + fn malformed_or_empty_cookies_are_rejected() { + for contents in ["", "\n", "nocolon", ":password", "user:", ":"] { + assert!(parse_cookie(contents).is_none(), "{contents:?}"); + } + } +} diff --git a/src/main.rs b/src/main.rs index cb51b14..44fdaf1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,6 +47,7 @@ fn main() { let result = match cli.command { Some(Command::GenerateConfig(cmd)) => generate_config(cmd), + Some(Command::Check) => check(cli.tui), None => run(cli.tui), }; @@ -66,6 +67,56 @@ fn generate_config(cmd: GenerateConfigCmd) -> Result<()> { Ok(()) } +/// Checks that the server exposes every RPC method the TUI uses, via `rpc.discover`. +fn check(cmd: TuiCmd) -> Result<()> { + // Error `Display` impls go through the localization loader, so it must be initialized + // even though this path prints plain output. + i18n::load_languages(&requested_languages()); + + let config = Config::load(cmd.config.as_deref())?; + let connection = Connection::resolve(&cmd, &config); + let client = connection.connect()?; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("failed to build the Tokio runtime")?; + let schema = match runtime.block_on(client.discover())? { + Ok(schema) => schema, + Err(e) => anyhow::bail!("rpc.discover failed: {e}"), + }; + + let missing = missing_methods(&schema); + if missing.is_empty() { + println!( + "OK: the server exposes all {} RPC methods zallet-tui uses (of {} methods served).", + client::REQUIRED_METHODS.len(), + schema.methods.len() + ); + Ok(()) + } else { + for method in &missing { + println!("MISSING: {method}"); + } + anyhow::bail!( + "the server is missing {} of the {} RPC methods zallet-tui uses", + missing.len(), + client::REQUIRED_METHODS.len() + ); + } +} + +/// The [`client::REQUIRED_METHODS`] that `schema` does not serve, in the order required. +fn missing_methods(schema: &client::OpenRpcSchema) -> Vec<&'static str> { + let available: std::collections::BTreeSet<&str> = + schema.methods.iter().map(|m| m.name.as_str()).collect(); + client::REQUIRED_METHODS + .iter() + .copied() + .filter(|method| !available.contains(method)) + .collect() +} + fn run(cmd: TuiCmd) -> Result<()> { // Negotiate the UI language from the environment. let requested = requested_languages(); @@ -161,3 +212,38 @@ fn requested_languages() -> Vec { } vec!["en-US".parse().expect("en-US is a valid language tag")] } + +#[cfg(test)] +mod tests { + use super::missing_methods; + use crate::client::{OpenRpcMethod, OpenRpcSchema, REQUIRED_METHODS}; + + fn schema_of(names: &[&str]) -> OpenRpcSchema { + OpenRpcSchema { + methods: names + .iter() + .map(|n| OpenRpcMethod { + name: n.to_string(), + }) + .collect(), + } + } + + #[test] + fn missing_methods_reports_exactly_the_unserved_methods() { + // A server exposing everything (plus extras) has nothing missing. + let mut served: Vec<&str> = REQUIRED_METHODS.to_vec(); + served.push("getinfo"); + assert!(missing_methods(&schema_of(&served)).is_empty()); + + // Dropping methods reports precisely those, regardless of extras. + served.retain(|m| *m != "z_sendfromaccount" && *m != "z_getbalances"); + assert_eq!( + missing_methods(&schema_of(&served)), + ["z_getbalances", "z_sendfromaccount"] + ); + + // An empty schema is missing everything. + assert_eq!(missing_methods(&schema_of(&[])), REQUIRED_METHODS); + } +} diff --git a/src/views/balances.rs b/src/views/balances.rs index b5c44cc..516be17 100644 --- a/src/views/balances.rs +++ b/src/views/balances.rs @@ -3,9 +3,9 @@ use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, - layout::{Constraint, Rect}, + layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, - widgets::{Block, Borders, Cell, Paragraph, Row, Table}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap}, }; use crate::app::App; @@ -30,6 +30,7 @@ pub(crate) fn render(app: &App, frame: &mut Frame<'_>, area: Rect) { Cell::from(crate::fl!("tui-bal-header-transparent")), Cell::from(crate::fl!("tui-bal-header-sapling")), Cell::from(crate::fl!("tui-bal-header-orchard")), + Cell::from(crate::fl!("tui-bal-header-ironwood")), Cell::from(crate::fl!("tui-bal-header-pending")), Cell::from(crate::fl!("tui-bal-header-total")), ]) @@ -58,6 +59,7 @@ pub(crate) fn render(app: &App, frame: &mut Frame<'_>, area: Rect) { Cell::from(zec(acct.transparent_zat())), Cell::from(zec(acct.sapling_zat())), Cell::from(zec(acct.orchard_zat())), + Cell::from(zec(acct.ironwood_zat())), Cell::from(zec(acct.pending_total_zat())), Cell::from(zec(acct.total_zat())), ]) @@ -87,15 +89,38 @@ pub(crate) fn render(app: &App, frame: &mut Frame<'_>, area: Rect) { return; } + // ZIP 318 asks wallets to surface at-risk Orchard balances: since NU6.3 the Orchard + // pool is sealed, so any spendable Orchard funds can only move (publicly, in amount) + // into the account's own Ironwood pool. Show a notice whenever such funds exist. + let orchard_at_risk = app + .data + .balances + .as_ref() + .is_some_and(|b| b.accounts.iter().any(|a| a.orchard_zat() > 0)); + let (table_area, note_area) = if orchard_at_risk && area.height > 4 { + let chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(2)]).split(area); + (chunks[0], Some(chunks[1])) + } else { + (area, None) + }; + if let Some(note_area) = note_area { + let note = Paragraph::new(crate::fl!("tui-bal-orchard-sealed")) + .style(Style::default().fg(Color::Yellow)) + .wrap(Wrap { trim: true }); + frame.render_widget(note, note_area); + } + let area = table_area; + let table = Table::new( rows, [ - Constraint::Percentage(25), - Constraint::Percentage(15), - Constraint::Percentage(15), - Constraint::Percentage(15), - Constraint::Percentage(15), - Constraint::Percentage(15), + Constraint::Percentage(22), + Constraint::Percentage(13), + Constraint::Percentage(13), + Constraint::Percentage(13), + Constraint::Percentage(13), + Constraint::Percentage(13), + Constraint::Percentage(13), ], ) .header(header) diff --git a/src/views/send.rs b/src/views/send.rs index 3ba40b8..b711710 100644 --- a/src/views/send.rs +++ b/src/views/send.rs @@ -1,8 +1,9 @@ -//! Send view: a form for `z_sendmany` with confirmation and inline operation polling. +//! Send view: a form for `z_sendfromaccount` with confirmation and completion tracking. //! -//! The "from" field is an account selector rather than free text: `z_sendmany` requires an -//! address as its source, so the selected account is resolved to one of its addresses (a -//! unified address where possible) at submit time. +//! The "from" field is an account selector; the selected account's UUID is passed to +//! `z_sendfromaccount` directly, together with an explicit fund source. The one exception +//! is a ZIP 320 (TEX) recipient, which only `z_sendmany` can pay: those are routed through +//! the legacy address-sourced async-operation path. use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ @@ -13,7 +14,7 @@ use ratatui::{ widgets::{Block, Borders, Paragraph, Wrap}, }; -use crate::app::{App, PRIVACY_POLICIES, SendField}; +use crate::app::{App, FUND_SOURCES, PRIVACY_POLICIES, SendField, fund_source_label}; /// Handles a key event for the send view. /// @@ -67,6 +68,12 @@ pub(crate) async fn on_key(app: &mut App, key: KeyEvent) { KeyCode::Right | KeyCode::Char('l') if app.send.field == SendField::From => { select_next_account(app) } + KeyCode::Left | KeyCode::Char('h') if app.send.field == SendField::FundSource => { + app.send.fund_source = app.send.fund_source.saturating_sub(1); + } + KeyCode::Right | KeyCode::Char('l') if app.send.field == SendField::FundSource => { + app.send.fund_source = (app.send.fund_source + 1).min(FUND_SOURCES.len() - 1); + } KeyCode::Left | KeyCode::Char('h') if app.send.field == SendField::PrivacyPolicy => { app.send.privacy_policy = app.send.privacy_policy.saturating_sub(1); } @@ -110,6 +117,9 @@ fn select_prev_account(app: &mut App) { } fn validate(app: &App) -> Result<(), String> { + if app.wallet_locked_for_sync() { + return Err(crate::fl!("tui-send-err-syncing")); + } if app.data.accounts.is_empty() { return Err(crate::fl!("tui-send-err-no-accounts")); } @@ -118,9 +128,14 @@ fn validate(app: &App) -> Result<(), String> { .accounts .get(app.send.from_account) .ok_or_else(|| crate::fl!("tui-send-err-no-source"))?; - if account.spend_source_address().is_none() { + // A TEX recipient is routed through `z_sendmany`, which spends from an address + // rather than an account and so needs one to exist. + if is_tex_recipient(app.send.to.trim()) && account.spend_source_address().is_none() { return Err(crate::fl!("tui-send-err-no-spendable")); } + if app.send.pending_task.is_some() || app.send.pending_opid.is_some() { + return Err(crate::fl!("tui-send-err-in-flight")); + } if app.send.to.trim().is_empty() { return Err(crate::fl!("tui-send-err-recipient-required")); } @@ -148,10 +163,10 @@ fn is_valid_amount(s: &str) -> bool { if !is_digits(whole) { return false; } - if let Some(frac) = frac { - if frac.len() > 8 || !is_digits(frac) { - return false; - } + if let Some(frac) = frac + && (frac.len() > 8 || !is_digits(frac)) + { + return false; } // Reject zero (e.g. "0", "0.0"): the wallet will not send it. @@ -161,15 +176,27 @@ fn is_valid_amount(s: &str) -> bool { .any(|b| b != b'0') } +/// Whether `address` is a ZIP 320 (TEX) address, which only `z_sendmany` can pay. +fn is_tex_recipient(address: &str) -> bool { + // The ZIP 320 HRPs: `tex` (mainnet), `textest` (testnet), `texregtest` (regtest). + ["tex1", "textest1", "texregtest1"] + .iter() + .any(|hrp| address.starts_with(hrp)) +} + async fn submit(app: &mut App) { + // Validation ran when the confirmation dialog opened, but the sync lock can engage + // while it sits open — and `z_sendfromaccount` is not gated server-side, so this + // client-side check is the only gate. Re-check at the moment of submission. + if app.wallet_locked_for_sync() { + app.error(crate::fl!("tui-send-err-syncing")); + return; + } let Some(account) = app.data.accounts.get(app.send.from_account) else { app.error(crate::fl!("tui-send-err-no-source-selected")); return; }; - let Some(from) = account.spend_source_address().map(|s| s.to_string()) else { - app.error(crate::fl!("tui-send-err-no-spendable")); - return; - }; + let account_uuid = account.account_uuid.clone(); let to = app.send.to.trim().to_string(); let amount = app.send.amount.trim().to_string(); @@ -183,22 +210,62 @@ async fn submit(app: &mut App) { }; let policy = PRIVACY_POLICIES[app.send.privacy_policy]; - match app - .client() - .send_many(&from, &to, &amount, memo.as_deref(), policy) - .await - { - Ok(Ok(opid)) => { - app.info(crate::fl!("tui-send-submitted", opid = opid.clone())); - app.send.pending_opid = Some(opid); - app.send.pending_status = None; - app.poll_send().await; - } - Ok(Err(e)) if e.is_unlock_needed() => { - app.error(crate::fl!("tui-err-locked-press-u-upper")); + // TEX recipients require two transactions, which only the legacy `z_sendmany` + // async-operation path can produce. + if is_tex_recipient(&to) { + let Some(from) = app + .data + .accounts + .get(app.send.from_account) + .and_then(|a| a.spend_source_address()) + .map(|s| s.to_string()) + else { + app.error(crate::fl!("tui-send-err-no-spendable")); + return; + }; + + match app + .client() + .send_many(&from, &to, &amount, memo.as_deref(), policy) + .await + { + Ok(Ok(opid)) => { + app.info(crate::fl!("tui-send-submitted", opid = opid.clone())); + app.send.pending_opid = Some(opid); + app.send.pending_status = None; + app.send.last_result = None; + app.poll_send().await; + } + Ok(Err(e)) if e.is_unlock_needed() => { + app.error(crate::fl!("tui-err-locked-press-u-upper")); + } + other => app.report_call_error("z_sendmany", other), } - other => app.report_call_error("z_sendmany", other), + return; } + + // Everything else goes through `z_sendfromaccount`, which spends from the account + // UUID directly. The call is synchronous through proving and broadcast, so it runs + // on its own task; the tick handler collects the outcome. + let client = app.client().clone(); + let fund_source = FUND_SOURCES[app.send.fund_source].to_string(); + let policy = policy.to_string(); + let task = tokio::spawn(async move { + client + .send_from_account( + &account_uuid, + &fund_source, + &to, + &amount, + memo.as_deref(), + &policy, + ) + .await + }); + app.send.pending_task = Some(task); + app.send.pending_status = None; + app.send.last_result = None; + app.info(crate::fl!("tui-toast-send-started")); } pub(crate) fn render(app: &App, frame: &mut Frame<'_>, area: Rect) { @@ -233,6 +300,16 @@ fn render_form(app: &App, frame: &mut Frame<'_>, area: Rect) { label_span(&crate::fl!("tui-send-from"), f.field == SendField::From), Span::styled(from_label, Style::default().fg(Color::Cyan)), ]), + Line::from(vec![ + label_span( + &crate::fl!("tui-send-fund-source"), + f.field == SendField::FundSource, + ), + Span::styled( + format!("◀ {} ▶", fund_source_label(f.fund_source)), + Style::default().fg(Color::Cyan), + ), + ]), input_line( &crate::fl!("tui-send-to"), &f.to, @@ -326,7 +403,7 @@ fn render_status(app: &App, frame: &mut Frame<'_>, area: Rect) { .get(f.from_account) .map(|a| a.label()) .unwrap_or_default(); - vec![ + let mut lines = vec![ Line::from(Span::styled( crate::fl!("tui-send-confirm"), Style::default() @@ -342,11 +419,42 @@ fn render_status(app: &App, frame: &mut Frame<'_>, area: Rect) { to = f.to.trim() ) )), + ]; + // A TEX recipient is routed through the legacy `z_sendmany` path, which spends + // from an address rather than the account — the Funds selection does not apply, + // so say so instead of silently ignoring it. + if is_tex_recipient(f.to.trim()) { + lines.push(Line::from(Span::styled( + format!(" {}", crate::fl!("tui-send-confirm-tex")), + Style::default().fg(Color::Yellow), + ))); + } + lines.push(Line::from(Span::styled( + format!(" {}", crate::fl!("tui-send-confirm-hint")), + Style::default().fg(Color::DarkGray), + ))); + lines + } else if app.send.pending_task.is_some() { + vec![ + Line::from(Span::styled( + crate::fl!("tui-send-sending"), + Style::default().fg(Color::Cyan), + )), Line::from(Span::styled( - format!(" {}", crate::fl!("tui-send-confirm-hint")), + crate::fl!("tui-send-sending-hint"), Style::default().fg(Color::DarkGray), )), ] + } else if let Some(result) = &app.send.last_result { + vec![ + Line::from(Span::styled( + crate::fl!("tui-send-succeeded"), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )), + Line::from(crate::fl!("tui-send-txid", txid = result.txids_label())), + ] } else if let Some(opid) = &app.send.pending_opid { let status = app .send @@ -436,7 +544,8 @@ fn policy_style(index: usize) -> Style { fn next_field(field: SendField) -> SendField { match field { - SendField::From => SendField::To, + SendField::From => SendField::FundSource, + SendField::FundSource => SendField::To, SendField::To => SendField::Amount, SendField::Amount => SendField::Memo, SendField::Memo => SendField::PrivacyPolicy, @@ -448,7 +557,8 @@ fn next_field(field: SendField) -> SendField { fn prev_field(field: SendField) -> SendField { match field { SendField::From => SendField::Submit, - SendField::To => SendField::From, + SendField::FundSource => SendField::From, + SendField::To => SendField::FundSource, SendField::Amount => SendField::To, SendField::Memo => SendField::Amount, SendField::PrivacyPolicy => SendField::Memo, @@ -463,13 +573,25 @@ fn text_field_mut(app: &mut App) -> Option<&mut String> { SendField::To => Some(&mut app.send.to), SendField::Amount => Some(&mut app.send.amount), SendField::Memo => Some(&mut app.send.memo), - SendField::From | SendField::PrivacyPolicy | SendField::Submit => None, + SendField::From | SendField::FundSource | SendField::PrivacyPolicy | SendField::Submit => { + None + } } } #[cfg(test)] mod tests { - use super::is_valid_amount; + use super::{is_tex_recipient, is_valid_amount}; + + #[test] + fn tex_recipients_are_detected_on_every_network() { + for addr in ["tex1qabc", "textest1qabc", "texregtest1qabc"] { + assert!(is_tex_recipient(addr), "{addr}"); + } + for addr in ["t1abc", "u1abc", "zs1abc", "tez1abc", "text1abc", ""] { + assert!(!is_tex_recipient(addr), "{addr}"); + } + } #[test] fn accepts_well_formed_positive_amounts() { diff --git a/src/views/transactions.rs b/src/views/transactions.rs index 87d3c70..5d5665f 100644 --- a/src/views/transactions.rs +++ b/src/views/transactions.rs @@ -19,6 +19,8 @@ pub(crate) struct TransactionsState { pub(crate) selected: usize, /// The offset of the current page, in transactions, from the most recent. pub(crate) offset: u32, + /// The last `z_listtransactions` failure, shown in place of the list. + pub(crate) error: Option, } pub(crate) async fn on_key(app: &mut App, key: KeyEvent) { @@ -66,6 +68,15 @@ fn render_list(app: &App, frame: &mut Frame<'_>, area: Rect) { let page = app.transactions.offset / TX_PAGE_SIZE + 1; let title = format!(" {} ", crate::fl!("tui-tx-title", page = page)); + if let Some(error) = &app.transactions.error { + let p = Paragraph::new(crate::fl!("tui-tx-error", error = error.clone())) + .block(Block::default().borders(Borders::ALL).title(title)) + .style(Style::default().fg(Color::Red)) + .wrap(Wrap { trim: true }); + frame.render_widget(p, area); + return; + } + if app.data.transactions.is_empty() { let p = Paragraph::new(crate::fl!("tui-tx-empty")) .block(Block::default().borders(Borders::ALL).title(title)) @@ -141,8 +152,19 @@ fn render_detail(app: &App, frame: &mut Frame<'_>, area: Rect) { if let Some(fee) = tx.fee_paid { lines.push(field(&crate::fl!("tui-tx-field-fee"), format_zat(fee))); } - if let Some(t) = tx.block_time { - lines.push(field(&crate::fl!("tui-tx-field-block-time"), t.to_string())); + // Prefer the RFC 3339 datetime the wallet now reports; fall back to the raw + // Unix timestamp for older servers. + match (&tx.block_datetime, tx.block_time) { + (Some(datetime), _) => { + lines.push(field( + &crate::fl!("tui-tx-field-block-time"), + datetime.clone(), + )); + } + (None, Some(t)) => { + lines.push(field(&crate::fl!("tui-tx-field-block-time"), t.to_string())); + } + (None, None) => {} } if let Some(uuid) = &tx.account_uuid { lines.push(field(&crate::fl!("tui-tx-field-account"), uuid.clone()));