dig_store/lifecycle.rs
1//! The store LIFECYCLE (SPEC §3): a store is a coin that gets SPENT.
2//!
3//! A DIG store is a CHIP-0035 DataLayer singleton. Three operations span its life, each a spend of
4//! that coin, composed directly over `dig-merkle` (the byte-source-of-truth for every DataLayer
5//! spend, INV-4):
6//!
7//! - [`create_store`] — launch the store coin from a funding parent, anchoring the first root + its
8//! size bucket + optional metadata (→ [`dig_merkle::mint_datastore_with_kind`]).
9//! - [`modify_store`] — spend the tip coin to recreate the store with a NEW root, preserving the rest
10//! of the anchored metadata (→ [`dig_merkle::update_root`]).
11//! - [`melt_store`] — terminally spend the coin, closing the store with no successor
12//! (→ [`dig_merkle::melt`]).
13//!
14//! Every operation returns an UNSIGNED [`MerkleCoinSpend`] (inherited boundary INV-2/INV-3 from
15//! `dig-merkle`): `dig-store` never holds a key, never signs, never broadcasts. The wallet-backend /
16//! node feeds the reported spends to `dig_merkle::required_signatures`, signs, assembles the
17//! `SpendBundle`, and submits it. The on-chain encoding is minimal (NC-8) — delegated wholesale to
18//! `dig-merkle`, which owns the byte layout.
19//!
20//! `modify_store` / `melt_store` take the already-hydrated tip [`Datastore`] (from
21//! [`crate::get_store_singleton_tip`], which does the single chain read) rather than a chain source,
22//! so these builders stay pure transforms of their inputs (INV-1).
23
24use chia_bls::PublicKey;
25use dig_merkle::{melt, mint_datastore_with_kind, update_root, StoreKind};
26
27use crate::error::DigStoreResult;
28use crate::size::SizeBucket;
29use crate::types::{Bytes32, Coin, Datastore, DigDataStoreMetadata, MerkleCoinSpend};
30
31/// Who is authorized to spend a store coin — the p2 ("inner") puzzle that guards it.
32///
33/// [`StoreOwner::Standard`] is the single-key case: `dig-merkle` builds the standard layer, and the
34/// resulting spend requires exactly one `AGG_SIG_ME` over the key.
35///
36/// # Why this is dig-store's own type and not a re-export
37///
38/// `dig-merkle` 0.6 refuses its `Owner::Custom` (a pre-built inner spend) on ALL THREE lifecycle
39/// operations — mint, `update_root`, and `melt` — with `MerkleError::UnsupportedOwner`, because each
40/// spend must emit conditions produced INSIDE the call, which an opaque pre-built spend cannot carry.
41/// So every use of that variant is a guaranteed runtime error, whether or not a caller can build one.
42///
43/// Re-exporting such a variant is an API that promises a composition that does not work. Owning the
44/// type instead makes the unusable case UNEXPRESSIBLE rather than merely documented — the argument
45/// rests on the refusal above, not on any claim about what a caller could construct. `#[non_exhaustive]` keeps a future owner kind (a real delegated or
46/// multisig authorization, once `dig-merkle` supports one) additive.
47#[derive(Debug, Clone)]
48#[non_exhaustive]
49pub enum StoreOwner {
50 /// The standard single-key p2 puzzle, owned by the given (synthetic) public key.
51 Standard(PublicKey),
52}
53
54impl From<StoreOwner> for dig_merkle::Owner {
55 /// Lowers a store owner onto the `dig-merkle` owner the spend builders take. Total by
56 /// construction: every expressible [`StoreOwner`] maps to an owner `dig-merkle` accepts.
57 fn from(owner: StoreOwner) -> Self {
58 match owner {
59 StoreOwner::Standard(key) => dig_merkle::Owner::Standard(key),
60 }
61 }
62}
63
64/// The parameters that describe a store's on-chain metadata at creation (SPEC §3.1).
65///
66/// `root_hash` and `size` are required — every store anchors its size so the SIZE PROOF (SPEC §4) can
67/// gate downloads. Every other field is optional and omitted-when-absent on chain (NC-8).
68#[derive(Debug, Clone)]
69pub struct CreateStoreParams {
70 /// The first anchored merkle root (the `.dig` root of generation 0).
71 pub root_hash: Bytes32,
72 /// The store's size, anchored as a power-of-2 bucket so clients can gate downloads (SPEC §4).
73 pub size: SizeBucket,
74 /// An optional human label (`dig-merkle` metadata `"l"`).
75 pub label: Option<String>,
76 /// An optional human description (`dig-merkle` metadata `"d"`).
77 pub description: Option<String>,
78 /// An optional CLVM tree-hash of a program/puzzle associated with the store (`dig-merkle` `"p"`).
79 pub program_hash: Option<Bytes32>,
80 /// The reserve fee (mojos) to attach to the launch spend.
81 pub fee: u64,
82}
83
84/// Launches a new store coin from a funding parent, anchoring the first root (SPEC §3.1).
85///
86/// `parent_coin` funds + parents the launcher, so `launcher_id == store_id` derives from its
87/// `coin_id`. `owner` authorizes the parent spend; `owner_puzzle_hash` is the store owner recorded in
88/// the singleton (and the target of the owner-discovery hint + any change). `params` carries the
89/// first root, the required size bucket, and optional metadata. The store is minted with the file
90/// launcher discriminator ([`StoreKind::File`]), byte-identical to existing on-chain DIG stores.
91///
92/// Returns the UNSIGNED launch spend.
93///
94/// # This builder mints from an ORDINARY funding coin, never a DID
95///
96/// A DID is a singleton, so it cannot parent the odd-amount launcher directly — it must interpose an
97/// even-amount intermediate coin, and the resulting launcher is built by the CALLER and handed to
98/// `dig_merkle::mint_datastore_launch_with_kind` (which also returns the parent conditions the DID
99/// spend must emit). `create_store` composes the simple funding-coin path only, so it cannot mint a
100/// DID-rooted store; a caller needing one drives that `dig-merkle` API directly until `dig-store`
101/// exposes a builder for it. [`crate::get_store_did_owner`] still READS the owning DID of any store
102/// minted that way.
103///
104/// # Errors
105///
106/// Returns a [`DigStoreResult`] error if the spend cannot be constructed (invalid metadata / size /
107/// fee overflow).
108pub fn create_store(
109 parent_coin: Coin,
110 owner: StoreOwner,
111 owner_puzzle_hash: Bytes32,
112 params: CreateStoreParams,
113) -> DigStoreResult<MerkleCoinSpend> {
114 Ok(mint_datastore_with_kind(
115 StoreKind::File,
116 parent_coin,
117 owner.into(),
118 params.root_hash,
119 params.label,
120 params.description,
121 None, // size_proof: superseded by the size bucket (NC-8), never emitted by dig-store.
122 params.program_hash,
123 Some(params.size),
124 owner_puzzle_hash,
125 Vec::new(), // delegated puzzles: added additively in a later unit (SPEC §3).
126 params.fee,
127 )?)
128}
129
130/// Spends the store's tip coin to recreate it anchoring `new_root` — a new generation (SPEC §3.2).
131///
132/// `store` is the current confirmed tip (from [`crate::get_store_singleton_tip`]); the spend consumes
133/// it and recreates the singleton with `new_root`, PRESERVING every other anchored metadata field
134/// (label, description, size bucket, program hash) and the store identity (`store_id`, owner,
135/// delegation set). Returns the UNSIGNED spend.
136///
137/// Note: attaching a reserve fee to a modify spend is a `dig-merkle` future unit (its `fee` module is
138/// a documented stub); this builder recreates the coin at its current amount.
139///
140/// # Errors
141///
142/// Returns a [`DigStoreResult`] error if the spend cannot be constructed.
143pub fn modify_store(
144 store: &Datastore<DigDataStoreMetadata>,
145 owner: StoreOwner,
146 new_root: Bytes32,
147) -> DigStoreResult<MerkleCoinSpend> {
148 let new_metadata = DigDataStoreMetadata {
149 root_hash: new_root,
150 ..store.info.metadata.clone()
151 };
152 Ok(update_root(store, owner.into(), new_metadata)?)
153}
154
155/// Terminally spends (melts) the store's tip coin, leaving no successor (SPEC §3.3).
156///
157/// Closes the store: the singleton is spent with no recreation, so no future generation can be
158/// anchored. `store` is the current tip (from [`crate::get_store_singleton_tip`]). Returns the
159/// UNSIGNED melt spend.
160///
161/// # Errors
162///
163/// Returns a [`DigStoreResult`] error if the spend cannot be constructed.
164pub fn melt_store(
165 store: &Datastore<DigDataStoreMetadata>,
166 owner: StoreOwner,
167) -> DigStoreResult<MerkleCoinSpend> {
168 Ok(melt(store, owner.into())?)
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use chia_puzzle_types::standard::StandardArgs;
175 use chia_wallet_sdk::test::Simulator;
176
177 /// The one expressible owner lowers onto the one `dig-merkle` owner the spend builders accept.
178 ///
179 /// The value of this test is what it CANNOT be written against: `dig_merkle::Owner::Custom` is
180 /// refused by mint, `update_root`, and `melt` alike, and no `StoreOwner` can name it — so the
181 /// lowering is total by construction and no lifecycle call can reach `UnsupportedOwner`.
182 #[test]
183 fn store_owner_lowers_to_the_standard_merkle_owner() {
184 let key = PublicKey::default();
185 match dig_merkle::Owner::from(StoreOwner::Standard(key)) {
186 dig_merkle::Owner::Standard(lowered) => assert_eq!(lowered, key),
187 dig_merkle::Owner::Custom(_) => {
188 panic!("a standard owner never lowers to a custom spend")
189 }
190 }
191 }
192
193 /// Mints a store on the simulator and returns the owner keypair + the settled eve Datastore, so
194 /// lifecycle tests start from a real on-chain store.
195 fn minted_store(
196 sim: &mut Simulator,
197 size: SizeBucket,
198 ) -> anyhow::Result<(
199 chia_wallet_sdk::test::BlsPairWithCoin,
200 Datastore<DigDataStoreMetadata>,
201 )> {
202 let owner = sim.bls(1_000_000);
203 let owner_ph: Bytes32 = StandardArgs::curry_tree_hash(owner.pk).into();
204 let built = create_store(
205 owner.coin,
206 StoreOwner::Standard(owner.pk),
207 owner_ph,
208 CreateStoreParams {
209 root_hash: Bytes32::new([0x5a; 32]),
210 size,
211 label: Some("docs".into()),
212 description: None,
213 program_hash: None,
214 fee: 0,
215 },
216 )?;
217 sim.spend_coins(built.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
218 Ok((owner, built.child.expect("mint yields a child")))
219 }
220
221 /// create_store anchors the first root + size bucket and validates on the simulator; the eve
222 /// store hydrates back with both preserved.
223 #[test]
224 fn create_store_anchors_root_and_size() -> anyhow::Result<()> {
225 let mut sim = Simulator::new();
226 let size = SizeBucket::from_exponent(7).unwrap();
227 let (_owner, store) = minted_store(&mut sim, size)?;
228
229 assert_eq!(store.info.metadata.root_hash, Bytes32::new([0x5a; 32]));
230 assert_eq!(store.info.metadata.size_bucket, Some(size));
231 assert_eq!(store.info.metadata.label, Some("docs".into()));
232 Ok(())
233 }
234
235 /// modify_store recreates the store with a NEW root, PRESERVES the anchored size bucket + label
236 /// (wholesale-replacement carry-forward), keeps the store id, and validates on the simulator.
237 #[test]
238 fn modify_store_updates_root_and_preserves_metadata() -> anyhow::Result<()> {
239 let mut sim = Simulator::new();
240 let size = SizeBucket::from_exponent(5).unwrap();
241 let (owner, store) = minted_store(&mut sim, size)?;
242
243 let new_root = Bytes32::new([0x77; 32]);
244 let built = modify_store(&store, StoreOwner::Standard(owner.pk), new_root)?;
245 let child = built.child.clone().expect("modify yields a child");
246
247 assert_eq!(child.info.metadata.root_hash, new_root);
248 assert_eq!(
249 child.info.metadata.size_bucket,
250 Some(size),
251 "the anchored size bucket is preserved across a modify"
252 );
253 assert_eq!(child.info.metadata.label, Some("docs".into()));
254 assert_eq!(child.info.launcher_id, store.info.launcher_id);
255
256 sim.spend_coins(built.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
257 Ok(())
258 }
259
260 /// melt_store yields no successor and the melt validates on the simulator: the store is closed.
261 #[test]
262 fn melt_store_closes_the_store() -> anyhow::Result<()> {
263 let mut sim = Simulator::new();
264 let size = SizeBucket::from_exponent(3).unwrap();
265 let (owner, store) = minted_store(&mut sim, size)?;
266
267 let built = melt_store(&store, StoreOwner::Standard(owner.pk))?;
268 assert!(built.child.is_none(), "a melt leaves no successor");
269
270 sim.spend_coins(built.coin_spends.clone(), std::slice::from_ref(&owner.sk))?;
271 Ok(())
272 }
273
274 /// The unsigned create spend requires exactly one `AGG_SIG_ME` over the owner's key — the custody
275 /// contract inherited from dig-merkle (a compromised dig-store cannot move funds, INV-2).
276 #[test]
277 fn create_store_requires_a_single_owner_signature() -> anyhow::Result<()> {
278 use chia_wallet_sdk::prelude::MAINNET_CONSTANTS;
279 use chia_wallet_sdk::signer::{AggSigConstants, RequiredSignature};
280
281 let mut sim = Simulator::new();
282 let owner = sim.bls(1_000_000);
283 let owner_ph: Bytes32 = StandardArgs::curry_tree_hash(owner.pk).into();
284 let built = create_store(
285 owner.coin,
286 StoreOwner::Standard(owner.pk),
287 owner_ph,
288 CreateStoreParams {
289 root_hash: Bytes32::new([0x01; 32]),
290 size: SizeBucket::from_exponent(0).unwrap(),
291 label: None,
292 description: None,
293 program_hash: None,
294 fee: 0,
295 },
296 )?;
297
298 let constants = AggSigConstants::from(&*MAINNET_CONSTANTS);
299 let required = dig_merkle::required_signatures(&built.coin_spends, &constants)?;
300 assert_eq!(required.len(), 1, "one AGG_SIG_ME expected");
301 match &required[0] {
302 RequiredSignature::Bls(bls) => assert_eq!(bls.public_key, owner.pk),
303 RequiredSignature::Secp(_) => panic!("standard owner uses a BLS key"),
304 }
305 Ok(())
306 }
307}