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}