Skip to main content

dig_did/
amount.rs

1//! The singleton amount — a coin amount proven odd at construction (SPEC §3 "Create").
2//!
3//! Chia's singleton top layer only recognises an ODD-amount coin as the singleton. A launch built
4//! from an even-amount funding coin therefore spends the funding coin and produces no singleton at
5//! all: the money is gone and the identity does not exist. Arbitrary wallet coins are even roughly
6//! half the time, so this is not an exotic input.
7//!
8//! [`SingletonAmount`] makes that state unrepresentable rather than merely refused. Its only
9//! constructor validates, its field is private, and it is the only type this crate hands to a
10//! launcher — unlike a bare `if amount % 2 == 0` inside one function, which the next caller can
11//! simply not write.
12//!
13//! The type alone does not stop a future author reaching the SDK's launcher directly, so EVERY
14//! route by which a launcher can take a raw amount — all five of its constructors and its
15//! `with_singleton_amount` mutator — is on `disallowed-methods` in `clippy.toml`, and CI runs
16//! `cargo clippy --all-targets -- -D warnings`. A launch site that skips this check therefore fails
17//! the build; the single production exemption is annotated at `create::singleton_launcher`.
18
19use chia_protocol::Coin;
20
21use crate::error::{DidError, DidResult};
22
23/// A coin amount that has been proven ODD, and is therefore usable as a singleton's amount.
24///
25/// Construct with [`SingletonAmount::new`] or [`SingletonAmount::from_funding_coin`]; there is no
26/// other way to obtain one, and the inner value is only readable through [`SingletonAmount::get`].
27///
28/// # Why this is public when no public function takes one
29///
30/// It is a PRE-FLIGHT VALIDATOR for callers, not a parameter type: a wallet splitting a funding coin
31/// checks the amount it is about to split to — `SingletonAmount::new(amount)?` — BEFORE building the
32/// spend, and gets the same answer, from the same code, that `create_did` would give it afterwards.
33/// The create entry points take a `Coin` and validate internally, so the type appears in no
34/// signature today. (dig_ecosystem#2479 proposes promoting it so `dig-account` and `dig-merkle`
35/// share this one validated type instead of each restating the odd-amount rule; until then, keep it
36/// public — a consumer restating the rule is exactly the drift it exists to prevent.)
37///
38/// # Money note
39///
40/// This type proves the amount is *launchable*, not that it is the amount you meant to lock up.
41/// The whole amount becomes the singleton's amount — see [`SingletonAmount::from_funding_coin`].
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
43pub struct SingletonAmount(u64);
44
45impl SingletonAmount {
46    /// The amount `dig-account` splits a funding coin down to before minting, and the smallest
47    /// amount a singleton can carry. Callers with no reason to prefer another value should use it.
48    ///
49    /// This const is built in-module, so it bypasses [`SingletonAmount::new`]'s check — the same is
50    /// true of any const added beside it. Every such const MUST therefore carry an ODD value, and
51    /// MUST be covered by a test that re-validates it through `new` (`consts_are_odd…` below) rather
52    /// than asserting its literal number, so that an even one turns a test red.
53    pub const MINIMAL: Self = Self(1);
54
55    /// Proves `amount` is odd, and therefore usable as a singleton's amount.
56    ///
57    /// # Errors
58    ///
59    /// [`DidError::EvenSingletonAmount`] if `amount` is even (which includes zero). A singleton is
60    /// identified on chain by being the odd-amount output of its launcher, so an even amount can
61    /// never produce one.
62    pub const fn new(amount: u64) -> DidResult<Self> {
63        if amount % 2 == 0 {
64            return Err(DidError::EvenSingletonAmount(amount));
65        }
66        Ok(Self(amount))
67    }
68
69    /// Proves the funding coin's amount is usable as the singleton's amount.
70    ///
71    /// # The whole coin becomes the singleton
72    ///
73    /// A launch built from `coin` gives the singleton `coin.amount` — the ENTIRE amount, because
74    /// this crate is a pure spend builder and emits no change output. Deciding where change goes is
75    /// the caller's policy, not this crate's: `dig-account` splits an exact 1-mojo coin off its
76    /// source coin first (`CREATE_COIN(puzzle_hash, 1, memos)`) and calls in with that, keeping the
77    /// remainder under its own control. A caller that instead passes a whole 1,000,001-mojo wallet
78    /// coin mints a 1,000,001-mojo DID and locks the excess in the identity coin.
79    ///
80    /// So: pass a coin pre-split to EXACTLY the amount the singleton should carry.
81    ///
82    /// # Errors
83    ///
84    /// [`DidError::EvenSingletonAmount`] if the coin's amount is even — see [`SingletonAmount::new`].
85    pub const fn from_funding_coin(coin: &Coin) -> DidResult<Self> {
86        Self::new(coin.amount)
87    }
88
89    /// The proven-odd amount.
90    #[must_use]
91    pub const fn get(self) -> u64 {
92        self.0
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use chia_protocol::Bytes32;
100
101    fn coin_of(amount: u64) -> Coin {
102        Coin::new(Bytes32::default(), Bytes32::default(), amount)
103    }
104
105    #[test]
106    fn odd_amounts_are_accepted_and_read_back_unchanged() {
107        for amount in [1_u64, 3, 1_000_001, u64::MAX] {
108            let proven = SingletonAmount::new(amount)
109                .expect("an odd amount is a valid singleton amount")
110                .get();
111            assert_eq!(proven, amount);
112        }
113    }
114
115    #[test]
116    fn even_amounts_are_refused_by_value() {
117        for amount in [0_u64, 2, 1_000_000, u64::MAX - 1] {
118            assert!(
119                matches!(
120                    SingletonAmount::new(amount),
121                    Err(DidError::EvenSingletonAmount(reported)) if reported == amount
122                ),
123                "{amount} is even and must be refused, naming itself"
124            );
125        }
126    }
127
128    #[test]
129    fn from_funding_coin_reads_the_coins_amount() {
130        assert_eq!(
131            SingletonAmount::from_funding_coin(&coin_of(7))
132                .expect("7 is odd")
133                .get(),
134            7
135        );
136        assert!(matches!(
137            SingletonAmount::from_funding_coin(&coin_of(8)),
138            Err(DidError::EvenSingletonAmount(8))
139        ));
140    }
141
142    /// Pins the PROPERTY the in-module consts must have, not the number they happen to hold.
143    ///
144    /// `MINIMAL` is `Self(1)`, a construction that skips [`SingletonAmount::new`]. Asserting
145    /// `MINIMAL.get() == 1` would only catch a changed *value*; it would stay green if someone
146    /// added — or changed `MINIMAL` to — an EVEN const, which is the failure that actually loses a
147    /// funding coin. Re-validating through `new` fails on exactly that.
148    #[test]
149    fn consts_are_odd_and_would_pass_the_constructor() {
150        assert_const_would_pass_the_constructor("MINIMAL", SingletonAmount::MINIMAL);
151    }
152
153    /// Re-validates an in-module const through the public constructor. Call it once per const added
154    /// to [`SingletonAmount`], from the test above.
155    fn assert_const_would_pass_the_constructor(name: &str, amount: SingletonAmount) {
156        assert!(
157            SingletonAmount::new(amount.get()).is_ok(),
158            "{name} = {} bypasses new(), so it must itself be odd",
159            amount.get()
160        );
161    }
162}