diff --git a/CLAUDE.md b/CLAUDE.md index f4c2a080..cffacaae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,7 +167,7 @@ The driver does not cache compiled modules between calls — every `Driver::exec **Host API** (registered via `register_fn` in `libs/sdk/src/lib.rs`): - On `action`: `input(class)`, `output(class)`, `mutate(class)`, `subaction(name)`, `random()`, `intro_vdf(iters, obj)`, `intro_lt_eq_u256(obj, target)`, `pow_obj_grind(obj, target)`, `top_limb_u256(n)`, plus one `st_*` per pod2 native predicate (`st_gt`, `st_sum`, `st_dict_contains`, `st_set_insert`, ... — the table in `libs/sdk/README.md` lists all of them; `SignedBy` and `PublicKey` have no host method). -- On object handles: `set([[k,v],...])` (initializer for literals), `update(k,v)` (writes a witness-derived value), indexer `obj.`. A `get(k)` method is registered but unimplemented; field reads go through the indexer. +- On object handles: `set([[k,v],...])` (literal initializer, only on an untouched output), `update(k,v)` (writes a witness-derived value), indexer `obj.`. A `get(k)` method is registered but unimplemented; field reads go through the indexer. - In scope as a constant: `state_header` — the grounding state root as a record (`block_number`, `block_timestamp`, `block_hash`, `created`, `nullifiers`, `prior_state_history`). Field reads (`state_header.block_timestamp`) emit anchored statements against the action's public `state_header` arg, which txlib pins from `TxFinalized` down through guards and bridges. Note it describes the (recent) grounding root, not the inclusion block — see `libs/sdk/README.md` for the timing caveat. **Constraint:** the event tree must be the same shape every run. Branching that emits _different events_ on different inputs is unsupported. Branching on wildcard values inside `unsafe { ... }` is fine. diff --git a/libs/sdk/README.md b/libs/sdk/README.md index 00f26fd9..c7e3d34d 100644 --- a/libs/sdk/README.md +++ b/libs/sdk/README.md @@ -237,8 +237,7 @@ as opaque entropy, not for byte-exact comparison with the L1 hash. - [x] pexe.zip support (packaged by the `pexe` crate's CLI) - [x] manifest support - [ ] error pretty print -- [ ] forbid multiple Object::set operations on the same object -- [ ] forbid Object::set after the objec thas been used in other operations +- [x] forbid Object::set after the object has been used in other operations - [ ] read a field of an output created in the same action (`out.field`) # Test example diff --git a/libs/sdk/src/lib.rs b/libs/sdk/src/lib.rs index 42dfa3ef..729d1e59 100644 --- a/libs/sdk/src/lib.rs +++ b/libs/sdk/src/lib.rs @@ -417,6 +417,9 @@ macro_rules! st_methods { #[derive(Default, Debug)] struct VarState { ts: usize, + /// Set by an operation that consumes the var's dict without recording + /// an `Inst` for it, which only `pow_obj_grind` does. + dict_read: bool, } /// This handler is accessible in the action script function to define action operations. It @@ -467,6 +470,73 @@ impl ActionContext { state.ts += 1; Ok(()) } + fn mark_dict_read(&mut self, var: &str) { + if let Some(state) = self.var_state.get_mut(var) { + state.dict_read = true; + } + } + /// Reject a `set` that cannot hold. `set` writes its initializer into + /// the object's dict without advancing the object's ts, so any + /// earlier operation that pinned the dict's exact contents would then + /// disagree with it. A repeated `set` is not such an operation: it + /// only asserts containment, which survives later inserts. + /// + /// Only an output is settable. On an input or a mutate the write + /// lands in a pre-state fixed by the state root the transaction + /// grounds against. + fn check_settable(&self, var: &str) -> RuntimeResult<()> { + if var == "?" { + return Err("set: bind the object with `var` first".into()); + } + let io = self.insts.iter().find_map(|inst| match inst { + Inst::Object { io, obj, .. } if obj.borrow().var_name() == var => Some(*io), + _ => None, + }); + match io { + Some(ObjectIO::Output) => {} + Some(_) => { + return Err(format!( + "set on {var}: only an output object can be initialized with set" + ) + .into()); + } + None => { + return Err(format!( + "set on {var}: expected an output object declared in this action" + ) + .into()); + } + } + let names_var = |r: &Ref| match &*r.borrow() { + VarOrValue::Var(v) => v.name == var, + VarOrValue::Value(_) => false, + }; + let pinned_by = self.insts.iter().find_map(|inst| match inst { + Inst::Update { obj, value, .. } => (obj == var || names_var(value)).then_some("update"), + Inst::Set { kvs, .. } => kvs + .iter() + .any(|(_, v)| names_var(v)) + .then_some("another object's set"), + Inst::Statement { args, .. } => args.iter().any(names_var).then_some("a statement"), + Inst::Intro { args, .. } => args.iter().any(names_var).then_some("an intro pod"), + Inst::Object { .. } | Inst::SubAction { .. } => None, + }); + let pinned_by = pinned_by.or_else(|| { + self.var_state + .get(var) + .is_some_and(|s| s.dict_read) + .then_some("pow_obj_grind") + }); + if let Some(op) = pinned_by { + return Err(format!( + "set on {var}: {op} already committed to this object's contents, so the set \ + would change a dict that has been proved about; move it directly below the \ + output declaration" + ) + .into()); + } + Ok(()) + } /// Per-var max ts, including the extra ts an Output reserves for /// the identity-stamp bump (`fmt_podlang::output_max_ts`). This is /// the single source for in/out/initials slot positions, consumed @@ -1370,6 +1440,9 @@ impl ActionHandle { // Target is a full u256 (Raw). To build one with a desired top-limb // difficulty, scripts use `action.top_limb_u256(n)`. let [obj, target] = validate_args([(obj, Type::Dict), (target, Type::Raw)])?; + if let VarOrValue::Var(var) = &*obj.borrow() { + self.0.borrow_mut().mark_dict_read(&var.name); + } // For now we assume that obj is var, and thus return a key that is also var let key = Rc::new(RefCell::new(VarOrValue::var(Type::Raw))); if let Some(exe_ctx) = self.0.borrow().exe_ref() { @@ -1568,27 +1641,31 @@ impl ArgHandle { fn set(self, kvs: Dynamic) -> RuntimeResult<()> { type_check_args([(&self, Type::Dict)])?; let kvs = dynamic_to_kvs(kvs)?; - let mut arg = self.arg.borrow_mut(); - if let VarOrValue::Var(var) = &*arg { - let var_name = var.name.clone(); - let mut ctx = self.ctx.0.borrow_mut(); - ctx.assert_unsafe(false)?; - let mut final_dict: Option = None; - if ctx.exe_ctx.is_some() { - for (key, value) in &kvs { - let value = value.borrow().as_value().clone(); - arg.mut_dict(|obj| { - obj.insert(&StrKey::from(key), &value).expect("TODO"); - }); - } - final_dict = Some(arg.to_dict()); + // The guard walks the action's recorded Insts, which alias this + // object's Ref, so resolve the name and drop the borrow first. + let var_name = match &*self.arg.borrow() { + VarOrValue::Var(var) => var.name.clone(), + VarOrValue::Value(_) => return Ok(()), + }; + let mut ctx = self.ctx.0.borrow_mut(); + ctx.assert_unsafe(false)?; + ctx.check_settable(&var_name)?; + let mut final_dict: Option = None; + if ctx.exe_ctx.is_some() { + let mut arg = self.arg.borrow_mut(); + for (key, value) in &kvs { + let value = value.borrow().as_value().clone(); + arg.mut_dict(|obj| { + obj.insert(&StrKey::from(key), &value).expect("TODO"); + }); } - ctx.insts.push(Inst::Set { - obj: var_name, - kvs, - final_dict, - }); + final_dict = Some(arg.to_dict()); } + ctx.insts.push(Inst::Set { + obj: var_name, + kvs, + final_dict, + }); Ok(()) } fn get(self, _key: String) -> RuntimeResult { diff --git a/libs/sdk/src/tests.rs b/libs/sdk/src/tests.rs index c919be98..080ff810 100644 --- a/libs/sdk/src/tests.rs +++ b/libs/sdk/src/tests.rs @@ -1193,3 +1193,74 @@ fn test_statement_surface_round_trips() { ], ); } + +/// `set` writes into the object's dict in place without advancing its +/// ts, so it is only sound on an output nothing has pinned yet. Repeated +/// sets stay consistent (see `test_cross_read_into_set`): containment +/// survives later inserts. +#[test] +fn test_set_guards() { + for (action, expected, src) in [ + ( + "SetOnInput", + "only an output object", + r#" + fn SetOnInput(action) { + var ore = action.input("Ore"); + ore.set([["grade", 1]]); + } +"#, + ), + ( + "SetOnMutate", + "only an output object", + r#" + fn SetOnMutate(action) { + var ore = action.mutate("Ore"); + ore.set([["grade", 1]]); + } +"#, + ), + ( + "SetAfterUpdate", + "update already committed", + r#" + fn SetAfterUpdate(action) { + var ore = action.output("Ore"); + var key = action.random(); + ore.update("work", key); + ore.set([["grade", 1]]); + } +"#, + ), + ( + "SetAfterStatement", + "a statement already committed", + r#" + fn SetAfterStatement(action) { + var ore = action.output("Ore"); + action.st_dict_contains(ore, "work", 0); + ore.set([["grade", 1]]); + } +"#, + ), + ( + "SetAfterGrind", + "pow_obj_grind already committed", + r#" + fn SetAfterGrind(action) { + var ore = action.output("Ore"); + let target = action.top_limb_u256(9007199254740992); + var key = action.pow_obj_grind(ore, target); + ore.set([["grade", 1]]); + } +"#, + ), + ] { + let err = match Sdk::default().load_module_from_src_actions(src, &[action]) { + Ok(_) => panic!("expected {action} to be rejected"), + Err(err) => err.to_string(), + }; + assert!(err.contains(expected), "{action}: {err}"); + } +}