diff --git a/Cargo.lock b/Cargo.lock index 1b668737..d86cc242 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6116,6 +6116,7 @@ dependencies = [ name = "pod2utils" version = "0.1.0" dependencies = [ + "alloy-primitives", "anyhow", "itertools 0.14.0", "log", diff --git a/Cargo.toml b/Cargo.toml index 002d7107..74c94fa0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ alloy = { version = "1.2.1", features = [ "rpc-client", "rpc-types-beacon", ] } +alloy-primitives = "1.5.7" # common anyhow = "1.0.56" diff --git a/libs/driver/src/clients/synchronizer_client.rs b/libs/driver/src/clients/synchronizer_client.rs index 374b76c6..63f9f52d 100644 --- a/libs/driver/src/clients/synchronizer_client.rs +++ b/libs/driver/src/clients/synchronizer_client.rs @@ -118,6 +118,8 @@ impl SynchronizerClient for HttpSynchronizerClient { let state_header = StateHeader::new( payload.block_number, + payload.block_timestamp, + payload.block_hash, payload.created_root, payload.nullifiers_root, payload.prior_state_history_root, diff --git a/libs/driver/src/object_store.rs b/libs/driver/src/object_store.rs index f5fa1460..de436445 100644 --- a/libs/driver/src/object_store.rs +++ b/libs/driver/src/object_store.rs @@ -195,6 +195,8 @@ mod tests { GroundingWitness::new( StateHeader::new( 1, + 1, + pod2::middleware::EMPTY_HASH, pod2::middleware::EMPTY_HASH, pod2::middleware::EMPTY_HASH, pod2::middleware::EMPTY_HASH, diff --git a/libs/driver/src/pexe_catalog.rs b/libs/driver/src/pexe_catalog.rs index 2a5a84cd..cf5c8c66 100644 --- a/libs/driver/src/pexe_catalog.rs +++ b/libs/driver/src/pexe_catalog.rs @@ -756,6 +756,8 @@ description = "consume a Foo to make a Bar" txlib::GroundingWitness::new( txlib::StateHeader::new( 1, + 1, + pod2::middleware::EMPTY_HASH, pod2::middleware::EMPTY_HASH, pod2::middleware::EMPTY_HASH, pod2::middleware::EMPTY_HASH, diff --git a/libs/driver/src/tests.rs b/libs/driver/src/tests.rs index 569345d1..0c2a3674 100644 --- a/libs/driver/src/tests.rs +++ b/libs/driver/src/tests.rs @@ -43,6 +43,8 @@ fn dummy_grounding_witness() -> GroundingWitness { GroundingWitness::new( StateHeader::new( 1, + 1, + pod2::middleware::EMPTY_HASH, pod2::middleware::EMPTY_HASH, pod2::middleware::EMPTY_HASH, pod2::middleware::EMPTY_HASH, @@ -66,6 +68,8 @@ fn state_header(state: &TestState) -> StateHeader { let (created_root, nullifiers_root, prior_state_history_root) = state.roots(); StateHeader::new( state.block_number, + state.block_timestamp, + state.block_hash, created_root, nullifiers_root, prior_state_history_root, diff --git a/libs/payload/src/test_state.rs b/libs/payload/src/test_state.rs index 71c3e59d..14a9b738 100644 --- a/libs/payload/src/test_state.rs +++ b/libs/payload/src/test_state.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use pod2::{ backends::plonky2::primitives::merkletree::MerkleProof, middleware::{ - Hash, Value, + F, Hash, Value, containers::{Array, Set}, }, }; @@ -15,6 +15,8 @@ use pod2::{ #[derive(Clone, Debug)] pub struct TestState { pub block_number: i64, + pub block_timestamp: i64, + pub block_hash: Hash, created: Array, created_index: HashMap, nullifiers: Set, @@ -27,10 +29,18 @@ impl Default for TestState { } } +pub struct BlockMetadata { + pub number: u32, + pub timestamp: u64, + pub hash: Hash, +} + impl TestState { pub fn empty(block_number: i64) -> Self { Self { block_number, + block_timestamp: block_number * 1000, + block_hash: Hash([F(0), F(0), F(0), F(block_number as u64)]), created: Array::new(Vec::::new()), created_index: HashMap::new(), nullifiers: Set::new(HashSet::::new()), @@ -54,15 +64,20 @@ impl TestState { pub fn build_grounding_witness( &self, input_commitments: &[Hash], - build: impl FnOnce(i64, Hash, Hash, Hash, HashMap) -> W, + build: impl FnOnce(BlockMetadata, Hash, Hash, Hash, HashMap) -> W, ) -> W { let created_proofs = input_commitments .iter() .map(|commitment| (*commitment, self.created_membership_proof(*commitment))) .collect::>(); let (created_root, nullifiers_root, prior_state_history_root) = self.roots(); + let meta = BlockMetadata { + number: self.block_number as u32, + timestamp: self.block_timestamp as u64, + hash: self.block_hash, + }; build( - self.block_number, + meta, created_root, nullifiers_root, prior_state_history_root, diff --git a/libs/pexe/src/fixtures.rs b/libs/pexe/src/fixtures.rs index e6492f7d..041e5df9 100644 --- a/libs/pexe/src/fixtures.rs +++ b/libs/pexe/src/fixtures.rs @@ -118,7 +118,14 @@ pub fn build_synthetic_state( indices.insert(commitment, index); } - let state_header = StateHeader::new(1, created.commitment(), EMPTY_HASH, EMPTY_HASH); + let state_header = StateHeader::new( + 1, + 1, + EMPTY_HASH, + created.commitment(), + EMPTY_HASH, + EMPTY_HASH, + ); let mut created_proofs: HashMap = HashMap::with_capacity(objs.len()); for obj in objs { diff --git a/libs/pod2utils/Cargo.toml b/libs/pod2utils/Cargo.toml index b6e27c61..a14ac841 100644 --- a/libs/pod2utils/Cargo.toml +++ b/libs/pod2utils/Cargo.toml @@ -12,3 +12,4 @@ serde = { workspace = true } serde_json = { workspace = true } plonky2 = { workspace = true } rand = { workspace = true } +alloy-primitives = { workspace = true } diff --git a/libs/pod2utils/src/lib.rs b/libs/pod2utils/src/lib.rs index 4eeecdba..eabe4717 100644 --- a/libs/pod2utils/src/lib.rs +++ b/libs/pod2utils/src/lib.rs @@ -1,8 +1,9 @@ pub mod macros; pub mod mockintro; +use alloy_primitives::B256; use plonky2::field::types::Field; -use pod2::middleware::{F, RawValue}; +use pod2::middleware::{EMPTY_HASH, F, Hash, RawValue}; use rand::{RngCore, SeedableRng, rngs::StdRng}; use std::array; use std::cell::RefCell; @@ -35,3 +36,16 @@ pub fn rand_raw_value() -> RawValue { } }) } + +/// Lossy conversion of a 256 bit value to a pod2 Hash +pub fn b256_to_hash(v: B256) -> Hash { + let mut limbs: [[u8; 8]; 4] = Default::default(); + for (i, b) in v.0.iter().enumerate() { + limbs[i / 8][i % 8] = *b; + } + let mut hash = EMPTY_HASH; + for (i, l) in limbs.into_iter().enumerate() { + hash.0[i] = F::from_noncanonical_u64(u64::from_le_bytes(l)); + } + hash +} diff --git a/libs/sdk/src/fmt_podlang.rs b/libs/sdk/src/fmt_podlang.rs index 01f6e826..6f477718 100644 --- a/libs/sdk/src/fmt_podlang.rs +++ b/libs/sdk/src/fmt_podlang.rs @@ -279,9 +279,13 @@ struct SubActionCall { /// Same, for the sub's `out` record. sub_out_var: Option, /// Script-side alias name (the `pick` in `var pick = action.subaction(...)`). - /// `None` if the user didn't bind via `var`. Used to skip the alias from - /// the parent's private wildcards list. + /// `None` if the user didn't bind via `var`. An alias the parent body + /// references becomes a parent wildcard pinned to the sub's out record; + /// an unreferenced alias is skipped from the private wildcards list. alias: Option, + /// Name of the sub's first out entry (always slot 0), the one the + /// alias refers to. `None` if the sub produces nothing. + first_out_entry: Option, } /// Walk the parent action's Insts and gather one `SubActionCall` per @@ -325,12 +329,14 @@ fn collect_sub_action_calls(action: &ActionContext, loader: &Loader) -> Vec = - sub_calls.iter().filter_map(|c| c.alias.clone()).collect(); + // Sub-action aliases: parent vars that hold a sub's first out + // entry's Object Ref. A referenced alias becomes a real parent + // wildcard (pinned to the sub's out record by an ArrayContains + // clause below); an unreferenced alias is structural only and is + // skipped from the private list. + let referenced = crate::body_referenced_vars(&action.insts); + let alias_names: std::collections::HashSet = sub_calls + .iter() + .filter_map(|c| c.alias.clone()) + .filter(|alias| !referenced.contains(alias)) + .collect(); // Private wildcards: every (var, ts) except sub-action aliases, // chain endpoints (public chain0/chain), packed chain intermediates @@ -483,6 +494,24 @@ fn fmt_action(action: &ActionContext, loader: &Loader, w: &mut dyn fmt::Write) - )?; } } + // Pin each referenced sub-action alias to its sub's first out + // entry. + for call in &sub_calls { + let Some(alias) = &call.alias else { continue }; + if !referenced.contains(alias) { + continue; + } + let (Some(out_var), Some(entry)) = (&call.sub_out_var, &call.first_out_entry) else { + continue; + }; + writeln!( + w, + " ArrayContains({out_var}, {}::{}, {})", + schema_name(&call.sub_name, Side::Out), + entry, + fmt_var_at(alias, 0, meta.max_ts(alias)), + )?; + } // ---- Body (Insts other than Object) ---- let mut sub_call_idx: usize = 0; @@ -629,7 +658,10 @@ fn fmt_bridges(loader: &Loader, w: &mut dyn fmt::Write) -> fmt::Result { // Bridge predicate signature: state, chain0, chain (public); // in , out private as needed. - write!(w, "{bridge_name}(state, chain0, chain")?; + write!( + w, + "{bridge_name}(state, state_header StateHeader, chain0, chain" + )?; let mut priv_parts: Vec = Vec::new(); if !meta.in_entries.is_empty() { priv_parts.push(format!("in {}", schema_name(&meta.name, Side::In))); @@ -686,7 +718,7 @@ fn fmt_class(loader: &Loader, w: &mut dyn fmt::Write, class: &ClassMeta) -> fmt: let o = &meta.object_refs[*obj_index]; let multi = is_multi_class(&meta.object_refs, &o.class); let bridge_name = bridge_predicate_name(&o.class, action_name, &o.varname, multi); - writeln!(w, " {bridge_name}(state, chain0, chain)")?; + writeln!(w, " {bridge_name}(state, state_header, chain0, chain)")?; } writeln!(w, ")")?; Ok(()) @@ -702,7 +734,7 @@ pub(crate) fn fmt(loader: &Loader, w: &mut dyn fmt::Write) -> fmt::Result { // `tx` writeln!( w, - "record StateHeader = (block_number, created, nullifiers, prior_state_history)" + "record StateHeader = (block_number, block_time, block_hash, created, nullifiers, prior_state_history)" )?; fmt_record_decls(loader, w)?; writeln!(w, "\n// Actions\n")?; diff --git a/libs/sdk/src/lib.rs b/libs/sdk/src/lib.rs index 4006787c..267ed4f1 100644 --- a/libs/sdk/src/lib.rs +++ b/libs/sdk/src/lib.rs @@ -152,11 +152,18 @@ enum Inst { /// statement is consumed here in the parent's post-Rhai walk. SubAction { action: String, - /// Aliases the sub-action's first producing object Ref so - /// parent scripts can bind it via `var foo = subaction(...)`. + /// Aliases the Ref of the sub-action's first produced object + /// (its out entry 0) so parent scripts can bind it via + /// `var foo = subaction(...)`. obj: Ref, /// Sub-action's predicate statement. Some at Execute, None at Load. st_sub: Option, + /// The sub-action's `out` record value plus the aliased out + /// entry's dict, snapshotted before any parent-side writes. + /// Backs the ArrayContains clause that pins the alias wildcard + /// when the parent body references it. Some at Execute when + /// the sub produces an object, None otherwise. + sub_out: Option<(Value, Dictionary)>, }, } @@ -518,8 +525,11 @@ impl ActionHandle { /// Open an action scope, run the action's rhai body, emit its /// txlib events, build its predicate, attach guards, and close /// the scope. Sub-actions invoked from the rhai body recurse here - /// and stack their scope on top of this one. - fn exe_action(&self) -> RuntimeResult { + /// and stack their scope on top of this one. Returns the action's + /// predicate statement plus its out record (one entry per produced + /// Object, in declaration order), which `subaction` uses to pin a + /// referenced alias. + fn exe_action(&self) -> RuntimeResult<(Statement, Array)> { let (module, scope_id, action) = { let ctx = self.0.borrow(); let exe_rc = ctx.exe_ctx.as_ref().expect("exe phase").clone(); @@ -692,6 +702,33 @@ impl ActionHandle { } } } + // Pin each referenced sub-action alias to its sub's first + // out entry, matching fmt_action's alias ArrayContains + // clause. + let referenced = body_referenced_vars(&ctx.insts); + for inst in &ctx.insts { + if let Inst::SubAction { + obj, + sub_out: Some((out_record, dict)), + .. + } = inst + { + let alias = obj.borrow().var_name().to_string(); + if alias == "?" || !referenced.contains(&alias) { + continue; + } + let st = exe_ctx + .bld + .builder + .priv_op(Operation::array_contains( + out_record.clone(), + 0_i64, + Value::from(dict.clone()), + )) + .unwrap(); + array_contains_sts.push(st); + } + } } // ---- Per-Object type guard + Tx event. Same order as @@ -714,8 +751,13 @@ impl ActionHandle { post_ts: usize, } // Assign a parent_ts to each Object/SubAction inst (each one - // advances the parent chain by exactly 1, in inst-iteration - // order). Drives chain anchoring and the chain_steps record. + // advances the parent chain by exactly 1). Events are recorded + // in emission order, not declaration order: sub-actions run and + // emit during the parent's Rhai body, while direct Object events + // are emitted post-Rhai, so all SubAction insts occupy the chain + // ts range before all Object insts (each group in inst order). + // `fmt_action` renders the same order. Drives chain anchoring + // and the chain_steps record. let chain_max_ts = meta.chain_max_ts; // chain_step_values[ts - 1] holds the per-ts chain hash for each // intermediate ts in 1..chain_max_ts (the final ts is the public @@ -727,17 +769,23 @@ impl ActionHandle { vec![Value::from(0_i64); chain_max_ts.saturating_sub(1)]; let inst_chain_ts: Vec> = { let ctx = self.0.borrow(); - let mut chain_ts: usize = 0; + let num_subs = ctx + .insts + .iter() + .filter(|inst| matches!(inst, Inst::SubAction { .. })) + .count(); + let mut sub_ts: usize = 0; + let mut obj_ts: usize = num_subs; ctx.insts .iter() .map(|inst| match inst { Inst::Object { .. } => { - chain_ts += 1; - Some(chain_ts) + obj_ts += 1; + Some(obj_ts) } Inst::SubAction { st_sub, .. } => { - chain_ts += 1; - if let Some(slot) = fmt_podlang::chain_step_at(chain_ts, chain_max_ts) { + sub_ts += 1; + if let Some(slot) = fmt_podlang::chain_step_at(sub_ts, chain_max_ts) { let st = st_sub .as_ref() .expect("SubAction statement captured at Rhai"); @@ -747,7 +795,7 @@ impl ActionHandle { }; chain_step_values[slot] = post_chain; } - Some(chain_ts) + Some(sub_ts) } _ => None, }) @@ -980,7 +1028,7 @@ impl ActionHandle { let ts = *current_ts.get(obj).unwrap_or(&0); let dict_arg = anchor_or_literal(obj, &dict, ts); for (key, value) in kvs { - let v = value.borrow().as_value().clone(); + let v = value.borrow().as_op_arg(); let st = exe_ctx .bld .builder @@ -998,7 +1046,7 @@ impl ActionHandle { } => { let old = old_dict.clone().expect("Update old_dict captured at Rhai"); let new = new_dict.clone().expect("Update new_dict captured at Rhai"); - let v = value.borrow().as_value().clone(); + let v = value.borrow().as_op_arg(); let ts_before = *current_ts.get(obj).unwrap_or(&0); let ts_after = ts_before + 1; let new_arg = anchor_or_literal(obj, &new, ts_after); @@ -1071,7 +1119,7 @@ impl ActionHandle { } exe_ctx.tx_builder.end_action(scope_id); } - Ok(st_action) + Ok((st_action, out_array)) } // // Exposed methods helpers @@ -1101,36 +1149,48 @@ impl ActionHandle { /// before this call returns. The sub-action's predicate statement /// is cached on the `Inst::SubAction` and composed into the /// parent's predicate during its post-Rhai body walk. The returned - /// `ArgHandle` aliases the sub's first producing object so parent - /// scripts can bind it via `var foo = subaction("X")`. + /// `ArgHandle` aliases the sub's first produced object (its out + /// entry 0) so parent scripts can bind it via + /// `var foo = subaction("X")`. fn subaction(self, action: String) -> RuntimeResult { let exe_rc_opt = self.0.borrow().exe_ctx.clone(); let arg_placeholder = Rc::new(RefCell::new(VarOrValue::var(Type::Dict))); - let (arg, st_sub) = if let Some(exe_rc) = exe_rc_opt { + let (arg, st_sub, sub_out) = if let Some(exe_rc) = exe_rc_opt { let sub_handle = ActionHandle::new(action.clone(), Some(exe_rc.clone())); - let st_sub = sub_handle.exe_action()?; + let (st_sub, sub_out_array) = sub_handle.exe_action()?; - // Alias the parent's binding to the sub-action's first - // producing object Ref, or a fresh placeholder if the sub + // Alias the parent's binding to the Ref of the sub-action's + // first produced object, or a fresh placeholder if the sub // produces nothing. - let arg = sub_handle - .0 - .borrow() - .insts - .iter() - .find_map(|i| match i { - Inst::Object { - io: ObjectIO::Output | ObjectIO::Mutate, - obj, - .. - } => Some(obj.clone()), + let aliased: Option<(ObjectIO, Ref)> = + sub_handle.0.borrow().insts.iter().find_map(|i| match i { + Inst::Object { io, obj, .. } if io.produces() => Some((*io, obj.clone())), _ => None, - }) - .unwrap_or_else(|| arg_placeholder.clone()); - (arg, Some(st_sub)) + }); + let (arg, sub_out) = if let Some((io, obj)) = aliased { + let entry0 = sub_out_array + .get(0) + .expect("array op") + .expect("sub out record has an entry per produced object"); + // An Output's out entry is the post-identity dict, but + // the sub left its Ref at the pre-identity form; rebind + // so parent reads see the recorded form. + if io == ObjectIO::Output { + obj.borrow_mut().set_value(entry0.clone()); + } + // The shared Ref still carries the sub's script-side + // var name; reset it so only a parent `var` binding + // names it (matching the Load-phase placeholder). + obj.borrow_mut().set_var_name("?".to_string())?; + let dict = entry0.as_dictionary().expect("out entry is a dict"); + (obj, Some((Value::from(sub_out_array), dict))) + } else { + (arg_placeholder.clone(), None) + }; + (arg, Some(st_sub), sub_out) } else { - (arg_placeholder, None) + (arg_placeholder, None, None) }; let mut ctx = self.0.borrow_mut(); @@ -1138,6 +1198,7 @@ impl ActionHandle { action, obj: arg.clone(), st_sub, + sub_out, }); ctx.inc_t_var("chain").expect("chain exists"); Ok(ArgHandle::new(self.clone(), arg)) @@ -1641,6 +1702,7 @@ impl ActionMeta { var_max_ts: ctx.max_ts_per_var(), ..Self::default() }; + let referenced = body_referenced_vars(&ctx.insts); for inst in &ctx.insts { match inst { Inst::Object { io, obj, class, .. } => { @@ -1657,11 +1719,17 @@ impl ActionMeta { } meta.object_refs.push(r); } - Inst::SubAction { action, .. } => { + Inst::SubAction { action, obj, .. } => { let sub = prior .iter() .find(|a| &a.name == action) .ok_or_else(|| anyhow!("subaction {action} not defined"))?; + let alias = obj.borrow().var_name().to_string(); + if alias != "?" && referenced.contains(&alias) && sub.out_entries.is_empty() { + return Err(anyhow!( + "subaction {action} produces no object; `{alias}` cannot be referenced in the parent body" + )); + } meta.total_inputs.extend(sub.total_inputs.iter().cloned()); meta.total_outputs.extend(sub.total_outputs.iter().cloned()); } @@ -1700,6 +1768,41 @@ impl ActionMeta { } } +/// Var names referenced by the action's body Insts: as a Statement or +/// Intro arg, as a Set/Update value, or as a Set/Update target. Drives +/// whether a sub-action alias must materialize as a parent wildcard +/// pinned to the sub's out record; unreferenced aliases stay purely +/// structural. +pub(crate) fn body_referenced_vars(insts: &[Inst]) -> HashSet { + fn add(referenced: &mut HashSet, r: &Ref) { + if let VarOrValue::Var(var) = &*r.borrow() { + referenced.insert(var.name.clone()); + } + } + let mut referenced = HashSet::new(); + for inst in insts { + match inst { + Inst::Object { .. } | Inst::SubAction { .. } => {} + Inst::Update { obj, value, .. } => { + referenced.insert(obj.clone()); + add(&mut referenced, value); + } + Inst::Set { obj, kvs, .. } => { + referenced.insert(obj.clone()); + for (_key, value) in kvs { + add(&mut referenced, value); + } + } + Inst::Statement { args, .. } | Inst::Intro { args, .. } => { + for arg in args { + add(&mut referenced, arg); + } + } + } + } + referenced +} + /// An Object's in/out/initials form normally collapses into the matching /// record and needs no wildcard of its own. A body reference can defeat /// that, keeping the form as an explicit wildcard instead; this walks the @@ -2086,7 +2189,12 @@ impl SdkModule { // Step 2: discharge the bridge predicate. let st_bridge = bld - .apply_custom_pred_simple(false, &bridge_name, vec![st_array_contains, st_action]) + .apply_custom_pred( + false, + &bridge_name, + map!({"state_header" => state_header.array()}), + vec![st_array_contains, st_action], + ) .expect("apply bridge predicate"); // Step 3: IsX OR with the bridge at the right branch. diff --git a/libs/sdk/src/tests.rs b/libs/sdk/src/tests.rs index 54c0594c..8fa5e4e7 100644 --- a/libs/sdk/src/tests.rs +++ b/libs/sdk/src/tests.rs @@ -13,10 +13,12 @@ fn apply_tx(state: &mut TestState, tx: &Tx) { fn grounding_witness(state: &TestState, input_commitments: &[Hash]) -> Arc { state.build_grounding_witness( input_commitments, - |block_number, created_root, nullifiers_root, prior_state_history_root, created_proofs| { + |block_meta, created_root, nullifiers_root, prior_state_history_root, created_proofs| { Arc::new(GroundingWitness::new( StateHeader::new( - block_number, + block_meta.number as i64, + block_meta.timestamp as i64, + block_meta.hash, created_root, nullifiers_root, prior_state_history_root, @@ -248,7 +250,7 @@ fn test_sdk_2() { [plugin] name = "test" version = "0.1.0" - module_hash = "9e84b0fb084e8be99f74c7788e3c43d13927826f0e0315f99d9b9a678c24103b" + module_hash = "2c0b88c5322c588e45a168c583cd557f17bddde7c64306ea93ffb7aa68c35645" [[classes]] name = "Log" @@ -325,7 +327,7 @@ JustOutput(out JustOutputOut, chain0, chain, private: initials JustOutputInitial // Bridges -IsFooFromJustOutput(state, chain0, chain, private: out JustOutputOut) = AND( +IsFooFromJustOutput(state, state_header StateHeader, chain0, chain, private: out JustOutputOut) = AND( ArrayContains(out, JustOutputOut::x, state) JustOutput(out, chain0, chain) ) @@ -333,7 +335,7 @@ IsFooFromJustOutput(state, chain0, chain, private: out JustOutputOut) = AND( // Classes IsFoo(state, state_header StateHeader, chain0, chain) = OR( - IsFooFromJustOutput(state, chain0, chain) + IsFooFromJustOutput(state, state_header, chain0, chain) ) "#; assert!( @@ -379,12 +381,12 @@ LogToWood(in LogToWoodIn, out LogToWoodOut, chain0, chain, private: chain1, wood // Bridges -IsLogFromLogToWood(state, chain0, chain, private: in LogToWoodIn, out LogToWoodOut) = AND( +IsLogFromLogToWood(state, state_header StateHeader, chain0, chain, private: in LogToWoodIn, out LogToWoodOut) = AND( ArrayContains(in, LogToWoodIn::log, state) LogToWood(in, out, chain0, chain) ) -IsWoodFromLogToWood(state, chain0, chain, private: in LogToWoodIn, out LogToWoodOut) = AND( +IsWoodFromLogToWood(state, state_header StateHeader, chain0, chain, private: in LogToWoodIn, out LogToWoodOut) = AND( ArrayContains(out, LogToWoodOut::wood, state) LogToWood(in, out, chain0, chain) ) @@ -392,11 +394,11 @@ IsWoodFromLogToWood(state, chain0, chain, private: in LogToWoodIn, out LogToWood // Classes IsLog(state, state_header StateHeader, chain0, chain) = OR( - IsLogFromLogToWood(state, chain0, chain) + IsLogFromLogToWood(state, state_header, chain0, chain) ) IsWood(state, state_header StateHeader, chain0, chain) = OR( - IsWoodFromLogToWood(state, chain0, chain) + IsWoodFromLogToWood(state, state_header, chain0, chain) ) "#; assert!( @@ -452,7 +454,7 @@ fn test_records_form_subaction() { assert!( module .podlang_src - .contains("IsBarFromMineBar(state, chain0, chain, private: out MineBarOut) = AND("), + .contains("IsBarFromMineBar(state, state_header StateHeader, chain0, chain, private: out MineBarOut) = AND("), "missing IsBarFromMineBar bridge:\n{}", module.podlang_src ); @@ -504,7 +506,7 @@ UseFoo(in UseFooIn, out UseFooOut, chain0, chain, private: foo0, dur) = AND( // Bridges -IsFooFromUseFoo(state, chain0, chain, private: in UseFooIn, out UseFooOut) = AND( +IsFooFromUseFoo(state, state_header StateHeader, chain0, chain, private: in UseFooIn, out UseFooOut) = AND( ArrayContains(out, UseFooOut::foo, state) UseFoo(in, out, chain0, chain) ) @@ -512,7 +514,7 @@ IsFooFromUseFoo(state, chain0, chain, private: in UseFooIn, out UseFooOut) = AND // Classes IsFoo(state, state_header StateHeader, chain0, chain) = OR( - IsFooFromUseFoo(state, chain0, chain) + IsFooFromUseFoo(state, state_header, chain0, chain) ) "#; assert!( @@ -522,6 +524,273 @@ IsFoo(state, state_header StateHeader, chain0, chain) = OR( ); } +/// Parent reads a value off the object mutated by a sub-action: the +/// referenced alias becomes a parent wildcard pinned to the sub's +/// first out entry. +#[allow(clippy::cloned_ref_to_slice_refs)] +#[test] +fn test_subaction_alias_read_mutate() { + let _ = env_logger::builder().is_test(true).try_init(); + let craft_src = r#" + fn SpawnShip(action) { + var ship = action.output("Ship"); + ship.set([["fuel", 10]]); + } + + fn BurnFuel(action) { + var ship = action.mutate("Ship"); + var fuel = action.random(); + ship.update("fuel", fuel); + } + + fn MineRock(action) { + var ship = action.subaction("BurnFuel"); + var rock = action.output("Rock"); + rock.set([["mined_with_fuel", ship.fuel]]); + } + "#; + let sdk = Sdk::default(); + let module = sdk + .load_module_from_src_actions(craft_src, &["SpawnShip", "BurnFuel", "MineRock"]) + .unwrap(); + println!("{}", module.podlang_src); + + let mut state = TestState::default(); + + let executor = module.executor(true, grounding_witness(&state, &[])); + let res = executor.action("SpawnShip", vec![]).unwrap(); + let ship_tx = res.tx.clone(); + let [ship] = res.objs(); + apply_tx(&mut state, &ship_tx); + + let executor = module.executor(true, grounding_witness(&state, &[ship.obj.commitment()])); + let res = executor.action("MineRock", vec![ship]).unwrap(); + let [_ship2, _rock] = res.objs(); +} + +/// Entry reads written into another object via set(): `seen_fuel` +/// reads the pre-mutation form (forces the in-side wildcard) and +/// `mined_with_fuel` the post-mutation form (forces the out-side +/// wildcard). Both must be emitted as Contains-backed args, not +/// literals, to match the rendered anchored-key templates. +#[allow(clippy::cloned_ref_to_slice_refs)] +#[test] +fn test_cross_read_into_set() { + let _ = env_logger::builder().is_test(true).try_init(); + let craft_src = r#" + fn SpawnShip(action) { + var ship = action.output("Ship"); + ship.set([["fuel", 10]]); + } + + fn MineRock(action) { + var ship = action.mutate("Ship"); + var rock = action.output("Rock"); + rock.set([["seen_fuel", ship.fuel]]); + var fuel = unsafe { ship.fuel - 1 }; + action.st_sum(fuel, 1, ship.fuel); + ship.update("fuel", fuel); + rock.set([["mined_with_fuel", ship.fuel]]); + } + "#; + let sdk = Sdk::default(); + let module = sdk + .load_module_from_src_actions(craft_src, &["SpawnShip", "MineRock"]) + .unwrap(); + println!("{}", module.podlang_src); + + let mut state = TestState::default(); + + let executor = module.executor(true, grounding_witness(&state, &[])); + let res = executor.action("SpawnShip", vec![]).unwrap(); + let ship_tx = res.tx.clone(); + let [ship] = res.objs(); + apply_tx(&mut state, &ship_tx); + + let executor = module.executor(true, grounding_witness(&state, &[ship.obj.commitment()])); + let res = executor.action("MineRock", vec![ship]).unwrap(); + let [_ship2, _rock] = res.objs(); +} + +/// Three direct-object chain events (mutate first) so the chain packs +/// into a `Chain` record, with no sub-actions: guards the +/// emission-order chain-ts numbering for the all-direct case. +#[allow(clippy::cloned_ref_to_slice_refs)] +#[test] +fn test_packed_chain_direct_objects() { + let _ = env_logger::builder().is_test(true).try_init(); + let craft_src = r#" + fn SpawnShip(action) { + var ship = action.output("Ship"); + ship.set([["fuel", 10]]); + } + + fn MineTwoRocks(action) { + var ship = action.mutate("Ship"); + var rock_a = action.output("Rock"); + var rock_b = action.output("Rock"); + var fuel = action.random(); + ship.update("fuel", fuel); + } + "#; + let sdk = Sdk::default(); + let module = sdk + .load_module_from_src_actions(craft_src, &["SpawnShip", "MineTwoRocks"]) + .unwrap(); + println!("{}", module.podlang_src); + + let mut state = TestState::default(); + + let executor = module.executor(true, grounding_witness(&state, &[])); + let res = executor.action("SpawnShip", vec![]).unwrap(); + let ship_tx = res.tx.clone(); + let [ship] = res.objs(); + apply_tx(&mut state, &ship_tx); + + let executor = module.executor(true, grounding_witness(&state, &[ship.obj.commitment()])); + let res = executor.action("MineTwoRocks", vec![ship]).unwrap(); + let [_ship2, _rock_a, _rock_b] = res.objs(); +} + +/// Direct objects declared before a subaction call, with enough events +/// to pack the parent's chain record. Events are recorded sub-actions +/// first (they run during Rhai; direct events are emitted post-Rhai), +/// so chain-ts numbering must follow emission order, not declaration +/// order. +#[allow(clippy::cloned_ref_to_slice_refs)] +#[test] +fn test_packed_chain_objects_before_subaction() { + let _ = env_logger::builder().is_test(true).try_init(); + let craft_src = r#" + fn SpawnShip(action) { + var ship = action.output("Ship"); + ship.set([["fuel", 10]]); + } + + fn BurnFuel(action) { + var ship = action.mutate("Ship"); + var fuel = action.random(); + ship.update("fuel", fuel); + } + + fn MineTwoRocks(action) { + var rock_a = action.output("Rock"); + var rock_b = action.output("Rock"); + var ship = action.subaction("BurnFuel"); + } + "#; + let sdk = Sdk::default(); + let module = sdk + .load_module_from_src_actions(craft_src, &["SpawnShip", "BurnFuel", "MineTwoRocks"]) + .unwrap(); + println!("{}", module.podlang_src); + + let mut state = TestState::default(); + + let executor = module.executor(true, grounding_witness(&state, &[])); + let res = executor.action("SpawnShip", vec![]).unwrap(); + let ship_tx = res.tx.clone(); + let [ship] = res.objs(); + apply_tx(&mut state, &ship_tx); + + let executor = module.executor(true, grounding_witness(&state, &[ship.obj.commitment()])); + let res = executor.action("MineTwoRocks", vec![ship]).unwrap(); + let [_ship2, _rock_a, _rock_b] = res.objs(); +} + +/// Two mutations in one action where the second object's update() +/// takes a value read off the first object, exercising the +/// Contains-backed value arg on the DictUpdate path. +#[allow(clippy::cloned_ref_to_slice_refs)] +#[test] +fn test_cross_read_into_update() { + let _ = env_logger::builder().is_test(true).try_init(); + let craft_src = r#" + fn SpawnShip(action) { + var ship = action.output("Ship"); + ship.set([["fuel", 10]]); + } + + fn SpawnSector(action) { + var sector = action.output("Sector"); + sector.set([["ship_fuel", 0]]); + } + + fn EnterSector(action) { + var ship = action.mutate("Ship"); + var sector = action.mutate("Sector"); + var fuel = unsafe { ship.fuel - 1 }; + action.st_sum(fuel, 1, ship.fuel); + ship.update("fuel", fuel); + sector.update("ship_fuel", ship.fuel); + } + "#; + let sdk = Sdk::default(); + let module = sdk + .load_module_from_src_actions(craft_src, &["SpawnShip", "SpawnSector", "EnterSector"]) + .unwrap(); + println!("{}", module.podlang_src); + + let mut state = TestState::default(); + + let executor = module.executor(true, grounding_witness(&state, &[])); + let res = executor.action("SpawnShip", vec![]).unwrap(); + let ship_tx = res.tx.clone(); + let [ship] = res.objs(); + apply_tx(&mut state, &ship_tx); + + let executor = module.executor(true, grounding_witness(&state, &[])); + let res = executor.action("SpawnSector", vec![]).unwrap(); + let sector_tx = res.tx.clone(); + let [sector] = res.objs(); + apply_tx(&mut state, §or_tx); + + let executor = module.executor( + true, + grounding_witness(&state, &[ship.obj.commitment(), sector.obj.commitment()]), + ); + let res = executor.action("EnterSector", vec![ship, sector]).unwrap(); + let [_ship2, _sector2] = res.objs(); +} + +/// Parent reads values off an object created (not mutated) by a +/// sub-action, exercising the post-identity rebinding of the alias. +#[allow(clippy::cloned_ref_to_slice_refs)] +#[test] +fn test_subaction_alias_read_output() { + let _ = env_logger::builder().is_test(true).try_init(); + let craft_src = r#" + fn SpawnShip(action) { + var ship = action.output("Ship"); + ship.set([["fuel", 10]]); + } + + fn ChristenShip(action) { + var ship = action.subaction("SpawnShip"); + var plaque = action.output("Plaque"); + plaque.set([["ship_fuel", ship.fuel], ["ship_id", ship.stable_identifier]]); + } + "#; + let sdk = Sdk::default(); + let module = sdk + .load_module_from_src_actions(craft_src, &["SpawnShip", "ChristenShip"]) + .unwrap(); + println!("{}", module.podlang_src); + + let state = TestState::default(); + + let executor = module.executor(true, grounding_witness(&state, &[])); + let res = executor.action("ChristenShip", vec![]).unwrap(); + let [ship, plaque] = res.objs(); + let ship_id = ship + .obj + .get(&StrKey::from("stable_identifier")) + .unwrap() + .unwrap(); + let plaque_ship_id = plaque.obj.get(&StrKey::from("ship_id")).unwrap().unwrap(); + assert_eq!(ship_id, plaque_ship_id); +} + /// Class names go straight into qualified ids (`::`) and /// `.dobj` filename prefixes. The SDK refuses to compile a script that /// declares a class name outside the `[A-Za-z0-9_-]` allowlist so a diff --git a/libs/txlib/src/lib.rs b/libs/txlib/src/lib.rs index 576cdea7..810e772b 100644 --- a/libs/txlib/src/lib.rs +++ b/libs/txlib/src/lib.rs @@ -54,7 +54,12 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StateHeader { + /// Execution block number pub block_number: i64, + /// Execution block timestamp + pub block_timestamp: i64, + /// Execution block hash + pub block_hash: Hash, /// Root of the global created-object set: the commitment of every object /// state ever created. Grounding proves an input is a member here. pub created_root: Hash, @@ -65,12 +70,16 @@ pub struct StateHeader { impl StateHeader { pub fn new( block_number: i64, + block_time: i64, + block_hash: Hash, created_root: Hash, nullifiers_root: Hash, prior_state_history_root: Hash, ) -> Self { Self { block_number, + block_timestamp: block_time, + block_hash, created_root, nullifiers_root, prior_state_history_root, @@ -84,6 +93,8 @@ impl StateHeader { pub fn array(&self) -> Array { Array::new(vec![ Value::from(self.block_number), + Value::from(self.block_timestamp), + Value::from(self.block_hash), Value::from(self.created_root), Value::from(self.nullifiers_root), Value::from(self.prior_state_history_root), @@ -99,9 +110,11 @@ impl StateHeader { /// Slot indices for the `StateHeader` record, matching the field order in /// the `record StateHeader` declaration in txlib.podlang. pub const STATE_HEADER_BLOCK_NUMBER_SLOT: usize = 0; -pub const STATE_HEADER_CREATED_SLOT: usize = 1; -pub const STATE_HEADER_NULLIFIERS_SLOT: usize = 2; -pub const STATE_HEADER_PRIOR_STATE_HISTORY_SLOT: usize = 3; +pub const STATE_HEADER_BLOCK_TIME_SLOT: usize = 1; +pub const STATE_HEADER_BLOCK_HASH_SLOT: usize = 2; +pub const STATE_HEADER_CREATED_SLOT: usize = 3; +pub const STATE_HEADER_NULLIFIERS_SLOT: usize = 4; +pub const STATE_HEADER_PRIOR_STATE_HISTORY_SLOT: usize = 5; /// Proof-bearing grounding data required to build a new transaction. /// @@ -1133,7 +1146,7 @@ mod tests { use pod2::{ backends::plonky2::mock::mainpod::MockProver, frontend::{MainPod, MultiPodBuilder}, - middleware::{Params, Predicate, VDSet, containers::Array}, + middleware::{F, Params, Predicate, VDSet, containers::Array}, }; use pod2utils::{macros::BuildContext, set}; @@ -1145,6 +1158,8 @@ mod tests { /// commitments-only `StateHeader`. The created set is grow-only. struct TestState { block_number: i64, + block_timestamp: i64, + block_hash: Hash, created: Array, created_index: HashMap, nullifiers: Set, @@ -1155,6 +1170,8 @@ mod tests { fn empty(block_number: i64) -> Self { Self { block_number, + block_timestamp: block_number * 1000, + block_hash: Hash([F(0), F(0), F(0), F(block_number as u64)]), created: Array::new(Vec::new()), created_index: HashMap::new(), nullifiers: set!(), @@ -1165,6 +1182,8 @@ mod tests { fn state_header(&self) -> StateHeader { StateHeader::new( self.block_number, + self.block_timestamp, + self.block_hash, self.created.commitment(), self.nullifiers.commitment(), self.state_history.commitment(), @@ -1250,15 +1269,27 @@ mod tests { #[test] fn state_header_hash_matches_array_commitment() { - let sr = StateHeader::new(7, test_hash(1), test_hash(2), test_hash(3)); + let sr = StateHeader::new(7, 8, test_hash(4), test_hash(1), test_hash(2), test_hash(3)); assert_eq!(sr.hash(), sr.array().commitment()); } #[test] fn state_header_serializes_and_deserializes_camelcase() { - let original = StateHeader::new(9, test_hash(1), test_hash(2), test_hash(3)); + let original = StateHeader::new( + 9, + 10, + test_hash(5), + test_hash(1), + test_hash(2), + test_hash(3), + ); let encoded = serde_json::to_value(&original).unwrap(); assert_eq!(encoded["blockNumber"], serde_json::json!(9)); + assert_eq!(encoded["blockTimestamp"], serde_json::json!(10)); + assert_eq!( + encoded["blockHash"], + serde_json::json!(hex::encode([5_u8; 32])) + ); assert_eq!( encoded["createdRoot"], serde_json::json!(hex::encode([1_u8; 32])) diff --git a/libs/txlib/src/predicates/crafting_test.podlang b/libs/txlib/src/predicates/crafting_test.podlang index d2f62429..7dc61b1b 100644 --- a/libs/txlib/src/predicates/crafting_test.podlang +++ b/libs/txlib/src/predicates/crafting_test.podlang @@ -7,7 +7,7 @@ use module 0xTX_EVENTS_MODULE_HASH as tx // TODO: Support importing records via `use module` -record StateHeader = (block_number, created, nullifiers, prior_state_history) +record StateHeader = (block_number, block_time, block_hash, created, nullifiers, prior_state_history) // ======================================================== // Deletion Sub-Actions diff --git a/libs/txlib/src/predicates/txlib.podlang b/libs/txlib/src/predicates/txlib.podlang index 5b55edab..72957905 100644 --- a/libs/txlib/src/predicates/txlib.podlang +++ b/libs/txlib/src/predicates/txlib.podlang @@ -264,7 +264,7 @@ ReplayDelete(state_header, before_tx, after_tx, before_chain, after_chain, // global created-object set (every object state ever created, maintained // by the synchronizer). -record StateHeader = (block_number, created, nullifiers, prior_state_history) +record StateHeader = (block_number, block_time, block_hash, created, nullifiers, prior_state_history) InputsGrounded(inputs, created) = OR( // Base case: empty inputs. `created` is intentionally unconstrained -- diff --git a/libs/wire-types/src/synchronizer.rs b/libs/wire-types/src/synchronizer.rs index f36fede6..fa7ad14c 100644 --- a/libs/wire-types/src/synchronizer.rs +++ b/libs/wire-types/src/synchronizer.rs @@ -151,6 +151,10 @@ pub struct GroundingWitnessResponse { pub state_root: Hash, /// Execution block number committed inside that state root. pub block_number: i64, + /// Execution block timestamp + pub block_timestamp: i64, + /// Execution block hash + pub block_hash: Hash, /// Created-set root. pub created_root: Hash, /// Nullifiers set root. diff --git a/services/synchronizer/src/api.rs b/services/synchronizer/src/api.rs index c9058961..41f9b228 100644 --- a/services/synchronizer/src/api.rs +++ b/services/synchronizer/src/api.rs @@ -245,6 +245,8 @@ async fn post_grounding_witness( Ok(Json(GroundingWitnessResponse { state_root, block_number: state_header.block_number, + block_timestamp: state_header.block_timestamp, + block_hash: state_header.block_hash, created_root: state_header.created_root, nullifiers_root: state_header.nullifiers_root, prior_state_history_root: state_header.prior_state_history_root, @@ -272,7 +274,11 @@ fn build_head_snapshot(snapshot: &CurrentSnapshot) -> HeadSnapshot { last_processed_slot: snapshot.last_processed_slot, last_processed_block_number: snapshot.last_processed_block_number, current_state_root: snapshot.head.metadata.current_state_root, - current_block_number: snapshot.head.metadata.current_block_number.map(i64::from), + current_block_number: snapshot + .head + .metadata + .current_block + .map(|meta| meta.number as i64), created_count: snapshot.head.metadata.created_count as usize, nullifier_count: snapshot.head.metadata.nullifier_count as usize, state_root_count: snapshot.head.metadata.state_root_count as usize, diff --git a/services/synchronizer/src/head.rs b/services/synchronizer/src/head.rs index 9333fd4e..0a2a8b83 100644 --- a/services/synchronizer/src/head.rs +++ b/services/synchronizer/src/head.rs @@ -31,13 +31,24 @@ impl StateRoots { } } +/// Execution block metadata +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct BlockMetadata { + /// Block number + pub number: u32, + /// Block timestamp + pub timestamp: u64, + /// Block hash + pub hash: Hash, +} + /// Non-root metadata tracked alongside the state roots. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct StateMetadata { /// Current state root for this head, if one exists. pub current_state_root: Option, - /// Execution block number associated with `current_state_root`. - pub current_block_number: Option, + /// Execution block metadata associated with `current_state_root` + pub current_block: Option, /// Number of objects in the global created set. The array is /// 0-indexed, so this doubles as the next array slot. pub created_count: u64, @@ -57,7 +68,7 @@ impl StateMetadata { pub fn empty() -> Self { Self { current_state_root: None, - current_block_number: None, + current_block: None, created_count: 0, nullifier_count: 0, state_root_count: 0, @@ -92,9 +103,11 @@ impl StateHead { } pub fn current_state_header(&self) -> Option { - self.metadata.current_block_number.map(|block_number| { + self.metadata.current_block.map(|block_meta| { StateHeader::new( - block_number as i64, + block_meta.number as i64, + block_meta.timestamp as i64, + block_meta.hash, self.roots.created, self.roots.nullifiers, self.roots.prior_state_history, diff --git a/services/synchronizer/src/node.rs b/services/synchronizer/src/node.rs index d33bd758..60d94811 100644 --- a/services/synchronizer/src/node.rs +++ b/services/synchronizer/src/node.rs @@ -22,10 +22,11 @@ use anyhow::{anyhow, Context, Result}; use backoff::ExponentialBackoffBuilder; use chrono::{DateTime, Utc}; use pod2::middleware::Hash; +use pod2utils::b256_to_hash; use tracing::{debug, info, trace, warn}; use crate::config::AppConfig; -use crate::head::StateHead; +use crate::head::{BlockMetadata, StateHead}; use crate::state_machine::{DerivedSlot, StateMachine, MAX_STATE_ROOT_AGE_BLOCKS}; use crate::sync_db::{CommittedSlotRecord, SyncDb}; @@ -46,7 +47,7 @@ struct SlotContext { parent_root: B256, execution_block_hash: B256, execution_block_number: u32, - execution_timestamp: u64, + execution_block_timestamp: u64, kzg_blob_commitments: Vec<(B256, KzgCommitment)>, } @@ -295,7 +296,7 @@ impl Node { parent_root: beacon_block.parent_root, execution_block_hash: execution_payload.block_hash, execution_block_number: execution_payload.block_number, - execution_timestamp: execution_payload.timestamp, + execution_block_timestamp: execution_payload.timestamp, kzg_blob_commitments, }) } @@ -340,12 +341,12 @@ impl Node { base_head: StateHead, recent_state_roots: Vec<(Hash, i64)>, slot: u32, - block_number: u32, + block_meta: BlockMetadata, blob_payloads: &[(u32, Vec)], ) -> Result { let parsed = self .state_machine - .parse_blobs(blob_payloads, slot, block_number); + .parse_blobs(blob_payloads, slot, block_meta.number); let candidates: Vec = parsed .iter() .flat_map(|(_, payload)| payload.live.iter().copied()) @@ -355,7 +356,7 @@ impl Node { base_head, recent_state_roots, slot, - block_number, + block_meta, &parsed, &prior_indices, ) @@ -375,18 +376,24 @@ impl Node { info!( "Processing slot {} from {}", slot_ctx.slot, - DateTime::::from_timestamp_secs(slot_ctx.execution_timestamp as i64) + DateTime::::from_timestamp_secs(slot_ctx.execution_block_timestamp as i64) .unwrap_or_default(), ); self.state_machine.log_current_state(base_head); let block_number = slot_ctx.execution_block_number; - let min_block_number = base_head - .metadata - .current_block_number - .map(|n| n.saturating_sub(MAX_STATE_ROOT_AGE_BLOCKS as u32)); + let min_block_number = base_head.metadata.current_block.map(|block_meta| { + block_meta + .number + .saturating_sub(MAX_STATE_ROOT_AGE_BLOCKS as u32) + }); let recent_state_roots = self.sync_db.recent_state_roots(min_block_number).await?; + let block_meta = BlockMetadata { + number: slot_ctx.execution_block_number, + timestamp: slot_ctx.execution_block_timestamp, + hash: b256_to_hash(slot_ctx.execution_block_hash), + }; if slot_ctx.kzg_blob_commitments.is_empty() { debug!(slot = slot_ctx.slot, "Slot has no blob commitments"); let derived = self @@ -394,7 +401,7 @@ impl Node { base_head, recent_state_roots, slot_ctx.slot, - block_number, + block_meta, &[], ) .await?; @@ -462,7 +469,7 @@ impl Node { base_head, recent_state_roots, slot_ctx.slot, - block_number, + block_meta, &[], ) .await?; @@ -533,7 +540,7 @@ impl Node { base_head, recent_state_roots, slot_ctx.slot, - block_number, + block_meta, &blob_payloads, ) .await?; diff --git a/services/synchronizer/src/state_machine.rs b/services/synchronizer/src/state_machine.rs index ff9555d2..804ef4cd 100644 --- a/services/synchronizer/src/state_machine.rs +++ b/services/synchronizer/src/state_machine.rs @@ -1,16 +1,15 @@ +use anyhow::{Context, Result}; +use payload::{payload::Payload, proof::BlobParser}; +use pod2::middleware::{containers::Array, containers::Set, Hash, Value}; use std::{ collections::{HashMap, HashSet}, sync::Arc, }; - -use anyhow::{Context, Result}; -use payload::{payload::Payload, proof::BlobParser}; -use pod2::middleware::{containers::Array, containers::Set, Hash, Value}; use tracing::{info, warn}; use crate::{ app_db::{created_array_holds, AppDb}, - head::{StateHead, StateMetadata, StateRoots}, + head::{BlockMetadata, StateHead, StateMetadata, StateRoots}, }; use txlib::StateHeader; @@ -258,7 +257,7 @@ impl StateMachine { base_head: StateHead, recent_state_roots: impl IntoIterator, slot: u32, - block_number: u32, + block_meta: BlockMetadata, payloads: &[(u32, Payload)], prior_indices: &HashMap, ) -> Result { @@ -274,15 +273,21 @@ impl StateMachine { }; for (blob_index, payload) in payloads { - self.apply_payload(&mut working, payload, prior_indices, slot, block_number) - .with_context(|| { - format!("applying blob at slot {slot}, blob_index {blob_index}") - })?; + self.apply_payload( + &mut working, + payload, + prior_indices, + slot, + block_meta.number, + ) + .with_context(|| format!("applying blob at slot {slot}, blob_index {blob_index}"))?; } let prior_state_history_root = base_head.roots.next_state_history; let new_state_root = StateHeader::new( - i64::from(block_number), + i64::from(block_meta.number), + block_meta.timestamp as i64, + block_meta.hash, working.created.commitment(), working.nullifiers.commitment(), prior_state_history_root, @@ -303,7 +308,7 @@ impl StateMachine { }, metadata: StateMetadata { current_state_root: Some(new_state_root), - current_block_number: Some(block_number), + current_block: Some(block_meta), created_count: working.metadata.created_count, nullifier_count: working.metadata.nullifier_count, state_root_count: base_head.metadata.state_root_count + 1, @@ -312,7 +317,7 @@ impl StateMachine { info!( slot, - block_number, + block_number = block_meta.number, state_root_count = new_head.metadata.state_root_count, "Slot data" ); @@ -358,6 +363,14 @@ mod tests { hash_values(&[Value::from(n)]) } + fn block_meta(n: u32) -> BlockMetadata { + BlockMetadata { + number: n, + timestamp: (n as u64) * 1000, + hash: unique_hash(n as i64), + } + } + fn mock_txn_bytes( tx_final: Hash, nullifiers: &[Hash], @@ -382,9 +395,16 @@ mod tests { } fn seed_state_root0(sm: &StateMachine) -> StateHead { - sm.derive_slot_head(StateHead::empty(), [], 0, 0, &[], &HashMap::new()) - .unwrap() - .head + sm.derive_slot_head( + StateHead::empty(), + [], + 0, + block_meta(0), + &[], + &HashMap::new(), + ) + .unwrap() + .head } /// Parse blobs and derive one slot in one step (mirroring `Node::derive_slot` @@ -394,16 +414,16 @@ mod tests { base_head: StateHead, recent_state_roots: impl IntoIterator, slot: u32, - block_number: u32, + block_meta: BlockMetadata, blobs: &[(u32, Vec)], prior_indices: &HashMap, ) -> DerivedSlot { - let parsed = sm.parse_blobs(blobs, slot, block_number); + let parsed = sm.parse_blobs(blobs, slot, block_meta.number); sm.derive_slot_head( base_head, recent_state_roots, slot, - block_number, + block_meta, &parsed, prior_indices, ) @@ -413,8 +433,17 @@ mod tests { #[test] fn test_empty_slot_produces_new_head() { let (sm, _app_db, _dir) = make_sm(); - let head = derive(&sm, StateHead::empty(), [], 1, 7, &[], &HashMap::new()).head; - assert_eq!(head.metadata.current_block_number, Some(7)); + let head = derive( + &sm, + StateHead::empty(), + [], + 1, + block_meta(7), + &[], + &HashMap::new(), + ) + .head; + assert_eq!(head.metadata.current_block.map(|m| m.number), Some(7)); assert_eq!(head.metadata.state_root_count, 1); } @@ -433,7 +462,7 @@ mod tests { head0, [(state_root0, 0)], 1, - 1, + block_meta(1), &[(0, blob)], &HashMap::new(), ); @@ -459,7 +488,16 @@ mod tests { let tx_final = unique_hash(21); let blob = mock_txn_bytes(tx_final, &[], &[unique_hash(22)], unique_hash(99)); - let head1 = derive(&sm, head0, [], 2, 2, &[(0, blob)], &HashMap::new()).head; + let head1 = derive( + &sm, + head0, + [], + 2, + block_meta(2), + &[(0, blob)], + &HashMap::new(), + ) + .head; assert_eq!(head1.metadata.created_count, head0.metadata.created_count); } @@ -476,7 +514,7 @@ mod tests { head0, [(state_root0, 0)], 1, - 1, + BlockMetadata::default(), &[(0, blob)], &HashMap::new(), ); @@ -509,7 +547,7 @@ mod tests { head0, [(state_root0, 0)], 1, - 1, + block_meta(1), &[(0, blob1)], &HashMap::new(), ); @@ -525,7 +563,7 @@ mod tests { head1, [(state_root1, 1)], 2, - 2, + block_meta(2), &[(0, blob2)], &derived1.created_added, ) @@ -549,7 +587,7 @@ mod tests { head0, [(state_root0, 0)], 1, - 1, + block_meta(1), &[(0, blob_a), (1, blob_b)], &HashMap::new(), ); @@ -570,7 +608,15 @@ mod tests { let obj = unique_hash(60); let phantom = HashMap::from([(obj, 1i64)]); let blob = mock_txn_bytes(unique_hash(61), &[], &[obj], state_root0); - let derived = derive(&sm, head0, [(state_root0, 0)], 1, 1, &[(0, blob)], &phantom); + let derived = derive( + &sm, + head0, + [(state_root0, 0)], + 1, + block_meta(1), + &[(0, blob)], + &phantom, + ); assert_eq!(derived.head.metadata.created_count, 1); assert_eq!(derived.created_added.get(&obj), Some(&0)); } diff --git a/services/synchronizer/src/sync_db.rs b/services/synchronizer/src/sync_db.rs index 28beb249..3992dc39 100644 --- a/services/synchronizer/src/sync_db.rs +++ b/services/synchronizer/src/sync_db.rs @@ -10,7 +10,7 @@ use sqlx::{ use crate::{ app_db::{db_bytes_to_hash, hash_to_db_bytes}, - head::{StateHead, StateMetadata, StateRoots}, + head::{BlockMetadata, StateHead, StateMetadata, StateRoots}, }; /// Current state head plus progress metadata loaded from Postgres. @@ -92,6 +92,8 @@ impl SyncDb { head_next_state_history_root BYTEA NOT NULL, head_current_state_root BYTEA NULL, head_current_block_number INTEGER NULL, + head_current_block_timestamp BIGINT NULL, + head_current_block_hash BYTEA NULL, head_created_count BIGINT NOT NULL, head_nullifier_count BIGINT NOT NULL, head_state_root_count BIGINT NOT NULL, @@ -190,6 +192,8 @@ impl SyncDb { head_next_state_history_root, head_current_state_root, head_current_block_number, + head_current_block_timestamp, + head_current_block_hash, head_created_count, head_nullifier_count, head_state_root_count @@ -270,11 +274,13 @@ impl SyncDb { head_next_state_history_root, head_current_state_root, head_current_block_number, + head_current_block_timestamp, + head_current_block_hash, head_created_count, head_nullifier_count, head_state_root_count ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) "#, ) .bind(slot.slot as i32) @@ -288,7 +294,17 @@ impl SyncDb { .bind(hash_to_db_bytes(head.roots.prior_state_history)) .bind(hash_to_db_bytes(head.roots.next_state_history)) .bind(head.metadata.current_state_root.map(hash_to_db_bytes)) - .bind(head.metadata.current_block_number.map(|v| v as i32)) + .bind(head.metadata.current_block.map(|meta| meta.number as i32)) + .bind( + head.metadata + .current_block + .map(|meta| meta.timestamp as i64), + ) + .bind( + head.metadata + .current_block + .map(|meta| hash_to_db_bytes(meta.hash)), + ) .bind(head.metadata.created_count as i64) .bind(head.metadata.nullifier_count as i64) .bind(head.metadata.state_root_count as i64) @@ -430,6 +446,24 @@ impl SyncDb { } fn decode_head_row(row: &PgRow) -> Result { + let current_block = { + let number = row.get::, _>("head_current_block_number"); + let timestamp = row.get::, _>("head_current_block_timestamp"); + let hash = row + .get::>, _>("head_current_block_hash") + .as_deref() + .map(db_bytes_to_hash) + .transpose()?; + + match (number, timestamp, hash) { + (Some(number), Some(timestamp), Some(hash)) => Some(BlockMetadata { + number: number as u32, + timestamp: timestamp as u64, + hash, + }), + _ => None, + } + }; Ok(StateHead { roots: StateRoots { created: db_bytes_to_hash(&row.get::, _>("head_created_root"))?, @@ -447,9 +481,7 @@ fn decode_head_row(row: &PgRow) -> Result { .as_deref() .map(db_bytes_to_hash) .transpose()?, - current_block_number: row - .get::, _>("head_current_block_number") - .map(|value| value as u32), + current_block, created_count: row.get::("head_created_count") as u64, nullifier_count: row.get::("head_nullifier_count") as u64, state_root_count: row.get::("head_state_root_count") as u64, @@ -478,7 +510,11 @@ mod tests { }, metadata: StateMetadata { current_state_root: Some(unique_hash(marker + 4)), - current_block_number: Some(block_number), + current_block: Some(BlockMetadata { + number: block_number, + timestamp: (block_number as u64) * 1000, + hash: unique_hash(marker + 5), + }), created_count: block_number as u64, nullifier_count: block_number as u64 + 1, state_root_count: block_number as u64 + 2,