dig-merkle 0.9.1

The DIG Network canonical CHIP-0035 DataLayer coin expert crate: a pure, key-free, network-free SpendBundle-builder for the Chia DataLayer singleton that anchors a .dig file's merkle root on-chain. Builds the exact CoinSpends for every DataLayer-coin lifecycle operation and reports the exact signatures a caller must produce — never holds a key, never signs, never broadcasts.
Documentation
//! The DataLayer-coin update builder (SPEC §3.2) — recreate the coin anchoring a new root.
//!
//! [`update_root`] spends an existing DataLayer store and recreates it with new metadata (a new
//! `root_hash`, and any other [`DigDataStoreMetadata`] fields), preserving the store's identity:
//! its `launcher_id`, owner puzzle hash, and delegated-puzzle set carry forward unchanged. The
//! caller supplies the FULL replacement metadata — metadata is replaced wholesale on chain, so an
//! update that means to KEEP an anchored `program_hash` (or size bucket, label, …) MUST re-send it
//! in `new_metadata`; omitting a field DROPS it (SPEC §3.2).
//!
//! Like every operation the returned spend is unsigned (INV-1..4): an [`Owner::Standard`] update
//! requires exactly one `AGG_SIG_ME` over the owner's synthetic key, obtained via
//! [`crate::required_signatures`].

use chia_wallet_sdk::driver::{Datastore, SpendContext};
use chia_wallet_sdk::types::Conditions;

use crate::context::inner_spend;
use crate::metadata::DigDataStoreMetadata;
use crate::types::{MerkleCoinSpend, Owner};
use crate::{MerkleError, MerkleResult};

/// Recreates `store` with `new_metadata`, preserving its `launcher_id`, owner, and delegation set.
///
/// The store's inner puzzle emits two conditions — an NFT metadata update to `new_metadata` and a
/// recreation `CREATE_COIN` back to the same owner puzzle hash carrying the same delegated puzzles —
/// both built by the SDK (INV-4, never hand-rolled). The resulting child [`Datastore`] is hydrated
/// from the freshly-built spend and returned in [`MerkleCoinSpend::child`].
///
/// # Metadata is replaced wholesale
///
/// `new_metadata` becomes the store's ENTIRE new metadata. To preserve an existing `program_hash`,
/// `size_bucket`, label, or description, copy it into `new_metadata` before calling; a field left
/// `None` is dropped from the anchored state (SPEC §3.2).
///
/// # Signing
///
/// The returned spend is UNSIGNED. An [`Owner::Standard`] update requires exactly one `AGG_SIG_ME`
/// over the owner's synthetic key. Obtain the requirement via [`crate::required_signatures`].
///
/// # Errors
///
/// Returns [`MerkleError::UnsupportedOwner`] for [`Owner::Custom`]: the metadata-update and
/// recreation conditions are built inside this call, in a [`SpendContext`] the caller never sees, so
/// a pre-built inner spend cannot contain them. What the caller got back was never an update: an
/// ordinary pre-built spend yields [`MerkleError::Driver`] from child hydration (the recreation it
/// never emitted), and a pre-built spend that emits its OWN recreation returns `Ok` for a bundle that
/// ignored `new_metadata` entirely (#2418).
///
/// (`Owner::Custom` is in practice unusable across this crate's whole public API: a
/// [`chia_wallet_sdk::driver::Spend`] holds CLVM node pointers valid only in the allocator that built
/// them, and no public operation exposes its [`SpendContext`] for the caller to build one in.)
///
/// Returns [`MerkleError::Driver`] if the SDK fails to build the update conditions or the store
/// spend, and [`MerkleError::NotDataStore`] if the freshly-built spend does not hydrate a child
/// store (which would indicate a malformed recreation).
pub fn update_root(
    store: &Datastore<DigDataStoreMetadata>,
    owner: Owner,
    new_metadata: DigDataStoreMetadata,
) -> MerkleResult<MerkleCoinSpend> {
    if matches!(owner, Owner::Custom(_)) {
        return Err(MerkleError::UnsupportedOwner(
            "an update's metadata and recreation conditions are built inside this call, so \
             Owner::Custom cannot emit them — the bundle would ignore new_metadata or melt the store",
        ));
    }

    let mut ctx = SpendContext::new();

    let launcher_id = store.info.launcher_id;
    let owner_puzzle_hash = store.info.owner_puzzle_hash;
    let delegated_puzzles = store.info.delegated_puzzles.clone();
    let hint_delegated_puzzles = !delegated_puzzles.is_empty();

    // The two conditions the inner puzzle emits: update the on-chain metadata, then recreate the
    // singleton back to the same owner with the same delegation set (the byte-source-of-truth SDK
    // helpers, INV-4).
    let new_metadata_condition = Datastore::new_metadata_condition(&mut ctx, new_metadata)?;
    let recreate_condition = Datastore::<DigDataStoreMetadata>::owner_create_coin_condition(
        &mut ctx,
        launcher_id,
        owner_puzzle_hash,
        delegated_puzzles.clone(),
        hint_delegated_puzzles,
    )?;

    let conditions = Conditions::new()
        .with(new_metadata_condition)
        .with(recreate_condition);
    let owner_spend = inner_spend(&mut ctx, owner, conditions)?;

    let store_spend = store.clone().spend(&mut ctx, owner_spend)?;

    // Hydrate the recreated child from the spend we just built, so callers get the post-update store
    // (with the new root/metadata) without re-fetching it from chain.
    let child =
        Datastore::<DigDataStoreMetadata>::from_spend(&mut ctx, &store_spend, &delegated_puzzles)?
            .ok_or(MerkleError::NotDataStore)?;

    Ok(MerkleCoinSpend::new(vec![store_spend], Some(child)))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::mint::mint_datastore;
    use crate::required_signatures;
    use crate::types::{Bytes32, Datastore, DatastoreInfo, DelegatedPuzzle};
    use chia_puzzle_types::standard::StandardArgs;
    use chia_wallet_sdk::driver::{Layer, StandardLayer};
    use chia_wallet_sdk::prelude::{TreeHash, MAINNET_CONSTANTS};
    use chia_wallet_sdk::signer::{AggSigConstants, RequiredSignature};
    use chia_wallet_sdk::test::Simulator;

    /// Mints a store on the simulator and returns its (settled) eve Datastore plus the owner keypair,
    /// so update tests start from a real on-chain store.
    fn minted_store(
        sim: &mut Simulator,
    ) -> anyhow::Result<(
        chia_wallet_sdk::test::BlsPairWithCoin,
        Datastore<DigDataStoreMetadata>,
    )> {
        let owner = sim.bls(1_000_000);
        let owner_ph: Bytes32 = StandardArgs::curry_tree_hash(owner.pk).into();
        let built = mint_datastore(
            owner.coin,
            Owner::Standard(owner.pk),
            Bytes32::new([0x5a; 32]),
            Some("site".into()),
            None,
            None,
            None,
            None,
            owner_ph,
            vec![],
            0,
        )?;
        sim.spend_coins(built.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
        Ok((owner, built.child.expect("mint yields a child")))
    }

    /// mint → update round-trips a NEW root: the child store carries the updated root and preserves
    /// the launcher id and owner, and the update validates on the simulator.
    #[test]
    fn update_round_trips_a_new_root() -> anyhow::Result<()> {
        let mut sim = Simulator::new();
        let (owner, store) = minted_store(&mut sim)?;

        let new_root = Bytes32::new([0x77; 32]);
        let new_metadata = DigDataStoreMetadata {
            root_hash: new_root,
            label: Some("site".into()),
            ..Default::default()
        };

        let built = update_root(&store, Owner::Standard(owner.pk), new_metadata)?;
        let child = built.child.clone().expect("update yields a child");

        assert_eq!(child.info.metadata.root_hash, new_root, "root updated");
        assert_eq!(
            child.info.launcher_id, store.info.launcher_id,
            "launcher id preserved"
        );
        assert_eq!(
            child.info.owner_puzzle_hash, store.info.owner_puzzle_hash,
            "owner preserved"
        );

        sim.spend_coins(built.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
        Ok(())
    }

    /// NON-VACUOUS owner preservation: the RECREATE `CREATE_COIN` actually targets the preserved
    /// owner. `Datastore::from_spend` re-derives `child.info.owner_puzzle_hash` from the PARENT coin
    /// (see the SDK: for an empty delegation set it uses the parent state-layer inner puzzle's tree
    /// hash), so `update_round_trips_a_new_root`'s `owner preserved` assert would STILL pass if the
    /// recreate had targeted an all-zero (or any wrong) owner. This pins the ACTUAL recreated coin's
    /// puzzle hash — `child.coin.puzzle_hash`, which is derived from the emitted CREATE_COIN's
    /// `puzzle_hash` — to the singleton puzzle for the ORIGINAL owner + the updated metadata, computed
    /// independently of `child.info`. A wrong recreate owner changes the emitted CREATE_COIN puzzle
    /// hash and FAILS this assert while leaving the parent-derived `info.owner_puzzle_hash` untouched.
    #[test]
    fn update_recreate_targets_the_original_owner_coin() -> anyhow::Result<()> {
        let mut sim = Simulator::new();
        let (owner, store) = minted_store(&mut sim)?;

        let new_metadata = DigDataStoreMetadata {
            root_hash: Bytes32::new([0x77; 32]),
            label: Some("site".into()),
            ..Default::default()
        };

        let built = update_root(&store, Owner::Standard(owner.pk), new_metadata.clone())?;
        let child = built.child.clone().expect("update yields a child");

        // The expected recreated-coin puzzle hash, built INDEPENDENTLY from the ORIGINAL owner's real
        // standard puzzle (`owner.pk`) + the new metadata — deliberately NOT from `child.info`, whose
        // `owner_puzzle_hash` is parent-derived and thus insensitive to a wrong recreate target. The
        // store is empty-delegated, so the correct recreated coin is the singleton over the NFT state
        // layer over the owner's standard inner puzzle.
        let mut ctx = SpendContext::new();
        let expected_layers = DatastoreInfo::new(
            store.info.launcher_id,
            new_metadata,
            store.info.owner_puzzle_hash,
            vec![],
        )
        .into_layers_without_delegation_layer(StandardLayer::new(owner.pk));
        let expected_puzzle = expected_layers.construct_puzzle(&mut ctx)?;
        let expected_coin_ph: Bytes32 = ctx.tree_hash(expected_puzzle).into();

        assert_eq!(
            child.coin.puzzle_hash, expected_coin_ph,
            "the recreate CREATE_COIN targets the preserved owner (a wrong owner would differ)"
        );

        // And the built spend still validates end to end.
        sim.spend_coins(built.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
        Ok(())
    }

    /// Delegation carry-forward: updating a DELEGATED store preserves its delegated-puzzle set. Every
    /// other update test uses `delegated_puzzles: vec![]`, so none exercises the delegation path; here
    /// the store is minted with a non-empty set (an admin + a writer authority) and the recreated
    /// store MUST keep the same `delegated_puzzles`. Dropping or altering the set on recreate FAILS.
    #[test]
    fn update_preserves_the_delegation_set() -> 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();

        // A non-empty delegation set: an admin and a writer authority (arbitrary inner-puzzle hashes —
        // the owner, not a delegated puzzle, authorizes this update, so the hashes need not be real
        // spendable puzzles for the recreation to validate).
        let delegated_puzzles = vec![
            DelegatedPuzzle::Admin(TreeHash::new([0x11; 32])),
            DelegatedPuzzle::Writer(TreeHash::new([0x22; 32])),
        ];

        let built = mint_datastore(
            owner.coin,
            Owner::Standard(owner.pk),
            Bytes32::new([0x5a; 32]),
            None,
            None,
            None,
            None,
            None,
            owner_ph,
            delegated_puzzles.clone(),
            0,
        )?;
        sim.spend_coins(built.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
        let store = built.child.expect("mint yields a child");
        assert_eq!(
            store.info.delegated_puzzles, delegated_puzzles,
            "the mint anchors the delegation set (test precondition)"
        );

        let updated = update_root(
            &store,
            Owner::Standard(owner.pk),
            DigDataStoreMetadata {
                root_hash: Bytes32::new([0x77; 32]),
                ..Default::default()
            },
        )?;
        let child = updated.child.expect("update yields a child");
        assert_eq!(
            child.info.delegated_puzzles, delegated_puzzles,
            "the delegation set survives the update"
        );

        sim.spend_coins(updated.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
        Ok(())
    }

    /// REGRESSION (#2418): an update MUST refuse [`Owner::Custom`] rather than return a bundle
    /// carrying neither the metadata update nor the recreation.
    ///
    /// `update_root` builds both conditions inside this call, in a [`SpendContext`] the caller never
    /// sees, and `context::inner_spend` drops the conditions for a custom owner — so accepting one
    /// returned `Ok` for a spend that melts the store by omission. That is #2418's signature verbatim,
    /// on a second entry point.
    #[test]
    fn a_custom_owner_update_is_refused() -> anyhow::Result<()> {
        use chia_wallet_sdk::driver::SpendWithConditions;

        let mut sim = Simulator::new();
        let (owner, store) = minted_store(&mut sim)?;

        let mut ctx = SpendContext::new();
        let prebuilt =
            StandardLayer::new(owner.pk).spend_with_conditions(&mut ctx, Conditions::new())?;

        let result = update_root(
            &store,
            Owner::Custom(prebuilt),
            DigDataStoreMetadata {
                root_hash: Bytes32::new([0x77; 32]),
                ..Default::default()
            },
        );

        assert!(
            matches!(result, Err(MerkleError::UnsupportedOwner(_))),
            "a custom-owner update must refuse, not return a bundle that updates nothing, got: \
             {result:?}"
        );
        Ok(())
    }

    /// The unsigned update requires exactly one `AGG_SIG_ME` over the owner's key — the custody
    /// contract for a standard-owner update.
    #[test]
    fn update_requires_a_single_agg_sig_me() -> anyhow::Result<()> {
        let mut sim = Simulator::new();
        let (owner, store) = minted_store(&mut sim)?;

        let built = update_root(
            &store,
            Owner::Standard(owner.pk),
            DigDataStoreMetadata {
                root_hash: Bytes32::new([0x01; 32]),
                ..Default::default()
            },
        )?;

        let constants = AggSigConstants::from(&*MAINNET_CONSTANTS);
        let required = required_signatures(&built.coin_spends, &constants)?;
        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"),
        }
        Ok(())
    }

    /// Metadata is replaced wholesale: omitting `program_hash` in the update DROPS a previously
    /// anchored program hash (SPEC §3.2).
    #[test]
    fn update_replaces_metadata_wholesale() -> 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();

        // Mint a store WITH a program hash.
        let built = mint_datastore(
            owner.coin,
            Owner::Standard(owner.pk),
            Bytes32::new([0x5a; 32]),
            None,
            None,
            None,
            Some(Bytes32::new([0xcc; 32])),
            None,
            owner_ph,
            vec![],
            0,
        )?;
        sim.spend_coins(built.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
        let store = built.child.expect("mint yields a child");
        assert_eq!(
            store.info.metadata.program_hash,
            Some(Bytes32::new([0xcc; 32]))
        );

        // Update with new_metadata that omits program_hash → it is dropped.
        let updated = update_root(
            &store,
            Owner::Standard(owner.pk),
            DigDataStoreMetadata {
                root_hash: Bytes32::new([0x99; 32]),
                ..Default::default()
            },
        )?;
        let child = updated.child.expect("update yields a child");
        assert_eq!(
            child.info.metadata.program_hash, None,
            "omitted program_hash is dropped (wholesale replacement)"
        );
        Ok(())
    }
}