use chia_wallet_sdk::driver::{Launcher, SpendContext};
use chia_wallet_sdk::puzzles::SINGLETON_LAUNCHER_HASH;
use chia_wallet_sdk::types::conditions::CreateCoin;
use chia_wallet_sdk::types::{Condition, Conditions};
use clvm_traits::FromClvm;
use crate::context::{drain_coin_spends, inner_spend};
use crate::hint::{digstore_owner_hint, launcher_hint_for, StoreKind};
use crate::metadata::DigDataStoreMetadata;
use crate::size::SizeBucket;
use crate::types::{Bytes32, Coin, Datastore, DelegatedPuzzle, MerkleCoinSpend, Owner};
use crate::{MerkleError, MerkleResult};
const SINGLETON_LAUNCHER_PUZZLE_HASH: Bytes32 = Bytes32::new(SINGLETON_LAUNCHER_HASH);
#[allow(clippy::too_many_arguments)]
pub fn mint_datastore(
parent_coin: Coin,
owner: Owner,
root_hash: Bytes32,
label: Option<String>,
description: Option<String>,
size_proof: Option<String>,
program_hash: Option<Bytes32>,
size_bucket: Option<SizeBucket>,
owner_puzzle_hash: Bytes32,
delegated_puzzles: Vec<DelegatedPuzzle>,
fee: u64,
) -> MerkleResult<MerkleCoinSpend> {
mint_datastore_with_kind(
StoreKind::File,
parent_coin,
owner,
root_hash,
label,
description,
size_proof,
program_hash,
size_bucket,
owner_puzzle_hash,
delegated_puzzles,
fee,
)
}
#[allow(clippy::too_many_arguments)]
pub fn mint_datastore_with_kind(
kind: StoreKind,
parent_coin: Coin,
owner: Owner,
root_hash: Bytes32,
label: Option<String>,
description: Option<String>,
size_proof: Option<String>,
program_hash: Option<Bytes32>,
size_bucket: Option<SizeBucket>,
owner_puzzle_hash: Bytes32,
delegated_puzzles: Vec<DelegatedPuzzle>,
fee: u64,
) -> MerkleResult<MerkleCoinSpend> {
if matches!(owner, Owner::Custom(_)) {
return Err(MerkleError::UnsupportedOwner(
"a launch's parent conditions are built inside this call, so Owner::Custom cannot emit \
them — use mint_datastore_launch_with_kind and compose the parent spend yourself",
));
}
let mut ctx = SpendContext::new();
let DatastoreLaunch {
parent_conditions: launch_conditions,
datastore,
launcher_memos_written,
..
} = mint_datastore_launch_with_kind(
&mut ctx,
kind,
Launcher::new(parent_coin.coin_id(), 1),
root_hash,
label,
description,
size_proof,
program_hash,
size_bucket,
owner_puzzle_hash,
delegated_puzzles,
)?;
debug_assert!(
launcher_memos_written,
"a direct launch emits the launcher CREATE_COIN itself, so the memo rewrite must have fired"
);
let reserved = fee
.checked_add(1)
.ok_or_else(|| MerkleError::Chain("fee overflow: fee + 1 exceeds u64::MAX".into()))?;
let owner_conditions = if parent_coin.amount > reserved {
let change_hint = ctx.hint(owner_puzzle_hash)?;
launch_conditions.create_coin(
owner_puzzle_hash,
parent_coin.amount - reserved,
change_hint,
)
} else {
launch_conditions
};
let owner_spend = inner_spend(&mut ctx, owner, owner_conditions)?;
ctx.spend(parent_coin, owner_spend)?;
Ok(MerkleCoinSpend::new(
drain_coin_spends(&mut ctx),
Some(datastore),
))
}
#[derive(Debug, Clone)]
#[must_use]
#[non_exhaustive]
pub struct DatastoreLaunch {
pub parent_conditions: Conditions,
pub datastore: Datastore<DigDataStoreMetadata>,
pub launcher_memos_written: bool,
}
#[allow(clippy::too_many_arguments)]
pub fn mint_datastore_launch_with_kind(
ctx: &mut SpendContext,
kind: StoreKind,
launcher: Launcher,
root_hash: Bytes32,
label: Option<String>,
description: Option<String>,
size_proof: Option<String>,
program_hash: Option<Bytes32>,
size_bucket: Option<SizeBucket>,
owner_puzzle_hash: Bytes32,
delegated_puzzles: Vec<DelegatedPuzzle>,
) -> MerkleResult<DatastoreLaunch> {
let launcher_coin = launcher.coin();
assert_launcher_is_legal(launcher_coin, launcher.singleton_amount())?;
let (launch_conditions, datastore) = launcher.mint_datastore(
ctx,
DigDataStoreMetadata {
root_hash,
label,
description,
size_proof,
program_hash,
size_bucket,
},
owner_puzzle_hash.into(),
delegated_puzzles,
)?;
assert_minted_singleton_amount_is_odd(datastore.coin.amount)?;
let (parent_conditions, launcher_memos_written) = override_launcher_hint(
ctx,
launch_conditions,
launcher_coin,
owner_puzzle_hash,
kind,
)?;
#[cfg(test)]
let parent_conditions = tests::drop_launcher_if_armed(parent_conditions);
assert_launch_reaches_the_launcher(ctx, &parent_conditions, launcher_coin)?;
Ok(DatastoreLaunch {
parent_conditions,
datastore,
launcher_memos_written,
})
}
fn assert_launcher_is_legal(launcher_coin: Coin, singleton_amount: u64) -> MerkleResult<()> {
if launcher_coin.puzzle_hash != SINGLETON_LAUNCHER_PUZZLE_HASH {
return Err(MerkleError::Chain(format!(
"the supplied launcher coin's puzzle hash is {}, not the singleton launcher puzzle — it \
would not mint a store singleton",
launcher_coin.puzzle_hash
)));
}
assert_minted_singleton_amount_is_odd(singleton_amount)
}
fn assert_minted_singleton_amount_is_odd(singleton_amount: u64) -> MerkleResult<()> {
if singleton_amount % 2 == 0 {
return Err(MerkleError::Chain(format!(
"the store singleton's amount would be {singleton_amount}, which is even — a singleton's \
amount must be odd, and an even-amount launch is accepted on chain but produces a store \
that can never be updated or melted"
)));
}
Ok(())
}
fn assert_launch_reaches_the_launcher(
ctx: &mut SpendContext,
conditions: &Conditions,
launcher_coin: Coin,
) -> MerkleResult<()> {
if creates_coin(conditions, launcher_coin.puzzle_hash, launcher_coin.amount) {
return Ok(());
}
let intermediate = staged_spend_of(ctx, launcher_coin.parent_coin_info).ok_or_else(|| {
MerkleError::Chain(
"launch conditions do not create the launcher coin, and no staged spend creates it \
either — the supplied Launcher is unreachable from this parent"
.into(),
)
})?;
if !creates_coin(
conditions,
intermediate.coin.puzzle_hash,
intermediate.coin.amount,
) {
return Err(MerkleError::Chain(
"launch conditions do not create the intermediate coin the launcher descends from"
.into(),
));
}
if !spend_creates(ctx, &intermediate, launcher_coin)? {
return Err(MerkleError::Chain(
"the intermediate coin's spend does not create the launcher coin".into(),
));
}
Ok(())
}
fn creates_coin(conditions: &Conditions, puzzle_hash: Bytes32, amount: u64) -> bool {
conditions.iter().any(|condition| {
matches!(condition, Condition::CreateCoin(cc)
if cc.puzzle_hash == puzzle_hash && cc.amount == amount)
})
}
fn staged_spend_of(ctx: &SpendContext, coin_id: Bytes32) -> Option<MerkleCoinSpendItem> {
ctx.iter()
.find(|spend| spend.coin.coin_id() == coin_id)
.cloned()
}
type MerkleCoinSpendItem = crate::types::CoinSpend;
fn spend_creates(
ctx: &mut SpendContext,
spend: &MerkleCoinSpendItem,
expected: Coin,
) -> MerkleResult<bool> {
let puzzle = ctx.alloc(&spend.puzzle_reveal)?;
let solution = ctx.alloc(&spend.solution)?;
let output = ctx.run(puzzle, solution)?;
let conditions = Vec::<Condition>::from_clvm(&**ctx, output)
.map_err(|error| MerkleError::Parse(format!("intermediate spend conditions: {error}")))?;
let parent_id = spend.coin.coin_id();
Ok(conditions.into_iter().any(|condition| {
matches!(condition, Condition::CreateCoin(cc)
if Coin::new(parent_id, cc.puzzle_hash, cc.amount).coin_id() == expected.coin_id())
}))
}
fn override_launcher_hint(
ctx: &mut SpendContext,
conditions: Conditions,
launcher_coin: Coin,
owner_puzzle_hash: Bytes32,
kind: StoreKind,
) -> MerkleResult<(Conditions, bool)> {
let is_this_launcher = |create_coin: &CreateCoin<_>| {
Coin::new(
launcher_coin.parent_coin_info,
create_coin.puzzle_hash,
create_coin.amount,
)
.coin_id()
== launcher_coin.coin_id()
};
let mut memos_written = false;
let mut rewritten = Conditions::new();
for condition in conditions {
match condition {
Condition::CreateCoin(create_coin) if is_this_launcher(&create_coin) => {
let memos = ctx.memos(&[
digstore_owner_hint(owner_puzzle_hash),
launcher_hint_for(kind),
])?;
rewritten = rewritten.with(Condition::CreateCoin(CreateCoin {
puzzle_hash: create_coin.puzzle_hash,
amount: create_coin.amount,
memos,
}));
memos_written = true;
}
other => rewritten = rewritten.with(other),
}
}
Ok((rewritten, memos_written))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::required_signatures;
use crate::types::Datastore;
use chia_puzzle_types::standard::StandardArgs;
use chia_puzzle_types::Memos;
use chia_wallet_sdk::driver::SpendContext;
use chia_wallet_sdk::prelude::{NodePtr, MAINNET_CONSTANTS};
use chia_wallet_sdk::signer::{AggSigConstants, RequiredSignature};
use chia_wallet_sdk::test::Simulator;
use clvm_traits::{FromClvm, ToClvm};
fn seeded_owner() -> (chia_wallet_sdk::prelude::PublicKey, Bytes32) {
let mut sim = Simulator::new();
let owner = sim.bls(0);
let owner_ph: Bytes32 = StandardArgs::curry_tree_hash(owner.pk).into();
(owner.pk, owner_ph)
}
fn conditions_of(spend: &crate::types::CoinSpend) -> Vec<Condition> {
let mut ctx = SpendContext::new();
let puzzle = ctx.alloc(&spend.puzzle_reveal).expect("alloc puzzle");
let solution = ctx.alloc(&spend.solution).expect("alloc solution");
let output = ctx.run(puzzle, solution).expect("run puzzle");
Vec::<Condition>::from_clvm(&*ctx, output).expect("parse conditions")
}
fn launcher_memos(coin_spends: &[crate::types::CoinSpend]) -> Vec<Bytes32> {
for spend in coin_spends {
let mut ctx = SpendContext::new();
let puzzle = ctx.alloc(&spend.puzzle_reveal).expect("alloc puzzle");
let solution = ctx.alloc(&spend.solution).expect("alloc solution");
let output = ctx.run(puzzle, solution).expect("run puzzle");
let conditions = Vec::<Condition>::from_clvm(&*ctx, output).expect("parse conditions");
for condition in conditions {
if let Condition::CreateCoin(cc) = condition {
if cc.puzzle_hash == SINGLETON_LAUNCHER_PUZZLE_HASH {
let Memos::Some(ptr) = cc.memos else {
panic!("launcher CREATE_COIN must carry memos");
};
return Vec::<Bytes32>::from_clvm(&*ctx, ptr)
.expect("parse launcher memos");
}
}
}
}
panic!("no launcher CREATE_COIN found");
}
#[test]
fn launcher_carries_the_two_memo_owner_discovery_hint() {
let (owner_pk, owner_ph) = seeded_owner();
let parent = Coin::new(Bytes32::new([0x33; 32]), owner_ph, 1_000_000);
let root = Bytes32::new([0xab; 32]);
let spend = mint_datastore(
parent,
Owner::Standard(owner_pk),
root,
None,
None,
None,
None,
None,
owner_ph,
vec![],
1_000,
)
.expect("mint builds");
let memos = launcher_memos(&spend.coin_spends);
assert_eq!(
memos,
vec![
digstore_owner_hint(owner_ph),
launcher_hint_for(StoreKind::File)
],
"launcher memos must be [owner_hint, launcher_hint] byte-for-byte"
);
}
#[test]
fn did_profile_mint_carries_the_profile_discriminator() {
use crate::hint::DID_PROFILE_LAUNCHER_HINT;
use crate::mint::mint_datastore_with_kind;
let (owner_pk, owner_ph) = seeded_owner();
let parent = Coin::new(Bytes32::new([0x55; 32]), owner_ph, 1_000_000);
let spend = mint_datastore_with_kind(
StoreKind::DidProfile,
parent,
Owner::Standard(owner_pk),
Bytes32::new([0xab; 32]),
None,
None,
None,
None,
None,
owner_ph,
vec![],
1_000,
)
.expect("did-profile mint builds");
let memos = launcher_memos(&spend.coin_spends);
assert_eq!(
memos,
vec![digstore_owner_hint(owner_ph), DID_PROFILE_LAUNCHER_HINT],
"a DidProfile mint carries the profile discriminator as memo[1]"
);
}
#[test]
fn metadata_clvm_encodes_root_as_first_atom() {
let mut ctx = SpendContext::new();
let root = Bytes32::new([0xcd; 32]);
let metadata = DigDataStoreMetadata {
root_hash: root,
label: Some("site".into()),
description: Some("desc".into()),
size_proof: None,
program_hash: None,
size_bucket: None,
};
let node = metadata.to_clvm(&mut *ctx).expect("encode metadata");
let (car, _rest) = <(Bytes32, NodePtr)>::from_clvm(&*ctx, node)
.expect("metadata is a pair with a Bytes32 car");
assert_eq!(car, root, "root_hash must be the first metadata atom");
}
#[test]
fn mint_validates_and_hydrates_on_simulator() -> anyhow::Result<()> {
let mut sim = Simulator::new();
let owner = sim.bls(1_000_000);
let owner_ph: Bytes32 = StandardArgs::curry_tree_hash(owner.pk).into();
let root = Bytes32::new([0x5a; 32]);
let built = mint_datastore(
owner.coin,
Owner::Standard(owner.pk),
root,
Some("site".into()),
None,
None,
None,
None,
owner_ph,
vec![],
0,
)?;
let datastore = built.child.clone().expect("mint yields a child datastore");
sim.spend_coins(built.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
let mut ctx = SpendContext::new();
let launcher_spend = built
.coin_spends
.iter()
.find(|s| s.coin.coin_id() == datastore.info.launcher_id)
.expect("launcher-coin spend present");
let hydrated =
Datastore::<DigDataStoreMetadata>::from_spend(&mut ctx, launcher_spend, &[])?
.expect("launcher spend hydrates a datastore");
assert_eq!(hydrated.info.metadata.root_hash, root);
assert_eq!(hydrated.info.owner_puzzle_hash, owner_ph);
assert_eq!(hydrated.info.launcher_id, datastore.info.launcher_id);
assert!(hydrated.info.delegated_puzzles.is_empty());
Ok(())
}
#[test]
fn mint_requires_a_single_agg_sig_me_for_the_owner() {
let (owner_pk, owner_ph) = seeded_owner();
let parent = Coin::new(Bytes32::new([0x77; 32]), owner_ph, 500_000);
let built = mint_datastore(
parent,
Owner::Standard(owner_pk),
Bytes32::new([0x01; 32]),
None,
None,
None,
None,
None,
owner_ph,
vec![],
1_000,
)
.expect("mint builds");
let constants = AggSigConstants::from(&*MAINNET_CONSTANTS);
let required =
required_signatures(&built.coin_spends, &constants).expect("signatures compute");
assert_eq!(required.len(), 1, "one AGG_SIG_ME expected");
match &required[0] {
RequiredSignature::Bls(bls) => assert_eq!(bls.public_key, owner_pk),
RequiredSignature::Secp(_) => panic!("standard owner uses a BLS key"),
}
}
#[test]
fn mint_without_change_omits_the_change_coin() {
let (owner_pk, owner_ph) = seeded_owner();
let parent = Coin::new(Bytes32::new([0x99; 32]), owner_ph, 1);
let built = mint_datastore(
parent,
Owner::Standard(owner_pk),
Bytes32::new([0x02; 32]),
None,
None,
None,
None,
None,
owner_ph,
vec![],
0,
)
.expect("mint builds with no change");
let parent_spend = built
.coin_spends
.iter()
.find(|s| s.coin.coin_id() == parent.coin_id())
.expect("parent spend present");
let create_coins: Vec<_> = conditions_of(parent_spend)
.into_iter()
.filter(|c| matches!(c, Condition::CreateCoin(_)))
.collect();
assert_eq!(
create_coins.len(),
1,
"only the launcher CREATE_COIN, no change"
);
}
#[allow(clippy::too_many_arguments)]
fn reference_sdk_mint(
parent_coin: Coin,
owner_pk: chia_wallet_sdk::prelude::PublicKey,
root: Bytes32,
label: Option<String>,
description: Option<String>,
size_proof: Option<String>,
owner_puzzle_hash: Bytes32,
fee: u64,
) -> Vec<crate::types::CoinSpend> {
use chia_wallet_sdk::driver::DatastoreMetadata;
let mut ctx = SpendContext::new();
let reference_launcher = Launcher::new(parent_coin.coin_id(), 1);
let reference_launcher_coin = reference_launcher.coin();
let (launch_conditions, _datastore) = reference_launcher
.mint_datastore(
&mut ctx,
DatastoreMetadata {
root_hash: root,
label,
description,
bytes: None,
size_proof,
},
owner_puzzle_hash.into(),
vec![],
)
.expect("reference mint builds");
let (launch_conditions, memos_written) = override_launcher_hint(
&mut ctx,
launch_conditions,
reference_launcher_coin,
owner_puzzle_hash,
StoreKind::File,
)
.expect("reference hint override");
assert!(
memos_written,
"the reference bundle launches directly, so the memos must have been written"
);
let reserved = fee + 1;
let owner_conditions = if parent_coin.amount > reserved {
let change_hint = ctx.hint(owner_puzzle_hash).expect("hint");
launch_conditions.create_coin(
owner_puzzle_hash,
parent_coin.amount - reserved,
change_hint,
)
} else {
launch_conditions
};
let owner_spend =
crate::context::inner_spend(&mut ctx, Owner::Standard(owner_pk), owner_conditions)
.expect("reference owner spend");
ctx.spend(parent_coin, owner_spend)
.expect("reference parent spend");
crate::context::drain_coin_spends(&mut ctx)
}
#[test]
fn mint_none_program_hash_is_byte_identical() {
let (owner_pk, owner_ph) = seeded_owner();
let parent = Coin::new(Bytes32::new([0x44; 32]), owner_ph, 1_000_000);
let root = Bytes32::new([0xba; 32]);
let dig = mint_datastore(
parent,
Owner::Standard(owner_pk),
root,
Some("store".into()),
None,
None,
None,
None,
owner_ph,
vec![],
1_000,
)
.expect("dig mint builds");
let reference = reference_sdk_mint(
parent,
owner_pk,
root,
Some("store".into()),
None,
None,
owner_ph,
1_000,
);
assert_eq!(
dig.coin_spends, reference,
"a None-extras mint must be byte-identical to an SDK-metadata mint"
);
}
#[test]
fn mint_with_program_hash_hydrates() -> anyhow::Result<()> {
let mut sim = Simulator::new();
let owner = sim.bls(1_000_000);
let owner_ph: Bytes32 = StandardArgs::curry_tree_hash(owner.pk).into();
let root = Bytes32::new([0x5b; 32]);
let program_hash = Bytes32::new([0xcc; 32]);
let built = mint_datastore(
owner.coin,
Owner::Standard(owner.pk),
root,
None,
None,
None,
Some(program_hash),
None,
owner_ph,
vec![],
0,
)?;
let datastore = built.child.clone().expect("mint yields a child datastore");
sim.spend_coins(built.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
let mut ctx = SpendContext::new();
let launcher_spend = built
.coin_spends
.iter()
.find(|s| s.coin.coin_id() == datastore.info.launcher_id)
.expect("launcher-coin spend present");
let hydrated =
Datastore::<DigDataStoreMetadata>::from_spend(&mut ctx, launcher_spend, &[])?
.expect("launcher spend hydrates a datastore");
assert_eq!(hydrated.info.metadata.root_hash, root);
assert_eq!(
hydrated.info.metadata.program_hash,
Some(program_hash),
"the program_hash survives the on-chain roundtrip"
);
Ok(())
}
#[test]
fn mint_with_size_bucket_hydrates() -> anyhow::Result<()> {
let mut sim = Simulator::new();
let owner = sim.bls(1_000_000);
let owner_ph: Bytes32 = StandardArgs::curry_tree_hash(owner.pk).into();
let root = Bytes32::new([0x5c; 32]);
let size_bucket = SizeBucket::from_exponent(6).expect("valid bucket");
let built = mint_datastore(
owner.coin,
Owner::Standard(owner.pk),
root,
None,
None,
None,
None,
Some(size_bucket),
owner_ph,
vec![],
0,
)?;
let datastore = built.child.clone().expect("mint yields a child datastore");
sim.spend_coins(built.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
let mut ctx = SpendContext::new();
let launcher_spend = built
.coin_spends
.iter()
.find(|s| s.coin.coin_id() == datastore.info.launcher_id)
.expect("launcher-coin spend present");
let hydrated =
Datastore::<DigDataStoreMetadata>::from_spend(&mut ctx, launcher_spend, &[])?
.expect("launcher spend hydrates a datastore");
assert_eq!(hydrated.info.metadata.root_hash, root);
assert_eq!(
hydrated.info.metadata.size_bucket,
Some(size_bucket),
"the size bucket survives the on-chain roundtrip"
);
Ok(())
}
thread_local! {
static DROP_LAUNCHER: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
pub(super) fn drop_launcher_if_armed(conditions: Conditions) -> Conditions {
if !DROP_LAUNCHER.with(std::cell::Cell::get) {
return conditions;
}
let mut kept = Conditions::new();
for condition in conditions {
let is_launcher = matches!(
&condition,
Condition::CreateCoin(cc) if cc.puzzle_hash == SINGLETON_LAUNCHER_PUZZLE_HASH
);
if !is_launcher {
kept = kept.with(condition);
}
}
kept
}
#[test]
fn a_custom_owner_mint_does_not_silently_omit_the_launcher() {
use chia_wallet_sdk::driver::{SpendWithConditions, StandardLayer};
let mut ctx = SpendContext::new();
let (owner_pk, owner_ph) = seeded_owner();
let prebuilt = StandardLayer::new(owner_pk)
.spend_with_conditions(&mut ctx, Conditions::new())
.expect("a caller-built inner spend");
let parent = Coin::new(Bytes32::new([0x21; 32]), owner_ph, 1_000_000);
let result = mint_datastore_with_kind(
StoreKind::File,
parent,
Owner::Custom(prebuilt),
Bytes32::new([0xab; 32]),
None,
None,
None,
None,
None,
owner_ph,
vec![],
1_000,
);
assert!(
matches!(result, Err(MerkleError::UnsupportedOwner(_))),
"a custom-owner mint must refuse, not return an unlaunchable bundle"
);
}
#[test]
fn a_launch_without_the_launcher_coin_fails_closed() {
let (_owner_pk, owner_ph) = seeded_owner();
let mut ctx = SpendContext::new();
DROP_LAUNCHER.with(|armed| armed.set(true));
let result = mint_datastore_launch_with_kind(
&mut ctx,
StoreKind::File,
Launcher::new(Bytes32::new([0x21; 32]), 1),
Bytes32::new([0xab; 32]),
None,
None,
None,
None,
None,
owner_ph,
vec![],
);
DROP_LAUNCHER.with(|armed| armed.set(false));
match result {
Err(MerkleError::Chain(message)) => assert!(
message.contains("do not create the launcher coin")
&& message.contains("no staged spend creates it"),
"the guard must name what it caught, got: {message}"
),
other => panic!("expected a fail-closed Chain error, got {other:?}"),
}
}
#[test]
fn an_ordinary_launch_carries_the_launcher_coin() {
let (_owner_pk, owner_ph) = seeded_owner();
let mut ctx = SpendContext::new();
let launch = mint_datastore_launch_with_kind(
&mut ctx,
StoreKind::File,
Launcher::new(Bytes32::new([0x21; 32]), 1),
Bytes32::new([0xab; 32]),
None,
None,
None,
None,
None,
owner_ph,
vec![],
)
.expect("an ordinary launch builds");
assert!(
launch.parent_conditions.iter().any(|condition| {
matches!(condition, Condition::CreateCoin(cc) if cc.puzzle_hash == SINGLETON_LAUNCHER_PUZZLE_HASH)
}),
"the launch conditions create the launcher coin"
);
}
#[test]
fn the_composed_launch_is_byte_identical_to_the_wrapper() {
let (owner_pk, owner_ph) = seeded_owner();
let parent = Coin::new(Bytes32::new([0x66; 32]), owner_ph, 1_000_000);
let root = Bytes32::new([0xab; 32]);
let fee = 1_000_u64;
let wrapper = mint_datastore_with_kind(
StoreKind::File,
parent,
Owner::Standard(owner_pk),
root,
Some("store".into()),
None,
None,
None,
None,
owner_ph,
vec![],
fee,
)
.expect("wrapper mint builds");
let mut ctx = SpendContext::new();
let launch = mint_datastore_launch_with_kind(
&mut ctx,
StoreKind::File,
Launcher::new(parent.coin_id(), 1),
root,
Some("store".into()),
None,
None,
None,
None,
owner_ph,
vec![],
)
.expect("composed launch builds");
let change_hint = ctx.hint(owner_ph).expect("change hint");
let owner_conditions =
launch
.parent_conditions
.create_coin(owner_ph, parent.amount - (fee + 1), change_hint);
let owner_spend = inner_spend(&mut ctx, Owner::Standard(owner_pk), owner_conditions)
.expect("owner spend");
ctx.spend(parent, owner_spend).expect("parent spend");
let composed = drain_coin_spends(&mut ctx);
assert_eq!(
wrapper.coin_spends, composed,
"the wrapper is the composed launch plus a standard parent spend, byte for byte"
);
assert_eq!(
wrapper.child.expect("wrapper yields a datastore").info,
launch.datastore.info,
"both paths describe the same eve Datastore"
);
}
fn launch_from_singleton(
ctx: &mut SpendContext,
launcher: Launcher,
owner_ph: Bytes32,
spend_parent: impl FnOnce(&mut SpendContext, Conditions) -> anyhow::Result<()>,
) -> anyhow::Result<DatastoreLaunch> {
let launch = mint_datastore_launch_with_kind(
ctx,
StoreKind::DidProfile,
launcher,
Bytes32::new([0x6d; 32]),
Some("profile".into()),
None,
None,
None,
None,
owner_ph,
vec![],
)?;
spend_parent(ctx, launch.parent_conditions.clone())?;
Ok(launch)
}
#[test]
fn a_singleton_parent_launch_via_an_intermediate_validates_on_chain() -> anyhow::Result<()> {
use chia_wallet_sdk::driver::{IntermediateLauncher, StandardLayer};
let mut sim = Simulator::new();
let ctx = &mut SpendContext::new();
let alice = sim.bls(1_000_000);
let alice_p2 = StandardLayer::new(alice.pk);
let owner_ph: Bytes32 = StandardArgs::curry_tree_hash(alice.pk).into();
let (create_did, did) =
Launcher::new(alice.coin.coin_id(), 1).create_simple_did(ctx, &alice_p2)?;
alice_p2.spend(ctx, alice.coin, create_did)?;
sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?;
let did_coin_id = did.coin.coin_id();
let intermediate = IntermediateLauncher::new(did_coin_id, 0, 1);
let intermediate_coin = intermediate.intermediate_coin();
let launcher = intermediate.create(ctx)?;
let launch = launch_from_singleton(ctx, launcher, owner_ph, |ctx, conditions| {
let _child = did.update(ctx, &alice_p2, conditions)?;
Ok(())
})?;
let funder = sim.bls(1);
StandardLayer::new(funder.pk).spend(ctx, funder.coin, Conditions::new())?;
let coin_spends = ctx.take();
sim.spend_coins(coin_spends.clone(), &[alice.sk.clone(), funder.sk.clone()])?;
let store_id = launch.datastore.info.launcher_id;
let launcher_spend = coin_spends
.iter()
.find(|spend| spend.coin.coin_id() == store_id)
.expect("the launcher coin was spent");
let hydrated = Datastore::<DigDataStoreMetadata>::from_spend(
&mut SpendContext::new(),
launcher_spend,
&[],
)?
.expect("launcher spend hydrates a datastore");
assert_eq!(hydrated.info.metadata.root_hash, Bytes32::new([0x6d; 32]));
assert_eq!(hydrated.info.launcher_id, store_id);
assert_eq!(
launcher_spend.coin.parent_coin_info,
intermediate_coin.coin_id(),
"the launcher's parent is the intermediate coin, not the singleton itself"
);
assert_eq!(
intermediate_coin.amount, 0,
"the coin the singleton emits is EVEN — the reason this composition is legal"
);
assert_ne!(
launcher_spend.coin.parent_coin_info, did_coin_id,
"the intermediate coin sits between the singleton and the launcher"
);
Ok(())
}
fn launch_with(ctx: &mut SpendContext, launcher: Launcher) -> MerkleResult<DatastoreLaunch> {
let (_owner_pk, owner_ph) = seeded_owner();
mint_datastore_launch_with_kind(
ctx,
StoreKind::File,
launcher,
Bytes32::new([0x6d; 32]),
None,
None,
None,
None,
None,
owner_ph,
vec![],
)
}
#[test]
fn an_even_amount_launcher_is_refused() {
let parent_id = Bytes32::new([0x71; 32]);
for amount in [0, 2] {
let result = launch_with(&mut SpendContext::new(), Launcher::new(parent_id, amount));
assert!(
matches!(result, Err(MerkleError::Chain(ref message)) if message.contains("even")),
"an amount-{amount} launcher must be refused as an even singleton amount, got: \
{result:?}"
);
}
}
#[test]
fn an_even_singleton_amount_is_refused_even_from_an_odd_launcher() {
let parent_id = Bytes32::new([0x71; 32]);
let launcher = Launcher::new(parent_id, 1).with_singleton_amount(2);
assert_eq!(
launcher.coin().amount,
1,
"the launcher coin itself looks legal"
);
let result = launch_with(&mut SpendContext::new(), launcher);
assert!(
matches!(result, Err(MerkleError::Chain(ref message)) if message.contains("singleton")),
"an even SINGLETON amount must be refused however legal the launcher coin looks, got: \
{result:?}"
);
}
#[test]
fn a_zero_amount_launcher_minting_an_odd_singleton_is_accepted() {
let parent_id = Bytes32::new([0x71; 32]);
let launcher = Launcher::new(parent_id, 0).with_singleton_amount(1);
let result = launch_with(&mut SpendContext::new(), launcher);
assert!(
result.is_ok(),
"a 0-amount launcher minting a 1-mojo singleton is the SDK's documented composition and \
must be accepted, got: {result:?}"
);
}
#[test]
fn the_memo_rewrite_leaves_a_foreign_launcher_alone() -> anyhow::Result<()> {
let (_owner_pk, owner_ph) = seeded_owner();
let ctx = &mut SpendContext::new();
let parent_id = Bytes32::new([0x71; 32]);
let foreign_owner_ph = Bytes32::new([0x0f; 32]);
let foreign_memos = ctx.memos(&[digstore_owner_hint(foreign_owner_ph)])?;
let ours = Launcher::new(parent_id, 1);
let ours_coin = ours.coin();
let foreign_coin = Coin::new(parent_id, ours_coin.puzzle_hash, 3);
assert_ne!(
foreign_coin.coin_id(),
ours_coin.coin_id(),
"the two launchers are different coins at the same puzzle hash"
);
let launch = mint_datastore_launch_with_kind(
ctx,
StoreKind::DidProfile,
Launcher::from_coin(
ours_coin,
Conditions::new()
.create_coin(ours_coin.puzzle_hash, ours_coin.amount, Memos::None)
.create_coin(foreign_coin.puzzle_hash, foreign_coin.amount, foreign_memos),
),
Bytes32::new([0x6d; 32]),
None,
None,
None,
None,
None,
owner_ph,
vec![],
)?;
let memos_of = |amount: u64| -> Option<Vec<Bytes32>> {
launch
.parent_conditions
.iter()
.find_map(|condition| match condition {
Condition::CreateCoin(cc)
if cc.puzzle_hash == SINGLETON_LAUNCHER_PUZZLE_HASH
&& cc.amount == amount =>
{
match cc.memos {
Memos::Some(ptr) => {
Some(Vec::<Bytes32>::from_clvm(&**ctx, ptr).expect("parse memos"))
}
Memos::None => None,
}
}
_ => None,
})
};
assert_eq!(
memos_of(ours_coin.amount),
Some(vec![
digstore_owner_hint(owner_ph),
launcher_hint_for(StoreKind::DidProfile)
]),
"our own launcher is stamped with our owner hint and kind"
);
assert_eq!(
memos_of(foreign_coin.amount),
Some(vec![digstore_owner_hint(foreign_owner_ph)]),
"the foreign launcher passes through with exactly the memos it arrived with"
);
Ok(())
}
#[test]
fn a_refused_launch_stages_nothing_into_the_callers_context() {
let parent_id = Bytes32::new([0x71; 32]);
let ctx = &mut SpendContext::new();
let refused = launch_with(ctx, Launcher::new(parent_id, 1).with_singleton_amount(2));
assert!(refused.is_err(), "the even-amount launch must be refused");
assert_eq!(
ctx.iter().count(),
0,
"a refused launch must leave the caller's context untouched — a stranded launcher spend \
would be drained with the caller's other launches and orphan the whole bundle"
);
let _accepted = launch_with(ctx, Launcher::new(parent_id, 1))
.expect("an odd-amount launch is accepted");
assert!(
ctx.iter().count() > 0,
"an accepted launch stages the launcher and eve-Datastore spends (control)"
);
}
#[test]
fn an_even_amount_launch_is_accepted_on_chain_and_freezes_the_store() -> anyhow::Result<()> {
use chia_wallet_sdk::driver::StandardLayer;
let mut sim = Simulator::new();
let ctx = &mut SpendContext::new();
let owner = sim.bls(1_000_000);
let owner_ph: Bytes32 = StandardArgs::curry_tree_hash(owner.pk).into();
let (launch_conditions, datastore) = Launcher::new(owner.coin.coin_id(), 2)
.mint_datastore(
ctx,
DigDataStoreMetadata {
root_hash: Bytes32::new([0x5a; 32]),
..Default::default()
},
owner_ph.into(),
vec![],
)?;
StandardLayer::new(owner.pk).spend(ctx, owner.coin, launch_conditions)?;
sim.spend_coins(ctx.take(), std::slice::from_ref(&owner.sk))
.expect("an even-amount launch is ACCEPTED on chain — nothing refuses it at mint time");
let update = crate::update_root(
&datastore,
Owner::Standard(owner.pk),
DigDataStoreMetadata {
root_hash: Bytes32::new([0x77; 32]),
..Default::default()
},
)?;
let result = sim.spend_coins(update.coin_spends, std::slice::from_ref(&owner.sk));
assert!(
result.is_err(),
"an even-amount store must be unspendable — every later update raises on the singleton \
odd-amount rule, so the store is permanently frozen, but got: {result:?}"
);
Ok(())
}
#[test]
fn an_odd_amount_launcher_is_accepted() {
let parent_id = Bytes32::new([0x71; 32]);
assert!(launch_with(&mut SpendContext::new(), Launcher::new(parent_id, 1)).is_ok());
assert!(launch_with(&mut SpendContext::new(), Launcher::new(parent_id, 3)).is_ok());
}
#[test]
fn a_launcher_coin_at_a_foreign_puzzle_hash_is_refused() {
let parent_id = Bytes32::new([0x71; 32]);
let foreign = Coin::new(parent_id, Bytes32::new([0x42; 32]), 1);
let result = launch_with(
&mut SpendContext::new(),
Launcher::from_coin(foreign, Conditions::new()),
);
assert!(
matches!(result, Err(MerkleError::Chain(ref message)) if message.contains("puzzle hash")),
"a launcher at a foreign puzzle hash must be refused, got: {result:?}"
);
}
fn launcher_memos_if_any(coin_spends: &[crate::types::CoinSpend]) -> Option<Vec<Bytes32>> {
for spend in coin_spends {
let mut ctx = SpendContext::new();
let puzzle = ctx.alloc(&spend.puzzle_reveal).expect("alloc puzzle");
let solution = ctx.alloc(&spend.solution).expect("alloc solution");
let output = ctx.run(puzzle, solution).expect("run puzzle");
let conditions = Vec::<Condition>::from_clvm(&*ctx, output).expect("parse conditions");
for condition in conditions {
if let Condition::CreateCoin(cc) = condition {
if cc.puzzle_hash == SINGLETON_LAUNCHER_PUZZLE_HASH {
return match cc.memos {
Memos::Some(ptr) => Some(
Vec::<Bytes32>::from_clvm(&*ctx, ptr)
.expect("parse launcher memos"),
),
Memos::None => None,
};
}
}
}
}
panic!("no launcher CREATE_COIN found");
}
#[test]
fn the_intermediate_shape_writes_no_launcher_memos_and_reports_it() -> anyhow::Result<()> {
use chia_wallet_sdk::driver::IntermediateLauncher;
let (_owner_pk, owner_ph) = seeded_owner();
let parent_id = Bytes32::new([0x71; 32]);
let ctx = &mut SpendContext::new();
let launcher = IntermediateLauncher::new(parent_id, 0, 1).create(ctx)?;
let intermediate_launch = mint_datastore_launch_with_kind(
ctx,
StoreKind::DidProfile,
launcher,
Bytes32::new([0x6d; 32]),
None,
None,
None,
None,
None,
owner_ph,
vec![],
)?;
assert!(
!intermediate_launch.launcher_memos_written,
"the intermediate path cannot write the launcher memos, and must not claim it did"
);
assert_eq!(
launcher_memos_if_any(&ctx.take()),
None,
"the launcher CREATE_COIN emitted by the fixed intermediate puzzle carries no memos, so \
neither the owner hint nor the StoreKind discriminator reaches the chain"
);
let ctx = &mut SpendContext::new();
let direct_launch = mint_datastore_launch_with_kind(
ctx,
StoreKind::DidProfile,
Launcher::new(parent_id, 1),
Bytes32::new([0x6d; 32]),
None,
None,
None,
None,
None,
owner_ph,
vec![],
)?;
assert!(
direct_launch.launcher_memos_written,
"the direct shape emits the launcher CREATE_COIN itself, so the rewrite must fire"
);
let memos = direct_launch
.parent_conditions
.iter()
.find_map(|condition| match condition {
Condition::CreateCoin(cc) if cc.puzzle_hash == SINGLETON_LAUNCHER_PUZZLE_HASH => {
Some(cc.memos)
}
_ => None,
})
.expect("the direct shape emits the launcher CREATE_COIN itself");
let Memos::Some(ptr) = memos else {
panic!("the direct shape must carry the owner-discovery memos");
};
assert_eq!(
Vec::<Bytes32>::from_clvm(&**ctx, ptr).expect("parse launcher memos"),
vec![
digstore_owner_hint(owner_ph),
launcher_hint_for(StoreKind::DidProfile)
],
"the direct shape writes BOTH the owner hint and the StoreKind discriminator"
);
Ok(())
}
#[test]
fn a_launcher_parented_directly_to_a_singleton_is_rejected_on_chain() -> anyhow::Result<()> {
use chia_wallet_sdk::clvmr::error::EvalErr;
use chia_wallet_sdk::driver::{IntermediateLauncher, StandardLayer};
use chia_wallet_sdk::signer::SignerError;
use chia_wallet_sdk::test::SimulatorError;
let mut sim = Simulator::new();
let ctx = &mut SpendContext::new();
let alice = sim.bls(1_000_000);
let alice_p2 = StandardLayer::new(alice.pk);
let owner_ph: Bytes32 = StandardArgs::curry_tree_hash(alice.pk).into();
let (create_did, did) =
Launcher::new(alice.coin.coin_id(), 1).create_simple_did(ctx, &alice_p2)?;
alice_p2.spend(ctx, alice.coin, create_did)?;
sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?;
let did_coin_id = did.coin.coin_id();
let launcher = Launcher::new(did_coin_id, 1);
let _launch = launch_from_singleton(ctx, launcher, owner_ph, |ctx, conditions| {
let _child = did.update(ctx, &alice_p2, conditions)?;
Ok(())
})?;
let funder = sim.bls(1);
StandardLayer::new(funder.pk).spend(ctx, funder.coin, Conditions::new())?;
let result = sim.spend_coins(ctx.take(), &[alice.sk.clone(), funder.sk.clone()]);
assert!(
matches!(
result,
Err(SimulatorError::Signer(SignerError::Eval(EvalErr::Raise(_))))
),
"a 1-mojo launcher emitted directly by a singleton is a second odd CREATE_COIN and must \
be rejected by a CLVM raise from the singleton puzzle, but got: {result:?}"
);
let mut sim = Simulator::new();
let ctx = &mut SpendContext::new();
let alice = sim.bls(1_000_000);
let alice_p2 = StandardLayer::new(alice.pk);
let owner_ph: Bytes32 = StandardArgs::curry_tree_hash(alice.pk).into();
let (create_did, did) =
Launcher::new(alice.coin.coin_id(), 1).create_simple_did(ctx, &alice_p2)?;
alice_p2.spend(ctx, alice.coin, create_did)?;
sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?;
let launcher = IntermediateLauncher::new(did.coin.coin_id(), 0, 1).create(ctx)?;
let _launch = launch_from_singleton(ctx, launcher, owner_ph, |ctx, conditions| {
let _child = did.update(ctx, &alice_p2, conditions)?;
Ok(())
})?;
let funder = sim.bls(1);
StandardLayer::new(funder.pk).spend(ctx, funder.coin, Conditions::new())?;
sim.spend_coins(ctx.take(), &[alice.sk.clone(), funder.sk.clone()])
.expect("the intermediate shape is the legal one and must be accepted");
Ok(())
}
#[test]
fn mint_fee_overflow_fails_closed() {
let (owner_pk, owner_ph) = seeded_owner();
let parent = Coin::new(Bytes32::new([0xfe; 32]), owner_ph, 1_000_000);
let result = mint_datastore(
parent,
Owner::Standard(owner_pk),
Bytes32::new([0x03; 32]),
None,
None,
None,
None,
None,
owner_ph,
vec![],
u64::MAX,
);
assert!(
matches!(result, Err(MerkleError::Chain(_))),
"fee == u64::MAX must error, not panic or wrap"
);
}
}