dig_did/error.rs
1//! The `dig-did` error taxonomy (SPEC §6).
2//!
3//! Every fallible operation in this crate returns [`DidError`]. It wraps the underlying
4//! chia-wallet-sdk driver error (the byte-source-of-truth for puzzle construction, INV-4) and adds
5//! the DID-domain failure modes this crate raises directly — parse failures, fail-closed hydration
6//! guards, and the `did:chia:` address-codec errors.
7
8use chia_wallet_sdk::driver::DriverError;
9use thiserror::Error;
10
11/// The result type returned by every fallible `dig-did` operation.
12pub type DidResult<T> = Result<T, DidError>;
13
14/// Everything that can go wrong while building or parsing a DID spend.
15///
16/// The variants split into two families: errors *delegated* to the chia-wallet-sdk driver/signer
17/// (wrapped verbatim so the underlying cause is never lost), and DID-domain errors this crate
18/// raises itself (parse/hydration/codec guards, all fail-closed per SPEC §5).
19///
20/// Marked `#[non_exhaustive]`: this taxonomy grows whenever a new fail-closed guard is added, and
21/// every such addition would otherwise be a breaking change for any downstream exhaustive `match`.
22/// Downstream code must carry a `_` arm. `dig-account`'s `AccountError` is `#[non_exhaustive]` for
23/// the same reason; the two now agree.
24#[derive(Debug, Error)]
25#[non_exhaustive]
26pub enum DidError {
27 /// A chia-wallet-sdk driver operation failed (puzzle currying, spend construction, CLVM
28 /// evaluation). The wrapped [`DriverError`] carries the precise cause.
29 #[error("chia driver error: {0}")]
30 Driver(#[from] DriverError),
31
32 /// The signing calculator failed to derive the required signatures from the coin spends
33 /// (invalid puzzle/solution, an infinity public key in an `AGG_SIG` condition). The message is
34 /// the underlying signer error rendered as a string, so this crate does not leak the signer's
35 /// error type into its public surface.
36 #[error("signature calculation failed: {0}")]
37 Signer(String),
38
39 /// A coin/puzzle/solution could not be parsed as the expected shape.
40 #[error("failed to parse DID: {0}")]
41 Parse(String),
42
43 /// The supplied puzzle parsed successfully but is not a DID singleton.
44 #[error("coin is not a DID singleton")]
45 NotDid,
46
47 /// A `did:chia:1…` string was malformed or failed bech32m decoding.
48 #[error("invalid did:chia string: {0}")]
49 InvalidDidString(String),
50
51 /// The operation cannot honour the [`crate::Owner`] variant it was given, because it must add
52 /// conditions of its own and a caller-supplied pre-built inner spend emits one fixed condition
53 /// set. Rather than silently dropping those conditions — which yields a well-formed bundle that
54 /// creates none of the coins it reports — the operation refuses. The message names the
55 /// alternative the caller should use instead (SPEC §5, fail-closed).
56 #[error("unsupported owner for this operation: {0}")]
57 UnsupportedOwner(&'static str),
58
59 /// A funding coin with an EVEN amount was supplied to a DID launch. The `u64` is that amount.
60 ///
61 /// Chia's singleton top layer recognises only the launcher's ODD-amount output as the
62 /// singleton, and this crate's launch gives the singleton the funding coin's entire amount. An
63 /// even-amount funding coin therefore produces a bundle that spends the money and creates no
64 /// DID at all — a total, silent loss of the funding coin, not a rejected spend. Arbitrary
65 /// wallet coins are even about half the time.
66 ///
67 /// Split the funding coin down to exactly the odd amount the singleton should carry first
68 /// (`dig-account` splits to 1 mojo) and pass that coin (SPEC §3, fail-closed).
69 #[error(
70 "funding coin amount {0} is even: a singleton is the odd-amount output of its launcher, so \
71 this launch would spend the coin and create no DID — split the funding coin to an exact \
72 odd amount (1 mojo is conventional) first"
73 )]
74 EvenSingletonAmount(u64),
75
76 /// A caller supplied an odd-amount `CREATE_COIN` to a DID-preserving spend. A singleton's inner
77 /// puzzle may emit exactly ONE odd-amount `CREATE_COIN`, and the DID's own recreation occupies
78 /// it, so a caller's odd-amount `CREATE_COIN` can never be valid here — most often an attempt to
79 /// parent a foreign singleton launcher (an amount-1 coin) to the DID coin.
80 ///
81 /// Refused at build time because the alternative is opaque: the bundle would assemble and report
82 /// a child DID, then be rejected at mempool admission. It never enters a block, so no fee is
83 /// paid — but the caller pays a wasted round-trip and gets no explanation. Parent the launcher to
84 /// an ordinary coin instead and bind it to the DID by an announcement this spend asserts, or by
85 /// the launched singleton's owner puzzle hash (SPEC §5, fail-closed).
86 #[error(
87 "caller supplied an odd-amount CREATE_COIN: a singleton may emit exactly one odd-amount \
88 output and the DID's recreation occupies it, so this spend could never be valid on chain \
89 — parent any singleton launcher to an ordinary coin and bind it to the DID by announcement"
90 )]
91 OddAmountCreateCoin,
92
93 /// A caller supplied an `AGG_SIG_UNSAFE` requirement in the conditions of a DID spend.
94 ///
95 /// Unlike every other `AGG_SIG_*` condition, `AGG_SIG_UNSAFE` is signed with **no coin binding
96 /// and no domain separation** — the signed message is the caller's bytes verbatim. A DID owner
97 /// induced to sign one produces a permanent, replayable assertion under their identity key,
98 /// reusable in any spend or challenge-response the attacker later constructs. Since this crate's
99 /// contract is that the caller signs every message `required_signatures` reports, such a
100 /// requirement is never legitimate in a DID spend and is refused (SPEC §5, fail-closed).
101 ///
102 /// This refusal removes the UNBOUNDED shape, not every shape whose damage outlives the bundle.
103 /// A permitted `AGG_SIG_PARENT` also outlives it: that signature is bound to the DID coin's
104 /// PARENT id, so it stays satisfiable by any future spend of any coin sharing that parent — the
105 /// other outputs of the DID's PREVIOUS spend, not anything this spend creates. That set was
106 /// fixed before this spend was built and MAY include a coin an earlier caller paid to a third
107 /// party, under a puzzle that third party chose. What the refusal buys is a BOUND, not an end
108 /// to persistence: unlike `AGG_SIG_UNSAFE`, a permitted signature can never reach a later
109 /// generation of the DID and can never become an off-domain assertion.
110 ///
111 /// Nor does it make a hostile condition set safe. The permitted shapes still move the caller's
112 /// own bundled funds to caller-chosen puzzle hashes and still emit announcements under the
113 /// DID's authority. A caller composing conditions from an untrusted source MUST review the
114 /// bundle before signing — and, where an `AGG_SIG_PARENT` is present, MUST also account for
115 /// what the DID's PREVIOUS spend created, which this bundle does not show.
116 #[error(
117 "caller supplied an AGG_SIG_UNSAFE condition: it is signed with no coin binding and no \
118 domain separation, so the resulting signature is replayable against any other spend — a \
119 DID spend must never carry one"
120 )]
121 AggSigUnsafeInConditions,
122
123 /// A caller supplied a `CREATE_COIN` whose amount atom is not chia's canonical integer encoding.
124 ///
125 /// CLVM integers are SIGNED and chia additionally requires a canonical encoding, but the typed
126 /// `CreateCoin::amount` this crate's allowlist reads is a `u64` decoded from the atom UNSIGNED.
127 /// The two disagree on exactly the encodings chia refuses: a leading byte with the sign bit set
128 /// (`0x80` reads as 128, chain says `CoinAmountNegative`), a redundant leading zero (`0x000002`
129 /// reads as 2, chain says `InvalidCoinAmount`), and an atom with more bytes than the value needs
130 /// (chain says the amount overflows). Such a spend assembles here, reports a child DID, and is
131 /// then dropped at mempool admission telling the caller nothing — the opaque failure this guard
132 /// exists to prevent.
133 ///
134 /// The rule mirrors chia's `sanitize_uint` exactly, so it can refuse nothing the chain would
135 /// accept (SPEC §5, fail-closed).
136 #[error(
137 "caller supplied a CREATE_COIN whose amount is not canonically encoded: {0} — CLVM \
138 integers are signed and chia requires a canonical encoding, so this amount would be \
139 rejected at mempool admission"
140 )]
141 NonCanonicalCreateCoinAmount(String),
142
143 /// A caller supplied a condition that is not on the allowlist of shapes a DID-preserving spend
144 /// may carry. The string renders the offending condition.
145 ///
146 /// The guard is an allowlist rather than a list of refusals for a structural reason:
147 /// `chia_sdk_types::Condition` is `#[non_exhaustive]` and carries a catch-all `Other` variant
148 /// that serializes to CLVM **verbatim**, so any caller can hand a refused condition over under a
149 /// name a denylist does not recognise while the chain still sees the condition itself. Only a
150 /// guard that refuses everything it does not explicitly permit can fail closed — and it stays
151 /// closed when a future SDK release adds a variant nobody here has considered (SPEC §5).
152 #[error(
153 "caller supplied a condition a DID spend may not carry: {0} — a DID-preserving spend \
154 permits only announcements, assertions, even-amount CREATE_COINs, fees, and coin-bound \
155 signature requirements"
156 )]
157 DisallowedCondition(String),
158
159 /// A recovery operation supplied an inconsistent recovery configuration (list hash / required
160 /// verifications mismatch).
161 #[error("invalid recovery configuration: {0}")]
162 InvalidRecovery(String),
163
164 /// Hydration could not establish the lineage proof required to spend the DID (SPEC §5,
165 /// fail-closed).
166 #[error("missing lineage proof for DID")]
167 MissingLineage,
168
169 /// A parsed DID coin was missing the owner hint memo required to recreate its child (SPEC §5,
170 /// fail-closed).
171 #[error("missing owner hint on DID coin")]
172 MissingHint,
173
174 /// A chain-level precondition was violated (e.g. a supplied coin does not match the expected
175 /// launcher). The string states the specific violation. Also carries a [`crate::resolve::ChainSource`]
176 /// read error verbatim — a failed read NEVER degrades to "assume owned" (SPEC §5, fail-closed).
177 #[error("chain precondition failed: {0}")]
178 Chain(String),
179
180 /// The DID's identity singleton has no current on-chain coin — it was never launched, or has been
181 /// melted, so there is no lineage to root a coin against (SPEC §5, fail-closed).
182 #[error("DID singleton has no current on-chain coin (unlaunched or melted)")]
183 NoIdentitySingleton,
184
185 /// The coin under proof could not be authenticated as a genuine singleton: its parent-spend chain
186 /// does not resolve to a singleton launcher (an ordinary payment/change coin, or a pay-to coin that
187 /// merely wears a singleton puzzle hash without a genuine recreation parent spend). SPEC §5.
188 #[error("coin is not a genuine singleton")]
189 NotASingleton,
190
191 /// The coin authenticates as a genuine singleton, but neither IS the DID singleton nor was launched
192 /// from a coin in the DID singleton's lineage — it is not rooted in the DID's identity (SPEC §5).
193 #[error("coin is not rooted in the DID's singleton lineage")]
194 NotDidRooted,
195
196 /// The DID's current tip authenticated as a genuine singleton, but its GENUINE launcher (walked
197 /// from the parent-spend chain) is not the launcher that was requested. This is the money-critical
198 /// guard for [`crate::resolve_xch_address`]: a dishonest [`crate::ChainSource`] can echo an
199 /// attacker DID's tip for a victim launcher, and the curried `launcher_id` on that tip is
200 /// attacker-chosen, so only the parent-walk-authenticated launcher may be trusted. Resolving an
201 /// address from a mismatched launcher would pay the wrong recipient, so this fails closed (SPEC §5).
202 #[error("the DID tip's authenticated launcher does not match the requested launcher")]
203 LauncherMismatch,
204
205 /// The parent-spend walk exceeded [`crate::resolve::MAX_LINEAGE_DEPTH`] — a DoS guard against an
206 /// unbounded (possibly adversarial) lineage. The proof fails closed rather than walk forever.
207 #[error("singleton lineage exceeds the maximum authenticated depth")]
208 LineageTooDeep,
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn display_messages_are_descriptive() {
217 assert_eq!(DidError::NotDid.to_string(), "coin is not a DID singleton");
218 assert_eq!(
219 DidError::MissingLineage.to_string(),
220 "missing lineage proof for DID"
221 );
222 assert_eq!(
223 DidError::MissingHint.to_string(),
224 "missing owner hint on DID coin"
225 );
226 assert_eq!(
227 DidError::Parse("bad".into()).to_string(),
228 "failed to parse DID: bad"
229 );
230 assert_eq!(
231 DidError::InvalidDidString("nope".into()).to_string(),
232 "invalid did:chia string: nope"
233 );
234 assert_eq!(
235 DidError::InvalidRecovery("mismatch".into()).to_string(),
236 "invalid recovery configuration: mismatch"
237 );
238 assert_eq!(
239 DidError::Signer("boom".into()).to_string(),
240 "signature calculation failed: boom"
241 );
242 assert_eq!(
243 DidError::Chain("wrong launcher".into()).to_string(),
244 "chain precondition failed: wrong launcher"
245 );
246 }
247
248 #[test]
249 fn wraps_driver_errors_via_from() {
250 let driver = DriverError::InvalidSingletonStruct;
251 let err: DidError = driver.into();
252 assert!(matches!(err, DidError::Driver(_)));
253 assert!(err.to_string().starts_with("chia driver error:"));
254 }
255}