Skip to main content

dig_did/
update.rs

1//! DID owner spends that leave the DID itself unchanged (SPEC §3, unit U3).
2//!
3//! The DID's inner puzzle is spent, the DID recreates itself byte-identically, and any conditions the
4//! caller supplies ride along in that same spend. This is how a caller binds another operation to an
5//! authenticated act of the DID it controls, in one atomic bundle.
6//!
7//! # What this composes with, and what it does not
8//!
9//! Composition is by ANNOUNCEMENT: the DID emits an announcement another spend in the same bundle
10//! asserts, or asserts an announcement that other spend emits. Both directions are on the allowlist,
11//! and both are atomic — neither spend confirms without the other. That covers a dig-merkle store
12//! launch, an attestation, and any operation whose own spend can be authorized by "the DID spent in
13//! this bundle said so".
14//!
15//! Composition by MAGIC CONDITION does not work here. The data-store root updater
16//! (`UpdateDataStoreMerkleRoot`, opcode −13) and the NFT owner assignment (`TransferNft`, opcode
17//! −10) are both refused by the allowlist today. A DID-authorized NFT assignment is a real
18//! operation and needs its own deliberate unit — widening a custody allowlist to make a sentence in
19//! a doc comment true is exactly the change this guard exists to prevent.
20//!
21//! # What the allowlist does and does not promise
22//!
23//! It bounds the KIND of authority a spend may create, not the VALUE it may move. A permitted
24//! even-amount `CREATE_COIN` may pay any amount of the caller's bundled funds to any puzzle hash,
25//! and a permitted `CREATE_PUZZLE_ANNOUNCEMENT` is emitted by the DID coin verbatim — which is
26//! precisely how a DID grants authority to another spend. Nor does it confine every permitted shape
27//! to this bundle: an `AGG_SIG_PARENT` signature is bound to the DID coin's PARENT id, so it stays
28//! satisfiable by any future spend of any coin sharing that parent — the outputs of the DID's
29//! PREVIOUS spend, not anything this spend creates. That set was fixed before this spend was built
30//! and may include a coin an earlier caller paid to a third party. What the guard does hold: such a
31//! signature can never reach a later generation of this DID, and can never become an off-domain
32//! assertion. A caller who does not trust the source of these conditions must review the bundle —
33//! and, where an `AGG_SIG_PARENT` is present, also what the DID's PREVIOUS spend created, which this
34//! bundle does not show. The guard does not make hostile conditions safe, and no allowlist over a
35//! conditions passthrough could. See [`permit_only_conditions_a_did_may_carry`].
36//!
37//! The DID's own spend requires exactly one `AGG_SIG_ME` under the owner's key. The caller's
38//! conditions may add signature requirements of their own on top of that, but only the kinds bound
39//! to a coin id or parent id: the caller's conditions are judged by an ALLOWLIST over their
40//! re-parsed CLVM form, so `AGG_SIG_UNSAFE` and every other unbound or lifetime-constant signature
41//! kind is refused. See [`permit_only_conditions_a_did_may_carry`] for why only an allowlist can
42//! fail closed here.
43//!
44//! # One odd-amount output
45//!
46//! A singleton's inner puzzle may emit exactly ONE odd-amount `CREATE_COIN`, and the DID's own
47//! recreation occupies it. A singleton launcher is an odd-amount coin, so a foreign singleton CANNOT
48//! be parented to the DID coin. [`spend_did_with_conditions`] therefore refuses a caller's
49//! odd-amount `CREATE_COIN` outright: left to run, the bundle would assemble and report a child DID,
50//! then be dropped at mempool admission — never entering a block, so costing no fee, but telling the
51//! caller nothing about why. A DID-rooted launch parents its launcher to an ordinary coin and binds
52//! it to the DID by other means (an announcement this spend asserts, or the launched singleton's
53//! owner puzzle hash). See
54//! `a_foreign_singleton_launcher_cannot_be_parented_to_the_did_coin` in this module's tests.
55
56use chia_protocol::Bytes32;
57use chia_wallet_sdk::clvmr::{Allocator, NodePtr, SExp};
58use chia_wallet_sdk::driver::{Did, SingletonInfo, SpendContext};
59use chia_wallet_sdk::types::{Condition, Conditions};
60
61use crate::context::inner_spend;
62use crate::error::{DidError, DidResult};
63use crate::types::Owner;
64
65/// Spends `did`, emitting `conditions` **in addition to** the recreation condition that preserves the
66/// DID unchanged. Returns the recreated child DID.
67///
68/// The recreation `CREATE_COIN` — same inner puzzle hash, same amount, same owner hint — is emitted
69/// FIRST and the caller's conditions follow it; it is never substituted for them, and they never
70/// replace it. A DID that fails to recreate itself is a burned identity, so that condition is not
71/// the caller's to omit. (The ordering is load-bearing, not cosmetic — see the comment in the body.)
72///
73/// A caller may emit at most one odd-amount `CREATE_COIN`'s worth of singleton output, and the
74/// recreation already uses it; see this module's docs before attempting a DID-parented launcher.
75///
76/// The spend is staged into `ctx` (drain it with `SpendContext::take` once the whole bundle is
77/// assembled). `conditions` MUST have been built in that SAME context: [`Conditions`] holds
78/// `NodePtr`s that address `ctx`'s allocator and are meaningless — silently, not as a compile error —
79/// in any other.
80///
81/// # Signature
82///
83/// The DID's own spend requires exactly one `AGG_SIG_ME`, over this DID coin, under `owner`'s key
84/// (SPEC §3). The caller's `conditions` MAY add further signature requirements of their own — a
85/// permitted `AGG_SIG_*` among them appears in `required_signatures` too — so the total is one PLUS
86/// whatever the caller supplied, not always one. Only the coin-bound kinds are permitted
87/// (`AGG_SIG_ME` and the `PARENT`-bearing kinds); `AGG_SIG_UNSAFE` and the kinds bound only to
88/// attributes a self-recreating DID never varies are refused, because the signatures they induce
89/// are replayable against other spends. See [`permit_only_conditions_a_did_may_carry`].
90///
91/// # Errors
92///
93/// - [`DidError::AggSigUnsafeInConditions`] if any caller condition is an `AGG_SIG_UNSAFE`.
94/// - [`DidError::DisallowedCondition`] if any caller condition falls outside the allowlist of
95///   shapes a DID-preserving spend may carry — the guard refuses everything it does not explicitly
96///   permit, judged on the re-parsed conditions, so it fails closed against a condition disguised
97///   as one the SDK cannot name.
98/// - [`DidError::UnsupportedOwner`] if `owner` is [`Owner::Custom`]. A pre-built inner spend emits
99///   one fixed condition set, so it cannot carry the recreation condition this function must add —
100///   the caller would receive a child DID the bundle never creates. Build the spend yourself and
101///   call `Did::spend` directly instead.
102/// - [`DidError::OddAmountCreateCoin`] if any caller condition is an odd-amount `CREATE_COIN`. The
103///   singleton's single odd-amount output is already the DID's recreation, so such a spend could
104///   never be valid on chain; it is refused here rather than at mempool admission.
105/// - [`DidError::Parse`] if the spend produces no parseable successor DID.
106/// - [`DidError::Driver`] for any underlying chia-wallet-sdk failure.
107pub fn spend_did_with_conditions(
108    ctx: &mut SpendContext,
109    did: Did,
110    owner: Owner,
111    conditions: Conditions,
112) -> DidResult<Did> {
113    if matches!(owner, Owner::Custom(_)) {
114        return Err(DidError::UnsupportedOwner(
115            "spend_did_with_conditions requires Owner::Standard; a pre-built custom inner spend \
116             cannot carry the DID's recreation condition — build the spend yourself and call \
117             Did::spend",
118        ));
119    }
120
121    permit_only_conditions_a_did_may_carry(ctx, &conditions)?;
122
123    let unchanged_inner_puzzle_hash: Bytes32 = did.info.inner_puzzle_hash().into();
124    let memos = ctx.hint(did.info.p2_puzzle_hash)?;
125
126    // The recreation is emitted FIRST, and that order is load-bearing, not cosmetic. `Did::spend`
127    // identifies the successor by scanning for the first odd-amount `CREATE_COIN`, and BAILS with
128    // `Ok(None)` — not `continue` — the moment it meets one carrying no memos. A singleton launcher
129    // is exactly that condition (amount 1, `Memos::None`), so a caller launching a foreign singleton
130    // would otherwise get "no successor DID" for a spend that is in fact perfectly valid.
131    let with_recreation = Conditions::new()
132        .create_coin(unchanged_inner_puzzle_hash, did.coin.amount, memos)
133        .extend(conditions);
134
135    let spend = inner_spend(ctx, owner, with_recreation)?;
136    did.spend(ctx, spend)?
137        .ok_or_else(|| DidError::Parse("DID spend produced no successor DID".into()))
138}
139
140/// Permits ONLY the condition shapes a DID-preserving spend legitimately carries, refusing
141/// everything else — including anything the SDK cannot name.
142///
143/// # Why an allowlist, and why it judges the RE-PARSED conditions
144///
145/// [`Condition`] is `#[non_exhaustive]` and its final variant is a catch-all, [`Condition::Other`],
146/// which holds a raw CLVM node and serializes to the wire **verbatim**. `Other` is a public variant
147/// of a public enum, so ANY caller may build the exact bytes of a forbidden condition and hand them
148/// over under that name. A guard that matches the caller's typing therefore judges what the caller
149/// chose to CALL the condition, not what the chain will EXECUTE — and its compiler-mandated `_` arm
150/// waves the disguise through. This was not theoretical: an `AGG_SIG_UNSAFE` smuggled as `Other`
151/// reached `required_signatures` intact, an arbitrary-message signing oracle under the DID owner's
152/// identity key.
153///
154/// Two properties close that, and both are required:
155///
156/// 1. **Re-parse before judging.** The caller's conditions are allocated to CLVM and read back as
157///    `Vec<Condition>`, so an `Other`-wrapped `AGG_SIG_UNSAFE` resolves into the typed variant it
158///    actually is. The guard then sees what the chain sees.
159/// 2. **Refuse anything not explicitly permitted.** A denylist over a `#[non_exhaustive]` enum with
160///    a verbatim catch-all is structurally unable to fail closed: every SDK release may add a
161///    variant it silently admits. An allowlist fails closed by construction, and `Other` itself is
162///    refused — a DID-preserving spend has no legitimate need for a condition the SDK cannot name.
163///
164/// # The scope of the guarantee
165///
166/// The allowlist bounds the KIND of authority a DID spend may create. It does NOT bound value, and
167/// it does NOT sanitize a hostile caller. Two permitted shapes make that concrete, both demonstrated
168/// on the simulator: an even-amount `CREATE_COIN` moved a caller-chosen amount of the signer's own
169/// bundled funds to a caller-chosen puzzle hash, and a `CREATE_PUZZLE_ANNOUNCEMENT` was emitted by
170/// the DID coin verbatim — announcements are Chia's authority-granting primitive, so that is a real
171/// grant, not merely a constraint.
172///
173/// Neither is everything it permits confined to the bundle's lifetime. An `AGG_SIG_PARENT`
174/// signature is bound to the DID coin's PARENT id, so it stays satisfiable by any future spend of
175/// any coin sharing that parent — the outputs of the DID's PREVIOUS spend, not anything this spend
176/// creates. (A coin this spend creates has THIS coin's id as its parent id, a different value.)
177/// That set was fixed before this spend was built and may include a coin an earlier caller paid to
178/// a third party, whose puzzle that third party chose. For the EVE generation the previous spend is
179/// the LAUNCHER's, whose only output is the eve coin itself, so the sibling set is empty and the
180/// exposure is nil; it opens from the first ordinary spend onwards. The bound that does hold: such a signature
181/// can never reach a later generation of this DID, and can never become an off-domain assertion.
182/// It is the whole of what the guard buys here. A caller composing conditions from an untrusted
183/// source MUST review the bundle before signing — and, where an `AGG_SIG_PARENT` is present, must
184/// also account for what the DID's PREVIOUS spend created, which this bundle does not show.
185///
186/// # What is permitted, and why
187///
188/// Announcements and assertions (including timelocks and `ASSERT_MY_*`) bind this spend to the rest
189/// of its bundle: assertions constrain when and alongside what it may run, and announcements grant
190/// other spends in the same bundle the DID's authority to run. Even-amount `CREATE_COIN`s and
191/// `RESERVE_FEE` move value the caller's own bundle supplies; `REMARK` is inert; messages are the
192/// announcement mechanism's successor.
193///
194/// Of the `AGG_SIG_*` family only the kinds bound to THIS spend's coin lineage are permitted —
195/// `AGG_SIG_ME` and the `PARENT`-bearing kinds, which commit to a coin id or parent id. A coin id is
196/// unique; a parent id is NOT unique to a coin (every sibling of one spend shares it), so an
197/// `AGG_SIG_PARENT` signature emitted here is reusable by any SIBLING of this DID coin — that is,
198/// by the other outputs of the DID's PREVIOUS spend, a set fixed before this spend was built. It is
199/// permitted nonetheless, because that set is BOUNDED: the signature cannot reach a later
200/// generation of the DID (each generation has a different parent id) and cannot become an
201/// off-domain assertion. Bounded is not the same as visible — enumerating the set requires fetching
202/// the DID's previous spend, which this bundle does not contain. It is also NOT confined to coins
203/// the caller controls — see "The scope of the guarantee" above.
204/// `AGG_SIG_PUZZLE`, `AGG_SIG_AMOUNT` and `AGG_SIG_PUZZLE_AMOUNT` are refused because
205/// a self-recreating DID keeps those attributes IDENTICAL for its entire lifetime (same puzzle
206/// hash, amount 1, every generation), so such a signature is replayable in any later spend emitting
207/// the same kind and message. `AGG_SIG_UNSAFE` is refused with its own error, being bound to
208/// nothing at all.
209///
210/// Refused by the catch-all arm, and deliberately: `MELT_SINGLETON` (it would burn the DID),
211/// `RUN_CAT_TAIL`, the NFT owner assignment (`TransferNft`, opcode −10), the data-store root updater
212/// (`UpdateDataStoreMerkleRoot`, opcode −13), `SOFTFORK`, and `Other`. `MELT_SINGLETON` is pinned by
213/// `refuses_a_melt_singleton_condition` rather than left to omission: its failure mode is a
214/// permanently burned identity, so widening the allowlist to admit it must turn a test red.
215fn permit_only_conditions_a_did_may_carry(
216    ctx: &mut SpendContext,
217    conditions: &Conditions,
218) -> DidResult<()> {
219    let allocated = ctx.alloc(conditions)?;
220    let as_the_chain_sees_them: Vec<Condition> = ctx.extract(allocated)?;
221    // The SAME conditions as raw CLVM nodes, judged alongside the typed ones. The typed form has
222    // already discarded information the chain still sees: `CreateCoin::amount` is a `u64` decoded
223    // from the atom UNSIGNED, so a negative or non-canonically-encoded amount arrives here as an
224    // innocuous number. Only the raw atom can answer that question.
225    let as_raw_clvm: Vec<NodePtr> = ctx.extract(allocated)?;
226
227    for (condition, raw) in as_the_chain_sees_them.iter().zip(as_raw_clvm) {
228        match condition {
229            // Judged BEFORE the odd-amount rule, because an amount the chain will not even decode
230            // is a more fundamental defect than the value it happens to read as.
231            Condition::CreateCoin(_) if !amount_is_canonically_encoded(ctx, raw) => {
232                return Err(DidError::NonCanonicalCreateCoinAmount(describe_amount(
233                    ctx, raw,
234                )));
235            }
236            // A singleton permits exactly ONE odd-amount `CREATE_COIN` and the recreation claims it.
237            // `spend_did_with_conditions` never melts, so a caller's odd-amount output is ALWAYS
238            // chain-invalid here — there is no legitimate case this rejects.
239            Condition::CreateCoin(create) if create.amount % 2 == 1 => {
240                return Err(DidError::OddAmountCreateCoin);
241            }
242            // `AGG_SIG_UNSAFE` carries no coin binding and no domain separation, so signing it yields
243            // a replayable assertion under the DID owner's key over attacker-chosen bytes. It keeps
244            // its own error because that is the failure a caller most needs named precisely.
245            Condition::AggSigUnsafe(_) => return Err(DidError::AggSigUnsafeInConditions),
246
247            Condition::Remark(_)
248            | Condition::CreateCoin(_)
249            | Condition::ReserveFee(_)
250            | Condition::CreateCoinAnnouncement(_)
251            | Condition::AssertCoinAnnouncement(_)
252            | Condition::CreatePuzzleAnnouncement(_)
253            | Condition::AssertPuzzleAnnouncement(_)
254            | Condition::AssertConcurrentSpend(_)
255            | Condition::AssertConcurrentPuzzle(_)
256            | Condition::SendMessage(_)
257            | Condition::ReceiveMessage(_)
258            | Condition::AssertMyCoinId(_)
259            | Condition::AssertMyParentId(_)
260            | Condition::AssertMyPuzzleHash(_)
261            | Condition::AssertMyAmount(_)
262            | Condition::AssertMyBirthSeconds(_)
263            | Condition::AssertMyBirthHeight(_)
264            | Condition::AssertEphemeral(_)
265            | Condition::AssertSecondsRelative(_)
266            | Condition::AssertSecondsAbsolute(_)
267            | Condition::AssertHeightRelative(_)
268            | Condition::AssertHeightAbsolute(_)
269            | Condition::AssertBeforeSecondsRelative(_)
270            | Condition::AssertBeforeSecondsAbsolute(_)
271            | Condition::AssertBeforeHeightRelative(_)
272            | Condition::AssertBeforeHeightAbsolute(_)
273            | Condition::AggSigMe(_)
274            | Condition::AggSigParent(_)
275            | Condition::AggSigParentAmount(_)
276            | Condition::AggSigParentPuzzle(_) => {}
277
278            // The arm that makes this fail closed. It is reached by `Other`, by the magic
279            // `CREATE_COIN` forms, by `SOFTFORK`, by the replayable `AGG_SIG_*` kinds — and by every
280            // variant a future SDK release adds. Widening the allowlist must be a deliberate act
281            // with a stated reason, never the default.
282            other => return Err(DidError::DisallowedCondition(describe(ctx, other, raw))),
283        }
284    }
285    Ok(())
286}
287
288/// The `CREATE_COIN` amount element of `condition`, as a raw CLVM node.
289///
290/// Returns `None` if the node is not shaped `(opcode puzzle_hash amount . rest)`. Every condition
291/// that typed as [`Condition::CreateCoin`] has that shape, so a `None` here means the raw and typed
292/// views disagree — which the caller treats as a refusal, never as an absence of the check.
293fn create_coin_amount_node(allocator: &Allocator, condition: NodePtr) -> Option<NodePtr> {
294    let SExp::Pair(_opcode, after_opcode) = allocator.sexp(condition) else {
295        return None;
296    };
297    let SExp::Pair(_puzzle_hash, after_puzzle_hash) = allocator.sexp(after_opcode) else {
298        return None;
299    };
300    let SExp::Pair(amount, _rest) = allocator.sexp(after_puzzle_hash) else {
301        return None;
302    };
303    Some(amount)
304}
305
306/// Whether the `CREATE_COIN` amount of `condition` is encoded exactly as chia requires.
307///
308/// This mirrors chia's `sanitize_uint` (`chia-consensus`, `max_size = 8`) rule for rule: the empty
309/// atom is zero; a leading byte with the sign bit set is a NEGATIVE integer; a leading zero byte is
310/// permitted ONLY where it stops the next byte reading as a sign bit; and the value must fit in
311/// eight significant bytes. Because it is that rule and not a stricter one, it can refuse nothing
312/// the chain would have accepted.
313fn amount_is_canonically_encoded(allocator: &Allocator, condition: NodePtr) -> bool {
314    let Some(amount) = create_coin_amount_node(allocator, condition) else {
315        return false;
316    };
317    let SExp::Atom = allocator.sexp(amount) else {
318        return false;
319    };
320    let atom = allocator.atom(amount);
321    let bytes = atom.as_ref();
322
323    if bytes.is_empty() {
324        return true;
325    }
326    if bytes[0] & 0x80 != 0 {
327        return false; // a negative integer
328    }
329    if bytes == [0_u8] || (bytes.len() > 1 && bytes[0] == 0 && bytes[1] & 0x80 == 0) {
330        return false; // a leading zero that the value does not need
331    }
332    let significant_bytes = if bytes[0] == 0 { 9 } else { 8 };
333    bytes.len() <= significant_bytes
334}
335
336/// The `CREATE_COIN` amount atom of `condition`, rendered for a refusal message.
337fn describe_amount(allocator: &Allocator, condition: NodePtr) -> String {
338    let Some(amount) = create_coin_amount_node(allocator, condition) else {
339        return "the condition has no amount element".to_string();
340    };
341    match allocator.sexp(amount) {
342        SExp::Atom => {
343            let atom = allocator.atom(amount);
344            let hex: String = atom
345                .as_ref()
346                .iter()
347                .map(|byte| format!("{byte:02x}"))
348                .collect();
349            format!("amount atom 0x{hex}")
350        }
351        SExp::Pair(..) => "the amount element is a pair, not an integer".to_string(),
352    }
353}
354
355/// A refused condition, rendered so the caller can identify it.
356///
357/// [`Condition::Other`] — the smuggling case, and the one a caller is most likely to hit by
358/// accident — holds nothing but a `NodePtr`, whose `Debug` is an allocator index that means nothing
359/// outside this process. Its CLVM OPCODE is the identifying fact, so that is what is surfaced.
360fn describe(allocator: &Allocator, condition: &Condition, raw: NodePtr) -> String {
361    let Condition::Other(_) = condition else {
362        return format!("{condition:?}");
363    };
364    match clvm_opcode(allocator, raw) {
365        Some(opcode) => format!("a condition the SDK cannot name, CLVM opcode {opcode}"),
366        None => "a condition the SDK cannot name, with no atom opcode".to_string(),
367    }
368}
369
370/// The CLVM opcode of `condition` — its first element — read as chia reads it: a SIGNED integer, so
371/// the negative magic opcodes render as themselves. `None` if the condition is not a list whose
372/// first element is an atom of at most eight bytes.
373fn clvm_opcode(allocator: &Allocator, condition: NodePtr) -> Option<i64> {
374    let SExp::Pair(opcode, _rest) = allocator.sexp(condition) else {
375        return None;
376    };
377    let SExp::Atom = allocator.sexp(opcode) else {
378        return None;
379    };
380    let atom = allocator.atom(opcode);
381    let bytes = atom.as_ref();
382    if bytes.len() > 8 {
383        return None;
384    }
385    let negative = bytes.first().is_some_and(|first| first & 0x80 != 0);
386    let mut value: i64 = if negative { -1 } else { 0 };
387    for byte in bytes {
388        value = (value << 8) | i64::from(*byte);
389    }
390    Some(value)
391}
392
393#[cfg(test)]
394// Tests build launchers directly, on purpose: a fixture needs an arbitrary parent id, and some
395// fixtures need an amount the production chokepoint would (rightly) refuse. The lint guards
396// PRODUCTION launch sites; see the note on `singleton_launcher` in create.rs.
397#[allow(clippy::disallowed_methods)]
398mod tests {
399    use super::*;
400    use crate::create::create_simple_did;
401    use crate::test_support::{all_emitted_conditions, creates_coin_to};
402    use chia_bls::SecretKey;
403    use chia_protocol::Bytes;
404    use chia_puzzle_types::singleton::SingletonArgs;
405    use chia_puzzle_types::Memos;
406    use chia_wallet_sdk::clvm_traits::ToClvm;
407    use chia_wallet_sdk::clvmr::{self, Allocator};
408    use chia_wallet_sdk::driver::Launcher;
409    use chia_wallet_sdk::prelude::{PublicKey, MAINNET_CONSTANTS};
410    use chia_wallet_sdk::signer::{AggSigConstants, RequiredSignature};
411    use chia_wallet_sdk::test::Simulator;
412    use chia_wallet_sdk::types::conditions::{AggSig, AggSigKind, CreateCoin};
413
414    /// A DID minted and confirmed on the simulator, with the owner key material needed to spend it
415    /// again. Every test here starts from a DID that genuinely exists on chain.
416    struct MintedDid {
417        did: Did,
418        pk: PublicKey,
419        sk: SecretKey,
420        puzzle_hash: Bytes32,
421    }
422
423    /// The puzzle hash the DID's recreation `CREATE_COIN` must carry: the singleton-wrapped inner
424    /// puzzle hash. Derived from the SDK's own currying rather than read back off the returned
425    /// child, so the assertion is independent of the value under test.
426    fn singleton_puzzle_hash(did: &Did) -> Bytes32 {
427        SingletonArgs::curry_tree_hash(did.info.launcher_id, did.info.inner_puzzle_hash()).into()
428    }
429
430    fn mint(sim: &mut Simulator, ctx: &mut SpendContext) -> anyhow::Result<MintedDid> {
431        let owner = sim.bls(1);
432        let spend = create_simple_did(ctx, owner.coin, Owner::Standard(owner.pk))?;
433        let did = spend.child.expect("create always returns a child DID");
434        sim.spend_coins(spend.coin_spends, std::slice::from_ref(&owner.sk))?;
435        Ok(MintedDid {
436            did,
437            pk: owner.pk,
438            sk: owner.sk,
439            puzzle_hash: owner.puzzle_hash,
440        })
441    }
442
443    /// The load-bearing property, in BOTH halves: the DID recreates itself into a parseable child the
444    /// simulator accepts, AND the caller's conditions actually reach the wire. A test asserting only
445    /// the child would stay green while the conditions were silently dropped — precisely the defect
446    /// class this crate is fixing.
447    #[test]
448    fn spend_did_with_conditions_recreates_the_did_and_emits_the_callers_conditions(
449    ) -> anyhow::Result<()> {
450        let mut sim = Simulator::new();
451        let ctx = &mut SpendContext::new();
452        let owner = mint(&mut sim, ctx)?;
453
454        // A distinctive, caller-chosen condition dig-did would never emit on its own.
455        let marker = Bytes::from(b"dig-did::update::marker".to_vec());
456        let child = spend_did_with_conditions(
457            ctx,
458            owner.did,
459            Owner::Standard(owner.pk),
460            Conditions::new().create_puzzle_announcement(marker.clone()),
461        )?;
462
463        let coin_spends = ctx.take();
464        let emitted = all_emitted_conditions(ctx, &coin_spends)?;
465        assert!(
466            emitted.iter().any(|condition| matches!(
467                condition.as_create_puzzle_announcement(),
468                Some(announcement) if announcement.message == marker
469            )),
470            "the caller's condition must reach the wire"
471        );
472
473        assert_eq!(child.info.launcher_id, owner.did.info.launcher_id);
474        assert_eq!(child.info.p2_puzzle_hash, owner.did.info.p2_puzzle_hash);
475        assert_eq!(child.coin.amount, owner.did.coin.amount);
476
477        sim.spend_coins(coin_spends, &[owner.sk])?;
478        Ok(())
479    }
480
481    /// The caller's conditions ride ALONGSIDE the recreation, never in place of it — a DID that fails
482    /// to recreate itself is a burned identity.
483    #[test]
484    fn callers_conditions_do_not_displace_the_recreation() -> anyhow::Result<()> {
485        let mut sim = Simulator::new();
486        let ctx = &mut SpendContext::new();
487        let owner = mint(&mut sim, ctx)?;
488
489        let recreated_puzzle_hash = singleton_puzzle_hash(&owner.did);
490        let child = spend_did_with_conditions(
491            ctx,
492            owner.did,
493            Owner::Standard(owner.pk),
494            Conditions::new()
495                .create_puzzle_announcement(Bytes::from(b"noise".to_vec()))
496                .create_puzzle_announcement(Bytes::from(b"more noise".to_vec())),
497        )?;
498        assert_eq!(child.coin.puzzle_hash, recreated_puzzle_hash);
499
500        let coin_spends = ctx.take();
501        assert!(
502            creates_coin_to(ctx, &coin_spends, recreated_puzzle_hash)?,
503            "the recreation CREATE_COIN must survive the caller's conditions"
504        );
505        sim.spend_coins(coin_spends, &[owner.sk])?;
506        Ok(())
507    }
508
509    /// Whether `condition` is a `CREATE_COIN` paying `puzzle_hash`.
510    fn creates_coin_with_puzzle_hash(condition: &Condition, puzzle_hash: Bytes32) -> bool {
511        condition
512            .as_create_coin()
513            .is_some_and(|create| create.puzzle_hash == puzzle_hash)
514    }
515
516    /// Whether `condition` is a `CREATE_PUZZLE_ANNOUNCEMENT` carrying exactly `message`.
517    fn announces(condition: &Condition, message: &Bytes) -> bool {
518        condition
519            .as_create_puzzle_announcement()
520            .is_some_and(|announcement| announcement.message == *message)
521    }
522
523    /// The recreation `CREATE_COIN` is emitted FIRST, ahead of every caller condition — pinned
524    /// structurally, on the conditions the spend actually emits.
525    ///
526    /// **Do not delete this as redundant with
527    /// `a_foreign_singleton_launcher_cannot_be_parented_to_the_did_coin`.** That test no longer
528    /// pins the ordering: it was the only fixture supplying the odd-amount, memo-less `CREATE_COIN`
529    /// that `Did::spend`'s successor scan aborts on, and `reject_conditions_a_did_must_never_carry`
530    /// now refuses exactly that input at build time. Every condition that reaches the emit is
531    /// therefore even-amount, and the successor scan finds the recreation wherever it sits — so the
532    /// ordering is no longer observable through this crate's public API by outcome alone. This test
533    /// is the only thing standing between a refactor to `Conditions::new().extend(conditions)
534    /// .create_coin(..)` and a silent break of a property `SPEC.md` states normatively.
535    ///
536    /// It asserts POSITION, not outcome, precisely because outcome cannot distinguish the two
537    /// orderings.
538    ///
539    /// Position is measured relative to the caller's own conditions rather than as an absolute
540    /// index: the puzzle layers contribute a fixed prefix of their own ahead of anything this
541    /// function composes (the singleton top layer's `ASSERT_MY_AMOUNT`/`ASSERT_MY_PARENT_ID`, and
542    /// the p2 puzzle's `AGG_SIG_ME`), so the recreation is never at absolute index 0. What IS
543    /// load-bearing, and what this pins, is that the recreation opens the composed list and the
544    /// caller's conditions follow it IMMEDIATELY and in order — the shape `Did::spend`'s successor
545    /// scan depends on. Two caller conditions, so a reversal is visible as more than an off-by-one.
546    #[test]
547    fn the_recreation_is_emitted_before_the_callers_conditions() -> anyhow::Result<()> {
548        let mut sim = Simulator::new();
549        let ctx = &mut SpendContext::new();
550        let owner = mint(&mut sim, ctx)?;
551
552        let recreated_puzzle_hash = singleton_puzzle_hash(&owner.did);
553        let first = Bytes::from(b"dig-did::ordering::first".to_vec());
554        let second = Bytes::from(b"dig-did::ordering::second".to_vec());
555        let _child = spend_did_with_conditions(
556            ctx,
557            owner.did,
558            Owner::Standard(owner.pk),
559            Conditions::new()
560                .create_puzzle_announcement(first.clone())
561                .create_puzzle_announcement(second.clone()),
562        )?;
563
564        let coin_spends = ctx.take();
565        let emitted = all_emitted_conditions(ctx, &coin_spends)?;
566
567        let index_of = |what: &str, predicate: &dyn Fn(&Condition) -> bool| {
568            emitted
569                .iter()
570                .position(predicate)
571                .unwrap_or_else(|| panic!("{what} must reach the wire, got {emitted:?}"))
572        };
573
574        let recreation = index_of("the DID's recreation", &|condition| {
575            creates_coin_with_puzzle_hash(condition, recreated_puzzle_hash)
576        });
577        let first = index_of("the caller's first condition", &|condition| {
578            announces(condition, &first)
579        });
580        let second = index_of("the caller's second condition", &|condition| {
581            announces(condition, &second)
582        });
583
584        assert_eq!(
585            (first, second),
586            (recreation + 1, recreation + 2),
587            "the recreation must open the composed list, with the caller's conditions following it \
588             in order, got {emitted:?}"
589        );
590
591        sim.spend_coins(coin_spends, &[owner.sk])?;
592        Ok(())
593    }
594
595    /// Exactly one `AGG_SIG_ME` under the owner's key — the key accounting the consuming crate's
596    /// signing gate depends on.
597    ///
598    /// The fixture deliberately carries a NON-empty condition list. An earlier version passed
599    /// `Conditions::new()`, which could not distinguish "the caller's conditions add no signature
600    /// requirement" from "the caller supplied nothing at all" — the assertion held for a reason the
601    /// test never exercised. A benign announcement is the honest control: conditions ARE present,
602    /// and the count is still one.
603    #[test]
604    fn spend_did_with_conditions_requires_exactly_one_agg_sig_me_under_the_owner(
605    ) -> anyhow::Result<()> {
606        let mut sim = Simulator::new();
607        let ctx = &mut SpendContext::new();
608        let owner = mint(&mut sim, ctx)?;
609
610        let _child = spend_did_with_conditions(
611            ctx,
612            owner.did,
613            Owner::Standard(owner.pk),
614            Conditions::new().create_puzzle_announcement(Bytes::from(b"benign".to_vec())),
615        )?;
616        let coin_spends = ctx.take();
617
618        let constants = AggSigConstants::from(&*MAINNET_CONSTANTS);
619        let required = crate::sign::required_signatures(&coin_spends, &constants)
620            .expect("signature calculation must succeed for a well-formed DID spend");
621
622        assert_eq!(required.len(), 1, "one owner spend, one AGG_SIG_ME");
623        match &required[0] {
624            RequiredSignature::Bls(bls) => assert_eq!(bls.public_key, owner.pk),
625            RequiredSignature::Secp(_) => panic!("a standard owner signs with BLS, not secp"),
626        }
627        Ok(())
628    }
629
630    /// A custom inner spend cannot carry the recreation condition, so it is refused rather than
631    /// silently producing a child DID the bundle never creates.
632    #[test]
633    fn spend_did_with_conditions_refuses_a_custom_owner() -> anyhow::Result<()> {
634        let mut sim = Simulator::new();
635        let ctx = &mut SpendContext::new();
636        let owner = mint(&mut sim, ctx)?;
637
638        let prebuilt = inner_spend(ctx, Owner::Standard(owner.pk), Conditions::new())?;
639        let result =
640            spend_did_with_conditions(ctx, owner.did, Owner::Custom(prebuilt), Conditions::new());
641
642        assert!(matches!(result, Err(DidError::UnsupportedOwner(_))));
643        Ok(())
644    }
645
646    /// General composition, proven on the simulator: the caller's conditions create a real extra
647    /// coin alongside the DID's own recreation, and the whole bundle is accepted. A bundle whose
648    /// conditions merely *look* right on inspection is exactly what a dead-allocator `NodePtr`
649    /// produces, so the assertion that matters is that the chain takes it.
650    #[test]
651    fn composes_with_caller_conditions_that_create_a_coin() -> anyhow::Result<()> {
652        let mut sim = Simulator::new();
653        let ctx = &mut SpendContext::new();
654        let owner = mint(&mut sim, ctx)?;
655
656        // The DID coin holds 1 mojo and must recreate itself with all of it, so the extra coin is
657        // balanced by a second coin spent in the same bundle (Chia checks additions against removals
658        // across the whole bundle, not per coin).
659        let funder = sim.bls(2);
660        let funder_spend = inner_spend(ctx, Owner::Standard(funder.pk), Conditions::new())?;
661        ctx.spend(funder.coin, funder_spend)?;
662
663        let recreated_puzzle_hash = singleton_puzzle_hash(&owner.did);
664        let child = spend_did_with_conditions(
665            ctx,
666            owner.did,
667            Owner::Standard(owner.pk),
668            Conditions::new().create_coin(owner.puzzle_hash, 2, Memos::None),
669        )?;
670        assert_eq!(child.coin.puzzle_hash, recreated_puzzle_hash);
671
672        let coin_spends = ctx.take();
673        assert!(
674            creates_coin_to(ctx, &coin_spends, recreated_puzzle_hash)?,
675            "the DID must still recreate itself"
676        );
677        assert!(
678            creates_coin_to(ctx, &coin_spends, owner.puzzle_hash)?,
679            "and the caller's coin must actually be created"
680        );
681        sim.spend_coins(coin_spends, &[owner.sk, funder.sk])?;
682        Ok(())
683    }
684
685    /// A CONSTRAINT, pinned so nobody re-derives it the expensive way: a singleton's inner puzzle may
686    /// emit exactly ONE odd-amount `CREATE_COIN`, and the DID's own recreation occupies it. A
687    /// singleton launcher is an odd-amount coin, so a foreign singleton CANNOT be parented to the DID
688    /// coin itself — `spend_did_with_conditions` builds a bundle the singleton top layer then
689    /// rejects. A DID-rooted launch must parent its launcher to an ordinary coin and bind it to the
690    /// DID some other way (an announcement asserted by this spend, an owner puzzle hash).
691    ///
692    /// This is a chia singleton rule, not a dig-did choice; the test exists so the failure is a
693    /// documented boundary rather than a surprise at the point of use.
694    ///
695    /// This test is ALSO the guard that pins the recreation-first ordering — it is the only test
696    /// whose fixture supplies an odd-amount, memo-less `CREATE_COIN`, the exact condition
697    /// `Did::spend`'s successor scan aborts on. Deleting it as "a redundant documentation test"
698    /// silently unpins a load-bearing property of `spend_did_with_conditions`.
699    #[test]
700    fn a_foreign_singleton_launcher_cannot_be_parented_to_the_did_coin() -> anyhow::Result<()> {
701        let mut sim = Simulator::new();
702        let ctx = &mut SpendContext::new();
703        let owner = mint(&mut sim, ctx)?;
704
705        let launcher = Launcher::new(owner.did.coin.coin_id(), 1);
706        let (launch_conditions, _eve_coin) = launcher.spend(ctx, owner.puzzle_hash, ())?;
707
708        // The launcher's amount-1 `CREATE_COIN` is the singleton's second odd-amount output, so the
709        // bundle could never confirm. It is refused here rather than assembled and dropped at
710        // mempool admission, which would report a child DID that no block ever creates.
711        let result =
712            spend_did_with_conditions(ctx, owner.did, Owner::Standard(owner.pk), launch_conditions);
713        assert!(
714            matches!(result, Err(DidError::OddAmountCreateCoin)),
715            "an odd-amount CREATE_COIN must be refused at build time, got {result:?}"
716        );
717        Ok(())
718    }
719
720    /// `AGG_SIG_UNSAFE` is signed with no coin binding and no domain separation, so a DID owner
721    /// induced to sign one produces a replayable assertion over attacker-chosen bytes under their
722    /// identity key. The fixture supplies a message that is NOT derived from any coin in this spend,
723    /// which is precisely what makes the resulting signature reusable elsewhere.
724    #[test]
725    fn spend_did_with_conditions_refuses_an_agg_sig_unsafe_condition() -> anyhow::Result<()> {
726        let mut sim = Simulator::new();
727        let ctx = &mut SpendContext::new();
728        let owner = mint(&mut sim, ctx)?;
729
730        let result = spend_did_with_conditions(
731            ctx,
732            owner.did,
733            Owner::Standard(owner.pk),
734            Conditions::new()
735                .agg_sig_unsafe(owner.pk, Bytes::from(b"ATTACKER-CHOSEN-MESSAGE".to_vec())),
736        );
737        assert!(
738            matches!(result, Err(DidError::AggSigUnsafeInConditions)),
739            "an AGG_SIG_UNSAFE must be refused at build time, got {result:?}"
740        );
741        Ok(())
742    }
743
744    /// Wraps `value`'s raw CLVM as [`Condition::Other`] — the catch-all variant the SDK's
745    /// `conditions!` macro appends, which serializes to the wire VERBATIM.
746    ///
747    /// This is the attacker's move, reproduced exactly: build the bytes of a condition the guard
748    /// refuses, then hand them over under a name the guard does not recognise. Any check that
749    /// matches on the caller's own typing sees `Other` and waves it through, while the chain sees
750    /// the condition itself.
751    fn smuggled(
752        ctx: &mut SpendContext,
753        value: &impl ToClvm<Allocator>,
754    ) -> anyhow::Result<Condition> {
755        Ok(Condition::Other(ctx.alloc(value)?))
756    }
757
758    /// The executed bypass, pinned permanently: an `AGG_SIG_UNSAFE` handed over as
759    /// [`Condition::Other`] reached the wire under the old denylist and was reported for signing
760    /// with no coin binding and no domain separation — an arbitrary-message signing oracle under the
761    /// DID owner's identity key. The message here is the attacker's own sentence, not a value
762    /// derived from this spend, which is exactly what makes such a signature reusable elsewhere.
763    #[test]
764    fn refuses_an_agg_sig_unsafe_smuggled_as_an_unrecognized_condition() -> anyhow::Result<()> {
765        let mut sim = Simulator::new();
766        let ctx = &mut SpendContext::new();
767        let owner = mint(&mut sim, ctx)?;
768
769        let unsafe_sig = AggSig::new(
770            AggSigKind::Unsafe,
771            owner.pk,
772            Bytes::from(b"I, the DID owner, authorize the transfer of everything".to_vec()),
773        );
774        let disguised = smuggled(ctx, &unsafe_sig)?;
775
776        let result = spend_did_with_conditions(
777            ctx,
778            owner.did,
779            Owner::Standard(owner.pk),
780            Conditions::new().with(disguised),
781        );
782        assert!(
783            matches!(result, Err(DidError::AggSigUnsafeInConditions)),
784            "an AGG_SIG_UNSAFE must be refused however the caller types it, got {result:?}"
785        );
786        Ok(())
787    }
788
789    /// The same bypass against the odd-`CREATE_COIN` half: judging the caller's typing rather than
790    /// the wire lets the singleton's single odd-amount output be claimed twice.
791    #[test]
792    fn refuses_an_odd_amount_create_coin_smuggled_as_an_unrecognized_condition(
793    ) -> anyhow::Result<()> {
794        let mut sim = Simulator::new();
795        let ctx = &mut SpendContext::new();
796        let owner = mint(&mut sim, ctx)?;
797
798        let odd: CreateCoin<clvmr::NodePtr> = CreateCoin::new(owner.puzzle_hash, 1, Memos::None);
799        let disguised = smuggled(ctx, &odd)?;
800
801        let result = spend_did_with_conditions(
802            ctx,
803            owner.did,
804            Owner::Standard(owner.pk),
805            Conditions::new().with(disguised),
806        );
807        assert!(
808            matches!(result, Err(DidError::OddAmountCreateCoin)),
809            "an odd-amount CREATE_COIN must be refused however the caller types it, got {result:?}"
810        );
811        Ok(())
812    }
813
814    /// A condition the SDK genuinely cannot name survives re-parsing as [`Condition::Other`], and
815    /// the allowlist refuses it. This is the arm that makes the guard fail CLOSED: whatever the next
816    /// SDK release, or a bare-CLVM caller, invents, it does not ride into a DID spend unexamined.
817    #[test]
818    fn refuses_a_condition_the_sdk_cannot_name() -> anyhow::Result<()> {
819        let mut sim = Simulator::new();
820        let ctx = &mut SpendContext::new();
821        let owner = mint(&mut sim, ctx)?;
822
823        // Opcode 12345 is not a condition the SDK knows, so it re-parses as `Other`, unchanged.
824        let unknown = smuggled(ctx, &(12345_u32, (Bytes::from(b"payload".to_vec()), ())))?;
825
826        let result = spend_did_with_conditions(
827            ctx,
828            owner.did,
829            Owner::Standard(owner.pk),
830            Conditions::new().with(unknown),
831        );
832        assert!(
833            matches!(result, Err(DidError::DisallowedCondition(_))),
834            "a condition outside the allowlist must be refused, got {result:?}"
835        );
836        Ok(())
837    }
838
839    /// `AGG_SIG_PUZZLE` binds to the DID's puzzle hash, which a self-recreating DID keeps IDENTICAL
840    /// in every generation — so such a signature is replayable in any later spend of the same DID
841    /// that emits the same kind and message. `AGG_SIG_AMOUNT` (always 1) and `AGG_SIG_PUZZLE_AMOUNT`
842    /// are the same class. The allowlist removes the class rather than waiting for a p2 layer that
843    /// authorizes on one of them.
844    #[test]
845    fn refuses_agg_sig_kinds_bound_only_to_lifetime_constant_attributes() -> anyhow::Result<()> {
846        let mut sim = Simulator::new();
847        let ctx = &mut SpendContext::new();
848        let owner = mint(&mut sim, ctx)?;
849
850        for kind in [
851            AggSigKind::Puzzle,
852            AggSigKind::Amount,
853            AggSigKind::PuzzleAmount,
854        ] {
855            let mut replay_ctx = SpendContext::new();
856            let sig = AggSig::new(kind, owner.pk, Bytes::from(b"replayable".to_vec()));
857            let condition = smuggled(&mut replay_ctx, &sig)?;
858            let result = spend_did_with_conditions(
859                &mut replay_ctx,
860                owner.did,
861                Owner::Standard(owner.pk),
862                Conditions::new().with(condition),
863            );
864            assert!(
865                matches!(result, Err(DidError::DisallowedCondition(_))),
866                "{kind:?} binds only to attributes constant across the DID's lifetime and must be \
867                 refused, got {result:?}"
868            );
869        }
870        Ok(())
871    }
872
873    /// A `CREATE_COIN` whose amount is the raw atom `amount_atom`, handed over as
874    /// [`Condition::Other`] so the atom reaches the guard byte-for-byte.
875    ///
876    /// The typed `CreateCoin` cannot express these fixtures at all: its `amount` is a `u64`, which
877    /// is precisely the information loss under test. `Bytes` serializes to a CLVM atom verbatim, so
878    /// this is the only way to state "an amount encoded like THIS".
879    fn create_coin_with_raw_amount(
880        ctx: &mut SpendContext,
881        puzzle_hash: Bytes32,
882        amount_atom: &[u8],
883    ) -> anyhow::Result<Condition> {
884        smuggled(
885            ctx,
886            &(
887                51_u8,
888                (puzzle_hash, (Bytes::from(amount_atom.to_vec()), ())),
889            ),
890        )
891    }
892
893    /// Builds a DID spend carrying one caller `CREATE_COIN` with the given raw amount atom, and
894    /// returns the guard's verdict.
895    fn spend_with_raw_amount(amount_atom: &[u8]) -> anyhow::Result<DidResult<Did>> {
896        let mut sim = Simulator::new();
897        let ctx = &mut SpendContext::new();
898        let owner = mint(&mut sim, ctx)?;
899        let condition = create_coin_with_raw_amount(ctx, owner.puzzle_hash, amount_atom)?;
900        Ok(spend_did_with_conditions(
901            ctx,
902            owner.did,
903            Owner::Standard(owner.pk),
904            Conditions::new().with(condition),
905        ))
906    }
907
908    /// `0x80` is −128 in CLVM's SIGNED integers, and the chain answers `CoinAmountNegative`. The
909    /// typed `CreateCoin::amount` is a `u64` decoded UNSIGNED, so the guard read it as 128 — an
910    /// ordinary even amount — and waved it through to an unexplained mempool drop.
911    #[test]
912    fn refuses_a_create_coin_amount_whose_leading_byte_sets_the_sign_bit() -> anyhow::Result<()> {
913        let result = spend_with_raw_amount(&[0x80])?;
914        assert!(
915            matches!(result, Err(DidError::NonCanonicalCreateCoinAmount(_))),
916            "a negative amount must be refused at build time, got {result:?}"
917        );
918        Ok(())
919    }
920
921    /// `0x000002` decodes UNSIGNED to 2, but chia refuses a leading zero the value does not need
922    /// (`InvalidCoinAmount`) — a leading zero is canonical only where it stops the next byte reading
923    /// as a sign bit.
924    #[test]
925    fn refuses_a_create_coin_amount_with_a_redundant_leading_zero() -> anyhow::Result<()> {
926        let result = spend_with_raw_amount(&[0x00, 0x00, 0x02])?;
927        assert!(
928            matches!(result, Err(DidError::NonCanonicalCreateCoinAmount(_))),
929            "a non-canonically-encoded amount must be refused at build time, got {result:?}"
930        );
931        Ok(())
932    }
933
934    /// Zero is the EMPTY atom in CLVM; the single byte `0x00` is a redundant encoding of it and
935    /// chia refuses it by name (`sanitize_uint` rejects `[0]` explicitly).
936    #[test]
937    fn refuses_a_create_coin_amount_encoded_as_a_single_zero_byte() -> anyhow::Result<()> {
938        let result = spend_with_raw_amount(&[0x00])?;
939        assert!(
940            matches!(result, Err(DidError::NonCanonicalCreateCoinAmount(_))),
941            "a redundant zero encoding must be refused at build time, got {result:?}"
942        );
943        Ok(())
944    }
945
946    /// The positive control the three refusals above cannot supply: a guard that refused EVERY
947    /// raw-atom amount would satisfy all of them while breaking ordinary payments. The atom `0x02`
948    /// is the canonical encoding of 2 — an even, chain-valid amount — travelling the SAME
949    /// `Condition::Other` path as the refused fixtures, so the only difference under test is the
950    /// encoding itself.
951    #[test]
952    fn permits_a_canonically_encoded_even_create_coin_amount() -> anyhow::Result<()> {
953        let mut sim = Simulator::new();
954        let ctx = &mut SpendContext::new();
955        let owner = mint(&mut sim, ctx)?;
956
957        // The DID holds 1 mojo and must recreate itself with all of it, so a second coin funds the
958        // caller's output (Chia balances additions against removals across the whole bundle).
959        let funder = sim.bls(2);
960        let funder_spend = inner_spend(ctx, Owner::Standard(funder.pk), Conditions::new())?;
961        ctx.spend(funder.coin, funder_spend)?;
962
963        let condition = create_coin_with_raw_amount(ctx, owner.puzzle_hash, &[0x02])?;
964        let _child = spend_did_with_conditions(
965            ctx,
966            owner.did,
967            Owner::Standard(owner.pk),
968            Conditions::new().with(condition),
969        )?;
970
971        let coin_spends = ctx.take();
972        assert!(
973            creates_coin_to(ctx, &coin_spends, owner.puzzle_hash)?,
974            "the caller's canonically-encoded payment must actually be created"
975        );
976        sim.spend_coins(coin_spends, &[owner.sk, funder.sk])?;
977        Ok(())
978    }
979
980    /// Builds a three-condition list whose `CREATE_COIN` sits at INDEX 2, behind two permitted
981    /// conditions, with the given raw amount atom.
982    ///
983    /// The guard walks the same allocated node twice — once typed, once raw — and zips the two
984    /// walks, so a `CREATE_COIN` at index 0 cannot distinguish an aligned zip from an offset one.
985    /// Every other raw-amount fixture puts it first. This one does not.
986    fn three_conditions_with_create_coin_last(
987        ctx: &mut SpendContext,
988        puzzle_hash: Bytes32,
989        amount_atom: &[u8],
990    ) -> anyhow::Result<Conditions> {
991        let create_coin = create_coin_with_raw_amount(ctx, puzzle_hash, amount_atom)?;
992        Ok(Conditions::new()
993            .remark(NodePtr::NIL)
994            .create_coin_announcement(Bytes::from(b"ahead-of-the-create-coin".to_vec()))
995            .with(create_coin))
996    }
997
998    /// The guard must inspect EVERY element, not just the first. Confirmed load-bearing: adding a
999    /// `.take(1)` to the zip turns this test red while all three index-0 refusals stay green.
1000    ///
1001    /// It does NOT, on its own, detect a pure MISALIGNMENT of the two walks. A `CREATE_COIN` paired
1002    /// with a neighbour's node finds no amount element there and is refused anyway — the guard fails
1003    /// closed in the refusal direction, so the refusal is preserved for the wrong reason. Alignment
1004    /// is pinned by the positive control below, which is the direction a misalignment can be seen
1005    /// in: rotating the raw walk by one turns THAT test red, and this one stays green.
1006    #[test]
1007    fn refuses_a_non_canonical_create_coin_amount_behind_two_permitted_conditions(
1008    ) -> anyhow::Result<()> {
1009        let mut sim = Simulator::new();
1010        let ctx = &mut SpendContext::new();
1011        let owner = mint(&mut sim, ctx)?;
1012
1013        let conditions = three_conditions_with_create_coin_last(ctx, owner.puzzle_hash, &[0x80])?;
1014        let result =
1015            spend_did_with_conditions(ctx, owner.did, Owner::Standard(owner.pk), conditions);
1016        assert!(
1017            matches!(result, Err(DidError::NonCanonicalCreateCoinAmount(_))),
1018            "a negative amount must be refused wherever it sits in the list, got {result:?}"
1019        );
1020        Ok(())
1021    }
1022
1023    /// The positive control for the test above — and the actual regression guard for the ZIP's
1024    /// alignment. A guard that refused this whole SHAPE (any list longer than one, say) would
1025    /// satisfy the refusal test while breaking every real multi-condition spend; the only difference
1026    /// here is the encoding of the amount at index 2.
1027    ///
1028    /// Confirmed load-bearing: rotating the raw walk by one element makes the amount check read a
1029    /// neighbour's node, which carries no amount element, and this legitimate spend is refused.
1030    #[test]
1031    fn permits_a_canonical_create_coin_amount_behind_two_permitted_conditions() -> anyhow::Result<()>
1032    {
1033        let mut sim = Simulator::new();
1034        let ctx = &mut SpendContext::new();
1035        let owner = mint(&mut sim, ctx)?;
1036
1037        // The DID holds 1 mojo and must recreate itself with all of it, so a second coin funds the
1038        // caller's output (Chia balances additions against removals across the whole bundle).
1039        let funder = sim.bls(2);
1040        let funder_spend = inner_spend(ctx, Owner::Standard(funder.pk), Conditions::new())?;
1041        ctx.spend(funder.coin, funder_spend)?;
1042
1043        let conditions = three_conditions_with_create_coin_last(ctx, owner.puzzle_hash, &[0x02])?;
1044        let _child =
1045            spend_did_with_conditions(ctx, owner.did, Owner::Standard(owner.pk), conditions)?;
1046
1047        let coin_spends = ctx.take();
1048        assert!(
1049            creates_coin_to(ctx, &coin_spends, owner.puzzle_hash)?,
1050            "the caller's canonically-encoded payment must actually be created"
1051        );
1052        sim.spend_coins(coin_spends, &[owner.sk, funder.sk])?;
1053        Ok(())
1054    }
1055
1056    /// `MELT_SINGLETON` — the `CREATE_COIN` form `(51 () -113)` — burns the DID permanently. It was
1057    /// documented as "refused by omission", which is not a property anything went red over: a
1058    /// future allowlist edit could admit it with every test still green, and the failure mode is a
1059    /// destroyed identity. Pinned here to its own resolved variant so the omission is deliberate
1060    /// and observable.
1061    #[test]
1062    fn refuses_a_melt_singleton_condition() -> anyhow::Result<()> {
1063        let mut sim = Simulator::new();
1064        let ctx = &mut SpendContext::new();
1065        let owner = mint(&mut sim, ctx)?;
1066
1067        // `(51 () -113)`: an empty puzzle-hash atom and the melt magic amount, byte for byte.
1068        let melt = smuggled(
1069            ctx,
1070            &(51_u8, (Bytes::default(), (Bytes::from(vec![0x8F_u8]), ()))),
1071        )?;
1072
1073        // The fixture must genuinely resolve to the melt condition, not to some near-miss the
1074        // allowlist would have refused for an unrelated reason.
1075        let reparsed: Vec<Condition> = {
1076            let allocated = ctx.alloc(&Conditions::new().with(melt.clone()))?;
1077            ctx.extract(allocated)?
1078        };
1079        assert!(
1080            matches!(reparsed.as_slice(), [Condition::MeltSingleton(_)]),
1081            "the fixture must resolve to MELT_SINGLETON, got {reparsed:?}"
1082        );
1083
1084        let result = spend_did_with_conditions(
1085            ctx,
1086            owner.did,
1087            Owner::Standard(owner.pk),
1088            Conditions::new().with(melt),
1089        );
1090        assert!(
1091            matches!(result, Err(DidError::DisallowedCondition(_))),
1092            "MELT_SINGLETON would burn the DID and must be refused, got {result:?}"
1093        );
1094        Ok(())
1095    }
1096
1097    /// The refusal message must identify the condition. For [`Condition::Other`] — the smuggling
1098    /// case, and the whole reason the allowlist exists — the variant holds only a `NodePtr`, whose
1099    /// `Debug` is an allocator index meaningless outside this process. The CLVM opcode is the
1100    /// identifying fact a caller can act on.
1101    #[test]
1102    fn names_the_clvm_opcode_of_a_condition_the_sdk_cannot_name() -> anyhow::Result<()> {
1103        let mut sim = Simulator::new();
1104        let ctx = &mut SpendContext::new();
1105        let owner = mint(&mut sim, ctx)?;
1106
1107        let unknown = smuggled(ctx, &(12345_u32, (Bytes::from(b"payload".to_vec()), ())))?;
1108        let result = spend_did_with_conditions(
1109            ctx,
1110            owner.did,
1111            Owner::Standard(owner.pk),
1112            Conditions::new().with(unknown),
1113        );
1114
1115        let Err(DidError::DisallowedCondition(rendered)) = result else {
1116            panic!("an unnameable condition must be refused, got {result:?}");
1117        };
1118        assert!(
1119            rendered.contains("12345"),
1120            "the refusal must name the CLVM opcode, got {rendered:?}"
1121        );
1122        assert!(
1123            !rendered.contains("NodePtr"),
1124            "an allocator index tells the caller nothing, got {rendered:?}"
1125        );
1126        Ok(())
1127    }
1128
1129    /// The control the rejection tests cannot supply on their own: an allowlist that refused
1130    /// EVERYTHING would satisfy every test above while breaking the function entirely. A realistic
1131    /// mix — an announcement, a timelock, a self-assertion, and a coin-bound signature requirement —
1132    /// must still build, reach the chain, and add its signature requirement to the DID's own.
1133    #[test]
1134    fn permits_the_conditions_a_did_spend_legitimately_carries() -> anyhow::Result<()> {
1135        let mut sim = Simulator::new();
1136        let ctx = &mut SpendContext::new();
1137        let owner = mint(&mut sim, ctx)?;
1138
1139        let _child = spend_did_with_conditions(
1140            ctx,
1141            owner.did,
1142            Owner::Standard(owner.pk),
1143            Conditions::new()
1144                .create_puzzle_announcement(Bytes::from(b"bind-me".to_vec()))
1145                .assert_my_amount(owner.did.coin.amount)
1146                .assert_height_relative(0)
1147                .agg_sig_me(owner.pk, Bytes::from(b"coin-bound".to_vec())),
1148        )?;
1149
1150        let coin_spends = ctx.take();
1151        let constants = AggSigConstants::from(&*MAINNET_CONSTANTS);
1152        let required = crate::sign::required_signatures(&coin_spends, &constants)
1153            .expect("signature calculation must succeed for a well-formed DID spend");
1154        assert_eq!(
1155            required.len(),
1156            2,
1157            "the DID's own AGG_SIG_ME plus the caller's coin-bound one"
1158        );
1159
1160        sim.spend_coins(coin_spends, &[owner.sk])?;
1161        Ok(())
1162    }
1163}