forked from Keelrleek/SoulStream
1051 lines
41 KiB
Rust
1051 lines
41 KiB
Rust
//! Spaces, product side (F6 decision → the app). One contract per Space at
|
|
//! (owner, space id): the owner's signed CHECKPOINT is the authority —
|
|
//! name, mode, roster, pinned playlist — with an epoch that moves on every
|
|
//! roster change; members post signed MESSAGES into a bounded window; a
|
|
//! private Space seals bodies to a per-epoch space key the owner hands out
|
|
//! through members' encrypted inboxes and rotates on every removal.
|
|
//! Invitations are owner-authored: to invite is to add the key to the
|
|
//! roster and tell the person, sealed, where the Space is.
|
|
//!
|
|
//! What this device knows about its Spaces — which ones, its role, the
|
|
//! epoch keys it was given — lives in `<app_data>/freenet/spaces.json`.
|
|
//! Losing that file loses the keys; the owner can re-send them.
|
|
//!
|
|
//! v1 keeps members as their persona keys (a joined Space is a linked
|
|
//! identity); the room-local member key the design describes for
|
|
//! unlinkability is the next step, not a different contract.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::path::PathBuf;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use freenet_adapter::FreenetAdapter;
|
|
use persona_state::space as sp;
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::{Emitter, Manager};
|
|
|
|
use crate::inbox::{self, Cargo, SpaceInvite, SpaceJoin, SpaceKeyCargo, SpaceLeave};
|
|
use crate::ledger;
|
|
use crate::persona;
|
|
use crate::slsk_link;
|
|
use crate::state::AppState;
|
|
|
|
const SPACE_WASM: &[u8] = include_bytes!("../assets/space_contract.wasm");
|
|
pub const EVENT: &str = "community:spaces";
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct SpaceRecord {
|
|
owner: String,
|
|
space_id: String,
|
|
name: String,
|
|
private: bool,
|
|
my_role: String,
|
|
joined_at: u64,
|
|
/// epoch → space key (b58), for private Spaces.
|
|
#[serde(default)]
|
|
keys: BTreeMap<String, String>,
|
|
#[serde(default)]
|
|
last_epoch: u64,
|
|
/// The key we wear in THIS Space (b58 secret), minted on the invitation.
|
|
/// A member is on the roster under this and nothing else, so the roster
|
|
/// says nothing about who they are anywhere else. Absent for a Space we
|
|
/// own: an owner is their persona there by construction, since the
|
|
/// Space's address is derived from it.
|
|
#[serde(default)]
|
|
room_secret: Option<String>,
|
|
/// OWNER ONLY, and never published: room key (b58) → the persona that
|
|
/// proved it. This is the mapping the whole scheme exists to keep off the
|
|
/// network, so it lives here and travels nowhere.
|
|
#[serde(default)]
|
|
roster: BTreeMap<String, String>,
|
|
/// OWNER ONLY: personas we have invited and will accept a join from.
|
|
#[serde(default)]
|
|
invited: BTreeMap<String, u64>,
|
|
/// When we last told the owner which key we would wear. The answer is one
|
|
/// sealed message and it can simply fail to land — the owner's descriptor
|
|
/// may not resolve on a young node — and a member who is never seated has
|
|
/// no way back in. Opening the Space re-sends after this goes stale.
|
|
#[serde(default)]
|
|
join_sent_at: u64,
|
|
}
|
|
|
|
#[derive(Default, Serialize, Deserialize)]
|
|
struct Ledger {
|
|
#[serde(default)]
|
|
spaces: BTreeMap<String, SpaceRecord>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct SpaceListItem {
|
|
pub id: String,
|
|
pub owner: String,
|
|
pub space_id: String,
|
|
pub name: String,
|
|
pub private: bool,
|
|
pub my_role: String,
|
|
pub last_epoch: u64,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct MemberView {
|
|
pub key: String,
|
|
pub role: String,
|
|
pub username: Option<String>,
|
|
pub is_me: bool,
|
|
pub since_epoch: u64,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct MessageView {
|
|
pub id: String,
|
|
pub author: String,
|
|
pub username: Option<String>,
|
|
pub is_me: bool,
|
|
pub created_at: u64,
|
|
pub epoch: u64,
|
|
/// None when sealed to a key this device does not hold.
|
|
pub text: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct SpaceView {
|
|
pub id: String,
|
|
pub owner: String,
|
|
pub space_id: String,
|
|
pub name: String,
|
|
pub description: String,
|
|
pub private: bool,
|
|
pub epoch: u64,
|
|
pub my_role: String,
|
|
pub message_cap: u32,
|
|
pub members: Vec<MemberView>,
|
|
pub pinned_owner: Option<String>,
|
|
pub pinned_uuid: Option<String>,
|
|
pub messages: Vec<MessageView>,
|
|
pub can_post: bool,
|
|
pub has_key: bool,
|
|
/// The contract exists on the network (a Space someone invited us to
|
|
/// may not have propagated to this node yet).
|
|
pub reachable: bool,
|
|
}
|
|
|
|
fn now_secs() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0)
|
|
}
|
|
fn rand16() -> [u8; 16] {
|
|
use rand::RngCore;
|
|
let mut n = [0u8; 16];
|
|
rand::thread_rng().fill_bytes(&mut n);
|
|
n
|
|
}
|
|
fn rand32() -> [u8; 32] {
|
|
use rand::RngCore;
|
|
let mut n = [0u8; 32];
|
|
rand::thread_rng().fill_bytes(&mut n);
|
|
n
|
|
}
|
|
fn b58(bytes: &[u8]) -> String {
|
|
bs58::encode(bytes).into_string()
|
|
}
|
|
fn key_of(s: &str) -> Result<[u8; 32], String> {
|
|
inbox::key_of(s)
|
|
}
|
|
fn id_of(s: &str) -> Result<[u8; 16], String> {
|
|
bs58::decode(s)
|
|
.into_vec()
|
|
.map_err(|e| format!("bad id: {e}"))?
|
|
.as_slice()
|
|
.try_into()
|
|
.map_err(|_| "id is not 16 bytes".to_string())
|
|
}
|
|
fn cap(s: &str, max: usize) -> String {
|
|
if s.len() <= max {
|
|
return s.to_string();
|
|
}
|
|
let mut end = max;
|
|
while end > 0 && !s.is_char_boundary(end) {
|
|
end -= 1;
|
|
}
|
|
s[..end].to_string()
|
|
}
|
|
fn record_id(owner: &str, space_id: &str) -> String {
|
|
format!("{owner}:{space_id}")
|
|
}
|
|
|
|
fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
|
Ok(crate::paths::app_data(app)
|
|
.map_err(|e| e.to_string())?
|
|
.join("freenet")
|
|
.join(LEDGER_FILE))
|
|
}
|
|
/// Both the file the ledger lives in and the key its write lock is taken on.
|
|
const LEDGER_FILE: &str = "spaces.json";
|
|
|
|
fn load(app: &tauri::AppHandle) -> Ledger {
|
|
path(app)
|
|
.ok()
|
|
.and_then(|p| std::fs::read(p).ok())
|
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
|
.unwrap_or_default()
|
|
}
|
|
fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> {
|
|
let p = path(app)?;
|
|
if let Some(dir) = p.parent() {
|
|
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
|
}
|
|
std::fs::write(&p, serde_json::to_vec_pretty(l).map_err(|e| e.to_string())?)
|
|
.map_err(|e| e.to_string())?;
|
|
let _ = app.emit(EVENT, ());
|
|
Ok(())
|
|
}
|
|
|
|
async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> {
|
|
let adapter = app.state::<AppState>().freenet.clone();
|
|
let info = persona::current(&adapter)
|
|
.await?
|
|
.ok_or("create a SoulStream identity first")?;
|
|
key_of(&info.public_key)
|
|
}
|
|
/// The key from the newest epoch below `epoch`. The map is keyed by a
|
|
/// STRING, so it iterates lexicographically — `.last()` picks "9" over "10"
|
|
/// and hands back a key from six epochs ago the moment a Space gets busy.
|
|
fn key_below(rec: &SpaceRecord, epoch: u64) -> Option<String> {
|
|
rec.keys
|
|
.iter()
|
|
.filter_map(|(e, k)| e.parse::<u64>().ok().filter(|n| *n < epoch).map(|n| (n, k.clone())))
|
|
.max_by_key(|(n, _)| *n)
|
|
.map(|(_, k)| k)
|
|
}
|
|
|
|
/// The secret we sign with inside one Space, minted the first time we need
|
|
/// it. Custody matches the inbox box key: it sits in the app folder, not in
|
|
/// the delegate — a persona restored on another device mints fresh room keys
|
|
/// and has to be re-admitted.
|
|
fn room_secret_of(rec: &SpaceRecord) -> Option<[u8; 32]> {
|
|
rec.room_secret.as_deref().and_then(|k| key_of(k).ok())
|
|
}
|
|
|
|
/// The key we wear in this Space: our persona when we own it, our room key
|
|
/// otherwise. Every membership and authorship check is against THIS.
|
|
fn worn_key(rec: &SpaceRecord, me: &[u8; 32]) -> [u8; 32] {
|
|
if rec.my_role == "owner" {
|
|
return *me;
|
|
}
|
|
room_secret_of(rec).map(|s| sp::member_public(&s)).unwrap_or(*me)
|
|
}
|
|
|
|
/// The identity to use inside one Space: the key we wear, and the room
|
|
/// secret behind it when that is not our persona. The proposal board rides a
|
|
/// Space's roster, so it has to author under exactly the same key.
|
|
pub(crate) fn worn_identity(
|
|
app: &tauri::AppHandle,
|
|
owner_b58: &str,
|
|
space_b58: &str,
|
|
me: &[u8; 32],
|
|
) -> ([u8; 32], Option<[u8; 32]>) {
|
|
let l = load(app);
|
|
match l.spaces.get(&record_id(owner_b58, space_b58)) {
|
|
Some(rec) if rec.my_role != "owner" => (worn_key(rec, me), room_secret_of(rec)),
|
|
_ => (*me, None),
|
|
}
|
|
}
|
|
|
|
/// The persona behind a room key, when this device is the one that seated it.
|
|
/// Nobody else can answer this, which is the point.
|
|
pub(crate) fn persona_behind(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, room: &str) -> Option<String> {
|
|
let l = load(app);
|
|
l.spaces.get(&record_id(owner_b58, space_b58))?.roster.get(room).cloned()
|
|
}
|
|
|
|
async fn sign(app: &tauri::AppHandle, payload: Vec<u8>) -> Result<Vec<u8>, String> {
|
|
let adapter = app.state::<AppState>().freenet.clone();
|
|
persona::sign(&adapter, sp::SIGN_DOMAIN, payload).await
|
|
}
|
|
|
|
pub(crate) async fn fetch_state(app: &tauri::AppHandle, owner: &[u8; 32], space_id: &[u8; 16]) -> Result<(sp::SpaceState, bool), String> {
|
|
let adapter = app.state::<AppState>().freenet.clone();
|
|
let ids = FreenetAdapter::contract_ids(SPACE_WASM.to_vec(), &sp::make_params(owner, space_id));
|
|
match adapter.contract_get(&ids.instance, false).await {
|
|
Ok(bytes) => Ok((sp::merge_space(owner, space_id, &[&bytes]), true)),
|
|
Err(e) if e.to_string().contains("not found") => Ok((sp::SpaceState::default(), false)),
|
|
Err(e) => Err(e.to_string()),
|
|
}
|
|
}
|
|
|
|
async fn offer(app: &tauri::AppHandle, owner: &[u8; 32], space_id: &[u8; 16], state: &sp::SpaceState, first: bool) -> Result<(), String> {
|
|
let adapter = app.state::<AppState>().freenet.clone();
|
|
let bytes = bincode::serialize(state).map_err(|e| e.to_string())?;
|
|
let params = sp::make_params(owner, space_id);
|
|
if first {
|
|
adapter.contract_put(SPACE_WASM.to_vec(), ¶ms, bytes, false).await.map(|_| ())
|
|
} else {
|
|
adapter.get_then_update(SPACE_WASM.to_vec(), ¶ms, bytes).await.map(|_| ())
|
|
}
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
fn empty_state(space_id: [u8; 16], checkpoint: Option<sp::Checkpoint>, messages: Vec<sp::Message>) -> sp::SpaceState {
|
|
sp::SpaceState { schema_version: sp::SCHEMA_VERSION, space_id, checkpoint, messages }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- views
|
|
|
|
fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me: &[u8; 32], reachable: bool) -> SpaceView {
|
|
let me_b58 = b58(me);
|
|
let key_for = |epoch: u64| -> Option<[u8; 32]> {
|
|
rec.keys.get(&epoch.to_string()).and_then(|k| key_of(k).ok())
|
|
};
|
|
let Some(c) = &state.checkpoint else {
|
|
return SpaceView {
|
|
id: record_id(&rec.owner, &rec.space_id),
|
|
owner: rec.owner.clone(),
|
|
space_id: rec.space_id.clone(),
|
|
name: rec.name.clone(),
|
|
description: String::new(),
|
|
private: rec.private,
|
|
epoch: 0,
|
|
my_role: rec.my_role.clone(),
|
|
message_cap: sp::DEFAULT_MESSAGE_CAP,
|
|
members: vec![],
|
|
pinned_owner: None,
|
|
pinned_uuid: None,
|
|
messages: vec![],
|
|
can_post: false,
|
|
has_key: false,
|
|
reachable,
|
|
};
|
|
};
|
|
let is_member = c.members.iter().any(|m| &m.key == me);
|
|
let has_key = !c.private || key_for(c.epoch).is_some();
|
|
SpaceView {
|
|
id: record_id(&rec.owner, &rec.space_id),
|
|
owner: rec.owner.clone(),
|
|
space_id: rec.space_id.clone(),
|
|
name: c.name.clone(),
|
|
description: c.description.clone(),
|
|
private: c.private,
|
|
epoch: c.epoch,
|
|
my_role: if &c.owner == me { "owner".into() } else if is_member { "member".into() } else { "none".into() },
|
|
message_cap: c.message_cap,
|
|
members: c
|
|
.members
|
|
.iter()
|
|
.map(|m| MemberView {
|
|
key: b58(&m.key),
|
|
role: match m.role {
|
|
sp::Role::Owner => "owner".into(),
|
|
sp::Role::Member => "member".into(),
|
|
},
|
|
// Only the owner holds room key → persona, so only the owner
|
|
// can put a name to a member. To everyone else they are the
|
|
// key they wear here, which is the whole point.
|
|
username: slsk_link::username_for_key(
|
|
app,
|
|
rec.roster.get(&b58(&m.key)).map(String::as_str).unwrap_or(&b58(&m.key)),
|
|
),
|
|
is_me: &m.key == me,
|
|
since_epoch: m.since_epoch,
|
|
})
|
|
.collect(),
|
|
pinned_owner: c.pinned.as_ref().map(|p| b58(&p.owner)),
|
|
pinned_uuid: c.pinned.as_ref().map(|p| b58(&p.uuid)),
|
|
messages: state
|
|
.messages
|
|
.iter()
|
|
.map(|m| {
|
|
let text = if c.private {
|
|
key_for(m.epoch).and_then(|k| sp::open_body(&k, m)).and_then(|b| String::from_utf8(b).ok())
|
|
} else {
|
|
String::from_utf8(m.body.clone()).ok()
|
|
};
|
|
MessageView {
|
|
id: b58(&m.msg_id),
|
|
author: b58(&m.author),
|
|
username: slsk_link::username_for_key(
|
|
app,
|
|
rec.roster.get(&b58(&m.author)).map(String::as_str).unwrap_or(&b58(&m.author)),
|
|
),
|
|
is_me: &m.author == me,
|
|
created_at: m.created_at,
|
|
epoch: m.epoch,
|
|
text,
|
|
}
|
|
})
|
|
.collect(),
|
|
can_post: is_member && has_key,
|
|
has_key,
|
|
reachable,
|
|
}
|
|
.with_me(me_b58)
|
|
}
|
|
|
|
impl SpaceView {
|
|
fn with_me(self, _me: String) -> Self {
|
|
self
|
|
}
|
|
}
|
|
|
|
pub fn list(app: &tauri::AppHandle) -> Vec<SpaceListItem> {
|
|
let l = load(app);
|
|
let mut v: Vec<_> = l
|
|
.spaces
|
|
.values()
|
|
.map(|r| SpaceListItem {
|
|
id: record_id(&r.owner, &r.space_id),
|
|
owner: r.owner.clone(),
|
|
space_id: r.space_id.clone(),
|
|
name: r.name.clone(),
|
|
private: r.private,
|
|
my_role: r.my_role.clone(),
|
|
last_epoch: r.last_epoch,
|
|
})
|
|
.collect();
|
|
v.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
|
v
|
|
}
|
|
|
|
// ---------------------------------------------------------------- actions
|
|
|
|
pub async fn create(app: &tauri::AppHandle, name: &str, description: &str, private: bool) -> Result<SpaceView, String> {
|
|
let me = my_key(app).await?;
|
|
let name = cap(name.trim(), sp::MAX_NAME_BYTES);
|
|
if name.is_empty() {
|
|
return Err("a Space needs a name".into());
|
|
}
|
|
let space_id = rand16();
|
|
let mut c = sp::Checkpoint {
|
|
schema_version: sp::SCHEMA_VERSION,
|
|
space_id,
|
|
owner: me,
|
|
epoch: 1,
|
|
name: name.clone(),
|
|
description: cap(description.trim(), sp::MAX_DESC_BYTES),
|
|
private,
|
|
members: vec![sp::Member { key: me, role: sp::Role::Owner, since_epoch: 1 }],
|
|
pinned: None,
|
|
message_cap: sp::DEFAULT_MESSAGE_CAP,
|
|
sig: vec![],
|
|
};
|
|
c.sig = sign(app, sp::checkpoint_sign_payload(&c)).await?;
|
|
let state = empty_state(space_id, Some(c), vec![]);
|
|
offer(app, &me, &space_id, &state, true).await?;
|
|
let mut keys = BTreeMap::new();
|
|
if private {
|
|
keys.insert("1".to_string(), b58(&rand32()));
|
|
}
|
|
let rec = SpaceRecord {
|
|
owner: b58(&me),
|
|
space_id: b58(&space_id),
|
|
name,
|
|
private,
|
|
my_role: "owner".into(),
|
|
joined_at: now_secs(),
|
|
keys,
|
|
last_epoch: 1,
|
|
room_secret: None,
|
|
roster: BTreeMap::new(),
|
|
invited: BTreeMap::new(),
|
|
join_sent_at: 0,
|
|
};
|
|
let _ledger = ledger::hold(LEDGER_FILE).await;
|
|
let mut l = load(app);
|
|
l.spaces.insert(record_id(&rec.owner, &rec.space_id), rec.clone());
|
|
save(app, &l)?;
|
|
log::info!("spaces: created {:?} ({})", rec.name, if private { "private" } else { "public" });
|
|
Ok(view_of(app, &rec, &state, &worn_key(&rec, &me), true))
|
|
}
|
|
|
|
pub async fn open(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<SpaceView, String> {
|
|
let me = my_key(app).await?;
|
|
let owner = key_of(owner_b58)?;
|
|
let space_id = id_of(space_b58)?;
|
|
let _ledger = ledger::hold(LEDGER_FILE).await;
|
|
let mut l = load(app);
|
|
let rid = record_id(owner_b58, space_b58);
|
|
let rec = l.spaces.get(&rid).cloned().ok_or("not a Space this device knows")?;
|
|
let (state, reachable) = fetch_state(app, &owner, &space_id).await?;
|
|
if let Some(c) = &state.checkpoint {
|
|
let r = l.spaces.get_mut(&rid).unwrap();
|
|
let mut dirty = r.name != c.name || r.last_epoch != c.epoch || r.private != c.private;
|
|
r.name = c.name.clone();
|
|
r.last_epoch = c.epoch;
|
|
r.private = c.private;
|
|
// Adding members never rotates the key: the previous epoch's key
|
|
// carries forward until a removal issues a new one. A removal DOES
|
|
// rotate, and the real key arrives sealed — `on_inbox_space_key`
|
|
// overwrites this one when it lands.
|
|
if c.private && !r.keys.contains_key(&c.epoch.to_string()) {
|
|
if let Some(k) = key_below(r, c.epoch) {
|
|
r.keys.insert(c.epoch.to_string(), k);
|
|
dirty = true;
|
|
}
|
|
}
|
|
if dirty {
|
|
let _ = save(app, &l);
|
|
}
|
|
}
|
|
let rec = l.spaces.get(&rid).cloned().unwrap_or(rec);
|
|
let worn = worn_key(&rec, &me);
|
|
let seated = state.checkpoint.as_ref().is_some_and(|c| c.members.iter().any(|m| m.key == worn));
|
|
retry_join_if_stranded(app, &rec, seated);
|
|
Ok(view_of(app, &rec, &state, &worn, reachable))
|
|
}
|
|
|
|
pub async fn post(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, text: &str) -> Result<(), String> {
|
|
let me = my_key(app).await?;
|
|
let owner = key_of(owner_b58)?;
|
|
let space_id = id_of(space_b58)?;
|
|
let text = text.trim();
|
|
if text.is_empty() {
|
|
return Err("nothing to say".into());
|
|
}
|
|
if text.len() > sp::MAX_BODY_BYTES {
|
|
return Err(format!("over {} bytes", sp::MAX_BODY_BYTES));
|
|
}
|
|
let (state, _) = fetch_state(app, &owner, &space_id).await?;
|
|
let c = state.checkpoint.as_ref().ok_or("the Space has not reached this node yet")?;
|
|
let (worn, room, epoch_key) = {
|
|
let l = load(app);
|
|
let rec = l.spaces.get(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?;
|
|
let epoch_key = if c.private {
|
|
Some(
|
|
rec.keys
|
|
.get(&c.epoch.to_string())
|
|
.and_then(|k| key_of(k).ok())
|
|
.ok_or("no key for this epoch yet — the owner has to hand it to you")?,
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
(worn_key(rec, &me), room_secret_of(rec), epoch_key)
|
|
};
|
|
if !c.members.iter().any(|m| m.key == worn) {
|
|
return Err("you are not in this Space's roster".into());
|
|
}
|
|
let mut m = sp::Message {
|
|
space_id,
|
|
epoch: c.epoch,
|
|
msg_id: rand16(),
|
|
author: worn,
|
|
created_at: now_secs(),
|
|
body: text.as_bytes().to_vec(),
|
|
sig: vec![],
|
|
};
|
|
if let Some(key) = epoch_key {
|
|
m.body = sp::seal_body(&key, &m);
|
|
}
|
|
// A member signs with the key they wear; only an owner reaches the
|
|
// delegate, because only an owner is their persona here.
|
|
m.sig = match room {
|
|
Some(secret) if worn != me => sp::sign_as_member(&secret, &sp::message_sign_payload(&m)),
|
|
_ => sign(app, sp::message_sign_payload(&m)).await?,
|
|
};
|
|
offer(app, &owner, &space_id, &empty_state(space_id, None, vec![m]), false).await
|
|
}
|
|
|
|
/// The owner rewrites the checkpoint: a new epoch with `mutate` applied.
|
|
async fn bump(app: &tauri::AppHandle, me: [u8; 32], owner: [u8; 32], space_id: [u8; 16], mutate: impl FnOnce(&mut sp::Checkpoint)) -> Result<sp::Checkpoint, String> {
|
|
if me != owner {
|
|
return Err("only the owner can change a Space".into());
|
|
}
|
|
let (state, _) = fetch_state(app, &owner, &space_id).await?;
|
|
let mut c = state.checkpoint.ok_or("the Space has not reached this node yet")?;
|
|
c.epoch += 1;
|
|
mutate(&mut c);
|
|
if c.members.len() > sp::MAX_MEMBERS {
|
|
return Err(format!("over {} members", sp::MAX_MEMBERS));
|
|
}
|
|
c.sig = sign(app, sp::checkpoint_sign_payload(&c)).await?;
|
|
offer(app, &owner, &space_id, &empty_state(space_id, Some(c.clone()), vec![]), false).await?;
|
|
Ok(c)
|
|
}
|
|
|
|
pub async fn invite(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, persona_key: &str) -> Result<SpaceView, String> {
|
|
let me = my_key(app).await?;
|
|
let owner = key_of(owner_b58)?;
|
|
let space_id = id_of(space_b58)?;
|
|
let their = key_of(persona_key)?;
|
|
if their == me {
|
|
return Err("that is your own identity".into());
|
|
}
|
|
if !inbox::reachable(app, &their).await {
|
|
return Err("they have not published an encrypted inbox — the invitation has nowhere to go".into());
|
|
}
|
|
// Nobody joins the roster here any more. We do not yet know the key they
|
|
// will wear — they mint it — so the invitation is an offer, and the roster
|
|
// moves when their signed answer comes back (on_inbox_join).
|
|
let (state, _) = fetch_state(app, &owner, &space_id).await?;
|
|
let c = state.checkpoint.ok_or("the Space has not reached this node yet")?;
|
|
if c.owner != me {
|
|
return Err("only the owner can invite".into());
|
|
}
|
|
let rid = record_id(owner_b58, space_b58);
|
|
let ledger = ledger::hold(LEDGER_FILE).await;
|
|
let mut l = load(app);
|
|
let rec = l.spaces.get_mut(&rid).ok_or("not a Space this device knows")?;
|
|
let mut key = None;
|
|
if c.private {
|
|
// They need this to read while they answer; adding them does not
|
|
// rotate, so the current epoch's key is the right one to hand over.
|
|
let k = rec
|
|
.keys
|
|
.get(&c.epoch.to_string())
|
|
.cloned()
|
|
.or_else(|| key_below(rec, c.epoch + 1))
|
|
.unwrap_or_else(|| b58(&rand32()));
|
|
rec.keys.insert(c.epoch.to_string(), k.clone());
|
|
key = Some(key_of(&k)?);
|
|
}
|
|
// Only a persona we actually asked can join, or anyone who learned the
|
|
// Space's address could put themselves on the roster.
|
|
rec.invited.insert(b58(&their), now_secs());
|
|
save(app, &l)?;
|
|
inbox::send(
|
|
app,
|
|
&their,
|
|
&Cargo::SpaceInvite(SpaceInvite { space_id, owner, name: c.name.clone(), private: c.private, epoch: c.epoch, key }),
|
|
)
|
|
.await?;
|
|
log::info!("spaces: invited {persona_key} to {:?} — waiting for the key they will wear", c.name);
|
|
// open() takes this same ledger; the write is done, so let go first.
|
|
drop(ledger);
|
|
open(app, owner_b58, space_b58).await
|
|
}
|
|
|
|
pub async fn remove_member(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, persona_key: &str) -> Result<SpaceView, String> {
|
|
let me = my_key(app).await?;
|
|
let owner = key_of(owner_b58)?;
|
|
let space_id = id_of(space_b58)?;
|
|
let asked = key_of(persona_key)?;
|
|
if asked == me {
|
|
return Err("the owner cannot be removed".into());
|
|
}
|
|
// The caller names a person — a persona key, or the room key the UI shows.
|
|
// Only the owner's private map can turn one into the other, which is the
|
|
// point; a Space seated before room keys still holds personas directly.
|
|
let gone = {
|
|
let l = load(app);
|
|
let rec = l.spaces.get(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?;
|
|
rec.roster
|
|
.iter()
|
|
.find(|(_, persona)| persona.as_str() == persona_key)
|
|
.and_then(|(room, _)| key_of(room).ok())
|
|
.unwrap_or(asked)
|
|
};
|
|
unseat(app, owner_b58, space_b58, me, owner, space_id, gone).await?;
|
|
open(app, owner_b58, space_b58).await
|
|
}
|
|
|
|
/// Take one key off the roster and re-key everyone who stays. The owner does
|
|
/// this both when they remove someone and when someone leaves of their own
|
|
/// accord, and it must look identical from the outside: a member who walks
|
|
/// away is only trusted to have DELETED the epoch key, never proven to.
|
|
async fn unseat(
|
|
app: &tauri::AppHandle,
|
|
owner_b58: &str,
|
|
space_b58: &str,
|
|
me: [u8; 32],
|
|
owner: [u8; 32],
|
|
space_id: [u8; 16],
|
|
gone: [u8; 32],
|
|
) -> Result<sp::Checkpoint, String> {
|
|
let c = bump(app, me, owner, space_id, |c| c.members.retain(|m| m.key != gone)).await?;
|
|
let rid = record_id(owner_b58, space_b58);
|
|
let ledger = ledger::hold(LEDGER_FILE).await;
|
|
let mut l = load(app);
|
|
let rec = l.spaces.get_mut(&rid).ok_or("not a Space this device knows")?;
|
|
rec.last_epoch = c.epoch;
|
|
// Nobody sits under that key any more, so neither does the private
|
|
// mapping. Keeping it would hold a persona this device no longer needs.
|
|
rec.roster.remove(&b58(&gone));
|
|
if c.private {
|
|
// A removal rotates: everyone who stays gets the new epoch's key.
|
|
let k = rand32();
|
|
rec.keys.insert(c.epoch.to_string(), b58(&k));
|
|
// An inbox is addressed by PERSONA, and since members became
|
|
// room-local the roster no longer holds one. Only this device can
|
|
// bridge the two; sealing to the roster key reaches nobody, and every
|
|
// member who stayed silently keeps a stale key.
|
|
let targets: Vec<[u8; 32]> = c
|
|
.members
|
|
.iter()
|
|
.filter(|m| m.key != me)
|
|
.map(|m| rec.roster.get(&b58(&m.key)).and_then(|p| key_of(p).ok()).unwrap_or(m.key))
|
|
.collect();
|
|
save(app, &l)?;
|
|
for to in targets {
|
|
let cargo = Cargo::SpaceKey(SpaceKeyCargo { space_id, owner, epoch: c.epoch, key: k });
|
|
if let Err(e) = inbox::send(app, &to, &cargo).await {
|
|
log::warn!("spaces: could not hand the epoch-{} key to {}: {e}", c.epoch, b58(&to));
|
|
}
|
|
}
|
|
} else {
|
|
save(app, &l)?;
|
|
}
|
|
// open() takes this same ledger; callers go straight back into it.
|
|
drop(ledger);
|
|
Ok(c)
|
|
}
|
|
|
|
pub async fn set_pinned(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, pl_owner: Option<String>, pl_uuid: Option<String>) -> Result<SpaceView, String> {
|
|
let me = my_key(app).await?;
|
|
let owner = key_of(owner_b58)?;
|
|
let space_id = id_of(space_b58)?;
|
|
let pinned = match (pl_owner, pl_uuid) {
|
|
(Some(o), Some(u)) => Some(sp::PlaylistRef { owner: key_of(&o)?, uuid: id_of(&u)? }),
|
|
_ => None,
|
|
};
|
|
bump(app, me, owner, space_id, |c| c.pinned = pinned).await?;
|
|
open(app, owner_b58, space_b58).await
|
|
}
|
|
|
|
pub async fn leave(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<(), String> {
|
|
let me = my_key(app).await?;
|
|
let owner = key_of(owner_b58)?;
|
|
let space_id = id_of(space_b58)?;
|
|
let rid = record_id(owner_b58, space_b58);
|
|
// Signed BEFORE the record goes: the room secret is the only thing that
|
|
// can prove this seat asked to be vacated, and leaving destroys it.
|
|
let (worn, room) = {
|
|
let l = load(app);
|
|
let rec = l.spaces.get(&rid).ok_or("not a Space this device knows")?;
|
|
if rec.my_role == "owner" {
|
|
return Err("an owner keeps the Space; remove members instead".into());
|
|
}
|
|
(worn_key(rec, &me), room_secret_of(rec))
|
|
};
|
|
let payload = sp::leave_proof_payload(&space_id, &owner, &worn);
|
|
let proof = match room {
|
|
Some(secret) => sp::sign_as_member(&secret, &payload),
|
|
None => sign(app, payload).await?,
|
|
};
|
|
// Best-effort, and deliberately so: the person asked to go, so they go
|
|
// whether or not the owner is reachable. A lost notice leaves a seat on
|
|
// the roster that can no longer post — the owner can still remove it.
|
|
let cargo = Cargo::SpaceLeave(SpaceLeave { space_id, owner, worn, proof });
|
|
if let Err(e) = inbox::send(app, &owner, &cargo).await {
|
|
log::warn!("spaces: left {space_b58} but could not tell its owner: {e}");
|
|
}
|
|
let _ledger = ledger::hold(LEDGER_FILE).await;
|
|
let mut l = load(app);
|
|
l.spaces.remove(&rid);
|
|
save(app, &l)
|
|
}
|
|
|
|
// ---------------------------------------------------------------- inbox cargo
|
|
|
|
pub(crate) async fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], inv: SpaceInvite) {
|
|
if sender != inv.owner {
|
|
log::warn!("spaces: an invitation was sealed by someone other than the Space's owner");
|
|
return;
|
|
}
|
|
let rid = record_id(&b58(&inv.owner), &b58(&inv.space_id));
|
|
let room = {
|
|
let _ledger = ledger::hold(LEDGER_FILE).await;
|
|
let mut l = load(app);
|
|
let rec = l.spaces.entry(rid).or_insert_with(|| SpaceRecord {
|
|
owner: b58(&inv.owner),
|
|
space_id: b58(&inv.space_id),
|
|
name: cap(&inv.name, sp::MAX_NAME_BYTES),
|
|
private: inv.private,
|
|
my_role: "member".into(),
|
|
joined_at: now_secs(),
|
|
keys: BTreeMap::new(),
|
|
last_epoch: 0,
|
|
room_secret: None,
|
|
roster: BTreeMap::new(),
|
|
invited: BTreeMap::new(),
|
|
join_sent_at: 0,
|
|
});
|
|
rec.name = cap(&inv.name, sp::MAX_NAME_BYTES);
|
|
rec.private = inv.private;
|
|
if let Some(k) = inv.key {
|
|
rec.keys.insert(inv.epoch.to_string(), b58(&k));
|
|
}
|
|
// One key per Space, minted once and kept. Re-minting on a second
|
|
// invitation would strand the roster entry we already have.
|
|
let secret = match room_secret_of(rec) {
|
|
Some(s) => s,
|
|
None => {
|
|
let s = rand32();
|
|
rec.room_secret = Some(b58(&s));
|
|
s
|
|
}
|
|
};
|
|
if let Err(e) = save(app, &l) {
|
|
log::warn!("spaces: could not keep an invitation: {e}");
|
|
return;
|
|
}
|
|
secret
|
|
};
|
|
log::info!("spaces: invited to {:?} by {}", inv.name, b58(&inv.owner));
|
|
send_join(app, &inv.owner, &inv.space_id, &room).await;
|
|
}
|
|
|
|
/// Tell the owner which key we will wear: the key itself, and a persona
|
|
/// signature over it so they know it is us. Sealed — that pairing is exactly
|
|
/// what must never be published. Best-effort, and stamped so `open` can try
|
|
/// again; being un-seated is the one state a member cannot fix by hand.
|
|
async fn send_join(app: &tauri::AppHandle, owner: &[u8; 32], space_id: &[u8; 16], room: &[u8; 32]) {
|
|
let room_key = sp::member_public(room);
|
|
let proof = match sign(app, sp::join_proof_payload(space_id, owner, &room_key)).await {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
log::warn!("spaces: could not sign a join: {e}");
|
|
return;
|
|
}
|
|
};
|
|
let join = Cargo::SpaceJoin(SpaceJoin { space_id: *space_id, owner: *owner, room_key, proof });
|
|
match inbox::send(app, owner, &join).await {
|
|
Ok(()) => {
|
|
let _ledger = ledger::hold(LEDGER_FILE).await;
|
|
let mut l = load(app);
|
|
if let Some(rec) = l.spaces.get_mut(&record_id(&b58(owner), &b58(space_id))) {
|
|
rec.join_sent_at = now_secs();
|
|
let _ = save(app, &l);
|
|
}
|
|
log::info!("spaces: told {} which key we will wear", b58(owner));
|
|
}
|
|
Err(e) => log::warn!("spaces: could not answer an invitation: {e} — will try again on open"),
|
|
}
|
|
}
|
|
|
|
/// Sealed cargo: a member gave up their seat. Three things have to hold —
|
|
/// the seat exists, its holder signed the request, and the sender is the
|
|
/// person this device seated under it. The last one is what stops anyone who
|
|
/// merely READ the roster (the keys are published) from unseating a stranger.
|
|
pub(crate) async fn on_inbox_leave(app: &tauri::AppHandle, sender: [u8; 32], lv: SpaceLeave) {
|
|
let owner_b58 = b58(&lv.owner);
|
|
let space_b58 = b58(&lv.space_id);
|
|
let rid = record_id(&owner_b58, &space_b58);
|
|
{
|
|
let _ledger = ledger::hold(LEDGER_FILE).await;
|
|
let l = load(app);
|
|
let Some(rec) = l.spaces.get(&rid) else { return };
|
|
if rec.my_role != "owner" {
|
|
return;
|
|
}
|
|
// Room-local seats map back to a persona here and nowhere else; a
|
|
// Space seated before room keys holds the persona on the roster
|
|
// itself, and then the sender IS the seat.
|
|
let behind = rec.roster.get(&b58(&lv.worn)).map(String::as_str);
|
|
if behind.unwrap_or(&b58(&lv.worn)) != b58(&sender) {
|
|
log::warn!("spaces: a leave came from someone other than the member seated under that key");
|
|
return;
|
|
}
|
|
}
|
|
if !sp::leave_proof_valid(&lv.space_id, &lv.owner, &lv.worn, &lv.proof) {
|
|
log::warn!("spaces: a leave did not prove it came from the seat it gives up");
|
|
return;
|
|
}
|
|
let me = match my_key(app).await {
|
|
Ok(k) => k,
|
|
Err(_) => return,
|
|
};
|
|
match unseat(app, &owner_b58, &space_b58, me, lv.owner, lv.space_id, lv.worn).await {
|
|
Ok(c) => log::info!("spaces: a member left {:?}, roster at epoch {}", c.name, c.epoch),
|
|
Err(e) => log::warn!("spaces: could not unseat a member who left: {e}"),
|
|
}
|
|
}
|
|
|
|
/// A member who holds a room key but is not on the roster never got seated.
|
|
/// Say so again, spaced out, rather than leaving them stranded.
|
|
const JOIN_RETRY_SECS: u64 = 120;
|
|
fn retry_join_if_stranded(app: &tauri::AppHandle, rec: &SpaceRecord, seated: bool) {
|
|
if seated || rec.my_role == "owner" {
|
|
return;
|
|
}
|
|
let Some(room) = room_secret_of(rec) else { return };
|
|
if now_secs().saturating_sub(rec.join_sent_at) < JOIN_RETRY_SECS {
|
|
return;
|
|
}
|
|
let (Ok(owner), Ok(space_id)) = (key_of(&rec.owner), id_of(&rec.space_id)) else { return };
|
|
let handle = app.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
send_join(&handle, &owner, &space_id, &room).await;
|
|
});
|
|
}
|
|
|
|
/// The owner's side of the handshake: someone we invited says which key they
|
|
/// will wear. Verify it is really them, then put THAT key — and only that key
|
|
/// — on the published roster.
|
|
pub(crate) async fn on_inbox_join(app: &tauri::AppHandle, sender: [u8; 32], j: SpaceJoin) {
|
|
let rid = record_id(&b58(&j.owner), &b58(&j.space_id));
|
|
let owner_b58 = b58(&j.owner);
|
|
let space_b58 = b58(&j.space_id);
|
|
{
|
|
let _ledger = ledger::hold(LEDGER_FILE).await;
|
|
let l = load(app);
|
|
let Some(rec) = l.spaces.get(&rid) else { return };
|
|
if rec.my_role != "owner" {
|
|
return;
|
|
}
|
|
if !rec.invited.contains_key(&b58(&sender)) {
|
|
log::warn!("spaces: a join arrived from someone this Space never invited");
|
|
return;
|
|
}
|
|
}
|
|
if !sp::join_proof_valid(&j.space_id, &j.owner, &j.room_key, &sender, &j.proof) {
|
|
log::warn!("spaces: a join did not prove the room key belongs to its sender");
|
|
return;
|
|
}
|
|
let me = match my_key(app).await {
|
|
Ok(k) => k,
|
|
Err(_) => return,
|
|
};
|
|
let c = match bump(app, me, j.owner, j.space_id, |c| {
|
|
if !c.members.iter().any(|m| m.key == j.room_key) {
|
|
let e = c.epoch;
|
|
c.members.push(sp::Member { key: j.room_key, role: sp::Role::Member, since_epoch: e });
|
|
}
|
|
})
|
|
.await
|
|
{
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
log::warn!("spaces: could not seat a joiner: {e}");
|
|
return;
|
|
}
|
|
};
|
|
let _ledger = ledger::hold(LEDGER_FILE).await;
|
|
let mut l = load(app);
|
|
let Some(rec) = l.spaces.get_mut(&rid) else { return };
|
|
// Who is behind the key, kept here and nowhere else.
|
|
rec.roster.insert(b58(&j.room_key), b58(&sender));
|
|
rec.invited.remove(&b58(&sender));
|
|
rec.last_epoch = c.epoch;
|
|
if let Err(e) = save(app, &l) {
|
|
log::warn!("spaces: seated a joiner but could not record it: {e}");
|
|
}
|
|
log::info!("spaces: seated a member in {:?} at epoch {} ({owner_b58}/{space_b58})", c.name, c.epoch);
|
|
}
|
|
|
|
pub(crate) async fn on_inbox_space_key(app: &tauri::AppHandle, sender: [u8; 32], sk: SpaceKeyCargo) {
|
|
if sender != sk.owner {
|
|
return;
|
|
}
|
|
let rid = record_id(&b58(&sk.owner), &b58(&sk.space_id));
|
|
let _ledger = ledger::hold(LEDGER_FILE).await;
|
|
let mut l = load(app);
|
|
let Some(rec) = l.spaces.get_mut(&rid) else { return };
|
|
rec.keys.insert(sk.epoch.to_string(), b58(&sk.key));
|
|
let _ = save(app, &l);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- commands
|
|
|
|
#[tauri::command]
|
|
pub fn spaces_list(app: tauri::AppHandle) -> Vec<SpaceListItem> {
|
|
list(&app)
|
|
}
|
|
#[tauri::command]
|
|
pub async fn space_create(app: tauri::AppHandle, name: String, description: String, private: bool) -> Result<SpaceView, String> {
|
|
create(&app, &name, &description, private).await
|
|
}
|
|
#[tauri::command]
|
|
pub async fn space_open(app: tauri::AppHandle, owner: String, space: String) -> Result<SpaceView, String> {
|
|
open(&app, &owner, &space).await
|
|
}
|
|
#[tauri::command]
|
|
pub async fn space_post(app: tauri::AppHandle, owner: String, space: String, text: String) -> Result<(), String> {
|
|
post(&app, &owner, &space, &text).await
|
|
}
|
|
#[tauri::command]
|
|
pub async fn space_invite(app: tauri::AppHandle, owner: String, space: String, persona_key: String) -> Result<SpaceView, String> {
|
|
invite(&app, &owner, &space, &persona_key).await
|
|
}
|
|
#[tauri::command]
|
|
pub async fn space_remove_member(app: tauri::AppHandle, owner: String, space: String, persona_key: String) -> Result<SpaceView, String> {
|
|
remove_member(&app, &owner, &space, &persona_key).await
|
|
}
|
|
#[tauri::command]
|
|
pub async fn space_set_pinned(app: tauri::AppHandle, owner: String, space: String, playlist_owner: Option<String>, playlist_uuid: Option<String>) -> Result<SpaceView, String> {
|
|
set_pinned(&app, &owner, &space, playlist_owner, playlist_uuid).await
|
|
}
|
|
#[tauri::command]
|
|
pub async fn space_leave(app: tauri::AppHandle, owner: String, space: String) -> Result<(), String> {
|
|
leave(&app, &owner, &space).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn rec(role: &str, secret: Option<[u8; 32]>) -> SpaceRecord {
|
|
SpaceRecord {
|
|
owner: b58(&[1u8; 32]),
|
|
space_id: b58(&[2u8; 16]),
|
|
name: "Late Night Ambient".into(),
|
|
private: true,
|
|
my_role: role.into(),
|
|
joined_at: 0,
|
|
keys: BTreeMap::new(),
|
|
last_epoch: 1,
|
|
room_secret: secret.map(|s| b58(&s)),
|
|
roster: BTreeMap::new(),
|
|
invited: BTreeMap::new(),
|
|
join_sent_at: 0,
|
|
}
|
|
}
|
|
|
|
/// Which key we wear where. An owner is their persona — the Space's
|
|
/// address is derived from it, so there is nothing to hide there — and a
|
|
/// member is the key minted for that Space and no other.
|
|
#[test]
|
|
fn a_member_wears_a_room_key_and_an_owner_wears_their_persona() {
|
|
let me = [5u8; 32];
|
|
assert_eq!(worn_key(&rec("owner", None), &me), me, "an owner is their persona here");
|
|
assert_eq!(worn_key(&rec("owner", Some([9u8; 32])), &me), me, "even holding a room key");
|
|
|
|
let worn = worn_key(&rec("member", Some([9u8; 32])), &me);
|
|
assert_eq!(worn, sp::member_public(&[9u8; 32]));
|
|
assert_ne!(worn, me, "a member must not appear as their persona");
|
|
|
|
// Two Spaces, two keys: one roster cannot be correlated with another.
|
|
assert_ne!(
|
|
worn_key(&rec("member", Some([9u8; 32])), &me),
|
|
worn_key(&rec("member", Some([8u8; 32])), &me),
|
|
);
|
|
// Before the mint there is nothing to wear but the persona, and the
|
|
// roster check then simply fails rather than posting as one.
|
|
assert_eq!(worn_key(&rec("member", None), &me), me);
|
|
}
|
|
|
|
/// The mapping the design exists to keep off the network stays local, and
|
|
/// is only ever answerable by the device that seated the member.
|
|
#[test]
|
|
fn only_the_seating_device_can_name_a_room_key() {
|
|
let mut r = rec("owner", None);
|
|
let room = b58(&sp::member_public(&[9u8; 32]));
|
|
r.roster.insert(room.clone(), b58(&[7u8; 32]));
|
|
assert_eq!(r.roster.get(&room).map(String::as_str), Some(b58(&[7u8; 32]).as_str()));
|
|
// A record that never seated them knows nothing.
|
|
assert!(rec("member", Some([9u8; 32])).roster.get(&room).is_none());
|
|
}
|
|
|
|
/// Epoch keys are keyed by a STRING, and a BTreeMap orders those
|
|
/// lexicographically — "9" sorts after "10". A Space only has to reach its
|
|
/// tenth epoch for the wrong key to carry forward, and the symptom is
|
|
/// messages that simply will not open.
|
|
#[test]
|
|
fn the_newest_epoch_key_is_found_by_number_not_by_string() {
|
|
let mut r = rec("member", None);
|
|
for (e, k) in [(1u64, "one"), (9, "nine"), (10, "ten"), (11, "eleven")] {
|
|
r.keys.insert(e.to_string(), k.into());
|
|
}
|
|
assert_eq!(key_below(&r, 12).as_deref(), Some("eleven"));
|
|
assert_eq!(key_below(&r, 11).as_deref(), Some("ten"), "lexicographic order would say \"nine\"");
|
|
assert_eq!(key_below(&r, 10).as_deref(), Some("nine"));
|
|
assert_eq!(key_below(&r, 1), None, "nothing precedes the first epoch");
|
|
}
|
|
|
|
#[test]
|
|
fn contract_generation_is_pinned() {
|
|
let ids = FreenetAdapter::contract_ids(SPACE_WASM.to_vec(), &sp::make_params(&[7u8; 32], &[1u8; 16]));
|
|
assert_eq!(ids.code_hash, "BQc5bg1QEfTsgkog1WsS3di7fkwVSd8VJQpNUHdEMH3a");
|
|
}
|
|
}
|