Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ All notable changes to this project are documented here.
This project adheres to [Semantic Versioning](https://semver.org) and
[Conventional Commits](https://www.conventionalcommits.org).

## [0.2.0] - 2026-07-20

### Features
- **options:** curated `transfer` builder — move the option ticket to a new owner, composing
`OptionContract::transfer` so consumers no longer reach past dig-options into the SDK primitive (#1288).
- **options:** `rehydrate` + `parse_metadata` — reconstruct a full, operable `CreatedOption` from on-chain
state (verified against the option's commitments), lifting the recoverable-fields limitation so a caller
can exercise/transfer/claw back an option it did not mint in the same session (#1288).

## [0.1.0] - 2026-07-19

### Features
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# as v0.1.0 in a single triple-gated feature PR against this foundation.
[package]
name = "dig-options"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
rust-version = "1.75.0"
license = "Apache-2.0 OR MIT"
Expand Down
78 changes: 77 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ before expiry) and **clawback** (strictly after expiry, the creator reclaims the
exercise use the same `OptionUnderlying::exercise_spend` / `clawback_spend` primitives wrapped for the
asset; they are additive and land in a later minor version.

**v0.2.0 additions (additive):**

- **`transfer`** — move the option ticket to a new owner (§5.6). Additive; the mint/exercise/clawback
envelope is unchanged.
- **Rehydration (`rehydrate` + `parse_metadata`)** — reconstruct a full, operable `CreatedOption` from
on-chain state, verified against the option's commitments (§5.7). This lifts the §5.4 recoverable-fields
limitation for a caller willing to supply the creator puzzle hash: `parse` still only inverts identity
fields, but `rehydrate` reconstructs + VERIFIES the full terms, so a caller can operate an option it did
not mint in the same session.

## 2. Custody invariants (HARD)

These are the crate's defining properties and MUST hold for every operation:
Expand Down Expand Up @@ -88,6 +98,18 @@ The confirmed-option handle the caller retains to operate the option later.
The identity fields recoverable from an option coin spend (§5.4): `option`, `launcher_id`, `coin_id`,
`underlying_coin_id`, `underlying_delegated_puzzle_hash`, `p2_puzzle_hash`.

### `RehydratedTerms` (v0.2.0)
The terms a caller supplies to `rehydrate` (§5.7); each is VERIFIED against the option's on-chain
commitments, never trusted blindly.
- `creator_puzzle_hash: Bytes32` — the clawback destination the caller recorded at mint (committed only
inside the underlying's clawback path, so it is supplied + verified rather than inverted).
- `expiry_seconds: u64` — recoverable from the launcher metadata via `parse_metadata`.
- `strike_type: OptionType` — recoverable from the launcher metadata via `parse_metadata`.

### `OptionMetadata` (v0.2.0, re-exported)
The launcher key-value metadata `parse_metadata` recovers: `expiration_seconds: u64`,
`strike_type: OptionType`.

## 5. Operations

### 5.1 `create(ctx, creator, funding_coin, terms) -> OptionSpend`
Expand Down Expand Up @@ -144,6 +166,47 @@ needs the terms retains the `CreatedOption` / `OptionTerms` from `create`.
Runs each spend's puzzle to collect its `AGG_SIG_*` conditions and reports the BLS messages the caller
must sign, given the network's `agg_sig_me` additional data. Performs NO signing.

### 5.6 `transfer(ctx, owner, created, new_owner_puzzle_hash) -> OptionSpend` (v0.2.0)
Moves the option singleton to a new owner. Spends the singleton through the current `owner`'s p2 layer
and recreates it (same launcher id, same underlying, same amount, hinted for wallet discovery) at
`new_owner_puzzle_hash`. Only the ticket moves — the locked underlying coin and the option's terms are
unchanged.
- **Enforced invariants:** a `Standard` `owner` whose `standard_puzzle_hash()` ≠ the option's current
`p2_puzzle_hash` is rejected up front (§6); a `Custom` owner cannot be checked here and relies on the
consensus to reject a wrong-party spend.
- **Returns** `created: Some(..)` — the option in its NEW-owner state, so the caller can chain further
transfers or an exercise/clawback against the transferred singleton once confirmed. (A transferred
option remains fully operable: the new owner exercises it and the ORIGINAL creator still receives the
strike — test `transfer_moves_option_then_new_owner_exercises`.)

### 5.7 `rehydrate(option, terms, underlying_coin) -> CreatedOption` and `parse_metadata(ctx, launcher_solution) -> OptionMetadata` (v0.2.0)
Reconstruct a full, operable `CreatedOption` from on-chain state, lifting the §5.4 recoverable-fields
limitation for a caller that supplies (and lets the crate verify) the creator puzzle hash.
- **`parse_metadata`** decodes the launcher coin's solution (fetched by the caller) into `OptionMetadata`
— recovering `expiration_seconds` and `strike_type`. Network-free.
- **`rehydrate`** rebuilds the `OptionUnderlying` from `option.info.launcher_id`, the supplied `terms`,
and `underlying_coin.amount`, then REJECTS the reconstruction unless ALL THREE on-chain commitments
match. Per the chia-wallet-sdk 0.30 `OptionUnderlying` derivation, the three checks bind **disjoint**
field sets and are therefore **jointly** load-bearing — none is mere defense-in-depth:
1. **1-of-2 path hash** = `underlying_coin.puzzle_hash`. The path is
`merkle([exercise_path(launcher_id), clawback_path(expiry, creator_ph)])`, so it binds ONLY the
launcher id, expiry, and creator puzzle hash — NOT the amount or strike type. Sole check that
catches a wrong **creator puzzle hash**.
2. **delegated-puzzle hash** = `option.info.underlying_delegated_puzzle_hash`. The delegated puzzle
commits to the expiry, the underlying amount, and the strike type (settlement target +
requested-payment amount). Sole check that catches a wrong **strike type**; also catches a wrong
amount.
3. **underlying coin id** = `option.info.underlying_coin_id`. Binds the coin's full identity
(parent + puzzle hash + amount), uniquely rejecting a substituted coin of the right shape but
wrong parent.
On success the returned `CreatedOption` is operable by `exercise` / `clawback` / `transfer` exactly as
one returned by `create`.
- **Verified, not trusted (security property):** a wrong term cannot produce a `CreatedOption` — it is
rejected with `InvalidInput`, never a handle that builds an unspendable or mis-targeted bundle. (Tests:
`rehydrate_recovers_operable_option` round-trips create → rehydrate → exercise; `rehydrate_rejects_wrong_creator`
asserts a wrong creator puzzle hash is rejected.)
- **Pure:** both perform no I/O and hold no key.

## 6. Error taxonomy

`Error` (`thiserror`), `Result<T> = std::result::Result<T, Error>`:
Expand All @@ -170,7 +233,8 @@ must sign, given the network's `agg_sig_me` additional data. Performs NO signing
```
An option is created, then reaches exactly one terminal state: **exercised** (strictly before expiry) or
**clawed-back** (strictly after expiry). Both exits are always reachable — the option is never
locked-forever (§8.6).
locked-forever (§8.6). **`transfer` (v0.2.0) is a self-loop on CREATED:** it re-homes the ticket to a new
owner without changing state or terms; the new owner then exercises or claws back as usual.

## 8. Security properties (guarantees)

Expand Down Expand Up @@ -202,6 +266,18 @@ locked-forever (§8.6).
strands the underlying at a public settlement coin, allowing any mempool watcher to claim it
key-free even though the holder has paid the strike and received nothing. (Test:
`exercise_drops_underlying_claim_leaves_coin_strandable`.)
10. **Rehydration is verified, not trusted (v0.2.0).** `rehydrate` cannot fabricate a `CreatedOption` from
wrong terms: it reconstructs the `OptionUnderlying` and rejects it unless the 1-of-2 path hash, the
delegated-puzzle hash, and the underlying coin id all match the on-chain option. These three checks
bind disjoint field sets and are jointly load-bearing (§5.7): a wrong **creator puzzle hash** is
caught only by the path-hash check, a wrong **strike type** only by the delegated-puzzle-hash check,
and a substituted same-shape coin only by the coin-id check — so each is rejected with `InvalidInput`
rather than yielding a handle that builds an unspendable or mis-targeted bundle. (Tests:
`rehydrate_rejects_wrong_creator`, `rehydrate_rejects_wrong_strike`, `rehydrate_rejects_wrong_amount`.)
11. **Transfer moves only the ticket (v0.2.0).** `transfer` re-homes the option singleton to a new owner
and leaves the underlying and terms untouched; a wrong-party `Standard` owner is rejected up front. A
transferred option is fully operable — the new owner exercises it and the original creator still
receives the strike. (Test: `transfer_moves_option_then_new_owner_exercises`.)


## 9. Conformance
Expand Down
16 changes: 13 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@
//! options (the CHIP-0042 option primitive). It constructs the exact
//! [`CoinSpend`](chia_protocol::CoinSpend)s for the option lifecycle — [`create`] (lock an XCH
//! underlying, mint the option singleton), [`exercise`] (pay the strike, unlock the underlying
//! to the holder), [`clawback`] (the creator reclaims the underlying after expiry), and
//! inspect ([`parse`]/[`parse_child`]) — and reports the exact signatures a caller must
//! produce ([`required_signatures`]).
//! to the holder), [`transfer`] (move the option ticket to a new owner), [`clawback`] (the
//! creator reclaims the underlying after expiry), and inspect ([`parse`]/[`parse_child`]) — and
//! reports the exact signatures a caller must produce ([`required_signatures`]).
//!
//! It also [`rehydrate`]s a previously-minted option: [`parse`] recovers only an option's
//! identity fields, so [`rehydrate`] reconstructs the full operable [`CreatedOption`] from
//! caller-observed terms + the launcher metadata ([`parse_metadata`]) and VERIFIES it against the
//! option's on-chain commitments, letting a caller exercise/transfer/claw back an option it did
//! not mint in the same session.
//!
//! ## The custody model (HARD invariants)
//!
Expand Down Expand Up @@ -35,15 +41,19 @@ mod create;
mod error;
mod exercise;
mod hydrate;
mod rehydrate;
mod sign;
mod transfer;
mod types;

pub use clawback::clawback;
pub use create::create;
pub use error::{Error, Result};
pub use exercise::{exercise, StrikePayment};
pub use hydrate::{parse, parse_child, ParsedOption};
pub use rehydrate::{parse_metadata, rehydrate, OptionMetadata, RehydratedTerms};
pub use sign::required_signatures;
pub use transfer::transfer;
pub use types::{CreatedOption, OptionSpend, OptionTerms, Owner};

// Re-exports so a consumer need not depend on the SDK directly for the common surface.
Expand Down
137 changes: 137 additions & 0 deletions src/rehydrate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
//! Reconstruct a full, operable [`CreatedOption`] from on-chain state.
//!
//! [`crate::parse`]/[`crate::parse_child`] recover only an option's *identity* fields — the
//! option singleton's puzzle does not commit to its terms (creator puzzle hash, expiry, underlying
//! amount, strike type), so those cannot be inverted from the singleton coin spend alone. But
//! [`crate::exercise`], [`crate::clawback`], and [`crate::transfer`] all need the full
//! [`OptionUnderlying`] terms. Without a way to recover them, a caller could only ever operate an
//! option it minted itself in the same session.
//!
//! [`rehydrate`] closes that gap. The caller supplies the terms it can observe off-chain — the
//! creator puzzle hash it recorded, plus the expiry + strike recovered from the launcher metadata
//! via [`parse_metadata`] — together with the parsed option and its fetched underlying coin.
//! `rehydrate` reconstructs the [`OptionUnderlying`] and **verifies it against the option's
//! on-chain commitments**: the 1-of-2 underlying path, the underlying delegated-puzzle hash, and
//! the underlying coin id all must match. A single wrong term changes one of those hashes and is
//! rejected — so a successfully rehydrated [`CreatedOption`] is guaranteed to bind to the real
//! on-chain option and produce spends the consensus will accept.

use chia_protocol::{Coin, Program};

use chia_wallet_sdk::driver::{OptionContract, OptionType, OptionUnderlying, SpendContext};
use chia_wallet_sdk::prelude::ToTreeHash;

use crate::error::{Error, Result};
use crate::types::CreatedOption;

// Re-exported so a caller need not depend on the SDK directly to name the recovered metadata.
pub use chia_wallet_sdk::driver::OptionMetadata;

/// The terms a caller supplies to [`rehydrate`] a previously-minted option.
///
/// Every field is verified against the option's on-chain commitments, so these are asserted, not
/// trusted: a wrong value is rejected rather than producing an option handle that builds an
/// unspendable bundle. `expiry_seconds` and `strike_type` are recoverable from the launcher
/// metadata ([`parse_metadata`]); `creator_puzzle_hash` is the party the caller recorded at mint
/// (it is committed only inside the underlying's clawback path, so it is supplied and verified
/// rather than inverted).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RehydratedTerms {
/// The puzzle hash the creator reclaims the underlying to on clawback.
pub creator_puzzle_hash: chia_protocol::Bytes32,
/// The absolute unix timestamp (seconds) at which the option expires.
pub expiry_seconds: u64,
/// The asset + amount the holder must pay to exercise the option.
pub strike_type: OptionType,
}

/// Recover an option's [`OptionMetadata`] (expiry seconds + strike type) from the launcher coin's
/// solution, which the caller fetched from a node/indexer.
///
/// The launcher solution carries the option's key-value metadata; this decodes it into the
/// caller-owned [`SpendContext`]. Network-free: the caller supplies the serialized solution.
pub fn parse_metadata(
ctx: &mut SpendContext,
launcher_solution: &Program,
) -> Result<OptionMetadata> {
let solution = ctx.alloc(launcher_solution)?;
Ok(OptionContract::parse_metadata(ctx, solution)?)
}

/// Reconstruct a full [`CreatedOption`] from a parsed `option`, caller-supplied `terms`, and the
/// fetched `underlying_coin`, verifying every reconstructed field against the option's on-chain
/// commitments.
///
/// Rebuilds the [`OptionUnderlying`] from `option.info.launcher_id`, `terms`, and
/// `underlying_coin.amount`, then rejects the reconstruction unless ALL THREE of the following
/// match the on-chain option. The three are **jointly** load-bearing — no single check covers
/// every field, so none is mere defense-in-depth (verified against the chia-wallet-sdk 0.30
/// `OptionUnderlying` derivation):
/// - **1-of-2 path hash** equals `underlying_coin.puzzle_hash`. Per the SDK, the path is
/// `merkle([exercise_path(launcher_id), clawback_path(expiry, creator_ph)])`, so it binds ONLY
/// the launcher id, expiry, and creator puzzle hash — NOT the amount or strike type. A wrong
/// creator hash or expiry is caught here.
/// - **delegated-puzzle hash** equals `option.info.underlying_delegated_puzzle_hash`. The
/// delegated puzzle commits to the expiry, the underlying amount, and the strike type (settlement
/// target + requested-payment amount). A wrong **strike type** is caught ONLY here; a wrong
/// amount is caught both here and by the coin-id check below.
/// - **underlying coin id** equals `option.info.underlying_coin_id`. This binds the coin's full
/// identity (parent + puzzle hash + amount), uniquely rejecting a substituted coin of the right
/// shape but wrong parent that would slip past the two hash checks.
///
/// On success the returned [`CreatedOption`] is operable by [`crate::exercise`],
/// [`crate::clawback`], and [`crate::transfer`] exactly as one returned by [`crate::create`].
///
/// **Pure: performs no I/O and holds no key.** The caller fetches the option spend + underlying
/// coin and recovers the metadata; `rehydrate` only reconstructs + verifies.
pub fn rehydrate(
option: &OptionContract,
terms: &RehydratedTerms,
underlying_coin: Coin,
) -> Result<CreatedOption> {
let underlying = OptionUnderlying::new(
option.info.launcher_id,
terms.creator_puzzle_hash,
terms.expiry_seconds,
underlying_coin.amount,
terms.strike_type,
);

// Check 1: the 1-of-2 path hash. Per the SDK it is
// merkle([exercise_path(launcher_id), clawback_path(expiry, creator_ph)]), so it binds ONLY the
// launcher id, expiry, and creator puzzle hash — NOT the amount or strike type. This is the sole
// check that catches a wrong creator puzzle hash.
let reconstructed_path: chia_protocol::Bytes32 = underlying.tree_hash().into();
if reconstructed_path != underlying_coin.puzzle_hash {
return Err(Error::invalid(
"rehydrated terms do not match the underlying coin's 1-of-2 path — check creator puzzle hash and expiry",
));
}

// Check 2: the delegated-puzzle hash, which the option singleton independently commits to. It
// binds the expiry, the underlying amount, and the STRIKE TYPE (settlement target +
// requested-payment amount). This is the ONLY check that catches a wrong strike type — it is
// load-bearing, not defense in depth.
let reconstructed_delegated: chia_protocol::Bytes32 =
underlying.delegated_puzzle().tree_hash().into();
if reconstructed_delegated != option.info.underlying_delegated_puzzle_hash {
return Err(Error::invalid(
"rehydrated terms do not match the option's underlying delegated-puzzle hash — check the strike type, amount, and expiry",
));
}

// Check 3: the underlying coin id. This binds the coin's full identity (parent + puzzle hash +
// amount), uniquely rejecting a substituted coin of the right shape but wrong parent that the
// two hash checks above could not distinguish.
if underlying_coin.coin_id() != option.info.underlying_coin_id {
return Err(Error::invalid(
"underlying coin id does not match the option's committed underlying coin id",
));
}

Ok(CreatedOption {
option: *option,
underlying,
underlying_coin,
})
}
Loading
Loading