polyc-payments 2026.9.0

Machine Payments Protocol (MPP/Tempo) integration for polychrome: the control-plane composition/glue layer over the standalone outbound, inbound, wallet-delegation, egress, and spend-policy primitive crates, plus the payment proxy/wallet views.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
//! Pure wallet self-service views.
//!
//! The wallet-state machine and the JSON renderings the wallet tools
//! (`wallet_status`, `wallet_history`, `wallet_roster`, `wallet_link`) reply
//! with.
//!
//! Everything here is pure — plain view structs in, `serde_json::Value` out —
//! so the exact JSON the model sees is unit-tested without a persona host, an
//! RPC endpoint, or a journal. The control plane keeps only the I/O shell that
//! gathers these views (persona store, chain balance, event-log receipts) and
//! calls the renderers.
//!
//! The wording in these renders is user-facing copy: each state is worded in
//! exactly one place here, so no two surfaces ever word the same state
//! differently.

use serde_json::{Value, json};

use polyc_payments_client::resolver::DelegatedKeyResolution;

/// The renewal-cause fragment this module's [`status_summary`] uses for
/// [`WalletState::NeedsRelink`] — hand-synchronized with
/// `polyc_proto::tool_display`'s `WALLET_LINK_RENEWAL_LEAD` (`#2122`).
/// `polyc-payments` is a plain component and `polyc-proto` a foundation, so
/// the dependency direction is allowed, but the card-copy constants are
/// private to that module — pulling the whole `tool_display` surface in for
/// one string is not worth the coupling, so the words are kept in sync by
/// convention instead, mirroring `polyc_proto::tool_display`'s own
/// `DELEGATE_TOOL_NAME` cross-layer duplication pattern. Both this fragment
/// and `WALLET_LINK_RENEWAL_LEAD` MUST read "spending access expired": the
/// model-facing `wallet_status` text and the chat "renew access" card must
/// never contradict each other on the same state — the exact bug (`"is
/// linked, but... needs to be relinked"`) a live incident surfaced.
const WALLET_NEEDS_RELINK_CAUSE: &str = "spending access expired";

/// The usability of a caller's linked wallet, reduced from the wallet-link record
/// plus the already-resolved onchain key resolution to the one thing a reader
/// cares about: can it pay, and if not, why.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WalletState {
    /// No live wallet-link record for this caller.
    NotLinked,
    /// Linked and onchain usable — ready to pay from the caller's own wallet.
    Ready,
    /// Linked, but the delegated key can't pay right now (unregistered, expired,
    /// or revoked). A re-link heals it.
    NeedsRelink,
    /// A transient failure prevented resolving the wallet (store-read or onchain
    /// probe error, or payments not configured). Try again shortly.
    Unavailable,
}

impl WalletState {
    /// The stable machine-readable status token a caller branches on.
    ///
    /// The one place each state is named, so the chat surface and the browser
    /// can never word the same state differently.
    #[must_use]
    pub const fn token(self) -> &'static str {
        match self {
            Self::NotLinked => "not_linked",
            Self::Ready => "ready",
            Self::NeedsRelink => "needs_relink",
            Self::Unavailable => "temporarily_unavailable",
        }
    }
}

/// Reduce a wallet-link record's presence and the already-resolved key resolution
/// to a single [`WalletState`]. Pure, so the state machine is unit-tested.
///
/// `has_link` is whether the persona store returned a live wallet-link record;
/// `resolution` is the onchain usability the payment path already computed for
/// this caller. A record present but resolution `Unlinked` means payments aren't
/// configured for this deployment — the wallet itself is linked but can't pay, so
/// that reads as [`Unavailable`](WalletState::Unavailable), never `NotLinked`
/// (which would wrongly tell a linked caller to link again).
#[must_use]
pub const fn wallet_state(has_link: bool, resolution: &DelegatedKeyResolution) -> WalletState {
    match (has_link, resolution) {
        (false, _) => WalletState::NotLinked,
        (true, DelegatedKeyResolution::Usable(_)) => WalletState::Ready,
        (true, DelegatedKeyResolution::LinkedButUnusable) => WalletState::NeedsRelink,
        (
            true,
            DelegatedKeyResolution::TemporarilyUnavailable(_) | DelegatedKeyResolution::Unlinked,
        ) => WalletState::Unavailable,
    }
}

/// A one-time spend cap (no periodic reset).
///
/// The reset periods a persona's spend policy can be set to are mapped to
/// fixed second counts here so `wallet_set_policy`'s friendly `period` string
/// and this module's phrasing of it can never drift apart — the one place the
/// mapping lives.
pub const PERIOD_ONCE_SECS: u64 = 0;
/// One day, in seconds.
pub const PERIOD_DAY_SECS: u64 = 86_400;
/// One week, in seconds.
pub const PERIOD_WEEK_SECS: u64 = 604_800;
/// One (30-day) month, in seconds — a fixed, unambiguous cadence rather than a
/// calendar month, so the reset period never depends on which month it is.
pub const PERIOD_MONTH_SECS: u64 = 2_592_000;

/// Parse a `wallet_set_policy` `period` argument into its fixed second count.
///
/// Accepts `"once"`, `"day"`, `"week"`, or `"month"`; `None` for anything
/// else, so the caller can refuse an unrecognized period rather than silently
/// defaulting.
#[must_use]
pub fn period_secs_from_str(period: &str) -> Option<u64> {
    match period {
        "once" => Some(PERIOD_ONCE_SECS),
        "day" => Some(PERIOD_DAY_SECS),
        "week" => Some(PERIOD_WEEK_SECS),
        "month" => Some(PERIOD_MONTH_SECS),
        _ => None,
    }
}

/// A persona's spend-policy narrowing, reduced to what a reader needs to hear
/// back in plain language.
///
/// Never the raw store record. Every field empty/zero means "no
/// persona-level narrowing at all," matching an absent policy record;
/// [`policy_summary`] treats the two cases identically.
#[derive(Debug, Clone, Default)]
pub struct SpendPolicyView {
    /// Human-readable PER-PAYMENT ceiling (e.g. `"5"`) — the most a single
    /// payment may cost; empty = the deployment default cap applies.
    pub limit_human: String,
    /// The reset period, in seconds, this narrowing was set with (see the
    /// `PERIOD_*` constants) — stored alongside `limit_human` but not a
    /// bound on it: it configures the delegated key's own onchain,
    /// per-period allowance at mint/renewal time (`wallet_link.rs`), and
    /// carries no effect on `limit_human`'s own per-payment enforcement
    /// (`crates/payments/src/proxy.rs::fulfill`) or on [`policy_summary`]'s
    /// wording (#2365).
    pub period_secs: u64,
    /// Maximum delegation lifetime in seconds; 0 = the deployment default
    /// applies.
    pub max_lifetime_secs: u64,
    /// Hosts the wallet may pay; empty = no persona-level restriction (any
    /// host allowed by the deployment).
    pub allowed_hosts: Vec<String>,
}

/// One or two clear sentences describing a persona's spend-policy narrowing,
/// or `None` when the persona has set no narrowing at all — callers omit the
/// line entirely rather than announcing an absence.
///
/// The limit sentence states a per-payment ceiling only — never a window —
/// because that is the only thing `limit_human` bounds:
/// `crates/payments/src/proxy.rs::fulfill` folds it as a flat cap on every
/// single payment and never reads `period_secs` (#2365). `period_secs` is
/// still stored on the record (it configures the delegated key's own
/// onchain, per-period allowance at mint/renewal time — `wallet_link.rs`),
/// but it bounds nothing this sentence describes, so it never appears here.
#[must_use]
pub fn policy_summary(policy: &SpendPolicyView) -> Option<String> {
    let mut sentences = Vec::new();
    if !policy.limit_human.is_empty() {
        sentences.push(format!(
            "This wallet won't pay more than {} {} in a single payment{}.",
            policy.limit_human,
            crate::amount::SETTLEMENT_SYMBOL,
            hosts_clause(&policy.allowed_hosts),
        ));
    } else if !policy.allowed_hosts.is_empty() {
        sentences.push(format!(
            "This wallet can only pay {}.",
            policy.allowed_hosts.join(", ")
        ));
    }
    if policy.max_lifetime_secs > 0 {
        sentences.push(format!(
            "Its link lapses after {}.",
            lifetime_phrase(policy.max_lifetime_secs)
        ));
    }
    if sentences.is_empty() {
        None
    } else {
        Some(sentences.join(" "))
    }
}

/// The optional `", to api.example.com only"` clause appended to
/// [`policy_summary`]'s per-payment sentence when the caller has also
/// narrowed which hosts the wallet may pay. Omitted entirely (never "any
/// host") when there is no host restriction — the per-payment sentence
/// already stands as a complete claim without one.
fn hosts_clause(allowed_hosts: &[String]) -> String {
    if allowed_hosts.is_empty() {
        String::new()
    } else {
        format!(", to {} only", allowed_hosts.join(", "))
    }
}

/// `"1 day"` / `"7 days"` for a lifetime in seconds, rounded down to whole
/// days (a sub-day lifetime still reads as "1 day" rather than "0 days").
fn lifetime_phrase(max_lifetime_secs: u64) -> String {
    let days = (max_lifetime_secs / PERIOD_DAY_SECS).max(1);
    if days == 1 {
        "1 day".to_owned()
    } else {
        format!("{days} days")
    }
}

/// Which source answered a wallet's remaining onchain spend allowance.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemainingSpendSource {
    /// The chain's own remaining-spend-allowance read — the source of truth
    /// whenever it answers.
    Onchain,
    /// The chain's own read reverts for this key/token pair — a stable
    /// property of the pair, not a transient failure — so this is the
    /// caller's configured spend limit instead. It is a ceiling, not a
    /// spend-adjusted figure: `SpendPolicy` tracks no spent-per-period
    /// amount, so this is never lower than the limit even after spending.
    LocalEstimate,
}

/// A linked wallet's remaining onchain spending allowance, for rendering
/// `wallet_status`.
///
/// Carries the amount and which source answered it, so a caller reading a
/// [`RemainingSpendSource::LocalEstimate`] figure knows it is the configured
/// spend limit, not the chain's spend-adjusted answer.
pub struct RemainingSpendView {
    /// Remaining allowance in the token's base units.
    pub base_units: u128,
    /// Which source produced [`base_units`](Self::base_units).
    pub source: RemainingSpendSource,
}

/// A linked wallet's non-secret facts, for rendering `wallet_status`.
pub struct WalletView {
    /// The wallet's onchain address (`0x…`).
    pub address: String,
    /// The settlement-token contract the link is scoped to (`0x…`).
    pub currency: String,
    /// Balance in the token's base units, when a read succeeded; `None` when the
    /// balance couldn't be read (the rest of the status still renders).
    pub balance_base_units: Option<u128>,
    /// Remaining onchain spend allowance, when a read was attempted and
    /// succeeded; `None` when payments aren't configured, no onchain-usable
    /// delegated key exists to read it for, or the read itself failed — the
    /// rest of the status still renders, exactly like
    /// [`balance_base_units`](Self::balance_base_units).
    pub remaining: Option<RemainingSpendView>,
}

/// Render the `wallet_status` result the model sees. Pure.
///
/// `policy` is the caller's spend-policy narrowing, when one is set; `None`
/// (no policy at all, or one whose fields carry no narrowing) omits the
/// `policy_summary` line entirely rather than announcing an absence.
///
/// `expiry_note` is a caller-formatted human date (e.g. `"Aug 3, 2026"`),
/// meaningful only alongside [`WalletState::NeedsRelink`] — this module
/// stays clock-free and does no date math itself, so the I/O shell
/// (`wallet_nav::status`) formats the wallet-link record's `expiry_unix`
/// before calling in. `None` when the state isn't `NeedsRelink`, or the
/// record carries no expiry to report.
#[must_use]
pub fn render_status(
    state: WalletState,
    wallet: Option<&WalletView>,
    policy: Option<&SpendPolicyView>,
    decimals: u32,
    chain_id: u64,
    explorer_base: &str,
    expiry_note: Option<&str>,
) -> Value {
    let summary = status_summary(state, wallet, expiry_note);
    let mut out = json!({
        "linked": state != WalletState::NotLinked,
        "status": state.token(),
        "summary": summary,
    });
    if let Some(policy_line) = policy.and_then(policy_summary) {
        out["policy_summary"] = json!(policy_line);
    }
    if let Some(w) = wallet {
        let mut wallet_json = json!({
            "address": w.address,
            "currency": w.currency,
            "network": network_name(chain_id),
        });
        if let Some(url) =
            polyc_payments_client::explorer::evm_address_explorer_url(explorer_base, &w.address)
        {
            wallet_json["explorer"] = json!(url);
        }
        if let Some(bal) = w.balance_base_units {
            wallet_json["balance"] = json!(crate::amount::format_settled_amount(bal, decimals));
        } else {
            wallet_json["balance_note"] = json!("Balance couldn't be read just now.");
        }
        if let Some(remaining) = &w.remaining {
            let mut remaining_json = json!({
                "amount": crate::amount::format_settled_amount(remaining.base_units, decimals),
            });
            // Kept under the same `remaining_allowance` key as the onchain case
            // rather than a separate label (e.g. "spending_cap"): the model and
            // the reader both want one answer to "how much can this wallet still
            // spend," and a second key would force them to pick between two
            // fields with no way to tell which one is live for this wallet. The
            // note is the disambiguator instead — it says plainly that this
            // figure is the configured limit, not a spend-adjusted read.
            if remaining.source == RemainingSpendSource::LocalEstimate {
                remaining_json["note"] = json!(
                    "This is your configured spending limit, not the exact amount left — the chain doesn't report a remaining allowance for this wallet."
                );
            }
            wallet_json["remaining_allowance"] = remaining_json;
        }
        out["wallet"] = wallet_json;
    }
    out
}

/// One clear sentence describing the wallet state — the single place this wording
/// lives, so the same state is never worded two ways.
///
/// `NeedsRelink` deliberately never says the wallet is "linked" — a live
/// incident (`#2122`) found that framing self-contradictory next to its own
/// "needs relink" instruction ("is linked, but... needs to be relinked"). It
/// names the cause instead ([`WALLET_NEEDS_RELINK_CAUSE`]), with the date
/// when `expiry_note` carries one, and asks for a renewal — the same
/// vocabulary the chat wallet-link card uses for the identical state.
fn status_summary(
    state: WalletState,
    wallet: Option<&WalletView>,
    expiry_note: Option<&str>,
) -> String {
    let short = wallet.map_or_else(|| "your wallet".to_owned(), |w| shorten(&w.address));
    match state {
        WalletState::NotLinked => {
            "No spending wallet is linked yet. Link one and it can pay for things on your behalf."
                .to_owned()
        }
        WalletState::Ready => format!("Your spending wallet {short} is linked and ready to pay."),
        WalletState::NeedsRelink => {
            let when = expiry_note.map_or_else(String::new, |d| format!(" on {d}"));
            format!("Your {WALLET_NEEDS_RELINK_CAUSE} for {short}{when} — renew it to keep paying.")
        }
        WalletState::Unavailable => {
            "Couldn't check your spending wallet just now. Try again in a moment.".to_owned()
        }
    }
}

/// A settled payment's non-secret facts, for rendering `wallet_history`.
pub struct PaymentView {
    /// Onchain settlement reference (tx hash) or native id.
    pub reference: String,
    /// Charged amount in the settlement token's base units.
    pub amount_base_units: u128,
    /// Payment method (e.g. `"tempo"`).
    pub method: String,
    /// Settlement timestamp.
    pub timestamp: String,
}

/// Render the `wallet_history` result the model sees, newest first and capped to
/// `limit`. Pure.
#[must_use]
pub fn render_history(
    payments: &[PaymentView],
    limit: usize,
    decimals: u32,
    explorer_base: &str,
) -> Value {
    let shown: Vec<Value> = payments
        .iter()
        .take(limit)
        .map(|p| {
            let mut entry = json!({
                "amount": crate::amount::format_settled_amount(p.amount_base_units, decimals),
                "reference": p.reference,
                "method": p.method,
                "settled_at": p.timestamp,
            });
            if let Some(url) =
                polyc_payments_client::explorer::evm_tx_explorer_url(explorer_base, &p.reference)
            {
                entry["explorer"] = json!(url);
            }
            entry
        })
        .collect();
    let note = if shown.is_empty() {
        "You haven't made any payments in this conversation yet."
    } else {
        "Payments you've made in this conversation."
    };
    json!({
        "count": shown.len(),
        "payments": shown,
        "note": note,
    })
}

/// One participant's wallet facts, for rendering the admin roster.
pub struct RosterEntry {
    /// A human label for the participant (display name, external id, or a
    /// shortened persona id).
    pub who: String,
    /// The participant's linked wallet address, when one is linked.
    pub address: Option<String>,
    /// The linked wallet's balance in base units, when it could be read.
    pub balance_base_units: Option<u128>,
}

/// Render the `wallet_roster` result. Pure.
#[must_use]
pub fn render_roster(entries: &[RosterEntry], decimals: u32, explorer_base: &str) -> Value {
    let wallets: Vec<Value> = entries
        .iter()
        .map(|e| {
            let mut w = json!({ "who": e.who, "linked": e.address.is_some() });
            if let Some(addr) = &e.address {
                w["address"] = json!(addr);
                if let Some(url) =
                    polyc_payments_client::explorer::evm_address_explorer_url(explorer_base, addr)
                {
                    w["explorer"] = json!(url);
                }
                if let Some(bal) = e.balance_base_units {
                    w["balance"] = json!(crate::amount::format_settled_amount(bal, decimals));
                }
            }
            w
        })
        .collect();
    json!({
        "count": wallets.len(),
        "wallets": wallets,
        "note": "Everyone who's taken part in this conversation, and whether they've linked a spending wallet.",
    })
}

/// Render the `wallet_link` result: the URL plus a short next step.
#[must_use]
pub fn render_link(url: &str) -> String {
    json!({
        "link_url": url,
        "summary": "Here's a secure link to set up your spending wallet. Open it on your device to finish — it only takes a few seconds.",
    })
    .to_string()
}

/// Short form of an address for a one-line summary: `0xAA18…95be`.
///
/// Slices on CHARACTER boundaries, not bytes — a normal address is ASCII hex, but
/// a wallet-link record stored on a degraded path (RPC unset ⇒ no onchain
/// address validation) could carry a non-ASCII string, and byte slicing would
/// panic and unwind the turn.
#[must_use]
pub fn shorten(address: &str) -> String {
    let chars: Vec<char> = address.chars().collect();
    if chars.len() <= 12 {
        return address.to_owned();
    }
    let head: String = chars[..6].iter().collect();
    let tail: String = chars[chars.len() - 4..].iter().collect();
    format!("{head}{tail}")
}

/// Friendly network name for a chain id. Only the Moderato testnet is asserted by
/// name; anything else renders generically rather than risk a wrong label.
fn network_name(chain_id: u64) -> String {
    match chain_id {
        crate::MODERATO_CHAIN_ID => "Tempo Moderato testnet".to_owned(),
        other => format!("Tempo (chain {other})"),
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery)]
    use super::*;
    use polyc_payments_client::resolver::DelegatedKeyRef;

    fn usable() -> DelegatedKeyResolution {
        DelegatedKeyResolution::Usable(DelegatedKeyRef {
            keys_toml: "[[keys]]".to_owned(),
            currency: "0x20c0000000000000000000000000000000000000".to_owned(),
        })
    }

    #[test]
    fn state_machine_covers_every_case() {
        assert_eq!(wallet_state(false, &usable()), WalletState::NotLinked);
        assert_eq!(
            wallet_state(false, &DelegatedKeyResolution::Unlinked),
            WalletState::NotLinked
        );
        assert_eq!(wallet_state(true, &usable()), WalletState::Ready);
        assert_eq!(
            wallet_state(true, &DelegatedKeyResolution::LinkedButUnusable),
            WalletState::NeedsRelink
        );
        assert_eq!(
            wallet_state(
                true,
                &DelegatedKeyResolution::TemporarilyUnavailable("x".into())
            ),
            WalletState::Unavailable
        );
        // Record present but resolver said Unlinked (payments not configured) must
        // NOT read as not-linked — that would tell a linked caller to re-link.
        assert_eq!(
            wallet_state(true, &DelegatedKeyResolution::Unlinked),
            WalletState::Unavailable
        );
    }

    fn wv(balance: Option<u128>) -> WalletView {
        WalletView {
            address: "0xAA187760A4178Bd4e027F253e2Aae8B2261995be".to_owned(),
            currency: "0x20c0000000000000000000000000000000000000".to_owned(),
            balance_base_units: balance,
            remaining: None,
        }
    }

    fn wv_with_remaining(remaining: RemainingSpendView) -> WalletView {
        WalletView {
            remaining: Some(remaining),
            ..wv(Some(0))
        }
    }

    #[test]
    fn ready_status_shows_address_balance_explorer_and_network() {
        let v = render_status(
            WalletState::Ready,
            Some(&wv(Some(12_500_000))),
            None,
            6,
            42431,
            "https://explore.testnet.tempo.xyz",
            None,
        );
        assert_eq!(v["linked"], true);
        assert_eq!(v["status"], "ready");
        assert_eq!(
            v["wallet"]["address"],
            "0xAA187760A4178Bd4e027F253e2Aae8B2261995be"
        );
        assert_eq!(v["wallet"]["balance"], "12.5 USD");
        assert_eq!(v["wallet"]["network"], "Tempo Moderato testnet");
        // The explorer helper lowercases the address for a canonical link.
        assert!(
            v["wallet"]["explorer"]
                .as_str()
                .unwrap()
                .contains("/address/0xaa18"),
            "{v}"
        );
        assert!(v["summary"].as_str().unwrap().contains("ready to pay"));
        // The short form appears in the summary.
        assert!(
            v["summary"].as_str().unwrap().contains("0xAA18…95be"),
            "{v}"
        );
    }

    #[test]
    fn not_linked_status_omits_the_wallet_block() {
        let v = render_status(WalletState::NotLinked, None, None, 6, 42431, "", None);
        assert_eq!(v["linked"], false);
        assert_eq!(v["status"], "not_linked");
        assert!(v.get("wallet").is_none(), "no wallet block when not linked");
        assert!(
            v["summary"]
                .as_str()
                .unwrap()
                .contains("No spending wallet is linked")
        );
    }

    #[test]
    fn unreadable_balance_still_renders_the_wallet_with_a_note() {
        let v = render_status(
            WalletState::Ready,
            Some(&wv(None)),
            None,
            6,
            42431,
            "",
            None,
        );
        assert!(v["wallet"]["balance"].is_null());
        assert_eq!(
            v["wallet"]["balance_note"],
            "Balance couldn't be read just now."
        );
    }

    #[test]
    fn needs_relink_summary_names_the_expired_cause_and_asks_to_renew() {
        let v = render_status(
            WalletState::NeedsRelink,
            Some(&wv(Some(0))),
            None,
            6,
            42431,
            "",
            None,
        );
        assert_eq!(v["status"], "needs_relink");
        let summary = v["summary"].as_str().unwrap();
        assert!(
            summary.contains("spending access expired"),
            "must name the cause, not a bare 'needs relink': {summary}"
        );
        assert!(
            summary.contains("renew"),
            "must ask for a renewal, the same verb the chat card uses: {summary}"
        );
        // `#2122`: the exact self-contradiction a live incident surfaced —
        // never claim the wallet is "linked" while also saying it needs
        // relinking.
        assert!(
            !summary.to_lowercase().contains("is linked"),
            "must never call an unusable wallet \"linked\": {summary}"
        );
    }

    /// `#2122`: when the wallet-link record's expiry is known, the summary
    /// carries the date so the reader knows exactly when access lapsed.
    #[test]
    fn needs_relink_summary_carries_the_expiry_date_when_known() {
        let v = render_status(
            WalletState::NeedsRelink,
            Some(&wv(Some(0))),
            None,
            6,
            42431,
            "",
            Some("Aug 3, 2026"),
        );
        let summary = v["summary"].as_str().unwrap();
        assert!(summary.contains("Aug 3, 2026"), "{summary}");
        assert!(summary.contains("spending access expired"), "{summary}");
    }

    // ---- Remaining onchain spend allowance (issue #1157) ----

    #[test]
    fn ready_status_shows_the_onchain_remaining_allowance_with_no_note() {
        let v = render_status(
            WalletState::Ready,
            Some(&wv_with_remaining(RemainingSpendView {
                base_units: 4_000_000,
                source: RemainingSpendSource::Onchain,
            })),
            None,
            6,
            42431,
            "",
            None,
        );
        assert_eq!(v["wallet"]["remaining_allowance"]["amount"], "4 USD");
        assert!(
            v["wallet"]["remaining_allowance"].get("note").is_none(),
            "the chain's own answer needs no estimate caveat: {v}"
        );
    }

    // The local-estimate note must name this as the configured spend limit —
    // never a transient-sounding "not available just now" (this key/token
    // pair reverts the onchain read every time, not intermittently) and
    // never "estimate" (the value is the full configured cap, not a
    // spend-adjusted figure — `SpendPolicy` tracks no spent-per-period
    // amount to subtract from it).
    #[test]
    fn ready_status_flags_a_local_estimate_with_a_configured_limit_note() {
        let v = render_status(
            WalletState::Ready,
            Some(&wv_with_remaining(RemainingSpendView {
                base_units: 1_500_000,
                source: RemainingSpendSource::LocalEstimate,
            })),
            None,
            6,
            42431,
            "",
            None,
        );
        assert_eq!(v["wallet"]["remaining_allowance"]["amount"], "1.5 USD");
        let note = v["wallet"]["remaining_allowance"]["note"]
            .as_str()
            .expect("local-estimate source carries a note");
        assert!(
            note.contains("configured spending limit"),
            "note must name this as the configured limit, not a live read: {note}"
        );
        assert!(
            !note.contains("just now") && !note.contains("estimate"),
            "note must not imply a transient failure or a spend-adjusted estimate: {note}"
        );
    }

    #[test]
    fn ready_status_omits_remaining_allowance_when_it_could_not_be_read() {
        let v = render_status(
            WalletState::Ready,
            Some(&wv(Some(0))),
            None,
            6,
            42431,
            "",
            None,
        );
        assert!(
            v["wallet"].get("remaining_allowance").is_none(),
            "no fabricated figure when the read failed or wasn't attempted: {v}"
        );
    }

    #[test]
    fn unknown_chain_renders_generically() {
        let v = render_status(
            WalletState::Ready,
            Some(&wv(Some(0))),
            None,
            6,
            99,
            "",
            None,
        );
        assert_eq!(v["wallet"]["network"], "Tempo (chain 99)");
    }

    // A narrowed policy is echoed back in plain language on `wallet_status` —
    // as a per-payment ceiling, never a window: `period_secs` is set here
    // (PERIOD_DAY_SECS) but must NOT appear in the rendered line (#2365).
    #[test]
    fn render_status_includes_the_policy_line() {
        let policy = SpendPolicyView {
            limit_human: "5".to_owned(),
            period_secs: PERIOD_DAY_SECS,
            max_lifetime_secs: PERIOD_DAY_SECS * 7,
            allowed_hosts: vec!["api.example.com".to_owned()],
        };
        let v = render_status(
            WalletState::Ready,
            Some(&wv(Some(0))),
            Some(&policy),
            6,
            42431,
            "",
            None,
        );
        let line = v["policy_summary"]
            .as_str()
            .expect("policy_summary present");
        assert!(
            line.contains("won't pay more than 5 USD in a single payment"),
            "{line}"
        );
        assert!(line.contains("api.example.com only"), "{line}");
        assert!(line.contains("lapses after 7 days"), "{line}");
        assert!(
            !line.contains("per day") && !line.contains("PERIOD"),
            "the per-payment ceiling must never be worded as a window \
             the policy's `period_secs` doesn't actually enforce: {line}"
        );
    }

    // No policy at all (the common case) omits the line entirely — no
    // "no policy set" filler.
    #[test]
    fn render_status_omits_the_policy_line_when_none_is_set() {
        let v = render_status(
            WalletState::Ready,
            Some(&wv(Some(0))),
            None,
            6,
            42431,
            "",
            None,
        );
        assert!(v.get("policy_summary").is_none(), "{v}");
    }

    // A policy record whose fields carry no actual narrowing (every axis at
    // its cleared default) also omits the line — an all-cleared record reads
    // identically to an absent one.
    #[test]
    fn render_status_omits_the_policy_line_when_the_policy_is_all_cleared() {
        let v = render_status(
            WalletState::Ready,
            Some(&wv(Some(0))),
            Some(&SpendPolicyView::default()),
            6,
            42431,
            "",
            None,
        );
        assert!(v.get("policy_summary").is_none(), "{v}");
    }

    #[test]
    fn policy_summary_covers_limit_hosts_and_lifetime_combinations() {
        // No narrowing at all → None.
        assert!(policy_summary(&SpendPolicyView::default()).is_none());

        // Limit only, no host restriction — a per-payment ceiling, worded
        // with no window (the record's own `period_secs` defaults to 0/
        // PERIOD_ONCE_SECS here, but even a non-zero period must never
        // surface in this sentence; see `render_status_includes_the_policy_line`).
        let limit_only = SpendPolicyView {
            limit_human: "5".to_owned(),
            ..Default::default()
        };
        let s = policy_summary(&limit_only).expect("some");
        assert_eq!(
            s,
            "This wallet won't pay more than 5 USD in a single payment."
        );

        // Hosts only, no limit.
        let hosts_only = SpendPolicyView {
            allowed_hosts: vec!["api.example.com".to_owned()],
            ..Default::default()
        };
        let s = policy_summary(&hosts_only).expect("some");
        assert_eq!(s, "This wallet can only pay api.example.com.");

        // Lifetime only.
        let lifetime_only = SpendPolicyView {
            max_lifetime_secs: PERIOD_DAY_SECS,
            ..Default::default()
        };
        let s = policy_summary(&lifetime_only).expect("some");
        assert_eq!(s, "Its link lapses after 1 day.");
    }

    #[test]
    fn history_renders_newest_first_capped_with_explorer_links() {
        let hash_a = "0xabc0000000000000000000000000000000000000000000000000000000000001";
        let hash_b = "0xdef0000000000000000000000000000000000000000000000000000000000002";
        let payments = vec![
            PaymentView {
                reference: hash_a.to_owned(),
                amount_base_units: 100_000,
                method: "tempo".to_owned(),
                timestamp: "2026-07-04T18:59:55Z".to_owned(),
            },
            PaymentView {
                reference: hash_b.to_owned(),
                amount_base_units: 250_000,
                method: "tempo".to_owned(),
                timestamp: "2026-07-04T12:17:00Z".to_owned(),
            },
        ];
        let v = render_history(&payments, 1, 6, "https://explore.testnet.tempo.xyz");
        assert_eq!(v["count"], 1);
        assert_eq!(v["payments"][0]["amount"], "0.1 USD");
        assert_eq!(v["payments"][0]["reference"], hash_a);
        assert!(
            v["payments"][0]["explorer"]
                .as_str()
                .unwrap()
                .contains(hash_a)
        );
        assert_eq!(v["note"], "Payments you've made in this conversation.");
    }

    #[test]
    fn empty_history_says_so_and_is_not_an_error() {
        let v = render_history(&[], 10, 6, "");
        assert_eq!(v["count"], 0);
        assert!(v["payments"].as_array().unwrap().is_empty());
        assert!(
            v["note"]
                .as_str()
                .unwrap()
                .contains("haven't made any payments")
        );
    }

    #[test]
    fn shorten_handles_short_and_long() {
        assert_eq!(shorten("0x1234"), "0x1234");
        assert_eq!(
            shorten("0xAA187760A4178Bd4e027F253e2Aae8B2261995be"),
            "0xAA18…95be"
        );
    }

    #[test]
    fn roster_renders_linked_and_unlinked_participants() {
        let entries = vec![
            RosterEntry {
                who: "Christopher".to_owned(),
                address: Some("0xAA187760A4178Bd4e027F253e2Aae8B2261995be".to_owned()),
                balance_base_units: Some(5_000_000),
            },
            RosterEntry {
                who: "Erica".to_owned(),
                address: None,
                balance_base_units: None,
            },
        ];
        let v = render_roster(&entries, 6, "https://explore.testnet.tempo.xyz");
        assert_eq!(v["count"], 2);
        assert_eq!(v["wallets"][0]["who"], "Christopher");
        assert_eq!(v["wallets"][0]["linked"], true);
        assert_eq!(v["wallets"][0]["balance"], "5 USD");
        assert!(
            v["wallets"][0]["explorer"]
                .as_str()
                .unwrap()
                .contains("/address/0xaa18"),
            "{v}"
        );
        // An unlinked participant is listed but carries no address/balance.
        assert_eq!(v["wallets"][1]["who"], "Erica");
        assert_eq!(v["wallets"][1]["linked"], false);
        assert!(v["wallets"][1].get("address").is_none(), "{v}");
    }

    #[test]
    fn render_link_carries_url_and_next_step() {
        let out = render_link("https://polychrome.sh/wallet/link?token=abc");
        let v: Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v["link_url"], "https://polychrome.sh/wallet/link?token=abc");
        assert!(
            v["summary"]
                .as_str()
                .unwrap()
                .contains("set up your spending wallet"),
            "{v}"
        );
    }
}