Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<field>`. 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.<field>`. 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.
Expand Down
3 changes: 1 addition & 2 deletions libs/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 96 additions & 19 deletions libs/sdk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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<Dictionary> = 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<Dictionary> = 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<ArgHandle> {
Expand Down
71 changes: 71 additions & 0 deletions libs/sdk/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}
}