Skip to main content

bee_tui/watch/
mod.rs

1#![allow(dead_code)] // wired into App + Health screen in the next commits.
2
3//! k9s-style watch / informer layer.
4//!
5//! One [`BeeWatch`] hub spawns a polling task per resource group;
6//! each task pushes fresh snapshots into a [`tokio::sync::watch`]
7//! channel. Screens subscribe via [`watch::Receiver`] handles and
8//! render the latest snapshot — they never poll directly.
9//!
10//! The cancellation tree mirrors `docs/PLAN.md` § 6: every poller's
11//! token is a child of the hub's, which is a child of the App's
12//! root. Quitting cancels the root and unwinds everything; switching
13//! profile (v0.4) drops one hub and starts another.
14//!
15//! Refresh policy is per resource group, not global — `tig`-style
16//! (`docs/PLAN.md` § 3 principle 7).
17
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20
21use bee::api::Tag;
22use bee::debug::{
23    Addresses, ChainState, ChequebookBalance, LastCheque, RedistributionState, Settlements, Status,
24    Topology, TransactionInfo, Wallet,
25};
26use bee::postage::PostageBatch;
27use num_bigint::BigInt;
28use tokio::sync::watch;
29use tokio_util::sync::CancellationToken;
30
31use crate::api::ApiClient;
32
33/// Snapshot fed to the Health screen and the connection-status bar.
34/// Updated together because the gates need a coherent view across
35/// `/status`, `/chainstate`, `/wallet`, and `/redistributionstate`.
36#[derive(Clone, Debug, Default)]
37pub struct HealthSnapshot {
38    pub status: Option<Status>,
39    pub chain_state: Option<ChainState>,
40    pub wallet: Option<Wallet>,
41    pub redistribution: Option<RedistributionState>,
42    /// Round-trip time of the last `/health` ping; `None` until the
43    /// first poll completes or after a transport failure.
44    pub last_ping: Option<Duration>,
45    /// One-line description of the most recent fetch error, if any.
46    /// Cleared on every successful refresh.
47    pub last_error: Option<String>,
48    /// Wall-clock instant of the last successful poll. Used to grey
49    /// out stale data when the link drops.
50    pub last_update: Option<Instant>,
51}
52
53impl HealthSnapshot {
54    /// True iff every required field is populated and there is no
55    /// recorded error. Used by the connection-status indicator.
56    pub fn is_fully_loaded(&self) -> bool {
57        self.last_error.is_none()
58            && self.status.is_some()
59            && self.chain_state.is_some()
60            && self.wallet.is_some()
61            && self.redistribution.is_some()
62    }
63}
64
65/// Snapshot fed to the S2 Stamps screen. `/stamps` polled at the
66/// slower 10 s cadence per `docs/PLAN.md` § 9 — postage state is
67/// updated on chain, not at request rate.
68#[derive(Clone, Debug, Default)]
69pub struct StampsSnapshot {
70    pub batches: Vec<PostageBatch>,
71    pub last_error: Option<String>,
72    pub last_update: Option<Instant>,
73}
74
75impl StampsSnapshot {
76    pub fn is_loaded(&self) -> bool {
77        self.last_update.is_some() && self.last_error.is_none()
78    }
79}
80
81/// Snapshot fed to the S3 SWAP / cheques screen. `/chequebook/*` and
82/// `/settlements` are slow-changing — chain-rate at most — so the
83/// poll cadence is 30 s per `docs/PLAN.md` § 9.
84#[derive(Clone, Debug, Default)]
85pub struct SwapSnapshot {
86    pub chequebook: Option<ChequebookBalance>,
87    pub settlements: Option<Settlements>,
88    pub time_settlements: Option<Settlements>,
89    /// Last received cheque per peer (from `/chequebook/cheque`).
90    pub last_received: Vec<LastCheque>,
91    pub last_error: Option<String>,
92    pub last_update: Option<Instant>,
93}
94
95impl SwapSnapshot {
96    pub fn is_loaded(&self) -> bool {
97        self.last_update.is_some() && self.last_error.is_none()
98    }
99}
100
101/// Snapshot fed to the S9 Tags / uploads screen. `/tags` is polled
102/// at 5 s — slow enough to be cheap on a quiet node, quick enough
103/// that an in-progress upload's split / sent / synced columns visibly
104/// tick. PLAN proposes 1 s when uploads are active; bumping the
105/// cadence dynamically can land in a follow-up once we observe real
106/// usage.
107#[derive(Clone, Debug, Default)]
108pub struct TagsSnapshot {
109    pub tags: Vec<Tag>,
110    pub last_error: Option<String>,
111    pub last_update: Option<Instant>,
112}
113
114impl TagsSnapshot {
115    pub fn is_loaded(&self) -> bool {
116        self.last_update.is_some() && self.last_error.is_none()
117    }
118}
119
120/// Snapshot fed to the S8 RPC / API health screen. `/transactions`
121/// only changes when the operator submits something (postage topup,
122/// stake deposit, withdrawal, etc.); 30 s cadence is the same tier
123/// as SWAP and Lottery — slow enough to be cheap, quick enough that
124/// a stuck pending TX shows up within a tick of submission.
125#[derive(Clone, Debug, Default)]
126pub struct TransactionsSnapshot {
127    pub pending: Vec<TransactionInfo>,
128    pub last_error: Option<String>,
129    pub last_update: Option<Instant>,
130}
131
132impl TransactionsSnapshot {
133    pub fn is_loaded(&self) -> bool {
134        self.last_update.is_some() && self.last_error.is_none()
135    }
136}
137
138/// Snapshot fed to the S7 Network/NAT screen. `/addresses` doesn't
139/// change unless the node restarts, so the cadence is 60 s — slow
140/// enough to be invisible in the command-log pane but quick enough
141/// to catch a restart-induced overlay change.
142#[derive(Clone, Debug, Default)]
143pub struct NetworkSnapshot {
144    pub addresses: Option<Addresses>,
145    pub last_error: Option<String>,
146    pub last_update: Option<Instant>,
147}
148
149impl NetworkSnapshot {
150    pub fn is_loaded(&self) -> bool {
151        self.addresses.is_some() && self.last_error.is_none()
152    }
153}
154
155/// Snapshot fed to the S6 Peers screen and the S1 bin-saturation
156/// gate. `/topology` is polled at 5 s — per-bin populations don't
157/// drift faster than peer churn, but the operator does want to see
158/// "bin 4 starving" go yellow within a few ticks of the issue.
159#[derive(Clone, Debug, Default)]
160pub struct TopologySnapshot {
161    pub topology: Option<Topology>,
162    pub last_error: Option<String>,
163    pub last_update: Option<Instant>,
164}
165
166impl TopologySnapshot {
167    pub fn is_loaded(&self) -> bool {
168        self.topology.is_some() && self.last_error.is_none()
169    }
170}
171
172/// Snapshot fed to the S4 Lottery screen. `/stake` is operator-driven
173/// (deposit / withdraw transactions only) so the cadence is 30 s per
174/// `docs/PLAN.md` § 9 — same as SWAP. The redistribution-state half of
175/// the screen is read off the existing 2 s [`HealthSnapshot`] feed; the
176/// Lottery component subscribes to both.
177#[derive(Clone, Debug, Default)]
178pub struct LotterySnapshot {
179    /// `/stake` — currently staked BZZ (PLUR).
180    pub staked: Option<BigInt>,
181    pub last_error: Option<String>,
182    pub last_update: Option<Instant>,
183}
184
185impl LotterySnapshot {
186    pub fn is_loaded(&self) -> bool {
187        self.last_update.is_some() && self.last_error.is_none()
188    }
189}
190
191/// Watch-channel hub. Owns one [`watch::Sender`] per resource group;
192/// hands out clones of the receiver via `health()` / `stamps()` /
193/// `swap()` / `lottery()` / `topology()` / `network()` etc.
194#[derive(Clone, Debug)]
195pub struct BeeWatch {
196    health_rx: watch::Receiver<HealthSnapshot>,
197    stamps_rx: watch::Receiver<StampsSnapshot>,
198    swap_rx: watch::Receiver<SwapSnapshot>,
199    lottery_rx: watch::Receiver<LotterySnapshot>,
200    topology_rx: watch::Receiver<TopologySnapshot>,
201    network_rx: watch::Receiver<NetworkSnapshot>,
202    transactions_rx: watch::Receiver<TransactionsSnapshot>,
203    tags_rx: watch::Receiver<TagsSnapshot>,
204    cancel: CancellationToken,
205}
206
207impl BeeWatch {
208    /// Spawn the polling tasks. The returned hub stays alive (and
209    /// pollers keep running) until `shutdown()` is called or `cancel`
210    /// is cancelled by the caller's parent.
211    pub fn start(client: Arc<ApiClient>, parent_cancel: &CancellationToken) -> Self {
212        let cancel = parent_cancel.child_token();
213        let (health_tx, health_rx) = watch::channel(HealthSnapshot::default());
214        spawn_health_poller(
215            client.clone(),
216            health_tx,
217            cancel.clone(),
218            Duration::from_secs(2),
219        );
220        let (stamps_tx, stamps_rx) = watch::channel(StampsSnapshot::default());
221        spawn_stamps_poller(
222            client.clone(),
223            stamps_tx,
224            cancel.clone(),
225            Duration::from_secs(10),
226        );
227        let (swap_tx, swap_rx) = watch::channel(SwapSnapshot::default());
228        spawn_swap_poller(
229            client.clone(),
230            swap_tx,
231            cancel.clone(),
232            Duration::from_secs(30),
233        );
234        let (lottery_tx, lottery_rx) = watch::channel(LotterySnapshot::default());
235        spawn_lottery_poller(
236            client.clone(),
237            lottery_tx,
238            cancel.clone(),
239            Duration::from_secs(30),
240        );
241        let (topology_tx, topology_rx) = watch::channel(TopologySnapshot::default());
242        spawn_topology_poller(
243            client.clone(),
244            topology_tx,
245            cancel.clone(),
246            Duration::from_secs(5),
247        );
248        let (network_tx, network_rx) = watch::channel(NetworkSnapshot::default());
249        spawn_network_poller(
250            client.clone(),
251            network_tx,
252            cancel.clone(),
253            Duration::from_secs(60),
254        );
255        let (transactions_tx, transactions_rx) =
256            watch::channel(TransactionsSnapshot::default());
257        spawn_transactions_poller(
258            client.clone(),
259            transactions_tx,
260            cancel.clone(),
261            Duration::from_secs(30),
262        );
263        let (tags_tx, tags_rx) = watch::channel(TagsSnapshot::default());
264        spawn_tags_poller(client, tags_tx, cancel.clone(), Duration::from_secs(5));
265        Self {
266            health_rx,
267            stamps_rx,
268            swap_rx,
269            lottery_rx,
270            topology_rx,
271            network_rx,
272            transactions_rx,
273            tags_rx,
274            cancel,
275        }
276    }
277
278    /// Subscribe to the health snapshot stream. Cheap; cloning the
279    /// receiver does not start a new poller.
280    pub fn health(&self) -> watch::Receiver<HealthSnapshot> {
281        self.health_rx.clone()
282    }
283
284    /// Subscribe to the stamps snapshot stream.
285    pub fn stamps(&self) -> watch::Receiver<StampsSnapshot> {
286        self.stamps_rx.clone()
287    }
288
289    /// Subscribe to the swap snapshot stream.
290    pub fn swap(&self) -> watch::Receiver<SwapSnapshot> {
291        self.swap_rx.clone()
292    }
293
294    /// Subscribe to the lottery snapshot stream (`/stake`).
295    pub fn lottery(&self) -> watch::Receiver<LotterySnapshot> {
296        self.lottery_rx.clone()
297    }
298
299    /// Subscribe to the topology snapshot stream (`/topology`).
300    pub fn topology(&self) -> watch::Receiver<TopologySnapshot> {
301        self.topology_rx.clone()
302    }
303
304    /// Subscribe to the network snapshot stream (`/addresses`).
305    pub fn network(&self) -> watch::Receiver<NetworkSnapshot> {
306        self.network_rx.clone()
307    }
308
309    /// Subscribe to the pending-transactions snapshot stream
310    /// (`/transactions`).
311    pub fn transactions(&self) -> watch::Receiver<TransactionsSnapshot> {
312        self.transactions_rx.clone()
313    }
314
315    /// Subscribe to the tags snapshot stream (`/tags`).
316    pub fn tags(&self) -> watch::Receiver<TagsSnapshot> {
317        self.tags_rx.clone()
318    }
319
320    /// Cancel every polling task this hub owns. Idempotent.
321    pub fn shutdown(&self) {
322        self.cancel.cancel();
323    }
324}
325
326/// Poll `/status` + `/chainstate` + `/wallet` + `/redistributionstate`
327/// every `interval` and broadcast a coherent [`HealthSnapshot`].
328fn spawn_health_poller(
329    client: Arc<ApiClient>,
330    tx: watch::Sender<HealthSnapshot>,
331    cancel: CancellationToken,
332    interval: Duration,
333) {
334    tokio::spawn(async move {
335        let mut tick = tokio::time::interval(interval);
336        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
337        loop {
338            tokio::select! {
339                _ = cancel.cancelled() => break,
340                _ = tick.tick() => {
341                    let snap = collect_health(&client).await;
342                    if tx.send(snap).is_err() {
343                        break; // no receivers; nobody cares anymore
344                    }
345                }
346            }
347        }
348    });
349}
350
351/// Poll `/stamps` every `interval` and broadcast a fresh
352/// [`StampsSnapshot`].
353fn spawn_stamps_poller(
354    client: Arc<ApiClient>,
355    tx: watch::Sender<StampsSnapshot>,
356    cancel: CancellationToken,
357    interval: Duration,
358) {
359    tokio::spawn(async move {
360        let mut tick = tokio::time::interval(interval);
361        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
362        loop {
363            tokio::select! {
364                _ = cancel.cancelled() => break,
365                _ = tick.tick() => {
366                    let snap = collect_stamps(&client).await;
367                    if tx.send(snap).is_err() {
368                        break;
369                    }
370                }
371            }
372        }
373    });
374}
375
376async fn collect_stamps(client: &ApiClient) -> StampsSnapshot {
377    match client.bee().postage().get_postage_batches().await {
378        Ok(batches) => StampsSnapshot {
379            batches,
380            last_error: None,
381            last_update: Some(Instant::now()),
382        },
383        Err(e) => StampsSnapshot {
384            batches: Vec::new(),
385            last_error: Some(format!("stamps: {e}")),
386            last_update: Some(Instant::now()),
387        },
388    }
389}
390
391/// Poll the four `/chequebook` + `/settlement` endpoints every
392/// `interval` and broadcast a fresh [`SwapSnapshot`].
393fn spawn_swap_poller(
394    client: Arc<ApiClient>,
395    tx: watch::Sender<SwapSnapshot>,
396    cancel: CancellationToken,
397    interval: Duration,
398) {
399    tokio::spawn(async move {
400        let mut tick = tokio::time::interval(interval);
401        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
402        loop {
403            tokio::select! {
404                _ = cancel.cancelled() => break,
405                _ = tick.tick() => {
406                    let snap = collect_swap(&client).await;
407                    if tx.send(snap).is_err() {
408                        break;
409                    }
410                }
411            }
412        }
413    });
414}
415
416async fn collect_swap(client: &ApiClient) -> SwapSnapshot {
417    let bee = client.bee();
418    let chequebook = bee.debug().chequebook_balance().await;
419    let settlements = bee.debug().settlements().await;
420    let time_settlements = bee.debug().time_settlements().await;
421    let last_received = bee.debug().last_cheques().await;
422
423    let mut snap = SwapSnapshot {
424        last_update: Some(Instant::now()),
425        ..Default::default()
426    };
427    let mut errors: Vec<String> = Vec::new();
428    match chequebook {
429        Ok(c) => snap.chequebook = Some(c),
430        Err(e) => errors.push(format!("chequebook: {e}")),
431    }
432    match settlements {
433        Ok(s) => snap.settlements = Some(s),
434        Err(e) => errors.push(format!("settlements: {e}")),
435    }
436    match time_settlements {
437        Ok(s) => snap.time_settlements = Some(s),
438        Err(e) => errors.push(format!("timesettlements: {e}")),
439    }
440    match last_received {
441        Ok(v) => snap.last_received = v,
442        Err(e) => errors.push(format!("cheques: {e}")),
443    }
444    if !errors.is_empty() {
445        snap.last_error = Some(errors.join("; "));
446    }
447    snap
448}
449
450/// Poll `/stake` every `interval` and broadcast a fresh
451/// [`LotterySnapshot`].
452fn spawn_lottery_poller(
453    client: Arc<ApiClient>,
454    tx: watch::Sender<LotterySnapshot>,
455    cancel: CancellationToken,
456    interval: Duration,
457) {
458    tokio::spawn(async move {
459        let mut tick = tokio::time::interval(interval);
460        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
461        loop {
462            tokio::select! {
463                _ = cancel.cancelled() => break,
464                _ = tick.tick() => {
465                    let snap = collect_lottery(&client).await;
466                    if tx.send(snap).is_err() {
467                        break;
468                    }
469                }
470            }
471        }
472    });
473}
474
475async fn collect_lottery(client: &ApiClient) -> LotterySnapshot {
476    match client.bee().debug().stake().await {
477        Ok(staked) => LotterySnapshot {
478            staked: Some(staked),
479            last_error: None,
480            last_update: Some(Instant::now()),
481        },
482        Err(e) => LotterySnapshot {
483            staked: None,
484            last_error: Some(format!("stake: {e}")),
485            last_update: Some(Instant::now()),
486        },
487    }
488}
489
490/// Poll `/topology` every `interval` and broadcast a fresh
491/// [`TopologySnapshot`].
492fn spawn_topology_poller(
493    client: Arc<ApiClient>,
494    tx: watch::Sender<TopologySnapshot>,
495    cancel: CancellationToken,
496    interval: Duration,
497) {
498    tokio::spawn(async move {
499        let mut tick = tokio::time::interval(interval);
500        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
501        loop {
502            tokio::select! {
503                _ = cancel.cancelled() => break,
504                _ = tick.tick() => {
505                    let snap = collect_topology(&client).await;
506                    if tx.send(snap).is_err() {
507                        break;
508                    }
509                }
510            }
511        }
512    });
513}
514
515async fn collect_topology(client: &ApiClient) -> TopologySnapshot {
516    match client.bee().debug().topology().await {
517        Ok(topology) => TopologySnapshot {
518            topology: Some(topology),
519            last_error: None,
520            last_update: Some(Instant::now()),
521        },
522        Err(e) => TopologySnapshot {
523            topology: None,
524            last_error: Some(format!("topology: {e}")),
525            last_update: Some(Instant::now()),
526        },
527    }
528}
529
530/// Poll `/addresses` every `interval` and broadcast a fresh
531/// [`NetworkSnapshot`].
532fn spawn_network_poller(
533    client: Arc<ApiClient>,
534    tx: watch::Sender<NetworkSnapshot>,
535    cancel: CancellationToken,
536    interval: Duration,
537) {
538    tokio::spawn(async move {
539        let mut tick = tokio::time::interval(interval);
540        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
541        loop {
542            tokio::select! {
543                _ = cancel.cancelled() => break,
544                _ = tick.tick() => {
545                    let snap = collect_network(&client).await;
546                    if tx.send(snap).is_err() {
547                        break;
548                    }
549                }
550            }
551        }
552    });
553}
554
555async fn collect_network(client: &ApiClient) -> NetworkSnapshot {
556    match client.bee().debug().addresses().await {
557        Ok(addresses) => NetworkSnapshot {
558            addresses: Some(addresses),
559            last_error: None,
560            last_update: Some(Instant::now()),
561        },
562        Err(e) => NetworkSnapshot {
563            addresses: None,
564            last_error: Some(format!("addresses: {e}")),
565            last_update: Some(Instant::now()),
566        },
567    }
568}
569
570/// Poll `/transactions` every `interval` and broadcast a fresh
571/// [`TransactionsSnapshot`].
572fn spawn_transactions_poller(
573    client: Arc<ApiClient>,
574    tx: watch::Sender<TransactionsSnapshot>,
575    cancel: CancellationToken,
576    interval: Duration,
577) {
578    tokio::spawn(async move {
579        let mut tick = tokio::time::interval(interval);
580        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
581        loop {
582            tokio::select! {
583                _ = cancel.cancelled() => break,
584                _ = tick.tick() => {
585                    let snap = collect_transactions(&client).await;
586                    if tx.send(snap).is_err() {
587                        break;
588                    }
589                }
590            }
591        }
592    });
593}
594
595async fn collect_transactions(client: &ApiClient) -> TransactionsSnapshot {
596    match client.bee().debug().pending_transactions().await {
597        Ok(pending) => TransactionsSnapshot {
598            pending,
599            last_error: None,
600            last_update: Some(Instant::now()),
601        },
602        Err(e) => TransactionsSnapshot {
603            pending: Vec::new(),
604            last_error: Some(format!("transactions: {e}")),
605            last_update: Some(Instant::now()),
606        },
607    }
608}
609
610/// Poll `/tags` every `interval` and broadcast a fresh
611/// [`TagsSnapshot`].
612fn spawn_tags_poller(
613    client: Arc<ApiClient>,
614    tx: watch::Sender<TagsSnapshot>,
615    cancel: CancellationToken,
616    interval: Duration,
617) {
618    tokio::spawn(async move {
619        let mut tick = tokio::time::interval(interval);
620        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
621        loop {
622            tokio::select! {
623                _ = cancel.cancelled() => break,
624                _ = tick.tick() => {
625                    let snap = collect_tags(&client).await;
626                    if tx.send(snap).is_err() {
627                        break;
628                    }
629                }
630            }
631        }
632    });
633}
634
635async fn collect_tags(client: &ApiClient) -> TagsSnapshot {
636    match client.bee().api().list_tags(None, None).await {
637        Ok(tags) => TagsSnapshot {
638            tags,
639            last_error: None,
640            last_update: Some(Instant::now()),
641        },
642        Err(e) => TagsSnapshot {
643            tags: Vec::new(),
644            last_error: Some(format!("tags: {e}")),
645            last_update: Some(Instant::now()),
646        },
647    }
648}
649
650async fn collect_health(client: &ApiClient) -> HealthSnapshot {
651    let bee = client.bee();
652
653    // Time the cheap /health probe alongside the rest so the header
654    // bar can show a single representative latency.
655    let ping_start = Instant::now();
656    let health_ok = bee.debug().health().await.is_ok();
657    let last_ping = health_ok.then(|| ping_start.elapsed());
658
659    let status = bee.debug().status().await;
660    let chain_state = bee.debug().chain_state().await;
661    let wallet = bee.debug().wallet().await;
662    let redistribution = bee.debug().redistribution_state().await;
663
664    let mut snap = HealthSnapshot {
665        last_ping,
666        last_update: Some(Instant::now()),
667        ..Default::default()
668    };
669    let mut errors: Vec<String> = Vec::new();
670    match status {
671        Ok(s) => snap.status = Some(s),
672        Err(e) => errors.push(format!("status: {e}")),
673    }
674    match chain_state {
675        Ok(c) => snap.chain_state = Some(c),
676        Err(e) => errors.push(format!("chainstate: {e}")),
677    }
678    match wallet {
679        Ok(w) => snap.wallet = Some(w),
680        Err(e) => errors.push(format!("wallet: {e}")),
681    }
682    match redistribution {
683        Ok(r) => snap.redistribution = Some(r),
684        Err(e) => errors.push(format!("redistributionstate: {e}")),
685    }
686    if !errors.is_empty() {
687        snap.last_error = Some(errors.join("; "));
688    }
689    snap
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695
696    #[test]
697    fn fully_loaded_default_is_false() {
698        assert!(!HealthSnapshot::default().is_fully_loaded());
699    }
700
701    #[test]
702    fn fully_loaded_requires_no_error_and_all_fields() {
703        // ChainState and Wallet don't implement Default; build empty
704        // instances via JSON to keep the test self-contained.
705        let snap = HealthSnapshot {
706            status: Some(Status::default()),
707            chain_state: Some(serde_json::from_str(r#"{"block":0,"chainTip":0}"#).unwrap()),
708            wallet: Some(
709                serde_json::from_str(
710                    r#"{"chainID":1,"walletAddress":"0x0000000000000000000000000000000000000000"}"#,
711                )
712                .unwrap(),
713            ),
714            redistribution: Some(RedistributionState::default()),
715            ..Default::default()
716        };
717        assert!(snap.is_fully_loaded());
718        let mut bad = snap;
719        bad.last_error = Some("boom".into());
720        assert!(!bad.is_fully_loaded());
721    }
722}