Skip to main content

polyester/types/
money.rs

1//! Money scalar types for write/read surfaces.
2
3use crate::codecs::scalars::{
4    format_price_ticks, format_qty_scaled, parse_price_ticks, parse_price_ticks_str,
5    parse_qty_scaled, parse_qty_scaled_str,
6};
7use crate::errors::{Error, Result};
8use rust_decimal::Decimal;
9
10/// Quantity domain — mixing domains is a validation error.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
12pub enum QuantityDomain {
13    #[default]
14    OrderBase,
15    OrderQuote,
16    Asset,
17    LedgerE18,
18}
19
20/// Distinct newtype for protocol price ticks (compile-time mix-up prevention).
21///
22/// Construction is crate-private so invalid negative ticks cannot bypass
23/// [`Price::from_ticks`].
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
25pub struct PriceTicks(i64);
26
27impl PriceTicks {
28    pub(crate) const fn new(ticks: i64) -> Self {
29        Self(ticks)
30    }
31    pub const fn get(self) -> i64 {
32        self.0
33    }
34}
35
36/// Distinct newtype for order/trigger qty_scaled.
37///
38/// Construction is crate-private so invalid negative values cannot bypass
39/// [`Quantity::from_scaled`].
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
41pub struct QtyScaled(i64);
42
43impl QtyScaled {
44    pub(crate) const fn new(scaled: i64) -> Self {
45        Self(scaled)
46    }
47    pub const fn get(self) -> i64 {
48        self.0
49    }
50}
51
52/// Resolved protocol price units (protobuf `price_ticks`, fixed 1e6).
53///
54/// Fields are private so metadata cannot be changed independently of the
55/// validated ticks. Use [`Price::symbol`] to inspect the immutable metadata.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Price {
58    ticks: PriceTicks,
59    symbol: Option<String>,
60}
61
62impl Price {
63    pub fn from_ticks(ticks: i64, symbol: Option<String>) -> Result<Self> {
64        if ticks < 0 {
65            return Err(Error::validation("ticks must be non-negative"));
66        }
67        Ok(Self {
68            ticks: PriceTicks::new(ticks),
69            symbol,
70        })
71    }
72
73    pub fn from_decimal_str(raw: &str, symbol: Option<String>) -> Result<Self> {
74        let ticks = parse_price_ticks_str(raw, "price")?;
75        Self::from_ticks(ticks, symbol)
76    }
77
78    pub fn from_decimal(raw: Decimal, symbol: Option<String>) -> Result<Self> {
79        let ticks = parse_price_ticks(raw, "price")?;
80        Self::from_ticks(ticks, symbol)
81    }
82
83    pub fn as_ticks(&self) -> i64 {
84        self.ticks.get()
85    }
86
87    pub fn symbol(&self) -> Option<&str> {
88        self.symbol.as_deref()
89    }
90
91    pub fn as_decimal(&self) -> Decimal {
92        // Price ticks always use the protocol's fixed 1e6 scale, so this
93        // conversion is exact and cannot silently substitute Decimal::ZERO.
94        Decimal::new(self.ticks.get(), 6)
95    }
96
97    pub fn format(&self) -> String {
98        format_price_ticks(self.ticks.get())
99    }
100
101    pub fn compatible_with(&self, symbol: Option<&str>) -> Result<()> {
102        if let (Some(a), Some(b)) = (self.symbol(), symbol)
103            && a != b
104        {
105            return Err(Error::validation(format!(
106                "price symbol mismatch: value is for {a}, destination is {b}"
107            )));
108        }
109        Ok(())
110    }
111}
112
113/// Resolved order/trigger base quantity (protobuf `qty_scaled`).
114///
115/// Fields are private so metadata cannot be changed independently of the
116/// validated scaled value. Use the immutable metadata getters to inspect it.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Quantity {
119    scaled: QtyScaled,
120    scale: Option<u32>,
121    domain: QuantityDomain,
122    symbol: Option<String>,
123    symbol_id: Option<u32>,
124}
125
126impl Quantity {
127    pub fn from_scaled(
128        scaled: i64,
129        scale: Option<u32>,
130        domain: QuantityDomain,
131        symbol: Option<String>,
132        symbol_id: Option<u32>,
133    ) -> Result<Self> {
134        if scaled < 0 {
135            return Err(Error::validation("scaled must be non-negative"));
136        }
137        if !matches!(
138            domain,
139            QuantityDomain::OrderBase | QuantityDomain::OrderQuote
140        ) {
141            return Err(Error::validation(
142                "Quantity domain must be order_base or order_quote",
143            ));
144        }
145        if let Some(scale) = scale {
146            crate::codecs::scalars::validate_protocol_scale(scale)?;
147        }
148        Ok(Self {
149            scaled: QtyScaled::new(scaled),
150            scale,
151            domain,
152            symbol,
153            symbol_id,
154        })
155    }
156
157    pub fn from_decimal_str(
158        raw: &str,
159        scale: u32,
160        symbol: Option<String>,
161        symbol_id: Option<u32>,
162    ) -> Result<Self> {
163        let scaled = parse_qty_scaled_str(raw, scale, "qty")?;
164        Self::from_scaled(
165            scaled,
166            Some(scale),
167            QuantityDomain::OrderBase,
168            symbol,
169            symbol_id,
170        )
171    }
172
173    pub fn from_decimal(
174        raw: Decimal,
175        scale: u32,
176        symbol: Option<String>,
177        symbol_id: Option<u32>,
178    ) -> Result<Self> {
179        let scaled = parse_qty_scaled(raw, scale, "qty")?;
180        Self::from_scaled(
181            scaled,
182            Some(scale),
183            QuantityDomain::OrderBase,
184            symbol,
185            symbol_id,
186        )
187    }
188
189    /// Construct a quote-debit budget from scaled integer units.
190    ///
191    /// `scale` is required so a bare integer can never silently inherit the
192    /// catalog scale. Use [`crate::catalogs::Manager::quote_quantity_scale_for_symbol`].
193    pub fn from_quote_scaled(
194        scaled: i64,
195        scale: u32,
196        symbol: Option<String>,
197        symbol_id: Option<u32>,
198    ) -> Result<Self> {
199        Self::from_scaled(
200            scaled,
201            Some(scale),
202            QuantityDomain::OrderQuote,
203            symbol,
204            symbol_id,
205        )
206    }
207
208    pub fn from_quote_decimal_str(
209        raw: &str,
210        scale: u32,
211        symbol: Option<String>,
212        symbol_id: Option<u32>,
213    ) -> Result<Self> {
214        let scaled = parse_qty_scaled_str(raw, scale, "quote amount")?;
215        Self::from_quote_scaled(scaled, scale, symbol, symbol_id)
216    }
217
218    pub fn from_quote_decimal(
219        raw: Decimal,
220        scale: u32,
221        symbol: Option<String>,
222        symbol_id: Option<u32>,
223    ) -> Result<Self> {
224        let scaled = parse_qty_scaled(raw, scale, "quote amount")?;
225        Self::from_quote_scaled(scaled, scale, symbol, symbol_id)
226    }
227
228    pub fn as_scaled(&self) -> i64 {
229        self.scaled.get()
230    }
231
232    pub fn scale(&self) -> Option<u32> {
233        self.scale
234    }
235
236    pub fn domain(&self) -> QuantityDomain {
237        self.domain
238    }
239
240    pub fn symbol(&self) -> Option<&str> {
241        self.symbol.as_deref()
242    }
243
244    pub fn symbol_id(&self) -> Option<u32> {
245        self.symbol_id
246    }
247
248    pub fn format(&self, scale: Option<u32>) -> Result<String> {
249        let resolved = scale.or(self.scale()).ok_or_else(|| {
250            Error::validation("format requires a known scale; pass scale= or construct with scale=")
251        })?;
252        format_qty_scaled(self.scaled.get(), resolved)
253    }
254
255    pub fn compatible_with(
256        &self,
257        domain: QuantityDomain,
258        scale: Option<u32>,
259        symbol: Option<&str>,
260        symbol_id: Option<u32>,
261    ) -> Result<()> {
262        if self.domain() != domain {
263            return Err(Error::validation(format!(
264                "quantity domain mismatch: value is {:?}, destination is {domain:?}",
265                self.domain()
266            )));
267        }
268        if let (Some(a), Some(b)) = (self.scale(), scale)
269            && a != b
270        {
271            return Err(Error::validation(format!(
272                "quantity scale mismatch: value scale is {a}, destination is {b}"
273            )));
274        }
275        if let (Some(a), Some(b)) = (self.symbol(), symbol)
276            && a != b
277        {
278            return Err(Error::validation(format!(
279                "quantity symbol mismatch: value is for {a}, destination is {b}"
280            )));
281        }
282        if let (Some(a), Some(b)) = (self.symbol_id(), symbol_id)
283            && a != b
284        {
285            return Err(Error::validation(format!(
286                "quantity symbol_id mismatch: value is for {a}, destination is {b}"
287            )));
288        }
289        Ok(())
290    }
291}
292
293/// Resolved asset/ledger amount.
294///
295/// Fields are private so invariants from [`AssetAmount::from_scaled`] cannot be
296/// bypassed via struct literals.
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct AssetAmount {
299    scaled: i128,
300    scale: Option<u32>,
301    domain: QuantityDomain,
302    asset_id: Option<u32>,
303}
304
305impl AssetAmount {
306    pub fn from_scaled(
307        scaled: i128,
308        scale: Option<u32>,
309        domain: QuantityDomain,
310        asset_id: Option<u32>,
311    ) -> Result<Self> {
312        if scaled < 0 {
313            return Err(Error::validation("scaled must be non-negative"));
314        }
315        if !matches!(domain, QuantityDomain::Asset | QuantityDomain::LedgerE18) {
316            return Err(Error::validation(
317                "AssetAmount domain must be asset or ledger_e18",
318            ));
319        }
320        if let Some(scale) = scale {
321            crate::codecs::scalars::validate_protocol_scale(scale)?;
322        }
323        if domain != QuantityDomain::LedgerE18 && scaled > crate::codecs::scalars::INT64_MAX {
324            return Err(Error::validation("scaled exceeds int64 range"));
325        }
326        Ok(Self {
327            scaled,
328            scale,
329            domain,
330            asset_id,
331        })
332    }
333
334    pub fn from_decimal_str(
335        raw: &str,
336        scale: u32,
337        domain: QuantityDomain,
338        asset_id: Option<u32>,
339    ) -> Result<Self> {
340        use crate::codecs::scalars::decimal_to_scaled_str;
341        let scaled = decimal_to_scaled_str(raw, scale, "amount")?;
342        Self::from_scaled(scaled, Some(scale), domain, asset_id)
343    }
344
345    pub fn from_decimal(
346        raw: Decimal,
347        scale: u32,
348        domain: QuantityDomain,
349        asset_id: Option<u32>,
350    ) -> Result<Self> {
351        use crate::codecs::scalars::decimal_to_scaled;
352        let scaled = decimal_to_scaled(raw, scale, "amount")?;
353        Self::from_scaled(scaled, Some(scale), domain, asset_id)
354    }
355
356    pub fn as_i64(&self) -> Result<i64> {
357        i64::try_from(self.scaled).map_err(|_| Error::validation("amount exceeds int64 range"))
358    }
359
360    pub fn as_scaled(&self) -> i128 {
361        self.scaled
362    }
363
364    pub fn scale(&self) -> Option<u32> {
365        self.scale
366    }
367
368    pub fn domain(&self) -> QuantityDomain {
369        self.domain
370    }
371
372    pub fn asset_id(&self) -> Option<u32> {
373        self.asset_id
374    }
375
376    pub fn compatible_with(
377        &self,
378        domain: QuantityDomain,
379        scale: Option<u32>,
380        asset_id: Option<u32>,
381    ) -> Result<()> {
382        if self.domain != domain {
383            return Err(Error::validation(format!(
384                "amount domain mismatch: value is {:?}, destination is {domain:?}",
385                self.domain
386            )));
387        }
388        if let (Some(a), Some(b)) = (self.scale, scale)
389            && a != b
390        {
391            return Err(Error::validation(format!(
392                "amount scale mismatch: value scale is {a}, destination is {b}"
393            )));
394        }
395        if let (Some(a), Some(b)) = (self.asset_id, asset_id)
396            && a != b
397        {
398            return Err(Error::validation(format!(
399                "amount asset_id mismatch: value is for {a}, destination is {b}"
400            )));
401        }
402        Ok(())
403    }
404}
405
406/// Resolve price for write paths.
407pub fn resolve_price_ticks(value: &Price, symbol: Option<&str>) -> Result<i64> {
408    value.compatible_with(symbol)?;
409    let ticks = value.as_ticks();
410    if ticks < 0 {
411        return Err(Error::validation("ticks must be non-negative"));
412    }
413    Ok(ticks)
414}
415
416/// Resolve qty for write paths. Requires a positive scaled value.
417pub fn resolve_qty_scaled(
418    value: &Quantity,
419    scale: u32,
420    symbol: Option<&str>,
421    symbol_id: Option<u32>,
422) -> Result<i64> {
423    value.compatible_with(QuantityDomain::OrderBase, Some(scale), symbol, symbol_id)?;
424    let scaled = value.as_scaled();
425    if scaled <= 0 {
426        return Err(Error::validation("qty must be positive"));
427    }
428    Ok(scaled)
429}
430
431/// Resolve a quote-debit budget. The value must carry the catalog quote scale.
432pub fn resolve_quote_qty_scaled(
433    value: &Quantity,
434    scale: u32,
435    symbol: Option<&str>,
436    symbol_id: Option<u32>,
437) -> Result<i64> {
438    if value.scale().is_none() {
439        return Err(Error::validation(
440            "quote amount scale is required; use Quantity::from_quote_scaled/from_quote_decimal",
441        ));
442    }
443    value.compatible_with(QuantityDomain::OrderQuote, Some(scale), symbol, symbol_id)?;
444    let scaled = value.as_scaled();
445    if scaled <= 0 {
446        return Err(Error::validation("quote amount must be positive"));
447    }
448    Ok(scaled)
449}
450
451/// Resolve asset/ledger amount for transfer/withdraw write paths.
452pub fn resolve_asset_amount_scaled(
453    value: &AssetAmount,
454    scale: u32,
455    domain: QuantityDomain,
456    asset_id: Option<u32>,
457) -> Result<i128> {
458    resolve_asset_amount_scaled_with_input_scale(value, None, scale, domain, asset_id)
459}
460
461/// Resolve an asset amount to `target_scale`, using `input_scale` only when the
462/// value does not already carry a scale.
463pub(crate) fn resolve_asset_amount_scaled_with_input_scale(
464    value: &AssetAmount,
465    input_scale: Option<u32>,
466    target_scale: u32,
467    domain: QuantityDomain,
468    asset_id: Option<u32>,
469) -> Result<i128> {
470    crate::codecs::scalars::validate_protocol_scale(target_scale)?;
471    if let Some(scale) = input_scale {
472        crate::codecs::scalars::validate_protocol_scale(scale)?;
473    }
474    value.compatible_with(domain, None, asset_id)?;
475    if let (Some(value_scale), Some(input_scale)) = (value.scale, input_scale)
476        && value_scale != input_scale
477    {
478        return Err(Error::validation(format!(
479            "amount scale mismatch: value scale is {value_scale}, input scale is {input_scale}"
480        )));
481    }
482    if value.scaled <= 0 {
483        return Err(Error::validation("amount must be positive"));
484    }
485    let source_scale = value.scale.or(input_scale).ok_or_else(|| {
486        Error::validation(
487            "amount scale is required; construct AssetAmount with an explicit scale or pass amount_scale/quantity_scale",
488        )
489    })?;
490    let scaled = if source_scale < target_scale {
491        let factor = 10_i128
492            .checked_pow(target_scale - source_scale)
493            .ok_or_else(|| Error::validation("amount scale conversion overflow"))?;
494        value
495            .scaled
496            .checked_mul(factor)
497            .ok_or_else(|| Error::validation("amount scale conversion overflow"))?
498    } else if source_scale > target_scale {
499        let divisor = 10_i128
500            .checked_pow(source_scale - target_scale)
501            .ok_or_else(|| Error::validation("amount scale conversion overflow"))?;
502        if value.scaled % divisor != 0 {
503            return Err(Error::validation(format!(
504                "amount cannot be represented exactly at scale {target_scale}"
505            )));
506        }
507        value.scaled / divisor
508    } else {
509        value.scaled
510    };
511    if domain != QuantityDomain::LedgerE18 && scaled > crate::codecs::scalars::INT64_MAX {
512        return Err(Error::validation("amount exceeds int64 range"));
513    }
514    Ok(scaled)
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn price_from_ticks_rejects_negative() {
523        assert!(Price::from_ticks(-1, None).is_err());
524    }
525
526    #[test]
527    fn price_as_decimal_is_exact_at_the_maximum_tick_value() {
528        let price = Price::from_ticks(i64::MAX, None).unwrap();
529        assert_eq!(price.as_decimal(), Decimal::new(i64::MAX, 6));
530    }
531
532    #[test]
533    fn quantity_from_scaled_rejects_negative() {
534        assert!(Quantity::from_scaled(-1, Some(8), QuantityDomain::OrderBase, None, None).is_err());
535    }
536
537    #[test]
538    fn resolve_paths_round_trip() {
539        let price = Price::from_decimal_str("42.5", Some("BTC-USDT".into())).unwrap();
540        assert_eq!(
541            resolve_price_ticks(&price, Some("BTC-USDT")).unwrap(),
542            42_500_000
543        );
544        let qty = Quantity::from_decimal_str("1.25", 8, Some("BTC-USDT".into()), Some(1)).unwrap();
545        assert_eq!(
546            resolve_qty_scaled(&qty, 8, Some("BTC-USDT"), Some(1)).unwrap(),
547            125_000_000
548        );
549    }
550
551    #[test]
552    fn price_and_quantity_metadata_getters_preserve_compatibility() {
553        let price = Price::from_ticks(42_500_000, Some("BTC-USDT".into())).unwrap();
554        assert_eq!(price.symbol(), Some("BTC-USDT"));
555        assert_eq!(price.as_ticks(), 42_500_000);
556        assert_eq!(price.format(), "42.5");
557        assert_eq!(price.clone(), price);
558        assert!(format!("{price:?}").contains("BTC-USDT"));
559
560        let qty = Quantity::from_scaled(
561            125_000_000,
562            Some(8),
563            QuantityDomain::OrderBase,
564            Some("BTC-USDT".into()),
565            Some(7),
566        )
567        .unwrap();
568        assert_eq!(qty.scale(), Some(8));
569        assert_eq!(qty.domain(), QuantityDomain::OrderBase);
570        assert_eq!(qty.symbol(), Some("BTC-USDT"));
571        assert_eq!(qty.symbol_id(), Some(7));
572        assert_eq!(qty.as_scaled(), 125_000_000);
573        assert_eq!(qty.format(None).unwrap(), "1.25");
574        assert_eq!(qty.clone(), qty);
575        assert!(format!("{qty:?}").contains("BTC-USDT"));
576        assert!(
577            qty.compatible_with(
578                QuantityDomain::OrderBase,
579                Some(8),
580                Some("BTC-USDT"),
581                Some(7)
582            )
583            .is_ok()
584        );
585    }
586
587    #[test]
588    fn resolve_qty_rejects_zero() {
589        let qty = Quantity::from_scaled(0, Some(8), QuantityDomain::OrderBase, None, None).unwrap();
590        assert!(resolve_qty_scaled(&qty, 8, None, None).is_err());
591    }
592
593    #[test]
594    fn quote_amount_requires_explicit_matching_scale() {
595        let quote =
596            Quantity::from_quote_decimal_str("12.5", 6, Some("BTC-USDT".into()), Some(1)).unwrap();
597        assert_eq!(
598            resolve_quote_qty_scaled(&quote, 6, Some("BTC-USDT"), Some(1)).unwrap(),
599            12_500_000
600        );
601        assert!(resolve_quote_qty_scaled(&quote, 8, Some("BTC-USDT"), Some(1)).is_err());
602
603        let missing_scale =
604            Quantity::from_scaled(12_500_000, None, QuantityDomain::OrderQuote, None, None)
605                .unwrap();
606        assert!(resolve_quote_qty_scaled(&missing_scale, 6, None, None).is_err());
607    }
608
609    #[test]
610    fn asset_amount_dual_path() {
611        let from_dec =
612            AssetAmount::from_decimal_str("0.5", 18, QuantityDomain::LedgerE18, Some(7)).unwrap();
613        let from_scaled = AssetAmount::from_scaled(
614            500_000_000_000_000_000,
615            Some(18),
616            QuantityDomain::LedgerE18,
617            Some(7),
618        )
619        .unwrap();
620        assert_eq!(
621            resolve_asset_amount_scaled(&from_dec, 18, QuantityDomain::LedgerE18, Some(7)).unwrap(),
622            resolve_asset_amount_scaled(&from_scaled, 18, QuantityDomain::LedgerE18, Some(7))
623                .unwrap()
624        );
625    }
626
627    #[test]
628    fn asset_amount_rejects_domain_scale_and_asset_mismatch() {
629        let amount =
630            AssetAmount::from_scaled(100, Some(18), QuantityDomain::LedgerE18, Some(7)).unwrap();
631        assert!(resolve_asset_amount_scaled(&amount, 18, QuantityDomain::Asset, Some(7)).is_err());
632        assert!(
633            resolve_asset_amount_scaled(&amount, 6, QuantityDomain::LedgerE18, Some(7)).is_err()
634        );
635        assert!(
636            resolve_asset_amount_scaled(&amount, 18, QuantityDomain::LedgerE18, Some(8)).is_err()
637        );
638    }
639
640    #[test]
641    fn asset_amount_rescales_exactly_without_rounding() {
642        let asset_precision =
643            AssetAmount::from_scaled(125, Some(2), QuantityDomain::LedgerE18, Some(7)).unwrap();
644        assert_eq!(
645            resolve_asset_amount_scaled(&asset_precision, 18, QuantityDomain::LedgerE18, Some(7))
646                .unwrap(),
647            1_250_000_000_000_000_000
648        );
649
650        let exact_downscale = AssetAmount::from_scaled(
651            1_250_000_000_000_000_000,
652            Some(18),
653            QuantityDomain::LedgerE18,
654            Some(7),
655        )
656        .unwrap();
657        assert_eq!(
658            resolve_asset_amount_scaled(&exact_downscale, 2, QuantityDomain::LedgerE18, Some(7))
659                .unwrap(),
660            125
661        );
662
663        let inexact_downscale =
664            AssetAmount::from_scaled(126, Some(3), QuantityDomain::LedgerE18, Some(7)).unwrap();
665        assert!(
666            resolve_asset_amount_scaled(&inexact_downscale, 2, QuantityDomain::LedgerE18, Some(7))
667                .is_err()
668        );
669    }
670
671    #[test]
672    fn asset_amount_rescale_rejects_overflow() {
673        let amount =
674            AssetAmount::from_scaled(i128::MAX, Some(17), QuantityDomain::LedgerE18, None).unwrap();
675        assert!(resolve_asset_amount_scaled(&amount, 18, QuantityDomain::LedgerE18, None).is_err());
676    }
677
678    #[test]
679    fn quantity_reuse_rejects_scale_symbol_and_symbol_id_mismatch() {
680        let qty = Quantity::from_scaled(
681            100,
682            Some(8),
683            QuantityDomain::OrderBase,
684            Some("BTC-USDT".into()),
685            Some(7),
686        )
687        .unwrap();
688        assert!(resolve_qty_scaled(&qty, 6, Some("BTC-USDT"), Some(7)).is_err());
689        assert!(resolve_qty_scaled(&qty, 8, Some("ETH-USDT"), Some(7)).is_err());
690        assert!(resolve_qty_scaled(&qty, 8, Some("BTC-USDT"), Some(8)).is_err());
691    }
692
693    #[test]
694    fn asset_amount_requires_positive_value_at_resolve() {
695        let amount =
696            AssetAmount::from_scaled(0, Some(18), QuantityDomain::LedgerE18, Some(7)).unwrap();
697        assert!(
698            resolve_asset_amount_scaled(&amount, 18, QuantityDomain::LedgerE18, Some(7)).is_err()
699        );
700    }
701
702    #[test]
703    fn asset_amount_without_value_or_parameter_scale_fails_closed() {
704        let amount = AssetAmount::from_scaled(1, None, QuantityDomain::LedgerE18, Some(7)).unwrap();
705        let err = resolve_asset_amount_scaled(&amount, 18, QuantityDomain::LedgerE18, Some(7))
706            .expect_err("missing source scale must not be treated as e18");
707        assert!(err.to_string().contains("amount scale is required"));
708
709        assert_eq!(
710            resolve_asset_amount_scaled_with_input_scale(
711                &amount,
712                Some(6),
713                18,
714                QuantityDomain::LedgerE18,
715                Some(7),
716            )
717            .unwrap(),
718            1_000_000_000_000
719        );
720    }
721
722    #[test]
723    fn asset_amount_as_i64_rejects_overflow_not_truncate() {
724        let amount = AssetAmount::from_scaled(
725            i128::from(u64::MAX) + 1,
726            Some(18),
727            QuantityDomain::LedgerE18,
728            None,
729        )
730        .unwrap();
731        assert!(amount.as_i64().is_err());
732    }
733}