Skip to main content

sumchain_primitives/
storage_metadata.rs

1//! Storage Metadata types for SUM Chain.
2//!
3//! Defines on-chain data structures for decentralized file storage metadata,
4//! including file identity (Blake3 Merkle root), access control lists,
5//! fee pools for storage-node payouts, and Proof-of-Retrievability challenges.
6
7use serde::{Deserialize, Serialize};
8use serde_big_array::BigArray;
9
10use crate::{Address, Hash};
11
12// ─── PoR Constants ───────────────────────────────────────────────────────────
13
14/// Chunk size for PoR challenges: 1 MB
15pub const CHUNK_SIZE: u64 = 1_048_576;
16
17/// How many blocks an ArchiveNode has to respond to a challenge
18pub const CHALLENGE_TTL_BLOCKS: u64 = 50;
19
20/// Issue a new challenge every N blocks
21pub const CHALLENGE_INTERVAL_BLOCKS: u64 = 100;
22
23/// Reward per valid proof: 10 Koppa (in base units)
24pub const CHALLENGE_REWARD: u64 = 10_000_000_000;
25
26/// Percentage of staked balance slashed on expired challenge
27pub const SLASH_PERCENTAGE: u64 = 5;
28
29// ─── File Metadata ───────────────────────────────────────────────────────────
30
31/// On-chain metadata for a stored file
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct StorageMetadata {
34    /// Blake3 Merkle root of the file's content tree
35    pub merkle_root: Hash,
36    /// Owner/uploader who controls the file
37    pub owner: Address,
38    /// Total file size in bytes
39    pub total_size_bytes: u64,
40    /// Native ACL — addresses allowed to retrieve the file
41    pub access_list: Vec<Address>,
42    /// Locked Koppa (base units) reserved for storage-node payouts
43    pub fee_pool: u64,
44    /// Block height at which the metadata was created
45    pub created_at: u64,
46}
47
48// ─── PoR Challenge ───────────────────────────────────────────────────────────
49
50/// An open cryptographic challenge issued by the L1 to an ArchiveNode.
51/// The node must submit a valid Merkle proof before `expires_at_height`
52/// or face slashing.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct StorageChallenge {
55    /// Deterministic ID: Blake3(merkle_root ++ chunk_index ++ created_at_height)
56    pub challenge_id: Hash,
57    /// Which file is being challenged
58    pub merkle_root: Hash,
59    /// Which 1 MB chunk to prove (0-indexed)
60    pub chunk_index: u32,
61    /// Which ArchiveNode must respond
62    pub target_node: Address,
63    /// Block height the challenge was issued
64    pub created_at_height: u64,
65    /// Deadline: must respond before this height
66    pub expires_at_height: u64,
67}
68
69// ─── Operations ──────────────────────────────────────────────────────────────
70
71/// Operations on storage metadata
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub enum StorageMetadataOperation {
74    /// Register a new file's metadata and lock a fee deposit
75    RegisterFile {
76        merkle_root: Hash,
77        total_size_bytes: u64,
78        access_list: Vec<Address>,
79        fee_deposit: u64,
80    },
81    /// Replace the entire access list (owner only)
82    UpdateAccessList {
83        merkle_root: Hash,
84        new_access_list: Vec<Address>,
85    },
86    /// Append a single address to the access list (owner only)
87    AddAccess {
88        merkle_root: Hash,
89        address: Address,
90    },
91    /// Remove a single address from the access list (owner only)
92    RemoveAccess {
93        merkle_root: Hash,
94        address: Address,
95    },
96    /// Top up the fee pool for a file (anyone can do this)
97    TopUpFeePool {
98        merkle_root: Hash,
99        amount: u64,
100    },
101    /// Submit a Merkle proof for a storage challenge (ArchiveNode only)
102    SubmitStorageProof {
103        /// The challenge being responded to
104        challenge_id: Hash,
105        /// File merkle root (must match challenge)
106        merkle_root: Hash,
107        /// Chunk index (must match challenge)
108        chunk_index: u32,
109        /// Blake3 hash of the raw chunk data
110        chunk_hash: Hash,
111        /// Merkle path from chunk leaf to root (sibling hashes, bottom-up)
112        merkle_path: Vec<Hash>,
113    },
114}
115
116/// Transaction data for storage metadata operations
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct StorageMetadataTxData {
119    pub operation: StorageMetadataOperation,
120}
121
122// ─── V2 Schema ───────────────────────────────────────────────────────────────
123//
124// Plan v3.1 §3.1–3.5. Additive over V1: new enum variants, new row shape,
125// new storage CF, new TxPayload variant. V1 stays untouched.
126//
127// Phase 1 checkpoint 1a defines the full V2 enum and implements
128// `RegisterFilePendingV2` + `AbandonFileV2`. Other variants are present in
129// the enum but their executor branches are stubbed in checkpoint 1a and will
130// be implemented in 1b/1c.
131
132/// Encrypted-key-bundle wrapper. Newtype so that `Option<EncryptedKeyBundleV2>`
133/// can derive serde — serde won't auto-derive `Serialize`/`Deserialize` for
134/// `Option<[u8; N]>` when `N > 32`. Wire layout is identical to a bare 80-byte
135/// array (BigArray serializes as a flat byte sequence).
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
137pub struct EncryptedKeyBundleV2(#[serde(with = "BigArray")] pub [u8; 80]);
138
139/// Per-recipient access entry for a Private V2 file (or a public ACL entry
140/// when bundles are absent). Plan §3.1.
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142pub struct AccessEntryV2 {
143    pub address: Address,
144    /// Encrypted file-key bundle for this recipient. `Some(80 bytes)` for
145    /// Private files; `None` for Public files.
146    pub encrypted_key_bundle: Option<EncryptedKeyBundleV2>,
147    /// Optional access expiry (block height). `None` = never expires.
148    pub expires_at: Option<u64>,
149}
150
151/// File lifecycle state. `Rotated = 3` is reserved for Ask 10 (file rotation)
152/// future work and intentionally not present in v1.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[repr(u8)]
155pub enum FileLifecycleV2 {
156    Pending = 0,
157    Active = 1,
158    Abandoned = 2,
159}
160
161impl FileLifecycleV2 {
162    pub fn from_byte(b: u8) -> Option<Self> {
163        match b {
164            0 => Some(Self::Pending),
165            1 => Some(Self::Active),
166            2 => Some(Self::Abandoned),
167            _ => None,
168        }
169    }
170}
171
172/// File visibility. Determines whether `access_list` entries must carry
173/// encrypted bundles (Private) or must not (Public). Plan §3.5.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175#[repr(u8)]
176pub enum FileVisibilityV2 {
177    Public = 0,
178    Private = 1,
179}
180
181impl FileVisibilityV2 {
182    pub fn from_byte(b: u8) -> Option<Self> {
183        match b {
184            0 => Some(Self::Public),
185            1 => Some(Self::Private),
186            _ => None,
187        }
188    }
189}
190
191/// V2 storage operations. Additive — V1 `StorageMetadataOperation` unchanged.
192/// Plan §3.1, §3.6 (AcceptAssignmentV2 added at v3).
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194pub enum StorageMetadataOperationV2 {
195    /// Register a file in the Pending state. Locks `fee_deposit` into
196    /// `fee_pool`, captures the active-archive snapshot at this block height,
197    /// and stages the file for owner-driven push fanout.
198    /// Plan §3.5 RegisterFilePendingV2.
199    RegisterFilePendingV2 {
200        merkle_root: Hash,
201        plaintext_size_bytes: u64,
202        stored_size_bytes: u64,
203        chunk_count: u32,
204        fee_deposit: u64,
205        /// 0 = Public, 1 = Private. Validation in the executor decodes via
206        /// `FileVisibilityV2::from_byte` and rejects other values.
207        visibility: u8,
208        initial_access: Vec<AccessEntryV2>,
209    },
210    /// Transition a file from Pending → Active. Validity precondition lives
211    /// in checkpoint 1b (every chunk index must have an `AcceptAssignmentV2`).
212    ActivateFileV2 {
213        merkle_root: Hash,
214    },
215    /// Refund deposit (minus `abandonment_fee_percent`) for a Pending file
216    /// that the owner can't activate. Anti-grief: only valid after
217    /// `created_at + activation_grace_blocks`. Plan §3.5 AbandonFileV2.
218    AbandonFileV2 {
219        merkle_root: Hash,
220    },
221    /// Per-archive attestation that this archive has received and stored the
222    /// listed chunks. Required before `ActivateFileV2`. Implemented in 1b.
223    AcceptAssignmentV2 {
224        merkle_root: Hash,
225        chunk_indices: Vec<u32>,
226    },
227    /// Add one access entry to an Active file's access list. Implemented in 1c.
228    AddAccessV2 {
229        merkle_root: Hash,
230        entry: AccessEntryV2,
231    },
232    /// Remove one access entry from an Active file's access list. Implemented in 1c.
233    RemoveAccessV2 {
234        merkle_root: Hash,
235        address: Address,
236    },
237    /// Replace one access entry's bundle/expiry on an Active file (rotation).
238    /// Implemented in 1c.
239    UpdateAccessV2 {
240        merkle_root: Hash,
241        address: Address,
242        new_entry: AccessEntryV2,
243    },
244}
245
246/// Transaction data wrapper for V2 storage operations.
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248pub struct StorageMetadataV2TxData {
249    pub operation: StorageMetadataOperationV2,
250}
251
252// ─── V2 Assignment Function (Plan v3.2 §3.6) ─────────────────────────────────
253
254/// Domain-separation context for the V2 chunk-assignment KDF.
255/// Exact 37-byte ASCII string; do NOT change without bumping the protocol
256/// version, since SNIP clients reproduce this byte-for-byte.
257pub const SNIP_V2_ASSIGNMENT_CONTEXT: &str = "sumchain SNIP-V2 chunk-assignment v1";
258
259/// Compute the rendezvous-hash assignment for a single chunk.
260///
261/// Returns the `min(R, snapshot.len())` archive addresses with the smallest
262/// BLAKE3-derived score for `(merkle_root, chunk_index)`. Output is sorted
263/// ascending by `(score, address)`.
264///
265/// The snapshot is sorted-and-deduped by ascending byte-order address before
266/// scoring, so callers may pass any order. This function is the single source
267/// of truth shared by the executor (`AcceptAssignmentV2` validity check),
268/// the `storage_getAssignmentCoverageV2` RPC, and SNIP client push logic;
269/// all three must agree byte-for-byte.
270///
271/// Conformance vectors are defined in plan v3.2 Appendix C and locked by
272/// tests in this module.
273pub fn assigned_archives(
274    merkle_root: &Hash,
275    snapshot_addresses: &[Address],
276    chunk_index: u32,
277    replication_factor: u32,
278) -> Vec<Address> {
279    // Sort + dedup by ascending byte order. Spec mandates this so any caller
280    // (executor, RPC, SNIP client) producing the same input snapshot computes
281    // the same output regardless of input ordering.
282    let mut addrs: Vec<Address> = snapshot_addresses.to_vec();
283    addrs.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
284    addrs.dedup_by(|a, b| a.as_bytes() == b.as_bytes());
285
286    let r_eff = (replication_factor as usize).min(addrs.len());
287    if r_eff == 0 {
288        return Vec::new();
289    }
290
291    // Score each archive. Build the 56-byte input (32 root + 4 chunk_index_be + 20 addr)
292    // exactly as specified in §3.6 — order matters for SNIP client conformance.
293    let mut scored: Vec<(u64, Address)> = Vec::with_capacity(addrs.len());
294    let chunk_be = chunk_index.to_be_bytes();
295    for a in &addrs {
296        let mut input = [0u8; 56];
297        input[..32].copy_from_slice(merkle_root.as_bytes());
298        input[32..36].copy_from_slice(&chunk_be);
299        input[36..56].copy_from_slice(a.as_bytes());
300        let derived = blake3::derive_key(SNIP_V2_ASSIGNMENT_CONTEXT, &input);
301        let score = u64::from_be_bytes(derived[..8].try_into().expect("8-byte slice"));
302        scored.push((score, *a));
303    }
304
305    // Sort ascending by (score, address). The address tie-break makes the
306    // ordering total even in the (negligible) event of a 1-in-2^64 score collision.
307    scored.sort_by(|x, y| {
308        x.0.cmp(&y.0)
309            .then_with(|| x.1.as_bytes().cmp(y.1.as_bytes()))
310    });
311
312    scored.into_iter().take(r_eff).map(|(_, a)| a).collect()
313}
314
315/// Like [`assigned_archives`] but takes a snapshot that the caller has
316/// already sorted and deduped (ascending byte order). Used by the coverage
317/// RPC and other batch callers that iterate the assignment over many chunks
318/// — calling [`assigned_archives`] in a loop re-sorts the snapshot every
319/// iteration, which dominates runtime for large `chunk_count`. This variant
320/// shifts that cost out of the hot loop.
321///
322/// Output is byte-identical to [`assigned_archives`] for the same input
323/// snapshot, regardless of how the caller obtained the sort. **Caller
324/// responsibility:** pass an already-sorted+deduped slice; this function
325/// does NOT re-sort, so an out-of-order input produces wrong assignments.
326pub fn assigned_archives_presorted(
327    merkle_root: &Hash,
328    sorted_addresses: &[Address],
329    chunk_index: u32,
330    replication_factor: u32,
331) -> Vec<Address> {
332    let r_eff = (replication_factor as usize).min(sorted_addresses.len());
333    if r_eff == 0 {
334        return Vec::new();
335    }
336    let mut scored: Vec<(u64, Address)> = Vec::with_capacity(sorted_addresses.len());
337    let chunk_be = chunk_index.to_be_bytes();
338    for a in sorted_addresses {
339        let mut input = [0u8; 56];
340        input[..32].copy_from_slice(merkle_root.as_bytes());
341        input[32..36].copy_from_slice(&chunk_be);
342        input[36..56].copy_from_slice(a.as_bytes());
343        let derived = blake3::derive_key(SNIP_V2_ASSIGNMENT_CONTEXT, &input);
344        let score = u64::from_be_bytes(derived[..8].try_into().expect("8-byte slice"));
345        scored.push((score, *a));
346    }
347    scored.sort_by(|x, y| {
348        x.0.cmp(&y.0)
349            .then_with(|| x.1.as_bytes().cmp(y.1.as_bytes()))
350    });
351    scored.into_iter().take(r_eff).map(|(_, a)| a).collect()
352}
353
354/// Convenience: is `archive` in the assigned set for `(merkle_root, chunk_index)`?
355/// Equivalent to `assigned_archives(...).contains(&archive)` but documents intent.
356pub fn is_archive_assigned_to_chunk(
357    merkle_root: &Hash,
358    snapshot_addresses: &[Address],
359    chunk_index: u32,
360    replication_factor: u32,
361    archive: &Address,
362) -> bool {
363    assigned_archives(merkle_root, snapshot_addresses, chunk_index, replication_factor)
364        .iter()
365        .any(|a| a.as_bytes() == archive.as_bytes())
366}
367
368// ─── V2 Assignment Function — Appendix C conformance tests ───────────────────
369#[cfg(test)]
370mod assignment_tests {
371    use super::*;
372
373    /// Construct the Appendix C archive set deterministically.
374    /// archive[j] = blake3::hash("snip-v2-archive-{j+1}").as_bytes()[..20]
375    fn fixture_archives() -> [Address; 5] {
376        let mut out = [Address::new([0u8; 20]); 5];
377        for j in 0..5 {
378            let label = format!("snip-v2-archive-{}", j + 1);
379            let h = blake3::hash(label.as_bytes());
380            out[j] = Address::from_slice(&h.as_bytes()[..20]).expect("20 bytes");
381        }
382        out
383    }
384
385    /// Construct the Appendix C merkle-root set deterministically.
386    /// merkle_root[i] = blake3::hash("snip-v2-test-file-{i+1}").as_bytes()
387    fn fixture_root(i: usize) -> Hash {
388        let label = format!("snip-v2-test-file-{}", i + 1);
389        let h = blake3::hash(label.as_bytes());
390        Hash::from_slice(h.as_bytes()).expect("32 bytes")
391    }
392
393    /// Lock fixture-derivation against drift — if blake3 hashing of the
394    /// construction strings ever produces different bytes, every Appendix C
395    /// entry below shifts. Catches that early with a single assertion.
396    #[test]
397    fn appendix_c_fixture_construction_matches() {
398        let roots = [fixture_root(0), fixture_root(1), fixture_root(2)];
399        let archives = fixture_archives();
400
401        let expected_roots = [
402            "a5e2668f5022b62b5e4a1342aa0cfbfcbde2af2e3626b2fd57d6cf44e8f615a4",
403            "eed453d08260268bbd3675997f407174d901d842711f3addb6a2e05f776bccce",
404            "81137f39ea2a36bae5333d021052c44c0fc4763769c9988241e6669af16dfa74",
405        ];
406        for (i, h) in expected_roots.iter().enumerate() {
407            assert_eq!(hex::encode(roots[i].as_bytes()), *h, "merkle_root[{}]", i);
408        }
409        let expected_archives = [
410            "37c4401960bd5a26d8ed7b676b1ef47c78fac5bb",
411            "f1a469857483cc381865df996b2cccd254878a16",
412            "8c6a62e786d02ae255a6f481580b95fe05bafffc",
413            "f8967230e6a6d6b5b4ce6816d43f406f24d3cdad",
414            "7e65c99f5b3994f2014187f24ee9230a027526bd",
415        ];
416        for (j, h) in expected_archives.iter().enumerate() {
417            assert_eq!(hex::encode(archives[j].as_bytes()), *h, "archive[{}]", j);
418        }
419    }
420
421    /// Plan v3.2 Appendix C — per-archive scores for (merkle_root[0], chunk_index=0).
422    /// Catches drift in the BLAKE3 derive_key call shape (context string, byte
423    /// order, the keyed_hash-vs-derive_key mistake the plan warns about).
424    #[test]
425    fn appendix_c_scores_for_root0_chunk0() {
426        let root = fixture_root(0);
427        let archives = fixture_archives();
428        let chunk_be = 0u32.to_be_bytes();
429
430        // Each entry: (archive, expected score as BE u64).
431        let cases: [(Address, u64); 5] = [
432            (archives[4], 0x4cd8130d5f5c7f55),
433            (archives[2], 0x73e9ad5ef9a6ba04),
434            (archives[1], 0xc8859dade38f7649),
435            (archives[3], 0xd2823bf6a2d883bb),
436            (archives[0], 0xf3c350979cb3f293),
437        ];
438
439        for (archive, expected_score) in cases.iter() {
440            let mut input = [0u8; 56];
441            input[..32].copy_from_slice(root.as_bytes());
442            input[32..36].copy_from_slice(&chunk_be);
443            input[36..56].copy_from_slice(archive.as_bytes());
444            let derived = blake3::derive_key(SNIP_V2_ASSIGNMENT_CONTEXT, &input);
445            let score = u64::from_be_bytes(derived[..8].try_into().unwrap());
446            assert_eq!(
447                score,
448                *expected_score,
449                "score mismatch for archive {} — most likely cause: wrong context string \
450                 (\"{}\" expected) or keyed_hash-vs-derive_key drift",
451                hex::encode(archive.as_bytes()),
452                SNIP_V2_ASSIGNMENT_CONTEXT,
453            );
454        }
455    }
456
457    /// Plan v3.2 Appendix C — assignment outputs for the seven listed cases.
458    /// Each row exercises a different aspect of the function.
459    #[test]
460    fn appendix_c_assignment_outputs() {
461        let snapshot = fixture_archives().to_vec();
462        let r0 = fixture_root(0);
463        let r1 = fixture_root(1);
464        let r2 = fixture_root(2);
465
466        // (merkle_root, chunk_index, R, expected output)
467        struct Case<'a> {
468            root: &'a Hash,
469            chunk_index: u32,
470            r: u32,
471            expected_hex: &'a [&'a str],
472        }
473        let cases = [
474            Case { root: &r0, chunk_index: 0, r: 1, expected_hex: &[
475                "7e65c99f5b3994f2014187f24ee9230a027526bd",
476            ]},
477            Case { root: &r0, chunk_index: 0, r: 3, expected_hex: &[
478                "7e65c99f5b3994f2014187f24ee9230a027526bd",
479                "8c6a62e786d02ae255a6f481580b95fe05bafffc",
480                "f1a469857483cc381865df996b2cccd254878a16",
481            ]},
482            Case { root: &r0, chunk_index: 7, r: 3, expected_hex: &[
483                "f8967230e6a6d6b5b4ce6816d43f406f24d3cdad",
484                "37c4401960bd5a26d8ed7b676b1ef47c78fac5bb",
485                "7e65c99f5b3994f2014187f24ee9230a027526bd",
486            ]},
487            Case { root: &r1, chunk_index: 0, r: 3, expected_hex: &[
488                "f1a469857483cc381865df996b2cccd254878a16",
489                "8c6a62e786d02ae255a6f481580b95fe05bafffc",
490                "37c4401960bd5a26d8ed7b676b1ef47c78fac5bb",
491            ]},
492            Case { root: &r1, chunk_index: 1, r: 3, expected_hex: &[
493                "7e65c99f5b3994f2014187f24ee9230a027526bd",
494                "8c6a62e786d02ae255a6f481580b95fe05bafffc",
495                "f8967230e6a6d6b5b4ce6816d43f406f24d3cdad",
496            ]},
497            Case { root: &r2, chunk_index: 42, r: 3, expected_hex: &[
498                "f1a469857483cc381865df996b2cccd254878a16",
499                "8c6a62e786d02ae255a6f481580b95fe05bafffc",
500                "f8967230e6a6d6b5b4ce6816d43f406f24d3cdad",
501            ]},
502            // R = 5 — full set
503            Case { root: &r2, chunk_index: 42, r: 5, expected_hex: &[
504                "f1a469857483cc381865df996b2cccd254878a16",
505                "8c6a62e786d02ae255a6f481580b95fe05bafffc",
506                "f8967230e6a6d6b5b4ce6816d43f406f24d3cdad",
507                "37c4401960bd5a26d8ed7b676b1ef47c78fac5bb",
508                "7e65c99f5b3994f2014187f24ee9230a027526bd",
509            ]},
510            // R = 7 — clamps to snapshot.len() = 5
511            Case { root: &r2, chunk_index: 42, r: 7, expected_hex: &[
512                "f1a469857483cc381865df996b2cccd254878a16",
513                "8c6a62e786d02ae255a6f481580b95fe05bafffc",
514                "f8967230e6a6d6b5b4ce6816d43f406f24d3cdad",
515                "37c4401960bd5a26d8ed7b676b1ef47c78fac5bb",
516                "7e65c99f5b3994f2014187f24ee9230a027526bd",
517            ]},
518        ];
519
520        for c in &cases {
521            let got = assigned_archives(c.root, &snapshot, c.chunk_index, c.r);
522            let got_hex: Vec<String> =
523                got.iter().map(|a| hex::encode(a.as_bytes())).collect();
524            let want_hex: Vec<String> = c.expected_hex.iter().map(|s| s.to_string()).collect();
525            assert_eq!(
526                got_hex, want_hex,
527                "case (root={}, chunk_index={}, R={}) — assignment drift",
528                hex::encode(c.root.as_bytes()),
529                c.chunk_index,
530                c.r,
531            );
532        }
533    }
534
535    /// Snapshot order independence — feeding the same archives in any
536    /// permutation must produce the same output (the function sort-and-dedups
537    /// internally per spec).
538    #[test]
539    fn assigned_archives_is_snapshot_order_independent() {
540        let mut snap_a = fixture_archives().to_vec();
541        let mut snap_b = snap_a.clone();
542        snap_b.reverse();
543        let snap_c = vec![snap_a[2], snap_a[0], snap_a[4], snap_a[1], snap_a[3]];
544
545        let root = fixture_root(2);
546        let a = assigned_archives(&root, &snap_a, 42, 3);
547        let b = assigned_archives(&root, &snap_b, 42, 3);
548        let c = assigned_archives(&root, &snap_c, 42, 3);
549        assert_eq!(a, b);
550        assert_eq!(a, c);
551
552        // Force a non-canonical input: also exercise dedup.
553        snap_a.push(snap_a[0]);
554        snap_a.push(snap_a[2]);
555        let d = assigned_archives(&root, &snap_a, 42, 3);
556        assert_eq!(a, d);
557    }
558
559    /// is_archive_assigned_to_chunk agrees with assigned_archives.
560    #[test]
561    fn is_archive_assigned_matches_assigned_archives() {
562        let snap = fixture_archives().to_vec();
563        let root = fixture_root(0);
564        let assigned = assigned_archives(&root, &snap, 0, 3);
565        for a in &snap {
566            let in_set = assigned.iter().any(|x| x.as_bytes() == a.as_bytes());
567            assert_eq!(
568                is_archive_assigned_to_chunk(&root, &snap, 0, 3, a),
569                in_set,
570                "is_archive_assigned_to_chunk disagrees with assigned_archives for {}",
571                hex::encode(a.as_bytes()),
572            );
573        }
574    }
575
576    /// Empty snapshot: function returns empty Vec, no panic.
577    #[test]
578    fn empty_snapshot_returns_empty() {
579        let root = fixture_root(0);
580        assert!(assigned_archives(&root, &[], 0, 3).is_empty());
581    }
582}
583
584/// On-chain V2 file row stored under prefix `[b'F', b'2', merkle_root]` to
585/// coexist with V1 `[b'F', merkle_root]`. Plan §3.2.
586#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
587pub struct StorageMetadataV2 {
588    pub merkle_root: Hash,
589    pub owner: Address,
590    pub plaintext_size_bytes: u64,
591    pub stored_size_bytes: u64,
592    pub chunk_count: u32,
593    /// Locked deposit. Settlement semantics (PoR payout / abandonment refund)
594    /// are unchanged from V1 fee_pool — only the *path in* changes.
595    pub fee_pool: u64,
596    /// Block height of `RegisterFilePendingV2`. Depends on Phase 0a fix.
597    pub created_at: u64,
598    /// Set on `ActivateFileV2`; `None` while Pending or Abandoned.
599    pub activated_at_height: Option<u64>,
600    /// Set on `AbandonFileV2`; `None` while Pending or Active. Off-chain
601    /// indexers (e.g. SNIP `IngestOutcome::AbandonedOnChain`) read this to
602    /// learn the exact lifecycle-transition block without scanning receipts.
603    pub abandoned_at_height: Option<u64>,
604    /// Block height at which the active-archive-node snapshot used for
605    /// chunk assignment was captured (Ask 15, Option A).
606    pub assignment_height: u64,
607    pub visibility: FileVisibilityV2,
608    pub lifecycle: FileLifecycleV2,
609    pub access_list: Vec<AccessEntryV2>,
610    /// Reserved for Ask 10 (file rotation). Always `None` in V2.
611    pub predecessor_root: Option<Hash>,
612}