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