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}