Skip to main content

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}