pub const CONTRACT_VERSION: &str = "dreamwell.canon.v1.0.0";
pub const EVENT_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug)]
pub struct CanonEventParams {
pub timeline_id: String,
pub scope_key: String,
pub event_type: String,
pub actor_id: String,
pub target_id: String,
pub payload: String,
pub impact_score: i32,
pub world_id: String,
pub loom_tier: u8,
pub irreversible: bool,
pub provenance_kind: String,
pub producer_id: String,
}
pub struct CanonEventRecord {
pub event_id: String,
pub digest: String,
pub prev_event_id: String,
pub tick: u64,
pub seq: u64,
pub occurred_at: u64,
pub contract_version: String,
pub event_schema_version: u32,
}
#[inline]
pub fn build_event_id(timeline_id: &str, tick: u64, seq: u64) -> String {
format!("evt:{}:{}:{:09}", timeline_id, tick, seq)
}
pub fn compute_event_digest(
event_id: &str,
event_type: &str,
tick: u64,
seq: u64,
timeline_id: &str,
scope_key: &str,
actor_id: &str,
prev_event_id: &str,
) -> String {
let mut hasher = blake3::Hasher::new_derive_key("dreamwell.canon.v1");
hasher.update(event_id.as_bytes());
hasher.update(b"|");
hasher.update(event_type.as_bytes());
hasher.update(b"|");
hasher.update(&tick.to_le_bytes());
hasher.update(&seq.to_le_bytes());
hasher.update(b"|");
hasher.update(timeline_id.as_bytes());
hasher.update(b"|");
hasher.update(scope_key.as_bytes());
hasher.update(b"|");
hasher.update(actor_id.as_bytes());
hasher.update(b"|");
hasher.update(prev_event_id.as_bytes());
hasher.finalize().to_hex().to_string()
}
#[inline]
pub fn build_scope_key(timeline_id: &str, level: &str, scope_id: &str) -> Result<String, String> {
let key = format!("tl:{}/lvl:{}/id:{}", timeline_id, level, scope_id);
crate::validation::validate_scope_key(&key)?;
Ok(key)
}
#[inline]
pub fn check_scope_budget(events_this_tick: u64, budget: u64) -> Result<(), String> {
if events_this_tick >= budget {
Err(format!(
"scope_budget_exhausted: {} events (budget {})",
events_this_tick, budget
))
} else {
Ok(())
}
}
#[inline]
pub fn check_token_bucket(tokens: u32, cost: u32) -> Result<(), String> {
if tokens < cost {
Err(format!("token_bucket_insufficient: {} tokens (cost {})", tokens, cost))
} else {
Ok(())
}
}
#[inline]
pub fn refill_tokens(current: u32, refill_rate: u32, max_tokens: u32) -> u32 {
current.saturating_add(refill_rate).min(max_tokens)
}
#[inline]
pub fn build_scope_state_id(timeline_id: &str, scope_key: &str) -> String {
format!("{}:{}", timeline_id, scope_key)
}
#[inline]
pub fn build_actor_state_id(timeline_id: &str, actor_id: &str) -> String {
format!("{}:{}", timeline_id, actor_id)
}
#[inline]
pub fn build_player_state_id(actor_id: &str) -> String {
actor_id.to_string()
}
#[inline]
pub fn build_ledger_id(scope_level: &str, scope_id: &str) -> String {
format!("ledger:{}:{}", scope_level, scope_id)
}
const MAX_TICK_HASH_EVENTS: usize = 65536;
pub fn compute_tick_hash(events: &[(String, u64)]) -> String {
let capped = &events[..events.len().min(MAX_TICK_HASH_EVENTS)];
let mut hasher = blake3::Hasher::new_derive_key("dreamwell.tick.v1");
for (event_id, seq) in capped {
hasher.update(event_id.as_bytes());
hasher.update(b":");
hasher.update(&seq.to_le_bytes());
hasher.update(b"|");
}
hasher.finalize().to_hex().to_string()
}
pub fn compute_tick_hash_ref(events: &[(&str, u64)]) -> String {
if events.len() > MAX_TICK_HASH_EVENTS {
#[cfg(not(target_arch = "wasm32"))]
eprintln!(
"warn: compute_tick_hash_ref capped at {} events (had {})",
MAX_TICK_HASH_EVENTS,
events.len()
);
}
let capped = &events[..events.len().min(MAX_TICK_HASH_EVENTS)];
let mut hasher = blake3::Hasher::new_derive_key("dreamwell.tick.v1");
for (event_id, seq) in capped {
hasher.update(event_id.as_bytes());
hasher.update(b":");
hasher.update(&seq.to_le_bytes());
hasher.update(b"|");
}
hasher.finalize().to_hex().to_string()
}
pub fn compute_block_hash(tick_hashes: &[(u64, String)], prev_block_hash: &str) -> String {
let mut hasher = blake3::Hasher::new_derive_key("dreamwell.block.v1");
hasher.update(prev_block_hash.as_bytes());
for (tick, hash) in tick_hashes {
hasher.update(b"|");
hasher.update(&tick.to_le_bytes());
hasher.update(b":");
hasher.update(hash.as_bytes());
}
hasher.finalize().to_hex().to_string()
}
pub fn compute_block_hash_ref(tick_hashes: &[(u64, &str)], prev_block_hash: &str) -> String {
let mut hasher = blake3::Hasher::new_derive_key("dreamwell.block.v1");
hasher.update(prev_block_hash.as_bytes());
for (tick, hash) in tick_hashes {
hasher.update(b"|");
hasher.update(&tick.to_le_bytes());
hasher.update(b":");
hasher.update(hash.as_bytes());
}
hasher.finalize().to_hex().to_string()
}
#[inline]
pub fn classify_impact(score: i32) -> &'static str {
match score {
0..=2 => "trivial",
3..=5 => "minor",
6..=7 => "moderate",
8..=9 => "major",
10 => "critical",
_ => "unknown",
}
}
pub fn validate_event_type(event_type: &str) -> Result<(), String> {
let dot_pos = match event_type.find('.') {
Some(pos) => pos,
None => {
return Err(format!(
"event_type_invalid_format: '{}' missing dot separator (expected domain.variant)",
event_type
));
}
};
let domain = &event_type[..dot_pos];
let variant = &event_type[dot_pos + 1..];
if domain.is_empty() {
return Err("event_type_empty_domain".to_string());
}
if variant.is_empty() {
return Err("event_type_empty_variant".to_string());
}
if variant.contains('.') {
return Err(format!(
"event_type_too_many_segments: '{}' (expected exactly domain.variant)",
event_type
));
}
if !domain
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
return Err(format!(
"event_type_invalid_domain_chars: '{}' (allowed: a-z, 0-9, _)",
domain
));
}
if !variant
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
return Err(format!(
"event_type_invalid_variant_chars: '{}' (allowed: a-z, 0-9, _)",
variant
));
}
Ok(())
}
#[inline]
pub fn compute_occurred_at(tick: u64, tick_rate_ms: u32) -> u64 {
tick * tick_rate_ms as u64 * 1000
}
pub fn build_canon_event_record(
params: &CanonEventParams,
tick: u64,
seq: u64,
prev_event_id: &str,
tick_rate_ms: u32,
) -> CanonEventRecord {
let event_id = build_event_id(¶ms.timeline_id, tick, seq);
let digest = compute_event_digest(
&event_id,
¶ms.event_type,
tick,
seq,
¶ms.timeline_id,
¶ms.scope_key,
¶ms.actor_id,
prev_event_id,
);
let occurred_at = compute_occurred_at(tick, tick_rate_ms);
CanonEventRecord {
event_id,
digest,
prev_event_id: prev_event_id.to_string(),
tick,
seq,
occurred_at,
contract_version: CONTRACT_VERSION.to_string(),
event_schema_version: EVENT_SCHEMA_VERSION,
}
}
#[cfg(feature = "signing")]
pub fn sign_event_digest(digest_hex: &str, key: &ed25519_dalek::SigningKey) -> [u8; 64] {
use ed25519_dalek::Signer;
key.sign(digest_hex.as_bytes()).to_bytes()
}
#[cfg(feature = "signing")]
pub fn verify_event_signature(digest_hex: &str, signature: &[u8; 64], key: &ed25519_dalek::VerifyingKey) -> bool {
use ed25519_dalek::Verifier;
let sig = ed25519_dalek::Signature::from_bytes(signature);
key.verify(digest_hex.as_bytes(), &sig).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_id_basic_format() {
assert_eq!(build_event_id("main", 100, 3), "evt:main:100:000000003");
}
#[test]
fn event_id_zero_tick_and_seq() {
assert_eq!(build_event_id("tl_alpha", 0, 0), "evt:tl_alpha:0:000000000");
}
#[test]
fn event_id_large_seq() {
assert_eq!(build_event_id("main", 1, 999999), "evt:main:1:000999999");
}
#[test]
fn event_id_seq_overflow_pads_beyond_nine() {
let id = build_event_id("main", 1, 1_000_000_000);
assert_eq!(id, "evt:main:1:1000000000");
}
#[test]
fn digest_is_64_hex_chars() {
let d = compute_event_digest(
"evt:m:1:000000",
"combat.attack",
1,
0,
"m",
"world:a",
"actor1",
"genesis",
);
assert_eq!(d.len(), 64);
assert!(d.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn digest_deterministic() {
let a = compute_event_digest("evt:m:1:000000", "combat.attack", 1, 0, "m", "s", "a", "genesis");
let b = compute_event_digest("evt:m:1:000000", "combat.attack", 1, 0, "m", "s", "a", "genesis");
assert_eq!(a, b);
}
#[test]
fn digest_changes_with_prev_event() {
let a = compute_event_digest("evt:m:1:000000", "t", 1, 0, "m", "s", "a", "genesis");
let b = compute_event_digest("evt:m:1:000000", "t", 1, 0, "m", "s", "a", "evt:m:0:000000");
assert_ne!(a, b);
}
#[test]
fn digest_changes_with_event_type() {
let a = compute_event_digest("e", "combat.attack", 1, 0, "m", "s", "a", "g");
let b = compute_event_digest("e", "trade.offer", 1, 0, "m", "s", "a", "g");
assert_ne!(a, b);
}
#[test]
fn scope_key_format() {
assert_eq!(
build_scope_key("main", "world", "ayora").unwrap(),
"tl:main/lvl:world/id:ayora"
);
}
#[test]
fn scope_key_with_region() {
assert_eq!(
build_scope_key("tl_1", "region", "heartland").unwrap(),
"tl:tl_1/lvl:region/id:heartland"
);
}
#[test]
fn scope_key_rejects_invalid_chars() {
assert!(build_scope_key("main", "world", "bad chars!").is_err());
}
#[test]
fn scope_budget_allows_under_limit() {
assert!(check_scope_budget(5, 10).is_ok());
}
#[test]
fn scope_budget_rejects_at_limit() {
assert!(check_scope_budget(10, 10).is_err());
}
#[test]
fn scope_budget_rejects_over_limit() {
assert!(check_scope_budget(11, 10).is_err());
}
#[test]
fn scope_budget_allows_zero_of_nonzero() {
assert!(check_scope_budget(0, 1).is_ok());
}
#[test]
fn token_bucket_allows_sufficient() {
assert!(check_token_bucket(10, 5).is_ok());
}
#[test]
fn token_bucket_allows_exact() {
assert!(check_token_bucket(5, 5).is_ok());
}
#[test]
fn token_bucket_rejects_insufficient() {
assert!(check_token_bucket(3, 5).is_err());
}
#[test]
fn token_bucket_zero_cost_always_passes() {
assert!(check_token_bucket(0, 0).is_ok());
}
#[test]
fn refill_tokens_basic() {
assert_eq!(refill_tokens(5, 3, 10), 8);
}
#[test]
fn refill_tokens_caps_at_max() {
assert_eq!(refill_tokens(8, 5, 10), 10);
}
#[test]
fn refill_tokens_already_at_max() {
assert_eq!(refill_tokens(10, 5, 10), 10);
}
#[test]
fn refill_tokens_overflow_saturates() {
assert_eq!(refill_tokens(u32::MAX, 1, u32::MAX), u32::MAX);
}
#[test]
fn refill_tokens_zero_refill() {
assert_eq!(refill_tokens(5, 0, 10), 5);
}
#[test]
fn scope_state_id_format() {
assert_eq!(build_scope_state_id("main", "world:ayora"), "main:world:ayora");
}
#[test]
fn actor_state_id_format() {
assert_eq!(build_actor_state_id("main", "actor_42"), "main:actor_42");
}
#[test]
fn player_state_id_format() {
assert_eq!(build_player_state_id("player_7"), "player_7");
}
#[test]
fn ledger_id_format() {
assert_eq!(build_ledger_id("world", "ayora"), "ledger:world:ayora");
}
#[test]
fn tick_hash_single_event() {
let events = vec![("evt:m:1:000000".to_string(), 0u64)];
let h = compute_tick_hash(&events);
assert_eq!(h.len(), 64);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn tick_hash_deterministic() {
let events = vec![
("evt:m:1:000000".to_string(), 0u64),
("evt:m:1:000001".to_string(), 1u64),
];
let a = compute_tick_hash(&events);
let b = compute_tick_hash(&events);
assert_eq!(a, b);
}
#[test]
fn tick_hash_order_matters() {
let events_a = vec![
("evt:m:1:000000".to_string(), 0u64),
("evt:m:1:000001".to_string(), 1u64),
];
let events_b = vec![
("evt:m:1:000001".to_string(), 1u64),
("evt:m:1:000000".to_string(), 0u64),
];
assert_ne!(compute_tick_hash(&events_a), compute_tick_hash(&events_b));
}
#[test]
fn tick_hash_empty_events() {
let events: Vec<(String, u64)> = vec![];
let h = compute_tick_hash(&events);
assert_eq!(h.len(), 64);
}
#[test]
fn tick_hash_ref_matches_owned() {
let events_owned = vec![
("evt:m:1:000000".to_string(), 0u64),
("evt:m:1:000001".to_string(), 1u64),
];
let events_ref: Vec<(&str, u64)> = vec![("evt:m:1:000000", 0u64), ("evt:m:1:000001", 1u64)];
assert_eq!(compute_tick_hash(&events_owned), compute_tick_hash_ref(&events_ref));
}
#[test]
fn block_hash_chains_from_prev() {
let ticks = vec![(1u64, "aaaa000000000000".to_string())];
let a = compute_block_hash(&ticks, "genesis");
let b = compute_block_hash(&ticks, "other_prev");
assert_ne!(a, b);
}
#[test]
fn block_hash_deterministic() {
let ticks = vec![
(1u64, "aaaa000000000000".to_string()),
(2u64, "bbbb000000000000".to_string()),
];
let a = compute_block_hash(&ticks, "genesis");
let b = compute_block_hash(&ticks, "genesis");
assert_eq!(a, b);
}
#[test]
fn block_hash_empty_ticks() {
let ticks: Vec<(u64, String)> = vec![];
let h = compute_block_hash(&ticks, "genesis");
assert_eq!(h.len(), 64);
}
#[test]
fn block_hash_ref_matches_owned() {
let ticks_owned = vec![(1u64, "abcdef0123456789".to_string())];
let ticks_ref: Vec<(u64, &str)> = vec![(1u64, "abcdef0123456789")];
assert_eq!(
compute_block_hash(&ticks_owned, "gen"),
compute_block_hash_ref(&ticks_ref, "gen")
);
}
#[test]
fn impact_trivial() {
assert_eq!(classify_impact(0), "trivial");
assert_eq!(classify_impact(1), "trivial");
assert_eq!(classify_impact(2), "trivial");
}
#[test]
fn impact_minor() {
assert_eq!(classify_impact(3), "minor");
assert_eq!(classify_impact(5), "minor");
}
#[test]
fn impact_moderate() {
assert_eq!(classify_impact(6), "moderate");
assert_eq!(classify_impact(7), "moderate");
}
#[test]
fn impact_major() {
assert_eq!(classify_impact(8), "major");
assert_eq!(classify_impact(9), "major");
}
#[test]
fn impact_critical() {
assert_eq!(classify_impact(10), "critical");
}
#[test]
fn impact_negative_is_unknown() {
assert_eq!(classify_impact(-1), "unknown");
}
#[test]
fn impact_above_10_is_unknown() {
assert_eq!(classify_impact(11), "unknown");
assert_eq!(classify_impact(100), "unknown");
}
#[test]
fn event_type_valid() {
assert!(validate_event_type("combat.attack").is_ok());
assert!(validate_event_type("trade.offer").is_ok());
assert!(validate_event_type("system.tick_advance").is_ok());
assert!(validate_event_type("quest.complete_stage").is_ok());
}
#[test]
fn event_type_with_digits() {
assert!(validate_event_type("v2.init").is_ok());
assert!(validate_event_type("system.phase3").is_ok());
}
#[test]
fn event_type_missing_dot() {
let err = validate_event_type("combatattack").unwrap_err();
assert!(err.contains("missing dot separator"));
}
#[test]
fn event_type_empty_domain() {
assert!(validate_event_type(".attack").is_err());
}
#[test]
fn event_type_empty_variant() {
assert!(validate_event_type("combat.").is_err());
}
#[test]
fn event_type_too_many_dots() {
let err = validate_event_type("combat.attack.crit").unwrap_err();
assert!(err.contains("too_many_segments"));
}
#[test]
fn event_type_uppercase_rejected() {
assert!(validate_event_type("Combat.attack").is_err());
assert!(validate_event_type("combat.Attack").is_err());
}
#[test]
fn event_type_special_chars_rejected() {
assert!(validate_event_type("combat.attack!").is_err());
assert!(validate_event_type("combat-system.attack").is_err());
assert!(validate_event_type("combat.attack hit").is_err());
}
#[test]
fn event_type_empty_string() {
assert!(validate_event_type("").is_err());
}
#[test]
fn occurred_at_basic() {
assert_eq!(compute_occurred_at(10, 100), 1_000_000);
}
#[test]
fn occurred_at_zero_tick() {
assert_eq!(compute_occurred_at(0, 100), 0);
}
#[test]
fn occurred_at_zero_rate() {
assert_eq!(compute_occurred_at(100, 0), 0);
}
#[test]
fn occurred_at_one_tick_one_ms() {
assert_eq!(compute_occurred_at(1, 1), 1000);
}
#[test]
fn build_record_populates_all_fields() {
let params = CanonEventParams {
timeline_id: "main".to_string(),
scope_key: "tl:main/lvl:world/id:ayora".to_string(),
event_type: "combat.attack".to_string(),
actor_id: "actor_1".to_string(),
target_id: "actor_2".to_string(),
payload: "{}".to_string(),
impact_score: 5,
world_id: "ayora".to_string(),
loom_tier: 2,
irreversible: false,
provenance_kind: "actor".to_string(),
producer_id: "0xabc".to_string(),
};
let record = build_canon_event_record(¶ms, 42, 7, "genesis", 100);
assert_eq!(record.event_id, "evt:main:42:000000007");
assert_eq!(record.prev_event_id, "genesis");
assert_eq!(record.tick, 42);
assert_eq!(record.seq, 7);
assert_eq!(record.occurred_at, 42 * 100 * 1000);
assert_eq!(record.contract_version, CONTRACT_VERSION);
assert_eq!(record.event_schema_version, EVENT_SCHEMA_VERSION);
assert_eq!(record.digest.len(), 64);
}
#[test]
fn build_record_digest_matches_standalone() {
let params = CanonEventParams {
timeline_id: "t".to_string(),
scope_key: "s".to_string(),
event_type: "e.v".to_string(),
actor_id: "a".to_string(),
target_id: "".to_string(),
payload: "".to_string(),
impact_score: 0,
world_id: "w".to_string(),
loom_tier: 0,
irreversible: false,
provenance_kind: "system".to_string(),
producer_id: "".to_string(),
};
let record = build_canon_event_record(¶ms, 1, 0, "prev", 50);
let expected_digest = compute_event_digest(&build_event_id("t", 1, 0), "e.v", 1, 0, "t", "s", "a", "prev");
assert_eq!(record.digest, expected_digest);
}
#[test]
fn contract_version_format() {
assert!(CONTRACT_VERSION.starts_with("dreamwell.canon."));
assert!(CONTRACT_VERSION.contains("v1"));
}
#[test]
fn event_schema_version_is_one() {
assert_eq!(EVENT_SCHEMA_VERSION, 1);
}
#[cfg(feature = "signing")]
#[test]
fn ed25519_sign_and_verify() {
use ed25519_dalek::SigningKey;
let seed: [u8; 32] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12,
0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
];
let signing_key = SigningKey::from_bytes(&seed);
let verifying_key = signing_key.verifying_key();
let digest = compute_event_digest(
"evt:t1:1:000000001",
"test",
1,
1,
"t1",
"scope:test",
"actor:1",
"none",
);
let sig = sign_event_digest(&digest, &signing_key);
assert!(verify_event_signature(&digest, &sig, &verifying_key));
assert!(!verify_event_signature("tampered", &sig, &verifying_key));
}
}