use serde::{Deserialize, Serialize};
use serde_big_array::BigArray;
use crate::{Address, Hash};
pub const CHUNK_SIZE: u64 = 1_048_576;
pub const CHALLENGE_TTL_BLOCKS: u64 = 50;
pub const CHALLENGE_INTERVAL_BLOCKS: u64 = 100;
pub const CHALLENGE_REWARD: u64 = 10_000_000_000;
pub const SLASH_PERCENTAGE: u64 = 5;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorageMetadata {
pub merkle_root: Hash,
pub owner: Address,
pub total_size_bytes: u64,
pub access_list: Vec<Address>,
pub fee_pool: u64,
pub created_at: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorageChallenge {
pub challenge_id: Hash,
pub merkle_root: Hash,
pub chunk_index: u32,
pub target_node: Address,
pub created_at_height: u64,
pub expires_at_height: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum StorageMetadataOperation {
RegisterFile {
merkle_root: Hash,
total_size_bytes: u64,
access_list: Vec<Address>,
fee_deposit: u64,
},
UpdateAccessList {
merkle_root: Hash,
new_access_list: Vec<Address>,
},
AddAccess {
merkle_root: Hash,
address: Address,
},
RemoveAccess {
merkle_root: Hash,
address: Address,
},
TopUpFeePool {
merkle_root: Hash,
amount: u64,
},
SubmitStorageProof {
challenge_id: Hash,
merkle_root: Hash,
chunk_index: u32,
chunk_hash: Hash,
merkle_path: Vec<Hash>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorageMetadataTxData {
pub operation: StorageMetadataOperation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct EncryptedKeyBundleV2(#[serde(with = "BigArray")] pub [u8; 80]);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccessEntryV2 {
pub address: Address,
pub encrypted_key_bundle: Option<EncryptedKeyBundleV2>,
pub expires_at: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum FileLifecycleV2 {
Pending = 0,
Active = 1,
Abandoned = 2,
}
impl FileLifecycleV2 {
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0 => Some(Self::Pending),
1 => Some(Self::Active),
2 => Some(Self::Abandoned),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum FileVisibilityV2 {
Public = 0,
Private = 1,
}
impl FileVisibilityV2 {
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0 => Some(Self::Public),
1 => Some(Self::Private),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum StorageMetadataOperationV2 {
RegisterFilePendingV2 {
merkle_root: Hash,
plaintext_size_bytes: u64,
stored_size_bytes: u64,
chunk_count: u32,
fee_deposit: u64,
visibility: u8,
initial_access: Vec<AccessEntryV2>,
},
ActivateFileV2 {
merkle_root: Hash,
},
AbandonFileV2 {
merkle_root: Hash,
},
AcceptAssignmentV2 {
merkle_root: Hash,
chunk_indices: Vec<u32>,
},
AddAccessV2 {
merkle_root: Hash,
entry: AccessEntryV2,
},
RemoveAccessV2 {
merkle_root: Hash,
address: Address,
},
UpdateAccessV2 {
merkle_root: Hash,
address: Address,
new_entry: AccessEntryV2,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorageMetadataV2TxData {
pub operation: StorageMetadataOperationV2,
}
pub const SNIP_V2_ASSIGNMENT_CONTEXT: &str = "sumchain SNIP-V2 chunk-assignment v1";
pub fn assigned_archives(
merkle_root: &Hash,
snapshot_addresses: &[Address],
chunk_index: u32,
replication_factor: u32,
) -> Vec<Address> {
let mut addrs: Vec<Address> = snapshot_addresses.to_vec();
addrs.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
addrs.dedup_by(|a, b| a.as_bytes() == b.as_bytes());
let r_eff = (replication_factor as usize).min(addrs.len());
if r_eff == 0 {
return Vec::new();
}
let mut scored: Vec<(u64, Address)> = Vec::with_capacity(addrs.len());
let chunk_be = chunk_index.to_be_bytes();
for a in &addrs {
let mut input = [0u8; 56];
input[..32].copy_from_slice(merkle_root.as_bytes());
input[32..36].copy_from_slice(&chunk_be);
input[36..56].copy_from_slice(a.as_bytes());
let derived = blake3::derive_key(SNIP_V2_ASSIGNMENT_CONTEXT, &input);
let score = u64::from_be_bytes(derived[..8].try_into().expect("8-byte slice"));
scored.push((score, *a));
}
scored.sort_by(|x, y| {
x.0.cmp(&y.0)
.then_with(|| x.1.as_bytes().cmp(y.1.as_bytes()))
});
scored.into_iter().take(r_eff).map(|(_, a)| a).collect()
}
pub fn assigned_archives_presorted(
merkle_root: &Hash,
sorted_addresses: &[Address],
chunk_index: u32,
replication_factor: u32,
) -> Vec<Address> {
let r_eff = (replication_factor as usize).min(sorted_addresses.len());
if r_eff == 0 {
return Vec::new();
}
let mut scored: Vec<(u64, Address)> = Vec::with_capacity(sorted_addresses.len());
let chunk_be = chunk_index.to_be_bytes();
for a in sorted_addresses {
let mut input = [0u8; 56];
input[..32].copy_from_slice(merkle_root.as_bytes());
input[32..36].copy_from_slice(&chunk_be);
input[36..56].copy_from_slice(a.as_bytes());
let derived = blake3::derive_key(SNIP_V2_ASSIGNMENT_CONTEXT, &input);
let score = u64::from_be_bytes(derived[..8].try_into().expect("8-byte slice"));
scored.push((score, *a));
}
scored.sort_by(|x, y| {
x.0.cmp(&y.0)
.then_with(|| x.1.as_bytes().cmp(y.1.as_bytes()))
});
scored.into_iter().take(r_eff).map(|(_, a)| a).collect()
}
pub fn is_archive_assigned_to_chunk(
merkle_root: &Hash,
snapshot_addresses: &[Address],
chunk_index: u32,
replication_factor: u32,
archive: &Address,
) -> bool {
assigned_archives(merkle_root, snapshot_addresses, chunk_index, replication_factor)
.iter()
.any(|a| a.as_bytes() == archive.as_bytes())
}
#[cfg(test)]
mod assignment_tests {
use super::*;
fn fixture_archives() -> [Address; 5] {
let mut out = [Address::new([0u8; 20]); 5];
for j in 0..5 {
let label = format!("snip-v2-archive-{}", j + 1);
let h = blake3::hash(label.as_bytes());
out[j] = Address::from_slice(&h.as_bytes()[..20]).expect("20 bytes");
}
out
}
fn fixture_root(i: usize) -> Hash {
let label = format!("snip-v2-test-file-{}", i + 1);
let h = blake3::hash(label.as_bytes());
Hash::from_slice(h.as_bytes()).expect("32 bytes")
}
#[test]
fn appendix_c_fixture_construction_matches() {
let roots = [fixture_root(0), fixture_root(1), fixture_root(2)];
let archives = fixture_archives();
let expected_roots = [
"a5e2668f5022b62b5e4a1342aa0cfbfcbde2af2e3626b2fd57d6cf44e8f615a4",
"eed453d08260268bbd3675997f407174d901d842711f3addb6a2e05f776bccce",
"81137f39ea2a36bae5333d021052c44c0fc4763769c9988241e6669af16dfa74",
];
for (i, h) in expected_roots.iter().enumerate() {
assert_eq!(hex::encode(roots[i].as_bytes()), *h, "merkle_root[{}]", i);
}
let expected_archives = [
"37c4401960bd5a26d8ed7b676b1ef47c78fac5bb",
"f1a469857483cc381865df996b2cccd254878a16",
"8c6a62e786d02ae255a6f481580b95fe05bafffc",
"f8967230e6a6d6b5b4ce6816d43f406f24d3cdad",
"7e65c99f5b3994f2014187f24ee9230a027526bd",
];
for (j, h) in expected_archives.iter().enumerate() {
assert_eq!(hex::encode(archives[j].as_bytes()), *h, "archive[{}]", j);
}
}
#[test]
fn appendix_c_scores_for_root0_chunk0() {
let root = fixture_root(0);
let archives = fixture_archives();
let chunk_be = 0u32.to_be_bytes();
let cases: [(Address, u64); 5] = [
(archives[4], 0x4cd8130d5f5c7f55),
(archives[2], 0x73e9ad5ef9a6ba04),
(archives[1], 0xc8859dade38f7649),
(archives[3], 0xd2823bf6a2d883bb),
(archives[0], 0xf3c350979cb3f293),
];
for (archive, expected_score) in cases.iter() {
let mut input = [0u8; 56];
input[..32].copy_from_slice(root.as_bytes());
input[32..36].copy_from_slice(&chunk_be);
input[36..56].copy_from_slice(archive.as_bytes());
let derived = blake3::derive_key(SNIP_V2_ASSIGNMENT_CONTEXT, &input);
let score = u64::from_be_bytes(derived[..8].try_into().unwrap());
assert_eq!(
score,
*expected_score,
"score mismatch for archive {} — most likely cause: wrong context string \
(\"{}\" expected) or keyed_hash-vs-derive_key drift",
hex::encode(archive.as_bytes()),
SNIP_V2_ASSIGNMENT_CONTEXT,
);
}
}
#[test]
fn appendix_c_assignment_outputs() {
let snapshot = fixture_archives().to_vec();
let r0 = fixture_root(0);
let r1 = fixture_root(1);
let r2 = fixture_root(2);
struct Case<'a> {
root: &'a Hash,
chunk_index: u32,
r: u32,
expected_hex: &'a [&'a str],
}
let cases = [
Case { root: &r0, chunk_index: 0, r: 1, expected_hex: &[
"7e65c99f5b3994f2014187f24ee9230a027526bd",
]},
Case { root: &r0, chunk_index: 0, r: 3, expected_hex: &[
"7e65c99f5b3994f2014187f24ee9230a027526bd",
"8c6a62e786d02ae255a6f481580b95fe05bafffc",
"f1a469857483cc381865df996b2cccd254878a16",
]},
Case { root: &r0, chunk_index: 7, r: 3, expected_hex: &[
"f8967230e6a6d6b5b4ce6816d43f406f24d3cdad",
"37c4401960bd5a26d8ed7b676b1ef47c78fac5bb",
"7e65c99f5b3994f2014187f24ee9230a027526bd",
]},
Case { root: &r1, chunk_index: 0, r: 3, expected_hex: &[
"f1a469857483cc381865df996b2cccd254878a16",
"8c6a62e786d02ae255a6f481580b95fe05bafffc",
"37c4401960bd5a26d8ed7b676b1ef47c78fac5bb",
]},
Case { root: &r1, chunk_index: 1, r: 3, expected_hex: &[
"7e65c99f5b3994f2014187f24ee9230a027526bd",
"8c6a62e786d02ae255a6f481580b95fe05bafffc",
"f8967230e6a6d6b5b4ce6816d43f406f24d3cdad",
]},
Case { root: &r2, chunk_index: 42, r: 3, expected_hex: &[
"f1a469857483cc381865df996b2cccd254878a16",
"8c6a62e786d02ae255a6f481580b95fe05bafffc",
"f8967230e6a6d6b5b4ce6816d43f406f24d3cdad",
]},
Case { root: &r2, chunk_index: 42, r: 5, expected_hex: &[
"f1a469857483cc381865df996b2cccd254878a16",
"8c6a62e786d02ae255a6f481580b95fe05bafffc",
"f8967230e6a6d6b5b4ce6816d43f406f24d3cdad",
"37c4401960bd5a26d8ed7b676b1ef47c78fac5bb",
"7e65c99f5b3994f2014187f24ee9230a027526bd",
]},
Case { root: &r2, chunk_index: 42, r: 7, expected_hex: &[
"f1a469857483cc381865df996b2cccd254878a16",
"8c6a62e786d02ae255a6f481580b95fe05bafffc",
"f8967230e6a6d6b5b4ce6816d43f406f24d3cdad",
"37c4401960bd5a26d8ed7b676b1ef47c78fac5bb",
"7e65c99f5b3994f2014187f24ee9230a027526bd",
]},
];
for c in &cases {
let got = assigned_archives(c.root, &snapshot, c.chunk_index, c.r);
let got_hex: Vec<String> =
got.iter().map(|a| hex::encode(a.as_bytes())).collect();
let want_hex: Vec<String> = c.expected_hex.iter().map(|s| s.to_string()).collect();
assert_eq!(
got_hex, want_hex,
"case (root={}, chunk_index={}, R={}) — assignment drift",
hex::encode(c.root.as_bytes()),
c.chunk_index,
c.r,
);
}
}
#[test]
fn assigned_archives_is_snapshot_order_independent() {
let mut snap_a = fixture_archives().to_vec();
let mut snap_b = snap_a.clone();
snap_b.reverse();
let snap_c = vec![snap_a[2], snap_a[0], snap_a[4], snap_a[1], snap_a[3]];
let root = fixture_root(2);
let a = assigned_archives(&root, &snap_a, 42, 3);
let b = assigned_archives(&root, &snap_b, 42, 3);
let c = assigned_archives(&root, &snap_c, 42, 3);
assert_eq!(a, b);
assert_eq!(a, c);
snap_a.push(snap_a[0]);
snap_a.push(snap_a[2]);
let d = assigned_archives(&root, &snap_a, 42, 3);
assert_eq!(a, d);
}
#[test]
fn is_archive_assigned_matches_assigned_archives() {
let snap = fixture_archives().to_vec();
let root = fixture_root(0);
let assigned = assigned_archives(&root, &snap, 0, 3);
for a in &snap {
let in_set = assigned.iter().any(|x| x.as_bytes() == a.as_bytes());
assert_eq!(
is_archive_assigned_to_chunk(&root, &snap, 0, 3, a),
in_set,
"is_archive_assigned_to_chunk disagrees with assigned_archives for {}",
hex::encode(a.as_bytes()),
);
}
}
#[test]
fn empty_snapshot_returns_empty() {
let root = fixture_root(0);
assert!(assigned_archives(&root, &[], 0, 3).is_empty());
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorageMetadataV2 {
pub merkle_root: Hash,
pub owner: Address,
pub plaintext_size_bytes: u64,
pub stored_size_bytes: u64,
pub chunk_count: u32,
pub fee_pool: u64,
pub created_at: u64,
pub activated_at_height: Option<u64>,
pub abandoned_at_height: Option<u64>,
pub assignment_height: u64,
pub visibility: FileVisibilityV2,
pub lifecycle: FileLifecycleV2,
pub access_list: Vec<AccessEntryV2>,
pub predecessor_root: Option<Hash>,
}