Skip to main content

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}