use chia_protocol::{Bytes32, Coin, CoinSpend, SpendBundle};
use chia_puzzles::SETTLEMENT_PAYMENT_HASH;
use chia_sdk_test::{sign_transaction, Simulator};
use chia_wallet_sdk::driver::{SingletonInfo, SpendContext, StandardLayer};
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,
};
fn puzzle_hash(seed: &str) -> Bytes32 {
use chia_sdk_test::BlsPair;
BlsPair::new(hash_seed(seed)).puzzle_hash
}
fn hash_seed(seed: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
seed.hash(&mut hasher);
hasher.finish()
}
fn sign_for_sim(coin_spends: &[CoinSpend], sks: &[SecretKey]) -> anyhow::Result<Signature> {
let reported =
required_signatures(coin_spends, TESTNET11_CONSTANTS.agg_sig_me_additional_data)?;
assert!(
!reported.is_empty(),
"a spend must report required signatures"
);
Ok(sign_transaction(coin_spends, sks)?)
}
fn xch(amount: u64) -> OptionType {
OptionType::Xch { amount }
}
#[test]
fn create_rejects_zero_underlying() {
let ctx = &mut SpendContext::new();
let creator = puzzle_hash("creator");
let funding = Coin::new(Bytes32::default(), creator, 10);
let terms = OptionTerms::new(creator, 0, xch(1), 10);
let err = create(ctx, &Owner::Standard(bls("creator").pk), funding, &terms).unwrap_err();
assert!(format!("{err}").contains("greater than zero"), "got: {err}");
}
#[test]
fn create_rejects_funding_too_small() {
let ctx = &mut SpendContext::new();
let alice = bls("alice");
let funding = Coin::new(Bytes32::default(), alice.puzzle_hash, 10);
let terms = OptionTerms::new(alice.puzzle_hash, 10, xch(1), 10);
let err = create(ctx, &Owner::Standard(alice.pk), funding, &terms).unwrap_err();
assert!(format!("{err}").contains("too small"), "got: {err}");
}
#[test]
fn clawback_rejects_wrong_creator_key() -> anyhow::Result<()> {
let ctx = &mut SpendContext::new();
let alice = bls("alice");
let stranger = bls("stranger");
let funding = Coin::new(Bytes32::default(), alice.puzzle_hash, 1_001);
let terms = OptionTerms::new(alice.puzzle_hash, 1_000, xch(1), 10);
let created = create(ctx, &Owner::Standard(alice.pk), funding, &terms)?
.created
.expect("create yields a CreatedOption");
let err = clawback(ctx, &Owner::Standard(stranger.pk), &created).unwrap_err();
assert!(
format!("{err}").contains("creator puzzle hash"),
"got: {err}"
);
Ok(())
}
#[test]
fn exercise_rejects_underfunded_strike() -> anyhow::Result<()> {
let ctx = &mut SpendContext::new();
let alice = bls("alice");
let funding = Coin::new(Bytes32::default(), alice.puzzle_hash, 1_001);
let terms = OptionTerms::new(alice.puzzle_hash, 1_000, xch(250), 10);
let created = create(ctx, &Owner::Standard(alice.pk), funding, &terms)?
.created
.unwrap();
let strike = StrikePayment {
funding_coin: Coin::new(Bytes32::default(), alice.puzzle_hash, 100),
};
let err = exercise(ctx, &Owner::Standard(alice.pk), &created, &strike).unwrap_err();
assert!(format!("{err}").contains("too small"), "got: {err}");
Ok(())
}
#[test]
fn exercise_rejects_non_xch_strike() -> anyhow::Result<()> {
let ctx = &mut SpendContext::new();
let alice = bls("alice");
let funding = Coin::new(Bytes32::default(), alice.puzzle_hash, 1_001);
let terms = OptionTerms::new(alice.puzzle_hash, 1_000, xch(250), 10);
let mut created = create(ctx, &Owner::Standard(alice.pk), funding, &terms)?
.created
.unwrap();
created.underlying.strike_type = OptionType::Cat {
asset_id: puzzle_hash("asset"),
amount: 250,
};
let strike = StrikePayment {
funding_coin: Coin::new(Bytes32::default(), alice.puzzle_hash, 1_000),
};
let err = exercise(ctx, &Owner::Standard(alice.pk), &created, &strike).unwrap_err();
assert!(
format!("{err}").contains("CAT/NFT strike exercise not yet supported"),
"got: {err}"
);
Ok(())
}
#[test]
fn create_rejects_non_xch_strike() -> anyhow::Result<()> {
let ctx = &mut SpendContext::new();
let alice = bls("alice");
let funding = Coin::new(Bytes32::default(), alice.puzzle_hash, 1_001);
let cat_strike = OptionType::Cat {
asset_id: puzzle_hash("asset"),
amount: 250,
};
let terms = OptionTerms::new(alice.puzzle_hash, 1_000, cat_strike, 10);
let err = create(ctx, &Owner::Standard(alice.pk), funding, &terms).unwrap_err();
assert!(format!("{err}").contains("CAT/NFT strike"), "got: {err}");
Ok(())
}
#[test]
fn required_signatures_reports_messages() -> anyhow::Result<()> {
let ctx = &mut SpendContext::new();
let alice = bls("alice");
let funding = Coin::new(Bytes32::default(), alice.puzzle_hash, 1_001);
let terms = OptionTerms::new(alice.puzzle_hash, 1_000, xch(1), 10);
let spend = create(ctx, &Owner::Standard(alice.pk), funding, &terms)?;
let reported = required_signatures(
&spend.coin_spends,
TESTNET11_CONSTANTS.agg_sig_me_additional_data,
)?;
assert!(
!reported.is_empty(),
"create must report at least one required signature"
);
Ok(())
}
fn create_confirmed(
sim: &mut Simulator,
ctx: &mut SpendContext,
alice: &chia_sdk_test::BlsPairWithCoin,
underlying_amount: u64,
strike_amount: u64,
expiry: u64,
) -> anyhow::Result<CreatedOption> {
let terms = OptionTerms::new(
alice.puzzle_hash,
underlying_amount,
xch(strike_amount),
expiry,
);
let spend = create(ctx, &Owner::Standard(alice.pk), alice.coin, &terms)?;
let created = spend.created.clone().unwrap();
let sig = sign_for_sim(&spend.coin_spends, std::slice::from_ref(&alice.sk))?;
sim.new_transaction(SpendBundle::new(spend.coin_spends, sig))?;
assert!(
sim.coin_state(created.option.coin.coin_id()).is_some(),
"the option singleton should exist after create"
);
Ok(created)
}
fn create_confirmed_terms(
sim: &mut Simulator,
ctx: &mut SpendContext,
creator: &chia_sdk_test::BlsPairWithCoin,
terms: &OptionTerms,
) -> anyhow::Result<CreatedOption> {
let spend = create(ctx, &Owner::Standard(creator.pk), creator.coin, terms)?;
let created = spend.created.clone().unwrap();
let sig = sign_for_sim(&spend.coin_spends, std::slice::from_ref(&creator.sk))?;
sim.new_transaction(SpendBundle::new(spend.coin_spends, sig))?;
assert!(
sim.coin_state(created.option.coin.coin_id()).is_some(),
"the option singleton should exist after create"
);
Ok(created)
}
fn balance(sim: &Simulator, puzzle_hash: Bytes32) -> u64 {
sim.unspent_coins(puzzle_hash, false)
.iter()
.map(|c| c.amount)
.sum()
}
#[test]
fn create_then_exercise_round_trip() -> anyhow::Result<()> {
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)?;
let strike_funding = sim.new_coin(bob.puzzle_hash, strike_amount);
let creator_before = balance(&sim, alice.puzzle_hash);
let holder_before = balance(&sim, bob.puzzle_hash);
let spend = exercise(
ctx,
&Owner::Standard(bob.pk),
&created,
&StrikePayment {
funding_coin: strike_funding,
},
)?;
let sig = sign_for_sim(&spend.coin_spends, &[bob.sk])?;
sim.new_transaction(SpendBundle::new(spend.coin_spends, sig))?;
let creator_gain = balance(&sim, alice.puzzle_hash) - creator_before;
let holder_gain = balance(&sim, bob.puzzle_hash) as i128 - holder_before as i128;
assert_eq!(
creator_gain, strike_amount,
"the creator should receive exactly the strike ({strike_amount})"
);
assert_eq!(
holder_gain,
underlying_amount as i128 - strike_amount as i128,
"the holder should net exactly underlying - strike (received {underlying_amount}, paid {strike_amount})"
);
Ok(())
}
#[test]
fn exercise_leaves_no_orphan_underlying_settlement_coin() -> anyhow::Result<()> {
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");
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)?;
let strike_funding = sim.new_coin(bob.puzzle_hash, strike_amount);
let spend = exercise(
ctx,
&Owner::Standard(bob.pk),
&created,
&StrikePayment {
funding_coin: strike_funding,
},
)?;
let sig = sign_for_sim(&spend.coin_spends, &[bob.sk])?;
sim.new_transaction(SpendBundle::new(spend.coin_spends, sig))?;
let orphan = sim
.unspent_coins(SETTLEMENT_PAYMENT_HASH.into(), false)
.iter()
.any(|c| c.amount == underlying_amount || c.amount == strike_amount);
assert!(
!orphan,
"no bare settlement coin holding the underlying/strike may survive a complete exercise"
);
assert!(
balance(&sim, bob.puzzle_hash) >= underlying_amount - strike_amount,
"the holder must hold the claimed underlying after exercise"
);
Ok(())
}
#[test]
fn create_then_clawback_on_expiry() -> anyhow::Result<()> {
let mut sim = Simulator::new();
let ctx = &mut SpendContext::new();
let underlying_amount = 2_000u64;
let expiry = 100u64;
let alice = sim.bls(underlying_amount + 1);
let created = create_confirmed(&mut sim, ctx, &alice, underlying_amount, 1, expiry)?;
sim.pass_time(expiry + 10);
let spend = clawback(ctx, &Owner::Standard(alice.pk), &created)?;
let sig = sign_for_sim(&spend.coin_spends, &[alice.sk])?;
sim.new_transaction(SpendBundle::new(spend.coin_spends, sig))?;
let recovered = sim
.unspent_coins(alice.puzzle_hash, false)
.iter()
.map(|c| c.amount)
.sum::<u64>();
assert_eq!(
recovered, underlying_amount,
"the creator should reclaim exactly the locked underlying on expiry"
);
Ok(())
}
#[test]
fn create_via_custom_owner_layer_validates() -> anyhow::Result<()> {
let mut sim = Simulator::new();
let ctx = &mut SpendContext::new();
let alice = sim.bls(1_001);
let layer = StandardLayer::new(alice.pk);
let terms = OptionTerms::new(alice.puzzle_hash, 1_000, xch(1), 10_000);
let spend = create(ctx, &Owner::Custom(&layer), alice.coin, &terms)?;
let sig = sign_for_sim(&spend.coin_spends, &[alice.sk])?;
sim.new_transaction(SpendBundle::new(spend.coin_spends, sig))?;
Ok(())
}
#[test]
fn exercise_without_strike_leg_is_rejected() -> anyhow::Result<()> {
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 alice_p2 = StandardLayer::new(alice.pk);
created.option.exercise(ctx, &alice_p2, Conditions::new())?;
created.underlying.exercise_coin_spend(
ctx,
created.underlying_coin,
created.option.info.inner_puzzle_hash().into(),
created.option.coin.amount,
)?;
let coin_spends = ctx.take();
let sig = sign_for_sim(&coin_spends, &[alice.sk])?;
assert!(
sim.new_transaction(SpendBundle::new(coin_spends, sig))
.is_err(),
"exercise without the strike payment must be rejected"
);
Ok(())
}
#[test]
fn exercise_after_expiry_is_rejected() -> anyhow::Result<()> {
let mut sim = Simulator::new();
let ctx = &mut SpendContext::new();
let expiry = 100u64;
let alice = sim.bls(1_001);
let created = create_confirmed(&mut sim, ctx, &alice, 1_000, 250, expiry)?;
sim.pass_time(expiry + 10);
let strike_funding = sim.new_coin(alice.puzzle_hash, 250);
let spend = exercise(
ctx,
&Owner::Standard(alice.pk),
&created,
&StrikePayment {
funding_coin: strike_funding,
},
)?;
let sig = sign_for_sim(&spend.coin_spends, &[alice.sk])?;
assert!(
sim.new_transaction(SpendBundle::new(spend.coin_spends, sig))
.is_err(),
"exercise after expiry must be rejected"
);
Ok(())
}
#[test]
fn clawback_before_expiry_is_rejected() -> anyhow::Result<()> {
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, 1, 10_000)?;
let spend = clawback(ctx, &Owner::Standard(alice.pk), &created)?;
let sig = sign_for_sim(&spend.coin_spends, &[alice.sk])?;
assert!(
sim.new_transaction(SpendBundle::new(spend.coin_spends, sig))
.is_err(),
"clawback before expiry must be rejected"
);
Ok(())
}
#[test]
fn parse_round_trips_identity() -> anyhow::Result<()> {
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)
.expect("the eve coin was spent in the create bundle")
.coin;
let (puzzle, solution) = sim
.puzzle_and_solution(eve_coin_id)
.expect("the eve coin's spend is recorded");
let parsed = parse_child(ctx, eve_coin, &puzzle, &solution)?
.expect("the eve spend produced an option child");
assert_eq!(parsed.launcher_id, created.option.info.launcher_id);
assert_eq!(parsed.coin_id, created.option.coin.coin_id());
assert_eq!(parsed.p2_puzzle_hash, created.option.info.p2_puzzle_hash);
assert_eq!(
parsed.underlying_coin_id,
created.option.info.underlying_coin_id
);
Ok(())
}
#[test]
fn parse_returns_none_for_non_option() -> anyhow::Result<()> {
let mut sim = Simulator::new();
let ctx = &mut SpendContext::new();
let alice = sim.bls(1_001);
let funding_id = alice.coin.coin_id();
let _ = create_confirmed(&mut sim, ctx, &alice, 1_000, 250, 10_000)?;
let coin = alice.coin;
let (puzzle, solution) = sim
.puzzle_and_solution(funding_id)
.expect("the funding coin's spend is recorded");
assert!(
parse(ctx, coin, &puzzle, &solution)?.is_none(),
"a standard funding-coin spend is not an option"
);
Ok(())
}
fn bls(seed: &str) -> chia_sdk_test::BlsPair {
chia_sdk_test::BlsPair::new(hash_seed(seed))
}