diff --git a/CHANGELOG.md b/CHANGELOG.md index 19d9643..65cf9d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 30e4757..e4eec62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -799,7 +799,7 @@ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "dig-options" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "chia-protocol", diff --git a/Cargo.toml b/Cargo.toml index 4bc29c5..21671aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/SPEC.md b/SPEC.md index 2675b17..4790a5b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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: @@ -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` @@ -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 = std::result::Result`: @@ -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) @@ -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 diff --git a/src/lib.rs b/src/lib.rs index 04f59b4..2d98910 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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) //! @@ -35,7 +41,9 @@ mod create; mod error; mod exercise; mod hydrate; +mod rehydrate; mod sign; +mod transfer; mod types; pub use clawback::clawback; @@ -43,7 +51,9 @@ 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. diff --git a/src/rehydrate.rs b/src/rehydrate.rs new file mode 100644 index 0000000..1fae9d5 --- /dev/null +++ b/src/rehydrate.rs @@ -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 { + 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 { + 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, + }) +} diff --git a/src/transfer.rs b/src/transfer.rs new file mode 100644 index 0000000..2e80729 --- /dev/null +++ b/src/transfer.rs @@ -0,0 +1,62 @@ +//! Transfer an option — move the option singleton to a new owner. +//! +//! [`transfer`] spends the option singleton through the current owner's p2 layer and recreates +//! it at `new_owner_puzzle_hash` (hinted, so the new owner's wallet discovers it). Only the +//! option ticket moves; the locked underlying and its terms are untouched. The returned +//! [`OptionSpend`] carries the transferred option as its `created` handle so the caller can +//! immediately track — and later exercise or claw back — the option in its new-owner state. + +use chia_protocol::Bytes32; +use chia_wallet_sdk::driver::SpendContext; +use chia_wallet_sdk::types::Conditions; + +use crate::error::{Error, Result}; +use crate::types::{CreatedOption, OptionSpend, Owner}; + +/// Build the unsigned coin spend that TRANSFERS `created`'s option singleton to +/// `new_owner_puzzle_hash`, authorized by its current `owner`. +/// +/// Spends the option singleton through `owner`'s p2 layer and recreates it (same launcher id, +/// same underlying, same amount) at `new_owner_puzzle_hash` with a hint so the recipient's +/// wallet discovers it. The locked underlying coin and the option's terms are unchanged — only +/// the ticket's `p2_puzzle_hash` moves. +/// +/// A [`Owner::Standard`] `owner` whose puzzle hash does not match the option's current +/// `p2_puzzle_hash` is rejected up front; a [`Owner::Custom`] owner cannot be checked here and +/// relies on the consensus to reject a wrong-party spend. +/// +/// **Pure: does NOT sign or broadcast.** Returns [`OptionSpend`] with `created: Some(..)` — the +/// option in its NEW-owner state (its coin now lives at `new_owner_puzzle_hash`), so the caller +/// can chain further transfers or an exercise/clawback against the transferred singleton once +/// this spend is confirmed. +pub fn transfer( + ctx: &mut SpendContext, + owner: &Owner, + created: &CreatedOption, + new_owner_puzzle_hash: Bytes32, +) -> Result { + if let Some(puzzle_hash) = owner.standard_puzzle_hash() { + if puzzle_hash != created.option.info.p2_puzzle_hash { + return Err(Error::invalid( + "transfer owner does not match the option's current owner puzzle hash", + )); + } + } + + // `OptionContract` is `Copy`; `transfer` consumes it and returns the recreated child at the + // new p2 puzzle hash (hinted for wallet discovery via the SDK). Only the singleton moves — + // the underlying stays locked under the same terms. + let transferred = + created + .option + .transfer(ctx, owner, new_owner_puzzle_hash, Conditions::new())?; + + Ok(OptionSpend { + coin_spends: ctx.take(), + created: Some(CreatedOption { + option: transferred, + underlying: created.underlying, + underlying_coin: created.underlying_coin, + }), + }) +} diff --git a/src/types.rs b/src/types.rs index 2cc422a..b1a00d2 100644 --- a/src/types.rs +++ b/src/types.rs @@ -106,13 +106,17 @@ impl OptionTerms { } } -/// A created option, returned by [`crate::create`] so the caller can later exercise or claw it -/// back — both need the option singleton, the underlying terms, and the locked-underlying -/// coin, which only exist once the create spend is confirmed. +/// The operable handle for a live option: the option singleton, the underlying terms, and the +/// locked-underlying coin needed to exercise, transfer, or claw it back. /// -/// The caller must retain this (or the equivalent [`OptionTerms`] plus the confirmed coins) to -/// operate the option: the terms are not recoverable from the option singleton coin alone -/// (see [`crate::parse`]). +/// Returned by [`crate::create`] (the freshly minted option), by [`crate::transfer`] (the option +/// re-homed to its new owner), and by [`crate::rehydrate`] (reconstructed from on-chain state). +/// The three fields only exist once the create spend is confirmed. +/// +/// The terms are not recoverable from the option singleton coin alone (see [`crate::parse`]), so a +/// caller either retains this handle from `create`/`transfer` or rebuilds it with +/// [`crate::rehydrate`], which reconstructs and verifies it against the option's on-chain +/// commitments. #[derive(Clone, Debug)] pub struct CreatedOption { /// The option singleton (the transferable "ticket"); its holder may exercise it. @@ -125,15 +129,17 @@ pub struct CreatedOption { pub underlying_coin: Coin, } -/// The result of a dig-options builder: the unsigned coin spends it produced, and — for -/// [`crate::create`] — the [`CreatedOption`] the caller keeps to operate the option later. +/// The result of a dig-options builder: the unsigned coin spends it produced, and — for the +/// builders that yield an operable option — the [`CreatedOption`] handle the caller keeps to +/// operate it later. /// -/// `created` is `Some` for `create` and `None` for `exercise`/`clawback` (which consume an -/// existing option rather than producing one). +/// `created` is `Some` for [`crate::create`] (the minted option) and [`crate::transfer`] (the +/// option re-homed to its new owner), and `None` for [`crate::exercise`]/[`crate::clawback`], +/// which consume an existing option rather than producing an ongoing one. #[derive(Clone, Debug)] pub struct OptionSpend { /// The unsigned coin spends this operation produced. pub coin_spends: Vec, - /// The created option — `Some` only for `create`. + /// The operable option handle — `Some` for `create` and `transfer`, `None` otherwise. pub created: Option, } diff --git a/tests/lifecycle.rs b/tests/lifecycle.rs index 888ec30..16f2099 100644 --- a/tests/lifecycle.rs +++ b/tests/lifecycle.rs @@ -18,8 +18,8 @@ use chia_wallet_sdk::prelude::{SecretKey, Signature}; use chia_wallet_sdk::types::{Conditions, TESTNET11_CONSTANTS}; use dig_options::{ - clawback, create, exercise, parse, parse_child, required_signatures, CreatedOption, - OptionTerms, OptionType, Owner, StrikePayment, + clawback, create, exercise, parse, parse_child, parse_metadata, rehydrate, required_signatures, + transfer, CreatedOption, OptionTerms, OptionType, Owner, RehydratedTerms, StrikePayment, }; /// A distinct 32-byte puzzle hash derived from a seed (never a hard-coded crypto literal — @@ -534,6 +534,282 @@ fn parse_returns_none_for_non_option() -> anyhow::Result<()> { Ok(()) } +// ----- transfer ----- + +#[test] +fn transfer_moves_option_then_new_owner_exercises() -> anyhow::Result<()> { + // Mint an option owned by bob, transfer it to carol, then have CAROL exercise it. The + // transfer must actually re-home the singleton (new p2 puzzle hash + a confirmed coin) and + // the transferred handle must be fully operable: carol receives the underlying, and the + // ORIGINAL creator (alice) still receives the strike. This proves transfer moves only the + // ticket, not the underlying terms. + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let underlying_amount = 1_000u64; + let strike_amount = 250u64; + + let alice = sim.bls(underlying_amount + 1); + let bob = bls("holder-bob"); + let carol = bls("holder-carol"); + let terms = OptionTerms { + creator_puzzle_hash: alice.puzzle_hash, + owner_puzzle_hash: bob.puzzle_hash, + underlying_amount, + strike_type: xch(strike_amount), + expiry_seconds: 10_000, + }; + let created = create_confirmed_terms(&mut sim, ctx, &alice, &terms)?; + + // Transfer bob -> carol. The spend must report a signature (bob authorizes it) and produce a + // handle whose option lives at carol's puzzle hash. + let spend = transfer(ctx, &Owner::Standard(bob.pk), &created, carol.puzzle_hash)?; + let transferred = spend + .created + .clone() + .expect("transfer yields the re-homed option"); + assert_eq!( + transferred.option.info.p2_puzzle_hash, carol.puzzle_hash, + "the transferred option must live at the new owner's puzzle hash" + ); + assert_eq!( + transferred.underlying, created.underlying, + "transfer must not change the underlying terms" + ); + let sig = sign_for_sim(&spend.coin_spends, &[bob.sk])?; + sim.new_transaction(SpendBundle::new(spend.coin_spends, sig))?; + assert!( + sim.coin_state(transferred.option.coin.coin_id()).is_some(), + "the re-homed option singleton must exist after transfer" + ); + + // Carol now exercises the transferred option and receives the underlying; alice gets the strike. + let strike_funding = sim.new_coin(carol.puzzle_hash, strike_amount); + let creator_before = balance(&sim, alice.puzzle_hash); + let holder_before = balance(&sim, carol.puzzle_hash); + let ex = exercise( + ctx, + &Owner::Standard(carol.pk), + &transferred, + &StrikePayment { + funding_coin: strike_funding, + }, + )?; + let sig = sign_for_sim(&ex.coin_spends, &[carol.sk])?; + sim.new_transaction(SpendBundle::new(ex.coin_spends, sig))?; + + assert_eq!( + balance(&sim, alice.puzzle_hash) - creator_before, + strike_amount, + "the original creator receives the strike after a transferred exercise" + ); + assert_eq!( + balance(&sim, carol.puzzle_hash) as i128 - holder_before as i128, + underlying_amount as i128 - strike_amount as i128, + "the NEW owner nets underlying - strike after exercising the transferred option" + ); + Ok(()) +} + +#[test] +fn transfer_rejects_wrong_owner() -> anyhow::Result<()> { + // A standard owner that is not the option's current holder is rejected up front. + let ctx = &mut SpendContext::new(); + let alice = bls("alice"); + let bob = bls("holder-bob"); + let stranger = bls("stranger"); + let funding = Coin::new(Bytes32::default(), alice.puzzle_hash, 1_001); + let terms = OptionTerms { + creator_puzzle_hash: alice.puzzle_hash, + owner_puzzle_hash: bob.puzzle_hash, + underlying_amount: 1_000, + strike_type: xch(1), + expiry_seconds: 10, + }; + let created = create(ctx, &Owner::Standard(alice.pk), funding, &terms)? + .created + .unwrap(); + + let err = transfer( + ctx, + &Owner::Standard(stranger.pk), + &created, + alice.puzzle_hash, + ) + .unwrap_err(); + assert!(format!("{err}").contains("current owner"), "got: {err}"); + Ok(()) +} + +// ----- rehydrate ----- + +#[test] +fn rehydrate_recovers_operable_option() -> anyhow::Result<()> { + // Reconstruct a full CreatedOption purely from on-chain state (as a wallet that did not mint + // the option would): parse the singleton, recover the metadata from the launcher solution, + // fetch the underlying coin, rehydrate, and prove the result matches AND is operable by + // exercising it. Nothing from the original `created` handle feeds the rehydrated terms. + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let underlying_amount = 1_000u64; + let strike_amount = 250u64; + let expiry = 10_000u64; + + let alice = sim.bls(underlying_amount + 1); + let bob = bls("holder"); + let terms = OptionTerms { + creator_puzzle_hash: alice.puzzle_hash, + owner_puzzle_hash: bob.puzzle_hash, + underlying_amount, + strike_type: xch(strike_amount), + expiry_seconds: expiry, + }; + let created = create_confirmed_terms(&mut sim, ctx, &alice, &terms)?; + + // 1. Parse the current option singleton from the eve spend (on-chain identity fields only). + let eve_coin_id = created.option.coin.parent_coin_info; + let eve_coin = sim.coin_state(eve_coin_id).unwrap().coin; + let (puzzle, solution) = sim.puzzle_and_solution(eve_coin_id).unwrap(); + let parsed = parse_child(ctx, eve_coin, &puzzle, &solution)?.expect("an option child"); + + // 2. Recover expiry + strike from the launcher solution. + let launcher_id = parsed.launcher_id; + let (l_puzzle, l_solution) = sim.puzzle_and_solution(launcher_id).unwrap(); + let _ = l_puzzle; + let metadata = parse_metadata(ctx, &l_solution)?; + assert_eq!(metadata.expiration_seconds, expiry); + assert_eq!(metadata.strike_type, xch(strike_amount)); + + // 3. Fetch the underlying coin the option commits to. + let underlying_coin = sim.coin_state(parsed.underlying_coin_id).unwrap().coin; + + // 4. Rehydrate — verified against the on-chain commitments. + let rehydrated_terms = RehydratedTerms { + creator_puzzle_hash: alice.puzzle_hash, + expiry_seconds: metadata.expiration_seconds, + strike_type: metadata.strike_type, + }; + let rehydrated = rehydrate(&parsed.option, &rehydrated_terms, underlying_coin)?; + assert_eq!( + rehydrated.underlying, created.underlying, + "rehydrated underlying terms must match the minted option" + ); + assert_eq!( + rehydrated.underlying_coin, created.underlying_coin, + "rehydrated underlying coin must match" + ); + + // 5. Prove it is operable: bob exercises the rehydrated option. + let strike_funding = sim.new_coin(bob.puzzle_hash, strike_amount); + let creator_before = balance(&sim, alice.puzzle_hash); + let ex = exercise( + ctx, + &Owner::Standard(bob.pk), + &rehydrated, + &StrikePayment { + funding_coin: strike_funding, + }, + )?; + let sig = sign_for_sim(&ex.coin_spends, &[bob.sk])?; + sim.new_transaction(SpendBundle::new(ex.coin_spends, sig))?; + assert_eq!( + balance(&sim, alice.puzzle_hash) - creator_before, + strike_amount, + "exercising a rehydrated option pays the creator the strike" + ); + Ok(()) +} + +#[test] +fn rehydrate_rejects_wrong_creator() -> anyhow::Result<()> { + // A wrong creator puzzle hash changes the underlying's 1-of-2 path hash, so it no longer + // matches the underlying coin — rehydrate rejects it rather than yielding an unspendable handle. + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1_001); + let created = create_confirmed(&mut sim, ctx, &alice, 1_000, 250, 10_000)?; + + let eve_coin_id = created.option.coin.parent_coin_info; + let eve_coin = sim.coin_state(eve_coin_id).unwrap().coin; + let (puzzle, solution) = sim.puzzle_and_solution(eve_coin_id).unwrap(); + let parsed = parse_child(ctx, eve_coin, &puzzle, &solution)?.unwrap(); + let underlying_coin = sim.coin_state(parsed.underlying_coin_id).unwrap().coin; + + let wrong = RehydratedTerms { + creator_puzzle_hash: puzzle_hash("not-the-creator"), + expiry_seconds: 10_000, + strike_type: xch(250), + }; + let err = rehydrate(&parsed.option, &wrong, underlying_coin).unwrap_err(); + assert!(format!("{err}").contains("1-of-2 path"), "got: {err}"); + Ok(()) +} + +#[test] +fn rehydrate_rejects_wrong_strike() -> anyhow::Result<()> { + // A wrong strike type is NOT bound by the 1-of-2 path hash (which commits only launcher id + + // expiry + creator ph); it is caught solely by the delegated-puzzle-hash check. This test pins + // that check so a refactor cannot silently drop it. + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1_001); + let created = create_confirmed(&mut sim, ctx, &alice, 1_000, 250, 10_000)?; + + let eve_coin_id = created.option.coin.parent_coin_info; + let eve_coin = sim.coin_state(eve_coin_id).unwrap().coin; + let (puzzle, solution) = sim.puzzle_and_solution(eve_coin_id).unwrap(); + let parsed = parse_child(ctx, eve_coin, &puzzle, &solution)?.unwrap(); + let underlying_coin = sim.coin_state(parsed.underlying_coin_id).unwrap().coin; + + let wrong = RehydratedTerms { + creator_puzzle_hash: alice.puzzle_hash, + expiry_seconds: 10_000, + strike_type: xch(251), // correct creator + expiry, wrong strike amount + }; + let err = rehydrate(&parsed.option, &wrong, underlying_coin).unwrap_err(); + assert!( + format!("{err}").contains("delegated-puzzle hash"), + "a wrong strike must be caught by the delegated-puzzle-hash check; got: {err}" + ); + Ok(()) +} + +#[test] +fn rehydrate_rejects_wrong_amount() -> anyhow::Result<()> { + // The underlying amount is taken from the supplied coin, so a wrong amount means a coin whose + // amount does not match the option's committed underlying. The path hash ignores amount, so the + // reconstruction is rejected by the delegated-puzzle-hash check (which commits the amount) — never + // silently accepted. Pins that a wrong-amount coin cannot rehydrate. + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1_001); + let created = create_confirmed(&mut sim, ctx, &alice, 1_000, 250, 10_000)?; + + let eve_coin_id = created.option.coin.parent_coin_info; + let eve_coin = sim.coin_state(eve_coin_id).unwrap().coin; + let (puzzle, solution) = sim.puzzle_and_solution(eve_coin_id).unwrap(); + let parsed = parse_child(ctx, eve_coin, &puzzle, &solution)?.unwrap(); + let real_coin = sim.coin_state(parsed.underlying_coin_id).unwrap().coin; + + // Same parent + puzzle hash as the real underlying, but a different amount. + let wrong_amount_coin = Coin::new(real_coin.parent_coin_info, real_coin.puzzle_hash, 999); + let terms = RehydratedTerms { + creator_puzzle_hash: alice.puzzle_hash, + expiry_seconds: 10_000, + strike_type: xch(250), + }; + let err = rehydrate(&parsed.option, &terms, wrong_amount_coin).unwrap_err(); + assert!( + format!("{err}").contains("delegated-puzzle hash") || format!("{err}").contains("coin id"), + "a wrong underlying amount must be rejected; got: {err}" + ); + Ok(()) +} + // ----- test helpers ----- /// A deterministic BLS pair (with coin) for a seed, so tests carry no literal keys.