diff --git a/Cargo.lock b/Cargo.lock index 34c6c246..31b48b31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5716,7 +5716,6 @@ dependencies = [ "flate2", "hex", "log", - "lt-eq-u256-pod", "pod2", "pod2utils", "sdk", @@ -5726,7 +5725,6 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", "toml_edit 0.23.10+spec-1.0.0", "txlib", - "vdfpod", "zip", ] diff --git a/libs/pexe/Cargo.toml b/libs/pexe/Cargo.toml index 12dd3429..ba60cbe1 100644 --- a/libs/pexe/Cargo.toml +++ b/libs/pexe/Cargo.toml @@ -20,8 +20,6 @@ pod2 = { workspace = true } zip = { version = "2", default-features = false, features = ["deflate"] } sdk = { path = "../sdk" } txlib = { path = "../txlib" } -vdfpod = { path = "../intro-pods/vdfpod" } -lt-eq-u256-pod = { path = "../intro-pods/lt-eq-u256-pod" } pod2utils = { path = "../pod2utils" } dirs = "6.0.0" clap = { version = "4", features = ["derive"] } diff --git a/libs/pexe/src/fixtures.rs b/libs/pexe/src/fixtures.rs index 041e5df9..a5b92ae3 100644 --- a/libs/pexe/src/fixtures.rs +++ b/libs/pexe/src/fixtures.rs @@ -1,62 +1,112 @@ -//! Synthetic input fixtures and grounded state for driving the SDK -//! against arbitrary actions without real chain state. +//! Synthetic input fixtures and grounded state for executing SDK actions +//! without live chain state. //! -//! Used by `pexe inspect plan` (and reusable in unit tests). Mock mode -//! must be set on the `Executor` since the synthetic Merkle proofs are -//! structurally valid but the surrounding chain history is fabricated. +//! Used by `pexe inspect plan` and unit tests. Mock mode must be enabled +//! on the `Executor` because synthetic Merkle proofs are structurally valid +//! but do not correspond to real chain history. -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::sync::Arc; use anyhow::{Result, anyhow}; use pod2::middleware::{EMPTY_HASH, EMPTY_VALUE, Hash, StrKey, Value, containers::Array}; use pod2utils::{dict, rand_raw_value}; -use sdk::{SdkModule, SpendableObject}; -use txlib::{GroundingWitness, StateHeader, with_stable_identifier}; +use sdk::{ActionMeta, ActionObjectRef, FieldFacts, Pin, SdkModule, SpendableObject}; +use txlib::{GroundingWitness, STABLE_IDENTIFIER_FIELD, StateHeader, with_stable_identifier}; -use crate::inspect::derive_class_signature; - -/// Mint a synthetic instance of `class_name` whose dict shape matches -/// the class's IsX rule. Fields the signature analyzer recognises -/// (string literals, int initials, witnesses) are populated with a -/// representative value; SDK-pre-populated keys (`type`, `key`, `work`) -/// are added in all cases. -pub fn mint_class( +/// Mints a synthetic dictionary instance for each input consumed by `action`, +/// matching the order of `action.total_inputs()`. +/// +/// Field values are derived directly from the action's constraints rather +/// than class declarations. This allows repeated inputs of the same class +/// with differing requirements to receive distinct fixtures, while coupled +/// fields share a single value. +pub fn mint_action_inputs( module: &SdkModule, - class_name: &str, -) -> Result { - let batch = &module.module().batch; - let signature = derive_class_signature(module, batch, class_name); - mint_with_signature(module, class_name, &signature) + action: &ActionMeta, +) -> Result> { + let inputs: Vec<&ActionObjectRef> = action.total_inputs().collect(); + report_unsatisfiable(&action.name, &inputs)?; + + // One value per equality group, shared by every field in it. + let mut groups: HashMap<&str, Value> = HashMap::new(); + inputs + .iter() + .map(|obj| mint_object(module, obj, &mut groups)) + .collect() } -/// Mint one synthetic instance per class name. Class signatures are -/// memoized so repeating a class (e.g. `[Wire, Wire, Steel]`) doesn't -/// re-derive the same signature. -pub fn mint_classes( - module: &SdkModule, - class_names: &[String], -) -> Result> { - let batch = &module.module().batch; - let mut cache: HashMap<&str, crate::inspect::ClassSignature> = HashMap::new(); - let mut out = Vec::with_capacity(class_names.len()); - for class in class_names { - let sig = cache - .entry(class.as_str()) - .or_insert_with(|| derive_class_signature(module, batch, class)); - out.push(mint_with_signature(module, class, sig)?); - } - Ok(out) +/// Resolved value requirement for a field after constraint evaluation. +enum Chosen<'a> { + Int(i64), + Text(&'a str), + Shared { group: &'a str, integer: bool }, + AnyInt, + Any, +} + +/// Resolves constraints for a single field. Returns conflicting values on error. +fn choose(facts: &FieldFacts) -> Result, BTreeSet> { + let mut pinned = facts.pinned.iter(); + match (pinned.next(), pinned.next(), facts.min) { + // Pinned value violates the lower bound constraint. + (Some(Pin::Int(v)), None, Some(min)) if *v < min => { + Err([Pin::Int(*v), Pin::Int(min)].into_iter().collect()) + } + (Some(Pin::Text(t)), None, Some(min)) => { + Err([Pin::Text(t.clone()), Pin::Int(min)].into_iter().collect()) + } + (Some(Pin::Int(v)), None, _) => Ok(Chosen::Int(*v)), + (Some(Pin::Text(t)), None, _) => Ok(Chosen::Text(t)), + // Satisfy lower-bound constraints using the minimum value. + (None, _, Some(min)) => Ok(Chosen::Int(min)), + (None, _, None) => Ok(match facts.group.as_deref() { + Some(group) => Chosen::Shared { + group, + integer: facts.integer, + }, + None if facts.integer => Chosen::AnyInt, + None => Chosen::Any, + }), + _ => Err(facts.pinned.clone()), + } +} + +/// Validates that all input field constraints are satisfiable, returning a +/// consolidated error for any conflicting requirements. +fn report_unsatisfiable(action_name: &str, inputs: &[&ActionObjectRef]) -> Result<()> { + let mut bad: Vec = Vec::new(); + for (slot, obj) in inputs.iter().enumerate() { + for (field, facts) in obj.field_facts() { + if let Err(values) = choose(facts) { + let values: Vec = values.iter().map(Pin::to_string).collect(); + bad.push(format!( + " input {slot} `{}` ({}): field `{field}` is required to be {}", + obj.varname(), + obj.class, + values.join(" and ") + )); + } + } + } + if bad.is_empty() { + return Ok(()); + } + Err(anyhow!( + "unsupported fixture: {action_name} constrains an input field to two different \ + values, so no synthetic input can satisfy it:\n{}", + bad.join("\n") + )) } -fn mint_with_signature( +fn mint_object<'a>( module: &SdkModule, - class_name: &str, - signature: &crate::inspect::ClassSignature, + obj: &'a ActionObjectRef, + groups: &mut HashMap<&'a str, Value>, ) -> Result { let class_hash = module - .class_hash(class_name) - .ok_or_else(|| anyhow!("unknown class: {class_name}"))?; + .class_hash(&obj.class) + .ok_or_else(|| anyhow!("unknown class: {}", obj.class))?; let mut d = dict!({ "type" => Value::from(class_hash), @@ -64,43 +114,48 @@ fn mint_with_signature( "work" => Value::from(EMPTY_VALUE), }); - for (field_name, info) in &signature.fields { - // `type`/`key`/`work` are SDK-pre-populated and already stamped. - if matches!(field_name.as_str(), "type" | "key" | "work") { + for (field_name, facts) in obj.field_facts() { + // Skip already-populated fields and reserved fields (`stable_identifier`). + if d.get(&StrKey::from(field_name)) + .map_err(|err| anyhow!("reading {field_name}: {err}"))? + .is_some() + || field_name == STABLE_IDENTIFIER_FIELD + { continue; } - let value: Value = if let Some(literal) = info.string_literals.iter().next() { - Value::from(literal.clone()) - } else if let Some(initial) = info.int_literals.iter().next() { - Value::from(*initial) - } else { - // Witness-derived application field: hand it a random Raw. - // Mock mode skips the constraints that would otherwise bind - // these values to real intro outputs. - Value::from(rand_raw_value()) + let value = match choose(facts) { + Ok(Chosen::Int(v)) => Value::from(v), + Ok(Chosen::Text(t)) => Value::from(t), + // In mock mode, unconstrained fields default to arbitrary values of the required type. + Ok(Chosen::AnyInt) => Value::from(0i64), + Ok(Chosen::Any) => Value::from(rand_raw_value()), + Ok(Chosen::Shared { group, integer }) => groups + .entry(group) + .or_insert_with(|| { + if integer { + Value::from(0i64) + } else { + Value::from(rand_raw_value()) + } + }) + .clone(), + Err(_) => unreachable!("refused by report_unsatisfiable"), }; - d.insert(&StrKey::from(field_name.as_str()), &value) + d.insert(&StrKey::from(field_name), &value) .map_err(|err| anyhow!("inserting {field_name}: {err}"))?; } - // A real chain object carries `stable_identifier = commitment(initial)`, - // stamped by TxInsert when it was first minted. Synthetic inputs stand - // in for chain objects, so they need the same field or a later mutate - // (which pins old.stable_identifier == new.stable_identifier) panics on - // the missing entry. Ok(with_stable_identifier(&d)) } -/// Result of fabricating a synthetic chain state that grounds a set of -/// input objects. Pair this with `executor.action(name, spendable)` to -/// drive an action end-to-end without touching the real synchronizer. +/// Synthetic chain state and spendable objects for testing action execution +/// without an active synchronizer. pub struct SyntheticState { pub grounding_witness: Arc, pub spendable: Vec, } -/// Build a state in which each `obj` is Live, by inserting every object -/// into a single global created set (an array, indexed by position) and -/// packaging per-object `(index, membership proof)` into a `GroundingWitness`. +/// Builds a synthetic state where all provided objects are live in the created set +/// with corresponding membership proofs in a `GroundingWitness`. pub fn build_synthetic_state( objs: &[pod2::middleware::containers::Dictionary], ) -> Result { @@ -156,9 +211,10 @@ mod tests { use sdk::Sdk; const PLUGIN_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../examples/craft-basics"); + const ROCKET_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../examples/craft-rocket"); - fn load_craft_basics() -> std::rc::Rc { - let source = crate::PluginSource::read(PLUGIN_DIR).unwrap(); + fn load_plugin(dir: &str) -> std::rc::Rc { + let source = crate::PluginSource::read(dir).unwrap(); let manifest = source.parse_manifest().unwrap(); let action_names: Vec<&str> = manifest.actions.iter().map(|a| a.name.as_str()).collect(); Sdk::default() @@ -166,37 +222,57 @@ mod tests { .unwrap() } + fn load_craft_basics() -> std::rc::Rc { + load_plugin(PLUGIN_DIR) + } + + fn action<'a>(module: &'a SdkModule, name: &str) -> &'a ActionMeta { + module + .actions() + .iter() + .find(|a| a.name == name) + .unwrap_or_else(|| panic!("no action {name}")) + } + #[test] fn mint_log_has_expected_shape() { let module = load_craft_basics(); - let log = mint_class(&module, "Log").unwrap(); + let [log] = &mint_action_inputs(&module, action(&module, "CraftWood")).unwrap()[..] else { + panic!("CraftWood consumes exactly one object"); + }; let class_hash = module.class_hash("Log").unwrap(); let typ = log.get(&StrKey::from("type")).unwrap().unwrap(); assert_eq!(typ.raw(), Value::from(class_hash).raw()); } - /// Plan every manifest action against freshly minted inputs, ensuring - /// that the synthetic objects are valid and preventing drift. + /// Verifies that synthetic inputs satisfy planning for all actions across + /// example plugins, including actions with sub-action calls. #[test] fn every_action_plans_with_synthetic_inputs() { - let module = load_craft_basics(); - for action in module.actions() { - let input_classes: Vec = - action.total_inputs().map(|r| r.class.clone()).collect(); - let minted = mint_classes(&module, &input_classes).unwrap(); - let state = build_synthetic_state(&minted).unwrap(); - let executor = module.executor(true, state.grounding_witness.clone()); - executor - .plan_action(&action.name, state.spendable) - .unwrap_or_else(|err| panic!("planning {} failed: {err}", action.name)); + for dir in [PLUGIN_DIR, ROCKET_DIR] { + let module = load_plugin(dir); + for action in module.actions() { + let minted = mint_action_inputs(&module, action).unwrap(); + assert_eq!( + minted.len(), + action.total_inputs().count(), + "{}: one fixture per input", + action.name + ); + let state = build_synthetic_state(&minted).unwrap(); + let executor = module.executor(true, state.grounding_witness.clone()); + executor + .plan_action(&action.name, state.spendable) + .unwrap_or_else(|err| panic!("planning {} failed: {err}", action.name)); + } } } #[test] fn craft_wood_runs_end_to_end_with_synthetic_log() { let module = load_craft_basics(); - let log = mint_class(&module, "Log").unwrap(); - let state = build_synthetic_state(&[log]).unwrap(); + let minted = mint_action_inputs(&module, action(&module, "CraftWood")).unwrap(); + let state = build_synthetic_state(&minted).unwrap(); let executor = module.executor(true, state.grounding_witness.clone()); let outputs = executor.action("CraftWood", state.spendable).unwrap(); @@ -208,4 +284,160 @@ mod tests { let typ = wood.get(&StrKey::from("type")).unwrap().unwrap(); assert_eq!(typ.raw(), Value::from(class_hash).raw()); } + + /// Verifies that multiple inputs of the same class receive distinct fixtures + /// when the action imposes different field constraints. + #[test] + fn repeated_class_slots_get_their_own_values() { + let src = r#" + fn MakeResource(action) { + var r = action.output("Resource"); + r.set([["kind", 1], ["amount", 10]]); + } + + fn CombineTwo(action) { + var a = action.mutate("Resource"); + var b = action.mutate("Resource"); + action.st_sum(a.kind, 0, 1); + action.st_sum(b.kind, 0, 2); + action.st_sum(a.amount, 0, 10); + action.st_sum(b.amount, 0, 10); + } + "#; + let module = Sdk::default() + .load_module_from_src_actions(src, &["MakeResource", "CombineTwo"]) + .unwrap(); + + let minted = mint_action_inputs(&module, action(&module, "CombineTwo")).unwrap(); + let kind = |d: &pod2::middleware::containers::Dictionary| { + d.get(&StrKey::from("kind")).unwrap().unwrap().as_int() + }; + assert_eq!(kind(&minted[0]), Some(1)); + assert_eq!(kind(&minted[1]), Some(2)); + + let state = build_synthetic_state(&minted).unwrap(); + let executor = module.executor(true, state.grounding_witness.clone()); + executor.plan_action("CombineTwo", state.spendable).unwrap(); + } + + /// Verifies that field requirements are preserved even when action lowering + /// splits the body across multiple helper predicates. + #[test] + fn deeply_split_body_keeps_every_field() { + let src = r#" + fn MakeWidget(action) { + var w = action.output("Widget"); + w.set([ + ["f01", 1], ["f02", 2], ["f03", 3], ["f04", 4], ["f05", 5], + ["f06", 6], ["f07", 7], ["f08", 8], ["f09", 9], ["f10", 10], + ]); + } + + fn ReadEveryField(action) { + var w = action.mutate("Widget"); + action.st_sum(w.f01, 0, 1); + action.st_sum(w.f02, 0, 2); + action.st_sum(w.f03, 0, 3); + action.st_sum(w.f04, 0, 4); + action.st_sum(w.f05, 0, 5); + action.st_sum(w.f06, 0, 6); + action.st_sum(w.f07, 0, 7); + action.st_sum(w.f08, 0, 8); + action.st_sum(w.f09, 0, 9); + action.st_sum(w.f10, 0, 10); + } + "#; + let module = Sdk::default() + .load_module_from_src_actions(src, &["MakeWidget", "ReadEveryField"]) + .unwrap(); + + // Ensure lowering generated helper predicates. + let levels = module + .module() + .batch + .predicates() + .iter() + .filter(|p| p.name.starts_with("ReadEveryField_")) + .count(); + assert!(levels >= 2, "expected a split body, got {levels} helpers"); + + let minted = mint_action_inputs(&module, action(&module, "ReadEveryField")).unwrap(); + let [widget] = &minted[..] else { + panic!("one input"); + }; + for i in 1..=10 { + let field = format!("f{i:02}"); + let got = widget.get(&StrKey::from(field.as_str())).unwrap(); + assert_eq!( + got.and_then(|v| v.as_int()), + Some(i), + "field {field} missing or wrong" + ); + } + + let state = build_synthetic_state(&minted).unwrap(); + let executor = module.executor(true, state.grounding_witness.clone()); + executor + .plan_action("ReadEveryField", state.spendable) + .unwrap(); + } + + /// Verifies that coupled fields across distinct input slots share the same value. + #[test] + fn coupled_fields_across_slots_share_a_value() { + let src = r#" + fn MakeParts(action) { + var a = action.output("Left"); + a.set([["here", 3]]); + var b = action.output("Right"); + b.set([["there", 3]]); + } + + fn CoupleThem(action) { + var a = action.mutate("Left"); + var b = action.mutate("Right"); + action.st_sum(a.here, 0, b.there); + } + "#; + let module = Sdk::default() + .load_module_from_src_actions(src, &["MakeParts", "CoupleThem"]) + .unwrap(); + + let minted = mint_action_inputs(&module, action(&module, "CoupleThem")).unwrap(); + let here = minted[0].get(&StrKey::from("here")).unwrap().unwrap(); + let there = minted[1].get(&StrKey::from("there")).unwrap().unwrap(); + assert_eq!(here.raw(), there.raw()); + + let state = build_synthetic_state(&minted).unwrap(); + let executor = module.executor(true, state.grounding_witness.clone()); + executor.plan_action("CoupleThem", state.spendable).unwrap(); + } + + /// Verifies that conflicting field constraints produce an error during fixture generation. + #[test] + fn contradictory_pins_are_reported_not_minted() { + let src = r#" + fn MakeThing(action) { + var t = action.output("Thing"); + t.set([["n", 1]]); + } + + fn WantsBoth(action) { + var t = action.mutate("Thing"); + action.st_sum(t.n, 0, 1); + action.st_sum(t.n, 0, 2); + } + "#; + let module = Sdk::default() + .load_module_from_src_actions(src, &["MakeThing", "WantsBoth"]) + .unwrap(); + + let err = match mint_action_inputs(&module, action(&module, "WantsBoth")) { + Ok(_) => panic!("expected the contradiction to be reported"), + Err(err) => err.to_string(), + }; + assert!(err.contains("unsupported fixture"), "{err}"); + assert!(err.contains("`n`"), "{err}"); + assert!(err.contains("1 and 2"), "{err}"); + } } diff --git a/libs/pexe/src/inspect.rs b/libs/pexe/src/inspect.rs index 171052bf..48ced68c 100644 --- a/libs/pexe/src/inspect.rs +++ b/libs/pexe/src/inspect.rs @@ -3,38 +3,16 @@ //! path that is either a `.pexe` archive or a source directory holding //! `manifest.toml` + `plugin.rhai`. -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::Path; -use std::sync::LazyLock; use anyhow::{Result, anyhow}; use pod2::lang::PrettyPrint; -use pod2::middleware::{ - CustomPredicateBatch, Hash, NativePredicate, Predicate, PredicateOrWildcard, StatementTmpl, - StatementTmplArg, Wildcard, -}; -use sdk::{Dependency, Sdk, SdkModule, manifest::Manifest}; +use pod2::middleware::{Hash, Predicate, PredicateOrWildcard}; +use sdk::{Dependency, FieldWrites, ObjectIdentity, Pin, Sdk, SdkModule, manifest::Manifest}; use crate::{PluginSource, read_pexe_file, unpack}; -/// txlib's compiled chain-primitive module. Building it isn't free, so -/// we share one instance between the two event-hash statics below. -static TX_EVENTS_MODULE: LazyLock = - LazyLock::new(txlib::predicates::events_module); - -/// Hashes of `Predicate::Custom(txlib::TxInsert)` and `TxMutate`. Used -/// to identify txlib events regardless of which batch referenced them. -static TX_INSERT_HASH: LazyLock = LazyLock::new(|| txlib_event_hash("TxInsert")); -static TX_MUTATE_HASH: LazyLock = LazyLock::new(|| txlib_event_hash("TxMutate")); - -fn txlib_event_hash(name: &str) -> Hash { - let custom_ref = TX_EVENTS_MODULE - .batch - .predicate_ref_by_name(name) - .unwrap_or_else(|| panic!("tx_events module is missing predicate {name}")); - Predicate::Custom(custom_ref).hash() -} - /// Resolve a target path to its parsed manifest and plugin script. /// Directories are read via `PluginSource::read`; anything else is /// treated as a `.pexe` archive and unpacked. @@ -301,7 +279,7 @@ fn prepare_run(target: &Path, action_name: &str) -> Result { .ok_or_else(|| anyhow!("no action named {action_name} in this plugin"))?; let input_classes: Vec = action.total_inputs().map(|r| r.class.clone()).collect(); let output_classes: Vec = action.total_outputs().map(|r| r.class.clone()).collect(); - let minted = crate::fixtures::mint_classes(&module, &input_classes)?; + let minted = crate::fixtures::mint_action_inputs(&module, action)?; let state = crate::fixtures::build_synthetic_state(&minted)?; Ok(ActionRun { module, @@ -1109,8 +1087,9 @@ fn build_class_graph_mermaid(module: &SdkModule) -> String { pub fn classes(target: &Path, class_filter: Option<&str>) -> Result<()> { let (manifest, script) = load_target(target)?; let module = load_sdk_module(&manifest, &script)?; - let batch: &std::sync::Arc = &module.module().batch; + let signatures = class_signatures(&module); + let empty = ClassSignature::default(); let mut first = true; let mut matched = false; for class in module.classes() { @@ -1124,8 +1103,8 @@ pub fn classes(target: &Path, class_filter: Option<&str>) -> Result<()> { println!(); } first = false; - let signature = derive_class_signature(&module, batch, &class.name); - println!("{}", render_signature(&signature)); + let signature = signatures.get(&class.name).unwrap_or(&empty); + println!("{}", render_signature(&class.name, signature)); } if let Some(name) = class_filter && !matched @@ -1135,406 +1114,67 @@ pub fn classes(target: &Path, class_filter: Option<&str>) -> Result<()> { Ok(()) } -/// Per-class collected info: fields and crypto provenance flags. -pub(crate) struct ClassSignature { - pub(crate) name: String, - pub(crate) fields: BTreeMap, - pub(crate) uses_vdf: bool, - pub(crate) uses_pow: bool, -} - -#[derive(Default)] -pub(crate) struct FieldInfo { - /// Literal string values ever assigned to this field. - pub(crate) string_literals: BTreeSet, - /// Integer literals ever assigned. - pub(crate) int_literals: BTreeSet, - /// True if any assignment was a wildcard whose source is a VDF intro. - pub(crate) from_vdf: bool, - /// True if any assignment was a wildcard with no other inferable provenance. - pub(crate) from_witness: bool, -} - -pub(crate) fn derive_class_signature( - module: &SdkModule, - batch: &std::sync::Arc, - class_name: &str, -) -> ClassSignature { - let mut sig = ClassSignature { - name: class_name.to_string(), - fields: BTreeMap::new(), - uses_vdf: false, - uses_pow: false, - }; - let class_hash = match module.class_hash(class_name) { - Some(h) => h, - None => return sig, - }; - - for predicate in batch.predicates() { - // Iterate the inlined scope, not just the direct body. Some - // actions get split so the TxInsert / TxMutate event lands in - // a `_N` helper while the dict-construction (Contains / - // ContainerUpdate linking output[i] to its inner wildcard) - // stays in the caller. We need both to be visible to one - // chain-tracing pass. - let scope = inline_action(predicate, batch); - let vdf_producers = collect_intro_outputs(&scope, &VDF_VD_HASH); - let scope_uses_pow = scope_uses_intro(&scope, <_EQ_U256_VD_HASH); - let mut scope_targets_class = false; - for stmt in &scope { - let focused = match tx_producer_focused(stmt, batch, class_hash) { - Some(arg) => arg, - None => continue, - }; - let chain = trace_state_chain(&scope, &focused); - collect_fields_into_scope(&scope, &chain, &vdf_producers, &mut sig); - scope_targets_class = true; - } - if scope_targets_class { - if !vdf_producers.is_empty() { - sig.uses_vdf = true; - } - if scope_uses_pow { - sig.uses_pow = true; - } - } - } - - sig -} - -static VDF_VD_HASH: LazyLock = LazyLock::new(|| *vdfpod::STANDARD_VDF_VD_HASH); -static LT_EQ_U256_VD_HASH: LazyLock = - LazyLock::new(|| *lt_eq_u256_pod::STANDARD_LT_EQ_U256_VD_HASH); - -/// Inline an action predicate's `BatchSelf(N)` calls into a flat list -/// of statements, substituting the helper's parameter wildcards with -/// the call-site args and offsetting the helper's private wildcards so -/// they cannot collide with the caller's. After this, the returned -/// statements all share one wildcard namespace: structural equality on -/// `StatementTmplArg` is sound for chain tracing. -fn inline_action( - predicate: &pod2::middleware::CustomPredicate, - batch: &std::sync::Arc, -) -> Vec { - let mut out: Vec = predicate.statements().to_vec(); - // Offset slots are spaced large enough that helpers never collide - // with the caller's wildcards or with each other. 10_000 is well - // beyond the wildcard count of any plausible predicate. - let caller_offset: usize = 0; - let mut next_helper_offset: usize = caller_offset + 10_000; - for stmt in predicate.statements() { - if let PredicateOrWildcard::Predicate(Predicate::BatchSelf(idx)) = &stmt.pred_or_wc { - let Some(sub) = batch.predicates().get(*idx) else { - continue; - }; - let bindings: &[StatementTmplArg] = &stmt.args; - let offset = next_helper_offset; - next_helper_offset += 10_000; - for sub_stmt in sub.statements() { - out.push(substitute_statement(sub_stmt, bindings, offset)); - } - } - } - out -} - -fn substitute_statement( - stmt: &StatementTmpl, - bindings: &[StatementTmplArg], - offset: usize, -) -> StatementTmpl { - let args = stmt - .args - .iter() - .map(|a| substitute_arg(a, bindings, offset)) - .collect(); - StatementTmpl { - pred_or_wc: stmt.pred_or_wc.clone(), - args, - } -} - -fn substitute_arg( - arg: &StatementTmplArg, - bindings: &[StatementTmplArg], - offset: usize, -) -> StatementTmplArg { - match arg { - StatementTmplArg::Wildcard(wc) => { - if wc.index < bindings.len() { - bindings[wc.index].clone() - } else { - StatementTmplArg::Wildcard(Wildcard { - name: wc.name.clone(), - index: wc.index + offset, - }) +/// Derives class signatures by aggregating field writes across all creating +/// and mutating actions in the module. +fn class_signatures(module: &SdkModule) -> BTreeMap { + let mut out: BTreeMap = BTreeMap::new(); + for action in module.actions() { + for obj in action.total_outputs() { + let sig = out.entry(obj.class.clone()).or_default(); + sig.identity.absorb(obj.identity()); + for (field, writes) in obj.field_writes() { + sig.fields + .entry(field.to_string()) + .or_default() + .ever + .absorb(writes); } } - StatementTmplArg::AnchoredKey(wc, key) => { - if wc.index < bindings.len() { - // The wildcard is a parameter; substitute it. If the - // call-site binding is itself a Wildcard, we can rewrite - // the AnchoredKey to reference the caller's wildcard. - // For other binding shapes (Literal, AnchoredKey, - // SelfPredicateHash) the construct isn't expressible and - // we leave the arg as-is (best-effort fallback). - match &bindings[wc.index] { - StatementTmplArg::Wildcard(w) => { - StatementTmplArg::AnchoredKey(w.clone(), key.clone()) - } - _ => arg.clone(), - } - } else { - StatementTmplArg::AnchoredKey( - Wildcard { - name: wc.name.clone(), - index: wc.index + offset, - }, - key.clone(), - ) + for obj in action.total_created() { + let sig = out.entry(obj.class.clone()).or_default(); + for (field, writes) in obj.field_writes() { + sig.fields + .entry(field.to_string()) + .or_default() + .at_mint + .absorb(writes); } } - _ => arg.clone(), - } -} - -/// If `stmt` is a txlib producer event (TxInsert or TxMutate) whose -/// `@self_predicate(IsX)` arg resolves to the given `class_hash`, return -/// the focused state arg. Compares predicates by hash, not name, so a -/// rename of TxInsert/TxMutate upstream is harmless. TxDelete is -/// excluded because deletion doesn't define the object's shape. -fn tx_producer_focused( - stmt: &StatementTmpl, - batch: &std::sync::Arc, - class_hash: Hash, -) -> Option { - let custom_ref = match &stmt.pred_or_wc { - PredicateOrWildcard::Predicate(Predicate::Custom(c)) => c, - _ => return None, - }; - let event_hash = Predicate::Custom(custom_ref.clone()).hash(); - if event_hash != *TX_INSERT_HASH && event_hash != *TX_MUTATE_HASH { - return None; - } - // TxInsert(chain0, chain, state, type_hash); - // TxMutate(chain0, chain, old_state, new_state, type_hash). - // The third arg is always the focused-state for the producer event. - let state = stmt.args.get(2)?.clone(); - let actual_class_hash = stmt.args.iter().find_map(|a| match a { - StatementTmplArg::SelfPredicateHash(idx) => batch - .predicate_ref_by_index(*idx) - .map(|cref| Predicate::Custom(cref).hash()), - _ => None, - })?; - if actual_class_hash != class_hash { - return None; - } - Some(state) -} - -/// Set of dict states semantically equivalent to `focused` within the -/// inlined scope. Follows dict-transition `new = f(old)` links until -/// fixed point. After inlining, wildcards have a single global scope -/// so structural equality on `StatementTmplArg` is correct. -fn trace_state_chain( - scope: &[StatementTmpl], - focused: &StatementTmplArg, -) -> HashSet { - let mut chain: HashSet = HashSet::new(); - chain.insert(focused.clone()); - loop { - let mut grew = false; - for stmt in scope { - if let Some((new, old)) = dict_transition(stmt) - && (chain.contains(&new) || chain.contains(&old)) - { - if chain.insert(new.clone()) { - grew = true; - } - if chain.insert(old.clone()) { - grew = true; - } - } - } - if !grew { - break; - } - } - chain -} - -/// If `stmt` is a dict transition op (Insert/Update/Delete), return -/// `(new_state, old_state)`. Matches the elaborated middleware forms -/// (`ContainerInsert`/`ContainerUpdate`/`ContainerDelete`) since -/// `DictInsert` etc. are syntactic sugar lowered during compilation. -fn dict_transition(stmt: &StatementTmpl) -> Option<(StatementTmplArg, StatementTmplArg)> { - let native = native_predicate(&stmt.pred_or_wc)?; - // Arg order is (old, key, value, new) for insert/update and - // (old, key, new) for delete: old root first, new root last. - let (old, new) = match native { - NativePredicate::ContainerInsert - | NativePredicate::ContainerUpdate - | NativePredicate::DictInsert - | NativePredicate::DictUpdate => (stmt.args.first()?.clone(), stmt.args.get(3)?.clone()), - NativePredicate::ContainerDelete | NativePredicate::DictDelete => { - (stmt.args.first()?.clone(), stmt.args.get(2)?.clone()) - } - _ => return None, - }; - Some((new, old)) -} - -fn native_predicate(pred_or_wc: &PredicateOrWildcard) -> Option { - match pred_or_wc { - PredicateOrWildcard::Predicate(Predicate::Native(n)) => Some(*n), - _ => None, - } -} - -/// Find wildcard *names* that a named intro produces (e.g. "Vdf"'s -/// third arg is the work output). Cross-predicate, name-based to match -/// the chain tracing strategy. -/// True if any statement in `scope` invokes the intro predicate with -/// the given verifier-data hash. Hash-based so a name change in the -/// intro pod registration doesn't silently break detection. -fn scope_uses_intro(scope: &[StatementTmpl], vd_hash: &Hash) -> bool { - scope.iter().any(|stmt| { - matches!( - &stmt.pred_or_wc, - PredicateOrWildcard::Predicate(Predicate::Intro(intro)) - if &intro.verifier_data_hash == vd_hash - ) - }) -} - -/// Collect the output wildcards produced by an intro identified by its -/// verifier-data hash. The convention is that the *last* arg of an -/// intro statement is its output wildcard (e.g., Vdf's `work`). -fn collect_intro_outputs(scope: &[StatementTmpl], vd_hash: &Hash) -> HashSet { - let mut out = HashSet::new(); - for stmt in scope { - if let PredicateOrWildcard::Predicate(Predicate::Intro(intro)) = &stmt.pred_or_wc - && &intro.verifier_data_hash == vd_hash - && let Some(StatementTmplArg::Wildcard(wc)) = stmt.args.last() - { - out.insert(wc.clone()); - } } out } -fn collect_fields_into_scope( - scope: &[StatementTmpl], - chain: &HashSet, - vdf_producers: &HashSet, - sig: &mut ClassSignature, -) { - for stmt in scope { - let native = match native_predicate(&stmt.pred_or_wc) { - Some(n) => n, - None => continue, - }; - let (state_arg, key_arg, value_arg) = match native { - NativePredicate::Contains | NativePredicate::DictContains => { - (stmt.args.first(), stmt.args.get(1), stmt.args.get(2)) - } - NativePredicate::ContainerInsert - | NativePredicate::ContainerUpdate - | NativePredicate::DictInsert - | NativePredicate::DictUpdate => { - // New order (old, key, value, new): new root last, - // key/value in the middle, old root first (checked below). - (stmt.args.get(3), stmt.args.get(1), stmt.args.get(2)) - } - _ => continue, - }; - let Some(state_arg) = state_arg else { - continue; - }; - let is_transition = matches!( - native, - NativePredicate::ContainerInsert - | NativePredicate::ContainerUpdate - | NativePredicate::DictInsert - | NativePredicate::DictUpdate - ); - let mut in_chain = chain.contains(state_arg); - if !in_chain - && is_transition - && let Some(old) = stmt.args.first() - { - in_chain = chain.contains(old); - } - if !in_chain { - continue; - } - let (Some(key_arg), Some(value_arg)) = (key_arg, value_arg) else { - continue; - }; - let field_name = match literal_string(key_arg) { - Some(s) => s, - None => continue, - }; - let info = sig.fields.entry(field_name).or_default(); - record_value(value_arg, vdf_producers, info); - } -} - -fn literal_string(arg: &StatementTmplArg) -> Option { - match arg { - StatementTmplArg::Literal(v) => v.as_string(), - _ => None, - } +/// Aggregated field signatures and identity constraints for a class. +#[derive(Default)] +struct ClassSignature { + fields: BTreeMap, + identity: ObjectIdentity, } -fn record_value(arg: &StatementTmplArg, vdf_producers: &HashSet, info: &mut FieldInfo) { - match arg { - StatementTmplArg::Literal(v) => { - if let Some(s) = v.as_string() { - info.string_literals.insert(s); - } else if let Some(i) = v.as_int() { - info.int_literals.insert(i); - } else { - info.from_witness = true; - } - } - StatementTmplArg::Wildcard(wc) => { - if vdf_producers.contains(wc) { - info.from_vdf = true; - } else { - info.from_witness = true; - } - } - _ => { - info.from_witness = true; - } - } +/// Tracks field writes partitioned by object creation versus subsequent mutations. +#[derive(Default)] +struct ClassField { + /// Values written when the object is created (minted). + at_mint: FieldWrites, + /// Values written by any action (creation or mutation). + ever: FieldWrites, } -fn render_signature(sig: &ClassSignature) -> String { - let mut out = String::new(); - out.push_str(&format!("class {} {{\n", sig.name)); - let mut field_lines: Vec<(String, String)> = Vec::new(); - for (name, info) in &sig.fields { - field_lines.push((name.clone(), render_field_value(info))); - } - let name_width = field_lines.iter().map(|(n, _)| n.len()).max().unwrap_or(0); - for (name, value) in &field_lines { +fn render_signature(name: &str, sig: &ClassSignature) -> String { + let mut out = format!("class {name} {{\n"); + let width = sig.fields.keys().map(|f| f.len()).max().unwrap_or(0); + for (field, summary) in &sig.fields { out.push_str(&format!( - " {:width$} {}\n", - name, - value, - width = name_width + " {field:width$} {}\n", + render_field_value(summary) )); } - if sig.uses_vdf || sig.uses_pow { + if sig.identity.is_constrained() { out.push_str(" // identity:"); - if sig.uses_pow { + if sig.identity.proof_of_work { out.push_str(" PoW (lt_eq_u256)"); } - if sig.uses_vdf { + if sig.identity.vdf { out.push_str(" VDF"); } out.push('\n'); @@ -1543,54 +1183,53 @@ fn render_signature(sig: &ClassSignature) -> String { out } -fn render_field_value(info: &FieldInfo) -> String { - let strings: Vec = info.string_literals.iter().cloned().collect(); - let ints: Vec = info.int_literals.iter().copied().collect(); +fn render_field_value(field: &ClassField) -> String { + let mint = &field.at_mint.values; + let later: BTreeSet<&Pin> = field.ever.values.difference(mint).collect(); - let parts: Vec = match (strings.is_empty(), ints.is_empty()) { - (false, true) => { - let union = strings - .iter() - .map(|s| format!("\"{s}\"")) - .collect::>() - .join(" | "); - vec![union] - } - (true, false) => { - let union = ints - .iter() - .map(|i| i.to_string()) - .collect::>() - .join(" | "); - vec![format!("Int // at mint: {union}")] - } - (false, false) => { - let strs = strings - .iter() - .map(|s| format!("\"{s}\"")) - .collect::>() - .join(" | "); - let nums = ints - .iter() - .map(|i| i.to_string()) - .collect::>() - .join(" | "); - vec![format!("{strs} | {nums}")] - } - (true, true) => Vec::new(), + let kind = match kind_of(field.ever.values.iter()) { + Some(kind) => kind, + None if field.ever.from_vdf => return "Raw // VDF-derived".to_string(), + // Unconstrained field supplied by witness. + None => return "Raw // witness".to_string(), }; - if !parts.is_empty() { - return parts.into_iter().next().unwrap(); + let mut clauses: Vec = Vec::new(); + if !mint.is_empty() { + clauses.push(format!("at mint: {}", join_pins(mint.iter()))); } + if !later.is_empty() { + clauses.push(format!("updated to {}", join_pins(later.into_iter()))); + } + format!("{kind} // {}", clauses.join("; ")) +} - if info.from_vdf { - "Raw // VDF-derived".to_string() - } else if info.from_witness { - "Raw // witness".to_string() - } else { - "?".to_string() +/// Infers the field type representation from written literals, or `None` if unconstrained. +fn kind_of<'a>(values: impl Iterator) -> Option<&'static str> { + let (mut text, mut int) = (false, false); + for v in values { + match v { + Pin::Text(_) => text = true, + Pin::Int(_) => int = true, + } } + match (text, int) { + (false, false) => None, + (true, false) => Some("Str"), + (false, true) => Some("Int"), + // Field contains both string and integer writes across producers. + (true, true) => Some("Str | Int"), + } +} + +fn join_pins<'a>(values: impl Iterator) -> String { + let (texts, ints): (Vec<&Pin>, Vec<&Pin>) = values.partition(|p| matches!(p, Pin::Text(_))); + texts + .into_iter() + .chain(ints) + .map(Pin::to_string) + .collect::>() + .join(" | ") } /// Extract the text of a top-level predicate definition by name. @@ -1693,21 +1332,56 @@ CraftWood(in, out) = AND( assert_eq!(sanitize("PlainName"), "PlainName"); } + fn written(values: &[Pin], from_vdf: bool) -> FieldWrites { + FieldWrites { + values: values.iter().cloned().collect(), + from_vdf, + } + } + + /// Verifies distinct labels for initial creation values versus mutation updates. #[test] - fn render_signature_labels_int_literal_as_at_mint() { - let mut fields = BTreeMap::new(); - let mut durability = FieldInfo::default(); - durability.int_literals.insert(100); - fields.insert("durability".to_string(), durability); - let sig = ClassSignature { - name: "WoodPick".to_string(), - fields, - uses_vdf: false, - uses_pow: false, - }; - let rendered = render_signature(&sig); - assert!(rendered.contains("// at mint: 100")); - assert!(!rendered.contains("// initial")); + fn render_signature_separates_mint_from_later_values() { + let mut sig = ClassSignature::default(); + sig.fields.insert( + "durability".to_string(), + ClassField { + at_mint: written(&[Pin::Int(100)], false), + ever: written(&[Pin::Int(100)], false), + }, + ); + sig.fields.insert( + "authorized".to_string(), + ClassField { + at_mint: written(&[Pin::Int(0)], false), + ever: written(&[Pin::Int(0), Pin::Int(1)], false), + }, + ); + sig.fields.insert( + "revealed".to_string(), + ClassField { + at_mint: written(&[], false), + ever: written(&[Pin::Int(1)], false), + }, + ); + sig.fields.insert( + "work".to_string(), + ClassField { + at_mint: written(&[], true), + ever: written(&[], true), + }, + ); + sig.identity.vdf = true; + + let rendered = render_signature("WoodPick", &sig); + assert!(rendered.contains("Int // at mint: 100"), "{rendered}"); + assert!( + rendered.contains("Int // at mint: 0; updated to 1"), + "{rendered}" + ); + assert!(rendered.contains("Int // updated to 1"), "{rendered}"); + assert!(rendered.contains("Raw // VDF-derived"), "{rendered}"); + assert!(rendered.contains("// identity: VDF"), "{rendered}"); } #[test] diff --git a/libs/sdk/src/lib.rs b/libs/sdk/src/lib.rs index c87c1dd6..62962707 100644 --- a/libs/sdk/src/lib.rs +++ b/libs/sdk/src/lib.rs @@ -29,6 +29,7 @@ use vdfpod::{STANDARD_VDF_VD_HASH, VdfPod}; mod error; mod fmt_podlang; pub mod manifest; +mod requirements; mod utils; #[cfg(test)] @@ -36,6 +37,7 @@ mod tests; pub use error::SdkError; use manifest::Manifest; +pub use requirements::{FieldFacts, FieldWrites, ObjectIdentity, Pin}; use utils::native_pred_to_op; /// Shared reference with interior mutability for anything that could be used as an argument to a @@ -56,6 +58,23 @@ enum Intro { LtEqU256, // (lhs, rhs) } +impl Intro { + /// Argument indices of objects constrained by this intro. + fn subject_args(&self) -> &'static [usize] { + match self { + Self::Vdf => &[1], + Self::LtEqU256 => &[0, 1], + } + } + /// Argument index that receives the intro output, if applicable. + fn output_arg(&self) -> Option { + match self { + Self::Vdf => Some(2), + Self::LtEqU256 => None, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ObjectIO { Input, @@ -272,38 +291,31 @@ impl VarOrValue { Self::Value(_) => panic!("not a var"), } } - // Only call this at exec time - fn as_value(&self) -> Value { + /// Resolves the runtime `Value`. Must only be called during execution. + /// + /// Returns a runtime error if the requested field does not exist on the target object. + fn as_value(&self) -> RuntimeResult { match self { - Self::Value(value) => value.clone(), + Self::Value(value) => Ok(value.clone()), Self::Var(Var { value, key: None, .. - }) => value.clone().expect("has value at exec time"), + }) => Ok(value.clone().expect("has value at exec time")), Self::Var(Var { value, typ, key: Some(key), - .. - }) => match typ { - Type::Dict => { - let dict = value - .as_ref() - .expect("has value at exec time") - .as_dictionary() - .expect("dict"); - dict.get(&StrKey::from(key)).unwrap().expect("key exists") - } - Type::Array(record) => { - let array = value - .as_ref() - .expect("has value at exec time") - .as_array() - .expect("array"); - let idx = record.iter().position(|k| k == key).unwrap(); - array.get(idx).unwrap().expect("index exists") + name, + }) => { + let value = value.as_ref().expect("has value at exec time"); + match typ { + Type::Dict => read_field(name, &value.as_dictionary().expect("dict"), key), + Type::Array(record) => { + read_entry(name, &value.as_array().expect("array"), record, key) + .map(|(_, value)| value) + } + _ => todo!("implement type {typ}"), } - _ => todo!("implement type {typ}"), - }, + } } } // Only call this at exec time @@ -320,33 +332,34 @@ impl VarOrValue { }) => panic!("entry can't be mutated"), } } - // Only call this at exec time - fn as_op_arg(&self) -> OperationArg { + /// Resolves the runtime `OperationArg`. Must only be called during execution. + fn as_op_arg(&self) -> RuntimeResult { match self { - Self::Value(value) => OperationArg::Literal(value.clone()), + Self::Value(value) => Ok(OperationArg::Literal(value.clone())), Self::Var(Var { - typ, value, key, .. + typ, + value, + key, + name, }) => { let value = value.as_ref().expect("has value at exec time").clone(); - if let Some(key) = key { - let st_contains = match typ { - Type::Dict => { - let dict = value.as_dictionary().expect("dict"); - let value = dict.get(&key.into()).unwrap().unwrap(); - Statement::Contains(dict.into(), key.clone().into(), value.into()) - } - Type::Array(record) => { - let array = value.as_array().expect("array"); - let index = record.iter().position(|k| k == key).unwrap(); - let value = array.get(index).unwrap().unwrap(); - Statement::Contains(array.into(), (index as i64).into(), value.into()) - } - _ => todo!("support other types"), - }; - OperationArg::Statement(st_contains) - } else { - OperationArg::Literal(value) - } + let Some(key) = key else { + return Ok(OperationArg::Literal(value)); + }; + let st_contains = match typ { + Type::Dict => { + let dict = value.as_dictionary().expect("dict"); + let value = read_field(name, &dict, key)?; + Statement::Contains(dict.into(), key.clone().into(), value.into()) + } + Type::Array(record) => { + let array = value.as_array().expect("array"); + let (index, value) = read_entry(name, &array, record, key)?; + Statement::Contains(array.into(), (index as i64).into(), value.into()) + } + _ => todo!("support other types"), + }; + Ok(OperationArg::Statement(st_contains)) } } } @@ -357,8 +370,8 @@ impl VarOrValue { } } // Only call this at exec time - fn to_dict(&self) -> Dictionary { - self.as_value().as_dictionary().expect("is dict") + fn to_dict(&self) -> RuntimeResult { + Ok(self.as_value()?.as_dictionary().expect("is dict")) } // Only call this at exec time fn mut_dict(&mut self, mut f: impl FnMut(&mut Dictionary) -> T) -> T { @@ -410,6 +423,21 @@ macro_rules! st_methods { fn register_st_methods(engine: &mut Engine) { $( engine.register_fn(stringify!($name), ActionHandle::$name); )+ } + + /// True if this predicate's `idx`th arg is checked as an integer, + /// read from the same table that declares the host methods so the + /// two cannot drift. + pub(crate) fn arg_is_int(pred: NativePredicate, idx: usize) -> bool { + match pred { + $( + NativePredicate::$pred => { + let types: &[Type] = &[$($typ),+]; + types.get(idx) == Some(&Type::Int) + } + )+ + _ => false, + } + } }; } @@ -732,7 +760,7 @@ impl ActionHandle { { let obj = obj.borrow(); let varname = obj.var_name().to_string(); - let raw_dict = obj.to_dict(); + let raw_dict = obj.to_dict()?; stamped.insert(varname, with_stable_identifier(&raw_dict)); if has_initials { raw.push(raw_dict); @@ -751,7 +779,7 @@ impl ActionHandle { } = inst { let varname = obj.borrow().var_name().to_string(); - let post_dict = obj.borrow().to_dict(); + let post_dict = obj.borrow().to_dict()?; match io { ObjectIO::Input => { in_dicts.push(Value::from(post_dict)); @@ -827,7 +855,7 @@ impl ActionHandle { let varname = obj.borrow().var_name().to_string(); let post_dict = match io { ObjectIO::Output => stamped_outputs[&varname].clone(), - _ => obj.borrow().to_dict(), + _ => obj.borrow().to_dict()?, }; let pre_dict = match io { ObjectIO::Mutate => original @@ -838,7 +866,7 @@ impl ActionHandle { }; // The script-final form, which for an Output is the // dict before TxInsert stamps identity onto it. - let initials_dict = obj.borrow().to_dict(); + let initials_dict = obj.borrow().to_dict()?; // Same forms and order as `fmt_action`'s clauses; the // two sets have to line up statement for statement. for (entry, record, dict) in [ @@ -984,7 +1012,7 @@ impl ActionHandle { } = inst { let varname = obj.borrow().var_name().to_string(); - let raw_obj_dict = obj.borrow().to_dict(); + let raw_obj_dict = obj.borrow().to_dict()?; // tx_builder.insert returns a new dictionary with // an added identity entry, other cases stick with // the raw dictionary. @@ -1136,15 +1164,17 @@ impl ActionHandle { // when it renders as a literal or a loose wildcard. A dict-field // ref (`var.key`) resolves to its entry; a whole-container ref // resolves to the record slot its Object collapses to at this ts. - let arg_anchor = |arg: &Ref, current_ts: &HashMap| -> Option { + let arg_anchor = |arg: &Ref, + current_ts: &HashMap| + -> RuntimeResult> { let arg = arg.borrow(); - match &*arg { - VarOrValue::Var(Var { key: Some(_), .. }) => Some(arg.as_op_arg()), + Ok(match &*arg { + VarOrValue::Var(Var { key: Some(_), .. }) => Some(arg.as_op_arg()?), VarOrValue::Var(Var { key: None, name, .. }) => current_ts.get(name).and_then(|ts| anchor_at(name, *ts)), VarOrValue::Value(_) => None, - } + }) }; { let mut exe_ctx = exe_rc.borrow_mut(); @@ -1160,11 +1190,11 @@ impl ActionHandle { // needs no lifting afterwards. let op_args = args .iter() - .map(|arg| { - arg_anchor(arg, ¤t_ts) - .unwrap_or_else(|| arg.borrow().as_op_arg()) + .map(|arg| match arg_anchor(arg, ¤t_ts)? { + Some(anchored) => Ok(anchored), + None => arg.borrow().as_op_arg(), }) - .collect(); + .collect::>>()?; let st = exe_ctx .bld .builder @@ -1183,7 +1213,7 @@ impl ActionHandle { let replacements: Vec> = args .iter() .map(|arg| arg_anchor(arg, ¤t_ts)) - .collect(); + .collect::>()?; let st = if replacements.iter().any(|r| r.is_some()) { exe_ctx .bld @@ -1243,7 +1273,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 arg = value.borrow().as_op_arg().clone(); + let arg = value.borrow().as_op_arg()?; let st = exe_ctx .bld .builder @@ -1265,7 +1295,7 @@ impl ActionHandle { } => { let old_dict = old_dict.clone().expect("Update old_dict captured at Rhai"); let new_dict = new_dict.clone().expect("Update new_dict captured at Rhai"); - let arg = value.borrow().as_op_arg().clone(); + let arg = value.borrow().as_op_arg()?; let ts_before = *current_ts.get(obj).unwrap_or(&0); let ts_after = ts_before + 1; let new_dict_arg = anchor_or_literal(obj, &new_dict, ts_after); @@ -1446,22 +1476,23 @@ 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); - } + let obj_name = match &*obj.borrow() { + VarOrValue::Var(var) => { + self.0.borrow_mut().mark_dict_read(&var.name); + var.name.clone() + } + VarOrValue::Value(_) => return Err(rt_err("pow_obj_grind: expected an object")), + }; // 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() { // This is a copy of the object, we don't modify the obj argument. - let mut obj = obj.borrow().to_dict(); - let target_raw = target.borrow().as_value().raw(); + let mut obj = obj.borrow().to_dict()?; + let target_raw = target.borrow().as_value()?.raw(); // Initialize k to obj's current key so that when the loop body doesn't run (mock mode, // or the initial random already satisfies the constraint), the returned k still // matches what's in obj. - let mut k = obj - .get(&StrKey::from("key")) - .expect("dict op") - .expect("obj has key"); + let mut k = read_field(&obj_name, &obj, "key")?; if !exe_ctx.mock { while u256_gt(&RawValue::from(obj.commitment()), &target_raw) { k = exe_ctx.rand_value(); @@ -1498,8 +1529,8 @@ impl ActionHandle { let mut statement: Option = None; if let Some(exe_rc) = ctx.exe_ctx.as_ref() { let mut exe_ctx = exe_rc.borrow_mut(); - let n = n_iters.borrow().as_value().as_int().expect("int") as usize; - let inp = input.borrow().as_value().raw(); + let n = n_iters.borrow().as_value()?.as_int().expect("int") as usize; + let inp = input.borrow().as_value()?.raw(); let pod = if exe_ctx.mock { VdfPod::new_boxed_mock(&exe_ctx.params, exe_ctx.vd_set.clone(), n, inp) } else { @@ -1524,8 +1555,8 @@ impl ActionHandle { let mut statement: Option = None; if let Some(exe_rc) = ctx.exe_ctx.as_ref() { let mut exe_ctx = exe_rc.borrow_mut(); - let l = lhs.borrow().as_value().raw(); - let r = rhs.borrow().as_value().raw(); + let l = lhs.borrow().as_value()?.raw(); + let r = rhs.borrow().as_value()?.raw(); let pod = if exe_ctx.mock { LtEqU256Pod::new_boxed_mock(&exe_ctx.params, exe_ctx.vd_set.clone(), l, r) } else { @@ -1576,6 +1607,41 @@ st_methods! { st_array_update, ArrayUpdate, [old: Type::Unk, i: Type::Int, v: Type::Unk, new: Type::Unk]; } +/// Constructs a script runtime error. +fn rt_err(msg: impl Into) -> Box { + msg.into().into() +} + +/// Constructs a runtime error for an absent field or record entry. +fn missing_field(name: &str, key: &str) -> Box { + rt_err(format!("object `{name}` has no field `{key}`")) +} + +/// Reads `key` from a dictionary, returning descriptive errors on lookup failure. +fn read_field(name: &str, dict: &Dictionary, key: &str) -> RuntimeResult { + dict.get(&StrKey::from(key)) + .map_err(|err| rt_err(format!("reading `{name}.{key}`: {err}")))? + .ok_or_else(|| missing_field(name, key)) +} + +/// Reads `key` from a record array using declared entry names. +fn read_entry( + name: &str, + array: &Array, + record: &[String], + key: &str, +) -> RuntimeResult<(usize, Value)> { + let index = record + .iter() + .position(|k| k == key) + .ok_or_else(|| rt_err(format!("record `{name}` has no entry `{key}`")))?; + let value = array + .get(index) + .map_err(|err| rt_err(format!("reading `{name}.{key}`: {err}")))? + .ok_or_else(|| missing_field(name, key))?; + Ok((index, value)) +} + fn rt_err_from_anyhow(err: anyhow::Error) -> Box { Box::new(EvalAltResult::ErrorRuntime( Dynamic::from(Rc::new(err)), @@ -1660,12 +1726,13 @@ impl ArgHandle { 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(); + let value = value.borrow().as_value()?; arg.mut_dict(|obj| { - obj.insert(&StrKey::from(key), &value).expect("TODO"); - }); + obj.insert(&StrKey::from(key), &value) + .map_err(|err| rt_err(format!("setting `{var_name}.{key}`: {err}"))) + })?; } - final_dict = Some(arg.to_dict()); + final_dict = Some(arg.to_dict()?); } ctx.insts.push(Inst::Set { obj: var_name, @@ -1698,12 +1765,13 @@ impl ArgHandle { let mut old_dict: Option = None; let mut new_dict: Option = None; if ctx.exe_ctx.is_some() { - let v = value.borrow().as_value().clone(); - let (obj0, obj) = arg.mut_dict(|obj| { + let v = value.borrow().as_value()?; + let (obj0, obj) = arg.mut_dict(|obj| -> RuntimeResult<_> { let obj0 = obj.clone(); - obj.update(&StrKey::from(&key), &v).expect("TODO"); - (obj0, obj.clone()) - }); + obj.update(&StrKey::from(&key), &v) + .map_err(|err| rt_err(format!("updating `{var_name}.{key}`: {err}")))?; + Ok((obj0, obj.clone())) + })?; old_dict = Some(obj0); new_dict = Some(obj); } @@ -1773,9 +1841,9 @@ fn arg_arith(op: ArithOp, a: ArgHandle, b: ArgHandle) -> RuntimeResult RuntimeResult { arg.borrow() - .as_value() + .as_value()? .as_int() - .ok_or_else(|| format!("operator{}: operand is not an int", op.symbol()).into()) + .ok_or_else(|| rt_err(format!("operator{}: operand is not an int", op.symbol()))) }; let (x, y) = (int(&a.arg)?, int(&b.arg)?); let result = op.apply(x, y).ok_or_else(|| -> Box { @@ -1847,11 +1915,11 @@ fn try_value_from_dynamic(v: Dynamic) -> RuntimeResult { Err(v) => v, }; let v = match v.try_cast_result::() { - Ok(v) => return Ok(v.borrow().as_value().clone()), + Ok(v) => return v.borrow().as_value(), Err(v) => v, }; let v = match v.try_cast_result::() { - Ok(v) => return Ok(v.arg.borrow().as_value().clone()), + Ok(v) => return v.arg.borrow().as_value(), Err(v) => v, }; _ = v; @@ -1868,6 +1936,35 @@ pub struct ActionObjectRef { pub(crate) io: ObjectIO, pub class: String, pub(crate) varname: String, + /// Field constraints and write records for this object. + pub(crate) fields: requirements::ObjectFields, + pub(crate) identity: ObjectIdentity, +} + +impl ActionObjectRef { + /// Script-side variable name. + pub fn varname(&self) -> &str { + &self.varname + } + + /// Field requirement constraints for this object, in field-name order. + pub fn field_facts(&self) -> impl Iterator { + self.fields + .iter() + .map(|entry| (entry.name.as_ref(), &entry.facts)) + } + + /// Field writes performed on this object, in field-name order. + pub fn field_writes(&self) -> impl Iterator { + self.fields + .iter() + .map(|entry| (entry.name.as_ref(), &entry.writes)) + } + + /// Cryptographic identity constraints applied to this object. + pub fn identity(&self) -> ObjectIdentity { + self.identity + } } /// One slot in an action's `IO` record (in-entries first, @@ -1944,6 +2041,13 @@ impl ActionMeta { self.total_outputs.iter() } + /// Output objects created (minted) by this action rather than mutated. + pub fn total_created(&self) -> impl Iterator { + self.total_outputs + .iter() + .filter(|r| r.io == ObjectIO::Output) + } + /// Find this Output's entry in the `Initials` record, with /// its slot. `needs_wildcard` is set when the body reads a field of /// the object's script-final form, which cannot render as an @@ -2029,13 +2133,20 @@ impl ActionMeta { ..Self::default() }; let referenced = body_referenced_vars(&ctx.insts); + let facts = requirements::object_facts(&ctx.name, ctx); for inst in &ctx.insts { match inst { Inst::Object { io, obj, class, .. } => { + let varname = obj.borrow().var_name().to_string(); + let required = facts.get(&varname); let r = ActionObjectRef { io: *io, class: class.clone(), - varname: obj.borrow().var_name().to_string(), + fields: required + .map(|r| r.fields.clone()) + .unwrap_or_else(|| Vec::new().into()), + identity: required.map(|r| r.identity).unwrap_or_default(), + varname, }; if io.consumes() { meta.total_inputs.push(r.clone()); diff --git a/libs/sdk/src/requirements.rs b/libs/sdk/src/requirements.rs new file mode 100644 index 00000000..a2332702 --- /dev/null +++ b/libs/sdk/src/requirements.rs @@ -0,0 +1,618 @@ +//! Static analysis of field constraints and writes extracted from the action's +//! Load-time instruction list. +//! +//! Inspects the unlowered `Inst` list to preserve original variable and field names +//! across helper predicates and sub-actions. + +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::fmt; +use std::rc::Rc; + +use pod2::middleware::{NativePredicate, Value}; + +use crate::{ActionContext, Inst, Intro, Ref, Var, VarOrValue, arg_is_int}; + +/// Literal value constraint for a field. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum Pin { + Int(i64), + Text(String), +} + +impl fmt::Display for Pin { + /// Formats the literal value matching pod2 value representation. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Int(v) => Value::from(*v).fmt(f), + Self::Text(t) => Value::from(t.as_str()).fmt(f), + } + } +} + +/// Cryptographic identity constraints applied to an object. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ObjectIdentity { + /// Constrained by a VDF intro. + pub vdf: bool, + /// Constrained by a proof-of-work (`lt_eq_u256`) intro. + pub proof_of_work: bool, +} + +impl ObjectIdentity { + pub fn is_constrained(&self) -> bool { + self.vdf || self.proof_of_work + } + /// Merges identity constraints from another object reference. + pub fn absorb(&mut self, other: Self) { + self.vdf |= other.vdf; + self.proof_of_work |= other.proof_of_work; + } +} + +/// Constraints and type requirements imposed on a field by an action. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FieldFacts { + /// Literal values this field is constrained to equal. + pub pinned: BTreeSet, + /// Minimum allowed integer value. + pub min: Option, + /// Identifier of the equality group if this field is coupled with other fields. + pub group: Option, + /// Whether the field is used in an integer context. + pub integer: bool, +} + +impl FieldFacts { + fn pin(&mut self, value: Pin) { + self.pinned.insert(value); + } + fn floor(&mut self, min: i64) { + self.min = Some(self.min.map_or(min, |cur| cur.max(min))); + } + /// Merges constraints from an equal field. + fn absorb(&mut self, other: &Self) { + self.pinned.extend(other.pinned.iter().cloned()); + if let Some(m) = other.min { + self.floor(m); + } + self.integer |= other.integer; + } +} + +/// Values written to an object field by an action. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FieldWrites { + /// Literal values written to the field. + pub values: BTreeSet, + /// Whether the field is populated from a VDF output. + pub from_vdf: bool, +} + +impl FieldWrites { + fn write(&mut self, value: Pin) { + self.values.insert(value); + } + /// Merges field write records from another action. + pub fn absorb(&mut self, other: &Self) { + self.values.extend(other.values.iter().cloned()); + self.from_vdf |= other.from_vdf; + } +} + +/// Combined constraint requirements and write records for a single field. +#[derive(Debug)] +pub struct FieldEntry { + pub name: Box, + pub facts: FieldFacts, + pub writes: FieldWrites, +} + +/// Field entries for an object, sorted by field name. +pub(crate) type ObjectFields = Rc<[FieldEntry]>; + +pub(crate) struct ObjectRequirements { + pub fields: ObjectFields, + pub identity: ObjectIdentity, +} + +/// A statement argument, resolved as far as Load-time data allows. +enum Term { + /// `.`. + Field(String, String), + Int(i64), + Str(String), + /// A plain script variable: an `unsafe {}` result, an intro output, + /// or a whole object. + Local(String), + Opaque, +} + +fn term(r: &Ref) -> Term { + match &*r.borrow() { + VarOrValue::Value(v) => { + if let Some(i) = v.as_int() { + Term::Int(i) + } else if let Some(s) = v.as_string() { + Term::Str(s) + } else { + Term::Opaque + } + } + VarOrValue::Var(Var { + name, key: Some(k), .. + }) => Term::Field(name.clone(), k.clone()), + VarOrValue::Var(Var { + name, key: None, .. + }) => Term::Local(name.clone()), + } +} + +type FieldKey = (String, String); + +/// Disjoint-set union tracking coupled `(object, field)` equality groups. +#[derive(Default)] +struct Dsu { + parent: HashMap, +} + +impl Dsu { + fn find(&mut self, key: &FieldKey) -> FieldKey { + let mut cur = key.clone(); + loop { + let Some(up) = self.parent.get(&cur) else { + self.parent.insert(cur.clone(), cur.clone()); + return cur; + }; + if *up == cur { + return cur; + } + let up = up.clone(); + // Path halving to compress equality chains. + if let Some(grand) = self.parent.get(&up).cloned() { + self.parent.insert(cur.clone(), grand); + } + cur = up; + } + } + fn union(&mut self, a: &FieldKey, b: &FieldKey) { + let (ra, rb) = (self.find(a), self.find(b)); + if ra != rb { + // Preserve the smaller key to ensure deterministic group IDs. + let (keep, drop) = if ra < rb { (ra, rb) } else { (rb, ra) }; + self.parent.insert(drop, keep); + } + } +} + +/// Extracts field constraints and writes for all objects declared by `action`. +pub(crate) fn object_facts( + action: &str, + ctx: &ActionContext, +) -> HashMap { + let mut intros = IntroFacts::collect(ctx); + + let mut objects: Vec = Vec::new(); + let mut touched: HashMap> = HashMap::new(); + let mut facts: HashMap = HashMap::new(); + let mut writes: HashMap = HashMap::new(); + let mut dsu = Dsu::default(); + // Defer statement evaluation until all local variable bounds are collected. + let mut statements: Vec<(NativePredicate, Vec)> = Vec::new(); + let mut local_min: HashMap = HashMap::new(); + + let touch = |touched: &mut HashMap>, t: &Term| { + if let Term::Field(var, field) = t { + touched + .entry(var.clone()) + .or_default() + .insert(field.clone()); + } + }; + + for inst in &ctx.insts { + match inst { + Inst::Object { obj, .. } => objects.push(obj.borrow().var_name().to_string()), + Inst::Set { obj, kvs, .. } => { + for (key, value) in kvs { + touched.entry(obj.clone()).or_default().insert(key.clone()); + let value = term(value); + touch(&mut touched, &value); + let target = (obj.clone(), key.clone()); + intros.note_write(&mut writes, &target, &value); + // Record `set` literal as a requirement in case equality propagates it to an input. + match value { + Term::Int(v) => facts.entry(target).or_default().pin(Pin::Int(v)), + Term::Str(t) => facts.entry(target).or_default().pin(Pin::Text(t)), + Term::Field(v, f) => dsu.union(&target, &(v, f)), + _ => {} + } + } + } + Inst::Update { + obj, key, value, .. + } => { + // Record write for next state without constraining input. + touched.entry(obj.clone()).or_default().insert(key.clone()); + let value = term(value); + touch(&mut touched, &value); + intros.note_write(&mut writes, &(obj.clone(), key.clone()), &value); + } + Inst::Statement { pred, args } => { + let terms: Vec = args.iter().map(term).collect(); + for (i, t) in terms.iter().enumerate() { + touch(&mut touched, t); + if let (Term::Field(var, field), true) = (t, arg_is_int(*pred, i)) { + facts + .entry((var.clone(), field.clone())) + .or_default() + .integer = true; + } + } + if let Some((name, min)) = local_floor(*pred, &terms) { + let slot = local_min.entry(name).or_insert(min); + *slot = (*slot).max(min); + } + statements.push((*pred, terms)); + } + Inst::Intro { args, .. } => { + for t in args.iter().map(term) { + touch(&mut touched, &t); + } + } + Inst::SubAction { .. } => {} + } + } + + for (pred, terms) in &statements { + apply(*pred, terms, &local_min, &mut facts, &mut dsu); + } + + // Aggregate constraints for each equality group and apply to all members. + let mut groups: HashMap = HashMap::new(); + let mut members: HashMap = HashMap::new(); + let keys: Vec = facts + .keys() + .cloned() + .chain(dsu.parent.keys().cloned()) + .chain( + touched + .iter() + .flat_map(|(v, fs)| fs.iter().map(|f| (v.clone(), f.clone()))), + ) + .collect(); + let mut root_of: HashMap = HashMap::with_capacity(keys.len()); + for key in keys { + let root = dsu.find(&key); + if root_of.insert(key.clone(), root.clone()).is_some() { + continue; + } + *members.entry(root.clone()).or_default() += 1; + if let Some(f) = facts.get(&key) { + groups.entry(root).or_default().absorb(f); + } else { + groups.entry(root).or_default(); + } + } + + let mut out: HashMap = HashMap::with_capacity(objects.len()); + for var in objects { + let mut fields: Vec = touched + .get(&var) + .into_iter() + .flatten() + .map(|field| { + let key = (var.clone(), field.clone()); + let root = root_of.get(&key).cloned().unwrap_or_else(|| key.clone()); + let mut facts = groups.get(&root).cloned().unwrap_or_default(); + if members.get(&root).copied().unwrap_or(1) > 1 { + facts.group = Some(format!("{action}:{}.{}", root.0, root.1)); + } + FieldEntry { + name: field.as_str().into(), + facts, + writes: writes.get(&key).cloned().unwrap_or_default(), + } + }) + .collect(); + fields.sort_by(|a, b| a.name.cmp(&b.name)); + let identity = intros.identity.get(&var).copied().unwrap_or_default(); + out.insert( + var, + ObjectRequirements { + fields: fields.into(), + identity, + }, + ); + } + out +} + +/// Pre-pass state tracking intro calls and identity constraints. +#[derive(Default)] +struct IntroFacts { + /// Local variables holding VDF outputs. + vdf_outputs: HashSet, + /// Local variables constrained by `lt_eq_u256`. + pow_values: HashSet, + identity: HashMap, +} + +impl IntroFacts { + fn collect(ctx: &ActionContext) -> Self { + let mut out = Self::default(); + for inst in &ctx.insts { + let Inst::Intro { pred, args, .. } = inst else { + continue; + }; + let local = |i: usize| match args.get(i).map(term) { + Some(Term::Local(name)) => Some(name), + _ => Option::None, + }; + for subject in pred.subject_args().iter().filter_map(|i| local(*i)) { + let entry = out.identity.entry(subject.clone()).or_default(); + match pred { + Intro::Vdf => entry.vdf = true, + Intro::LtEqU256 => { + entry.proof_of_work = true; + // The same arg may name a value bound for a field + // rather than the object itself. + out.pow_values.insert(subject); + } + } + } + if let Some(output) = pred.output_arg().and_then(local) { + out.vdf_outputs.insert(output); + } + } + out + } + + /// Records a field write and updates identity constraints if the value originated from an intro. + fn note_write( + &mut self, + writes: &mut HashMap, + target: &FieldKey, + value: &Term, + ) { + let slot = writes.entry(target.clone()).or_default(); + match value { + Term::Int(v) => slot.write(Pin::Int(*v)), + Term::Str(t) => slot.write(Pin::Text(t.clone())), + Term::Local(name) => { + if self.vdf_outputs.contains(name) { + slot.from_vdf = true; + self.identity.entry(target.0.clone()).or_default().vdf = true; + } + if self.pow_values.contains(name) { + self.identity + .entry(target.0.clone()) + .or_default() + .proof_of_work = true; + } + } + _ => {} + } + } +} + +/// Extracts lower bounds on local variables from comparison statements. +fn local_floor(pred: NativePredicate, terms: &[Term]) -> Option<(String, i64)> { + use NativePredicate::*; + match (pred, terms) { + (Gt, [Term::Local(l), Term::Int(m)]) | (Lt, [Term::Int(m), Term::Local(l)]) => { + Some((l.clone(), m + 1)) + } + (GtEq, [Term::Local(l), Term::Int(m)]) | (LtEq, [Term::Int(m), Term::Local(l)]) => { + Some((l.clone(), *m)) + } + // Explicit `Option::None` to disambiguate from `NativePredicate::None`. + _ => Option::None, + } +} + +/// Record what one statement says about the fields it mentions. +fn apply( + pred: NativePredicate, + terms: &[Term], + local_min: &HashMap, + facts: &mut HashMap, + dsu: &mut Dsu, +) { + use NativePredicate::*; + let mut pin = |v: &String, f: &String, value: Pin| { + facts.entry((v.clone(), f.clone())).or_default().pin(value); + }; + match (pred, terms) { + // Sum constraint: v2 = v0 + v1. + (Sum, [Term::Field(v, f), Term::Int(b), Term::Int(c)]) => pin(v, f, Pin::Int(c - b)), + (Sum, [Term::Int(a), Term::Field(v, f), Term::Int(c)]) => pin(v, f, Pin::Int(c - a)), + (Sum, [Term::Int(a), Term::Int(b), Term::Field(v, f)]) => pin(v, f, Pin::Int(a + b)), + (Equal, [Term::Field(v, f), Term::Int(k)]) | (Equal, [Term::Int(k), Term::Field(v, f)]) => { + pin(v, f, Pin::Int(*k)) + } + // Lower bound derived from local variable: field = local + k. + (Sum, [Term::Local(l), Term::Int(k), Term::Field(v, f)]) + | (Sum, [Term::Int(k), Term::Local(l), Term::Field(v, f)]) => { + if let Some(lo) = local_min.get(l) { + facts + .entry((v.clone(), f.clone())) + .or_default() + .floor(lo + k); + } + } + // Equality between two fields. + (Sum, [Term::Field(v1, f1), Term::Int(0), Term::Field(v2, f2)]) + | (Sum, [Term::Int(0), Term::Field(v1, f1), Term::Field(v2, f2)]) + | (Equal, [Term::Field(v1, f1), Term::Field(v2, f2)]) => { + dsu.union(&(v1.clone(), f1.clone()), &(v2.clone(), f2.clone())) + } + // Direct lower bound comparison. + (Gt, [Term::Field(v, f), Term::Int(m)]) | (Lt, [Term::Int(m), Term::Field(v, f)]) => facts + .entry((v.clone(), f.clone())) + .or_default() + .floor(m + 1), + (GtEq, [Term::Field(v, f), Term::Int(m)]) | (LtEq, [Term::Int(m), Term::Field(v, f)]) => { + facts.entry((v.clone(), f.clone())).or_default().floor(*m) + } + // Dictionary contains constraint. + (Contains | DictContains, [Term::Local(o), Term::Str(k), rest]) => match rest { + Term::Int(v) => pin(o, k, Pin::Int(*v)), + Term::Str(t) => pin(o, k, Pin::Text(t.clone())), + Term::Field(v2, f2) => dsu.union(&(o.clone(), k.clone()), &(v2.clone(), f2.clone())), + _ => {} + }, + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Sdk; + + /// Returns field constraints and writes for a given action and object variable. + fn facts_for(src: &str, action: &str, var: &str) -> Vec<(String, FieldFacts, FieldWrites)> { + let module = Sdk::default() + .load_module_from_src_actions(src, &[action]) + .unwrap(); + let meta = module.actions().iter().find(|a| a.name == action).unwrap(); + let obj = meta + .object_refs + .iter() + .find(|r| r.varname == var) + .unwrap_or_else(|| panic!("no object {var} in {action}")); + obj.fields + .iter() + .map(|e| (e.name.to_string(), e.facts.clone(), e.writes.clone())) + .collect() + } + + fn field<'a>( + all: &'a [(String, FieldFacts, FieldWrites)], + name: &str, + ) -> &'a (String, FieldFacts, FieldWrites) { + all.iter() + .find(|(f, _, _)| f == name) + .unwrap_or_else(|| panic!("no field {name}")) + } + + #[test] + fn statement_literals_become_requirements_not_writes() { + let all = facts_for( + r#" + fn Consume(action) { + var ore = action.mutate("Ore"); + action.st_sum(ore.grade, 0, 7); + action.st_gt(ore.depth, 4); + } + "#, + "Consume", + "ore", + ); + let (_, grade, grade_writes) = field(&all, "grade"); + assert_eq!(grade.pinned, [Pin::Int(7)].into_iter().collect()); + assert!(grade.integer); + assert!(grade_writes.values.is_empty(), "a read is not a write"); + + let (_, depth, _) = field(&all, "depth"); + assert_eq!(depth.min, Some(5)); + assert!(depth.pinned.is_empty()); + } + + #[test] + fn set_literals_are_both_written_and_required() { + let all = facts_for( + r#" + fn Mint(action) { + var badge = action.output("Badge"); + badge.set([["tier", "gold"], ["level", 3]]); + } + "#, + "Mint", + "badge", + ); + let (_, tier, tier_writes) = field(&all, "tier"); + let gold = [Pin::Text("gold".to_string())].into_iter().collect(); + assert_eq!(tier_writes.values, gold); + assert_eq!(tier.pinned, gold, "an equality can carry a set literal"); + + let (_, _, level_writes) = field(&all, "level"); + assert_eq!(level_writes.values, [Pin::Int(3)].into_iter().collect()); + } + + #[test] + fn update_literals_are_written_but_not_required() { + let all = facts_for( + r#" + fn Drain(action) { + var tank = action.mutate("Tank"); + tank.update("fuel", 0); + } + "#, + "Drain", + "tank", + ); + let (_, fuel, fuel_writes) = field(&all, "fuel"); + assert_eq!(fuel_writes.values, [Pin::Int(0)].into_iter().collect()); + assert!( + fuel.pinned.is_empty(), + "the next state does not constrain the supplied one" + ); + } + + /// Verifies that equality propagates field constraints without propagating writes. + #[test] + fn equality_shares_requirements_but_not_writes() { + let all = facts_for( + r#" + fn Copy(action) { + var tank = action.mutate("Tank"); + var receipt = action.output("Receipt"); + receipt.set([["seen", tank.fuel]]); + tank.update("fuel", 0); + } + "#, + "Copy", + "receipt", + ); + let (_, seen, seen_writes) = field(&all, "seen"); + assert!( + seen_writes.values.is_empty(), + "the write to tank.fuel is not a write to receipt.seen" + ); + assert!( + seen.group.is_some(), + "the two fields are one equality group" + ); + } + + #[test] + fn vdf_output_marks_the_field_and_the_identity() { + let module = Sdk::default() + .load_module_from_src_actions( + r#" + fn Find(action) { + var log = action.output("Log"); + var work = action.intro_vdf(3, log); + log.update("work", work); + } + "#, + &["Find"], + ) + .unwrap(); + let meta = &module.actions()[0]; + let log = &meta.object_refs[0]; + let (_, writes) = log + .field_writes() + .find(|(name, _)| *name == "work") + .expect("work is written"); + assert!(writes.from_vdf); + assert_eq!( + log.identity(), + ObjectIdentity { + vdf: true, + proof_of_work: false + } + ); + } +} diff --git a/libs/sdk/src/tests.rs b/libs/sdk/src/tests.rs index 84abc3f8..c3117eaa 100644 --- a/libs/sdk/src/tests.rs +++ b/libs/sdk/src/tests.rs @@ -1302,3 +1302,55 @@ fn test_set_guards() { assert!(err.contains(expected), "{action}: {err}"); } } + +/// Verifies that accessing or updating an absent field returns a runtime error rather than panicking. +#[test] +fn test_missing_field_is_an_error_not_a_panic() { + let _ = env_logger::builder().is_test(true).try_init(); + let src = r#" + fn MakeWidget(action) { + var w = action.output("Widget"); + w.set([["grade", 1]]); + } + + fn ReadInSet(action) { + var w = action.input("Widget"); + var g = action.output("Gadget"); + g.set([["grade", w.absent]]); + } + + fn ReadInStatement(action) { + var w = action.mutate("Widget"); + action.st_sum(w.absent, 0, 1); + } + + fn UpdateAbsent(action) { + var w = action.mutate("Widget"); + w.update("absent", 1); + } + "#; + let actions = &["MakeWidget", "ReadInSet", "ReadInStatement", "UpdateAbsent"]; + let module = Sdk::default() + .load_module_from_src_actions(src, actions) + .unwrap(); + + let mut state = TestState::default(); + let executor = module.executor(true, grounding_witness(&state, &[])); + let res = executor.action("MakeWidget", vec![]).unwrap(); + let widget_tx = res.tx.clone(); + let [widget] = res.objs(); + apply_tx(&mut state, &widget_tx); + + for (action, expected) in [ + ("ReadInSet", "object `w` has no field `absent`"), + ("ReadInStatement", "object `w` has no field `absent`"), + ("UpdateAbsent", "updating `w.absent`"), + ] { + let executor = module.executor(true, grounding_witness(&state, &[widget.obj.commitment()])); + let err = match executor.action(action, vec![widget.clone()]) { + Ok(_) => panic!("expected {action} to fail on the absent field"), + Err(err) => err.to_string(), + }; + assert!(err.contains(expected), "{action}: {err}"); + } +}