Skip to main content

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}