forked from Keelrleek/SoulStream
community: spaces, an owner-signed roster with epochs, a bounded window of signed lines, bodies sealed to a rotating key when private
This commit is contained in:
1 parent
4378c4c0cc
commit
8b511b20e7
14 files changed
+2913
-13
No files matched your search
@@ -13,8 +13,10 @@ serde.workspace = true
|
||||
bincode = "1"
|
||||
ed25519-dalek = { version = "2", default-features = false, features = ["alloc"] }
|
||||
blake3 = "1"
|
||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||
chacha20poly1305 = "0.10"
|
||||
x25519-dalek = { version = "2", default-features = false, features = ["static_secrets", "alloc", "precomputed-tables"] }
|
||||
# no default features: the OS-RNG feature would drag getrandom into the
|
||||
# wasm32 contract build; this crate draws no entropy of its own anyway
|
||||
chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] }
|
||||
|
||||
[dev-dependencies]
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
@@ -19,6 +19,7 @@ pub mod slsk_link;
|
||||
pub mod friend;
|
||||
pub mod playlist;
|
||||
pub mod inbox;
|
||||
pub mod space;
|
||||
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
pub const SIGN_DOMAIN: &str = "soulstream.profile/v1";
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
//! A SoulStream Space — the persistent community the F6 decision chose to
|
||||
//! build on River's patterns rather than River's format: ONE contract per
|
||||
//! Space addressed by (owner, space id); an owner-signed CHECKPOINT that IS
|
||||
//! the authority (name, mode, the member roster, the pinned playlist) with
|
||||
//! a monotonic epoch, so a removal is a new epoch without that member and
|
||||
//! nothing about membership is ever inferred from who talked recently; a
|
||||
//! bounded window of member-signed MESSAGES admitted only from the current
|
||||
//! roster; and, for a private Space, message bodies sealed to a per-epoch
|
||||
//! space key the owner hands members through their encrypted inboxes —
|
||||
//! rotated on every removal. Deterministic merge, canonical-only
|
||||
//! validation: the F5 bouncer, again. BYTE-COMPATIBILITY IS THE CONTRACT:
|
||||
//! `crates/space-contract` compiles this very module into the wasm.
|
||||
//!
|
||||
//! Signing rides `soulstream.friend/v1` like the inbox does (the delegate
|
||||
//! generation in the app has no space domain yet).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
pub const PARAMS_FAMILY: &[u8] = b"soulstream.space.v1";
|
||||
pub const SIGN_DOMAIN: &str = "soulstream.friend/v1";
|
||||
pub const MAX_STATE_BYTES: usize = 1024 * 1024;
|
||||
pub const MAX_MEMBERS: usize = 256;
|
||||
pub const MAX_MESSAGES: usize = 500;
|
||||
pub const DEFAULT_MESSAGE_CAP: u32 = 200;
|
||||
pub const MAX_BODY_BYTES: usize = 4096;
|
||||
pub const MAX_NAME_BYTES: usize = 64;
|
||||
pub const MAX_DESC_BYTES: usize = 512;
|
||||
pub const MAX_PER_AUTHOR_UNCAPPED: usize = 64;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Role {
|
||||
Owner,
|
||||
Member,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Member {
|
||||
pub key: [u8; 32],
|
||||
pub role: Role,
|
||||
pub since_epoch: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PlaylistRef {
|
||||
pub owner: [u8; 32],
|
||||
pub uuid: [u8; 16],
|
||||
}
|
||||
|
||||
/// The owner's word on what the Space is and who is in it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Checkpoint {
|
||||
pub schema_version: u32,
|
||||
pub space_id: [u8; 16],
|
||||
pub owner: [u8; 32],
|
||||
pub epoch: u64,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub private: bool,
|
||||
pub members: Vec<Member>,
|
||||
pub pinned: Option<PlaylistRef>,
|
||||
pub message_cap: u32,
|
||||
pub sig: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CheckpointSigBody<'a> {
|
||||
schema_version: u32,
|
||||
space_id: &'a [u8; 16],
|
||||
owner: &'a [u8; 32],
|
||||
epoch: u64,
|
||||
name: &'a str,
|
||||
description: &'a str,
|
||||
private: bool,
|
||||
members: &'a [Member],
|
||||
pinned: &'a Option<PlaylistRef>,
|
||||
message_cap: u32,
|
||||
}
|
||||
|
||||
pub fn checkpoint_sig_body(c: &Checkpoint) -> Vec<u8> {
|
||||
bincode::serialize(&CheckpointSigBody {
|
||||
schema_version: c.schema_version,
|
||||
space_id: &c.space_id,
|
||||
owner: &c.owner,
|
||||
epoch: c.epoch,
|
||||
name: &c.name,
|
||||
description: &c.description,
|
||||
private: c.private,
|
||||
members: &c.members,
|
||||
pinned: &c.pinned,
|
||||
message_cap: c.message_cap,
|
||||
})
|
||||
.expect("serializes")
|
||||
}
|
||||
pub fn checkpoint_sign_payload(c: &Checkpoint) -> Vec<u8> {
|
||||
blake3::hash(&checkpoint_sig_body(c)).as_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// One line in the Space. Public: `body` is UTF-8. Private: `body` is the
|
||||
/// ChaCha20-Poly1305 seal of the UTF-8 under the epoch's space key.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Message {
|
||||
pub space_id: [u8; 16],
|
||||
pub epoch: u64,
|
||||
pub msg_id: [u8; 16],
|
||||
pub author: [u8; 32],
|
||||
pub created_at: u64,
|
||||
pub body: Vec<u8>,
|
||||
pub sig: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MessageSigBody<'a> {
|
||||
space_id: &'a [u8; 16],
|
||||
epoch: u64,
|
||||
msg_id: &'a [u8; 16],
|
||||
author: &'a [u8; 32],
|
||||
created_at: u64,
|
||||
body: &'a [u8],
|
||||
}
|
||||
|
||||
pub fn message_sig_body(m: &Message) -> Vec<u8> {
|
||||
bincode::serialize(&MessageSigBody {
|
||||
space_id: &m.space_id,
|
||||
epoch: m.epoch,
|
||||
msg_id: &m.msg_id,
|
||||
author: &m.author,
|
||||
created_at: m.created_at,
|
||||
body: &m.body,
|
||||
})
|
||||
.expect("serializes")
|
||||
}
|
||||
pub fn message_sign_payload(m: &Message) -> Vec<u8> {
|
||||
blake3::hash(&message_sig_body(m)).as_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// The AAD for a sealed body: everything about the message but the body.
|
||||
fn message_aad(m: &Message) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity(80);
|
||||
v.extend_from_slice(&m.space_id);
|
||||
v.extend_from_slice(&m.epoch.to_le_bytes());
|
||||
v.extend_from_slice(&m.msg_id);
|
||||
v.extend_from_slice(&m.author);
|
||||
v.extend_from_slice(&m.created_at.to_le_bytes());
|
||||
v
|
||||
}
|
||||
fn body_key(space_key: &[u8; 32]) -> [u8; 32] {
|
||||
blake3::derive_key("soulstream.space-body.v1", space_key)
|
||||
}
|
||||
fn body_nonce(msg_id: &[u8; 16]) -> [u8; 12] {
|
||||
blake3::hash(msg_id).as_bytes()[..12].try_into().unwrap()
|
||||
}
|
||||
|
||||
/// Seal a private message body. Call BEFORE signing (the signature covers
|
||||
/// the sealed bytes). `m.body` must hold the plaintext going in.
|
||||
pub fn seal_body(space_key: &[u8; 32], m: &Message) -> Vec<u8> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
|
||||
let key = body_key(space_key);
|
||||
ChaCha20Poly1305::new(Key::from_slice(&key))
|
||||
.encrypt(
|
||||
Nonce::from_slice(&body_nonce(&m.msg_id)),
|
||||
Payload { msg: &m.body, aad: &message_aad(m) },
|
||||
)
|
||||
.expect("seal")
|
||||
}
|
||||
|
||||
/// Open a private message body. None = wrong key for this epoch, or tampered.
|
||||
pub fn open_body(space_key: &[u8; 32], m: &Message) -> Option<Vec<u8>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
|
||||
let key = body_key(space_key);
|
||||
ChaCha20Poly1305::new(Key::from_slice(&key))
|
||||
.decrypt(
|
||||
Nonce::from_slice(&body_nonce(&m.msg_id)),
|
||||
Payload { msg: &m.body, aad: &message_aad(m) },
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpaceState {
|
||||
pub schema_version: u32,
|
||||
pub space_id: [u8; 16],
|
||||
pub checkpoint: Option<Checkpoint>,
|
||||
pub messages: Vec<Message>,
|
||||
}
|
||||
|
||||
pub fn make_params(owner: &[u8; 32], space_id: &[u8; 16]) -> Vec<u8> {
|
||||
let mut p = Vec::with_capacity(PARAMS_FAMILY.len() + 48);
|
||||
p.extend_from_slice(PARAMS_FAMILY);
|
||||
p.extend_from_slice(owner);
|
||||
p.extend_from_slice(space_id);
|
||||
p
|
||||
}
|
||||
|
||||
pub fn params_parts(params: &[u8]) -> Option<([u8; 32], [u8; 16])> {
|
||||
let t = PARAMS_FAMILY.len();
|
||||
if params.len() != t + 48 || ¶ms[..t] != PARAMS_FAMILY {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
params[t..t + 32].try_into().unwrap(),
|
||||
params[t + 32..t + 48].try_into().unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn checkpoint_valid(owner: &[u8; 32], space_id: &[u8; 16], c: &Checkpoint) -> bool {
|
||||
c.schema_version == SCHEMA_VERSION
|
||||
&& &c.space_id == space_id
|
||||
&& &c.owner == owner
|
||||
&& c.epoch >= 1
|
||||
&& c.name.len() <= MAX_NAME_BYTES
|
||||
&& c.description.len() <= MAX_DESC_BYTES
|
||||
&& !c.members.is_empty()
|
||||
&& c.members.len() <= MAX_MEMBERS
|
||||
&& c.message_cap >= 1
|
||||
&& c.message_cap as usize <= MAX_MESSAGES
|
||||
&& c.members.iter().any(|m| &m.key == owner && m.role == Role::Owner)
|
||||
&& crate::verify_sig_in(SIGN_DOMAIN, owner, &checkpoint_sign_payload(c), &c.sig)
|
||||
}
|
||||
|
||||
pub fn message_valid(space_id: &[u8; 16], c: &Checkpoint, m: &Message) -> bool {
|
||||
&m.space_id == space_id
|
||||
&& m.epoch <= c.epoch
|
||||
&& m.body.len() <= MAX_BODY_BYTES + 16
|
||||
&& c.members.iter().any(|mem| mem.key == m.author)
|
||||
&& crate::verify_sig_in(SIGN_DOMAIN, &m.author, &message_sign_payload(m), &m.sig)
|
||||
}
|
||||
|
||||
/// Deterministic multi-writer merge: the highest valid (epoch, hash)
|
||||
/// checkpoint wins; messages are admitted only from ITS roster, deduped by
|
||||
/// id (smaller content hash wins a collision), fairly bounded per author,
|
||||
/// then the newest `message_cap` kept in (created_at, id) order.
|
||||
pub fn merge_space(owner: &[u8; 32], space_id: &[u8; 16], candidates: &[&[u8]]) -> SpaceState {
|
||||
let mut best: Option<(u64, [u8; 32], Checkpoint)> = None;
|
||||
let mut parsed: Vec<SpaceState> = Vec::new();
|
||||
for bytes in candidates {
|
||||
if bytes.is_empty() || bytes.len() > MAX_STATE_BYTES {
|
||||
continue;
|
||||
}
|
||||
let Ok(st) = bincode::deserialize::<SpaceState>(bytes) else { continue };
|
||||
if st.schema_version != SCHEMA_VERSION || &st.space_id != space_id {
|
||||
continue;
|
||||
}
|
||||
if let Some(c) = &st.checkpoint {
|
||||
if checkpoint_valid(owner, space_id, c) {
|
||||
let rank = (c.epoch, *blake3::hash(&checkpoint_sig_body(c)).as_bytes());
|
||||
let better = match &best {
|
||||
None => true,
|
||||
Some((e, h, _)) => rank > (*e, *h),
|
||||
};
|
||||
if better {
|
||||
best = Some((rank.0, rank.1, c.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
parsed.push(st);
|
||||
}
|
||||
let Some((_, _, checkpoint)) = best else {
|
||||
return SpaceState { schema_version: SCHEMA_VERSION, space_id: *space_id, checkpoint: None, messages: vec![] };
|
||||
};
|
||||
|
||||
let mut by_id: BTreeMap<[u8; 16], (Message, [u8; 32])> = BTreeMap::new();
|
||||
for st in &parsed {
|
||||
for m in &st.messages {
|
||||
if !message_valid(space_id, &checkpoint, m) {
|
||||
continue;
|
||||
}
|
||||
let h = *blake3::hash(&bincode::serialize(m).unwrap()).as_bytes();
|
||||
match by_id.get(&m.msg_id) {
|
||||
Some((_, existing)) if existing <= &h => {}
|
||||
_ => {
|
||||
by_id.insert(m.msg_id, (m.clone(), h));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut all: Vec<Message> = by_id.into_values().map(|(m, _)| m).collect();
|
||||
all.sort_by_key(|m| (m.created_at, m.msg_id));
|
||||
// Per-author fairness before the global window: one voice cannot fill
|
||||
// the whole window and push everyone else out.
|
||||
let mut per_author: BTreeMap<[u8; 32], usize> = BTreeMap::new();
|
||||
let mut kept: Vec<Message> = Vec::with_capacity(all.len());
|
||||
for m in all.into_iter().rev() {
|
||||
let n = per_author.entry(m.author).or_insert(0);
|
||||
if *n < MAX_PER_AUTHOR_UNCAPPED {
|
||||
*n += 1;
|
||||
kept.push(m);
|
||||
}
|
||||
}
|
||||
kept.reverse();
|
||||
let cap = checkpoint.message_cap as usize;
|
||||
if kept.len() > cap {
|
||||
kept.drain(..kept.len() - cap);
|
||||
}
|
||||
SpaceState { schema_version: SCHEMA_VERSION, space_id: *space_id, checkpoint: Some(checkpoint), messages: kept }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
fn sign(key: &SigningKey, payload: &[u8]) -> Vec<u8> {
|
||||
key.sign(&crate::signable(SIGN_DOMAIN, payload)).to_bytes().to_vec()
|
||||
}
|
||||
fn checkpoint(owner: &SigningKey, epoch: u64, members: &[&SigningKey], private: bool) -> Checkpoint {
|
||||
let mut c = Checkpoint {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
space_id: [4; 16],
|
||||
owner: owner.verifying_key().to_bytes(),
|
||||
epoch,
|
||||
name: "Late Night Ambient".into(),
|
||||
description: String::new(),
|
||||
private,
|
||||
members: std::iter::once(Member { key: owner.verifying_key().to_bytes(), role: Role::Owner, since_epoch: 1 })
|
||||
.chain(members.iter().map(|k| Member { key: k.verifying_key().to_bytes(), role: Role::Member, since_epoch: epoch }))
|
||||
.collect(),
|
||||
pinned: None,
|
||||
message_cap: DEFAULT_MESSAGE_CAP,
|
||||
sig: vec![],
|
||||
};
|
||||
c.sig = sign(owner, &checkpoint_sign_payload(&c));
|
||||
c
|
||||
}
|
||||
fn message(author: &SigningKey, epoch: u64, id: u8, at: u64, key: Option<&[u8; 32]>) -> Message {
|
||||
let mut m = Message {
|
||||
space_id: [4; 16],
|
||||
epoch,
|
||||
msg_id: [id; 16],
|
||||
author: author.verifying_key().to_bytes(),
|
||||
created_at: at,
|
||||
body: b"hello".to_vec(),
|
||||
sig: vec![],
|
||||
};
|
||||
if let Some(k) = key {
|
||||
m.body = seal_body(k, &m);
|
||||
}
|
||||
m.sig = sign(author, &message_sign_payload(&m));
|
||||
m
|
||||
}
|
||||
fn state(c: Option<&Checkpoint>, msgs: &[Message]) -> Vec<u8> {
|
||||
bincode::serialize(&SpaceState { schema_version: SCHEMA_VERSION, space_id: [4; 16], checkpoint: c.cloned(), messages: msgs.to_vec() }).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roster_is_the_authority_and_removal_is_an_epoch() {
|
||||
let owner = SigningKey::from_bytes(&[1; 32]);
|
||||
let bob = SigningKey::from_bytes(&[2; 32]);
|
||||
let mallory = SigningKey::from_bytes(&[3; 32]);
|
||||
let ok = owner.verifying_key().to_bytes();
|
||||
let c1 = checkpoint(&owner, 1, &[&bob], false);
|
||||
let m_bob = message(&bob, 1, 10, 100, None);
|
||||
let m_mal = message(&mallory, 1, 11, 101, None);
|
||||
let merged = merge_space(&ok, &[4; 16], &[&state(Some(&c1), &[]), &state(None, &[m_bob.clone(), m_mal])]);
|
||||
assert_eq!(merged.messages.len(), 1, "an off-roster author never enters");
|
||||
// the canonical state merges to itself
|
||||
let canon = bincode::serialize(&merged).unwrap();
|
||||
assert_eq!(bincode::serialize(&merge_space(&ok, &[4; 16], &[&canon])).unwrap(), canon);
|
||||
// a newer epoch without bob drops bob's messages; an old checkpoint cannot restore him
|
||||
let c2 = checkpoint(&owner, 2, &[], false);
|
||||
let after = merge_space(&ok, &[4; 16], &[&canon, &state(Some(&c2), &[])]);
|
||||
assert_eq!(after.checkpoint.as_ref().map(|c| c.epoch), Some(2));
|
||||
assert!(after.messages.is_empty());
|
||||
let replay = merge_space(&ok, &[4; 16], &[&bincode::serialize(&after).unwrap(), &state(Some(&c1), &[m_bob])]);
|
||||
assert_eq!(replay.checkpoint.as_ref().map(|c| c.epoch), Some(2));
|
||||
// a checkpoint signed by a non-owner is not a checkpoint
|
||||
let mut forged = checkpoint(&owner, 3, &[&mallory], false);
|
||||
forged.sig = sign(&mallory, &checkpoint_sign_payload(&forged));
|
||||
assert!(!checkpoint_valid(&ok, &[4; 16], &forged));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_bodies_open_only_with_the_epoch_key() {
|
||||
let owner = SigningKey::from_bytes(&[1; 32]);
|
||||
let key = [9u8; 32];
|
||||
let m = message(&owner, 1, 7, 100, Some(&key));
|
||||
assert_eq!(open_body(&key, &m).as_deref(), Some(&b"hello"[..]));
|
||||
assert_eq!(open_body(&[8u8; 32], &m), None);
|
||||
let mut t = m.clone();
|
||||
t.created_at += 1;
|
||||
assert_eq!(open_body(&key, &t), None, "the AAD binds the header");
|
||||
}
|
||||
}
|
||||
Generated
+1449
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "soulstream-space-contract"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "The SoulStream Space contract — a thin shell over persona_state::space::merge_space. Built to wasm32-unknown-unknown and bundled into the app; its code hash is a commitment (see src-tauri/assets)."
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
freenet-stdlib = { version = "=0.8.5" }
|
||||
persona-state = { path = "../persona-state" }
|
||||
bincode = "1"
|
||||
blake3 = "1"
|
||||
|
||||
[features]
|
||||
default = ["freenet-main-contract", "contract"]
|
||||
freenet-main-contract = []
|
||||
contract = ["freenet-stdlib/contract"]
|
||||
trace = ["freenet-stdlib/trace"]
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
|
||||
# Its own workspace: this crate targets wasm32 and must never join the
|
||||
# app's host build.
|
||||
[workspace]
|
||||
@@ -0,0 +1,118 @@
|
||||
//! The SoulStream Space contract. Multi-writer: the owner offers
|
||||
//! checkpoints, members offer messages; `persona_state::space::merge_space`
|
||||
//! is the bouncer (owner signature, roster, dedup, per-author fairness,
|
||||
//! the window). Deterministic by construction, so every delivery order
|
||||
//! converges; a state that does not merge to itself is refused.
|
||||
//!
|
||||
//! Build: `cargo build --release --target wasm32-unknown-unknown` in this
|
||||
//! directory; copy `target/wasm32-unknown-unknown/release/
|
||||
//! soulstream_space_contract.wasm` to `src-tauri/assets/space_contract.wasm`
|
||||
//! and re-pin the code hash in `src-tauri/src/spaces.rs`.
|
||||
|
||||
use freenet_stdlib::prelude::*;
|
||||
use persona_state::space as t;
|
||||
|
||||
pub struct Contract;
|
||||
|
||||
fn parts(parameters: &Parameters<'static>) -> Result<([u8; 32], [u8; 16]), ContractError> {
|
||||
t::params_parts(parameters.as_ref())
|
||||
.ok_or_else(|| ContractError::Other("params are not family+owner+space".into()))
|
||||
}
|
||||
|
||||
fn collect<'a>(state: &'a State<'static>, data: &'a [UpdateData<'static>]) -> (Vec<&'a [u8]>, bool) {
|
||||
let mut candidates: Vec<&[u8]> = Vec::new();
|
||||
if !state.as_ref().is_empty() {
|
||||
candidates.push(state.as_ref());
|
||||
}
|
||||
let mut any_parse = false;
|
||||
let mut push = |b: &'a [u8]| {
|
||||
if !b.is_empty() {
|
||||
any_parse |= bincode::deserialize::<t::SpaceState>(b).is_ok();
|
||||
candidates.push(b);
|
||||
}
|
||||
};
|
||||
for ud in data {
|
||||
match ud {
|
||||
UpdateData::State(s) => push(s.as_ref()),
|
||||
UpdateData::Delta(d) => push(d.as_ref()),
|
||||
UpdateData::StateAndDelta { state, delta } => {
|
||||
push(state.as_ref());
|
||||
push(delta.as_ref());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(candidates, any_parse)
|
||||
}
|
||||
|
||||
#[contract]
|
||||
impl ContractInterface for Contract {
|
||||
fn validate_state(
|
||||
parameters: Parameters<'static>,
|
||||
state: State<'static>,
|
||||
_related: RelatedContracts<'static>,
|
||||
) -> Result<ValidateResult, ContractError> {
|
||||
let (owner, space_id) = parts(¶meters)?;
|
||||
let bytes = state.as_ref();
|
||||
if bytes.is_empty() {
|
||||
return Ok(ValidateResult::Valid);
|
||||
}
|
||||
if bytes.len() > t::MAX_STATE_BYTES {
|
||||
return Err(ContractError::Other(format!(
|
||||
"space state {} bytes over cap {}",
|
||||
bytes.len(),
|
||||
t::MAX_STATE_BYTES
|
||||
)));
|
||||
}
|
||||
let merged = t::merge_space(&owner, &space_id, &[bytes]);
|
||||
let canon = bincode::serialize(&merged).map_err(|e| ContractError::Deser(e.to_string()))?;
|
||||
if canon != bytes {
|
||||
return Err(ContractError::Other(
|
||||
"state is not canonical (unsigned, off-roster, or over the window)".into(),
|
||||
));
|
||||
}
|
||||
Ok(ValidateResult::Valid)
|
||||
}
|
||||
|
||||
fn update_state(
|
||||
parameters: Parameters<'static>,
|
||||
state: State<'static>,
|
||||
data: Vec<UpdateData<'static>>,
|
||||
) -> Result<UpdateModification<'static>, ContractError> {
|
||||
let (owner, space_id) = parts(¶meters)?;
|
||||
let (candidates, any_parse) = collect(&state, &data);
|
||||
if !any_parse && !data.is_empty() {
|
||||
return Err(ContractError::InvalidUpdate);
|
||||
}
|
||||
let merged = t::merge_space(&owner, &space_id, &candidates);
|
||||
let bytes = bincode::serialize(&merged).map_err(|e| ContractError::Deser(e.to_string()))?;
|
||||
Ok(UpdateModification::valid(State::from(bytes)))
|
||||
}
|
||||
|
||||
fn summarize_state(
|
||||
parameters: Parameters<'static>,
|
||||
state: State<'static>,
|
||||
) -> Result<StateSummary<'static>, ContractError> {
|
||||
let _ = parts(¶meters)?;
|
||||
if state.as_ref().is_empty() {
|
||||
return Ok(StateSummary::from(Vec::new()));
|
||||
}
|
||||
Ok(StateSummary::from(blake3::hash(state.as_ref()).as_bytes().to_vec()))
|
||||
}
|
||||
|
||||
fn get_state_delta(
|
||||
parameters: Parameters<'static>,
|
||||
state: State<'static>,
|
||||
summary: StateSummary<'static>,
|
||||
) -> Result<StateDelta<'static>, ContractError> {
|
||||
let _ = parts(¶meters)?;
|
||||
if state.as_ref().is_empty() {
|
||||
return Ok(StateDelta::from(Vec::new()));
|
||||
}
|
||||
if summary.as_ref() == blake3::hash(state.as_ref()).as_bytes() {
|
||||
Ok(StateDelta::from(Vec::new()))
|
||||
} else {
|
||||
Ok(StateDelta::from(state.as_ref().to_vec()))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user