dig_options/rehydrate.rs
1//! Reconstruct a full, operable [`CreatedOption`] from on-chain state.
2//!
3//! [`crate::parse`]/[`crate::parse_child`] recover only an option's *identity* fields — the
4//! option singleton's puzzle does not commit to its terms (creator puzzle hash, expiry, underlying
5//! amount, strike type), so those cannot be inverted from the singleton coin spend alone. But
6//! [`crate::exercise`], [`crate::clawback`], and [`crate::transfer`] all need the full
7//! [`OptionUnderlying`] terms. Without a way to recover them, a caller could only ever operate an
8//! option it minted itself in the same session.
9//!
10//! [`rehydrate`] closes that gap. The caller supplies the terms it can observe off-chain — the
11//! creator puzzle hash it recorded, plus the expiry + strike recovered from the launcher metadata
12//! via [`parse_metadata`] — together with the parsed option and its fetched underlying coin.
13//! `rehydrate` reconstructs the [`OptionUnderlying`] and **verifies it against the option's
14//! on-chain commitments**: the 1-of-2 underlying path, the underlying delegated-puzzle hash, and
15//! the underlying coin id all must match. A single wrong term changes one of those hashes and is
16//! rejected — so a successfully rehydrated [`CreatedOption`] is guaranteed to bind to the real
17//! on-chain option and produce spends the consensus will accept.
18
19use chia_protocol::{Coin, Program};
20
21use chia_wallet_sdk::driver::{OptionContract, OptionType, OptionUnderlying, SpendContext};
22use chia_wallet_sdk::prelude::ToTreeHash;
23
24use crate::error::{Error, Result};
25use crate::types::CreatedOption;
26
27// Re-exported so a caller need not depend on the SDK directly to name the recovered metadata.
28pub use chia_wallet_sdk::driver::OptionMetadata;
29
30/// The terms a caller supplies to [`rehydrate`] a previously-minted option.
31///
32/// Every field is verified against the option's on-chain commitments, so these are asserted, not
33/// trusted: a wrong value is rejected rather than producing an option handle that builds an
34/// unspendable bundle. `expiry_seconds` and `strike_type` are recoverable from the launcher
35/// metadata ([`parse_metadata`]); `creator_puzzle_hash` is the party the caller recorded at mint
36/// (it is committed only inside the underlying's clawback path, so it is supplied and verified
37/// rather than inverted).
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub struct RehydratedTerms {
40 /// The puzzle hash the creator reclaims the underlying to on clawback.
41 pub creator_puzzle_hash: chia_protocol::Bytes32,
42 /// The absolute unix timestamp (seconds) at which the option expires.
43 pub expiry_seconds: u64,
44 /// The asset + amount the holder must pay to exercise the option.
45 pub strike_type: OptionType,
46}
47
48/// Recover an option's [`OptionMetadata`] (expiry seconds + strike type) from the launcher coin's
49/// solution, which the caller fetched from a node/indexer.
50///
51/// The launcher solution carries the option's key-value metadata; this decodes it into the
52/// caller-owned [`SpendContext`]. Network-free: the caller supplies the serialized solution.
53pub fn parse_metadata(
54 ctx: &mut SpendContext,
55 launcher_solution: &Program,
56) -> Result<OptionMetadata> {
57 let solution = ctx.alloc(launcher_solution)?;
58 Ok(OptionContract::parse_metadata(ctx, solution)?)
59}
60
61/// Reconstruct a full [`CreatedOption`] from a parsed `option`, caller-supplied `terms`, and the
62/// fetched `underlying_coin`, verifying every reconstructed field against the option's on-chain
63/// commitments.
64///
65/// Rebuilds the [`OptionUnderlying`] from `option.info.launcher_id`, `terms`, and
66/// `underlying_coin.amount`, then rejects the reconstruction unless ALL THREE of the following
67/// match the on-chain option. The three are **jointly** load-bearing — no single check covers
68/// every field, so none is mere defense-in-depth (verified against the chia-wallet-sdk 0.30
69/// `OptionUnderlying` derivation):
70/// - **1-of-2 path hash** equals `underlying_coin.puzzle_hash`. Per the SDK, the path is
71/// `merkle([exercise_path(launcher_id), clawback_path(expiry, creator_ph)])`, so it binds ONLY
72/// the launcher id, expiry, and creator puzzle hash — NOT the amount or strike type. A wrong
73/// creator hash or expiry is caught here.
74/// - **delegated-puzzle hash** equals `option.info.underlying_delegated_puzzle_hash`. The
75/// delegated puzzle commits to the expiry, the underlying amount, and the strike type (settlement
76/// target + requested-payment amount). A wrong **strike type** is caught ONLY here; a wrong
77/// amount is caught both here and by the coin-id check below.
78/// - **underlying coin id** equals `option.info.underlying_coin_id`. This binds the coin's full
79/// identity (parent + puzzle hash + amount), uniquely rejecting a substituted coin of the right
80/// shape but wrong parent that would slip past the two hash checks.
81///
82/// On success the returned [`CreatedOption`] is operable by [`crate::exercise`],
83/// [`crate::clawback`], and [`crate::transfer`] exactly as one returned by [`crate::create`].
84///
85/// **Pure: performs no I/O and holds no key.** The caller fetches the option spend + underlying
86/// coin and recovers the metadata; `rehydrate` only reconstructs + verifies.
87pub fn rehydrate(
88 option: &OptionContract,
89 terms: &RehydratedTerms,
90 underlying_coin: Coin,
91) -> Result<CreatedOption> {
92 let underlying = OptionUnderlying::new(
93 option.info.launcher_id,
94 terms.creator_puzzle_hash,
95 terms.expiry_seconds,
96 underlying_coin.amount,
97 terms.strike_type,
98 );
99
100 // Check 1: the 1-of-2 path hash. Per the SDK it is
101 // merkle([exercise_path(launcher_id), clawback_path(expiry, creator_ph)]), so it binds ONLY the
102 // launcher id, expiry, and creator puzzle hash — NOT the amount or strike type. This is the sole
103 // check that catches a wrong creator puzzle hash.
104 let reconstructed_path: chia_protocol::Bytes32 = underlying.tree_hash().into();
105 if reconstructed_path != underlying_coin.puzzle_hash {
106 return Err(Error::invalid(
107 "rehydrated terms do not match the underlying coin's 1-of-2 path — check creator puzzle hash and expiry",
108 ));
109 }
110
111 // Check 2: the delegated-puzzle hash, which the option singleton independently commits to. It
112 // binds the expiry, the underlying amount, and the STRIKE TYPE (settlement target +
113 // requested-payment amount). This is the ONLY check that catches a wrong strike type — it is
114 // load-bearing, not defense in depth.
115 let reconstructed_delegated: chia_protocol::Bytes32 =
116 underlying.delegated_puzzle().tree_hash().into();
117 if reconstructed_delegated != option.info.underlying_delegated_puzzle_hash {
118 return Err(Error::invalid(
119 "rehydrated terms do not match the option's underlying delegated-puzzle hash — check the strike type, amount, and expiry",
120 ));
121 }
122
123 // Check 3: the underlying coin id. This binds the coin's full identity (parent + puzzle hash +
124 // amount), uniquely rejecting a substituted coin of the right shape but wrong parent that the
125 // two hash checks above could not distinguish.
126 if underlying_coin.coin_id() != option.info.underlying_coin_id {
127 return Err(Error::invalid(
128 "underlying coin id does not match the option's committed underlying coin id",
129 ));
130 }
131
132 Ok(CreatedOption {
133 option: *option,
134 underlying,
135 underlying_coin,
136 })
137}