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
//! Settlement-amount units: dollars ↔ base units, and reader-facing rendering.
//!
//! One home for the scale conversions every payment control shares. The
//! per-call cap, the conversation budget, the pre-signing spend caps, and the
//! wallet-link limit are all denominated in the settlement token's **base
//! units** at a configured decimal scale, while people configure and read
//! dollar strings — these helpers are the only bridge between the two, so no
//! two controls can scale the same figure differently.
//!
//! # Reading a stored receipt amount (#1739)
//!
//! A signed payment receipt stores its amount as a **string**, and the two
//! settlement directions do not store the same unit:
//!
//! - an **inbound** receipt (`payment_receipt`, "the deployment charged a
//!   caller") stores the per-call price as a decimal dollar figure — `"0.01"`;
//! - an **outbound** receipt (`outbound_payment_receipt`, "the control plane
//!   paid a service on a caller's behalf") stores the charged amount in the
//!   token's base units — `"10000"`, or `""` when the proxy could not capture
//!   it.
//!
//! [`read_dollar_amount`](crate::amount::read_dollar_amount) and
//! [`read_base_unit_amount`](crate::amount::read_base_unit_amount) are the
//! only two ways to turn either of those strings into a number. They are
//! separate functions rather than one function with a unit flag, so the
//! ARITY blocks a swap: the dollar read needs a `decimals` scale and the
//! base-unit read does not, so a caller cannot flip a flag and silently
//! change units. What the arity does NOT block is a mis-dispatched value —
//! `read_dollar_amount("10000", 6)` happily returns `Ok(10_000_000_000)`,
//! reading a stored base-unit figure as ten thousand dollars. Only the
//! opposite mistake is caught by shape
//! ([`read_base_unit_amount`](crate::amount::read_base_unit_amount) refuses a
//! decimal figure). Dispatch on the receipt KIND, never on the string's
//! appearance.
//!
//! Both return the settlement token's base units — at the deployment's
//! configured scale
//! ([`PaymentsConfig::currency_decimals`](crate::config::PaymentsConfig),
//! [`DEFAULT_DECIMALS`](crate::amount::DEFAULT_DECIMALS) only when nothing is
//! configured) — so the two are comparable numbers. Comparable is not
//! interchangeable, though: what a caller settled OUT of their own wallet and
//! what the deployment charged them are different quantities, and summing them
//! produces a figure that answers no question (`polyc-query`'s
//! `dashboard::DashboardSettlement` is the rollup that keeps them apart).
//! A caller that substitutes the default for a configured scale
//! gets a figure that parses and is wrong by a power of ten, which no counter
//! here can catch.
//!
//! Both return a [`Result`], never an `Option` — an unreadable amount is real
//! money that a caller must handle out loud
//! ([`record_unreadable_amount`](crate::amount::record_unreadable_amount)),
//! never `unwrap_or_default` away.

use std::sync::OnceLock;

use prometheus::{IntCounterVec, register_int_counter_vec};

/// Which settlement direction recorded a stored amount.
///
/// The unit discriminator between [`read_dollar_amount`] and
/// [`read_base_unit_amount`], and the bounded `direction` label on
/// `polychrome_settlement_amount_unreadable_total`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettlementDirection {
    /// The deployment charged a caller (`payment_receipt`). Its `amount` is a
    /// decimal dollar figure, so it is read with [`read_dollar_amount`].
    Inbound,
    /// The control plane paid a service on a caller's behalf
    /// (`outbound_payment_receipt`). Its `amount` is already in the token's
    /// base units, so it is read with [`read_base_unit_amount`].
    Outbound,
}

impl SettlementDirection {
    /// The stable, bounded spelling used as a metric label and a log field —
    /// the same two literals the rest of the codebase already uses for this
    /// distinction (the `payments` typed table's `direction` column, the
    /// trace projector's payment step).
    #[must_use]
    pub const fn label(self) -> &'static str {
        match self {
            Self::Inbound => "inbound",
            Self::Outbound => "outbound",
        }
    }
}

/// Why a stored settlement amount could not be read as a number.
///
/// Deliberately distinguishes "nothing was recorded" from "something was
/// recorded that this unit cannot represent": the first is the known
/// uncaptured-charge gap (an outbound receipt whose proxy never observed the
/// charged amount), the second means a receipt was written in a unit its
/// reader does not expect.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum AmountReadError {
    /// The stored amount is empty — the receipt records no amount at all.
    #[error("the receipt records no amount")]
    Absent,
    /// The stored amount is not a decimal figure the settlement currency can
    /// represent — either not a decimal figure at all, or one carrying a
    /// NON-ZERO digit below the configured scale. Padding zeros below the
    /// scale do not land here: they are exactly representable, so they are
    /// normalized away before the parse.
    #[error("the recorded amount is not a decimal figure in the settlement currency")]
    MalformedDollars,
    /// The stored amount is not a whole number of the token's base units —
    /// a decimal figure lands here, which is exactly how an inbound amount
    /// read with the outbound reader is caught.
    #[error("the recorded amount is not a whole number of the token's base units")]
    MalformedBaseUnits,
}

impl AmountReadError {
    /// The stable, bounded spelling used as the `reason` metric label:
    /// `"absent"` for [`Self::Absent`], `"malformed"` for either
    /// unit-specific parse failure. Two values, deliberately — the metric
    /// answers "how much money is going unread, and is it missing or
    /// mis-shaped", and the direction label already says which unit was
    /// expected.
    #[must_use]
    pub const fn label(self) -> &'static str {
        match self {
            Self::Absent => "absent",
            Self::MalformedDollars | Self::MalformedBaseUnits => "malformed",
        }
    }
}

/// Reads an **inbound** receipt's stored decimal dollar amount into the
/// settlement token's base units at `decimals`.
///
/// The read side of [`dollars_to_base_units`], for the one string an inbound
/// `payment_receipt` stores: the per-call price the 402 challenge advertised
/// (`"0.01"`). The result is in base units, the same unit
/// [`read_base_unit_amount`] returns, so the two directions' figures are
/// comparable — which is not the same as addable (see the module doc).
///
/// A stored figure padded with zeros past `decimals` (`"0.1000000"` at six) is
/// exactly representable and reads fine — the padding is normalized away
/// before the parse (see [`dollars_to_base_units`]).
///
/// # Errors
///
/// Returns [`AmountReadError::Absent`] when `stored` is empty or blank, and
/// [`AmountReadError::MalformedDollars`] when it is not a decimal figure this
/// scale can represent (including a value that overflows `u128`).
pub fn read_dollar_amount(stored: &str, decimals: u32) -> Result<u128, AmountReadError> {
    let trimmed = stored.trim();
    if trimmed.is_empty() {
        return Err(AmountReadError::Absent);
    }
    dollars_to_base_units(trimmed, decimals).ok_or(AmountReadError::MalformedDollars)
}

/// Reads an **outbound** receipt's stored base-unit amount.
///
/// The one string an outbound `outbound_payment_receipt` stores: the charged
/// amount already in the token's base units, captured from the challenge. It
/// is empty when the payment proxy could not capture it, which is a real
/// settled charge with no recorded amount rather than a zero — hence the
/// distinct [`AmountReadError::Absent`].
///
/// # Errors
///
/// Returns [`AmountReadError::Absent`] when `stored` is empty or blank, and
/// [`AmountReadError::MalformedBaseUnits`] when it is not a whole number that
/// fits a `u128` — which is also what catches a decimal dollar figure handed
/// to the wrong reader.
pub fn read_base_unit_amount(stored: &str) -> Result<u128, AmountReadError> {
    let trimmed = stored.trim();
    if trimmed.is_empty() {
        return Err(AmountReadError::Absent);
    }
    trimmed
        .parse::<u128>()
        .map_err(|_| AmountReadError::MalformedBaseUnits)
}

/// Count of settled payment receipts dropped from an accounting read because
/// their recorded amount could not be read, labeled `direction`
/// ([`SettlementDirection::label`]) and `reason` ([`AmountReadError::label`]).
///
/// Both label sets are closed two-value enums, so this series has exactly
/// four children — never a per-conversation or per-receipt dimension. One
/// metric across every reader (the dashboard spend rollup in `polyc-query`,
/// the committed-spend floor and wallet history in the control plane) so an
/// operator watches one series rather than one per crate.
fn unreadable_amount_total() -> &'static IntCounterVec {
    static V: OnceLock<IntCounterVec> = OnceLock::new();
    V.get_or_init(|| {
        register_int_counter_vec!(
            "polychrome_settlement_amount_unreadable_total",
            "Count of settled payment receipts dropped from an accounting read because their \
             recorded amount could not be read, by settlement direction and reason.",
            &["direction", "reason"]
        )
        .expect("register polychrome_settlement_amount_unreadable_total")
    })
}

/// Which accounting read dropped a settled receipt it could not read.
///
/// The one place each consequence is worded, so the three readers never
/// describe the same drop three ways (`record_unreadable_amount` writes the
/// log line; this enum supplies the "and therefore" clause).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AmountReadSite {
    /// The dashboard's per-conversation settlement rollup (`polyc-query`).
    DashboardRollup,
    /// The committed-spend floor a restart or a second replica reseeds a
    /// conversation budget from.
    CommittedSpendFloor,
    /// The payment history a caller reads back for their own wallet.
    WalletHistory,
}

impl AmountReadSite {
    /// What this deployment loses because the amount could not be read —
    /// the log line's message, written once per site instead of once per
    /// call site.
    #[must_use]
    const fn consequence(self) -> &'static str {
        match self {
            Self::DashboardRollup => {
                "settled payment left out of the dashboard settlement rollup; the figure a reader \
                 sees is below what really settled"
            }
            Self::CommittedSpendFloor => {
                "settled outbound payment missing from the committed-spend floor; the reseeded \
                 budget is below actual spend for this conversation"
            }
            Self::WalletHistory => {
                "settled payment left out of the caller's wallet history; the payment does not \
                 appear in their own history at all"
            }
        }
    }
}

/// One settled receipt an accounting read could not put a number on.
///
/// Named fields rather than positional arguments: every one of these is a
/// short opaque string, so an argument order slip would silently mislabel a
/// money log.
#[derive(Debug, Clone, Copy)]
pub struct UnreadableAmount<'a> {
    /// Which read dropped it, and therefore what is now understated.
    pub site: AmountReadSite,
    /// Which direction's unit the reader expected.
    pub direction: SettlementDirection,
    /// Why the stored string could not be read.
    pub error: AmountReadError,
    /// The conversation id (or partition) the receipt was read from.
    pub scope: &'a str,
    /// The receipt's chain/transaction reference; empty when the merchant
    /// returned none.
    pub reference: &'a str,
    /// The `paid_fetch` tool call the payment answered; empty on an inbound
    /// receipt.
    pub tool_call_id: &'a str,
    /// The approval log position the payment was bound to; empty where the
    /// reader does not carry one.
    pub approval_pos: &'a str,
    /// The receipt's signed `subject`; empty on every inbound receipt and on
    /// an outbound one written before persona attribution.
    pub subject: &'a str,
    /// The receipt's RFC3339 settlement timestamp.
    pub timestamp: &'a str,
}

/// Counts and logs one settled receipt whose recorded amount could not be
/// read, so the money it represents is visible even though it never reached a
/// total.
///
/// The whole "count, warn, drop" trailer, written once: the counter answers
/// "is it happening, and how often", the `warn!` answers "which record, and
/// what is understated as a result". A caller drops the receipt right after
/// calling this — never a bare `continue`.
///
/// # Log fields
///
/// The record's id is emitted as `scope`. The three hand-written trailers
/// this replaced named that same value `conversation_id` (the dashboard
/// rollup and the committed-spend floor) or `partition` (the wallet history),
/// so a saved search or alert matching either of those field names stops
/// matching. Search on `scope` instead. The old names are not emitted
/// alongside it: one of the three carried a raw partition and the other two a
/// conversation id, so a second field would be right on some lines and wrong
/// on others.
pub fn record_unreadable_amount(dropped: &UnreadableAmount<'_>) {
    unreadable_amount_total()
        .with_label_values(&[dropped.direction.label(), dropped.error.label()])
        .inc();
    tracing::warn!(
        scope = %dropped.scope,
        direction = dropped.direction.label(),
        reference = %dropped.reference,
        tool_call_id = %dropped.tool_call_id,
        approval_pos = %dropped.approval_pos,
        subject = %dropped.subject,
        timestamp = %dropped.timestamp,
        error = %dropped.error,
        "{}",
        dropped.site.consequence()
    );
}

/// Current value of one `polychrome_settlement_amount_unreadable_total` child.
///
/// The one reader every crate's tests use to assert a drop was counted — the
/// counter lives in the process-global default registry, so a caller diffs two
/// calls rather than asserting an absolute count. Typed on the two label enums
/// instead of raw strings, so a test cannot assert against a label spelling
/// that does not exist.
#[must_use]
pub fn unreadable_amount_count(direction: SettlementDirection, error: AmountReadError) -> u64 {
    unreadable_amount_total()
        .with_label_values(&[direction.label(), error.label()])
        .get()
}

/// Force-register [`unreadable_amount_total`] with every bounded
/// `direction` × `reason` child pre-created (zero-valued), so a dashboard
/// reads zeros from the first scrape instead of a missing series. See
/// [`crate::init_metrics`].
pub(crate) fn force() {
    for direction in [SettlementDirection::Inbound, SettlementDirection::Outbound] {
        for reason in [AmountReadError::Absent, AmountReadError::MalformedBaseUnits] {
            unreadable_amount_total().with_label_values(&[direction.label(), reason.label()]);
        }
    }
}

/// Parses a decimal dollar string (e.g. `"0.10"`, `"1"`, `"2.5"`) into the
/// settlement token's **base units** at `decimals`, as a `u128`.
///
/// This is the budget's unit — the same exact-integer base unit the challenge
/// wire amount and the standalone `polyc-payments-client` crate's
/// `CappedProvider` cap use, so no lossy cross-unit conversion exists. Routes
/// through mpp's
/// [`parse_dollar_amount`](mpp::server::parse_dollar_amount) so the dollar→base
/// scaling stays in lockstep with the server.
///
/// A figure carrying padding zeros past `decimals` (`"0.1000000"` at six) is
/// normalized to the scale first, since it is exactly representable: mpp's
/// parser counts digit PLACES and refuses it, which would drop real money out
/// of an accounting read of an already-settled receipt (and would turn every
/// historical receipt unreadable if `TEMPO_CURRENCY_DECIMALS` were ever
/// lowered).
///
/// Returns `None` on malformed input, on a value that overflows `u128`, or on
/// a figure that genuinely does not fit `decimals` — a NON-ZERO digit below
/// the scale is real precision this refuses rather than rounds.
#[must_use]
pub fn dollars_to_base_units(s: &str, decimals: u32) -> Option<u128> {
    let base_str =
        mpp::server::parse_dollar_amount(at_scale(s.trim(), decimals)?, decimals).ok()?;
    let amount = mpp::evm::parse_amount(&base_str).ok()?;
    u128::try_from(amount).ok()
}

/// Rewrites a decimal figure whose fraction runs past `decimals` places into
/// the equivalent figure AT `decimals` places, when every digit below the
/// scale is a zero — `("0.1000000", 6)` is `"0.100000"`, the same money.
///
/// mpp's [`parse_dollar_amount`](mpp::server::parse_dollar_amount) refuses any
/// fraction longer than the scale outright (`AmountError::TooManyDecimals`),
/// counting digit places rather than value. That is the right answer for a
/// figure a person is about to be charged, and the wrong one for a figure
/// already settled and stored: `$0.10` padded to seven places is exactly
/// representable at six, and refusing it drops real money out of an accounting
/// read and blames the record. The same widening matters if
/// `TEMPO_CURRENCY_DECIMALS` is ever LOWERED, which would otherwise turn every
/// historical receipt carrying more fractional digits than the new scale into
/// an unreadable amount at once.
///
/// Returns `None` when a NON-ZERO digit falls below the scale — a real
/// precision loss this refuses rather than rounds. Returns the input unchanged
/// when its fraction already fits, or when it is not a plain ASCII-digit
/// fraction at all, so mpp stays the one judge of what "malformed" means.
fn at_scale(figure: &str, decimals: u32) -> Option<&str> {
    let Some((_, fraction)) = figure.split_once('.') else {
        return Some(figure);
    };
    let scale = decimals as usize;
    if fraction.len() <= scale || !fraction.bytes().all(|b| b.is_ascii_digit()) {
        return Some(figure);
    }
    // All-ASCII by the check above, so this split is on a char boundary.
    let (kept, below_scale) = fraction.split_at(scale);
    if below_scale.bytes().any(|b| b != b'0') {
        return None;
    }
    // `figure` ends with `fraction`, so trimming the dropped zeros off the end
    // yields a borrowed slice — including the degenerate `decimals == 0` case,
    // which leaves a trailing `"1."` that mpp reads as the integer `1`.
    Some(&figure[..figure.len() - (fraction.len() - kept.len())])
}

/// Stable settlement-currency symbol for rendering a settled amount to a reader.
///
/// Matches the symbol committed into inbound signed receipts
/// (`INBOUND_RECEIPT_CURRENCY`) so the same spend is never worded two ways —
/// deliberately NOT the raw `TEMPO_CURRENCY` token address.
pub const SETTLEMENT_SYMBOL: &str = "USD";

/// The payment currency's decimal scale when payments aren't configured
/// (pathUSD-style, 6 decimals).
///
/// The single default every consumer of `TEMPO_CURRENCY_DECIMALS` falls back
/// to — the config loader, the wallet-link cap scaling, and the wallet view
/// renders — so no two controls can ever scale the same figure differently
/// when the deployment leaves the decimals unset.
pub const DEFAULT_DECIMALS: u32 = 6;

/// Renders a base-unit `amount` at `decimals` as a bare decimal figure with
/// no currency symbol (e.g. `1_500_000` at 6 decimals → `"1.5"`).
///
/// The same scaling [`format_settled_amount`] uses before appending
/// [`SETTLEMENT_SYMBOL`] — the inverse of [`dollars_to_base_units`] for a
/// caller that stores the figure rather than displaying it. A record that
/// already holds a bare decimal string (never `"5.00 USD"`, e.g. a persona's
/// spend-policy limit) round-trips through this, not
/// [`format_settled_amount`]. A `decimals` past the point where
/// `10^decimals` exceeds `u128` (only reachable if that bound is ever
/// removed) saturates the scale rather than overflowing, so this never
/// panics.
#[must_use]
pub fn format_bare_amount(amount: u128, decimals: u32) -> String {
    if decimals == 0 {
        return amount.to_string();
    }
    let scale = 10u128.checked_pow(decimals).unwrap_or(u128::MAX);
    let whole = amount / scale;
    let frac = amount % scale;
    if frac == 0 {
        return whole.to_string();
    }
    let frac_str = format!("{frac:0width$}", width = decimals as usize);
    format!("{whole}.{}", frac_str.trim_end_matches('0'))
}

/// Renders a settled base-unit `amount` at `decimals` as a human-readable
/// amount string with the [`SETTLEMENT_SYMBOL`] (e.g. `"0.10 USD"` → rendered
/// `"0.1 USD"`; trailing fractional zeros are trimmed).
///
/// The inverse of [`dollars_to_base_units`], for attributing a fulfilled payment
/// back to a reader: `decimals` is the configured `TEMPO_CURRENCY_DECIMALS`,
/// bounded at load to the tempo-MPP ceiling. Built on [`format_bare_amount`];
/// see that function when the currency symbol is not wanted.
#[must_use]
pub fn format_settled_amount(amount: u128, decimals: u32) -> String {
    format!(
        "{} {SETTLEMENT_SYMBOL}",
        format_bare_amount(amount, decimals)
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn dollars_to_base_units_parses() {
        // 6-decimal token (pathUSD-style).
        assert_eq!(dollars_to_base_units("0.10", 6), Some(100_000));
        assert_eq!(dollars_to_base_units("1", 6), Some(1_000_000));
        assert_eq!(dollars_to_base_units("2.5", 6), Some(2_500_000));
        assert_eq!(dollars_to_base_units("0.000001", 6), Some(1));
        assert_eq!(dollars_to_base_units("", 6), None);
        assert_eq!(dollars_to_base_units("abc", 6), None);
        // Decimals drive the scale: $1 at 2 decimals = 100 base units.
        assert_eq!(dollars_to_base_units("1", 2), Some(100));
        assert_eq!(dollars_to_base_units("1.5", 2), Some(150));
    }

    /// A figure padded with zeros past the scale is EXACTLY representable, so
    /// it must convert rather than be refused as malformed: `$0.10` written to
    /// seven places is still ten cents at six decimals. mpp's own
    /// `parse_dollar_amount` counts digit places and refuses this, which is
    /// what `at_scale` exists to widen (#1739 review).
    #[test]
    fn dollars_to_base_units_accepts_padding_zeros_below_the_scale() {
        assert_eq!(dollars_to_base_units("0.1000000", 6), Some(100_000));
        assert_eq!(dollars_to_base_units("0.010000000000", 6), Some(10_000));
        assert_eq!(dollars_to_base_units("1.000", 0), Some(1));
        // A LOWERED scale is the same mechanism with a wider blast radius:
        // every historical receipt with more fractional digits than the new
        // scale still reads, as long as the digits below it are zeros.
        assert_eq!(dollars_to_base_units("2.500000", 2), Some(250));
        // mpp already accepted these; the normalization must not change them.
        assert_eq!(dollars_to_base_units("0.1", 6), Some(100_000));
        assert_eq!(dollars_to_base_units("0.100000", 6), Some(100_000));
    }

    /// The line `at_scale` will NOT cross: a non-zero digit below the scale is
    /// real precision the token cannot hold, so it stays refused rather than
    /// silently rounded or truncated.
    #[test]
    fn dollars_to_base_units_still_refuses_precision_it_cannot_hold() {
        assert_eq!(dollars_to_base_units("0.1000001", 6), None);
        assert_eq!(dollars_to_base_units("0.0000001", 6), None);
        assert_eq!(dollars_to_base_units("1.01", 1), None);
        // Malformed input stays malformed — the normalization hands anything
        // that is not a plain digit fraction straight to mpp.
        assert_eq!(dollars_to_base_units("0.1abcdefg", 6), None);
        assert_eq!(dollars_to_base_units("1.2.3", 6), None);
    }

    /// The reader inherits the widening, since it delegates: a stored inbound
    /// amount padded past the configured scale reaches the rollup instead of
    /// being counted as malformed and dropped.
    #[test]
    fn read_dollar_amount_reads_a_padded_stored_figure() {
        assert_eq!(
            read_dollar_amount("0.1000000", DEFAULT_DECIMALS),
            Ok(100_000)
        );
        assert_eq!(
            read_dollar_amount("0.1000001", DEFAULT_DECIMALS),
            Err(AmountReadError::MalformedDollars)
        );
    }

    /// The production-shaped inbound value (#1739): an inbound
    /// `payment_receipt` stores the per-call price as a decimal figure, which
    /// is precisely what the old `parse::<u128>()` reads dropped on the floor.
    #[test]
    fn read_dollar_amount_reads_a_production_shaped_price() {
        assert_eq!(read_dollar_amount("0.01", DEFAULT_DECIMALS), Ok(10_000));
        assert_eq!(read_dollar_amount("1", DEFAULT_DECIMALS), Ok(1_000_000));
        assert_eq!(read_dollar_amount(" 2.5 ", DEFAULT_DECIMALS), Ok(2_500_000));
        assert_eq!(
            read_dollar_amount("", DEFAULT_DECIMALS),
            Err(AmountReadError::Absent)
        );
        assert_eq!(
            read_dollar_amount("   ", DEFAULT_DECIMALS),
            Err(AmountReadError::Absent)
        );
        assert_eq!(
            read_dollar_amount("abc", DEFAULT_DECIMALS),
            Err(AmountReadError::MalformedDollars)
        );
    }

    /// The production-shaped outbound values (#1739): a captured charge is an
    /// integer base-unit string, and an UNCAPTURED one is empty — which must
    /// read as [`AmountReadError::Absent`], not as zero, because it is a real
    /// settled charge whose amount was never recorded.
    #[test]
    fn read_base_unit_amount_separates_an_uncaptured_charge_from_a_zero() {
        assert_eq!(read_base_unit_amount("10000"), Ok(10_000));
        assert_eq!(read_base_unit_amount("0"), Ok(0));
        assert_eq!(read_base_unit_amount(""), Err(AmountReadError::Absent));
        assert_eq!(read_base_unit_amount("  "), Err(AmountReadError::Absent));
        assert_eq!(
            read_base_unit_amount("-1"),
            Err(AmountReadError::MalformedBaseUnits)
        );
    }

    /// The unit mix-up the two readers exist to prevent: a decimal dollar
    /// figure handed to the base-unit reader must FAIL, never silently
    /// truncate or round to a wrong number of base units.
    #[test]
    fn read_base_unit_amount_refuses_a_decimal_figure() {
        assert_eq!(
            read_base_unit_amount("0.01"),
            Err(AmountReadError::MalformedBaseUnits)
        );
        assert_eq!(
            read_base_unit_amount("1.50"),
            Err(AmountReadError::MalformedBaseUnits)
        );
    }

    /// The metric labels are a closed four-child set — the bounded-cardinality
    /// contract this series is registered under.
    #[test]
    fn unreadable_amount_labels_are_a_closed_set() {
        assert_eq!(SettlementDirection::Inbound.label(), "inbound");
        assert_eq!(SettlementDirection::Outbound.label(), "outbound");
        assert_eq!(AmountReadError::Absent.label(), "absent");
        assert_eq!(AmountReadError::MalformedDollars.label(), "malformed");
        assert_eq!(AmountReadError::MalformedBaseUnits.label(), "malformed");
    }

    /// Recording an unreadable amount lands on the exact labeled child, so an
    /// operator can tell a missing outbound amount apart from a mis-shaped
    /// inbound one.
    #[test]
    fn record_unreadable_amount_increments_only_the_recorded_child() {
        force();
        let before =
            unreadable_amount_count(SettlementDirection::Outbound, AmountReadError::Absent);
        let other_before = unreadable_amount_count(
            SettlementDirection::Inbound,
            AmountReadError::MalformedDollars,
        );
        record_unreadable_amount(&UnreadableAmount {
            site: AmountReadSite::CommittedSpendFloor,
            direction: SettlementDirection::Outbound,
            error: AmountReadError::Absent,
            scope: "conv-1",
            reference: "tx-1",
            tool_call_id: "call-1",
            approval_pos: "1",
            subject: "persona-1",
            timestamp: "2026-07-20T00:00:00Z",
        });
        assert_eq!(
            unreadable_amount_count(SettlementDirection::Outbound, AmountReadError::Absent),
            before + 1
        );
        assert_eq!(
            unreadable_amount_count(
                SettlementDirection::Inbound,
                AmountReadError::MalformedDollars
            ),
            other_before
        );
    }

    /// Each site words its own consequence, and no two share a sentence — the
    /// point of folding the trailer into one helper is that a reader of the
    /// logs can tell WHICH total is now understated.
    #[test]
    fn every_read_site_words_a_distinct_consequence() {
        let sites = [
            AmountReadSite::DashboardRollup,
            AmountReadSite::CommittedSpendFloor,
            AmountReadSite::WalletHistory,
        ];
        let worded: Vec<&str> = sites.iter().map(|s| s.consequence()).collect();
        for phrase in &worded {
            assert!(!phrase.is_empty());
        }
        let distinct: std::collections::BTreeSet<&&str> = worded.iter().collect();
        assert_eq!(distinct.len(), sites.len(), "one wording per site");
    }

    #[test]
    fn format_settled_amount_renders_human_with_symbol() {
        // 6-decimal token: 100000 base units = 0.1, trailing zeros trimmed.
        assert_eq!(format_settled_amount(100_000, 6), "0.1 USD");
        assert_eq!(format_settled_amount(1_000_000, 6), "1 USD");
        assert_eq!(format_settled_amount(2_500_000, 6), "2.5 USD");
        assert_eq!(format_settled_amount(1, 6), "0.000001 USD");
        assert_eq!(format_settled_amount(0, 6), "0 USD");
        // Zero-decimal token renders the integer amount.
        assert_eq!(format_settled_amount(42, 0), "42 USD");
    }

    #[test]
    fn format_bare_amount_renders_the_same_figure_with_no_symbol() {
        assert_eq!(format_bare_amount(100_000, 6), "0.1");
        assert_eq!(format_bare_amount(1_000_000, 6), "1");
        assert_eq!(format_bare_amount(2_500_000, 6), "2.5");
        assert_eq!(format_bare_amount(0, 6), "0");
        assert_eq!(format_bare_amount(42, 0), "42");
    }

    #[test]
    fn format_bare_amount_round_trips_through_dollars_to_base_units() {
        // The inverse relationship the doc comment claims: a figure parsed to
        // base units and rendered back must read the same, for every figure a
        // caller would plausibly store (a policy limit, an onchain cap).
        for figure in ["5", "5.00", "0.10", "123.456789"] {
            let base = dollars_to_base_units(figure, 6).expect("parses");
            let rendered = format_bare_amount(base, 6);
            let re_parsed = dollars_to_base_units(&rendered, 6).expect("re-parses");
            assert_eq!(
                base, re_parsed,
                "{figure} must round-trip through base units"
            );
        }
    }
}