dig_options/exercise.rs
1//! Exercise an option — pay the strike and unlock the underlying to the holder.
2//!
3//! [`exercise`] builds the complete exercise in one bundle: it spends the option singleton
4//! through its exercise path, unlocks the locked XCH underlying to the holder, and — for an
5//! XCH strike — pays the strike into the settlement puzzle and settles it to the creator's
6//! requested payment. The holder authorizes both the singleton spend and the strike-funding
7//! spend through the caller's [`Owner`] layer. The exercise is valid only strictly before
8//! `expiry_seconds` (enforced by the consensus).
9
10use chia_protocol::Coin;
11use chia_puzzle_types::offer::{NotarizedPayment, Payment, SettlementPaymentsSolution};
12use chia_puzzle_types::Memos;
13use chia_puzzles::SETTLEMENT_PAYMENT_HASH;
14use chia_wallet_sdk::driver::{
15 Layer, OptionType, SettlementLayer, SingletonInfo, SpendContext, SpendWithConditions,
16};
17use chia_wallet_sdk::types::Conditions;
18
19use crate::error::{Error, Result};
20use crate::types::{CreatedOption, OptionSpend, Owner};
21
22/// The strike payment funding a [`exercise`]: the caller-supplied XCH coin the holder spends
23/// to pay the strike into the settlement puzzle.
24///
25/// It must hold at least the strike amount (`created.underlying.strike_type.amount()`); any
26/// excess is left as an implicit fee.
27#[derive(Clone, Copy, Debug)]
28pub struct StrikePayment {
29 /// The XCH coin the holder spends to fund the strike payment.
30 pub funding_coin: Coin,
31}
32
33/// Build the unsigned coin spends that EXERCISE `created` by its `holder`, paying `strike`.
34///
35/// Spends the option singleton through its exercise path, unlocks the locked XCH underlying to
36/// the holder, and pays the XCH strike into the settlement puzzle — settled to the creator's
37/// requested payment — all in one bundle. Rejects a `strike.funding_coin` smaller than the
38/// strike amount.
39///
40/// **v0.1.0 scope:** only an XCH strike can be exercised. A CAT/NFT strike returns
41/// [`Error::InvalidInput`] rather than emitting an incorrect spend — building the CAT/NFT
42/// settlement leg is a documented follow-up.
43///
44/// **Pure: does NOT sign or broadcast.** Returns [`OptionSpend`] with `created: None`.
45///
46/// **CRITICAL: The returned `coin_spends` include the settlement leg that claims the unlocked
47/// underlying to the holder.** That leg is REQUIRED but only builder-enforced: consensus forces
48/// the strike payment to the creator, but does NOT force the underlying claim. Callers MUST
49/// broadcast the full returned bundle intact — dropping or reordering the underlying-claim spend
50/// strands the underlying at a publicly-claimable settlement coin that anyone can take. Never
51/// submit a subset of these spends.
52pub fn exercise(
53 ctx: &mut SpendContext,
54 holder: &Owner,
55 created: &CreatedOption,
56 strike: &StrikePayment,
57) -> Result<OptionSpend> {
58 let OptionType::Xch {
59 amount: strike_amount,
60 } = created.underlying.strike_type
61 else {
62 return Err(Error::invalid(
63 "CAT/NFT strike exercise not yet supported — see dig-options CAT/NFT follow-up",
64 ));
65 };
66
67 if strike.funding_coin.amount < strike_amount {
68 return Err(Error::invalid(format!(
69 "strike funding coin amount {} is too small: need {strike_amount} for the XCH strike",
70 strike.funding_coin.amount
71 )));
72 }
73
74 // Spend the option singleton through its exercise path (the holder authorizes it).
75 // `OptionContract` is `Copy`, so this copies rather than moves `created.option`.
76 created.option.exercise(ctx, holder, Conditions::new())?;
77
78 // Unlock the locked underlying. The underlying's exercise-path delegated puzzle emits
79 // `CreateCoin(SETTLEMENT_PAYMENT_HASH, underlying_amount)` — the unlocked XCH lands on a BARE
80 // settlement/offer coin (spendable by anyone with a `SettlementPaymentsSolution`, no key). The
81 // `inner_puzzle_hash` argument here is ONLY the singleton co-spend proof (a `SingletonMember`
82 // check), NOT a payout destination. We MUST claim that settlement coin to the holder in this
83 // same bundle — otherwise the underlying is stranded and any mempool watcher steals it while
84 // the holder has paid the strike and received nothing.
85 created.underlying.exercise_coin_spend(
86 ctx,
87 created.underlying_coin,
88 created.option.info.inner_puzzle_hash().into(),
89 created.option.coin.amount,
90 )?;
91
92 // Claim the unlocked-underlying settlement coin to the holder (the option's current p2 owner),
93 // paying the full underlying amount, in the same bundle. Mirrors the strike leg below.
94 let underlying_settlement_coin = Coin::new(
95 created.underlying_coin.coin_id(),
96 SETTLEMENT_PAYMENT_HASH.into(),
97 created.underlying.amount,
98 );
99 let holder_payment = NotarizedPayment::new(
100 created.option.info.launcher_id,
101 vec![Payment::new(
102 created.option.info.p2_puzzle_hash,
103 created.underlying.amount,
104 Memos::None,
105 )],
106 );
107 let underlying_claim = SettlementLayer.construct_coin_spend(
108 ctx,
109 underlying_settlement_coin,
110 SettlementPaymentsSolution::new(vec![holder_payment]),
111 )?;
112 ctx.insert(underlying_claim);
113
114 // Pay the XCH strike into the settlement puzzle, then settle it to the creator's
115 // requested payment. The holder authorizes the strike-funding spend.
116 let strike_inner = holder.spend_with_conditions(
117 ctx,
118 Conditions::new().create_coin(SETTLEMENT_PAYMENT_HASH.into(), strike_amount, Memos::None),
119 )?;
120 ctx.spend(strike.funding_coin, strike_inner)?;
121
122 let settlement_coin = Coin::new(
123 strike.funding_coin.coin_id(),
124 SETTLEMENT_PAYMENT_HASH.into(),
125 strike_amount,
126 );
127 let payment = created.underlying.requested_payment(&mut **ctx)?;
128 let coin_spend = SettlementLayer.construct_coin_spend(
129 ctx,
130 settlement_coin,
131 SettlementPaymentsSolution::new(vec![payment]),
132 )?;
133 ctx.insert(coin_spend);
134
135 Ok(OptionSpend {
136 coin_spends: ctx.take(),
137 created: None,
138 })
139}