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 over the strike is returned as change to this coin's OWN `puzzle_hash`, so an
27/// oversized funding coin loses nothing; the builder itself takes no fee.
28#[derive(Clone, Copy, Debug)]
29pub struct StrikePayment {
30 /// The XCH coin the holder spends to fund the strike payment.
31 pub funding_coin: Coin,
32}
33
34/// Build the unsigned coin spends that EXERCISE `created` by its `holder`, paying `strike`.
35///
36/// Spends the option singleton through its exercise path, unlocks the locked XCH underlying to
37/// the holder, and pays the XCH strike into the settlement puzzle — settled to the creator's
38/// requested payment — all in one bundle. Rejects a `strike.funding_coin` smaller than the
39/// strike amount.
40///
41/// **v0.1.0 scope:** only an XCH strike can be exercised. A CAT/NFT strike returns
42/// [`Error::InvalidInput`] rather than emitting an incorrect spend — building the CAT/NFT
43/// settlement leg is a documented follow-up.
44///
45/// **Pure: does NOT sign or broadcast.** Returns [`OptionSpend`] with `created: None`.
46///
47/// **CRITICAL: The returned `coin_spends` include the settlement leg that claims the unlocked
48/// underlying to the holder.** That leg is REQUIRED but only builder-enforced: consensus forces
49/// the strike payment to the creator, but does NOT force the underlying claim. Callers MUST
50/// broadcast the full returned bundle intact — dropping or reordering the underlying-claim spend
51/// strands the underlying at a publicly-claimable settlement coin that anyone can take. Never
52/// submit a subset of these spends.
53pub fn exercise(
54 ctx: &mut SpendContext,
55 holder: &Owner,
56 created: &CreatedOption,
57 strike: &StrikePayment,
58) -> Result<OptionSpend> {
59 let OptionType::Xch {
60 amount: strike_amount,
61 } = created.underlying.strike_type
62 else {
63 return Err(Error::invalid(
64 "CAT/NFT strike exercise not yet supported — see dig-options CAT/NFT follow-up",
65 ));
66 };
67
68 if strike.funding_coin.amount < strike_amount {
69 return Err(Error::invalid(format!(
70 "strike funding coin amount {} is too small: need {strike_amount} for the XCH strike",
71 strike.funding_coin.amount
72 )));
73 }
74
75 // Spend the option singleton through its exercise path (the holder authorizes it).
76 // `OptionContract` is `Copy`, so this copies rather than moves `created.option`.
77 created.option.exercise(ctx, holder, Conditions::new())?;
78
79 // Unlock the locked underlying. The underlying's exercise-path delegated puzzle emits
80 // `CreateCoin(SETTLEMENT_PAYMENT_HASH, underlying_amount)` — the unlocked XCH lands on a BARE
81 // settlement/offer coin (spendable by anyone with a `SettlementPaymentsSolution`, no key). The
82 // `inner_puzzle_hash` argument here is ONLY the singleton co-spend proof (a `SingletonMember`
83 // check), NOT a payout destination. We MUST claim that settlement coin to the holder in this
84 // same bundle — otherwise the underlying is stranded and any mempool watcher steals it while
85 // the holder has paid the strike and received nothing.
86 created.underlying.exercise_coin_spend(
87 ctx,
88 created.underlying_coin,
89 created.option.info.inner_puzzle_hash().into(),
90 created.option.coin.amount,
91 )?;
92
93 // Claim the unlocked-underlying settlement coin to the holder (the option's current p2 owner),
94 // paying the full underlying amount, in the same bundle. Mirrors the strike leg below.
95 let underlying_settlement_coin = Coin::new(
96 created.underlying_coin.coin_id(),
97 SETTLEMENT_PAYMENT_HASH.into(),
98 created.underlying.amount,
99 );
100 let holder_payment = NotarizedPayment::new(
101 created.option.info.launcher_id,
102 vec![Payment::new(
103 created.option.info.p2_puzzle_hash,
104 created.underlying.amount,
105 Memos::None,
106 )],
107 );
108 let underlying_claim = SettlementLayer.construct_coin_spend(
109 ctx,
110 underlying_settlement_coin,
111 SettlementPaymentsSolution::new(vec![holder_payment]),
112 )?;
113 ctx.insert(underlying_claim);
114
115 // Pay the XCH strike into the settlement puzzle, then settle it to the creator's
116 // requested payment. The holder authorizes the strike-funding spend.
117 //
118 // Any excess over the strike is returned as CHANGE to the funding coin's OWN puzzle hash.
119 // Without this the whole funding coin is consumed and the difference is burned as an
120 // implicit network fee — an unbounded, silent loss, because a caller selecting the smallest
121 // spendable coin at the owner's address routinely finds one far larger than the strike.
122 // The change destination is the funding coin's existing puzzle hash rather than a new
123 // caller-supplied field: the same layer already controls it, so change cannot be redirected
124 // anywhere the funder did not already own. This builder takes NO fee; a caller that wants
125 // one attaches its own fee spend.
126 let mut strike_conditions =
127 Conditions::new().create_coin(SETTLEMENT_PAYMENT_HASH.into(), strike_amount, Memos::None);
128
129 // Only when there IS excess — an exact-size funding coin must not emit a zero-amount coin.
130 if let Some(change_amount) = strike
131 .funding_coin
132 .amount
133 .checked_sub(strike_amount)
134 .filter(|excess| *excess > 0)
135 {
136 strike_conditions = strike_conditions.create_coin(
137 strike.funding_coin.puzzle_hash,
138 change_amount,
139 Memos::None,
140 );
141 }
142
143 let strike_inner = holder.spend_with_conditions(ctx, strike_conditions)?;
144 ctx.spend(strike.funding_coin, strike_inner)?;
145
146 let settlement_coin = Coin::new(
147 strike.funding_coin.coin_id(),
148 SETTLEMENT_PAYMENT_HASH.into(),
149 strike_amount,
150 );
151 let payment = created.underlying.requested_payment(&mut **ctx)?;
152 let coin_spend = SettlementLayer.construct_coin_spend(
153 ctx,
154 settlement_coin,
155 SettlementPaymentsSolution::new(vec![payment]),
156 )?;
157 ctx.insert(coin_spend);
158
159 Ok(OptionSpend {
160 coin_spends: ctx.take(),
161 created: None,
162 })
163}