dig_merkle/metadata.rs
1//! The DIG DataLayer metadata (SPEC §2) — the SDK's `DataStoreMetadata` shape with the exact-byte
2//! `"b"` size REPLACED by a power-of-2 `size_bucket` (`"sz"`), plus the additive `program_hash` (`"p"`).
3//!
4//! [`DigDataStoreMetadata`] carries `root_hash` + optional `label`/`description`/`size_proof` (the SDK
5//! keys `l`/`d`/`sp`), the additive `program_hash` (`"p"`), and the DIG store size as a coarse
6//! [`SizeBucket`] (`"sz"`) INSTEAD of the SDK's exact `bytes`/`"b"` count:
7//!
8//! - **Byte-identity when empty (INV-4, SPEC §5.1).** dig-merkle NEVER emits `"b"`. It appends
9//! `("p" . program_hash)` then `("sz" . exponent)` LAST, each only when `Some`. With both `None` the
10//! bytes are IDENTICAL to the SDK's `DataStoreMetadata` with `bytes == None` (it emits `l`/`d`/`sp`
11//! only) — a plain store is byte-for-byte an ordinary DataLayer store.
12//! - **SDK interop.** An SDK-typed reader decoding a DIG store ignores the unknown `"p"`/`"sz"` keys
13//! (same `_ => ()` tolerance), and dig-merkle decoding an SDK store parses `root`/`l`/`d`/`sp` and
14//! ignores the SDK's `"b"` (a foreign `"b"` is not a DIG size-proof) → `size_bucket == None`.
15//!
16//! ## `program_hash` + `size_bucket` semantics
17//!
18//! `program_hash` is the CLVM tree-hash (puzzle hash) of the program/puzzle associated with the store
19//! or capsule. dig-merkle STORES and ECHOES it only — it never computes it (producers compute it via
20//! `clvm_utils::tree_hash` / `ToTreeHash`). `size_bucket` is the store's size quantised to a
21//! power-of-2 bucket (`k ∈ 0..=10` ↔ `2^k MiB`, 1 MB..1 GB) — the ONE size field, replacing the exact
22//! byte count. dig-merkle round-trips both through the on-chain metadata unchanged.
23
24use chia_protocol::Bytes32;
25use chia_wallet_sdk::driver::MetadataWithRootHash;
26use clvm_traits::{ClvmDecoder, ClvmEncoder, FromClvm, FromClvmError, Raw, ToClvm, ToClvmError};
27
28use crate::size::SizeBucket;
29
30/// The DIG DataLayer metadata: the SDK's `DataStoreMetadata` shape with the exact byte count
31/// (`"b"`) REPLACED by a power-of-2 `size_bucket` (`"sz"`), plus the additive `program_hash` (`"p"`).
32///
33/// This is the metadata `dig-merkle` curries into a minted store. The DIG store size is expressed as
34/// a coarse power-of-2 [`SizeBucket`] rather than an exact byte count — a clean replacement of the
35/// SDK's `bytes`/`"b"` field (pre-release: there are NO on-chain DIG stores carrying `"b"`). With
36/// `size_bucket == None && program_hash == None` it serializes byte-identically to the SDK's
37/// `DataStoreMetadata` with `bytes == None` (it emits `l`/`d`/`sp` only, never `"b"`) — an SDK-typed
38/// reader parses it and simply ignores the unknown `"sz"`/`"p"` keys (SPEC §8/§9).
39#[derive(Debug, Clone, PartialEq, Eq, Default)]
40pub struct DigDataStoreMetadata {
41 /// The anchored `.dig` merkle root — always the first metadata atom (SPEC §8).
42 pub root_hash: Bytes32,
43
44 /// An optional human-readable store label (CLVM alist key `"l"`).
45 pub label: Option<String>,
46
47 /// An optional human-readable store description (CLVM alist key `"d"`).
48 pub description: Option<String>,
49
50 /// An optional size-proof string (CLVM alist key `"sp"`).
51 pub size_proof: Option<String>,
52
53 /// The optional CLVM tree-hash of the program/puzzle this store is associated with (CLVM alist
54 /// key `"p"`, appended after `"sp"`). dig-merkle stores and echoes this value; it never computes it.
55 pub program_hash: Option<Bytes32>,
56
57 /// The optional `.dig` store size as a power-of-2 bucket (CLVM alist key `"sz"`, appended LAST —
58 /// after `"p"`). This REPLACES the SDK's exact-byte `"b"` field: dig-merkle never emits `"b"`, and
59 /// the size is this coarse bucket instead. Encoded on-wire as the minimal 1-byte bucket exponent
60 /// `k ∈ 0..=10` (empty atom for `k = 0`), so a `None` size emits no key at all. See [`SizeBucket`].
61 pub size_bucket: Option<SizeBucket>,
62}
63
64/// Encodes `(root_hash . items)` where `items` pushes `l`/`d`/`sp` (mirroring the SDK, but NEVER the
65/// exact-byte `"b"` key), then appends `("p" . program_hash)` and finally `("sz" . exponent)` LAST,
66/// each only when `Some`.
67///
68/// Never emitting `"b"` and appending the DIG keys only when present is the byte-identity guarantee:
69/// with `program_hash == None && size_bucket == None` the bytes equal the SDK's `DataStoreMetadata`
70/// with `bytes == None` (SPEC §9).
71impl<N, E: ClvmEncoder<Node = N>> ToClvm<E> for DigDataStoreMetadata {
72 fn to_clvm(&self, encoder: &mut E) -> Result<N, ToClvmError> {
73 let mut items: Vec<(&str, Raw<N>)> = Vec::new();
74
75 if let Some(label) = &self.label {
76 items.push(("l", Raw(label.to_clvm(encoder)?)));
77 }
78
79 if let Some(description) = &self.description {
80 items.push(("d", Raw(description.to_clvm(encoder)?)));
81 }
82
83 if let Some(size_proof) = &self.size_proof {
84 items.push(("sp", Raw(size_proof.to_clvm(encoder)?)));
85 }
86
87 // The DIG additions, appended after the SDK keys and each omitted when None → byte-identical
88 // to the SDK's metadata for an ordinary store (SPEC §8/§9).
89 if let Some(program_hash) = self.program_hash {
90 items.push(("p", Raw(program_hash.to_clvm(encoder)?)));
91 }
92
93 // The size bucket is appended LAST, its exponent (0..=10) encoded as a minimal CLVM integer
94 // (NC-8): the empty atom for k=0, a single byte for k=1..=10.
95 if let Some(size_bucket) = self.size_bucket {
96 items.push(("sz", Raw(size_bucket.exponent().to_clvm(encoder)?)));
97 }
98
99 (self.root_hash, items).to_clvm(encoder)
100 }
101}
102
103/// Decodes `(root_hash . items)`, reading `l`/`d`/`sp`/`p`/`sz` and IGNORING any other key (the same
104/// `_ => ()` tolerance as the SDK).
105///
106/// There is deliberately NO `"b"` arm. Every DIG capsule store ALWAYS carries `"sz"` (dig-merkle
107/// writes it), so `"sz"` is the authoritative size. A foreign SDK DataLayer store's exact-byte `"b"`
108/// is NOT a DIG size-proof — fabricating a bucket from it would misrepresent a non-capsule — so `"b"`
109/// falls to the unknown-key tolerance and is ignored, and a `sz`-free store decodes honestly with
110/// `size_bucket == None`. This does NOT break reading SDK stores: they still DECODE fine (root/l/d/sp
111/// parse, `"b"` skipped). A `p`-free store likewise decodes with `program_hash == None` (SPEC §5.1).
112impl<N, D: ClvmDecoder<Node = N>> FromClvm<D> for DigDataStoreMetadata {
113 fn from_clvm(decoder: &D, node: N) -> Result<Self, FromClvmError> {
114 let (root_hash, items) = <(Bytes32, Vec<(String, Raw<N>)>)>::from_clvm(decoder, node)?;
115 let mut metadata = Self::root_hash_only(root_hash);
116
117 for (key, Raw(ptr)) in items {
118 match key.as_str() {
119 "l" => metadata.label = Some(String::from_clvm(decoder, ptr)?),
120 "d" => metadata.description = Some(String::from_clvm(decoder, ptr)?),
121 "sp" => metadata.size_proof = Some(String::from_clvm(decoder, ptr)?),
122 "p" => metadata.program_hash = Some(Bytes32::from_clvm(decoder, ptr)?),
123 "sz" => metadata.size_bucket = Some(decode_size_bucket(decoder, ptr)?),
124 _ => (),
125 }
126 }
127
128 Ok(metadata)
129 }
130}
131
132/// Decodes a `"sz"` value into a [`SizeBucket`], enforcing the CANONICAL minimal encoding (NC-8) and
133/// failing CLOSED on anything else: the empty atom is `k = 0`, a single byte `1..=10` is that `k`,
134/// and a non-minimal (leading-zero) atom, a byte `> 10`, or a multi-byte atom is REJECTED. This is
135/// what makes the on-wire size a canonical form no producer can encode two ways.
136fn decode_size_bucket<N, D: ClvmDecoder<Node = N>>(
137 decoder: &D,
138 ptr: N,
139) -> Result<SizeBucket, FromClvmError> {
140 const INVALID_SZ: &str = "invalid sz: non-minimal or out-of-range size exponent";
141
142 let atom = decoder.decode_atom(&ptr)?;
143 let exponent = match atom.as_ref() {
144 [] => 0u8,
145 [byte] if (1..=10).contains(byte) => *byte,
146 _ => return Err(FromClvmError::Custom(INVALID_SZ.to_string())),
147 };
148
149 // The exponent is already validated to 0..=10 above, so this cannot fail; map defensively rather
150 // than unwrap so an invariant change surfaces as an error, never a panic.
151 SizeBucket::from_exponent(exponent).map_err(|error| FromClvmError::Custom(error.to_string()))
152}
153
154/// The SDK's generic `build_datastore`/`from_spend` require this bound to recover the anchored root.
155/// `root_hash_only` clears every optional field — including `program_hash` and `size_bucket` — to `None`.
156impl MetadataWithRootHash for DigDataStoreMetadata {
157 fn root_hash(&self) -> Bytes32 {
158 self.root_hash
159 }
160
161 fn root_hash_only(root_hash: Bytes32) -> Self {
162 Self {
163 root_hash,
164 label: None,
165 description: None,
166 size_proof: None,
167 program_hash: None,
168 size_bucket: None,
169 }
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use chia_wallet_sdk::driver::{DataStoreMetadata, SpendContext};
177 use chia_wallet_sdk::prelude::{Allocator, NodePtr};
178 use clvm_traits::{FromClvm, ToClvm};
179
180 /// Serializes a value to canonical CLVM bytes via a `chia_protocol::Program` round-trip, so two
181 /// encodings can be compared byte-for-byte.
182 fn clvm_bytes<T: ToClvm<Allocator>>(value: &T) -> Vec<u8> {
183 let mut ctx = SpendContext::new();
184 let node = value.to_clvm(&mut *ctx).expect("encode value");
185 let program = chia_protocol::Program::from_clvm(&*ctx, node).expect("node to program");
186 program.as_ref().to_vec()
187 }
188
189 /// The shared non-size fields used across the metadata tests (root, label, description, size_proof).
190 fn sample_fields() -> (Bytes32, Option<String>, Option<String>, Option<String>) {
191 (
192 Bytes32::new([0xcd; 32]),
193 Some("site".into()),
194 Some("a description".into()),
195 Some("size-proof".into()),
196 )
197 }
198
199 /// The exact raw atom bytes of the `"sz"` value in an encoded metadata, for on-wire assertions.
200 fn sz_atom_bytes(metadata: &DigDataStoreMetadata) -> Vec<u8> {
201 let mut ctx = SpendContext::new();
202 let node = metadata.to_clvm(&mut *ctx).expect("encode");
203 let (_root, items) =
204 <(Bytes32, Vec<(String, Raw<NodePtr>)>)>::from_clvm(&*ctx, node).expect("decode alist");
205 let (_key, Raw(ptr)) = items
206 .into_iter()
207 .find(|(k, _)| k == "sz")
208 .expect("sz key present");
209 chia_protocol::Bytes::from_clvm(&*ctx, ptr)
210 .expect("sz atom")
211 .into_inner()
212 }
213
214 /// With `size_bucket == None && program_hash == None`, the CLVM bytes are IDENTICAL to the SDK's
215 /// `DataStoreMetadata` with `bytes == None` — the clean-replacement byte-identity (SPEC §8/§9).
216 /// dig-merkle emits NO `"b"` key, so the two encodings (l/d/sp) match exactly.
217 #[test]
218 fn metadata_none_size_bucket_is_byte_identical_to_sdk() {
219 let (root, label, description, size_proof) = sample_fields();
220
221 let dig = DigDataStoreMetadata {
222 root_hash: root,
223 label: label.clone(),
224 description: description.clone(),
225 size_proof: size_proof.clone(),
226 program_hash: None,
227 size_bucket: None,
228 };
229 let sdk = DataStoreMetadata {
230 root_hash: root,
231 label,
232 description,
233 bytes: None,
234 size_proof,
235 };
236
237 assert_eq!(
238 clvm_bytes(&dig),
239 clvm_bytes(&sdk),
240 "a None size_bucket/program_hash store must serialize byte-identically to the SDK metadata"
241 );
242 }
243
244 /// SDK-PARSE-COMPAT: a DIG store carrying a `size_bucket` (has `"sz"`, NO `"b"`) parses as a valid
245 /// `chia_wallet_sdk::driver::DataStoreMetadata` — the SDK ignores the unknown `"sz"`, and an
246 /// absent `"b"` decodes as `bytes == None`.
247 #[test]
248 fn sdk_reader_parses_size_bucket_store() {
249 let (root, label, description, size_proof) = sample_fields();
250 let dig = DigDataStoreMetadata {
251 root_hash: root,
252 label: label.clone(),
253 description: description.clone(),
254 size_proof: size_proof.clone(),
255 program_hash: None,
256 size_bucket: Some(SizeBucket::from_exponent(5).unwrap()),
257 };
258
259 let mut ctx = SpendContext::new();
260 let node = dig.to_clvm(&mut *ctx).expect("encode dig");
261 let sdk = DataStoreMetadata::from_clvm(&*ctx, node).expect("sdk decodes, ignoring sz");
262
263 assert_eq!(sdk.root_hash, root);
264 assert_eq!(sdk.label, label);
265 assert_eq!(sdk.description, description);
266 assert_eq!(sdk.size_proof, size_proof);
267 assert_eq!(sdk.bytes, None, "no b key so SDK reads bytes as None");
268 }
269
270 /// The on-wire `"sz"` atom is minimally encoded (NC-8): the empty atom for k=0, a single byte
271 /// otherwise. Pins the exact bytes for k=0/5/10.
272 #[test]
273 fn sz_atom_is_minimally_encoded() {
274 let with_k = |k: u8| DigDataStoreMetadata {
275 root_hash: Bytes32::new([0x01; 32]),
276 label: None,
277 description: None,
278 size_proof: None,
279 program_hash: None,
280 size_bucket: Some(SizeBucket::from_exponent(k).unwrap()),
281 };
282 assert_eq!(
283 sz_atom_bytes(&with_k(0)),
284 Vec::<u8>::new(),
285 "k=0 empty atom"
286 );
287 assert_eq!(sz_atom_bytes(&with_k(5)), vec![0x05], "k=5 single byte");
288 assert_eq!(sz_atom_bytes(&with_k(10)), vec![0x0a], "k=10 single byte");
289 }
290
291 /// A `Some(SizeBucket)` encodes then decodes unchanged, with every other field preserved.
292 #[test]
293 fn sz_roundtrips() {
294 let (root, label, description, size_proof) = sample_fields();
295 let metadata = DigDataStoreMetadata {
296 root_hash: root,
297 label,
298 description,
299 size_proof,
300 program_hash: Some(Bytes32::new([0x11; 32])),
301 size_bucket: Some(SizeBucket::from_exponent(7).unwrap()),
302 };
303 let mut ctx = SpendContext::new();
304 let node = metadata.to_clvm(&mut *ctx).expect("encode");
305 let decoded = DigDataStoreMetadata::from_clvm(&*ctx, node).expect("decode");
306 assert_eq!(decoded, metadata, "size_bucket and all fields round-trip");
307 }
308
309 /// Fail-closed decode: a `"sz"` atom that is non-minimal (`[0x00]`, `[0x00,0x05]`) or out of range
310 /// (`[0x0b]` == 11) is REJECTED. Also `SizeBucket::from_exponent(11)` errors.
311 #[test]
312 fn sz_decode_rejects_non_minimal_and_oversized() {
313 let build_with_raw_sz = |raw: Vec<u8>| -> Result<DigDataStoreMetadata, FromClvmError> {
314 let mut ctx = SpendContext::new();
315 let root = Bytes32::new([0x01; 32]);
316 let sz_node = chia_protocol::Bytes::new(raw).to_clvm(&mut *ctx).unwrap();
317 let items: Vec<(&str, Raw<NodePtr>)> = vec![("sz", Raw(sz_node))];
318 let node = (root, items).to_clvm(&mut *ctx).unwrap();
319 DigDataStoreMetadata::from_clvm(&*ctx, node)
320 };
321
322 assert!(
323 build_with_raw_sz(vec![0x00]).is_err(),
324 "leading-zero rejected"
325 );
326 assert!(
327 build_with_raw_sz(vec![0x00, 0x05]).is_err(),
328 "multi-byte non-minimal rejected"
329 );
330 assert!(
331 build_with_raw_sz(vec![0x0b]).is_err(),
332 "exponent 11 rejected"
333 );
334 assert!(matches!(
335 SizeBucket::from_exponent(11),
336 Err(crate::MerkleError::InvalidSize(_))
337 ));
338 }
339
340 /// With l/d/sp/p/sz all set, the decoded keys are exactly `["l","d","sp","p","sz"]` — `"sz"` is
341 /// appended LAST, after `"p"`, and `"b"` is never present.
342 #[test]
343 fn sz_is_last_key() {
344 let metadata = DigDataStoreMetadata {
345 root_hash: Bytes32::new([0xab; 32]),
346 label: Some("l".into()),
347 description: Some("d".into()),
348 size_proof: Some("sp".into()),
349 program_hash: Some(Bytes32::new([0x01; 32])),
350 size_bucket: Some(SizeBucket::from_exponent(3).unwrap()),
351 };
352 let mut ctx = SpendContext::new();
353 let node = metadata.to_clvm(&mut *ctx).expect("encode");
354 let (_root, items) =
355 <(Bytes32, Vec<(String, Raw<NodePtr>)>)>::from_clvm(&*ctx, node).expect("decode");
356 let keys: Vec<&str> = items.iter().map(|(k, _)| k.as_str()).collect();
357 assert_eq!(
358 keys,
359 vec!["l", "d", "sp", "p", "sz"],
360 "sz is appended last, no b"
361 );
362 }
363
364 /// §5.1 SDK-store readability + honest None: an SDK `DataStoreMetadata` carrying an exact-byte
365 /// `"b"` DECODES successfully (root/l/d/sp parse, `"b"` ignored) with `size_bucket == None` — a
366 /// foreign `"b"` is NOT a DIG size-proof, so the honest answer is no bucket. `bytes == None`
367 /// likewise decodes to `size_bucket == None`.
368 #[test]
369 fn sdk_store_with_b_still_decodes() {
370 let decode_sdk_bytes = |bytes: Option<u64>| -> DigDataStoreMetadata {
371 let mut ctx = SpendContext::new();
372 let sdk = DataStoreMetadata {
373 root_hash: Bytes32::new([0x02; 32]),
374 label: Some("sdk".into()),
375 description: None,
376 bytes,
377 size_proof: Some("sp".into()),
378 };
379 let node = sdk.to_clvm(&mut *ctx).expect("encode sdk");
380 DigDataStoreMetadata::from_clvm(&*ctx, node).expect("decode sdk store")
381 };
382
383 let with_b = decode_sdk_bytes(Some(4096));
384 assert_eq!(with_b.root_hash, Bytes32::new([0x02; 32]));
385 assert_eq!(
386 with_b.label,
387 Some("sdk".into()),
388 "other fields still decode"
389 );
390 assert_eq!(with_b.size_proof, Some("sp".into()));
391 assert_eq!(
392 with_b.size_bucket, None,
393 "a foreign b is ignored, not read as the size"
394 );
395 assert_eq!(decode_sdk_bytes(None).size_bucket, None);
396 }
397}