From c7901ea6f7b4c8770b004efec10f8cfed41091d0 Mon Sep 17 00:00:00 2001 From: Dhvani Patel Date: Thu, 13 Aug 2026 15:31:07 +0530 Subject: [PATCH 01/13] Prove a transaction can compose top-level actions from two plugin batches --- libs/txlib/src/lib.rs | 139 ++++++++++++++++++++ libs/txlib/src/predicates/mod.rs | 12 ++ libs/txlib/src/predicates/swap_test.podlang | 32 +++++ 3 files changed, 183 insertions(+) create mode 100644 libs/txlib/src/predicates/swap_test.podlang diff --git a/libs/txlib/src/lib.rs b/libs/txlib/src/lib.rs index eafb3e07..8ecc346c 100644 --- a/libs/txlib/src/lib.rs +++ b/libs/txlib/src/lib.rs @@ -1219,6 +1219,17 @@ mod tests { ) } + /// Place an object in the created set without proving the + /// transaction that would have produced it, so a test can start + /// from a given holding instead of minting it first. + fn seed(&mut self, obj: &Dictionary) { + let index = self.created_index.len() as i64; + self.created + .insert(index as usize, Value::from(obj.clone())) + .unwrap(); + self.created_index.insert(obj.commitment(), index); + } + fn apply_tx(&mut self, tx: &Tx) { for obj in tx.live.iter() { let obj = obj.expect("tx live entry should decode"); @@ -1803,4 +1814,132 @@ mod tests { ); } } + + /// One transaction, two top-level actions, two different plugin + /// batches: `UseWoodPick` from the crafting batch and `ClaimGem` + /// from a second batch that neither txlib nor the crafting batch + /// references. Guard dispatch reaches each batch through its + /// object's `type` field, which is what lets a transaction compose + /// actions from plugins that were compiled independently. + #[test] + fn test_actions_from_two_batches_in_one_tx() { + let events = Arc::new(crate::predicates::events_module()); + let txlib = Arc::new(crate::predicates::module()); + let craft = Arc::new(crate::predicates::crafting_test_module()); + let swap = Arc::new(crate::predicates::swap_test_module()); + assert_ne!( + craft.batch.id(), + swap.batch.id(), + "the two plugin batches must be distinct for this test to mean anything" + ); + + let is_wood_pick = Value::from( + Predicate::Custom(craft.predicate_ref_by_name("IsWoodPick").unwrap()).hash(), + ); + let is_gem = + Value::from(Predicate::Custom(swap.predicate_ref_by_name("IsGem").unwrap()).hash()); + let modules = vec![events, txlib, craft, swap]; + + let params = Params::default(); + let vd_set = VDSet::new(&[]); + + // Start from a holding of one pick and one gem rather than + // minting each in its own transaction first. + let mut state = TestState::empty(0); + let pick = with_stable_identifier(&make_object( + is_wood_pick, + &[("durability", Value::from(100_i64))], + )); + let gem = with_stable_identifier(&make_object(is_gem, &[])); + state.seed(&pick); + state.seed(&gem); + + let builder = MultiPodBuilder::new(¶ms, &vd_set); + let mut ctx = BuildContext { builder, modules }; + + let inputs = vec![pick.clone(), gem.clone()]; + let witness = state.grounding_witness(&inputs); + let mut tx = TxBuilder::new(&mut ctx, &inputs, witness); + + // ---- top-level action 0: UseWoodPick, guarded by the crafting batch ---- + let mut pick_new = pick.clone(); + pick_new + .update(&StrKey::from("durability"), &Value::from(99_i64)) + .unwrap(); + let scope_pick = tx.begin_action(); + let (st_mutate_pick, h_pick) = tx.mutate(&mut ctx, &pick_new, &pick); + let op_gt = ctx + .builder + .priv_op(op!(Gt((&pick, "durability"), 0_i64))) + .unwrap(); + let op_sum = ctx + .builder + .priv_op(op!(Sum(99_i64, 1_i64, (&pick, "durability")))) + .unwrap(); + let op_du_pick = ctx + .builder + .priv_op(op!(DictUpdate(pick, "durability", 99_i64, pick_new))) + .unwrap(); + let st_use = ctx + .apply_custom_pred_simple( + false, + "UseWoodPick", + vec![op_gt, op_sum, op_du_pick, st_mutate_pick], + ) + .unwrap(); + let st_guard_pick = ctx + .apply_custom_pred( + false, + "IsWoodPick", + map!({"state_header" => state.state_header().array()}), + vec![Statement::None, Statement::None, st_use], + ) + .unwrap(); + tx.set_guard(h_pick, st_guard_pick); + tx.end_action(scope_pick); + + // ---- top-level action 1: ClaimGem, guarded by the second batch ---- + let new_key = Value::from(rand_raw_value()); + let mut gem_new = gem.clone(); + gem_new.update(&StrKey::from("key"), &new_key).unwrap(); + let scope_gem = tx.begin_action(); + let (st_mutate_gem, h_gem) = tx.mutate(&mut ctx, &gem_new, &gem); + let op_du_gem = ctx + .builder + .priv_op(op!(DictUpdate(gem, "key", new_key, gem_new))) + .unwrap(); + let st_claim = ctx + .apply_custom_pred_simple(false, "ClaimGem", vec![op_du_gem, st_mutate_gem]) + .unwrap(); + let st_guard_gem = ctx + .apply_custom_pred( + false, + "IsGem", + map!({"state_header" => state.state_header().array()}), + vec![Statement::None, st_claim], + ) + .unwrap(); + tx.set_guard(h_gem, st_guard_gem); + tx.end_action(scope_gem); + + eprintln!("{tx}"); + let (st, tx_out, stats) = tx.finalize(&mut ctx); + print_stats(&stats); + ctx.builder.reveal(&st).unwrap(); + solve_and_verify(ctx.builder); + + // Both old states are spent and both successors are live, so the + // two actions landed as one atomic transaction. + for old in [&pick, &gem] { + assert!( + tx_out + .nullifiers + .contains(&Value::from(compute_nullifier(old))) + .unwrap() + ); + } + for new in [&pick_new, &gem_new] { + assert!(tx_out.live.contains(&Value::from(new.clone())).unwrap()); + } + } } diff --git a/libs/txlib/src/predicates/mod.rs b/libs/txlib/src/predicates/mod.rs index b5aa3f3a..eb836c63 100644 --- a/libs/txlib/src/predicates/mod.rs +++ b/libs/txlib/src/predicates/mod.rs @@ -15,6 +15,18 @@ pub fn crafting_test_module() -> lang::Module { load_module(&source, "craft", ¶ms, &[events]).expect("crafting_test.podlang compiles") } +#[cfg(test)] +/// Load a second, independent plugin batch. Used to prove a transaction +/// can carry top-level actions guarded by two different batches. +pub fn swap_test_module() -> lang::Module { + let params = pod2::middleware::Params::default(); + let events = Arc::new(events_module()); + let events_hash = format!("{:#}", events.batch.id()); + let source = + include_str!("swap_test.podlang").replace(TX_EVENTS_HASH_PLACEHOLDER, &events_hash); + load_module(&source, "swap", ¶ms, &[events]).expect("swap_test.podlang compiles") +} + /// The chain-primitive event predicates (TxInsert/TxMutate/TxDelete). /// Kept in their own batch so action predicates and recorded /// transactions keep stable hashes across edits to the replay and diff --git a/libs/txlib/src/predicates/swap_test.podlang b/libs/txlib/src/predicates/swap_test.podlang new file mode 100644 index 00000000..201af16c --- /dev/null +++ b/libs/txlib/src/predicates/swap_test.podlang @@ -0,0 +1,32 @@ +/* + A second test plugin batch, separate from crafting_test.podlang. + + Exists so a test can prove one transaction whose top-level actions + are guarded by predicates from two different batches. Guard dispatch + reaches these predicates through the object's `type` field, so + nothing here is referenced by txlib or by the other batch. +*/ + +use module 0xTX_EVENTS_MODULE_HASH as tx + +// TODO: Support importing records via `use module` +record StateHeader = (block_number, block_timestamp, block_hash, created, nullifiers, prior_state_history) + +// SpawnGem: test-only creation from nothing. +SpawnGem(gem, chain_start, chain_end, private: gem0) = AND( + tx::TxInsert(chain_start, chain_end, gem0, gem, @self_predicate(IsGem)) +) + +// ClaimGem: take exclusive possession by rotating `key`. Rotating the +// key changes the commitment and the nullifier, so a holder who was +// sent this object can make the sender's copy unspendable. No other +// field is constrained. +ClaimGem(gem, chain_start, chain_end, private: gem0, key) = AND( + DictUpdate(gem0, "key", key, gem) + tx::TxMutate(chain_start, chain_end, gem0, gem, @self_predicate(IsGem)) +) + +IsGem(obj, state_header StateHeader, chain_start, chain_end) = OR( + SpawnGem(obj, chain_start, chain_end) + ClaimGem(obj, chain_start, chain_end) +) From 126034267ceaec4403b91b5ef3011b0a80a45d15 Mon Sep 17 00:00:00 2001 From: Dhvani Patel Date: Thu, 13 Aug 2026 15:34:03 +0530 Subject: [PATCH 02/13] Reject ambiguous predicate names instead of resolving to the first module --- libs/pod2utils/src/macros.rs | 204 ++++++++++++++++++++++++++--------- 1 file changed, 152 insertions(+), 52 deletions(-) diff --git a/libs/pod2utils/src/macros.rs b/libs/pod2utils/src/macros.rs index 551a767d..0b798e2a 100644 --- a/libs/pod2utils/src/macros.rs +++ b/libs/pod2utils/src/macros.rs @@ -251,13 +251,46 @@ macro_rules! _wildcard_values { }}; } -pub fn find_custom_pred_by_name(modules: &[Arc], name: &str) -> Option { +/// Find the one module defining `name`. +/// +/// Ambiguity is an error rather than a first-match win: two plugin +/// batches can each define an action or class of the same name, and +/// resolving to whichever was loaded first would prove a predicate from +/// the wrong batch. Callers that already know the batch should use the +/// `_in` variants and skip resolution entirely. +pub fn resolve_module<'a>( + modules: &'a [Arc], + name: &str, +) -> anyhow::Result<&'a Arc> { + let mut found: Option<&Arc> = None; for module in modules { - if let Some(cpr) = module.predicate_ref_by_name(name) { - return Some(cpr); + if module.predicate_ref_by_name(name).is_none() { + continue; + } + if let Some(previous) = found { + anyhow::bail!( + "predicate {name} is defined in both module {} and module {}; \ + qualify the call with the intended module", + previous.batch.name, + module.batch.name, + ); } + found = Some(module); } - None + found.ok_or_else(|| anyhow::anyhow!("predicate {name} is not defined in any loaded module")) +} + +pub fn find_custom_pred_by_name( + modules: &[Arc], + name: &str, +) -> anyhow::Result { + let module = resolve_module(modules, name)?; + module.predicate_ref_by_name(name).ok_or_else(|| { + anyhow::anyhow!( + "predicate {name} vanished from module {}", + module.batch.name + ) + }) } pub fn apply_custom_pred( @@ -268,21 +301,31 @@ pub fn apply_custom_pred( wildcard_map: HashMap, statements: Vec, ) -> anyhow::Result { - for module in modules { - if let Some(cpr) = module.predicate_ref_by_name(name) { - return module.apply_predicate_with(name, statements, public, |is_public, op| { - let mut wildcard_values: Vec<(usize, Value)> = Vec::new(); - for (i, name) in cpr.predicate().wildcard_names().iter().enumerate() { - if let Some(value) = wildcard_map.get(name) { - wildcard_values.push((i, value.clone())); - } - } - let st = builder.op(is_public, wildcard_values, op).unwrap(); - Ok(st) - }); + let module = resolve_module(modules, name)?.clone(); + apply_custom_pred_in(&module, builder, public, name, wildcard_map, statements) +} + +/// Apply a predicate from a known module, bypassing name resolution. +pub fn apply_custom_pred_in( + module: &Arc, + builder: &mut MultiPodBuilder, + public: bool, + name: &str, + wildcard_map: HashMap, + statements: Vec, +) -> anyhow::Result { + let cpr = module + .predicate_ref_by_name(name) + .ok_or_else(|| anyhow::anyhow!("module {} defines no {name}", module.batch.name))?; + module.apply_predicate_with(name, statements, public, |is_public, op| { + let mut wildcard_values: Vec<(usize, Value)> = Vec::new(); + for (i, name) in cpr.predicate().wildcard_names().iter().enumerate() { + if let Some(value) = wildcard_map.get(name) { + wildcard_values.push((i, value.clone())); + } } - } - panic!("predicate not found"); + Ok(builder.op(is_public, wildcard_values, op)?) + }) } /// Argument types: @@ -325,28 +368,35 @@ impl BuildContext { wildcard_map: HashMap, statements: Vec, ) -> anyhow::Result { - for module in &self.modules { - if module.predicate_ref_by_name(name).is_some() { - return module.apply_predicate_with(name, statements, public, |is_public, op| { - let mut wildcard_values: Vec<(usize, Value)> = Vec::new(); - // Get the CustomPredicateRef from the closure because this may be a chain in a - // split predicate where the wildcard indices are different than the top level - // predicate. - let cpr = match &op.0 { - OperationType::Custom(cpr) => cpr, - _ => unreachable!(), - }; - for (i, name) in cpr.predicate().wildcard_names().iter().enumerate() { - if let Some(value) = wildcard_map.get(name) { - wildcard_values.push((i, value.clone())); - } - } - let st = self.builder.op(is_public, wildcard_values, op).unwrap(); - Ok(st) - }); + let module = resolve_module(&self.modules, name)?.clone(); + self.apply_custom_pred_in(&module, public, name, wildcard_map, statements) + } + + /// Apply a predicate from a known module, bypassing name resolution. + pub fn apply_custom_pred_in( + &mut self, + module: &Arc, + public: bool, + name: &str, + wildcard_map: HashMap, + statements: Vec, + ) -> anyhow::Result { + module.apply_predicate_with(name, statements, public, |is_public, op| { + let mut wildcard_values: Vec<(usize, Value)> = Vec::new(); + // Get the CustomPredicateRef from the closure because this may be a chain in a + // split predicate where the wildcard indices are different than the top level + // predicate. + let cpr = match &op.0 { + OperationType::Custom(cpr) => cpr, + _ => unreachable!(), + }; + for (i, name) in cpr.predicate().wildcard_names().iter().enumerate() { + if let Some(value) = wildcard_map.get(name) { + wildcard_values.push((i, value.clone())); + } } - } - panic!("predicate not found"); + Ok(self.builder.op(is_public, wildcard_values, op)?) + }) } /// Apply a custom predicate without wildcard value hints. @@ -358,19 +408,26 @@ impl BuildContext { name: &str, statements: Vec, ) -> anyhow::Result { - for module in &self.modules { - if module.predicate_ref_by_name(name).is_some() { - return module.apply_predicate_with( - name, - statements, - public, - |is_public, op| -> anyhow::Result { - Ok(self.builder.op(is_public, vec![], op)?) - }, - ); - } - } - panic!("predicate {name} not found"); + let module = resolve_module(&self.modules, name)?.clone(); + self.apply_custom_pred_simple_in(&module, public, name, statements) + } + + /// Apply a predicate from a known module, bypassing name resolution. + pub fn apply_custom_pred_simple_in( + &mut self, + module: &Arc, + public: bool, + name: &str, + statements: Vec, + ) -> anyhow::Result { + module.apply_predicate_with( + name, + statements, + public, + |is_public, op| -> anyhow::Result { + Ok(self.builder.op(is_public, vec![], op)?) + }, + ) } } @@ -397,3 +454,46 @@ macro_rules! st_custom { $crate::_st_custom!(&mut $ctx.builder, &$ctx.modules, false, $pred($($wc_name=$wc_value),*) = ($($sts)*)) }}; } + +#[cfg(test)] +mod tests { + use super::*; + use pod2::{lang::load_module, middleware::Params}; + + /// A one-predicate module, so two of them can be made to collide on + /// a name the way two independently compiled plugins would. + fn module_named(module: &str, predicate: &str) -> Arc { + let params = Params::default(); + let source = format!("{predicate}(a, b) = AND(\n Equal(a, b)\n)\n"); + Arc::new(load_module(&source, module, ¶ms, &[]).expect("test module compiles")) + } + + #[test] + fn resolves_a_name_defined_once() { + let modules = vec![ + module_named("plug_a", "Claim"), + module_named("plug_b", "Ship"), + ]; + let found = resolve_module(&modules, "Claim").expect("Claim resolves"); + assert_eq!(found.batch.name, "plug_a"); + } + + #[test] + fn rejects_a_name_two_modules_define() { + let modules = vec![ + module_named("plug_a", "Claim"), + module_named("plug_b", "Claim"), + ]; + let err = resolve_module(&modules, "Claim").expect_err("ambiguity must not resolve"); + let message = format!("{err}"); + assert!(message.contains("plug_a"), "{message}"); + assert!(message.contains("plug_b"), "{message}"); + } + + #[test] + fn reports_a_name_no_module_defines() { + let modules = vec![module_named("plug_a", "Claim")]; + let err = resolve_module(&modules, "Swap").expect_err("missing name must not resolve"); + assert!(format!("{err}").contains("not defined in any loaded module")); + } +} From ac35c2089224141559a7f2afd4a0b6e72a23fe72 Mon Sep 17 00:00:00 2001 From: Dhvani Patel Date: Thu, 13 Aug 2026 15:42:23 +0530 Subject: [PATCH 03/13] Run several actions from different plugins as one transaction --- libs/sdk/src/lib.rs | 143 ++++++++++++++++++++------- libs/sdk/src/tests.rs | 224 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 333 insertions(+), 34 deletions(-) diff --git a/libs/sdk/src/lib.rs b/libs/sdk/src/lib.rs index 93cbf279..8bc2599d 100644 --- a/libs/sdk/src/lib.rs +++ b/libs/sdk/src/lib.rs @@ -2349,6 +2349,15 @@ impl SpendableObjects { } } +/// One action to run inside a transaction, together with the plugin it +/// comes from and the objects it consumes. A transaction is a list of +/// these; they need not share a plugin. +pub struct Invocation { + pub module: Rc, + pub action: String, + pub inputs: Vec, +} + /// The Executor is used to hold the state of action execution at Execution time. pub struct Executor { mock: bool, @@ -2406,6 +2415,27 @@ fn prove(builder: MultiPodBuilder, prover: &dyn MainPodProver) -> MainPod { impl Executor { fn new(module: Rc, mock: bool, grounding_witness: Arc) -> Self { + Self::with_modules(vec![module], mock, grounding_witness) + .expect("one module is never empty") + } + + /// Build an executor over several plugin modules, so one transaction + /// can carry actions from more than one plugin. + /// + /// The txlib batches arrive once per plugin and are deduplicated by + /// batch id: leaving copies in would make every txlib predicate name + /// resolve ambiguously. Two plugins compiled against genuinely + /// different txlib batches keep both copies and fail that way, which + /// is the honest outcome -- their events are not interchangeable. + pub fn with_modules( + modules: Vec>, + mock: bool, + grounding_witness: Arc, + ) -> Result { + let module = modules + .first() + .ok_or_else(|| anyhow!("an executor needs at least one plugin module"))? + .clone(); let mock_prover = MockProver {}; let real_prover = Prover {}; let (vd_set, prover): (_, Box) = if mock { @@ -2415,20 +2445,24 @@ impl Executor { (vd_set.clone(), Box::new(real_prover)) }; let params = Params::default(); - let modules = vec![ - module.tx_events_mod.clone(), - module.txlib_mod.clone(), - module.module.clone(), - ]; - Self { + let mut pod_modules: Vec> = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for plugin in &modules { + for batch in [&plugin.tx_events_mod, &plugin.txlib_mod, &plugin.module] { + if seen.insert(batch.batch.id()) { + pod_modules.push(batch.clone()); + } + } + } + Ok(Self { mock, params, vd_set, grounding_witness, prover, - pod_modules: modules, + pod_modules, module, - } + }) } fn new_builder(&self) -> MultiPodBuilder { MultiPodBuilder::new(&self.params, &self.vd_set) @@ -2442,45 +2476,85 @@ impl Executor { action: &str, inputs: Vec, ) -> Result { + self.actions(vec![Invocation { + module: self.module.clone(), + action: action.to_string(), + inputs, + }]) + } + + /// Execute several actions as one transaction. + /// + /// Each invocation opens its own top-level action scope, so the + /// actions are siblings on the event chain rather than nested, and + /// they may come from different plugins. Grounding covers the whole + /// transaction, so the caller's witness must carry a proof for every + /// input across every invocation. + pub fn actions(&self, invocations: Vec) -> Result { // TODO: In this function: return errors instead of panic from unwrap. + if invocations.is_empty() { + return Err(anyhow!("a transaction needs at least one action").into()); + } let builder = self.new_builder(); let mut bld = BuildContext { builder, modules: self.pod_modules.clone(), }; - let total = &self.module.action_by_name(action).total_inputs; - - let mut tx_inputs: Vec = Vec::with_capacity(inputs.len()); - let mut rhai_input_objs: Vec = Vec::with_capacity(inputs.len()); - for (input, _ref) in zip_eq(inputs, total.iter()) { - let SpendableObject { obj } = input; - tx_inputs.push(obj.clone()); - rhai_input_objs.push(obj); + // The tx builder grounds every input up front; each invocation + // then pops only its own off the rhai stack. + let mut tx_inputs: Vec = Vec::new(); + let mut per_action_inputs: Vec> = Vec::with_capacity(invocations.len()); + for invocation in &invocations { + let total = &invocation + .module + .action_by_name(&invocation.action) + .total_inputs; + let mut objs: Vec = Vec::with_capacity(invocation.inputs.len()); + for (input, _ref) in zip_eq(invocation.inputs.iter(), total.iter()) { + tx_inputs.push(input.obj.clone()); + objs.push(input.obj.clone()); + } + // Reverse so rhai pops in declaration order (last-declared on top). + objs.reverse(); + per_action_inputs.push(objs); } - // Reverse so rhai pops in declaration order (last-declared on top). - rhai_input_objs.reverse(); let tx_builder = self.new_tx_builder(&mut bld, &tx_inputs); let exe_rc = Rc::new(RefCell::new(ExeContext { mock: self.mock, params: self.params.clone(), vd_set: self.vd_set.clone(), - inputs: rhai_input_objs, + inputs: Vec::new(), bld, tx_builder, - module: self.module.clone(), + module: invocations[0].module.clone(), outputs: Vec::new(), })); - let action_handle = ActionHandle::new(action.to_string(), Some(exe_rc.clone())); - log::info!("executing action {}", action); - let start = std::time::Instant::now(); - action_handle.exe_action()?; - log::info!("executing action {} took {:?}", action, start.elapsed()); - // Release the handle's Rc clone so `exe_rc` has a unique - // owner for the `try_unwrap` below. - action_handle.0.borrow_mut().exe_ctx = None; + for (invocation, inputs) in zip_eq(&invocations, per_action_inputs) { + { + let mut exe_ctx = exe_rc.borrow_mut(); + // Each body is parsed from its own plugin's script, so the + // module handle moves with the invocation. + exe_ctx.module = invocation.module.clone(); + exe_ctx.inputs = inputs; + } + let action_handle = ActionHandle::new(invocation.action.clone(), Some(exe_rc.clone())); + log::info!("executing action {}", invocation.action); + let start = std::time::Instant::now(); + action_handle.exe_action()?; + log::info!( + "executing action {} took {:?}", + invocation.action, + start.elapsed() + ); + + // Release the handle's Rc clone so `exe_rc` has a unique + // owner for the `try_unwrap` below. + action_handle.0.borrow_mut().exe_ctx = None; + } + let ExeContext { tx_builder, mut bld, @@ -2498,15 +2572,16 @@ impl Executor { // statements are not revealed: they would force a wrapping pod // to mask them from the relayer / synchronizer's `ProofParser`, // which expects a single public statement. - log::info!("proving tx_pod for action {}", action); + let label = invocations + .iter() + .map(|invocation| invocation.action.as_str()) + .collect::>() + .join(" + "); + log::info!("proving tx_pod for {label}"); let start = std::time::Instant::now(); let tx_pod = prove(bld.builder, &*self.prover); tx_pod.pod.verify().unwrap(); - log::info!( - "proving tx_pod for action {} took {:?}", - action, - start.elapsed() - ); + log::info!("proving tx_pod for {label} took {:?}", start.elapsed()); let objs: Vec = outputs .into_iter() diff --git a/libs/sdk/src/tests.rs b/libs/sdk/src/tests.rs index f0c82467..a2e7722e 100644 --- a/libs/sdk/src/tests.rs +++ b/libs/sdk/src/tests.rs @@ -886,3 +886,227 @@ fn test_sdk_state_header() { let [_ticker1] = res.objs(); apply_tx(&mut state, &ticker1_tx); } + +/// Two actions in one transaction: both objects are re-keyed together, +/// so the pair lands or fails as a unit. This is the shape a swap takes. +#[test] +fn test_two_actions_one_transaction() { + let _ = env_logger::builder().is_test(true).try_init(); + let src = r#" + fn SpawnLog(action) { + var log = action.output("Log"); + } + fn SpawnWood(action) { + var wood = action.output("Wood"); + } + fn ClaimLog(action) { + var log = action.mutate("Log"); + var key = action.random(); + log.update("key", key); + } + fn ClaimWood(action) { + var wood = action.mutate("Wood"); + var key = action.random(); + wood.update("key", key); + } + "#; + let sdk = Sdk::default(); + let module = sdk + .load_module_from_src_actions(src, &["SpawnLog", "SpawnWood", "ClaimLog", "ClaimWood"]) + .unwrap(); + + let mut state = TestState::default(); + + let executor = module.executor(true, grounding_witness(&state, &[])); + let res = executor.action("SpawnLog", vec![]).unwrap(); + let spawn_log_tx = res.tx.clone(); + let [log] = res.objs(); + apply_tx(&mut state, &spawn_log_tx); + + let executor = module.executor(true, grounding_witness(&state, &[])); + let res = executor.action("SpawnWood", vec![]).unwrap(); + let spawn_wood_tx = res.tx.clone(); + let [wood] = res.objs(); + apply_tx(&mut state, &spawn_wood_tx); + + // One transaction, two top-level actions. + let witness = grounding_witness(&state, &[log.obj.commitment(), wood.obj.commitment()]); + let executor = module.executor(true, witness); + let res = executor + .actions(vec![ + Invocation { + module: module.clone(), + action: "ClaimLog".to_string(), + inputs: vec![log.clone()], + }, + Invocation { + module: module.clone(), + action: "ClaimWood".to_string(), + inputs: vec![wood.clone()], + }, + ]) + .unwrap(); + + let [claimed_log, claimed_wood] = res.objs(); + let nullifiers = res.tx.nullifier_hashes().unwrap(); + assert_eq!(nullifiers.len(), 2, "both inputs are spent by the one tx"); + assert!(nullifiers.contains(&txlib::object_nullifier_hash(&log.obj).unwrap())); + assert!(nullifiers.contains(&txlib::object_nullifier_hash(&wood.obj).unwrap())); + + // Re-keying changes each commitment, and the stable identifier + // carries across so both stay the same objects. + let live = res.tx.live_commitments().unwrap(); + assert!(live.contains(&claimed_log.obj.commitment())); + assert!(live.contains(&claimed_wood.obj.commitment())); + assert_ne!(claimed_log.obj.commitment(), log.obj.commitment()); + assert_ne!(claimed_wood.obj.commitment(), wood.obj.commitment()); + let stable = |obj: &pod2::middleware::containers::Dictionary| { + obj.get(&pod2::middleware::StrKey::from("stable_identifier")) + .unwrap() + .unwrap() + }; + assert_eq!(stable(&claimed_log.obj), stable(&log.obj)); + assert_eq!(stable(&claimed_wood.obj), stable(&wood.obj)); +} + +/// The same transaction, but the two actions come from two separately +/// compiled plugins. This is what lets a user's own pexe compose actions +/// over classes another plugin defined. +#[test] +fn test_two_plugins_one_transaction() { + let _ = env_logger::builder().is_test(true).try_init(); + let logs_src = r#" + fn SpawnLog(action) { + var log = action.output("Log"); + } + fn ClaimLog(action) { + var log = action.mutate("Log"); + var key = action.random(); + log.update("key", key); + } + "#; + // Structurally different from the log plugin, not just differently + // named: predicate names are not hashed, so two plugins whose + // rendered podlang has the same shape compile to the same batch and + // therefore to the same classes. The extra literal field is what + // makes this a second batch. + let gems_src = r#" + fn SpawnGem(action) { + var gem = action.output("Gem"); + gem.set([ + ["facets", 8] + ]); + } + fn ClaimGem(action) { + var gem = action.mutate("Gem"); + var key = action.random(); + gem.update("key", key); + } + "#; + let sdk = Sdk::default(); + let logs = sdk + .load_module_from_src_actions(logs_src, &["SpawnLog", "ClaimLog"]) + .unwrap(); + let gems = sdk + .load_module_from_src_actions(gems_src, &["SpawnGem", "ClaimGem"]) + .unwrap(); + assert_ne!( + logs.module().batch.id(), + gems.module().batch.id(), + "the two plugins must compile to distinct batches" + ); + + let mut state = TestState::default(); + + let executor = logs.executor(true, grounding_witness(&state, &[])); + let res = executor.action("SpawnLog", vec![]).unwrap(); + let tx = res.tx.clone(); + let [log] = res.objs(); + apply_tx(&mut state, &tx); + + let executor = gems.executor(true, grounding_witness(&state, &[])); + let res = executor.action("SpawnGem", vec![]).unwrap(); + let tx = res.tx.clone(); + let [gem] = res.objs(); + apply_tx(&mut state, &tx); + + let witness = grounding_witness(&state, &[log.obj.commitment(), gem.obj.commitment()]); + let executor = Executor::with_modules(vec![logs.clone(), gems.clone()], true, witness).unwrap(); + let res = executor + .actions(vec![ + Invocation { + module: logs.clone(), + action: "ClaimLog".to_string(), + inputs: vec![log.clone()], + }, + Invocation { + module: gems.clone(), + action: "ClaimGem".to_string(), + inputs: vec![gem.clone()], + }, + ]) + .unwrap(); + + let nullifiers = res.tx.nullifier_hashes().unwrap(); + assert!(nullifiers.contains(&txlib::object_nullifier_hash(&log.obj).unwrap())); + assert!(nullifiers.contains(&txlib::object_nullifier_hash(&gem.obj).unwrap())); + + let [claimed_log, claimed_gem] = res.objs(); + let live = res.tx.live_commitments().unwrap(); + assert!(live.contains(&claimed_log.obj.commitment())); + assert!(live.contains(&claimed_gem.obj.commitment())); +} + +/// Plugin identity is structural, not nominal: predicate names are +/// metadata and are not hashed, so renaming every class and action in a +/// plugin leaves its batch id -- and therefore all of its class hashes +/// -- unchanged. Two independently authored plugins that render to the +/// same shape share an economy, and a recipe that pins a `module_hash` +/// is pinning structure rather than a name. +#[test] +fn test_batch_id_ignores_names() { + let _ = env_logger::builder().is_test(true).try_init(); + let logs_src = r#" + fn SpawnLog(action) { + var log = action.output("Log"); + } + "#; + let renamed_src = r#" + fn ConjureIngot(action) { + var ingot = action.output("Ingot"); + } + "#; + let sdk = Sdk::default(); + let logs = sdk + .load_module_from_src_actions(logs_src, &["SpawnLog"]) + .unwrap(); + let renamed = sdk + .load_module_from_src_actions(renamed_src, &["ConjureIngot"]) + .unwrap(); + + assert_eq!( + logs.module().batch.id(), + renamed.module().batch.id(), + "renaming a class and its action must not change the batch id" + ); + assert_eq!( + logs.class_hash("Log").unwrap(), + renamed.class_hash("Ingot").unwrap(), + "structurally identical classes are the same class" + ); + + // Adding a constrained field is a structural change, so it does move + // the batch id. + let extra_src = r#" + fn SpawnLog(action) { + var log = action.output("Log"); + log.set([ + ["facets", 8] + ]); + } + "#; + let extra = sdk + .load_module_from_src_actions(extra_src, &["SpawnLog"]) + .unwrap(); + assert_ne!(logs.module().batch.id(), extra.module().batch.id()); +} From 70225a924c25574bffa3e8081888ea148775c5db Mon Sep 17 00:00:00 2001 From: Dhvani Patel Date: Thu, 13 Aug 2026 15:56:38 +0530 Subject: [PATCH 04/13] Add recipe pexes that compose other plugins' actions into one transaction --- libs/driver/src/pexe_catalog.rs | 555 ++++++++++++++++++++++++++++++-- libs/pexe/src/bin/pexe.rs | 83 +++-- libs/pexe/src/fixtures.rs | 2 +- libs/pexe/src/inspect.rs | 16 +- libs/pexe/src/lib.rs | 76 ++++- libs/sdk/src/lib.rs | 10 +- libs/sdk/src/manifest.rs | 86 ++++- 7 files changed, 746 insertions(+), 82 deletions(-) diff --git a/libs/driver/src/pexe_catalog.rs b/libs/driver/src/pexe_catalog.rs index cf5c8c66..223498d1 100644 --- a/libs/driver/src/pexe_catalog.rs +++ b/libs/driver/src/pexe_catalog.rs @@ -17,8 +17,9 @@ //! therefore `!Send`. `execute_action` re-loads the script from its stored bytes //! on demand, matching the per-call pattern used before. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use std::rc::Rc; use std::sync::Arc; use anyhow::{Context, Result, anyhow}; @@ -34,7 +35,17 @@ struct Plugin { #[allow(dead_code)] path: PathBuf, manifest: Manifest, - script: String, + /// Absent for a recipe pexe, which composes other plugins' actions + /// and compiles to no module of its own. + script: Option, +} + +/// A catalog action assembled from other plugins' actions rather than +/// compiled from a script. Its steps run as sibling top-level actions of +/// one transaction, and its inputs are the steps' inputs concatenated in +/// step order. +struct RecipeEntry { + steps: Vec, } pub struct PexeCatalog { @@ -43,6 +54,11 @@ pub struct PexeCatalog { actions_by_name: HashMap, /// Maps qualified action -> plugin index in `plugins`. action_plugin_idx: HashMap, + /// Every action including hidden ones, so recipe steps can resolve + /// actions the catalog does not surface on its own. + actions_including_hidden: HashMap, + /// Recipe actions, keyed by their own qualified name. + recipes: HashMap, classes: Vec, classes_by_name: HashMap, classes_by_hash: HashMap, @@ -51,6 +67,29 @@ pub struct PexeCatalog { } impl PexeCatalog { + /// Recompile the plugin that provides `action`. The driver does not + /// cache compiled modules, so this runs per execution. + fn load_module(&self, sdk: &Sdk, action: &QualifiedName) -> Result> { + let plugin_idx = *self + .action_plugin_idx + .get(action) + .ok_or_else(|| anyhow!("no plugin provides action {action}"))?; + let plugin = &self.plugins[plugin_idx]; + let script = plugin.script.as_deref().ok_or_else(|| { + anyhow!( + "plugin {} has no script, so it cannot run {action}", + plugin.manifest.plugin.name + ) + })?; + sdk.load_module_from_src_manifest(script, &plugin.manifest) + .map_err(|err| { + anyhow!( + "failed to reload plugin {} for execution: {err}", + plugin.manifest.plugin.name + ) + }) + } + /// Scan `actions_dir` for `.pexe` files, unpack them, and assemble the catalog. pub fn load(actions_dir: &Path) -> Result { let plugins = discover_plugins(actions_dir)?; @@ -103,10 +142,21 @@ impl PexeCatalog { let mut enriched_plugins: Vec = Vec::with_capacity(plugins.len()); let mut action_plugin_idx: HashMap = HashMap::new(); + let mut actions_including_hidden: HashMap = HashMap::new(); + for plugin in plugins { let plugin_name = plugin.manifest.plugin.name.clone(); + // Recipes contribute no classes or predicates, so they are + // resolved after every plugin is loaded and their steps exist. + if plugin.manifest.is_recipe() { + enriched_plugins.push(plugin); + continue; + } + let script = plugin.script.as_deref().ok_or_else(|| { + anyhow!("plugin {plugin_name} has no script and declares no recipes") + })?; let module = sdk - .load_module_from_src_manifest(&plugin.script, &plugin.manifest) + .load_module_from_src_manifest(script, &plugin.manifest) .map_err(|err| anyhow!("failed to load plugin {plugin_name}: {err}"))?; let podlang_src = module.podlang_src().to_string(); if !combined_podlang.is_empty() { @@ -201,10 +251,6 @@ impl PexeCatalog { .map(|r| resolve_class(&r.class)) .collect::>>()?; - if meta.is_some_and(|m| m.hidden) { - continue; - } - let action_hash = module .action_hash(&bare) .map(|h| format!("{:#}", h)) @@ -213,7 +259,7 @@ impl PexeCatalog { // prefix like classes get). let predicate_source = extract_predicate(&podlang_src, &bare) .unwrap_or_else(|| format!("{bare}(state) = AND(...)")); - all_actions.push(ActionSummary { + let summary = ActionSummary { action: qname, emoji: meta.map_or("โš™๏ธ", |m| m.emoji.as_str()).to_string(), hash: action_hash, @@ -223,12 +269,117 @@ impl PexeCatalog { total_inputs, total_outputs, predicate_source, - }); + }; + actions_including_hidden.insert(summary.action.clone(), summary.clone()); + + if meta.is_some_and(|m| m.hidden) { + continue; + } + all_actions.push(summary); } enriched_plugins.push(plugin); } + // Recipe pass: every plugin is loaded, so a recipe's steps can be + // resolved and its inputs derived from them. + let installed_hashes: HashMap<&str, Option> = enriched_plugins + .iter() + .map(|plugin| { + ( + plugin.manifest.plugin.name.as_str(), + plugin.manifest.plugin.module_hash, + ) + }) + .collect(); + let mut recipes: HashMap = HashMap::new(); + for (plugin_idx, plugin) in enriched_plugins.iter().enumerate() { + if !plugin.manifest.is_recipe() { + continue; + } + let plugin_name = plugin.manifest.plugin.name.clone(); + + // A required plugin present at a different hash is a different + // set of classes, so its actions are not the ones this recipe + // was written against. + let mut required: HashSet<&str> = HashSet::new(); + for require in &plugin.manifest.requires { + required.insert(require.plugin.as_str()); + match installed_hashes.get(require.plugin.as_str()) { + None => { + return Err(anyhow!( + "recipe {plugin_name} requires plugin {} which is not installed", + require.plugin + )); + } + Some(None) => { + return Err(anyhow!( + "recipe {plugin_name} requires plugin {} at {:#}, but that plugin declares no module hash", + require.plugin, + require.module_hash + )); + } + Some(Some(installed)) if *installed != require.module_hash => { + return Err(anyhow!( + "recipe {plugin_name} requires plugin {} at {:#}, but it is installed at {:#}; rebuild the recipe against the installed version", + require.plugin, + require.module_hash, + installed + )); + } + Some(Some(_)) => {} + } + } + + for recipe in &plugin.manifest.recipes { + let qname = QualifiedName::new(plugin_name.clone(), recipe.name.clone()); + if recipe.steps.is_empty() { + return Err(anyhow!("recipe {qname} declares no steps")); + } + let mut steps = Vec::with_capacity(recipe.steps.len()); + let mut total_inputs = Vec::new(); + let mut total_outputs = Vec::new(); + for step in &recipe.steps { + let step_name = QualifiedName::parse(step) + .map_err(|err| anyhow!("recipe {qname}: {err}"))?; + if !required.contains(step_name.plugin_name.as_str()) { + return Err(anyhow!( + "recipe {qname} runs {step_name} but does not require plugin {}", + step_name.plugin_name + )); + } + let step_action = + actions_including_hidden.get(&step_name).ok_or_else(|| { + anyhow!("recipe {qname} runs {step_name}, which no plugin provides") + })?; + total_inputs.extend(step_action.total_inputs.iter().cloned()); + total_outputs.extend(step_action.total_outputs.iter().cloned()); + steps.push(step_name); + } + + if let Some(prior) = action_plugin_idx.insert(qname.clone(), plugin_idx) { + return Err(anyhow!( + "duplicate action qualified name {qname} (already mapped to plugin idx {prior})" + )); + } + let summary = ActionSummary { + action: qname.clone(), + emoji: recipe.emoji.clone(), + hash: String::new(), + description: recipe.description.clone(), + total_inputs, + total_outputs, + predicate_source: format!( + "// recipe: one transaction running\n// {}", + recipe.steps.join("\n// ") + ), + }; + actions_including_hidden.insert(qname.clone(), summary.clone()); + all_actions.push(summary); + recipes.insert(qname, RecipeEntry { steps }); + } + } + // Second pass: fill produced_by / consumed_by per class. for class in classes_in_order.iter_mut() { class.produced_by = all_actions @@ -270,6 +421,8 @@ impl PexeCatalog { Ok(Self { plugins: enriched_plugins, + actions_including_hidden, + recipes, actions: all_actions, actions_by_name, action_plugin_idx, @@ -314,21 +467,47 @@ impl ActionCatalog for PexeCatalog { grounding_witness: GroundingWitness, inputs: Vec, ) -> Result { - let plugin_idx = *self - .action_plugin_idx - .get(&action) - .ok_or_else(|| anyhow!("no plugin provides action {action}"))?; - let plugin = &self.plugins[plugin_idx]; let sdk = Sdk::default(); - let module = sdk - .load_module_from_src_manifest(&plugin.script, &plugin.manifest) - .map_err(|err| { - anyhow!( - "failed to reload plugin {} for execution: {err}", - plugin.manifest.plugin.name - ) - })?; - let executor = module.executor(self.mock_proofs, Arc::new(grounding_witness)); + let witness = Arc::new(grounding_witness); + + if let Some(recipe) = self.recipes.get(&action) { + // Inputs arrive in the same order the recipe's `total_inputs` + // concatenated them, so each step takes the next slice. + let mut remaining = inputs.into_iter(); + let mut modules: Vec> = Vec::with_capacity(recipe.steps.len()); + let mut invocations = Vec::with_capacity(recipe.steps.len()); + for step in &recipe.steps { + let module = self.load_module(&sdk, step)?; + let arity = self + .actions_including_hidden + .get(step) + .ok_or_else(|| anyhow!("recipe {action} runs unknown step {step}"))? + .total_inputs + .len(); + let step_inputs: Vec = remaining.by_ref().take(arity).collect(); + if step_inputs.len() != arity { + return Err(anyhow!( + "recipe {action} ran out of inputs at step {step}: it needs {arity} more" + )); + } + modules.push(module.clone()); + invocations.push(sdk::Invocation { + module, + action: step.name.clone(), + inputs: step_inputs, + }); + } + if remaining.next().is_some() { + return Err(anyhow!( + "recipe {action} was given more inputs than its steps consume" + )); + } + let executor = sdk::Executor::with_modules(modules, self.mock_proofs, witness)?; + return Ok(executor.actions(invocations)?); + } + + let module = self.load_module(&sdk, &action)?; + let executor = module.executor(self.mock_proofs, witness); Ok(executor.action(&action.name, inputs)?) } @@ -396,7 +575,7 @@ pub(crate) fn test_plugin_bytes() -> Vec { // Pack the live plugin sources in-memory so tests never touch ~/.dobj/actions. let manifest = include_str!("../../../examples/craft-basics/manifest.toml"); let script = include_str!("../../../examples/craft-basics/plugin.rhai"); - pexe::pack(manifest, script).expect("test plugin packs") + pexe::pack(manifest, Some(script)).expect("test plugin packs") } #[cfg(test)] @@ -595,7 +774,7 @@ description = "consume a Foo to make a Bar" pexe::compile_module_hash(&manifest, script).expect("synthetic script compiles"); let with_hash = pexe::set_manifest_hash(&template, &real_hash).expect("rewrite module_hash"); - pexe::pack(&with_hash, script).expect("pack synthetic plugin") + pexe::pack(&with_hash, Some(script)).expect("pack synthetic plugin") } fn alpha_beta_catalog() -> PexeCatalog { @@ -770,4 +949,330 @@ description = "consume a Foo to make a Bar" let value = obj.get(&pod2::middleware::StrKey::from("type")).ok()??; Some(Hash(value.raw().0)) } + + // --- Recipe fixtures ----------------------------------------------------- + // + // A recipe pexe carries no script. It pins the plugins it composes by + // module hash and lists qualified actions to run as one transaction. + + const CLAIM_SCRIPT: &str = r#" +fn MakeFoo(action) { + var foo = action.output("Foo"); + foo.set([["durability", 100]]); + var key = action.random(); + foo.update("key", key); +} + +fn MakeBar(action) { + var bar = action.output("Bar"); + bar.set([["durability", 100]]); + var key = action.random(); + bar.update("key", key); +} + +fn ClaimFoo(action) { + var foo = action.mutate("Foo"); + var key = action.random(); + foo.update("key", key); +} + +fn ClaimBar(action) { + var bar = action.mutate("Bar"); + var key = action.random(); + bar.update("key", key); +} +"#; + + /// A plugin exposing claim actions, i.e. the extension surface a base + /// plugin has to publish before recipes can compose it. + fn claims_plugin_bytes(plugin_name: &str) -> Vec { + let template = format!( + r#"[plugin] +name = "{plugin_name}" +version = "0.1.0" +module_hash = "0000000000000000000000000000000000000000000000000000000000000000" + +[[classes]] +name = "Foo" +emoji = "F" +description = "test class Foo" + +[[classes]] +name = "Bar" +emoji = "B" +description = "test class Bar" + +[[actions]] +name = "MakeFoo" +emoji = "F" +description = "make a Foo" + +[[actions]] +name = "MakeBar" +emoji = "B" +description = "make a Bar" + +[[actions]] +name = "ClaimFoo" +emoji = "F" +description = "take possession of a Foo" + +[[actions]] +name = "ClaimBar" +emoji = "B" +description = "take possession of a Bar" +"# + ); + let manifest: sdk::manifest::Manifest = + toml::from_str(&template).expect("claims manifest parses"); + let real_hash = + pexe::compile_module_hash(&manifest, CLAIM_SCRIPT).expect("claims script compiles"); + let with_hash = + pexe::set_manifest_hash(&template, &real_hash).expect("rewrite module_hash"); + pexe::pack(&with_hash, Some(CLAIM_SCRIPT)).expect("pack claims plugin") + } + + /// The module hash a recipe must pin to compose `claims_plugin_bytes`. + fn claims_module_hash(plugin_name: &str) -> String { + let bytes = claims_plugin_bytes(plugin_name); + let (manifest, _) = pexe::unpack(&bytes).expect("claims pexe unpacks"); + format!( + "{:#}", + manifest.plugin.module_hash.expect("plugin has a hash") + ) + .trim_start_matches("0x") + .to_string() + } + + fn recipe_test_witness( + state: &payload::test_state::TestState, + input_commitments: &[Hash], + ) -> txlib::GroundingWitness { + state.build_grounding_witness( + input_commitments, + |meta, created_root, nullifiers_root, prior_state_history_root, created_proofs| { + txlib::GroundingWitness::new( + txlib::StateHeader::new( + meta.number as i64, + meta.timestamp as i64, + meta.hash, + created_root, + nullifiers_root, + prior_state_history_root, + ), + created_proofs, + ) + }, + ) + } + + /// The end of the whole chain: a recipe from one pexe consuming and + /// re-keying objects whose classes were defined by another, in a single + /// transaction, driven through the ordinary single-action entry point. + #[test] + fn test_recipe_runs_its_steps_as_one_transaction() { + let catalog = claims_and_recipe_catalog(); + let mut state = payload::test_state::TestState::default(); + + let mut mint = |action: &str| { + let out = catalog + .execute_action( + QualifiedName::new("base", action), + dummy_grounding_witness(), + vec![], + ) + .unwrap_or_else(|err| panic!("base::{action} runs: {err}")); + state.apply_tx( + out.tx.live_commitments().unwrap(), + out.tx.nullifier_hashes().unwrap(), + ); + out.obj(0) + }; + let foo = mint("MakeFoo"); + let bar = mint("MakeBar"); + + let witness = recipe_test_witness(&state, &[foo.obj.commitment(), bar.obj.commitment()]); + let out = catalog + .execute_action( + QualifiedName::new("swap", "SwapFooBar"), + witness, + vec![foo.clone(), bar.clone()], + ) + .expect("recipe runs"); + + // One transaction spent both inputs, so the pair cannot half-land. + let nullifiers = out.tx.nullifier_hashes().unwrap(); + assert_eq!(nullifiers.len(), 2, "both inputs spent by the one tx"); + assert!(nullifiers.contains(&txlib::object_nullifier_hash(&foo.obj).unwrap())); + assert!(nullifiers.contains(&txlib::object_nullifier_hash(&bar.obj).unwrap())); + + let live = out.tx.live_commitments().unwrap(); + assert_eq!(out.objs.len(), 2, "one successor per claimed object"); + for produced in &out.objs { + assert!(live.contains(&produced.obj.commitment())); + } + + // Each successor keeps the class its own plugin defined: a recipe + // cannot mint into a class, only re-key within one. + let foo_type = obj_type_hash_for_test(&foo.obj).unwrap(); + let bar_type = obj_type_hash_for_test(&bar.obj).unwrap(); + assert_eq!(obj_type_hash_for_test(&out.obj(0).obj).unwrap(), foo_type); + assert_eq!(obj_type_hash_for_test(&out.obj(1).obj).unwrap(), bar_type); + + // Re-keying moves every commitment. + assert_ne!(out.obj(0).obj.commitment(), foo.obj.commitment()); + assert_ne!(out.obj(1).obj.commitment(), bar.obj.commitment()); + } + + fn recipe_bytes(recipe_name: &str, requires: &str, module_hash: &str, steps: &str) -> Vec { + let manifest = format!( + r#"[plugin] +name = "{recipe_name}" +version = "0.1.0" + +[[requires]] +plugin = "{requires}" +module_hash = "{module_hash}" + +[[recipes]] +name = "SwapFooBar" +emoji = "S" +description = "re-key one Foo and one Bar in a single transaction" +steps = [{steps}] +"# + ); + pexe::pack(&manifest, None).expect("pack recipe") + } + + fn claims_and_recipe_catalog() -> PexeCatalog { + let hash = claims_module_hash("base"); + PexeCatalog::from_bytes( + [ + (PathBuf::from("base.pexe"), claims_plugin_bytes("base")), + ( + PathBuf::from("swap.pexe"), + recipe_bytes( + "swap", + "base", + &hash, + r#""base::ClaimFoo", "base::ClaimBar""#, + ), + ), + ], + true, + ) + .expect("catalog loads plugin plus recipe") + } + + #[test] + fn test_recipe_surfaces_as_an_action_with_the_steps_inputs() { + let catalog = claims_and_recipe_catalog(); + let recipe = catalog + .get_action(&QualifiedName::new("swap", "SwapFooBar")) + .expect("recipe is a catalog action"); + + // The recipe consumes and produces exactly what its steps do, in + // step order, which is what lets it run through the ordinary + // single-action request path. + let classes = |refs: &[ClassRef]| -> Vec { + refs.iter().map(|r| r.class.name.clone()).collect() + }; + assert_eq!(classes(&recipe.total_inputs), vec!["Foo", "Bar"]); + assert_eq!(classes(&recipe.total_outputs), vec!["Foo", "Bar"]); + // Its classes stay owned by the plugin that declared them. + assert_eq!(recipe.total_inputs[0].class.plugin_name, "base"); + } + + #[test] + fn test_recipe_requiring_a_different_module_hash_is_rejected() { + let wrong = "1".repeat(64); + let result = PexeCatalog::from_bytes( + [ + (PathBuf::from("base.pexe"), claims_plugin_bytes("base")), + ( + PathBuf::from("swap.pexe"), + recipe_bytes("swap", "base", &wrong, r#""base::ClaimFoo""#), + ), + ], + true, + ); + let err = result + .err() + .map(|err| err.to_string()) + .unwrap_or_else(|| panic!("stale pin must be rejected")); + assert!(err.contains("installed at"), "unexpected error: {err}"); + assert!( + err.contains("rebuild the recipe"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_recipe_requiring_a_missing_plugin_is_rejected() { + let hash = claims_module_hash("base"); + let result = PexeCatalog::from_bytes( + std::iter::once(( + PathBuf::from("swap.pexe"), + recipe_bytes("swap", "base", &hash, r#""base::ClaimFoo""#), + )), + true, + ); + let err = result + .err() + .map(|err| err.to_string()) + .unwrap_or_else(|| panic!("missing plugin must be rejected")); + assert!(err.contains("is not installed"), "unexpected error: {err}"); + } + + #[test] + fn test_recipe_step_outside_its_requires_is_rejected() { + let hash = claims_module_hash("base"); + let result = PexeCatalog::from_bytes( + [ + (PathBuf::from("base.pexe"), claims_plugin_bytes("base")), + ( + PathBuf::from("swap.pexe"), + recipe_bytes("swap", "base", &hash, r#""other::ClaimFoo""#), + ), + ], + true, + ); + let err = result + .err() + .map(|err| err.to_string()) + .unwrap_or_else(|| panic!("step outside requires must be rejected")); + assert!( + err.contains("does not require plugin"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_recipe_pexe_with_a_script_is_rejected() { + let hash = claims_module_hash("base"); + let manifest = format!( + r#"[plugin] +name = "swap" +version = "0.1.0" + +[[requires]] +plugin = "base" +module_hash = "{hash}" + +[[recipes]] +name = "SwapFooBar" +emoji = "S" +description = "re-key one Foo and one Bar" +steps = ["base::ClaimFoo"] +"# + ); + let bytes = pexe::pack(&manifest, Some(CLAIM_SCRIPT)).expect("pack"); + let err = pexe::unpack(&bytes) + .expect_err("a recipe carrying a script must be rejected") + .to_string(); + assert!( + err.contains("has no script of its own"), + "unexpected error: {err}" + ); + } } diff --git a/libs/pexe/src/bin/pexe.rs b/libs/pexe/src/bin/pexe.rs index 70476286..609943ed 100644 --- a/libs/pexe/src/bin/pexe.rs +++ b/libs/pexe/src/bin/pexe.rs @@ -5,8 +5,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow}; use clap::{Parser, Subcommand}; use pexe::{ - MANIFEST_FILE, PEXE_EXTENSION, PluginSource, compile_module_hash, inspect, install, pack, - read_pexe_file, set_manifest_hash, unpack, + MANIFEST_FILE, PEXE_EXTENSION, PluginSource, SCRIPT_FILE, compile_module_hash, inspect, + install, pack, read_pexe_file, set_manifest_hash, unpack, }; // These names intentionally mirror `driver::paths::{DOBJ_HOME_DIR, ACTIONS_DIR}`. @@ -247,8 +247,13 @@ fn main() -> Result<()> { let (manifest, script) = unpack(&bytes)?; println!("# manifest"); println!("{:#?}", manifest); - println!("\n# plugin.rhai"); - println!("{}", script); + match script { + Some(script) => { + println!("\n# plugin.rhai"); + println!("{}", script); + } + None => println!("\n# no plugin.rhai (recipe)"), + } } Cmd::Inspect { cmd } => match cmd { InspectCmd::Predicates { @@ -347,35 +352,51 @@ fn build_one( let manifest = source.parse_manifest()?; let plugin_name = manifest.plugin.name.clone(); - // Compile the script to derive the real module hash from the pod2 batch id. - let real_hash = compile_module_hash(&manifest, &source.script)?; - let declared_hash = format!("{:#}", manifest.plugin.module_hash); - let declared_hash = declared_hash.trim_start_matches("0x").to_lowercase(); - let real_hash_clean = real_hash.trim_start_matches("0x").to_lowercase(); - - let manifest_toml = if declared_hash == real_hash_clean { - source.manifest_toml.clone() - } else if check { - return Err(anyhow!( - "module_hash mismatch in {name}: manifest says {declared}, compiled script yields {real} (re-run without --check to rewrite)", - name = plugin_name, - declared = declared_hash, - real = real_hash_clean, - )); + // A recipe has no script to compile and no module hash of its own; its + // pinned hashes name the plugins it composes, checked at catalog load. + let (manifest_toml, hash_label) = if manifest.is_recipe() { + if source.script.is_some() { + return Err(anyhow!( + "{plugin_name} declares recipes and also has a {SCRIPT_FILE}; a recipe composes other plugins' actions and has no script of its own" + )); + } + (source.manifest_toml.clone(), "recipe".to_string()) } else { - log::info!( - " rewriting module_hash in source manifest: {} -> {}", - declared_hash, - real_hash_clean, - ); - let rewritten = set_manifest_hash(&source.manifest_toml, &real_hash_clean)?; - let manifest_path = source.root.join(MANIFEST_FILE); - std::fs::write(&manifest_path, &rewritten) - .with_context(|| format!("failed to write back {}", manifest_path.display()))?; - rewritten + // Compile the script to derive the real module hash from the pod2 batch id. + let real_hash = compile_module_hash(&manifest, source.require_script()?)?; + let declared_hash = manifest + .plugin + .module_hash + .map(|hash| format!("{hash:#}")) + .unwrap_or_default(); + let declared_hash = declared_hash.trim_start_matches("0x").to_lowercase(); + let real_hash_clean = real_hash.trim_start_matches("0x").to_lowercase(); + + let manifest_toml = if declared_hash == real_hash_clean { + source.manifest_toml.clone() + } else if check { + return Err(anyhow!( + "module_hash mismatch in {name}: manifest says {declared}, compiled script yields {real} (re-run without --check to rewrite)", + name = plugin_name, + declared = declared_hash, + real = real_hash_clean, + )); + } else { + log::info!( + " rewriting module_hash in source manifest: {} -> {}", + declared_hash, + real_hash_clean, + ); + let rewritten = set_manifest_hash(&source.manifest_toml, &real_hash_clean)?; + let manifest_path = source.root.join(MANIFEST_FILE); + std::fs::write(&manifest_path, &rewritten) + .with_context(|| format!("failed to write back {}", manifest_path.display()))?; + rewritten + }; + (manifest_toml, real_hash_clean) }; - let bytes = pack(&manifest_toml, &source.script)?; + let bytes = pack(&manifest_toml, source.script.as_deref())?; let out_path = out_dir.join(format!("{plugin_name}.{PEXE_EXTENSION}")); std::fs::write(&out_path, &bytes) .with_context(|| format!("failed to write {}", out_path.display()))?; @@ -383,7 +404,7 @@ fn build_one( " wrote {} ({} bytes, hash={})", out_path.display(), bytes.len(), - real_hash_clean, + hash_label, ); if let Some(dir) = install_dir { diff --git a/libs/pexe/src/fixtures.rs b/libs/pexe/src/fixtures.rs index 041e5df9..044d89dc 100644 --- a/libs/pexe/src/fixtures.rs +++ b/libs/pexe/src/fixtures.rs @@ -162,7 +162,7 @@ mod tests { let manifest = source.parse_manifest().unwrap(); let action_names: Vec<&str> = manifest.actions.iter().map(|a| a.name.as_str()).collect(); Sdk::default() - .load_module_from_src_actions(&source.script, &action_names) + .load_module_from_src_actions(source.require_script().unwrap(), &action_names) .unwrap() } diff --git a/libs/pexe/src/inspect.rs b/libs/pexe/src/inspect.rs index 171052bf..cd9aff8b 100644 --- a/libs/pexe/src/inspect.rs +++ b/libs/pexe/src/inspect.rs @@ -39,14 +39,22 @@ fn txlib_event_hash(name: &str) -> Hash { /// Directories are read via `PluginSource::read`; anything else is /// treated as a `.pexe` archive and unpacked. fn load_target(path: &Path) -> Result<(Manifest, String)> { - if path.is_dir() { + let (manifest, script) = if path.is_dir() { let source = PluginSource::read(path)?; let manifest = source.parse_manifest()?; - Ok((manifest, source.script)) + (manifest, source.script) } else { let bytes = read_pexe_file(path)?; - unpack(&bytes) - } + unpack(&bytes)? + }; + // Recipes hold no predicates, so there is nothing here to render. + let script = script.ok_or_else(|| { + anyhow::anyhow!( + "{} is a recipe: it composes other plugins' actions and has no predicates of its own", + manifest.plugin.name + ) + })?; + Ok((manifest, script)) } /// Compile the plugin script with the manifest's action list and return diff --git a/libs/pexe/src/lib.rs b/libs/pexe/src/lib.rs index 3a7b1e24..c75b344c 100644 --- a/libs/pexe/src/lib.rs +++ b/libs/pexe/src/lib.rs @@ -40,11 +40,12 @@ const MAX_ENTRY_BYTES: u64 = 1024 * 1024; /// forcing large allocations inside `ZipArchive`. const MAX_ENTRIES: usize = 16; -/// Pexe source on disk: a directory containing `manifest.toml` and `plugin.rhai`. +/// Pexe source on disk: a directory containing `manifest.toml`, plus +/// `plugin.rhai` unless it is a recipe. pub struct PluginSource { pub root: PathBuf, pub manifest_toml: String, - pub script: String, + pub script: Option, } impl PluginSource { @@ -54,8 +55,14 @@ impl PluginSource { let script_path = root.join(SCRIPT_FILE); let manifest_toml = std::fs::read_to_string(&manifest_path) .with_context(|| format!("failed to read manifest: {}", manifest_path.display()))?; - let script = std::fs::read_to_string(&script_path) - .with_context(|| format!("failed to read script: {}", script_path.display()))?; + let script = if script_path.exists() { + Some( + std::fs::read_to_string(&script_path) + .with_context(|| format!("failed to read script: {}", script_path.display()))?, + ) + } else { + None + }; Ok(Self { root, manifest_toml, @@ -63,13 +70,25 @@ impl PluginSource { }) } + /// The script, or an error naming the directory when this source is a + /// recipe and the caller needs a script. + pub fn require_script(&self) -> Result<&str> { + self.script.as_deref().ok_or_else(|| { + anyhow!( + "{} has no {SCRIPT_FILE}; a recipe pexe composes other plugins' actions", + self.root.display() + ) + }) + } + pub fn parse_manifest(&self) -> Result { toml::from_str(&self.manifest_toml).map_err(|err| anyhow!("invalid manifest.toml: {err}")) } } -/// Pack a manifest + script into pexe bytes. -pub fn pack(manifest_toml: &str, script: &str) -> Result> { +/// Pack a manifest + script into pexe bytes. Pass `None` for a recipe +/// pexe, which composes other plugins' actions and has no script. +pub fn pack(manifest_toml: &str, script: Option<&str>) -> Result> { let buf = Cursor::new(Vec::::new()); let mut zip = ZipWriter::new(buf); let opts = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); @@ -77,15 +96,18 @@ pub fn pack(manifest_toml: &str, script: &str) -> Result> { zip.start_file(MANIFEST_FILE, opts)?; zip.write_all(manifest_toml.as_bytes())?; - zip.start_file(SCRIPT_FILE, opts)?; - zip.write_all(script.as_bytes())?; + if let Some(script) = script { + zip.start_file(SCRIPT_FILE, opts)?; + zip.write_all(script.as_bytes())?; + } let buf = zip.finish()?; Ok(buf.into_inner()) } -/// Unpack pexe bytes into `(manifest_toml_src, script_src)` without parsing. -pub fn unpack_raw(bytes: &[u8]) -> Result<(String, String)> { +/// Unpack pexe bytes into `(manifest_toml_src, script_src)` without +/// parsing. The script is absent for a recipe pexe. +pub fn unpack_raw(bytes: &[u8]) -> Result<(String, Option)> { let mut zip = ZipArchive::new(Cursor::new(bytes)).map_err(|err| anyhow!("invalid pexe zip: {err}"))?; if zip.len() > MAX_ENTRIES { @@ -95,15 +117,33 @@ pub fn unpack_raw(bytes: &[u8]) -> Result<(String, String)> { ); } let manifest_toml = read_entry(&mut zip, MANIFEST_FILE)?; - let script = read_entry(&mut zip, SCRIPT_FILE)?; + let script = match zip.index_for_name(SCRIPT_FILE) { + Some(_) => Some(read_entry(&mut zip, SCRIPT_FILE)?), + None => None, + }; Ok((manifest_toml, script)) } /// Unpack pexe bytes into a parsed [`Manifest`] and the script source. -pub fn unpack(bytes: &[u8]) -> Result<(Manifest, String)> { +/// +/// A plugin must carry a script and a recipe must not: the two kinds are +/// distinguished by the manifest, and an archive that disagrees with its +/// own manifest is rejected here rather than confusing the catalog. +pub fn unpack(bytes: &[u8]) -> Result<(Manifest, Option)> { let (manifest_toml, script) = unpack_raw(bytes)?; let manifest: Manifest = toml::from_str(&manifest_toml).map_err(|err| anyhow!("invalid manifest.toml: {err}"))?; + match (manifest.is_recipe(), &script) { + (true, Some(_)) => bail!( + "{} declares recipes and also ships a {SCRIPT_FILE}; a recipe composes other plugins' actions and has no script of its own", + manifest.plugin.name + ), + (false, None) => bail!( + "{} ships no {SCRIPT_FILE} and declares no recipes", + manifest.plugin.name + ), + _ => {} + } Ok((manifest, script)) } @@ -206,10 +246,18 @@ module_hash = "0000000000000000000000000000000000000000000000000000000000000000" #[test] fn test_pack_unpack_round_trip() { - let bytes = pack("name = \"x\"", "fn Foo() {}").unwrap(); + let bytes = pack("name = \"x\"", Some("fn Foo() {}")).unwrap(); + let (manifest, script) = unpack_raw(&bytes).unwrap(); + assert!(manifest.contains("name = \"x\"")); + assert_eq!(script.as_deref(), Some("fn Foo() {}")); + } + + #[test] + fn test_pack_unpack_round_trip_without_script() { + let bytes = pack("name = \"x\"", None).unwrap(); let (manifest, script) = unpack_raw(&bytes).unwrap(); assert!(manifest.contains("name = \"x\"")); - assert_eq!(script, "fn Foo() {}"); + assert_eq!(script, None, "a recipe archive carries no script entry"); } fn zip_with_entries(entries: &[(&str, &[u8])]) -> Vec { diff --git a/libs/sdk/src/lib.rs b/libs/sdk/src/lib.rs index 8bc2599d..14e713e2 100644 --- a/libs/sdk/src/lib.rs +++ b/libs/sdk/src/lib.rs @@ -2853,10 +2853,16 @@ impl Sdk { loaded_classes ))?; } - if manifest.plugin.module_hash != sdk_module.module.batch.id() { + let pinned = manifest.plugin.module_hash.ok_or_else(|| { + anyhow!( + "plugin {} declares no module_hash; run `pexe build` to fill it in", + manifest.plugin.name + ) + })?; + if pinned != sdk_module.module.batch.id() { return Err(anyhow!( "manifest.plugin.module_hash = {:#} but module.hash = {:#}", - manifest.plugin.module_hash, + pinned, sdk_module.module.batch.id() ))?; } diff --git a/libs/sdk/src/manifest.rs b/libs/sdk/src/manifest.rs index 8d1c8802..a7a65bef 100644 --- a/libs/sdk/src/manifest.rs +++ b/libs/sdk/src/manifest.rs @@ -4,15 +4,37 @@ use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct Manifest { pub plugin: Plugin, + #[serde(default)] pub classes: Vec, + #[serde(default)] pub actions: Vec, + /// Plugins whose actions this pexe's recipes compose, each pinned to + /// the module hash the recipe was authored against. + #[serde(default)] + pub requires: Vec, + /// Transactions assembled from other plugins' actions. A pexe that + /// declares recipes carries no script and compiles to no module of + /// its own, so it can never define or alter a class. + #[serde(default)] + pub recipes: Vec, +} + +impl Manifest { + /// A recipe pexe composes other plugins' actions instead of shipping + /// a script, so it has no `module_hash` and no classes to declare. + pub fn is_recipe(&self) -> bool { + !self.recipes.is_empty() + } } #[derive(Debug, Deserialize)] pub struct Plugin { pub name: String, pub version: String, - pub module_hash: Hash, + /// Absent on a recipe pexe. `pexe build` fills it in for a plugin by + /// compiling the script. + #[serde(default)] + pub module_hash: Option, } #[derive(Debug, Deserialize)] @@ -31,6 +53,27 @@ pub struct Action { pub hidden: bool, } +/// A pinned dependency on another installed plugin. The hash is checked +/// at catalog load: a required plugin present at a different hash is a +/// different set of classes, so its actions are not the ones the recipe +/// was written against. +#[derive(Debug, Deserialize)] +pub struct Require { + pub plugin: String, + pub module_hash: Hash, +} + +#[derive(Debug, Deserialize)] +pub struct Recipe { + pub name: String, + pub emoji: String, + pub description: String, + /// Qualified action names (`plugin::Action`) run as sibling + /// top-level actions of one transaction, in this order. The recipe's + /// inputs are the steps' inputs concatenated in the same order. + pub steps: Vec, +} + #[cfg(test)] mod tests { use super::*; @@ -41,7 +84,6 @@ mod tests { [plugin] name = "craft-wood-pick" version = "0.1.0" -imports = ["craft-wood", "craft-sticks"] module_hash = "b77a964de74c8569e6c6172692bb50147df9334fd9b572abc8d4d9c688a40e06" [[classes]] @@ -51,18 +93,52 @@ description = "A wood pick that can mine stone while durability remains." [[actions]] name = "CraftWoodPick" -fn_name = "CraftWoodPick" emoji = "โ›๏ธ" description = "Combine wood and a stick to craft a wood pick." [[actions]] name = "UseWoodPick" -fn_name = "UseWoodPick" emoji = "โ›๏ธ" description = "Internal durability/work update for wood pick usage." hidden = true "#; let manifest: Manifest = toml::from_str(toml_str).unwrap(); - println!("{:#?}", manifest); + assert!(!manifest.is_recipe()); + assert!(manifest.plugin.module_hash.is_some()); + assert_eq!(manifest.classes.len(), 1); + assert_eq!(manifest.actions.len(), 2); + assert!(manifest.actions[1].hidden); + } + + #[test] + fn test_recipe_manifest() { + let toml_str = r#" +[plugin] +name = "swap-log-wood" +version = "0.1.0" + +[[requires]] +plugin = "craft-basics" +module_hash = "57631b51fb9a921588d391211f94c0bd8f777aff0a16755bc2dfefb52d6ff5b0" + +[[recipes]] +name = "SwapLogWood" +emoji = "๐Ÿค" +description = "Re-key one Log and one Wood in a single transaction." +steps = ["craft-basics::ClaimLog", "craft-basics::ClaimWood"] + "#; + let manifest: Manifest = toml::from_str(toml_str).unwrap(); + assert!(manifest.is_recipe()); + assert!( + manifest.plugin.module_hash.is_none(), + "a recipe compiles to no module" + ); + assert!(manifest.classes.is_empty()); + assert_eq!(manifest.requires.len(), 1); + assert_eq!(manifest.requires[0].plugin, "craft-basics"); + assert_eq!( + manifest.recipes[0].steps, + vec!["craft-basics::ClaimLog", "craft-basics::ClaimWood"] + ); } } From 8ba72e326a929eb61fe92c052497f81942ac42fd Mon Sep 17 00:00:00 2001 From: Dhvani Patel Date: Thu, 13 Aug 2026 16:25:58 +0530 Subject: [PATCH 05/13] Add claim actions to craft-basics and a swap-log-wood recipe example --- examples/craft-basics/manifest.toml | 32 +++++++++++++++++++- examples/craft-basics/plugin.rhai | 44 ++++++++++++++++++++++++++++ examples/swap-log-wood/manifest.toml | 29 ++++++++++++++++++ libs/driver/src/pexe_catalog.rs | 40 +++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 examples/swap-log-wood/manifest.toml diff --git a/examples/craft-basics/manifest.toml b/examples/craft-basics/manifest.toml index 0211c8b6..b17764af 100644 --- a/examples/craft-basics/manifest.toml +++ b/examples/craft-basics/manifest.toml @@ -2,7 +2,7 @@ name = "craft-basics" version = "0.1.0" # Rewritten by `cargo run -p pexe -- build examples/craft-basics`. -module_hash = "57631b51fb9a921588d391211f94c0bd8f777aff0a16755bc2dfefb52d6ff5b0" +module_hash = "44d6fd33861e3ab021c0362a348243226cfa94345c912676102115a4864cd084" [[classes]] name = "Log" @@ -80,3 +80,33 @@ hidden = true name = "MineStoneWithStonePick" emoji = "๐Ÿชจ" description = "Mine stone using a stone pick (consumes durability)." + +[[actions]] +name = "ClaimLog" +emoji = "๐Ÿชง" +description = "Take exclusive possession of a received log by rotating its key." + +[[actions]] +name = "ClaimWood" +emoji = "๐Ÿชง" +description = "Take exclusive possession of a received wood by rotating its key." + +[[actions]] +name = "ClaimStick" +emoji = "๐Ÿชง" +description = "Take exclusive possession of a received stick by rotating its key." + +[[actions]] +name = "ClaimStone" +emoji = "๐Ÿชง" +description = "Take exclusive possession of a received stone by rotating its key." + +[[actions]] +name = "ClaimWoodPick" +emoji = "๐Ÿชง" +description = "Take exclusive possession of a received wood pick by rotating its key." + +[[actions]] +name = "ClaimStonePick" +emoji = "๐Ÿชง" +description = "Take exclusive possession of a received stone pick by rotating its key." diff --git a/examples/craft-basics/plugin.rhai b/examples/craft-basics/plugin.rhai index 649454bd..3f1eceb2 100644 --- a/examples/craft-basics/plugin.rhai +++ b/examples/craft-basics/plugin.rhai @@ -70,3 +70,47 @@ fn MineStoneWithStonePick(action) { var pick = action.subaction("UseStonePick"); var stone = action.output("Stone"); } + +// โ”€โ”€ claims โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Rotating `key` moves an object's commitment and its nullifier, so a holder +// who was sent an object can spend the sender's copy out from under them. +// Nothing else about the state changes, and the stable identifier carries +// across, so the object stays the same object under new custody. +// +// These are also the surface a recipe pexe composes: a recipe can only run +// actions this plugin already exposes. + +fn claim(action, obj) { + var key = action.random(); + obj.update("key", key); +} + +fn ClaimLog(action) { + var log = action.mutate("Log"); + claim(action, log); +} + +fn ClaimWood(action) { + var wood = action.mutate("Wood"); + claim(action, wood); +} + +fn ClaimStick(action) { + var stick = action.mutate("Stick"); + claim(action, stick); +} + +fn ClaimStone(action) { + var stone = action.mutate("Stone"); + claim(action, stone); +} + +fn ClaimWoodPick(action) { + var pick = action.mutate("WoodPick"); + claim(action, pick); +} + +fn ClaimStonePick(action) { + var pick = action.mutate("StonePick"); + claim(action, pick); +} diff --git a/examples/swap-log-wood/manifest.toml b/examples/swap-log-wood/manifest.toml new file mode 100644 index 00000000..703e4e16 --- /dev/null +++ b/examples/swap-log-wood/manifest.toml @@ -0,0 +1,29 @@ +# A recipe pexe: it ships no plugin.rhai and compiles to no module, so it +# defines no classes and cannot change any. All it does is name actions that +# another plugin already exposes and run them as one transaction. +# +# Because the steps are craft-basics actions, each object's own class guard +# authorizes its change -- which is what lets this live in a separate pexe +# that anyone can write and install without touching craft-basics. + +[plugin] +name = "swap-log-wood" +version = "0.1.0" + +# Pinned by module hash, not by name. A craft-basics installed at any other +# hash is a different set of classes, so the catalog refuses to load this +# recipe against it rather than running the wrong actions. Re-pin with: +# cargo run -p pexe --release -- build examples/craft-basics +# then copy the hash it prints. +[[requires]] +plugin = "craft-basics" +module_hash = "44d6fd33861e3ab021c0362a348243226cfa94345c912676102115a4864cd084" + +# Inputs are the steps' inputs concatenated in step order, so this action +# takes one Log then one Wood. Both are re-keyed in a single transaction: +# either both land or neither does. +[[recipes]] +name = "SwapLogWood" +emoji = "๐Ÿค" +description = "Re-key one log and one wood together, in a single transaction." +steps = ["craft-basics::ClaimLog", "craft-basics::ClaimWood"] diff --git a/libs/driver/src/pexe_catalog.rs b/libs/driver/src/pexe_catalog.rs index 223498d1..762cad9c 100644 --- a/libs/driver/src/pexe_catalog.rs +++ b/libs/driver/src/pexe_catalog.rs @@ -578,6 +578,13 @@ pub(crate) fn test_plugin_bytes() -> Vec { pexe::pack(manifest, Some(script)).expect("test plugin packs") } +#[cfg(test)] +/// The bundled recipe example, packed from source like the plugin above. +pub(crate) fn bundled_recipe_bytes() -> Vec { + let manifest = include_str!("../../../examples/swap-log-wood/manifest.toml"); + pexe::pack(manifest, None).expect("bundled recipe packs") +} + #[cfg(test)] mod tests { use super::*; @@ -950,6 +957,39 @@ description = "consume a Foo to make a Bar" Some(Hash(value.raw().0)) } + /// The bundled recipe must stay loadable against the bundled + /// craft-basics. `pexe build` re-pins a plugin's own `module_hash` but + /// never a recipe's `[[requires]]`, so any change to craft-basics + /// silently staleness this pin until something checks it. + #[test] + fn test_bundled_recipe_matches_bundled_plugin() { + let catalog = PexeCatalog::from_bytes( + [ + (PathBuf::from("craft-basics.pexe"), test_plugin_bytes()), + (PathBuf::from("swap-log-wood.pexe"), bundled_recipe_bytes()), + ], + true, + ) + .expect("bundled recipe loads against bundled craft-basics -- if this fails, re-pin examples/swap-log-wood/manifest.toml to the hash `pexe build examples/craft-basics` prints"); + + let recipe = catalog + .get_action(&QualifiedName::new("swap-log-wood", "SwapLogWood")) + .expect("bundled recipe is a catalog action"); + let classes: Vec<&str> = recipe + .total_inputs + .iter() + .map(|r| r.class.name.as_str()) + .collect(); + assert_eq!(classes, vec!["Log", "Wood"]); + assert!( + recipe + .total_inputs + .iter() + .all(|r| r.class.plugin_name == "craft-basics"), + "the recipe consumes craft-basics classes, not its own" + ); + } + // --- Recipe fixtures ----------------------------------------------------- // // A recipe pexe carries no script. It pins the plugins it composes by From 00ecc8ced9fe8f4b4ff7f25aee97cb69df5ea126 Mon Sep 17 00:00:00 2001 From: Dhvani Patel Date: Thu, 13 Aug 2026 16:36:37 +0530 Subject: [PATCH 06/13] Let a recipe pexe carry its own script and mint its own classes --- examples/swap-log-wood/manifest.toml | 40 +++-- examples/swap-log-wood/plugin.rhai | 15 ++ libs/driver/src/pexe_catalog.rs | 226 ++++++++++++++++++++++++--- libs/pexe/src/bin/pexe.rs | 16 +- libs/pexe/src/lib.rs | 18 +-- libs/sdk/src/manifest.rs | 6 +- 6 files changed, 265 insertions(+), 56 deletions(-) create mode 100644 examples/swap-log-wood/plugin.rhai diff --git a/examples/swap-log-wood/manifest.toml b/examples/swap-log-wood/manifest.toml index 703e4e16..430288a0 100644 --- a/examples/swap-log-wood/manifest.toml +++ b/examples/swap-log-wood/manifest.toml @@ -1,14 +1,19 @@ -# A recipe pexe: it ships no plugin.rhai and compiles to no module, so it -# defines no classes and cannot change any. All it does is name actions that -# another plugin already exposes and run them as one transaction. +# A pexe that both declares its own class and composes another plugin's +# actions. # -# Because the steps are craft-basics actions, each object's own class guard -# authorizes its change -- which is what lets this live in a separate pexe -# that anyone can write and install without touching craft-basics. +# The recipe below runs three actions as siblings of one transaction: two +# craft-basics claims, and one action from this plugin's own script. A script +# here could never re-key a craft-basics Log directly -- `action.mutate("Log")` +# would mean *this* plugin's Log, since a class is the OR over the actions of +# the script defining it. Naming craft-basics' own actions as steps is what +# lets their guards match, and the receipt is minted alongside because +# `Swapped` is a class this plugin owns. [plugin] name = "swap-log-wood" version = "0.1.0" +# Rewritten by `cargo run -p pexe -- build examples/swap-log-wood`. +module_hash = "30275cba84f1a6d8edb13ce093b27891c253a41f37f751518f5af7368b714678" # Pinned by module hash, not by name. A craft-basics installed at any other # hash is a different set of classes, so the catalog refuses to load this @@ -19,11 +24,26 @@ version = "0.1.0" plugin = "craft-basics" module_hash = "44d6fd33861e3ab021c0362a348243226cfa94345c912676102115a4864cd084" +[[classes]] +name = "Swapped" +emoji = "๐Ÿงพ" +description = "A receipt minted by a swap, stamped with the grounding block's timestamp." + +[[actions]] +name = "MintSwapped" +emoji = "๐Ÿงพ" +description = "Mint a swap receipt." +hidden = true + # Inputs are the steps' inputs concatenated in step order, so this action -# takes one Log then one Wood. Both are re-keyed in a single transaction: -# either both land or neither does. +# takes one Log then one Wood -- MintSwapped consumes nothing. All three +# land in a single transaction: either every part happens or none does. [[recipes]] name = "SwapLogWood" emoji = "๐Ÿค" -description = "Re-key one log and one wood together, in a single transaction." -steps = ["craft-basics::ClaimLog", "craft-basics::ClaimWood"] +description = "Re-key one log and one wood together and mint a receipt, in a single transaction." +steps = [ + "craft-basics::ClaimLog", + "craft-basics::ClaimWood", + "swap-log-wood::MintSwapped", +] diff --git a/examples/swap-log-wood/plugin.rhai b/examples/swap-log-wood/plugin.rhai new file mode 100644 index 00000000..fa034ea6 --- /dev/null +++ b/examples/swap-log-wood/plugin.rhai @@ -0,0 +1,15 @@ +// swap-log-wood's own class. A script can only ever declare and touch its +// own classes -- `action.output("Swapped")` means *this* plugin's Swapped, +// because a class is the OR over the actions of the script that defines it. +// That is exactly why the log and wood halves of the swap are craft-basics +// actions named in the recipe rather than code written here. +// +// This action runs as a third sibling in the same transaction, so the receipt +// lands if and only if both claims do. + +fn MintSwapped(action) { + var receipt = action.output("Swapped"); + receipt.set([ + ["swapped_at", state_header.block_timestamp] + ]); +} diff --git a/libs/driver/src/pexe_catalog.rs b/libs/driver/src/pexe_catalog.rs index 762cad9c..3c83f935 100644 --- a/libs/driver/src/pexe_catalog.rs +++ b/libs/driver/src/pexe_catalog.rs @@ -35,8 +35,8 @@ struct Plugin { #[allow(dead_code)] path: PathBuf, manifest: Manifest, - /// Absent for a recipe pexe, which composes other plugins' actions - /// and compiles to no module of its own. + /// Absent for a pexe that only carries recipes, which compiles to no + /// module of its own. A pexe may carry both. script: Option, } @@ -146,15 +146,18 @@ impl PexeCatalog { for plugin in plugins { let plugin_name = plugin.manifest.plugin.name.clone(); - // Recipes contribute no classes or predicates, so they are - // resolved after every plugin is loaded and their steps exist. - if plugin.manifest.is_recipe() { + // A pexe with no script contributes no classes or predicates. + // Recipes are resolved in a later pass either way, once every + // plugin is loaded and their steps exist. + let Some(script) = plugin.script.as_deref() else { + if !plugin.manifest.is_recipe() { + return Err(anyhow!( + "plugin {plugin_name} has no script and declares no recipes" + )); + } enriched_plugins.push(plugin); continue; - } - let script = plugin.script.as_deref().ok_or_else(|| { - anyhow!("plugin {plugin_name} has no script and declares no recipes") - })?; + }; let module = sdk .load_module_from_src_manifest(script, &plugin.manifest) .map_err(|err| anyhow!("failed to load plugin {plugin_name}: {err}"))?; @@ -302,7 +305,10 @@ impl PexeCatalog { // A required plugin present at a different hash is a different // set of classes, so its actions are not the ones this recipe // was written against. - let mut required: HashSet<&str> = HashSet::new(); + // A recipe may also run its own plugin's actions, so a composed + // transaction can produce objects of the recipe author's own + // classes alongside the ones it claims. + let mut required: HashSet<&str> = HashSet::from([plugin_name.as_str()]); for require in &plugin.manifest.requires { required.insert(require.plugin.as_str()); match installed_hashes.get(require.plugin.as_str()) { @@ -579,10 +585,12 @@ pub(crate) fn test_plugin_bytes() -> Vec { } #[cfg(test)] -/// The bundled recipe example, packed from source like the plugin above. +/// The bundled recipe example, packed from source like the plugin above. It +/// carries a script of its own for the receipt class it mints. pub(crate) fn bundled_recipe_bytes() -> Vec { let manifest = include_str!("../../../examples/swap-log-wood/manifest.toml"); - pexe::pack(manifest, None).expect("bundled recipe packs") + let script = include_str!("../../../examples/swap-log-wood/plugin.rhai"); + pexe::pack(manifest, Some(script)).expect("bundled recipe packs") } #[cfg(test)] @@ -990,6 +998,48 @@ description = "consume a Foo to make a Bar" ); } + /// A recipe may run its own plugin's actions alongside another's, which + /// is how a composed transaction also produces objects of classes the + /// recipe author owns. The bundled example does exactly this. + #[test] + fn test_bundled_recipe_mints_its_own_class() { + let catalog = PexeCatalog::from_bytes( + [ + (PathBuf::from("craft-basics.pexe"), test_plugin_bytes()), + (PathBuf::from("swap-log-wood.pexe"), bundled_recipe_bytes()), + ], + true, + ) + .expect("bundled recipe loads"); + + let recipe = catalog + .get_action(&QualifiedName::new("swap-log-wood", "SwapLogWood")) + .expect("bundled recipe is a catalog action"); + + // Consumes craft-basics classes; produces those plus its own receipt. + let names = + |refs: &[ClassRef]| -> Vec { refs.iter().map(|r| r.class.id()).collect() }; + assert_eq!( + names(&recipe.total_inputs), + vec!["craft-basics::Log", "craft-basics::Wood"] + ); + assert_eq!( + names(&recipe.total_outputs), + vec![ + "craft-basics::Log", + "craft-basics::Wood", + "swap-log-wood::Swapped" + ] + ); + + // The receipt's class belongs to the recipe's own module, so its + // guard is the recipe plugin's own IsSwapped. + let swapped = catalog + .get_class(&QualifiedName::new("swap-log-wood", "Swapped")) + .expect("the recipe plugin declares Swapped"); + assert_eq!(recipe.total_outputs[2].hash, swapped.hash); + } + // --- Recipe fixtures ----------------------------------------------------- // // A recipe pexe carries no script. It pins the plugins it composes by @@ -1164,6 +1214,82 @@ description = "take possession of a Bar" assert_ne!(out.obj(1).obj.commitment(), bar.obj.commitment()); } + /// Three actions, two batches, one transaction: the bundled recipe + /// re-keys a craft-basics log and wood and mints its own receipt. + #[test] + fn test_bundled_recipe_executes_across_two_batches() { + let catalog = PexeCatalog::from_bytes( + [ + (PathBuf::from("craft-basics.pexe"), test_plugin_bytes()), + (PathBuf::from("swap-log-wood.pexe"), bundled_recipe_bytes()), + ], + true, + ) + .expect("bundled recipe loads"); + let mut state = payload::test_state::TestState::default(); + + // FindLog twice, then turn one log into wood, to hold both at once. + let mut run = |action: QualifiedName, inputs: Vec| { + let commitments: Vec = inputs.iter().map(|i| i.obj.commitment()).collect(); + let witness = recipe_test_witness(&state, &commitments); + let out = catalog + .execute_action(action.clone(), witness, inputs) + .unwrap_or_else(|err| panic!("{action} runs: {err}")); + state.apply_tx( + out.tx.live_commitments().unwrap(), + out.tx.nullifier_hashes().unwrap(), + ); + out + }; + let log = run(QualifiedName::new("craft-basics", "FindLog"), vec![]).obj(0); + let spare = run(QualifiedName::new("craft-basics", "FindLog"), vec![]).obj(0); + let wood = run(QualifiedName::new("craft-basics", "CraftWood"), vec![spare]).obj(0); + + let out = run( + QualifiedName::new("swap-log-wood", "SwapLogWood"), + vec![log.clone(), wood.clone()], + ); + + // Both claims spent their input; the receipt consumed nothing. + let nullifiers = out.tx.nullifier_hashes().unwrap(); + assert_eq!(nullifiers.len(), 2); + assert!(nullifiers.contains(&txlib::object_nullifier_hash(&log.obj).unwrap())); + assert!(nullifiers.contains(&txlib::object_nullifier_hash(&wood.obj).unwrap())); + + // Log, wood, receipt -- all three live off one transaction. + assert_eq!(out.objs.len(), 3); + let live = out.tx.live_commitments().unwrap(); + for produced in &out.objs { + assert!(live.contains(&produced.obj.commitment())); + } + + // The claimed objects keep craft-basics' classes; the receipt carries + // the recipe plugin's own. + assert_eq!( + obj_type_hash_for_test(&out.obj(0).obj).unwrap(), + obj_type_hash_for_test(&log.obj).unwrap() + ); + assert_eq!( + obj_type_hash_for_test(&out.obj(1).obj).unwrap(), + obj_type_hash_for_test(&wood.obj).unwrap() + ); + let swapped = catalog + .get_class(&QualifiedName::new("swap-log-wood", "Swapped")) + .expect("Swapped class present"); + assert_eq!( + obj_type_hash_for_test(&out.obj(2).obj).unwrap(), + decode_hash_hex(&swapped.hash).unwrap() + ); + assert!( + out.obj(2) + .obj + .get(&pod2::middleware::StrKey::from("swapped_at")) + .unwrap() + .is_some(), + "the receipt records the grounding block timestamp" + ); + } + fn recipe_bytes(recipe_name: &str, requires: &str, module_hash: &str, steps: &str) -> Vec { let manifest = format!( r#"[plugin] @@ -1288,31 +1414,85 @@ steps = [{steps}] } #[test] - fn test_recipe_pexe_with_a_script_is_rejected() { + fn test_pexe_with_neither_script_nor_recipes_is_rejected() { + let manifest = r#"[plugin] +name = "empty" +version = "0.1.0" +"#; + let bytes = pexe::pack(manifest, None).expect("pack"); + let err = pexe::unpack(&bytes) + .expect_err("an archive that does nothing must be rejected") + .to_string(); + assert!( + err.contains("declares no recipes"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_recipe_may_carry_its_own_script() { let hash = claims_module_hash("base"); - let manifest = format!( + // A recipe pexe that also declares a class of its own, and runs its + // own action as a third step alongside the two it claims. + let script = r#" +fn MintReceipt(action) { + var receipt = action.output("Receipt"); + var key = action.random(); + receipt.update("key", key); +} +"#; + let template = format!( r#"[plugin] name = "swap" version = "0.1.0" +module_hash = "0000000000000000000000000000000000000000000000000000000000000000" [[requires]] plugin = "base" module_hash = "{hash}" +[[classes]] +name = "Receipt" +emoji = "R" +description = "a swap receipt" + +[[actions]] +name = "MintReceipt" +emoji = "R" +description = "mint a receipt" +hidden = true + [[recipes]] name = "SwapFooBar" emoji = "S" -description = "re-key one Foo and one Bar" -steps = ["base::ClaimFoo"] +description = "re-key a Foo and a Bar and mint a receipt" +steps = ["base::ClaimFoo", "base::ClaimBar", "swap::MintReceipt"] "# ); - let bytes = pexe::pack(&manifest, Some(CLAIM_SCRIPT)).expect("pack"); - let err = pexe::unpack(&bytes) - .expect_err("a recipe carrying a script must be rejected") - .to_string(); - assert!( - err.contains("has no script of its own"), - "unexpected error: {err}" + let manifest: sdk::manifest::Manifest = toml::from_str(&template).expect("manifest parses"); + let real_hash = + pexe::compile_module_hash(&manifest, script).expect("recipe script compiles"); + let with_hash = + pexe::set_manifest_hash(&template, &real_hash).expect("rewrite module_hash"); + let bytes = pexe::pack(&with_hash, Some(script)).expect("pack"); + + let catalog = PexeCatalog::from_bytes( + [ + (PathBuf::from("base.pexe"), claims_plugin_bytes("base")), + (PathBuf::from("swap.pexe"), bytes), + ], + true, + ) + .expect("a recipe pexe may carry its own script"); + + let recipe = catalog + .get_action(&QualifiedName::new("swap", "SwapFooBar")) + .expect("recipe present"); + let outputs: Vec = recipe.total_outputs.iter().map(|r| r.class.id()).collect(); + assert_eq!( + outputs, + vec!["base::Foo", "base::Bar", "swap::Receipt"], + "the recipe produces its own class alongside the claimed ones" ); } } diff --git a/libs/pexe/src/bin/pexe.rs b/libs/pexe/src/bin/pexe.rs index 609943ed..f93b6c47 100644 --- a/libs/pexe/src/bin/pexe.rs +++ b/libs/pexe/src/bin/pexe.rs @@ -5,8 +5,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow}; use clap::{Parser, Subcommand}; use pexe::{ - MANIFEST_FILE, PEXE_EXTENSION, PluginSource, SCRIPT_FILE, compile_module_hash, inspect, - install, pack, read_pexe_file, set_manifest_hash, unpack, + MANIFEST_FILE, PEXE_EXTENSION, PluginSource, compile_module_hash, inspect, install, pack, + read_pexe_file, set_manifest_hash, unpack, }; // These names intentionally mirror `driver::paths::{DOBJ_HOME_DIR, ACTIONS_DIR}`. @@ -352,14 +352,10 @@ fn build_one( let manifest = source.parse_manifest()?; let plugin_name = manifest.plugin.name.clone(); - // A recipe has no script to compile and no module hash of its own; its - // pinned hashes name the plugins it composes, checked at catalog load. - let (manifest_toml, hash_label) = if manifest.is_recipe() { - if source.script.is_some() { - return Err(anyhow!( - "{plugin_name} declares recipes and also has a {SCRIPT_FILE}; a recipe composes other plugins' actions and has no script of its own" - )); - } + // A recipe-only pexe has no script to compile and no module hash of its + // own; its pinned hashes name the plugins it composes, checked at + // catalog load. A pexe carrying both is compiled like any plugin. + let (manifest_toml, hash_label) = if source.script.is_none() { (source.manifest_toml.clone(), "recipe".to_string()) } else { // Compile the script to derive the real module hash from the pod2 batch id. diff --git a/libs/pexe/src/lib.rs b/libs/pexe/src/lib.rs index c75b344c..7c790055 100644 --- a/libs/pexe/src/lib.rs +++ b/libs/pexe/src/lib.rs @@ -126,23 +126,19 @@ pub fn unpack_raw(bytes: &[u8]) -> Result<(String, Option)> { /// Unpack pexe bytes into a parsed [`Manifest`] and the script source. /// -/// A plugin must carry a script and a recipe must not: the two kinds are -/// distinguished by the manifest, and an archive that disagrees with its -/// own manifest is rejected here rather than confusing the catalog. +/// A pexe may carry a script, recipes, or both: a recipe can name its own +/// plugin's actions alongside another plugin's, which is how a composed +/// transaction also produces objects of the recipe author's own classes. +/// Carrying neither is what makes an archive useless, so that is rejected. pub fn unpack(bytes: &[u8]) -> Result<(Manifest, Option)> { let (manifest_toml, script) = unpack_raw(bytes)?; let manifest: Manifest = toml::from_str(&manifest_toml).map_err(|err| anyhow!("invalid manifest.toml: {err}"))?; - match (manifest.is_recipe(), &script) { - (true, Some(_)) => bail!( - "{} declares recipes and also ships a {SCRIPT_FILE}; a recipe composes other plugins' actions and has no script of its own", - manifest.plugin.name - ), - (false, None) => bail!( + if script.is_none() && !manifest.is_recipe() { + bail!( "{} ships no {SCRIPT_FILE} and declares no recipes", manifest.plugin.name - ), - _ => {} + ); } Ok((manifest, script)) } diff --git a/libs/sdk/src/manifest.rs b/libs/sdk/src/manifest.rs index a7a65bef..6bf3a95e 100644 --- a/libs/sdk/src/manifest.rs +++ b/libs/sdk/src/manifest.rs @@ -20,8 +20,10 @@ pub struct Manifest { } impl Manifest { - /// A recipe pexe composes other plugins' actions instead of shipping - /// a script, so it has no `module_hash` and no classes to declare. + /// Whether this pexe declares any recipes. A pexe may carry recipes + /// with or without a script of its own: a recipe can name its own + /// plugin's actions alongside another plugin's, so a composed + /// transaction can also produce objects of classes it declares here. pub fn is_recipe(&self) -> bool { !self.recipes.is_empty() } From 457151ee7a6c2c85d6e3ee00ccf37b469b60f2c3 Mon Sep 17 00:00:00 2001 From: Dhvani Patel Date: Thu, 13 Aug 2026 17:19:01 +0530 Subject: [PATCH 07/13] Prove a plugin batch can import another and call its action as a sub-action --- libs/txlib/src/lib.rs | 116 ++++++++++++++++++ libs/txlib/src/predicates/import_test.podlang | 34 +++++ libs/txlib/src/predicates/mod.rs | 28 +++++ 3 files changed, 178 insertions(+) create mode 100644 libs/txlib/src/predicates/import_test.podlang diff --git a/libs/txlib/src/lib.rs b/libs/txlib/src/lib.rs index 8ecc346c..17a5f441 100644 --- a/libs/txlib/src/lib.rs +++ b/libs/txlib/src/lib.rs @@ -1815,6 +1815,122 @@ mod tests { } } + /// A plugin batch importing another and calling its action as a + /// sub-action clause, rather than sitting beside it as a sibling + /// top-level action. The parent predicate spans both halves, so the + /// receipt it mints could be constrained against the claimed object. + #[test] + fn test_imported_batch_action_as_subaction() { + let events = Arc::new(crate::predicates::events_module()); + let txlib = Arc::new(crate::predicates::module()); + let swap = Arc::new(crate::predicates::swap_test_module()); + let imp = Arc::new(crate::predicates::import_test_module()); + + let is_gem = + Value::from(Predicate::Custom(swap.predicate_ref_by_name("IsGem").unwrap()).hash()); + let is_receipt = + Value::from(Predicate::Custom(imp.predicate_ref_by_name("IsReceipt").unwrap()).hash()); + let modules = vec![events, txlib, swap, imp]; + + let params = Params::default(); + let vd_set = VDSet::new(&[]); + let mut state = TestState::empty(0); + let gem = with_stable_identifier(&make_object(is_gem, &[])); + state.seed(&gem); + + let builder = MultiPodBuilder::new(¶ms, &vd_set); + let mut ctx = BuildContext { builder, modules }; + let inputs = vec![gem.clone()]; + let witness = state.grounding_witness(&inputs); + let mut tx = TxBuilder::new(&mut ctx, &inputs, witness); + + let scope_outer = tx.begin_action(); + + // Nested action from the imported batch. Its own scope is what makes + // the gem's guard range match the ClaimGem statement. + let new_key = Value::from(rand_raw_value()); + let mut gem_new = gem.clone(); + gem_new.update(&StrKey::from("key"), &new_key).unwrap(); + let st_claim = { + let scope_sub = tx.begin_action(); + let (st_mutate, h_sub) = tx.mutate(&mut ctx, &gem_new, &gem); + let op_du = ctx + .builder + .priv_op(op!(DictUpdate(gem, "key", new_key, gem_new))) + .unwrap(); + let st_claim = ctx + .apply_custom_pred_simple(false, "ClaimGem", vec![op_du, st_mutate]) + .unwrap(); + let st_guard = ctx + .apply_custom_pred( + false, + "IsGem", + map!({"state_header" => state.state_header().array()}), + vec![Statement::None, st_claim.clone()], + ) + .unwrap(); + tx.set_guard(h_sub, st_guard); + tx.end_action(scope_sub); + st_claim + }; + + // Direct event in the parent scope: mint this batch's own class, + // pinned to the object the nested action just claimed. + let claimed_id = gem_new + .get(&StrKey::from(STABLE_IDENTIFIER_FIELD)) + .unwrap() + .unwrap(); + let receipt_initial = make_object(is_receipt, &[("gem", claimed_id.clone())]); + let (receipt, st_insert, h) = tx.insert(&mut ctx, &receipt_initial); + let op_binds = ctx + .builder + .priv_op(op!(Equal( + (&receipt_initial, "gem"), + (&gem_new, STABLE_IDENTIFIER_FIELD) + ))) + .unwrap(); + let st_parent = ctx + .apply_custom_pred_simple( + false, + "ClaimAndReceipt", + vec![st_claim, op_binds, st_insert], + ) + .unwrap(); + let st_guard = ctx + .apply_custom_pred( + false, + "IsReceipt", + map!({"state_header" => state.state_header().array()}), + vec![st_parent], + ) + .unwrap(); + tx.set_guard(h, st_guard); + tx.end_action(scope_outer); + + eprintln!("{tx}"); + let (st, tx_out, stats) = tx.finalize(&mut ctx); + print_stats(&stats); + ctx.builder.reveal(&st).unwrap(); + solve_and_verify(ctx.builder); + + assert!( + tx_out + .nullifiers + .contains(&Value::from(compute_nullifier(&gem))) + .unwrap() + ); + for live in [&gem_new, &receipt] { + assert!(tx_out.live.contains(&Value::from(live.clone())).unwrap()); + } + + // The receipt names the object it was minted for, which is the thing + // a transaction-level composition cannot prove. + assert_eq!( + receipt.get(&StrKey::from("gem")).unwrap().unwrap(), + claimed_id + ); + } + /// One transaction, two top-level actions, two different plugin /// batches: `UseWoodPick` from the crafting batch and `ClaimGem` /// from a second batch that neither txlib nor the crafting batch diff --git a/libs/txlib/src/predicates/import_test.podlang b/libs/txlib/src/predicates/import_test.podlang new file mode 100644 index 00000000..a3b35e2f --- /dev/null +++ b/libs/txlib/src/predicates/import_test.podlang @@ -0,0 +1,34 @@ +/* + A third test batch that IMPORTS the swap_test batch and calls its + predicate directly, rather than naming it as a separate top-level + action of the same transaction. + + This is the alternative to composing at the transaction level: here one + action's predicate contains another batch's action as a sub-action + clause, so the parent spans both and can constrain them against each + other. The `Receipt` class belongs to this module. +*/ + +use module 0xTX_EVENTS_MODULE_HASH as tx +use module 0xSWAP_MODULE_HASH as gem + +// TODO: Support importing records via `use module` +record StateHeader = (block_number, block_timestamp, block_hash, created, nullifiers, prior_state_history) + +// Claim someone else's Gem and mint a Receipt of our own, in one action. +// `gem::ClaimGem` occupies the range (chain_start, h0) as a nested +// action, and the receipt insert closes out the parent's range. +// The `Equal` clause is the payoff of importing rather than composing at +// the transaction level: `gem_obj` is in scope here, so the receipt can be +// pinned to the very object that was claimed. Sibling top-level actions +// cannot see each other's objects and so cannot express this. +ClaimAndReceipt(gem_obj, receipt, chain_start, chain_end, + private: h0, receipt0) = AND( + gem::ClaimGem(gem_obj, chain_start, h0) + Equal(receipt0.gem, gem_obj.stable_identifier) + tx::TxInsert(h0, chain_end, receipt0, receipt, @self_predicate(IsReceipt)) +) + +IsReceipt(obj, state_header StateHeader, chain_start, chain_end, private: other) = OR( + ClaimAndReceipt(other, obj, chain_start, chain_end) +) diff --git a/libs/txlib/src/predicates/mod.rs b/libs/txlib/src/predicates/mod.rs index eb836c63..69bd657c 100644 --- a/libs/txlib/src/predicates/mod.rs +++ b/libs/txlib/src/predicates/mod.rs @@ -27,6 +27,23 @@ pub fn swap_test_module() -> lang::Module { load_module(&source, "swap", ¶ms, &[events]).expect("swap_test.podlang compiles") } +#[cfg(test)] +/// Load a batch that imports [`swap_test_module`] and calls its action as a +/// sub-action clause, to check whether a plugin batch can depend on +/// another rather than only sit beside it in a transaction. +pub fn import_test_module() -> lang::Module { + let params = pod2::middleware::Params::default(); + let events = Arc::new(events_module()); + let swap = Arc::new(swap_test_module()); + let source = include_str!("import_test.podlang") + .replace( + TX_EVENTS_HASH_PLACEHOLDER, + &format!("{:#}", events.batch.id()), + ) + .replace("0xSWAP_MODULE_HASH", &format!("{:#}", swap.batch.id())); + load_module(&source, "imp", ¶ms, &[events, swap]).expect("import_test.podlang compiles") +} + /// The chain-primitive event predicates (TxInsert/TxMutate/TxDelete). /// Kept in their own batch so action predicates and recorded /// transactions keep stable hashes across edits to the replay and @@ -52,6 +69,17 @@ mod tests { use super::*; + // A batch may import another batch and call its predicates. Note the + // consequence: the call embeds the imported batch's id in this batch's + // statement templates, so this module's id -- and every class hash in it + // -- moves whenever the imported plugin changes. + #[test] + fn test_import_test_predicates_exist() { + let module = import_test_module(); + module.predicate_ref_by_name("ClaimAndReceipt").unwrap(); + module.predicate_ref_by_name("IsReceipt").unwrap(); + } + #[test] fn test_crafting_predicates_exist() { let module = crafting_test_module(); From 7c27863c51847f7fc397cfaa6b36693db1fd594d Mon Sep 17 00:00:00 2001 From: Dhvani Patel Date: Thu, 13 Aug 2026 17:39:36 +0530 Subject: [PATCH 08/13] Let a plugin script call another plugin's action by qualified name --- examples/swap-log-wood/manifest.toml | 39 +- examples/swap-log-wood/plugin.rhai | 25 +- libs/driver/src/pexe_catalog.rs | 815 ++++++--------------------- libs/pexe/src/bin/pexe.rs | 86 ++- libs/pexe/src/fixtures.rs | 2 +- libs/pexe/src/inspect.rs | 16 +- libs/pexe/src/lib.rs | 115 ++-- libs/sdk/src/fmt_podlang.rs | 82 ++- libs/sdk/src/lib.rs | 315 +++++++++-- libs/sdk/src/manifest.rs | 81 +-- libs/sdk/src/tests.rs | 84 +++ 11 files changed, 716 insertions(+), 944 deletions(-) diff --git a/examples/swap-log-wood/manifest.toml b/examples/swap-log-wood/manifest.toml index 430288a0..8676a55f 100644 --- a/examples/swap-log-wood/manifest.toml +++ b/examples/swap-log-wood/manifest.toml @@ -1,28 +1,15 @@ -# A pexe that both declares its own class and composes another plugin's -# actions. +# A plugin whose script composes another plugin's actions. # -# The recipe below runs three actions as siblings of one transaction: two -# craft-basics claims, and one action from this plugin's own script. A script -# here could never re-key a craft-basics Log directly -- `action.mutate("Log")` -# would mean *this* plugin's Log, since a class is the OR over the actions of -# the script defining it. Naming craft-basics' own actions as steps is what -# lets their guards match, and the receipt is minted alongside because -# `Swapped` is a class this plugin owns. +# There is nothing here about the dependency: the script's qualified +# `subaction("craft-basics::ClaimLog")` calls declare it by using it, and the +# catalog loads craft-basics first so its compiled batch is available. The +# module_hash below covers that import, so it moves whenever craft-basics does. [plugin] name = "swap-log-wood" version = "0.1.0" # Rewritten by `cargo run -p pexe -- build examples/swap-log-wood`. -module_hash = "30275cba84f1a6d8edb13ce093b27891c253a41f37f751518f5af7368b714678" - -# Pinned by module hash, not by name. A craft-basics installed at any other -# hash is a different set of classes, so the catalog refuses to load this -# recipe against it rather than running the wrong actions. Re-pin with: -# cargo run -p pexe --release -- build examples/craft-basics -# then copy the hash it prints. -[[requires]] -plugin = "craft-basics" -module_hash = "44d6fd33861e3ab021c0362a348243226cfa94345c912676102115a4864cd084" +module_hash = "73666676f7531778615b77172f281f2f7a400f4e7feeb3102d1468c367826f00" [[classes]] name = "Swapped" @@ -30,20 +17,6 @@ emoji = "๐Ÿงพ" description = "A receipt minted by a swap, stamped with the grounding block's timestamp." [[actions]] -name = "MintSwapped" -emoji = "๐Ÿงพ" -description = "Mint a swap receipt." -hidden = true - -# Inputs are the steps' inputs concatenated in step order, so this action -# takes one Log then one Wood -- MintSwapped consumes nothing. All three -# land in a single transaction: either every part happens or none does. -[[recipes]] name = "SwapLogWood" emoji = "๐Ÿค" description = "Re-key one log and one wood together and mint a receipt, in a single transaction." -steps = [ - "craft-basics::ClaimLog", - "craft-basics::ClaimWood", - "swap-log-wood::MintSwapped", -] diff --git a/examples/swap-log-wood/plugin.rhai b/examples/swap-log-wood/plugin.rhai index fa034ea6..8878576f 100644 --- a/examples/swap-log-wood/plugin.rhai +++ b/examples/swap-log-wood/plugin.rhai @@ -1,13 +1,22 @@ -// swap-log-wood's own class. A script can only ever declare and touch its -// own classes -- `action.output("Swapped")` means *this* plugin's Swapped, -// because a class is the OR over the actions of the script that defines it. -// That is exactly why the log and wood halves of the swap are craft-basics -// actions named in the recipe rather than code written here. +// A plugin that reaches into another one. // -// This action runs as a third sibling in the same transaction, so the receipt -// lands if and only if both claims do. +// `action.subaction("craft-basics::ClaimLog")` runs craft-basics' own action +// as a nested step of this one, which is what makes the log's class guard +// match: a class is the OR over the actions of the script that defines it, so +// `action.mutate("Log")` written here would mean *this* plugin's Log and +// could never spend a craft-basics one. +// +// Declaring the dependency is the call itself -- nothing to keep in step in +// the manifest. The cost is coupling: calling into craft-basics puts its +// batch id inside this plugin's, so this plugin's own classes rehash whenever +// craft-basics does. +// +// `Swapped` is a class this plugin owns, minted in the same transaction, so +// the receipt lands if and only if both claims do. -fn MintSwapped(action) { +fn SwapLogWood(action) { + var log = action.subaction("craft-basics::ClaimLog"); + var wood = action.subaction("craft-basics::ClaimWood"); var receipt = action.output("Swapped"); receipt.set([ ["swapped_at", state_header.block_timestamp] diff --git a/libs/driver/src/pexe_catalog.rs b/libs/driver/src/pexe_catalog.rs index 3c83f935..379afbf0 100644 --- a/libs/driver/src/pexe_catalog.rs +++ b/libs/driver/src/pexe_catalog.rs @@ -25,7 +25,9 @@ use std::sync::Arc; use anyhow::{Context, Result, anyhow}; use payload::decode_hash_hex; use pod2::middleware::Hash; -use sdk::{Sdk, SpendableObject, SpendableObjects, manifest::Manifest}; +use sdk::{ + PluginDeps, Sdk, SpendableObject, SpendableObjects, manifest::Manifest, script_dependencies, +}; use txlib::GroundingWitness; use crate::catalog::{ActionCatalog, CatalogClass, extract_predicate}; @@ -35,17 +37,7 @@ struct Plugin { #[allow(dead_code)] path: PathBuf, manifest: Manifest, - /// Absent for a pexe that only carries recipes, which compiles to no - /// module of its own. A pexe may carry both. - script: Option, -} - -/// A catalog action assembled from other plugins' actions rather than -/// compiled from a script. Its steps run as sibling top-level actions of -/// one transaction, and its inputs are the steps' inputs concatenated in -/// step order. -struct RecipeEntry { - steps: Vec, + script: String, } pub struct PexeCatalog { @@ -54,11 +46,6 @@ pub struct PexeCatalog { actions_by_name: HashMap, /// Maps qualified action -> plugin index in `plugins`. action_plugin_idx: HashMap, - /// Every action including hidden ones, so recipe steps can resolve - /// actions the catalog does not surface on its own. - actions_including_hidden: HashMap, - /// Recipe actions, keyed by their own qualified name. - recipes: HashMap, classes: Vec, classes_by_name: HashMap, classes_by_hash: HashMap, @@ -67,27 +54,48 @@ pub struct PexeCatalog { } impl PexeCatalog { - /// Recompile the plugin that provides `action`. The driver does not - /// cache compiled modules, so this runs per execution. + /// Recompile the plugin that provides `action`, together with any + /// plugins its script reaches by qualified sub-action call. The driver + /// does not cache compiled modules, so this runs per execution. fn load_module(&self, sdk: &Sdk, action: &QualifiedName) -> Result> { let plugin_idx = *self .action_plugin_idx .get(action) .ok_or_else(|| anyhow!("no plugin provides action {action}"))?; + self.load_plugin_module(sdk, plugin_idx, &mut HashMap::new()) + } + + /// Compile one plugin, recursing into its dependencies first. `cache` + /// keeps a plugin compiled once per call even when several dependents + /// name it. + fn load_plugin_module( + &self, + sdk: &Sdk, + plugin_idx: usize, + cache: &mut HashMap>, + ) -> Result> { let plugin = &self.plugins[plugin_idx]; - let script = plugin.script.as_deref().ok_or_else(|| { - anyhow!( - "plugin {} has no script, so it cannot run {action}", - plugin.manifest.plugin.name - ) - })?; - sdk.load_module_from_src_manifest(script, &plugin.manifest) - .map_err(|err| { - anyhow!( - "failed to reload plugin {} for execution: {err}", - plugin.manifest.plugin.name - ) - }) + let plugin_name = plugin.manifest.plugin.name.clone(); + if let Some(module) = cache.get(&plugin_name) { + return Ok(module.clone()); + } + let mut deps = PluginDeps::new(); + for dep_name in script_dependencies(&plugin.script) { + let dep_idx = self + .plugins + .iter() + .position(|candidate| candidate.manifest.plugin.name == dep_name) + .ok_or_else(|| { + anyhow!("plugin {plugin_name} calls into {dep_name}, which is not installed") + })?; + let dep = self.load_plugin_module(sdk, dep_idx, cache)?; + deps.insert(dep_name, dep); + } + let module = sdk + .load_module_from_manifest_deps(&plugin.script, &plugin.manifest, deps) + .map_err(|err| anyhow!("failed to reload plugin {plugin_name} for execution: {err}"))?; + cache.insert(plugin_name, module.clone()); + Ok(module) } /// Scan `actions_dir` for `.pexe` files, unpack them, and assemble the catalog. @@ -142,25 +150,25 @@ impl PexeCatalog { let mut enriched_plugins: Vec = Vec::with_capacity(plugins.len()); let mut action_plugin_idx: HashMap = HashMap::new(); - let mut actions_including_hidden: HashMap = HashMap::new(); + // Load in dependency order so a plugin whose script makes a + // qualified `subaction("other::Action")` call has `other`'s compiled + // module available; the call embeds its batch id in this one's. + let plugins = order_by_dependencies(plugins)?; + let mut loaded: PluginDeps = PluginDeps::new(); for plugin in plugins { let plugin_name = plugin.manifest.plugin.name.clone(); - // A pexe with no script contributes no classes or predicates. - // Recipes are resolved in a later pass either way, once every - // plugin is loaded and their steps exist. - let Some(script) = plugin.script.as_deref() else { - if !plugin.manifest.is_recipe() { - return Err(anyhow!( - "plugin {plugin_name} has no script and declares no recipes" - )); - } - enriched_plugins.push(plugin); - continue; - }; + let mut deps = PluginDeps::new(); + for dep_name in script_dependencies(&plugin.script) { + let dep = loaded.get(&dep_name).cloned().ok_or_else(|| { + anyhow!("plugin {plugin_name} calls into {dep_name}, which is not installed") + })?; + deps.insert(dep_name, dep); + } let module = sdk - .load_module_from_src_manifest(script, &plugin.manifest) + .load_module_from_manifest_deps(&plugin.script, &plugin.manifest, deps) .map_err(|err| anyhow!("failed to load plugin {plugin_name}: {err}"))?; + loaded.insert(plugin_name.clone(), module.clone()); let podlang_src = module.podlang_src().to_string(); if !combined_podlang.is_empty() { combined_podlang.push_str("\n// ---\n"); @@ -231,27 +239,35 @@ impl PexeCatalog { } let meta = action_meta_by_name.get(bare.as_str()); - let resolve_class = |class_name: &str| -> Result { - let hash = class_hashes.get(class_name).ok_or_else(|| { + // A class is resolved against the plugin that declares it: + // this one, or the dependency an imported sub-action came + // from. Classes never move between plugins. + let resolve_class = |r: &sdk::ActionObjectRef| -> Result { + let owner = r.owner.as_deref().unwrap_or(plugin_name.as_str()); + let hash = if owner == plugin_name { + class_hashes.get(&r.class).copied() + } else { + loaded.get(owner).and_then(|dep| dep.class_hash(&r.class)) + } + .ok_or_else(|| { anyhow!( - "plugin {plugin_name}: action {bare} references class {class_name:?} \ - which is not declared in this plugin (cross-plugin class \ - references are not supported yet)" + "plugin {plugin_name}: action {bare} references class {:?} of plugin {owner}, which does not declare it", + r.class ) })?; Ok(ClassRef { - class: QualifiedName::new(plugin_name.clone(), class_name.to_string()), + class: QualifiedName::new(owner.to_string(), r.class.clone()), hash: format!("{:#}", hash), }) }; let total_inputs = action .total_inputs() - .map(|r| resolve_class(&r.class)) + .map(resolve_class) .collect::>>()?; let total_outputs = action .total_outputs() - .map(|r| resolve_class(&r.class)) + .map(resolve_class) .collect::>>()?; let action_hash = module @@ -273,8 +289,6 @@ impl PexeCatalog { total_outputs, predicate_source, }; - actions_including_hidden.insert(summary.action.clone(), summary.clone()); - if meta.is_some_and(|m| m.hidden) { continue; } @@ -284,108 +298,6 @@ impl PexeCatalog { enriched_plugins.push(plugin); } - // Recipe pass: every plugin is loaded, so a recipe's steps can be - // resolved and its inputs derived from them. - let installed_hashes: HashMap<&str, Option> = enriched_plugins - .iter() - .map(|plugin| { - ( - plugin.manifest.plugin.name.as_str(), - plugin.manifest.plugin.module_hash, - ) - }) - .collect(); - let mut recipes: HashMap = HashMap::new(); - for (plugin_idx, plugin) in enriched_plugins.iter().enumerate() { - if !plugin.manifest.is_recipe() { - continue; - } - let plugin_name = plugin.manifest.plugin.name.clone(); - - // A required plugin present at a different hash is a different - // set of classes, so its actions are not the ones this recipe - // was written against. - // A recipe may also run its own plugin's actions, so a composed - // transaction can produce objects of the recipe author's own - // classes alongside the ones it claims. - let mut required: HashSet<&str> = HashSet::from([plugin_name.as_str()]); - for require in &plugin.manifest.requires { - required.insert(require.plugin.as_str()); - match installed_hashes.get(require.plugin.as_str()) { - None => { - return Err(anyhow!( - "recipe {plugin_name} requires plugin {} which is not installed", - require.plugin - )); - } - Some(None) => { - return Err(anyhow!( - "recipe {plugin_name} requires plugin {} at {:#}, but that plugin declares no module hash", - require.plugin, - require.module_hash - )); - } - Some(Some(installed)) if *installed != require.module_hash => { - return Err(anyhow!( - "recipe {plugin_name} requires plugin {} at {:#}, but it is installed at {:#}; rebuild the recipe against the installed version", - require.plugin, - require.module_hash, - installed - )); - } - Some(Some(_)) => {} - } - } - - for recipe in &plugin.manifest.recipes { - let qname = QualifiedName::new(plugin_name.clone(), recipe.name.clone()); - if recipe.steps.is_empty() { - return Err(anyhow!("recipe {qname} declares no steps")); - } - let mut steps = Vec::with_capacity(recipe.steps.len()); - let mut total_inputs = Vec::new(); - let mut total_outputs = Vec::new(); - for step in &recipe.steps { - let step_name = QualifiedName::parse(step) - .map_err(|err| anyhow!("recipe {qname}: {err}"))?; - if !required.contains(step_name.plugin_name.as_str()) { - return Err(anyhow!( - "recipe {qname} runs {step_name} but does not require plugin {}", - step_name.plugin_name - )); - } - let step_action = - actions_including_hidden.get(&step_name).ok_or_else(|| { - anyhow!("recipe {qname} runs {step_name}, which no plugin provides") - })?; - total_inputs.extend(step_action.total_inputs.iter().cloned()); - total_outputs.extend(step_action.total_outputs.iter().cloned()); - steps.push(step_name); - } - - if let Some(prior) = action_plugin_idx.insert(qname.clone(), plugin_idx) { - return Err(anyhow!( - "duplicate action qualified name {qname} (already mapped to plugin idx {prior})" - )); - } - let summary = ActionSummary { - action: qname.clone(), - emoji: recipe.emoji.clone(), - hash: String::new(), - description: recipe.description.clone(), - total_inputs, - total_outputs, - predicate_source: format!( - "// recipe: one transaction running\n// {}", - recipe.steps.join("\n// ") - ), - }; - actions_including_hidden.insert(qname.clone(), summary.clone()); - all_actions.push(summary); - recipes.insert(qname, RecipeEntry { steps }); - } - } - // Second pass: fill produced_by / consumed_by per class. for class in classes_in_order.iter_mut() { class.produced_by = all_actions @@ -427,8 +339,6 @@ impl PexeCatalog { Ok(Self { plugins: enriched_plugins, - actions_including_hidden, - recipes, actions: all_actions, actions_by_name, action_plugin_idx, @@ -476,42 +386,6 @@ impl ActionCatalog for PexeCatalog { let sdk = Sdk::default(); let witness = Arc::new(grounding_witness); - if let Some(recipe) = self.recipes.get(&action) { - // Inputs arrive in the same order the recipe's `total_inputs` - // concatenated them, so each step takes the next slice. - let mut remaining = inputs.into_iter(); - let mut modules: Vec> = Vec::with_capacity(recipe.steps.len()); - let mut invocations = Vec::with_capacity(recipe.steps.len()); - for step in &recipe.steps { - let module = self.load_module(&sdk, step)?; - let arity = self - .actions_including_hidden - .get(step) - .ok_or_else(|| anyhow!("recipe {action} runs unknown step {step}"))? - .total_inputs - .len(); - let step_inputs: Vec = remaining.by_ref().take(arity).collect(); - if step_inputs.len() != arity { - return Err(anyhow!( - "recipe {action} ran out of inputs at step {step}: it needs {arity} more" - )); - } - modules.push(module.clone()); - invocations.push(sdk::Invocation { - module, - action: step.name.clone(), - inputs: step_inputs, - }); - } - if remaining.next().is_some() { - return Err(anyhow!( - "recipe {action} was given more inputs than its steps consume" - )); - } - let executor = sdk::Executor::with_modules(modules, self.mock_proofs, witness)?; - return Ok(executor.actions(invocations)?); - } - let module = self.load_module(&sdk, &action)?; let executor = module.executor(self.mock_proofs, witness); Ok(executor.action(&action.name, inputs)?) @@ -581,16 +455,62 @@ pub(crate) fn test_plugin_bytes() -> Vec { // Pack the live plugin sources in-memory so tests never touch ~/.dobj/actions. let manifest = include_str!("../../../examples/craft-basics/manifest.toml"); let script = include_str!("../../../examples/craft-basics/plugin.rhai"); - pexe::pack(manifest, Some(script)).expect("test plugin packs") + pexe::pack(manifest, script).expect("test plugin packs") } #[cfg(test)] -/// The bundled recipe example, packed from source like the plugin above. It -/// carries a script of its own for the receipt class it mints. -pub(crate) fn bundled_recipe_bytes() -> Vec { +/// The bundled swap example, packed from source like the plugin above. Its +/// script reaches craft-basics with a qualified sub-action call. +pub(crate) fn bundled_swap_bytes() -> Vec { let manifest = include_str!("../../../examples/swap-log-wood/manifest.toml"); let script = include_str!("../../../examples/swap-log-wood/plugin.rhai"); - pexe::pack(manifest, Some(script)).expect("bundled recipe packs") + pexe::pack(manifest, script).expect("bundled swap packs") +} + +/// Order plugins so every plugin follows the ones it calls into. +/// +/// A cycle is rejected: two plugins cannot each embed the other's batch id +/// in their own, so there is no order in which both could compile. +fn order_by_dependencies(plugins: Vec) -> Result> { + let mut remaining = plugins; + let mut ordered: Vec = Vec::with_capacity(remaining.len()); + let mut placed: HashSet = HashSet::new(); + + while !remaining.is_empty() { + let ready = remaining.iter().position(|plugin| { + script_dependencies(&plugin.script) + .iter() + .all(|dep| placed.contains(dep)) + }); + match ready { + Some(idx) => { + let plugin = remaining.remove(idx); + placed.insert(plugin.manifest.plugin.name.clone()); + ordered.push(plugin); + } + None => { + let stuck: Vec = remaining + .iter() + .map(|plugin| { + let missing: Vec = script_dependencies(&plugin.script) + .into_iter() + .filter(|dep| !placed.contains(dep)) + .collect(); + format!( + "{} needs {}", + plugin.manifest.plugin.name, + missing.join(", ") + ) + }) + .collect(); + return Err(anyhow!( + "cannot resolve plugin load order ({}); either a dependency is not installed or the plugins form a cycle", + stuck.join("; ") + )); + } + } + } + Ok(ordered) } #[cfg(test)] @@ -789,7 +709,7 @@ description = "consume a Foo to make a Bar" pexe::compile_module_hash(&manifest, script).expect("synthetic script compiles"); let with_hash = pexe::set_manifest_hash(&template, &real_hash).expect("rewrite module_hash"); - pexe::pack(&with_hash, Some(script)).expect("pack synthetic plugin") + pexe::pack(&with_hash, script).expect("pack synthetic plugin") } fn alpha_beta_catalog() -> PexeCatalog { @@ -965,270 +885,57 @@ description = "consume a Foo to make a Bar" Some(Hash(value.raw().0)) } - /// The bundled recipe must stay loadable against the bundled - /// craft-basics. `pexe build` re-pins a plugin's own `module_hash` but - /// never a recipe's `[[requires]]`, so any change to craft-basics - /// silently staleness this pin until something checks it. - #[test] - fn test_bundled_recipe_matches_bundled_plugin() { - let catalog = PexeCatalog::from_bytes( - [ - (PathBuf::from("craft-basics.pexe"), test_plugin_bytes()), - (PathBuf::from("swap-log-wood.pexe"), bundled_recipe_bytes()), - ], - true, - ) - .expect("bundled recipe loads against bundled craft-basics -- if this fails, re-pin examples/swap-log-wood/manifest.toml to the hash `pexe build examples/craft-basics` prints"); - - let recipe = catalog - .get_action(&QualifiedName::new("swap-log-wood", "SwapLogWood")) - .expect("bundled recipe is a catalog action"); - let classes: Vec<&str> = recipe - .total_inputs - .iter() - .map(|r| r.class.name.as_str()) - .collect(); - assert_eq!(classes, vec!["Log", "Wood"]); - assert!( - recipe - .total_inputs - .iter() - .all(|r| r.class.plugin_name == "craft-basics"), - "the recipe consumes craft-basics classes, not its own" - ); - } - - /// A recipe may run its own plugin's actions alongside another's, which - /// is how a composed transaction also produces objects of classes the - /// recipe author owns. The bundled example does exactly this. + /// The bundled swap example calls into craft-basics from its script. + /// Its own `module_hash` covers that import, so any change to + /// craft-basics invalidates it until it is rebuilt -- which is what this + /// checks. #[test] - fn test_bundled_recipe_mints_its_own_class() { + fn test_bundled_swap_loads_against_bundled_plugin() { let catalog = PexeCatalog::from_bytes( [ + (PathBuf::from("swap-log-wood.pexe"), bundled_swap_bytes()), (PathBuf::from("craft-basics.pexe"), test_plugin_bytes()), - (PathBuf::from("swap-log-wood.pexe"), bundled_recipe_bytes()), ], true, ) - .expect("bundled recipe loads"); + .expect("bundled swap loads -- if this fails, rebuild examples/swap-log-wood"); - let recipe = catalog + let swap = catalog .get_action(&QualifiedName::new("swap-log-wood", "SwapLogWood")) - .expect("bundled recipe is a catalog action"); - - // Consumes craft-basics classes; produces those plus its own receipt. - let names = + .expect("swap is a catalog action"); + let ids = |refs: &[ClassRef]| -> Vec { refs.iter().map(|r| r.class.id()).collect() }; + // Consumes craft-basics classes through the qualified calls, and + // produces those plus its own receipt. assert_eq!( - names(&recipe.total_inputs), + ids(&swap.total_inputs), vec!["craft-basics::Log", "craft-basics::Wood"] ); assert_eq!( - names(&recipe.total_outputs), + ids(&swap.total_outputs), vec![ "craft-basics::Log", "craft-basics::Wood", "swap-log-wood::Swapped" ] ); - - // The receipt's class belongs to the recipe's own module, so its - // guard is the recipe plugin's own IsSwapped. - let swapped = catalog - .get_class(&QualifiedName::new("swap-log-wood", "Swapped")) - .expect("the recipe plugin declares Swapped"); - assert_eq!(recipe.total_outputs[2].hash, swapped.hash); - } - - // --- Recipe fixtures ----------------------------------------------------- - // - // A recipe pexe carries no script. It pins the plugins it composes by - // module hash and lists qualified actions to run as one transaction. - - const CLAIM_SCRIPT: &str = r#" -fn MakeFoo(action) { - var foo = action.output("Foo"); - foo.set([["durability", 100]]); - var key = action.random(); - foo.update("key", key); -} - -fn MakeBar(action) { - var bar = action.output("Bar"); - bar.set([["durability", 100]]); - var key = action.random(); - bar.update("key", key); -} - -fn ClaimFoo(action) { - var foo = action.mutate("Foo"); - var key = action.random(); - foo.update("key", key); -} - -fn ClaimBar(action) { - var bar = action.mutate("Bar"); - var key = action.random(); - bar.update("key", key); -} -"#; - - /// A plugin exposing claim actions, i.e. the extension surface a base - /// plugin has to publish before recipes can compose it. - fn claims_plugin_bytes(plugin_name: &str) -> Vec { - let template = format!( - r#"[plugin] -name = "{plugin_name}" -version = "0.1.0" -module_hash = "0000000000000000000000000000000000000000000000000000000000000000" - -[[classes]] -name = "Foo" -emoji = "F" -description = "test class Foo" - -[[classes]] -name = "Bar" -emoji = "B" -description = "test class Bar" - -[[actions]] -name = "MakeFoo" -emoji = "F" -description = "make a Foo" - -[[actions]] -name = "MakeBar" -emoji = "B" -description = "make a Bar" - -[[actions]] -name = "ClaimFoo" -emoji = "F" -description = "take possession of a Foo" - -[[actions]] -name = "ClaimBar" -emoji = "B" -description = "take possession of a Bar" -"# - ); - let manifest: sdk::manifest::Manifest = - toml::from_str(&template).expect("claims manifest parses"); - let real_hash = - pexe::compile_module_hash(&manifest, CLAIM_SCRIPT).expect("claims script compiles"); - let with_hash = - pexe::set_manifest_hash(&template, &real_hash).expect("rewrite module_hash"); - pexe::pack(&with_hash, Some(CLAIM_SCRIPT)).expect("pack claims plugin") - } - - /// The module hash a recipe must pin to compose `claims_plugin_bytes`. - fn claims_module_hash(plugin_name: &str) -> String { - let bytes = claims_plugin_bytes(plugin_name); - let (manifest, _) = pexe::unpack(&bytes).expect("claims pexe unpacks"); - format!( - "{:#}", - manifest.plugin.module_hash.expect("plugin has a hash") - ) - .trim_start_matches("0x") - .to_string() - } - - fn recipe_test_witness( - state: &payload::test_state::TestState, - input_commitments: &[Hash], - ) -> txlib::GroundingWitness { - state.build_grounding_witness( - input_commitments, - |meta, created_root, nullifiers_root, prior_state_history_root, created_proofs| { - txlib::GroundingWitness::new( - txlib::StateHeader::new( - meta.number as i64, - meta.timestamp as i64, - meta.hash, - created_root, - nullifiers_root, - prior_state_history_root, - ), - created_proofs, - ) - }, - ) } - /// The end of the whole chain: a recipe from one pexe consuming and - /// re-keying objects whose classes were defined by another, in a single - /// transaction, driven through the ordinary single-action entry point. + /// The whole point, end to end: a plugin's script re-keys two objects + /// whose classes another plugin defines, and mints one of its own, all in + /// a single transaction. #[test] - fn test_recipe_runs_its_steps_as_one_transaction() { - let catalog = claims_and_recipe_catalog(); - let mut state = payload::test_state::TestState::default(); - - let mut mint = |action: &str| { - let out = catalog - .execute_action( - QualifiedName::new("base", action), - dummy_grounding_witness(), - vec![], - ) - .unwrap_or_else(|err| panic!("base::{action} runs: {err}")); - state.apply_tx( - out.tx.live_commitments().unwrap(), - out.tx.nullifier_hashes().unwrap(), - ); - out.obj(0) - }; - let foo = mint("MakeFoo"); - let bar = mint("MakeBar"); - - let witness = recipe_test_witness(&state, &[foo.obj.commitment(), bar.obj.commitment()]); - let out = catalog - .execute_action( - QualifiedName::new("swap", "SwapFooBar"), - witness, - vec![foo.clone(), bar.clone()], - ) - .expect("recipe runs"); - - // One transaction spent both inputs, so the pair cannot half-land. - let nullifiers = out.tx.nullifier_hashes().unwrap(); - assert_eq!(nullifiers.len(), 2, "both inputs spent by the one tx"); - assert!(nullifiers.contains(&txlib::object_nullifier_hash(&foo.obj).unwrap())); - assert!(nullifiers.contains(&txlib::object_nullifier_hash(&bar.obj).unwrap())); - - let live = out.tx.live_commitments().unwrap(); - assert_eq!(out.objs.len(), 2, "one successor per claimed object"); - for produced in &out.objs { - assert!(live.contains(&produced.obj.commitment())); - } - - // Each successor keeps the class its own plugin defined: a recipe - // cannot mint into a class, only re-key within one. - let foo_type = obj_type_hash_for_test(&foo.obj).unwrap(); - let bar_type = obj_type_hash_for_test(&bar.obj).unwrap(); - assert_eq!(obj_type_hash_for_test(&out.obj(0).obj).unwrap(), foo_type); - assert_eq!(obj_type_hash_for_test(&out.obj(1).obj).unwrap(), bar_type); - - // Re-keying moves every commitment. - assert_ne!(out.obj(0).obj.commitment(), foo.obj.commitment()); - assert_ne!(out.obj(1).obj.commitment(), bar.obj.commitment()); - } - - /// Three actions, two batches, one transaction: the bundled recipe - /// re-keys a craft-basics log and wood and mints its own receipt. - #[test] - fn test_bundled_recipe_executes_across_two_batches() { + fn test_bundled_swap_executes() { let catalog = PexeCatalog::from_bytes( [ (PathBuf::from("craft-basics.pexe"), test_plugin_bytes()), - (PathBuf::from("swap-log-wood.pexe"), bundled_recipe_bytes()), + (PathBuf::from("swap-log-wood.pexe"), bundled_swap_bytes()), ], true, ) - .expect("bundled recipe loads"); + .expect("bundled swap loads"); let mut state = payload::test_state::TestState::default(); - // FindLog twice, then turn one log into wood, to hold both at once. let mut run = |action: QualifiedName, inputs: Vec| { let commitments: Vec = inputs.iter().map(|i| i.obj.commitment()).collect(); let witness = recipe_test_witness(&state, &commitments); @@ -1250,21 +957,19 @@ description = "take possession of a Bar" vec![log.clone(), wood.clone()], ); - // Both claims spent their input; the receipt consumed nothing. let nullifiers = out.tx.nullifier_hashes().unwrap(); - assert_eq!(nullifiers.len(), 2); + assert_eq!(nullifiers.len(), 2, "both claims spent their input"); assert!(nullifiers.contains(&txlib::object_nullifier_hash(&log.obj).unwrap())); assert!(nullifiers.contains(&txlib::object_nullifier_hash(&wood.obj).unwrap())); - // Log, wood, receipt -- all three live off one transaction. - assert_eq!(out.objs.len(), 3); + assert_eq!(out.objs.len(), 3, "log, wood, and the receipt"); let live = out.tx.live_commitments().unwrap(); for produced in &out.objs { assert!(live.contains(&produced.obj.commitment())); } - // The claimed objects keep craft-basics' classes; the receipt carries - // the recipe plugin's own. + // The claimed objects keep craft-basics' classes; only the receipt + // carries this plugin's own. assert_eq!( obj_type_hash_for_test(&out.obj(0).obj).unwrap(), obj_type_hash_for_test(&log.obj).unwrap() @@ -1280,219 +985,27 @@ description = "take possession of a Bar" obj_type_hash_for_test(&out.obj(2).obj).unwrap(), decode_hash_hex(&swapped.hash).unwrap() ); - assert!( - out.obj(2) - .obj - .get(&pod2::middleware::StrKey::from("swapped_at")) - .unwrap() - .is_some(), - "the receipt records the grounding block timestamp" - ); } - fn recipe_bytes(recipe_name: &str, requires: &str, module_hash: &str, steps: &str) -> Vec { - let manifest = format!( - r#"[plugin] -name = "{recipe_name}" -version = "0.1.0" - -[[requires]] -plugin = "{requires}" -module_hash = "{module_hash}" - -[[recipes]] -name = "SwapFooBar" -emoji = "S" -description = "re-key one Foo and one Bar in a single transaction" -steps = [{steps}] -"# - ); - pexe::pack(&manifest, None).expect("pack recipe") - } - - fn claims_and_recipe_catalog() -> PexeCatalog { - let hash = claims_module_hash("base"); - PexeCatalog::from_bytes( - [ - (PathBuf::from("base.pexe"), claims_plugin_bytes("base")), - ( - PathBuf::from("swap.pexe"), - recipe_bytes( - "swap", - "base", - &hash, - r#""base::ClaimFoo", "base::ClaimBar""#, + fn recipe_test_witness( + state: &payload::test_state::TestState, + input_commitments: &[Hash], + ) -> txlib::GroundingWitness { + state.build_grounding_witness( + input_commitments, + |meta, created_root, nullifiers_root, prior_state_history_root, created_proofs| { + txlib::GroundingWitness::new( + txlib::StateHeader::new( + meta.number as i64, + meta.timestamp as i64, + meta.hash, + created_root, + nullifiers_root, + prior_state_history_root, ), - ), - ], - true, - ) - .expect("catalog loads plugin plus recipe") - } - - #[test] - fn test_recipe_surfaces_as_an_action_with_the_steps_inputs() { - let catalog = claims_and_recipe_catalog(); - let recipe = catalog - .get_action(&QualifiedName::new("swap", "SwapFooBar")) - .expect("recipe is a catalog action"); - - // The recipe consumes and produces exactly what its steps do, in - // step order, which is what lets it run through the ordinary - // single-action request path. - let classes = |refs: &[ClassRef]| -> Vec { - refs.iter().map(|r| r.class.name.clone()).collect() - }; - assert_eq!(classes(&recipe.total_inputs), vec!["Foo", "Bar"]); - assert_eq!(classes(&recipe.total_outputs), vec!["Foo", "Bar"]); - // Its classes stay owned by the plugin that declared them. - assert_eq!(recipe.total_inputs[0].class.plugin_name, "base"); - } - - #[test] - fn test_recipe_requiring_a_different_module_hash_is_rejected() { - let wrong = "1".repeat(64); - let result = PexeCatalog::from_bytes( - [ - (PathBuf::from("base.pexe"), claims_plugin_bytes("base")), - ( - PathBuf::from("swap.pexe"), - recipe_bytes("swap", "base", &wrong, r#""base::ClaimFoo""#), - ), - ], - true, - ); - let err = result - .err() - .map(|err| err.to_string()) - .unwrap_or_else(|| panic!("stale pin must be rejected")); - assert!(err.contains("installed at"), "unexpected error: {err}"); - assert!( - err.contains("rebuild the recipe"), - "unexpected error: {err}" - ); - } - - #[test] - fn test_recipe_requiring_a_missing_plugin_is_rejected() { - let hash = claims_module_hash("base"); - let result = PexeCatalog::from_bytes( - std::iter::once(( - PathBuf::from("swap.pexe"), - recipe_bytes("swap", "base", &hash, r#""base::ClaimFoo""#), - )), - true, - ); - let err = result - .err() - .map(|err| err.to_string()) - .unwrap_or_else(|| panic!("missing plugin must be rejected")); - assert!(err.contains("is not installed"), "unexpected error: {err}"); - } - - #[test] - fn test_recipe_step_outside_its_requires_is_rejected() { - let hash = claims_module_hash("base"); - let result = PexeCatalog::from_bytes( - [ - (PathBuf::from("base.pexe"), claims_plugin_bytes("base")), - ( - PathBuf::from("swap.pexe"), - recipe_bytes("swap", "base", &hash, r#""other::ClaimFoo""#), - ), - ], - true, - ); - let err = result - .err() - .map(|err| err.to_string()) - .unwrap_or_else(|| panic!("step outside requires must be rejected")); - assert!( - err.contains("does not require plugin"), - "unexpected error: {err}" - ); - } - - #[test] - fn test_pexe_with_neither_script_nor_recipes_is_rejected() { - let manifest = r#"[plugin] -name = "empty" -version = "0.1.0" -"#; - let bytes = pexe::pack(manifest, None).expect("pack"); - let err = pexe::unpack(&bytes) - .expect_err("an archive that does nothing must be rejected") - .to_string(); - assert!( - err.contains("declares no recipes"), - "unexpected error: {err}" - ); - } - - #[test] - fn test_recipe_may_carry_its_own_script() { - let hash = claims_module_hash("base"); - // A recipe pexe that also declares a class of its own, and runs its - // own action as a third step alongside the two it claims. - let script = r#" -fn MintReceipt(action) { - var receipt = action.output("Receipt"); - var key = action.random(); - receipt.update("key", key); -} -"#; - let template = format!( - r#"[plugin] -name = "swap" -version = "0.1.0" -module_hash = "0000000000000000000000000000000000000000000000000000000000000000" - -[[requires]] -plugin = "base" -module_hash = "{hash}" - -[[classes]] -name = "Receipt" -emoji = "R" -description = "a swap receipt" - -[[actions]] -name = "MintReceipt" -emoji = "R" -description = "mint a receipt" -hidden = true - -[[recipes]] -name = "SwapFooBar" -emoji = "S" -description = "re-key a Foo and a Bar and mint a receipt" -steps = ["base::ClaimFoo", "base::ClaimBar", "swap::MintReceipt"] -"# - ); - let manifest: sdk::manifest::Manifest = toml::from_str(&template).expect("manifest parses"); - let real_hash = - pexe::compile_module_hash(&manifest, script).expect("recipe script compiles"); - let with_hash = - pexe::set_manifest_hash(&template, &real_hash).expect("rewrite module_hash"); - let bytes = pexe::pack(&with_hash, Some(script)).expect("pack"); - - let catalog = PexeCatalog::from_bytes( - [ - (PathBuf::from("base.pexe"), claims_plugin_bytes("base")), - (PathBuf::from("swap.pexe"), bytes), - ], - true, + created_proofs, + ) + }, ) - .expect("a recipe pexe may carry its own script"); - - let recipe = catalog - .get_action(&QualifiedName::new("swap", "SwapFooBar")) - .expect("recipe present"); - let outputs: Vec = recipe.total_outputs.iter().map(|r| r.class.id()).collect(); - assert_eq!( - outputs, - vec!["base::Foo", "base::Bar", "swap::Receipt"], - "the recipe produces its own class alongside the claimed ones" - ); } } diff --git a/libs/pexe/src/bin/pexe.rs b/libs/pexe/src/bin/pexe.rs index f93b6c47..71cae910 100644 --- a/libs/pexe/src/bin/pexe.rs +++ b/libs/pexe/src/bin/pexe.rs @@ -5,8 +5,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow}; use clap::{Parser, Subcommand}; use pexe::{ - MANIFEST_FILE, PEXE_EXTENSION, PluginSource, compile_module_hash, inspect, install, pack, - read_pexe_file, set_manifest_hash, unpack, + MANIFEST_FILE, PEXE_EXTENSION, PluginSource, compile_module_hash_with_deps, inspect, install, + pack, read_pexe_file, resolve_script_deps, set_manifest_hash, unpack, }; // These names intentionally mirror `driver::paths::{DOBJ_HOME_DIR, ACTIONS_DIR}`. @@ -247,13 +247,8 @@ fn main() -> Result<()> { let (manifest, script) = unpack(&bytes)?; println!("# manifest"); println!("{:#?}", manifest); - match script { - Some(script) => { - println!("\n# plugin.rhai"); - println!("{}", script); - } - None => println!("\n# no plugin.rhai (recipe)"), - } + println!("\n# plugin.rhai"); + println!("{}", script); } Cmd::Inspect { cmd } => match cmd { InspectCmd::Predicates { @@ -352,47 +347,42 @@ fn build_one( let manifest = source.parse_manifest()?; let plugin_name = manifest.plugin.name.clone(); - // A recipe-only pexe has no script to compile and no module hash of its - // own; its pinned hashes name the plugins it composes, checked at - // catalog load. A pexe carrying both is compiled like any plugin. - let (manifest_toml, hash_label) = if source.script.is_none() { - (source.manifest_toml.clone(), "recipe".to_string()) + // Compile the script to derive the real module hash from the pod2 batch id. + // Any plugin this script calls into is compiled first, from the install + // dir, since its batch id is part of this hash. + let dep_search_dir = match install_dir { + Some(dir) => dir.to_path_buf(), + None => default_install_dir()?, + }; + let deps = resolve_script_deps(&source.script, &dep_search_dir)?; + let real_hash = compile_module_hash_with_deps(&manifest, &source.script, deps)?; + let declared_hash = format!("{:#}", manifest.plugin.module_hash); + let declared_hash = declared_hash.trim_start_matches("0x").to_lowercase(); + let real_hash_clean = real_hash.trim_start_matches("0x").to_lowercase(); + + let manifest_toml = if declared_hash == real_hash_clean { + source.manifest_toml.clone() + } else if check { + return Err(anyhow!( + "module_hash mismatch in {name}: manifest says {declared}, compiled script yields {real} (re-run without --check to rewrite)", + name = plugin_name, + declared = declared_hash, + real = real_hash_clean, + )); } else { - // Compile the script to derive the real module hash from the pod2 batch id. - let real_hash = compile_module_hash(&manifest, source.require_script()?)?; - let declared_hash = manifest - .plugin - .module_hash - .map(|hash| format!("{hash:#}")) - .unwrap_or_default(); - let declared_hash = declared_hash.trim_start_matches("0x").to_lowercase(); - let real_hash_clean = real_hash.trim_start_matches("0x").to_lowercase(); - - let manifest_toml = if declared_hash == real_hash_clean { - source.manifest_toml.clone() - } else if check { - return Err(anyhow!( - "module_hash mismatch in {name}: manifest says {declared}, compiled script yields {real} (re-run without --check to rewrite)", - name = plugin_name, - declared = declared_hash, - real = real_hash_clean, - )); - } else { - log::info!( - " rewriting module_hash in source manifest: {} -> {}", - declared_hash, - real_hash_clean, - ); - let rewritten = set_manifest_hash(&source.manifest_toml, &real_hash_clean)?; - let manifest_path = source.root.join(MANIFEST_FILE); - std::fs::write(&manifest_path, &rewritten) - .with_context(|| format!("failed to write back {}", manifest_path.display()))?; - rewritten - }; - (manifest_toml, real_hash_clean) + log::info!( + " rewriting module_hash in source manifest: {} -> {}", + declared_hash, + real_hash_clean, + ); + let rewritten = set_manifest_hash(&source.manifest_toml, &real_hash_clean)?; + let manifest_path = source.root.join(MANIFEST_FILE); + std::fs::write(&manifest_path, &rewritten) + .with_context(|| format!("failed to write back {}", manifest_path.display()))?; + rewritten }; - let bytes = pack(&manifest_toml, source.script.as_deref())?; + let bytes = pack(&manifest_toml, &source.script)?; let out_path = out_dir.join(format!("{plugin_name}.{PEXE_EXTENSION}")); std::fs::write(&out_path, &bytes) .with_context(|| format!("failed to write {}", out_path.display()))?; @@ -400,7 +390,7 @@ fn build_one( " wrote {} ({} bytes, hash={})", out_path.display(), bytes.len(), - hash_label, + real_hash_clean, ); if let Some(dir) = install_dir { diff --git a/libs/pexe/src/fixtures.rs b/libs/pexe/src/fixtures.rs index 044d89dc..041e5df9 100644 --- a/libs/pexe/src/fixtures.rs +++ b/libs/pexe/src/fixtures.rs @@ -162,7 +162,7 @@ mod tests { let manifest = source.parse_manifest().unwrap(); let action_names: Vec<&str> = manifest.actions.iter().map(|a| a.name.as_str()).collect(); Sdk::default() - .load_module_from_src_actions(source.require_script().unwrap(), &action_names) + .load_module_from_src_actions(&source.script, &action_names) .unwrap() } diff --git a/libs/pexe/src/inspect.rs b/libs/pexe/src/inspect.rs index cd9aff8b..171052bf 100644 --- a/libs/pexe/src/inspect.rs +++ b/libs/pexe/src/inspect.rs @@ -39,22 +39,14 @@ fn txlib_event_hash(name: &str) -> Hash { /// Directories are read via `PluginSource::read`; anything else is /// treated as a `.pexe` archive and unpacked. fn load_target(path: &Path) -> Result<(Manifest, String)> { - let (manifest, script) = if path.is_dir() { + if path.is_dir() { let source = PluginSource::read(path)?; let manifest = source.parse_manifest()?; - (manifest, source.script) + Ok((manifest, source.script)) } else { let bytes = read_pexe_file(path)?; - unpack(&bytes)? - }; - // Recipes hold no predicates, so there is nothing here to render. - let script = script.ok_or_else(|| { - anyhow::anyhow!( - "{} is a recipe: it composes other plugins' actions and has no predicates of its own", - manifest.plugin.name - ) - })?; - Ok((manifest, script)) + unpack(&bytes) + } } /// Compile the plugin script with the manifest's action list and return diff --git a/libs/pexe/src/lib.rs b/libs/pexe/src/lib.rs index 7c790055..40a88d0b 100644 --- a/libs/pexe/src/lib.rs +++ b/libs/pexe/src/lib.rs @@ -12,7 +12,7 @@ use std::io::{Cursor, Read, Write}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow, bail}; -use sdk::{Sdk, manifest::Manifest}; +use sdk::{PluginDeps, Sdk, manifest::Manifest}; use zip::{ZipArchive, ZipWriter, write::SimpleFileOptions}; pub mod fixtures; @@ -40,12 +40,11 @@ const MAX_ENTRY_BYTES: u64 = 1024 * 1024; /// forcing large allocations inside `ZipArchive`. const MAX_ENTRIES: usize = 16; -/// Pexe source on disk: a directory containing `manifest.toml`, plus -/// `plugin.rhai` unless it is a recipe. +/// Pexe source on disk: a directory containing `manifest.toml` and `plugin.rhai`. pub struct PluginSource { pub root: PathBuf, pub manifest_toml: String, - pub script: Option, + pub script: String, } impl PluginSource { @@ -55,14 +54,8 @@ impl PluginSource { let script_path = root.join(SCRIPT_FILE); let manifest_toml = std::fs::read_to_string(&manifest_path) .with_context(|| format!("failed to read manifest: {}", manifest_path.display()))?; - let script = if script_path.exists() { - Some( - std::fs::read_to_string(&script_path) - .with_context(|| format!("failed to read script: {}", script_path.display()))?, - ) - } else { - None - }; + let script = std::fs::read_to_string(&script_path) + .with_context(|| format!("failed to read script: {}", script_path.display()))?; Ok(Self { root, manifest_toml, @@ -70,25 +63,13 @@ impl PluginSource { }) } - /// The script, or an error naming the directory when this source is a - /// recipe and the caller needs a script. - pub fn require_script(&self) -> Result<&str> { - self.script.as_deref().ok_or_else(|| { - anyhow!( - "{} has no {SCRIPT_FILE}; a recipe pexe composes other plugins' actions", - self.root.display() - ) - }) - } - pub fn parse_manifest(&self) -> Result { toml::from_str(&self.manifest_toml).map_err(|err| anyhow!("invalid manifest.toml: {err}")) } } -/// Pack a manifest + script into pexe bytes. Pass `None` for a recipe -/// pexe, which composes other plugins' actions and has no script. -pub fn pack(manifest_toml: &str, script: Option<&str>) -> Result> { +/// Pack a manifest + script into pexe bytes. +pub fn pack(manifest_toml: &str, script: &str) -> Result> { let buf = Cursor::new(Vec::::new()); let mut zip = ZipWriter::new(buf); let opts = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); @@ -96,18 +77,15 @@ pub fn pack(manifest_toml: &str, script: Option<&str>) -> Result> { zip.start_file(MANIFEST_FILE, opts)?; zip.write_all(manifest_toml.as_bytes())?; - if let Some(script) = script { - zip.start_file(SCRIPT_FILE, opts)?; - zip.write_all(script.as_bytes())?; - } + zip.start_file(SCRIPT_FILE, opts)?; + zip.write_all(script.as_bytes())?; let buf = zip.finish()?; Ok(buf.into_inner()) } -/// Unpack pexe bytes into `(manifest_toml_src, script_src)` without -/// parsing. The script is absent for a recipe pexe. -pub fn unpack_raw(bytes: &[u8]) -> Result<(String, Option)> { +/// Unpack pexe bytes into `(manifest_toml_src, script_src)` without parsing. +pub fn unpack_raw(bytes: &[u8]) -> Result<(String, String)> { let mut zip = ZipArchive::new(Cursor::new(bytes)).map_err(|err| anyhow!("invalid pexe zip: {err}"))?; if zip.len() > MAX_ENTRIES { @@ -117,29 +95,15 @@ pub fn unpack_raw(bytes: &[u8]) -> Result<(String, Option)> { ); } let manifest_toml = read_entry(&mut zip, MANIFEST_FILE)?; - let script = match zip.index_for_name(SCRIPT_FILE) { - Some(_) => Some(read_entry(&mut zip, SCRIPT_FILE)?), - None => None, - }; + let script = read_entry(&mut zip, SCRIPT_FILE)?; Ok((manifest_toml, script)) } /// Unpack pexe bytes into a parsed [`Manifest`] and the script source. -/// -/// A pexe may carry a script, recipes, or both: a recipe can name its own -/// plugin's actions alongside another plugin's, which is how a composed -/// transaction also produces objects of the recipe author's own classes. -/// Carrying neither is what makes an archive useless, so that is rejected. -pub fn unpack(bytes: &[u8]) -> Result<(Manifest, Option)> { +pub fn unpack(bytes: &[u8]) -> Result<(Manifest, String)> { let (manifest_toml, script) = unpack_raw(bytes)?; let manifest: Manifest = toml::from_str(&manifest_toml).map_err(|err| anyhow!("invalid manifest.toml: {err}"))?; - if script.is_none() && !manifest.is_recipe() { - bail!( - "{} ships no {SCRIPT_FILE} and declares no recipes", - manifest.plugin.name - ); - } Ok((manifest, script)) } @@ -166,14 +130,53 @@ fn read_entry(zip: &mut ZipArchive, name: &str) -> R /// Compile the script against its manifest's action names and return the hex-encoded /// module hash. pub fn compile_module_hash(manifest: &Manifest, script: &str) -> Result { + compile_module_hash_with_deps(manifest, script, PluginDeps::new()) +} + +/// As [`compile_module_hash`], with dependency plugins available to the +/// script's qualified sub-action calls. The imported batches are part of +/// what the returned hash covers. +pub fn compile_module_hash_with_deps( + manifest: &Manifest, + script: &str, + deps: PluginDeps, +) -> Result { let sdk = Sdk::default(); let names: Vec<&str> = manifest.actions.iter().map(|a| a.name.as_str()).collect(); let module = sdk - .load_module_from_src_actions(script, &names) + .load_module_from_src_deps(script, &names, deps) .map_err(|err| anyhow!("failed to compile plugin: {err}"))?; Ok(format!("{:#}", module.module().batch.id())) } +/// Compile the plugins a script calls into, reading them as installed +/// `.pexe` archives from `search_dir`. +/// +/// Build-time dependency resolution has to come from somewhere; the install +/// directory is the same place the driver loads from, so a plugin that +/// builds here is one that will also load there. +pub fn resolve_script_deps(script: &str, search_dir: &Path) -> Result { + let sdk = Sdk::default(); + let mut deps = PluginDeps::new(); + for plugin_name in sdk::script_dependencies(script) { + let path = search_dir.join(format!("{plugin_name}.{PEXE_EXTENSION}")); + let bytes = read_pexe_file(&path).with_context(|| { + format!( + "this script calls into {plugin_name}, which must be installed to build against; expected {}", + path.display() + ) + })?; + let (manifest, dep_script) = unpack(&bytes)?; + // A dependency may itself call into others, so resolve depth-first. + let dep_deps = resolve_script_deps(&dep_script, search_dir)?; + let module = sdk + .load_module_from_manifest_deps(&dep_script, &manifest, dep_deps) + .map_err(|err| anyhow!("failed to compile dependency {plugin_name}: {err}"))?; + deps.insert(plugin_name, module); + } + Ok(deps) +} + /// Rewrite the `module_hash` line in a manifest's TOML source to the given hash, /// preserving formatting of everything else. Adds the line under `[plugin]` if /// absent. @@ -242,18 +245,10 @@ module_hash = "0000000000000000000000000000000000000000000000000000000000000000" #[test] fn test_pack_unpack_round_trip() { - let bytes = pack("name = \"x\"", Some("fn Foo() {}")).unwrap(); - let (manifest, script) = unpack_raw(&bytes).unwrap(); - assert!(manifest.contains("name = \"x\"")); - assert_eq!(script.as_deref(), Some("fn Foo() {}")); - } - - #[test] - fn test_pack_unpack_round_trip_without_script() { - let bytes = pack("name = \"x\"", None).unwrap(); + let bytes = pack("name = \"x\"", "fn Foo() {}").unwrap(); let (manifest, script) = unpack_raw(&bytes).unwrap(); assert!(manifest.contains("name = \"x\"")); - assert_eq!(script, None, "a recipe archive carries no script entry"); + assert_eq!(script, "fn Foo() {}"); } fn zip_with_entries(entries: &[(&str, &[u8])]) -> Vec { diff --git a/libs/sdk/src/fmt_podlang.rs b/libs/sdk/src/fmt_podlang.rs index 2dfa7a36..d14b2d9e 100644 --- a/libs/sdk/src/fmt_podlang.rs +++ b/libs/sdk/src/fmt_podlang.rs @@ -211,11 +211,51 @@ fn schema_name_io(action_name: &str) -> String { format!("{action_name}IO") } +/// IO schema name for an imported action. Namespaced by plugin so it +/// cannot collide with a local action of the same name. +fn imported_schema_name_io(plugin: &str, action_name: &str) -> String { + format!("{}_{action_name}IO", crate::dep_alias(plugin)) +} + /// Emit `record = ()` lines for any non-empty /// io schema across all actions, plus `Chain` records for /// actions whose chain has 2+ intermediate states. fn fmt_record_decls(loader: &Loader, w: &mut dyn fmt::Write) -> fmt::Result { let render = |entries: &[String]| entries.join(", "); + let io_entries = |meta: &ActionMeta| -> Vec { + meta.in_entries + .iter() + .map(|e| Side::In.arg_name(&e.varname)) + .chain( + meta.out_entries + .iter() + .map(|e| Side::Out.arg_name(&e.varname)), + ) + .collect() + }; + + // Records are frontend metadata and are not importable, so every + // imported action's `io` shape is re-declared here under a namespaced + // name. The entry order is what fixes the array indices, so it must + // match the dependency's own declaration. + let mut imported_io: Vec<(String, Vec)> = Vec::new(); + for meta in &loader.actions_meta { + for sub in &meta.sub_refs { + let Some(plugin) = &sub.plugin else { continue }; + let name = imported_schema_name_io(plugin, &sub.action); + if imported_io.iter().any(|(existing, _)| existing == &name) { + continue; + } + imported_io.push((name, io_entries(loader.sub_meta(sub)))); + } + } + for (name, entries) in &imported_io { + if entries.is_empty() { + continue; + } + writeln!(w, "record {} = ({})", name, render(entries))?; + } + for meta in &loader.actions_meta { let names: Vec = meta .in_entries @@ -260,7 +300,13 @@ fn fmt_record_decls(loader: &Loader, w: &mut dyn fmt::Write) -> fmt::Result { /// One sub-action call in the parent's body, with its synthesized /// private wildcard names + record-shape info for the call. struct SubActionCall { - sub_name: String, + /// How the call is written in podlang: a bare name for a local + /// sub-action, `dep_::` for an imported one. + call_name: String, + /// Record type of the sub's `io` argument. Imported subs get a + /// locally-declared copy under a namespaced name, since records are + /// frontend metadata and cannot be imported. + io_schema: String, /// Name of the parent's synthesized private wildcard for the sub's /// `io` record sub_io_var: String, @@ -288,10 +334,23 @@ fn collect_sub_action_calls(action: &ActionContext, loader: &Loader) -> Vec ( + sub_ref.action.clone(), + schema_name_io(&sub_ref.action), + sub_ref.action.clone(), + ), + Some(plugin) => ( + format!("{}::{}", crate::dep_alias(plugin), sub_ref.action), + imported_schema_name_io(plugin, &sub_ref.action), + format!("{}_{}", crate::dep_alias(plugin), sub_ref.action), + ), + }; + let sub_io_var = format!("_{}_io_{}", io_var_stem, idx); let alias_name = obj.borrow().var_name().to_string(); let alias = if alias_name == "?" { @@ -299,15 +358,12 @@ fn collect_sub_action_calls(action: &ActionContext, loader: &Loader) -> Vec { + Inst::SubAction { .. } => { let call = &sub_calls[sub_call_idx]; sub_call_idx += 1; let chain = vars["chain"]; @@ -530,7 +584,7 @@ fn fmt_action(action: &ActionContext, loader: &Loader, w: &mut dyn fmt::Write) - args.push("state_header".to_string()); args.push(format!("{chain}")); args.push(format!("{chain_next}")); - writeln!(w, " {sub_name}({})", args.join(", "))?; + writeln!(w, " {}({})", call.call_name, args.join(", "))?; vars.get_mut("chain").expect("chain exists").inc(); } } diff --git a/libs/sdk/src/lib.rs b/libs/sdk/src/lib.rs index 14e713e2..acbefc3d 100644 --- a/libs/sdk/src/lib.rs +++ b/libs/sdk/src/lib.rs @@ -1,8 +1,7 @@ use std::cell::RefCell; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fmt; use std::rc::Rc; -use std::slice; use std::sync::Arc; use std::sync::LazyLock; @@ -1148,9 +1147,16 @@ impl ActionHandle { let st_action = { let mut exe_ctx = exe_rc.borrow_mut(); let state_header = exe_ctx.tx_builder.state_header().array(); + let owner = exe_ctx.module.module.clone(); exe_ctx .bld - .apply_custom_pred(false, &action, map!({"state_header" => state_header}), sts) + .apply_custom_pred_in( + &owner, + false, + &action, + map!({"state_header" => state_header}), + sts, + ) .unwrap() }; @@ -1226,9 +1232,37 @@ impl ActionHandle { let exe_rc_opt = self.0.borrow().exe_ctx.clone(); let arg_placeholder = Rc::new(RefCell::new(VarOrValue::var(Type::Dict))); + let sub_ref = SubRef::parse(&action).map_err(rt_err_from_anyhow)?; let (arg, st_sub, sub_out) = if let Some(exe_rc) = exe_rc_opt { - let sub_handle = ActionHandle::new(action.clone(), Some(exe_rc.clone())); - let (st_sub, sub_io_array) = sub_handle.exe_action()?; + // An imported action's body lives in its own plugin's script, so + // the module handle moves to that plugin for the duration of the + // call while the transaction builder stays shared. + let restore = match &sub_ref.plugin { + None => None, + Some(plugin) => { + let mut exe_ctx = exe_rc.borrow_mut(); + let dep = exe_ctx + .module + .plugin_deps + .get(plugin) + .ok_or_else(|| { + rt_err_from_anyhow(anyhow!( + "subaction {action} names a plugin this script does not depend on" + )) + })? + .clone(); + Some(std::mem::replace(&mut exe_ctx.module, dep)) + } + }; + let sub_handle = ActionHandle::new(sub_ref.action.clone(), Some(exe_rc.clone())); + let sub_result = sub_handle.exe_action(); + // The sub's own module owns its `ActionMeta`, so keep it for the + // out-entry lookup below before handing the slot back. + let sub_module = exe_rc.borrow().module.clone(); + if let Some(parent_module) = restore { + exe_rc.borrow_mut().module = parent_module; + } + let (st_sub, sub_io_array) = sub_result?; // Alias the parent's binding to the Ref of the sub-action's // first produced object, or a fresh placeholder if the sub @@ -1239,8 +1273,7 @@ impl ActionHandle { _ => None, }); let (arg, sub_out) = if let Some((io, obj)) = aliased { - let module = exe_rc.borrow().module.clone(); - let sub_meta = module.action_by_name(&action); + let sub_meta = sub_module.action_by_name(&sub_ref.action); let varname = obj.borrow().var_name().to_string(); let (entry_idx, _) = sub_meta .out_entry(&varname) @@ -1656,6 +1689,10 @@ pub struct ActionObjectRef { pub(crate) io: ObjectIO, pub class: String, pub(crate) varname: String, + /// Plugin that declares `class`. `None` means the plugin being loaded; + /// `Some` appears on refs spliced in from a dependency reached by a + /// qualified sub-action call, whose classes that plugin still owns. + pub owner: Option, } /// One slot in an action's `IO` record (in-entries first, @@ -1703,6 +1740,9 @@ pub struct ActionMeta { /// cached result of `ActionContext::max_ts_per_var`, so `collapsed_at` /// need not recompute it or take it as an argument. var_max_ts: HashMap, + /// One entry per sub-action call in body order, so the renderer and + /// the executor agree on which calls cross into a dependency plugin. + pub(crate) sub_refs: Vec, } impl ActionMeta { @@ -1801,7 +1841,39 @@ impl ActionMeta { /// sub-action's already-computed `total_inputs`/`total_outputs` at /// the point of its `subaction` call. `prior` must contain entries /// for every sub-action this one references. - fn from_action_ctx(prior: &[ActionMeta], ctx: &ActionContext) -> Result { + /// Resolve a sub-action to the `ActionMeta` describing it, looking in + /// this script's already-loaded actions or in a dependency plugin. + fn resolve_sub<'a>( + prior: &'a [ActionMeta], + deps: &'a PluginDeps, + sub: &SubRef, + ) -> Result<&'a ActionMeta> { + match &sub.plugin { + None => prior + .iter() + .find(|a| a.name == sub.action) + .ok_or_else(|| anyhow!("subaction {} not defined", sub.action)), + Some(plugin) => { + let dep = deps.get(plugin).ok_or_else(|| { + anyhow!( + "subaction {}::{} names a plugin this script does not depend on", + plugin, + sub.action + ) + })?; + dep.actions + .iter() + .find(|a| a.name == sub.action) + .ok_or_else(|| anyhow!("plugin {plugin} defines no action {}", sub.action)) + } + } + } + + fn from_action_ctx( + prior: &[ActionMeta], + deps: &PluginDeps, + ctx: &ActionContext, + ) -> Result { let mut meta = Self { name: ctx.name.clone(), chain_max_ts: ctx.var_state.get("chain").map(|s| s.ts).unwrap_or(0), @@ -1816,6 +1888,7 @@ impl ActionMeta { io: *io, class: class.clone(), varname: obj.borrow().var_name().to_string(), + owner: None, }; if io.consumes() { meta.total_inputs.push(r.clone()); @@ -1826,18 +1899,25 @@ impl ActionMeta { meta.object_refs.push(r); } Inst::SubAction { action, obj, .. } => { - let sub = prior - .iter() - .find(|a| &a.name == action) - .ok_or_else(|| anyhow!("subaction {action} not defined"))?; + let sub_ref = SubRef::parse(action)?; + let sub = Self::resolve_sub(prior, deps, &sub_ref)?; let alias = obj.borrow().var_name().to_string(); if alias != "?" && referenced.contains(&alias) && sub.out_entries.is_empty() { return Err(anyhow!( "subaction {action} produces no object; `{alias}` cannot be referenced in the parent body" )); } - meta.total_inputs.extend(sub.total_inputs.iter().cloned()); - meta.total_outputs.extend(sub.total_outputs.iter().cloned()); + let stamp = |refs: &[ActionObjectRef]| -> Vec { + refs.iter() + .map(|r| ActionObjectRef { + owner: r.owner.clone().or_else(|| sub_ref.plugin.clone()), + ..r.clone() + }) + .collect() + }; + meta.total_inputs.extend(stamp(&sub.total_inputs)); + meta.total_outputs.extend(stamp(&sub.total_outputs)); + meta.sub_refs.push(sub_ref); } _ => {} } @@ -2039,6 +2119,76 @@ pub struct ClassMeta { pub actions: Vec<(String, usize)>, } +/// Plugin modules a script may reach with a qualified sub-action call. +/// +/// Keyed by plugin name, which is what appears in the script: +/// `action.subaction("craft-basics::ClaimLog")`. Calling a dependency's +/// action embeds its batch id in the caller's own batch, so the caller's +/// class hashes move whenever a dependency changes. +pub type PluginDeps = BTreeMap>; + +/// A sub-action target: an action in this script, or one in a dependency. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SubRef { + /// `None` for an action defined in the calling script. + pub(crate) plugin: Option, + pub(crate) action: String, +} + +impl SubRef { + /// Parse `Action` (local) or `plugin::Action` (a dependency's). + pub(crate) fn parse(raw: &str) -> Result { + match raw.split_once("::") { + None => Ok(Self { + plugin: None, + action: raw.to_string(), + }), + Some((plugin, action)) if !plugin.is_empty() && !action.is_empty() => Ok(Self { + plugin: Some(plugin.to_string()), + action: action.to_string(), + }), + Some(_) => Err(anyhow!( + "invalid sub-action name {raw:?}: expected `Action` or `plugin::Action`" + )), + } + } +} + +/// Plugin names a script reaches with a qualified sub-action call. +/// +/// The dependency set is declared by use: a script that calls +/// `subaction("craft-basics::ClaimLog")` depends on craft-basics, with +/// nothing to keep in step in the manifest. Scanning the source is enough +/// because the SDK only accepts a literal name here -- a computed one +/// would not survive the load phase, which runs with no inputs. +pub fn script_dependencies(script: &str) -> Vec { + let mut found: Vec = Vec::new(); + for rest in script.split("subaction(").skip(1) { + let Some(open) = rest.find(['"', '\'']) else { + continue; + }; + let quote = rest.as_bytes()[open] as char; + let after = &rest[open + 1..]; + let Some(close) = after.find(quote) else { + continue; + }; + let name = &after[..close]; + if let Some((plugin, _action)) = name.split_once("::") + && !plugin.is_empty() + && !found.iter().any(|existing| existing == plugin) + { + found.push(plugin.to_string()); + } + } + found +} + +/// Podlang module alias for a dependency plugin. Plugin names allow `-`, +/// which is not an identifier character, so it is mapped to `_`. +pub(crate) fn dep_alias(plugin: &str) -> String { + format!("dep_{}", plugin.replace(['-', '.'], "_")) +} + /// The Loader is used to store declarative module information at Load time. struct Loader { // The frozen chain-primitive batch (TxInsert/TxMutate/TxDelete). @@ -2047,6 +2197,10 @@ struct Loader { tx_events_mod: Arc, txlib_mod: Arc, dependencies: Vec, + /// Every plugin module offered to this script, whether reached or not. + plugin_deps: PluginDeps, + /// Plugins actually reached by a qualified sub-action call, sorted. + imported_plugins: Vec, actions: Vec, // Metadata extracted from `actions` actions_meta: Vec, @@ -2081,10 +2235,10 @@ impl Loader { classes } - fn new(actions: Vec) -> Result { + fn new(actions: Vec, plugin_deps: PluginDeps) -> Result { let tx_events_mod = Arc::new(txlib::predicates::events_module()); let txlib_mod = Arc::new(txlib::predicates::module()); - let dependencies = vec![ + let mut dependencies = vec![ Dependency::Module { name: "tx".to_string(), hash: tx_events_mod.id(), @@ -2100,20 +2254,64 @@ impl Loader { ]; let mut actions_meta = Vec::with_capacity(actions.len()); for handle in &actions { - let meta = ActionMeta::from_action_ctx(&actions_meta, &handle.0.borrow())?; + let meta = + ActionMeta::from_action_ctx(&actions_meta, &plugin_deps, &handle.0.borrow())?; actions_meta.push(meta); } + // Import only the plugins actually reached by a qualified call, so an + // unused dependency cannot move this module's hash. + let mut imported: Vec = Vec::new(); + for meta in &actions_meta { + for sub in &meta.sub_refs { + if let Some(plugin) = &sub.plugin + && !imported.contains(plugin) + { + imported.push(plugin.clone()); + } + } + } + imported.sort(); + for plugin in &imported { + let dep = plugin_deps + .get(plugin) + .ok_or_else(|| anyhow!("no dependency module for plugin {plugin}"))?; + dependencies.push(Dependency::Module { + name: dep_alias(plugin), + hash: dep.module.id(), + }); + } let classes = Self::actions_to_classes(&actions_meta); Ok(Self { tx_events_mod, txlib_mod, dependencies, + plugin_deps, + imported_plugins: imported, actions, actions_meta, classes, }) } + /// The `ActionMeta` for a sub-action call, local or imported. Panics + /// only on shapes `ActionMeta::from_action_ctx` already rejected. + fn sub_meta(&self, sub: &SubRef) -> &ActionMeta { + match &sub.plugin { + None => self + .actions_meta + .iter() + .find(|m| m.name == sub.action) + .expect("local sub-action meta exists at fmt time"), + Some(plugin) => self + .plugin_deps + .get(plugin) + .expect("dependency resolved at load time") + .actions + .iter() + .find(|m| m.name == sub.action) + .expect("imported sub-action meta exists at fmt time"), + } + } /// Map (action_name, object_index) -> index of that action's branch /// in the class's IsX OR, matching the order in which branches are /// emitted by `fmt_class`. `object_index` is the 0-based position of @@ -2142,14 +2340,19 @@ impl Loader { ); let params = Params::default(); + // Every batch the rendered source declares with `use module` has to + // be available here: the chain primitives, plus one per imported + // plugin. + let mut imports: Vec> = vec![self.tx_events_mod.clone()]; + for plugin in &self.imported_plugins { + let dep = self + .plugin_deps + .get(plugin) + .expect("dependency resolved at load time"); + imports.push(dep.module.clone()); + } let module = Arc::new( - load_module( - podlang_src.as_str(), - "root", - ¶ms, - slice::from_ref(&self.tx_events_mod), - ) - .expect("compiles"), + load_module(podlang_src.as_str(), "root", ¶ms, &imports).expect("compiles"), ); let object_index_class_st_index = Self::object_index_class_st_index(&self.actions_meta); let class_hashes: HashMap = self @@ -2173,6 +2376,7 @@ impl Loader { ast, class_hashes, dependencies: self.dependencies, + plugin_deps: self.plugin_deps, } } } @@ -2199,6 +2403,9 @@ pub struct SdkModule { // Exposed so callers can map a foreign batch hash back to its // declared module alias (e.g. for qualified-name rendering). dependencies: Vec, + /// Dependency plugin modules, keyed by plugin name. A qualified + /// `subaction("plugin::Action")` runs its body out of these. + plugin_deps: PluginDeps, } impl SdkModule { @@ -2301,7 +2508,12 @@ impl SdkModule { // Step 2: discharge the bridge predicate. let st_bridge = bld - .apply_custom_pred_simple(false, &bridge_name, vec![st_array_contains, st_action]) + .apply_custom_pred_simple_in( + &self.module, + false, + &bridge_name, + vec![st_array_contains, st_action], + ) .expect("apply bridge predicate"); // Step 3: IsX OR with the bridge at the right branch. @@ -2310,7 +2522,8 @@ impl SdkModule { let class_st_index = self.object_index_class_st_index[&(action_name.to_string(), object_refs_index)]; branch_sts[class_st_index] = st_bridge; - bld.apply_custom_pred( + bld.apply_custom_pred_in( + &self.module, false, &class_predicate_name(class), map!({"state_header" => state_header.array()}), @@ -2447,12 +2660,23 @@ impl Executor { let params = Params::default(); let mut pod_modules: Vec> = Vec::new(); let mut seen: HashSet = HashSet::new(); - for plugin in &modules { + let push_plugin = |plugin: &Rc, + pod_modules: &mut Vec>, + seen: &mut HashSet| { for batch in [&plugin.tx_events_mod, &plugin.txlib_mod, &plugin.module] { if seen.insert(batch.batch.id()) { pod_modules.push(batch.clone()); } } + }; + // Breadth-first over dependencies so an imported plugin's batch is + // available to discharge the predicates a qualified sub-action calls. + let mut queue: Vec> = modules.clone(); + while let Some(plugin) = queue.pop() { + push_plugin(&plugin, &mut pod_modules, &mut seen); + for dep in plugin.plugin_deps.values() { + queue.push(dep.clone()); + } } Ok(Self { mock, @@ -2796,6 +3020,18 @@ impl Sdk { &self, src: &str, actions: &[&str], + ) -> Result, SdkError> { + self.load_module_from_src_deps(src, actions, PluginDeps::new()) + } + + /// Load a module whose script may reach other plugins' actions with + /// qualified `subaction("plugin::Action")` calls. Reaching one imports + /// that plugin's batch, which puts its id inside this module's own. + pub fn load_module_from_src_deps( + &self, + src: &str, + actions: &[&str], + plugin_deps: PluginDeps, ) -> Result, SdkError> { let scope = Scope::new(); let started = std::time::Instant::now(); @@ -2825,7 +3061,7 @@ impl Sdk { ); let started = std::time::Instant::now(); - let loader = Loader::new(action_handles)?; + let loader = Loader::new(action_handles, plugin_deps)?; log::debug!("loader analysis: {:?}", started.elapsed()); Ok(Rc::new(loader.module(self.engine.clone(), ast))) } @@ -2834,9 +3070,20 @@ impl Sdk { &self, src: &str, manifest: &Manifest, + ) -> Result, SdkError> { + self.load_module_from_manifest_deps(src, manifest, PluginDeps::new()) + } + + /// As [`Self::load_module_from_src_manifest`], with dependency plugins + /// available to qualified sub-action calls. + pub fn load_module_from_manifest_deps( + &self, + src: &str, + manifest: &Manifest, + plugin_deps: PluginDeps, ) -> Result, SdkError> { let manifest_actions: Vec<_> = manifest.actions.iter().map(|a| a.name.as_str()).collect(); - let sdk_module = self.load_module_from_src_actions(src, &manifest_actions)?; + let sdk_module = self.load_module_from_src_deps(src, &manifest_actions, plugin_deps)?; // Validate against the manifest metadata let loaded_classes: HashSet<_> = sdk_module @@ -2853,16 +3100,10 @@ impl Sdk { loaded_classes ))?; } - let pinned = manifest.plugin.module_hash.ok_or_else(|| { - anyhow!( - "plugin {} declares no module_hash; run `pexe build` to fill it in", - manifest.plugin.name - ) - })?; - if pinned != sdk_module.module.batch.id() { + if manifest.plugin.module_hash != sdk_module.module.batch.id() { return Err(anyhow!( "manifest.plugin.module_hash = {:#} but module.hash = {:#}", - pinned, + manifest.plugin.module_hash, sdk_module.module.batch.id() ))?; } diff --git a/libs/sdk/src/manifest.rs b/libs/sdk/src/manifest.rs index 6bf3a95e..b67f66e3 100644 --- a/libs/sdk/src/manifest.rs +++ b/libs/sdk/src/manifest.rs @@ -4,39 +4,15 @@ use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct Manifest { pub plugin: Plugin, - #[serde(default)] pub classes: Vec, - #[serde(default)] pub actions: Vec, - /// Plugins whose actions this pexe's recipes compose, each pinned to - /// the module hash the recipe was authored against. - #[serde(default)] - pub requires: Vec, - /// Transactions assembled from other plugins' actions. A pexe that - /// declares recipes carries no script and compiles to no module of - /// its own, so it can never define or alter a class. - #[serde(default)] - pub recipes: Vec, -} - -impl Manifest { - /// Whether this pexe declares any recipes. A pexe may carry recipes - /// with or without a script of its own: a recipe can name its own - /// plugin's actions alongside another plugin's, so a composed - /// transaction can also produce objects of classes it declares here. - pub fn is_recipe(&self) -> bool { - !self.recipes.is_empty() - } } #[derive(Debug, Deserialize)] pub struct Plugin { pub name: String, pub version: String, - /// Absent on a recipe pexe. `pexe build` fills it in for a plugin by - /// compiling the script. - #[serde(default)] - pub module_hash: Option, + pub module_hash: Hash, } #[derive(Debug, Deserialize)] @@ -55,27 +31,6 @@ pub struct Action { pub hidden: bool, } -/// A pinned dependency on another installed plugin. The hash is checked -/// at catalog load: a required plugin present at a different hash is a -/// different set of classes, so its actions are not the ones the recipe -/// was written against. -#[derive(Debug, Deserialize)] -pub struct Require { - pub plugin: String, - pub module_hash: Hash, -} - -#[derive(Debug, Deserialize)] -pub struct Recipe { - pub name: String, - pub emoji: String, - pub description: String, - /// Qualified action names (`plugin::Action`) run as sibling - /// top-level actions of one transaction, in this order. The recipe's - /// inputs are the steps' inputs concatenated in the same order. - pub steps: Vec, -} - #[cfg(test)] mod tests { use super::*; @@ -105,42 +60,8 @@ description = "Internal durability/work update for wood pick usage." hidden = true "#; let manifest: Manifest = toml::from_str(toml_str).unwrap(); - assert!(!manifest.is_recipe()); - assert!(manifest.plugin.module_hash.is_some()); assert_eq!(manifest.classes.len(), 1); assert_eq!(manifest.actions.len(), 2); assert!(manifest.actions[1].hidden); } - - #[test] - fn test_recipe_manifest() { - let toml_str = r#" -[plugin] -name = "swap-log-wood" -version = "0.1.0" - -[[requires]] -plugin = "craft-basics" -module_hash = "57631b51fb9a921588d391211f94c0bd8f777aff0a16755bc2dfefb52d6ff5b0" - -[[recipes]] -name = "SwapLogWood" -emoji = "๐Ÿค" -description = "Re-key one Log and one Wood in a single transaction." -steps = ["craft-basics::ClaimLog", "craft-basics::ClaimWood"] - "#; - let manifest: Manifest = toml::from_str(toml_str).unwrap(); - assert!(manifest.is_recipe()); - assert!( - manifest.plugin.module_hash.is_none(), - "a recipe compiles to no module" - ); - assert!(manifest.classes.is_empty()); - assert_eq!(manifest.requires.len(), 1); - assert_eq!(manifest.requires[0].plugin, "craft-basics"); - assert_eq!( - manifest.recipes[0].steps, - vec!["craft-basics::ClaimLog", "craft-basics::ClaimWood"] - ); - } } diff --git a/libs/sdk/src/tests.rs b/libs/sdk/src/tests.rs index a2e7722e..55cf088d 100644 --- a/libs/sdk/src/tests.rs +++ b/libs/sdk/src/tests.rs @@ -1110,3 +1110,87 @@ fn test_batch_id_ignores_names() { .unwrap(); assert_ne!(logs.module().batch.id(), extra.module().batch.id()); } + +/// A script reaching into another plugin with a qualified sub-action call. +/// The parent's predicate contains the dependency's action, so the two are +/// one action rather than siblings -- and the parent can pin its own output +/// to the object the dependency just claimed. +#[test] +fn test_qualified_subaction_into_a_dependency() { + let _ = env_logger::builder().is_test(true).try_init(); + let base_src = r#" + fn SpawnGem(action) { + var gem = action.output("Gem"); + } + fn ClaimGem(action) { + var gem = action.mutate("Gem"); + var key = action.random(); + gem.update("key", key); + } + "#; + // Reaches base::ClaimGem and mints a receipt of its own class, bound to + // the gem's stable identifier. + let swap_src = r#" + fn ClaimAndReceipt(action) { + var gem = action.subaction("base::ClaimGem"); + var receipt = action.output("Receipt"); + } + "#; + let sdk = Sdk::default(); + let base = sdk + .load_module_from_src_actions(base_src, &["SpawnGem", "ClaimGem"]) + .unwrap(); + let mut deps = PluginDeps::new(); + deps.insert("base".to_string(), base.clone()); + let swap = sdk + .load_module_from_src_deps(swap_src, &["ClaimAndReceipt"], deps) + .unwrap(); + println!("{}", swap.podlang_src()); + + // The dependency is imported, so its batch id is inside the caller's. + assert!( + swap.dependencies().iter().any(|dep| matches!( + dep, + Dependency::Module { hash, .. } if *hash == base.module().id() + )), + "the caller must import the dependency's batch" + ); + + // The parent's declared arity covers the dependency's objects too. + let meta = &swap.actions()[0]; + let classes: Vec<&str> = meta.total_inputs().map(|r| r.class.as_str()).collect(); + assert_eq!(classes, vec!["Gem"]); + let out: Vec<&str> = meta.total_outputs().map(|r| r.class.as_str()).collect(); + assert_eq!(out, vec!["Gem", "Receipt"]); + + let mut state = TestState::default(); + let executor = base.executor(true, grounding_witness(&state, &[])); + let res = executor.action("SpawnGem", vec![]).unwrap(); + let tx = res.tx.clone(); + let [gem] = res.objs(); + apply_tx(&mut state, &tx); + + let witness = grounding_witness(&state, &[gem.obj.commitment()]); + let executor = swap.executor(true, witness); + let res = executor + .action("ClaimAndReceipt", vec![gem.clone()]) + .unwrap(); + + // The gem was re-keyed and a receipt minted, in one action. + let nullifiers = res.tx.nullifier_hashes().unwrap(); + assert!(nullifiers.contains(&txlib::object_nullifier_hash(&gem.obj).unwrap())); + let [claimed, receipt] = res.objs(); + let live = res.tx.live_commitments().unwrap(); + assert!(live.contains(&claimed.obj.commitment())); + assert!(live.contains(&receipt.obj.commitment())); + + // The claimed gem keeps the dependency's class; the receipt carries the + // caller's own. + let type_of = |obj: &pod2::middleware::containers::Dictionary| { + obj.get(&pod2::middleware::StrKey::from("type")) + .unwrap() + .unwrap() + }; + assert_eq!(type_of(&claimed.obj), type_of(&gem.obj)); + assert_ne!(type_of(&receipt.obj), type_of(&claimed.obj)); +} From 202a5eaed8476a31dc54ae74dba03f2f848b1e11 Mon Sep 17 00:00:00 2001 From: Dhvani Patel Date: Thu, 13 Aug 2026 20:18:16 +0530 Subject: [PATCH 09/13] Drop the multi-action executor and other scaffolding the final design does not use --- examples/craft-basics/plugin.rhai | 4 +- libs/driver/src/pexe_catalog.rs | 16 ++- libs/pod2utils/src/macros.rs | 2 +- libs/sdk/src/lib.rs | 152 +++++++------------------- libs/sdk/src/tests.rs | 174 +----------------------------- libs/txlib/src/lib.rs | 128 ---------------------- 6 files changed, 55 insertions(+), 421 deletions(-) diff --git a/examples/craft-basics/plugin.rhai b/examples/craft-basics/plugin.rhai index 3f1eceb2..46bf24b9 100644 --- a/examples/craft-basics/plugin.rhai +++ b/examples/craft-basics/plugin.rhai @@ -77,8 +77,8 @@ fn MineStoneWithStonePick(action) { // Nothing else about the state changes, and the stable identifier carries // across, so the object stays the same object under new custody. // -// These are also the surface a recipe pexe composes: a recipe can only run -// actions this plugin already exposes. +// These are also this plugin's extension surface: another plugin's script can +// only reach the actions exposed here. fn claim(action, obj) { var key = action.random(); diff --git a/libs/driver/src/pexe_catalog.rs b/libs/driver/src/pexe_catalog.rs index 379afbf0..e6e30002 100644 --- a/libs/driver/src/pexe_catalog.rs +++ b/libs/driver/src/pexe_catalog.rs @@ -9,9 +9,14 @@ //! when printed). Two plugins may declare a class or action with the same //! bare name; they stay distinct because every internal map keys on the full //! `QualifiedName` and because their on-chain `Is{class}` predicate hashes -//! differ (each module has a unique `module_hash`). Cross-plugin class -//! references are not supported: an action must reference classes declared -//! in its own plugin. +//! differ (each module has a unique `module_hash`). +//! +//! A script names its own classes only, so an action's own objects belong to +//! its plugin. It may still act on another plugin's objects by calling that +//! plugin's action -- `subaction("other::Action")` -- which is why an +//! action's declared inputs and outputs can span plugins and why each is +//! resolved against the plugin that owns it. Those calls also set the load +//! order here, since the callee's compiled batch is part of the caller's. //! //! The compiled [`sdk::SdkModule`] is not kept โ€” it holds a `Rc` and is //! therefore `!Send`. `execute_action` re-loads the script from its stored bytes @@ -938,7 +943,7 @@ description = "consume a Foo to make a Bar" let mut run = |action: QualifiedName, inputs: Vec| { let commitments: Vec = inputs.iter().map(|i| i.obj.commitment()).collect(); - let witness = recipe_test_witness(&state, &commitments); + let witness = witness_for(&state, &commitments); let out = catalog .execute_action(action.clone(), witness, inputs) .unwrap_or_else(|err| panic!("{action} runs: {err}")); @@ -987,7 +992,8 @@ description = "consume a Foo to make a Bar" ); } - fn recipe_test_witness( + /// A grounding witness over `state` covering the given inputs. + fn witness_for( state: &payload::test_state::TestState, input_commitments: &[Hash], ) -> txlib::GroundingWitness { diff --git a/libs/pod2utils/src/macros.rs b/libs/pod2utils/src/macros.rs index 0b798e2a..075d8e60 100644 --- a/libs/pod2utils/src/macros.rs +++ b/libs/pod2utils/src/macros.rs @@ -306,7 +306,7 @@ pub fn apply_custom_pred( } /// Apply a predicate from a known module, bypassing name resolution. -pub fn apply_custom_pred_in( +fn apply_custom_pred_in( module: &Arc, builder: &mut MultiPodBuilder, public: bool, diff --git a/libs/sdk/src/lib.rs b/libs/sdk/src/lib.rs index acbefc3d..aee8b662 100644 --- a/libs/sdk/src/lib.rs +++ b/libs/sdk/src/lib.rs @@ -2128,7 +2128,7 @@ pub struct ClassMeta { pub type PluginDeps = BTreeMap>; /// A sub-action target: an action in this script, or one in a dependency. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] pub(crate) struct SubRef { /// `None` for an action defined in the calling script. pub(crate) plugin: Option, @@ -2164,12 +2164,11 @@ impl SubRef { pub fn script_dependencies(script: &str) -> Vec { let mut found: Vec = Vec::new(); for rest in script.split("subaction(").skip(1) { - let Some(open) = rest.find(['"', '\'']) else { + let Some(open) = rest.find('"') else { continue; }; - let quote = rest.as_bytes()[open] as char; let after = &rest[open + 1..]; - let Some(close) = after.find(quote) else { + let Some(close) = after.find('"') else { continue; }; let name = &after[..close]; @@ -2186,7 +2185,7 @@ pub fn script_dependencies(script: &str) -> Vec { /// Podlang module alias for a dependency plugin. Plugin names allow `-`, /// which is not an identifier character, so it is mapped to `_`. pub(crate) fn dep_alias(plugin: &str) -> String { - format!("dep_{}", plugin.replace(['-', '.'], "_")) + format!("dep_{}", plugin.replace('-', "_")) } /// The Loader is used to store declarative module information at Load time. @@ -2562,15 +2561,6 @@ impl SpendableObjects { } } -/// One action to run inside a transaction, together with the plugin it -/// comes from and the objects it consumes. A transaction is a list of -/// these; they need not share a plugin. -pub struct Invocation { - pub module: Rc, - pub action: String, - pub inputs: Vec, -} - /// The Executor is used to hold the state of action execution at Execution time. pub struct Executor { mock: bool, @@ -2628,27 +2618,6 @@ fn prove(builder: MultiPodBuilder, prover: &dyn MainPodProver) -> MainPod { impl Executor { fn new(module: Rc, mock: bool, grounding_witness: Arc) -> Self { - Self::with_modules(vec![module], mock, grounding_witness) - .expect("one module is never empty") - } - - /// Build an executor over several plugin modules, so one transaction - /// can carry actions from more than one plugin. - /// - /// The txlib batches arrive once per plugin and are deduplicated by - /// batch id: leaving copies in would make every txlib predicate name - /// resolve ambiguously. Two plugins compiled against genuinely - /// different txlib batches keep both copies and fail that way, which - /// is the honest outcome -- their events are not interchangeable. - pub fn with_modules( - modules: Vec>, - mock: bool, - grounding_witness: Arc, - ) -> Result { - let module = modules - .first() - .ok_or_else(|| anyhow!("an executor needs at least one plugin module"))? - .clone(); let mock_prover = MockProver {}; let real_prover = Prover {}; let (vd_set, prover): (_, Box) = if mock { @@ -2658,27 +2627,25 @@ impl Executor { (vd_set.clone(), Box::new(real_prover)) }; let params = Params::default(); + // Walk dependencies so an imported plugin's batch is present to + // discharge the predicates its qualified sub-action calls. Batches are + // deduplicated by id: every plugin carries its own copy of the txlib + // batches, and leaving duplicates in would make each txlib predicate + // name resolve ambiguously. let mut pod_modules: Vec> = Vec::new(); let mut seen: HashSet = HashSet::new(); - let push_plugin = |plugin: &Rc, - pod_modules: &mut Vec>, - seen: &mut HashSet| { + let mut queue: Vec> = vec![module.clone()]; + while let Some(plugin) = queue.pop() { for batch in [&plugin.tx_events_mod, &plugin.txlib_mod, &plugin.module] { if seen.insert(batch.batch.id()) { pod_modules.push(batch.clone()); } } - }; - // Breadth-first over dependencies so an imported plugin's batch is - // available to discharge the predicates a qualified sub-action calls. - let mut queue: Vec> = modules.clone(); - while let Some(plugin) = queue.pop() { - push_plugin(&plugin, &mut pod_modules, &mut seen); for dep in plugin.plugin_deps.values() { queue.push(dep.clone()); } } - Ok(Self { + Self { mock, params, vd_set, @@ -2686,7 +2653,7 @@ impl Executor { prover, pod_modules, module, - }) + } } fn new_builder(&self) -> MultiPodBuilder { MultiPodBuilder::new(&self.params, &self.vd_set) @@ -2700,85 +2667,45 @@ impl Executor { action: &str, inputs: Vec, ) -> Result { - self.actions(vec![Invocation { - module: self.module.clone(), - action: action.to_string(), - inputs, - }]) - } - - /// Execute several actions as one transaction. - /// - /// Each invocation opens its own top-level action scope, so the - /// actions are siblings on the event chain rather than nested, and - /// they may come from different plugins. Grounding covers the whole - /// transaction, so the caller's witness must carry a proof for every - /// input across every invocation. - pub fn actions(&self, invocations: Vec) -> Result { // TODO: In this function: return errors instead of panic from unwrap. - if invocations.is_empty() { - return Err(anyhow!("a transaction needs at least one action").into()); - } let builder = self.new_builder(); let mut bld = BuildContext { builder, modules: self.pod_modules.clone(), }; - // The tx builder grounds every input up front; each invocation - // then pops only its own off the rhai stack. - let mut tx_inputs: Vec = Vec::new(); - let mut per_action_inputs: Vec> = Vec::with_capacity(invocations.len()); - for invocation in &invocations { - let total = &invocation - .module - .action_by_name(&invocation.action) - .total_inputs; - let mut objs: Vec = Vec::with_capacity(invocation.inputs.len()); - for (input, _ref) in zip_eq(invocation.inputs.iter(), total.iter()) { - tx_inputs.push(input.obj.clone()); - objs.push(input.obj.clone()); - } - // Reverse so rhai pops in declaration order (last-declared on top). - objs.reverse(); - per_action_inputs.push(objs); + let total = &self.module.action_by_name(action).total_inputs; + + let mut tx_inputs: Vec = Vec::with_capacity(inputs.len()); + let mut rhai_input_objs: Vec = Vec::with_capacity(inputs.len()); + for (input, _ref) in zip_eq(inputs, total.iter()) { + let SpendableObject { obj } = input; + tx_inputs.push(obj.clone()); + rhai_input_objs.push(obj); } + // Reverse so rhai pops in declaration order (last-declared on top). + rhai_input_objs.reverse(); let tx_builder = self.new_tx_builder(&mut bld, &tx_inputs); let exe_rc = Rc::new(RefCell::new(ExeContext { mock: self.mock, params: self.params.clone(), vd_set: self.vd_set.clone(), - inputs: Vec::new(), + inputs: rhai_input_objs, bld, tx_builder, - module: invocations[0].module.clone(), + module: self.module.clone(), outputs: Vec::new(), })); + let action_handle = ActionHandle::new(action.to_string(), Some(exe_rc.clone())); + log::info!("executing action {}", action); + let start = std::time::Instant::now(); + action_handle.exe_action()?; + log::info!("executing action {} took {:?}", action, start.elapsed()); - for (invocation, inputs) in zip_eq(&invocations, per_action_inputs) { - { - let mut exe_ctx = exe_rc.borrow_mut(); - // Each body is parsed from its own plugin's script, so the - // module handle moves with the invocation. - exe_ctx.module = invocation.module.clone(); - exe_ctx.inputs = inputs; - } - let action_handle = ActionHandle::new(invocation.action.clone(), Some(exe_rc.clone())); - log::info!("executing action {}", invocation.action); - let start = std::time::Instant::now(); - action_handle.exe_action()?; - log::info!( - "executing action {} took {:?}", - invocation.action, - start.elapsed() - ); - - // Release the handle's Rc clone so `exe_rc` has a unique - // owner for the `try_unwrap` below. - action_handle.0.borrow_mut().exe_ctx = None; - } - + // Release the handle's Rc clone so `exe_rc` has a unique + // owner for the `try_unwrap` below. + action_handle.0.borrow_mut().exe_ctx = None; let ExeContext { tx_builder, mut bld, @@ -2796,16 +2723,15 @@ impl Executor { // statements are not revealed: they would force a wrapping pod // to mask them from the relayer / synchronizer's `ProofParser`, // which expects a single public statement. - let label = invocations - .iter() - .map(|invocation| invocation.action.as_str()) - .collect::>() - .join(" + "); - log::info!("proving tx_pod for {label}"); + log::info!("proving tx_pod for action {}", action); let start = std::time::Instant::now(); let tx_pod = prove(bld.builder, &*self.prover); tx_pod.pod.verify().unwrap(); - log::info!("proving tx_pod for {label} took {:?}", start.elapsed()); + log::info!( + "proving tx_pod for action {} took {:?}", + action, + start.elapsed() + ); let objs: Vec = outputs .into_iter() diff --git a/libs/sdk/src/tests.rs b/libs/sdk/src/tests.rs index 55cf088d..8e453a9e 100644 --- a/libs/sdk/src/tests.rs +++ b/libs/sdk/src/tests.rs @@ -887,182 +887,12 @@ fn test_sdk_state_header() { apply_tx(&mut state, &ticker1_tx); } -/// Two actions in one transaction: both objects are re-keyed together, -/// so the pair lands or fails as a unit. This is the shape a swap takes. -#[test] -fn test_two_actions_one_transaction() { - let _ = env_logger::builder().is_test(true).try_init(); - let src = r#" - fn SpawnLog(action) { - var log = action.output("Log"); - } - fn SpawnWood(action) { - var wood = action.output("Wood"); - } - fn ClaimLog(action) { - var log = action.mutate("Log"); - var key = action.random(); - log.update("key", key); - } - fn ClaimWood(action) { - var wood = action.mutate("Wood"); - var key = action.random(); - wood.update("key", key); - } - "#; - let sdk = Sdk::default(); - let module = sdk - .load_module_from_src_actions(src, &["SpawnLog", "SpawnWood", "ClaimLog", "ClaimWood"]) - .unwrap(); - - let mut state = TestState::default(); - - let executor = module.executor(true, grounding_witness(&state, &[])); - let res = executor.action("SpawnLog", vec![]).unwrap(); - let spawn_log_tx = res.tx.clone(); - let [log] = res.objs(); - apply_tx(&mut state, &spawn_log_tx); - - let executor = module.executor(true, grounding_witness(&state, &[])); - let res = executor.action("SpawnWood", vec![]).unwrap(); - let spawn_wood_tx = res.tx.clone(); - let [wood] = res.objs(); - apply_tx(&mut state, &spawn_wood_tx); - - // One transaction, two top-level actions. - let witness = grounding_witness(&state, &[log.obj.commitment(), wood.obj.commitment()]); - let executor = module.executor(true, witness); - let res = executor - .actions(vec![ - Invocation { - module: module.clone(), - action: "ClaimLog".to_string(), - inputs: vec![log.clone()], - }, - Invocation { - module: module.clone(), - action: "ClaimWood".to_string(), - inputs: vec![wood.clone()], - }, - ]) - .unwrap(); - - let [claimed_log, claimed_wood] = res.objs(); - let nullifiers = res.tx.nullifier_hashes().unwrap(); - assert_eq!(nullifiers.len(), 2, "both inputs are spent by the one tx"); - assert!(nullifiers.contains(&txlib::object_nullifier_hash(&log.obj).unwrap())); - assert!(nullifiers.contains(&txlib::object_nullifier_hash(&wood.obj).unwrap())); - - // Re-keying changes each commitment, and the stable identifier - // carries across so both stay the same objects. - let live = res.tx.live_commitments().unwrap(); - assert!(live.contains(&claimed_log.obj.commitment())); - assert!(live.contains(&claimed_wood.obj.commitment())); - assert_ne!(claimed_log.obj.commitment(), log.obj.commitment()); - assert_ne!(claimed_wood.obj.commitment(), wood.obj.commitment()); - let stable = |obj: &pod2::middleware::containers::Dictionary| { - obj.get(&pod2::middleware::StrKey::from("stable_identifier")) - .unwrap() - .unwrap() - }; - assert_eq!(stable(&claimed_log.obj), stable(&log.obj)); - assert_eq!(stable(&claimed_wood.obj), stable(&wood.obj)); -} - -/// The same transaction, but the two actions come from two separately -/// compiled plugins. This is what lets a user's own pexe compose actions -/// over classes another plugin defined. -#[test] -fn test_two_plugins_one_transaction() { - let _ = env_logger::builder().is_test(true).try_init(); - let logs_src = r#" - fn SpawnLog(action) { - var log = action.output("Log"); - } - fn ClaimLog(action) { - var log = action.mutate("Log"); - var key = action.random(); - log.update("key", key); - } - "#; - // Structurally different from the log plugin, not just differently - // named: predicate names are not hashed, so two plugins whose - // rendered podlang has the same shape compile to the same batch and - // therefore to the same classes. The extra literal field is what - // makes this a second batch. - let gems_src = r#" - fn SpawnGem(action) { - var gem = action.output("Gem"); - gem.set([ - ["facets", 8] - ]); - } - fn ClaimGem(action) { - var gem = action.mutate("Gem"); - var key = action.random(); - gem.update("key", key); - } - "#; - let sdk = Sdk::default(); - let logs = sdk - .load_module_from_src_actions(logs_src, &["SpawnLog", "ClaimLog"]) - .unwrap(); - let gems = sdk - .load_module_from_src_actions(gems_src, &["SpawnGem", "ClaimGem"]) - .unwrap(); - assert_ne!( - logs.module().batch.id(), - gems.module().batch.id(), - "the two plugins must compile to distinct batches" - ); - - let mut state = TestState::default(); - - let executor = logs.executor(true, grounding_witness(&state, &[])); - let res = executor.action("SpawnLog", vec![]).unwrap(); - let tx = res.tx.clone(); - let [log] = res.objs(); - apply_tx(&mut state, &tx); - - let executor = gems.executor(true, grounding_witness(&state, &[])); - let res = executor.action("SpawnGem", vec![]).unwrap(); - let tx = res.tx.clone(); - let [gem] = res.objs(); - apply_tx(&mut state, &tx); - - let witness = grounding_witness(&state, &[log.obj.commitment(), gem.obj.commitment()]); - let executor = Executor::with_modules(vec![logs.clone(), gems.clone()], true, witness).unwrap(); - let res = executor - .actions(vec![ - Invocation { - module: logs.clone(), - action: "ClaimLog".to_string(), - inputs: vec![log.clone()], - }, - Invocation { - module: gems.clone(), - action: "ClaimGem".to_string(), - inputs: vec![gem.clone()], - }, - ]) - .unwrap(); - - let nullifiers = res.tx.nullifier_hashes().unwrap(); - assert!(nullifiers.contains(&txlib::object_nullifier_hash(&log.obj).unwrap())); - assert!(nullifiers.contains(&txlib::object_nullifier_hash(&gem.obj).unwrap())); - - let [claimed_log, claimed_gem] = res.objs(); - let live = res.tx.live_commitments().unwrap(); - assert!(live.contains(&claimed_log.obj.commitment())); - assert!(live.contains(&claimed_gem.obj.commitment())); -} - /// Plugin identity is structural, not nominal: predicate names are /// metadata and are not hashed, so renaming every class and action in a /// plugin leaves its batch id -- and therefore all of its class hashes /// -- unchanged. Two independently authored plugins that render to the -/// same shape share an economy, and a recipe that pins a `module_hash` -/// is pinning structure rather than a name. +/// same shape share an economy, and a plugin that pins a dependency's +/// `module_hash` is pinning structure rather than a name. #[test] fn test_batch_id_ignores_names() { let _ = env_logger::builder().is_test(true).try_init(); diff --git a/libs/txlib/src/lib.rs b/libs/txlib/src/lib.rs index 17a5f441..32d8051a 100644 --- a/libs/txlib/src/lib.rs +++ b/libs/txlib/src/lib.rs @@ -1930,132 +1930,4 @@ mod tests { claimed_id ); } - - /// One transaction, two top-level actions, two different plugin - /// batches: `UseWoodPick` from the crafting batch and `ClaimGem` - /// from a second batch that neither txlib nor the crafting batch - /// references. Guard dispatch reaches each batch through its - /// object's `type` field, which is what lets a transaction compose - /// actions from plugins that were compiled independently. - #[test] - fn test_actions_from_two_batches_in_one_tx() { - let events = Arc::new(crate::predicates::events_module()); - let txlib = Arc::new(crate::predicates::module()); - let craft = Arc::new(crate::predicates::crafting_test_module()); - let swap = Arc::new(crate::predicates::swap_test_module()); - assert_ne!( - craft.batch.id(), - swap.batch.id(), - "the two plugin batches must be distinct for this test to mean anything" - ); - - let is_wood_pick = Value::from( - Predicate::Custom(craft.predicate_ref_by_name("IsWoodPick").unwrap()).hash(), - ); - let is_gem = - Value::from(Predicate::Custom(swap.predicate_ref_by_name("IsGem").unwrap()).hash()); - let modules = vec![events, txlib, craft, swap]; - - let params = Params::default(); - let vd_set = VDSet::new(&[]); - - // Start from a holding of one pick and one gem rather than - // minting each in its own transaction first. - let mut state = TestState::empty(0); - let pick = with_stable_identifier(&make_object( - is_wood_pick, - &[("durability", Value::from(100_i64))], - )); - let gem = with_stable_identifier(&make_object(is_gem, &[])); - state.seed(&pick); - state.seed(&gem); - - let builder = MultiPodBuilder::new(¶ms, &vd_set); - let mut ctx = BuildContext { builder, modules }; - - let inputs = vec![pick.clone(), gem.clone()]; - let witness = state.grounding_witness(&inputs); - let mut tx = TxBuilder::new(&mut ctx, &inputs, witness); - - // ---- top-level action 0: UseWoodPick, guarded by the crafting batch ---- - let mut pick_new = pick.clone(); - pick_new - .update(&StrKey::from("durability"), &Value::from(99_i64)) - .unwrap(); - let scope_pick = tx.begin_action(); - let (st_mutate_pick, h_pick) = tx.mutate(&mut ctx, &pick_new, &pick); - let op_gt = ctx - .builder - .priv_op(op!(Gt((&pick, "durability"), 0_i64))) - .unwrap(); - let op_sum = ctx - .builder - .priv_op(op!(Sum(99_i64, 1_i64, (&pick, "durability")))) - .unwrap(); - let op_du_pick = ctx - .builder - .priv_op(op!(DictUpdate(pick, "durability", 99_i64, pick_new))) - .unwrap(); - let st_use = ctx - .apply_custom_pred_simple( - false, - "UseWoodPick", - vec![op_gt, op_sum, op_du_pick, st_mutate_pick], - ) - .unwrap(); - let st_guard_pick = ctx - .apply_custom_pred( - false, - "IsWoodPick", - map!({"state_header" => state.state_header().array()}), - vec![Statement::None, Statement::None, st_use], - ) - .unwrap(); - tx.set_guard(h_pick, st_guard_pick); - tx.end_action(scope_pick); - - // ---- top-level action 1: ClaimGem, guarded by the second batch ---- - let new_key = Value::from(rand_raw_value()); - let mut gem_new = gem.clone(); - gem_new.update(&StrKey::from("key"), &new_key).unwrap(); - let scope_gem = tx.begin_action(); - let (st_mutate_gem, h_gem) = tx.mutate(&mut ctx, &gem_new, &gem); - let op_du_gem = ctx - .builder - .priv_op(op!(DictUpdate(gem, "key", new_key, gem_new))) - .unwrap(); - let st_claim = ctx - .apply_custom_pred_simple(false, "ClaimGem", vec![op_du_gem, st_mutate_gem]) - .unwrap(); - let st_guard_gem = ctx - .apply_custom_pred( - false, - "IsGem", - map!({"state_header" => state.state_header().array()}), - vec![Statement::None, st_claim], - ) - .unwrap(); - tx.set_guard(h_gem, st_guard_gem); - tx.end_action(scope_gem); - - eprintln!("{tx}"); - let (st, tx_out, stats) = tx.finalize(&mut ctx); - print_stats(&stats); - ctx.builder.reveal(&st).unwrap(); - solve_and_verify(ctx.builder); - - // Both old states are spent and both successors are live, so the - // two actions landed as one atomic transaction. - for old in [&pick, &gem] { - assert!( - tx_out - .nullifiers - .contains(&Value::from(compute_nullifier(old))) - .unwrap() - ); - } - for new in [&pick_new, &gem_new] { - assert!(tx_out.live.contains(&Value::from(new.clone())).unwrap()); - } - } } From 932996314aa3f28443d87daaf4361952cf9db90c Mon Sep 17 00:00:00 2001 From: Dhvani Patel Date: Sun, 16 Aug 2026 14:10:55 +0530 Subject: [PATCH 10/13] Let an all-subaction action compile without an io record, and add a receipt-free swap-log-sticks example --- examples/swap-log-sticks/manifest.toml | 16 +++++ examples/swap-log-sticks/plugin.rhai | 8 +++ libs/sdk/src/fmt_podlang.rs | 41 ++++++++++--- libs/sdk/src/tests.rs | 82 ++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 examples/swap-log-sticks/manifest.toml create mode 100644 examples/swap-log-sticks/plugin.rhai diff --git a/examples/swap-log-sticks/manifest.toml b/examples/swap-log-sticks/manifest.toml new file mode 100644 index 00000000..7f43fb53 --- /dev/null +++ b/examples/swap-log-sticks/manifest.toml @@ -0,0 +1,16 @@ +# A receipt-free recipe: every slot is spliced from craft-basics claims, +# so this plugin declares no classes of its own (`classes = []`) and its +# action compiles without an io record. Three re-keyed survivors plus the +# chain overhead land exactly on pod2's 8-wildcard predicate budget -- +# there is no room for a receipt here, which is why none is minted. +classes = [] + +[plugin] +name = "swap-log-sticks" +version = "0.1.0" +module_hash = "cb4f2e337f85a82c903a9ce4be9ac9fac0687194f1e6e36d522ec677245e2fce" + +[[actions]] +name = "SwapLogSticks" +emoji = "๐Ÿค" +description = "Re-key one log against two sticks in a single transaction." diff --git a/examples/swap-log-sticks/plugin.rhai b/examples/swap-log-sticks/plugin.rhai new file mode 100644 index 00000000..f17bb599 --- /dev/null +++ b/examples/swap-log-sticks/plugin.rhai @@ -0,0 +1,8 @@ +// The asymmetric-count swap: one log crosses one way, two sticks the +// other. Each claim rotates its object's key so the giver's copy dies; +// the proof budget (see manifest.toml) rules out a receipt output. +fn SwapLogSticks(action) { + var log = action.subaction("craft-basics::ClaimLog"); + var stick_a = action.subaction("craft-basics::ClaimStick"); + var stick_b = action.subaction("craft-basics::ClaimStick"); +} diff --git a/libs/sdk/src/fmt_podlang.rs b/libs/sdk/src/fmt_podlang.rs index d14b2d9e..ed8f1fb6 100644 --- a/libs/sdk/src/fmt_podlang.rs +++ b/libs/sdk/src/fmt_podlang.rs @@ -217,6 +217,14 @@ fn imported_schema_name_io(plugin: &str, action_name: &str) -> String { format!("{}_{action_name}IO", crate::dep_alias(plugin)) } +/// Whether an action carries an `io` record at all. An action whose every +/// object slot is spliced in from sub-actions owns no entries; its record, +/// its `io` signature arg, and the `io` arg of calls to it are all omitted +/// together, since a record without entries is unrepresentable in podlang. +pub(crate) fn action_has_io(meta: &ActionMeta) -> bool { + !meta.in_entries.is_empty() || !meta.out_entries.is_empty() +} + /// Emit `record = ()` lines for any non-empty /// io schema across all actions, plus `Chain` records for /// actions whose chain has 2+ intermediate states. @@ -267,12 +275,17 @@ fn fmt_record_decls(loader: &Loader, w: &mut dyn fmt::Write) -> fmt::Result { .map(|e| Side::Out.arg_name(&e.varname)), ) .collect(); - writeln!( - w, - "record {} = ({})", - schema_name_io(&meta.name), - render(&names), - )?; + // An all-subaction action owns no entries; podlang has no empty + // record form, and the action's signature drops its `io` arg to + // match (see fmt_action), so nothing references the schema. + if !names.is_empty() { + writeln!( + w, + "record {} = ({})", + schema_name_io(&meta.name), + render(&names), + )?; + } if chain_packed(meta.chain_max_ts) { // Intermediates: ts=1..=chain_max_ts-1 -> step_0..step_(K-2). let steps: Vec = (0..meta.chain_max_ts - 1) @@ -319,6 +332,10 @@ struct SubActionCall { /// Name of the sub's first out entry, the one the alias refers to. /// `None` if the sub produces nothing. first_out_entry: Option, + /// False when the sub owns no io entries (an all-subaction composite): + /// no typed private is synthesized and the call passes no `io` arg, + /// matching the sub's own io-less signature. + has_io: bool, } /// Walk the parent action's Insts and gather one `SubActionCall` per @@ -367,6 +384,7 @@ fn collect_sub_action_calls(action: &ActionContext, loader: &Loader) -> Vec = Vec::new(); - args.push(call.sub_io_var.clone()); + if call.has_io { + args.push(call.sub_io_var.clone()); + } args.push("state_header".to_string()); args.push(format!("{chain}")); args.push(format!("{chain_next}")); diff --git a/libs/sdk/src/tests.rs b/libs/sdk/src/tests.rs index 8e453a9e..a2b06525 100644 --- a/libs/sdk/src/tests.rs +++ b/libs/sdk/src/tests.rs @@ -1024,3 +1024,85 @@ fn test_qualified_subaction_into_a_dependency() { assert_eq!(type_of(&claimed.obj), type_of(&gem.obj)); assert_ne!(type_of(&receipt.obj), type_of(&claimed.obj)); } + +#[test] +fn test_receipt_free_composite_action() { + let _ = env_logger::builder().is_test(true).try_init(); + let base_src = r#" + fn SpawnGem(action) { + var gem = action.output("Gem"); + } + fn SpawnCoin(action) { + var coin = action.output("Coin"); + } + fn ClaimGem(action) { + var gem = action.mutate("Gem"); + var key = action.random(); + gem.update("key", key); + } + fn ClaimCoin(action) { + var coin = action.mutate("Coin"); + var key = action.random(); + coin.update("key", key); + } + "#; + // A pure swap: every object slot is spliced from the dependency's + // claims, the caller mints nothing of its own. The caller's io record + // is empty, so its predicate has no `io` arg at all -- previously this + // failed to compile as `record SwapIO = ()`. + let swap_src = r#" + fn Swap(action) { + var gem = action.subaction("base::ClaimGem"); + var coin = action.subaction("base::ClaimCoin"); + } + "#; + let sdk = Sdk::default(); + let base = sdk + .load_module_from_src_actions( + base_src, + &["SpawnGem", "SpawnCoin", "ClaimGem", "ClaimCoin"], + ) + .unwrap(); + let mut deps = PluginDeps::new(); + deps.insert("base".to_string(), base.clone()); + let swap = sdk + .load_module_from_src_deps(swap_src, &["Swap"], deps) + .unwrap(); + println!("{}", swap.podlang_src()); + + // No own entries: the io record and signature arg are gone, and the + // declared arity is exactly the spliced slots. + assert!(!swap.podlang_src().contains("SwapIO")); + let meta = &swap.actions()[0]; + let classes: Vec<&str> = meta.total_inputs().map(|r| r.class.as_str()).collect(); + assert_eq!(classes, vec!["Gem", "Coin"]); + let out: Vec<&str> = meta.total_outputs().map(|r| r.class.as_str()).collect(); + assert_eq!(out, vec!["Gem", "Coin"]); + + let mut state = TestState::default(); + let executor = base.executor(true, grounding_witness(&state, &[])); + let res = executor.action("SpawnGem", vec![]).unwrap(); + let [gem] = res.objs(); + apply_tx(&mut state, &res.tx.clone()); + let executor = base.executor(true, grounding_witness(&state, &[])); + let res = executor.action("SpawnCoin", vec![]).unwrap(); + let [coin] = res.objs(); + apply_tx(&mut state, &res.tx.clone()); + + let witness = grounding_witness(&state, &[gem.obj.commitment(), coin.obj.commitment()]); + let executor = swap.executor(true, witness); + let res = executor + .action("Swap", vec![gem.clone(), coin.clone()]) + .unwrap(); + + // Both survivors re-keyed atomically; nothing else minted. + let nullifiers = res.tx.nullifier_hashes().unwrap(); + assert!(nullifiers.contains(&txlib::object_nullifier_hash(&gem.obj).unwrap())); + assert!(nullifiers.contains(&txlib::object_nullifier_hash(&coin.obj).unwrap())); + let [new_gem, new_coin] = res.objs(); + let live = res.tx.live_commitments().unwrap(); + assert!(live.contains(&new_gem.obj.commitment())); + assert!(live.contains(&new_coin.obj.commitment())); + assert_ne!(new_gem.obj.commitment(), gem.obj.commitment()); + assert_ne!(new_coin.obj.commitment(), coin.obj.commitment()); +} From a901fa0e0c9d19874d2a09f53a70b9ed8865dd7a Mon Sep 17 00:00:00 2001 From: Dhvani Patel Date: Mon, 17 Aug 2026 15:50:52 +0530 Subject: [PATCH 11/13] Rename the Claim actions to Rekey and add per-class rekeys to craft-rocket --- examples/craft-basics/manifest.toml | 24 +-- examples/craft-basics/plugin.rhai | 28 +-- examples/craft-rocket/manifest.toml | 267 ++++++++++++++++++++++- examples/craft-rocket/plugin.rhai | 280 ++++++++++++++++++++++++- examples/swap-log-sticks/manifest.toml | 2 +- examples/swap-log-sticks/plugin.rhai | 8 +- examples/swap-log-wood/manifest.toml | 2 +- examples/swap-log-wood/plugin.rhai | 8 +- libs/sdk/src/lib.rs | 4 +- libs/sdk/src/tests.rs | 38 ++-- 10 files changed, 602 insertions(+), 59 deletions(-) diff --git a/examples/craft-basics/manifest.toml b/examples/craft-basics/manifest.toml index b17764af..204777fc 100644 --- a/examples/craft-basics/manifest.toml +++ b/examples/craft-basics/manifest.toml @@ -82,31 +82,31 @@ emoji = "๐Ÿชจ" description = "Mine stone using a stone pick (consumes durability)." [[actions]] -name = "ClaimLog" -emoji = "๐Ÿชง" +name = "RekeyLog" +emoji = "๐Ÿ”‘" description = "Take exclusive possession of a received log by rotating its key." [[actions]] -name = "ClaimWood" -emoji = "๐Ÿชง" +name = "RekeyWood" +emoji = "๐Ÿ”‘" description = "Take exclusive possession of a received wood by rotating its key." [[actions]] -name = "ClaimStick" -emoji = "๐Ÿชง" +name = "RekeyStick" +emoji = "๐Ÿ”‘" description = "Take exclusive possession of a received stick by rotating its key." [[actions]] -name = "ClaimStone" -emoji = "๐Ÿชง" +name = "RekeyStone" +emoji = "๐Ÿ”‘" description = "Take exclusive possession of a received stone by rotating its key." [[actions]] -name = "ClaimWoodPick" -emoji = "๐Ÿชง" +name = "RekeyWoodPick" +emoji = "๐Ÿ”‘" description = "Take exclusive possession of a received wood pick by rotating its key." [[actions]] -name = "ClaimStonePick" -emoji = "๐Ÿชง" +name = "RekeyStonePick" +emoji = "๐Ÿ”‘" description = "Take exclusive possession of a received stone pick by rotating its key." diff --git a/examples/craft-basics/plugin.rhai b/examples/craft-basics/plugin.rhai index 46bf24b9..0f1a700a 100644 --- a/examples/craft-basics/plugin.rhai +++ b/examples/craft-basics/plugin.rhai @@ -71,7 +71,7 @@ fn MineStoneWithStonePick(action) { var stone = action.output("Stone"); } -// โ”€โ”€ claims โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// โ”€โ”€ rekeys โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Rotating `key` moves an object's commitment and its nullifier, so a holder // who was sent an object can spend the sender's copy out from under them. // Nothing else about the state changes, and the stable identifier carries @@ -80,37 +80,37 @@ fn MineStoneWithStonePick(action) { // These are also this plugin's extension surface: another plugin's script can // only reach the actions exposed here. -fn claim(action, obj) { +fn rekey(action, obj) { var key = action.random(); obj.update("key", key); } -fn ClaimLog(action) { +fn RekeyLog(action) { var log = action.mutate("Log"); - claim(action, log); + rekey(action, log); } -fn ClaimWood(action) { +fn RekeyWood(action) { var wood = action.mutate("Wood"); - claim(action, wood); + rekey(action, wood); } -fn ClaimStick(action) { +fn RekeyStick(action) { var stick = action.mutate("Stick"); - claim(action, stick); + rekey(action, stick); } -fn ClaimStone(action) { +fn RekeyStone(action) { var stone = action.mutate("Stone"); - claim(action, stone); + rekey(action, stone); } -fn ClaimWoodPick(action) { +fn RekeyWoodPick(action) { var pick = action.mutate("WoodPick"); - claim(action, pick); + rekey(action, pick); } -fn ClaimStonePick(action) { +fn RekeyStonePick(action) { var pick = action.mutate("StonePick"); - claim(action, pick); + rekey(action, pick); } diff --git a/examples/craft-rocket/manifest.toml b/examples/craft-rocket/manifest.toml index fd183df3..10f2be7b 100644 --- a/examples/craft-rocket/manifest.toml +++ b/examples/craft-rocket/manifest.toml @@ -2,7 +2,7 @@ name = "craft-rocket" version = "0.1.0" # Rewritten by `cargo run -p pexe -- build examples/craft-rocket`. -module_hash = "f838acbb5b8a196174ec1adb15b2dc19f60faa5ef76ab0ab60b6ee010f0f964f" +module_hash = "ce8779af93a5a494f717ba26e7280b97a6b402d84ffb17f2d716d1624aeab5ca" # โ”€โ”€ raw resources โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -737,3 +737,268 @@ description = "Final assembly: engine + casing + payload + 2 resin โ†’ 1 rocket. name = "CraftRocketCat" emoji = "๐Ÿš€" description = "Catalysed rocket โ€” reaction-chamber + catalyst, deterministic." + +[[actions]] +name = "RekeyIron" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received iron by rotating its key." + +[[actions]] +name = "RekeyCopper" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received copper by rotating its key." + +[[actions]] +name = "RekeyOil" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received oil by rotating its key." + +[[actions]] +name = "RekeySulfur" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received sulfur by rotating its key." + +[[actions]] +name = "RekeyWater" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received water by rotating its key." + +[[actions]] +name = "RekeyCane" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received cane by rotating its key." + +[[actions]] +name = "RekeyHemp" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received hemp by rotating its key." + +[[actions]] +name = "RekeyIngot" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received ingot by rotating its key." + +[[actions]] +name = "RekeyPlate" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received plate by rotating its key." + +[[actions]] +name = "RekeyPulp" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received pulp by rotating its key." + +[[actions]] +name = "RekeyFiber" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received fiber by rotating its key." + +[[actions]] +name = "RekeyAcid" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received acid by rotating its key." + +[[actions]] +name = "RekeyTar" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received tar by rotating its key." + +[[actions]] +name = "RekeyFuel" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received fuel by rotating its key." + +[[actions]] +name = "RekeyGas" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received gas by rotating its key." + +[[actions]] +name = "RekeySlag" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received slag by rotating its key." + +[[actions]] +name = "RekeyFlux" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received flux by rotating its key." + +[[actions]] +name = "RekeySludge" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received sludge by rotating its key." + +[[actions]] +name = "RekeyMold" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received mold by rotating its key." + +[[actions]] +name = "RekeyCatalyst" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received catalyst by rotating its key." + +[[actions]] +name = "RekeyBinder" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received binder by rotating its key." + +[[actions]] +name = "RekeyLye" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received lye by rotating its key." + +[[actions]] +name = "RekeyDrillBit" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received drill bit by rotating its key." + +[[actions]] +name = "RekeySolderingIron" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received soldering iron by rotating its key." + +[[actions]] +name = "RekeyPressureValve" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received pressure valve by rotating its key." + +[[actions]] +name = "RekeySteel" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received steel by rotating its key." + +[[actions]] +name = "RekeyWire" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received wire by rotating its key." + +[[actions]] +name = "RekeyCloth" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received cloth by rotating its key." + +[[actions]] +name = "RekeyBoard" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received board by rotating its key." + +[[actions]] +name = "RekeyWax" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received wax by rotating its key." + +[[actions]] +name = "RekeyGrease" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received grease by rotating its key." + +[[actions]] +name = "RekeySolvent" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received solvent by rotating its key." + +[[actions]] +name = "RekeyCoating" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received coating by rotating its key." + +[[actions]] +name = "RekeyRubber" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received rubber by rotating its key." + +[[actions]] +name = "RekeyExtract" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received extract by rotating its key." + +[[actions]] +name = "RekeyGear" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received gear by rotating its key." + +[[actions]] +name = "RekeyCoil" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received coil by rotating its key." + +[[actions]] +name = "RekeyBearing" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received bearing by rotating its key." + +[[actions]] +name = "RekeyCircuit" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received circuit by rotating its key." + +[[actions]] +name = "RekeyCanvas" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received canvas by rotating its key." + +[[actions]] +name = "RekeyPanel" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received panel by rotating its key." + +[[actions]] +name = "RekeyPistons" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received pistons by rotating its key." + +[[actions]] +name = "RekeyResin" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received resin by rotating its key." + +[[actions]] +name = "RekeyEngine" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received engine by rotating its key." + +[[actions]] +name = "RekeyCasing" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received casing by rotating its key." + +[[actions]] +name = "RekeyPayload" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received payload by rotating its key." + +[[actions]] +name = "RekeyRocket" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received rocket by rotating its key." + +[[actions]] +name = "RekeyMachineI" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received machine I by rotating its key." + +[[actions]] +name = "RekeyMachineII" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received machine II by rotating its key." + +[[actions]] +name = "RekeyBlastFurnace" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received blast furnace by rotating its key." + +[[actions]] +name = "RekeyCircuitFab" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received circuit fab by rotating its key." + +[[actions]] +name = "RekeyCrackingUnit" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received cracking unit by rotating its key." + +[[actions]] +name = "RekeyReactionChamber" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received reaction chamber by rotating its key." diff --git a/examples/craft-rocket/plugin.rhai b/examples/craft-rocket/plugin.rhai index cc2889d2..476e71c0 100644 --- a/examples/craft-rocket/plugin.rhai +++ b/examples/craft-rocket/plugin.rhai @@ -872,4 +872,282 @@ fn CraftRocketCat(action) { var catalyst = action.input("Catalyst"); var rocket = action.output("Rocket"); vdf_proof(action, rocket, 70); -} \ No newline at end of file +} +// โ”€โ”€ rekeys โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Rotating `key` moves an object's commitment and its nullifier, so a holder +// who was sent an object can spend the sender's copy out from under them. +// Nothing else about the state changes, and the stable identifier carries +// across, so the object stays the same object under new custody. +// +// These are also this plugin's extension surface: another plugin's script can +// only reach the actions exposed here. + +fn rekey(action, obj) { + var key = action.random(); + obj.update("key", key); +} + +fn RekeyIron(action) { + var obj = action.mutate("Iron"); + rekey(action, obj); +} + +fn RekeyCopper(action) { + var obj = action.mutate("Copper"); + rekey(action, obj); +} + +fn RekeyOil(action) { + var obj = action.mutate("Oil"); + rekey(action, obj); +} + +fn RekeySulfur(action) { + var obj = action.mutate("Sulfur"); + rekey(action, obj); +} + +fn RekeyWater(action) { + var obj = action.mutate("Water"); + rekey(action, obj); +} + +fn RekeyCane(action) { + var obj = action.mutate("Cane"); + rekey(action, obj); +} + +fn RekeyHemp(action) { + var obj = action.mutate("Hemp"); + rekey(action, obj); +} + +fn RekeyIngot(action) { + var obj = action.mutate("Ingot"); + rekey(action, obj); +} + +fn RekeyPlate(action) { + var obj = action.mutate("Plate"); + rekey(action, obj); +} + +fn RekeyPulp(action) { + var obj = action.mutate("Pulp"); + rekey(action, obj); +} + +fn RekeyFiber(action) { + var obj = action.mutate("Fiber"); + rekey(action, obj); +} + +fn RekeyAcid(action) { + var obj = action.mutate("Acid"); + rekey(action, obj); +} + +fn RekeyTar(action) { + var obj = action.mutate("Tar"); + rekey(action, obj); +} + +fn RekeyFuel(action) { + var obj = action.mutate("Fuel"); + rekey(action, obj); +} + +fn RekeyGas(action) { + var obj = action.mutate("Gas"); + rekey(action, obj); +} + +fn RekeySlag(action) { + var obj = action.mutate("Slag"); + rekey(action, obj); +} + +fn RekeyFlux(action) { + var obj = action.mutate("Flux"); + rekey(action, obj); +} + +fn RekeySludge(action) { + var obj = action.mutate("Sludge"); + rekey(action, obj); +} + +fn RekeyMold(action) { + var obj = action.mutate("Mold"); + rekey(action, obj); +} + +fn RekeyCatalyst(action) { + var obj = action.mutate("Catalyst"); + rekey(action, obj); +} + +fn RekeyBinder(action) { + var obj = action.mutate("Binder"); + rekey(action, obj); +} + +fn RekeyLye(action) { + var obj = action.mutate("Lye"); + rekey(action, obj); +} + +fn RekeyDrillBit(action) { + var obj = action.mutate("DrillBit"); + rekey(action, obj); +} + +fn RekeySolderingIron(action) { + var obj = action.mutate("SolderingIron"); + rekey(action, obj); +} + +fn RekeyPressureValve(action) { + var obj = action.mutate("PressureValve"); + rekey(action, obj); +} + +fn RekeySteel(action) { + var obj = action.mutate("Steel"); + rekey(action, obj); +} + +fn RekeyWire(action) { + var obj = action.mutate("Wire"); + rekey(action, obj); +} + +fn RekeyCloth(action) { + var obj = action.mutate("Cloth"); + rekey(action, obj); +} + +fn RekeyBoard(action) { + var obj = action.mutate("Board"); + rekey(action, obj); +} + +fn RekeyWax(action) { + var obj = action.mutate("Wax"); + rekey(action, obj); +} + +fn RekeyGrease(action) { + var obj = action.mutate("Grease"); + rekey(action, obj); +} + +fn RekeySolvent(action) { + var obj = action.mutate("Solvent"); + rekey(action, obj); +} + +fn RekeyCoating(action) { + var obj = action.mutate("Coating"); + rekey(action, obj); +} + +fn RekeyRubber(action) { + var obj = action.mutate("Rubber"); + rekey(action, obj); +} + +fn RekeyExtract(action) { + var obj = action.mutate("Extract"); + rekey(action, obj); +} + +fn RekeyGear(action) { + var obj = action.mutate("Gear"); + rekey(action, obj); +} + +fn RekeyCoil(action) { + var obj = action.mutate("Coil"); + rekey(action, obj); +} + +fn RekeyBearing(action) { + var obj = action.mutate("Bearing"); + rekey(action, obj); +} + +fn RekeyCircuit(action) { + var obj = action.mutate("Circuit"); + rekey(action, obj); +} + +fn RekeyCanvas(action) { + var obj = action.mutate("Canvas"); + rekey(action, obj); +} + +fn RekeyPanel(action) { + var obj = action.mutate("Panel"); + rekey(action, obj); +} + +fn RekeyPistons(action) { + var obj = action.mutate("Pistons"); + rekey(action, obj); +} + +fn RekeyResin(action) { + var obj = action.mutate("Resin"); + rekey(action, obj); +} + +fn RekeyEngine(action) { + var obj = action.mutate("Engine"); + rekey(action, obj); +} + +fn RekeyCasing(action) { + var obj = action.mutate("Casing"); + rekey(action, obj); +} + +fn RekeyPayload(action) { + var obj = action.mutate("Payload"); + rekey(action, obj); +} + +fn RekeyRocket(action) { + var obj = action.mutate("Rocket"); + rekey(action, obj); +} + +fn RekeyMachineI(action) { + var obj = action.mutate("MachineI"); + rekey(action, obj); +} + +fn RekeyMachineII(action) { + var obj = action.mutate("MachineII"); + rekey(action, obj); +} + +fn RekeyBlastFurnace(action) { + var obj = action.mutate("BlastFurnace"); + rekey(action, obj); +} + +fn RekeyCircuitFab(action) { + var obj = action.mutate("CircuitFab"); + rekey(action, obj); +} + +fn RekeyCrackingUnit(action) { + var obj = action.mutate("CrackingUnit"); + rekey(action, obj); +} + +fn RekeyReactionChamber(action) { + var obj = action.mutate("ReactionChamber"); + rekey(action, obj); +} diff --git a/examples/swap-log-sticks/manifest.toml b/examples/swap-log-sticks/manifest.toml index 7f43fb53..79865a44 100644 --- a/examples/swap-log-sticks/manifest.toml +++ b/examples/swap-log-sticks/manifest.toml @@ -1,4 +1,4 @@ -# A receipt-free recipe: every slot is spliced from craft-basics claims, +# A receipt-free recipe: every slot is spliced from craft-basics rekeys, # so this plugin declares no classes of its own (`classes = []`) and its # action compiles without an io record. Three re-keyed survivors plus the # chain overhead land exactly on pod2's 8-wildcard predicate budget -- diff --git a/examples/swap-log-sticks/plugin.rhai b/examples/swap-log-sticks/plugin.rhai index f17bb599..3a750796 100644 --- a/examples/swap-log-sticks/plugin.rhai +++ b/examples/swap-log-sticks/plugin.rhai @@ -1,8 +1,8 @@ // The asymmetric-count swap: one log crosses one way, two sticks the -// other. Each claim rotates its object's key so the giver's copy dies; +// other. Each rekey rotates its object's key so the giver's copy dies; // the proof budget (see manifest.toml) rules out a receipt output. fn SwapLogSticks(action) { - var log = action.subaction("craft-basics::ClaimLog"); - var stick_a = action.subaction("craft-basics::ClaimStick"); - var stick_b = action.subaction("craft-basics::ClaimStick"); + var log = action.subaction("craft-basics::RekeyLog"); + var stick_a = action.subaction("craft-basics::RekeyStick"); + var stick_b = action.subaction("craft-basics::RekeyStick"); } diff --git a/examples/swap-log-wood/manifest.toml b/examples/swap-log-wood/manifest.toml index 8676a55f..0bec4b69 100644 --- a/examples/swap-log-wood/manifest.toml +++ b/examples/swap-log-wood/manifest.toml @@ -1,7 +1,7 @@ # A plugin whose script composes another plugin's actions. # # There is nothing here about the dependency: the script's qualified -# `subaction("craft-basics::ClaimLog")` calls declare it by using it, and the +# `subaction("craft-basics::RekeyLog")` calls declare it by using it, and the # catalog loads craft-basics first so its compiled batch is available. The # module_hash below covers that import, so it moves whenever craft-basics does. diff --git a/examples/swap-log-wood/plugin.rhai b/examples/swap-log-wood/plugin.rhai index 8878576f..fc63d547 100644 --- a/examples/swap-log-wood/plugin.rhai +++ b/examples/swap-log-wood/plugin.rhai @@ -1,6 +1,6 @@ // A plugin that reaches into another one. // -// `action.subaction("craft-basics::ClaimLog")` runs craft-basics' own action +// `action.subaction("craft-basics::RekeyLog")` runs craft-basics' own action // as a nested step of this one, which is what makes the log's class guard // match: a class is the OR over the actions of the script that defines it, so // `action.mutate("Log")` written here would mean *this* plugin's Log and @@ -12,11 +12,11 @@ // craft-basics does. // // `Swapped` is a class this plugin owns, minted in the same transaction, so -// the receipt lands if and only if both claims do. +// the receipt lands if and only if both rekeys do. fn SwapLogWood(action) { - var log = action.subaction("craft-basics::ClaimLog"); - var wood = action.subaction("craft-basics::ClaimWood"); + var log = action.subaction("craft-basics::RekeyLog"); + var wood = action.subaction("craft-basics::RekeyWood"); var receipt = action.output("Swapped"); receipt.set([ ["swapped_at", state_header.block_timestamp] diff --git a/libs/sdk/src/lib.rs b/libs/sdk/src/lib.rs index aee8b662..e94d3f4c 100644 --- a/libs/sdk/src/lib.rs +++ b/libs/sdk/src/lib.rs @@ -2122,7 +2122,7 @@ pub struct ClassMeta { /// Plugin modules a script may reach with a qualified sub-action call. /// /// Keyed by plugin name, which is what appears in the script: -/// `action.subaction("craft-basics::ClaimLog")`. Calling a dependency's +/// `action.subaction("craft-basics::RekeyLog")`. Calling a dependency's /// action embeds its batch id in the caller's own batch, so the caller's /// class hashes move whenever a dependency changes. pub type PluginDeps = BTreeMap>; @@ -2157,7 +2157,7 @@ impl SubRef { /// Plugin names a script reaches with a qualified sub-action call. /// /// The dependency set is declared by use: a script that calls -/// `subaction("craft-basics::ClaimLog")` depends on craft-basics, with +/// `subaction("craft-basics::RekeyLog")` depends on craft-basics, with /// nothing to keep in step in the manifest. Scanning the source is enough /// because the SDK only accepts a literal name here -- a computed one /// would not survive the load phase, which runs with no inputs. diff --git a/libs/sdk/src/tests.rs b/libs/sdk/src/tests.rs index a2b06525..51577dab 100644 --- a/libs/sdk/src/tests.rs +++ b/libs/sdk/src/tests.rs @@ -944,7 +944,7 @@ fn test_batch_id_ignores_names() { /// A script reaching into another plugin with a qualified sub-action call. /// The parent's predicate contains the dependency's action, so the two are /// one action rather than siblings -- and the parent can pin its own output -/// to the object the dependency just claimed. +/// to the object the dependency just rekeyed. #[test] fn test_qualified_subaction_into_a_dependency() { let _ = env_logger::builder().is_test(true).try_init(); @@ -952,28 +952,28 @@ fn test_qualified_subaction_into_a_dependency() { fn SpawnGem(action) { var gem = action.output("Gem"); } - fn ClaimGem(action) { + fn RekeyGem(action) { var gem = action.mutate("Gem"); var key = action.random(); gem.update("key", key); } "#; - // Reaches base::ClaimGem and mints a receipt of its own class, bound to + // Reaches base::RekeyGem and mints a receipt of its own class, bound to // the gem's stable identifier. let swap_src = r#" - fn ClaimAndReceipt(action) { - var gem = action.subaction("base::ClaimGem"); + fn RekeyAndReceipt(action) { + var gem = action.subaction("base::RekeyGem"); var receipt = action.output("Receipt"); } "#; let sdk = Sdk::default(); let base = sdk - .load_module_from_src_actions(base_src, &["SpawnGem", "ClaimGem"]) + .load_module_from_src_actions(base_src, &["SpawnGem", "RekeyGem"]) .unwrap(); let mut deps = PluginDeps::new(); deps.insert("base".to_string(), base.clone()); let swap = sdk - .load_module_from_src_deps(swap_src, &["ClaimAndReceipt"], deps) + .load_module_from_src_deps(swap_src, &["RekeyAndReceipt"], deps) .unwrap(); println!("{}", swap.podlang_src()); @@ -1003,26 +1003,26 @@ fn test_qualified_subaction_into_a_dependency() { let witness = grounding_witness(&state, &[gem.obj.commitment()]); let executor = swap.executor(true, witness); let res = executor - .action("ClaimAndReceipt", vec![gem.clone()]) + .action("RekeyAndReceipt", vec![gem.clone()]) .unwrap(); // The gem was re-keyed and a receipt minted, in one action. let nullifiers = res.tx.nullifier_hashes().unwrap(); assert!(nullifiers.contains(&txlib::object_nullifier_hash(&gem.obj).unwrap())); - let [claimed, receipt] = res.objs(); + let [rekeyed, receipt] = res.objs(); let live = res.tx.live_commitments().unwrap(); - assert!(live.contains(&claimed.obj.commitment())); + assert!(live.contains(&rekeyed.obj.commitment())); assert!(live.contains(&receipt.obj.commitment())); - // The claimed gem keeps the dependency's class; the receipt carries the + // The rekeyed gem keeps the dependency's class; the receipt carries the // caller's own. let type_of = |obj: &pod2::middleware::containers::Dictionary| { obj.get(&pod2::middleware::StrKey::from("type")) .unwrap() .unwrap() }; - assert_eq!(type_of(&claimed.obj), type_of(&gem.obj)); - assert_ne!(type_of(&receipt.obj), type_of(&claimed.obj)); + assert_eq!(type_of(&rekeyed.obj), type_of(&gem.obj)); + assert_ne!(type_of(&receipt.obj), type_of(&rekeyed.obj)); } #[test] @@ -1035,32 +1035,32 @@ fn test_receipt_free_composite_action() { fn SpawnCoin(action) { var coin = action.output("Coin"); } - fn ClaimGem(action) { + fn RekeyGem(action) { var gem = action.mutate("Gem"); var key = action.random(); gem.update("key", key); } - fn ClaimCoin(action) { + fn RekeyCoin(action) { var coin = action.mutate("Coin"); var key = action.random(); coin.update("key", key); } "#; // A pure swap: every object slot is spliced from the dependency's - // claims, the caller mints nothing of its own. The caller's io record + // rekeys, the caller mints nothing of its own. The caller's io record // is empty, so its predicate has no `io` arg at all -- previously this // failed to compile as `record SwapIO = ()`. let swap_src = r#" fn Swap(action) { - var gem = action.subaction("base::ClaimGem"); - var coin = action.subaction("base::ClaimCoin"); + var gem = action.subaction("base::RekeyGem"); + var coin = action.subaction("base::RekeyCoin"); } "#; let sdk = Sdk::default(); let base = sdk .load_module_from_src_actions( base_src, - &["SpawnGem", "SpawnCoin", "ClaimGem", "ClaimCoin"], + &["SpawnGem", "SpawnCoin", "RekeyGem", "RekeyCoin"], ) .unwrap(); let mut deps = PluginDeps::new(); From eea86421f46963c71d30c69d651dde62fd7a42ab Mon Sep 17 00:00:00 2001 From: Dhvani Patel Date: Mon, 17 Aug 2026 16:13:04 +0530 Subject: [PATCH 12/13] Declare plugin imports in the manifest with path and hash pins, and make the swap example log-for-copper --- examples/craft-basics/plugin.rhai | 7 +- examples/craft-rocket/plugin.rhai | 7 +- examples/swap-log-copper/manifest.toml | 33 ++++++++ examples/swap-log-copper/plugin.rhai | 27 +++++++ examples/swap-log-wood/manifest.toml | 22 ------ examples/swap-log-wood/plugin.rhai | 24 ------ libs/driver/src/pexe_catalog.rs | 85 +++++++++++++------- libs/pexe/src/bin/pexe.rs | 21 +++-- libs/pexe/src/lib.rs | 104 ++++++++++++++++++++++++- libs/sdk/src/manifest.rs | 26 +++++++ 10 files changed, 270 insertions(+), 86 deletions(-) create mode 100644 examples/swap-log-copper/manifest.toml create mode 100644 examples/swap-log-copper/plugin.rhai delete mode 100644 examples/swap-log-wood/manifest.toml delete mode 100644 examples/swap-log-wood/plugin.rhai diff --git a/examples/craft-basics/plugin.rhai b/examples/craft-basics/plugin.rhai index 0f1a700a..21b183b0 100644 --- a/examples/craft-basics/plugin.rhai +++ b/examples/craft-basics/plugin.rhai @@ -77,8 +77,11 @@ fn MineStoneWithStonePick(action) { // Nothing else about the state changes, and the stable identifier carries // across, so the object stays the same object under new custody. // -// These are also this plugin's extension surface: another plugin's script can -// only reach the actions exposed here. +// These are also part of this plugin's extension surface -- which is every +// [[actions]] entry in the manifest, hidden ones included (hidden only skips +// catalog listings). A dependent plugin's script reaches any of those with a +// qualified subaction call; lowercase helpers like rekey() are inlined at +// compile time and are not callable, so the manifest IS the public API. fn rekey(action, obj) { var key = action.random(); diff --git a/examples/craft-rocket/plugin.rhai b/examples/craft-rocket/plugin.rhai index 476e71c0..4bdfc45f 100644 --- a/examples/craft-rocket/plugin.rhai +++ b/examples/craft-rocket/plugin.rhai @@ -879,8 +879,11 @@ fn CraftRocketCat(action) { // Nothing else about the state changes, and the stable identifier carries // across, so the object stays the same object under new custody. // -// These are also this plugin's extension surface: another plugin's script can -// only reach the actions exposed here. +// These are also part of this plugin's extension surface -- which is every +// [[actions]] entry in the manifest, hidden ones included (hidden only skips +// catalog listings). A dependent plugin's script reaches any of those with a +// qualified subaction call; lowercase helpers like rekey() are inlined at +// compile time and are not callable, so the manifest IS the public API. fn rekey(action, obj) { var key = action.random(); diff --git a/examples/swap-log-copper/manifest.toml b/examples/swap-log-copper/manifest.toml new file mode 100644 index 00000000..d1ba6f01 --- /dev/null +++ b/examples/swap-log-copper/manifest.toml @@ -0,0 +1,33 @@ +# A plugin whose script composes actions from two other plugins, with the +# dependencies declared as [[imports]]: each entry names the plugin, the path +# its built pexe loads from at build time (relative to this directory), and +# the batch id to pin. A stale or swapped dependency then fails with a message +# naming the import, rather than as this plugin's own module_hash mismatch. +# The declaration must cover exactly what the script's qualified subaction +# calls compose -- a missing or unused entry is a build error. + +[plugin] +name = "swap-log-copper" +version = "0.1.0" +# Rewritten by `cargo run -p pexe -- build examples/swap-log-copper`. +module_hash = "5f108a4f9e3b5ba79d268b25d3550bd7f280836f5639ab9fa6ec3170bec565d4" + +[[imports]] +name = "craft-basics" +path = "../../target/pexe/craft-basics.pexe" +module_hash = "44d6fd33861e3ab021c0362a348243226cfa94345c912676102115a4864cd084" + +[[imports]] +name = "craft-rocket" +path = "../../target/pexe/craft-rocket.pexe" +module_hash = "ce8779af93a5a494f717ba26e7280b97a6b402d84ffb17f2d716d1624aeab5ca" + +[[classes]] +name = "Swapped" +emoji = "๐Ÿงพ" +description = "A receipt minted by a swap, stamped with the grounding block's timestamp." + +[[actions]] +name = "SwapLogCopper" +emoji = "๐Ÿค" +description = "Re-key one log and one copper together and mint a receipt, in a single transaction." diff --git a/examples/swap-log-copper/plugin.rhai b/examples/swap-log-copper/plugin.rhai new file mode 100644 index 00000000..2d23fc0c --- /dev/null +++ b/examples/swap-log-copper/plugin.rhai @@ -0,0 +1,27 @@ +// A plugin that reaches into two other plugins. +// +// `action.subaction("craft-basics::RekeyLog")` runs craft-basics' own action +// as a nested step of this one, which is what makes the log's class guard +// match: a class is the OR over the actions of the script that defines it, so +// `action.mutate("Log")` written here would mean *this* plugin's Log and +// could never spend a craft-basics one. The copper side works the same way +// through craft-rocket. +// +// This example declares its dependencies in the manifest's [[imports]] -- +// each entry names the plugin, the pexe path to load it from, and the batch +// id to pin (see swap-log-sticks for the scan-only style, where the call +// itself is the declaration). Either way the coupling is the same: calling +// into a plugin puts its batch id inside this one's, so this plugin's own +// classes rehash whenever a dependency does. +// +// `Swapped` is a class this plugin owns, minted in the same transaction, so +// the receipt lands if and only if both rekeys do. + +fn SwapLogCopper(action) { + var log = action.subaction("craft-basics::RekeyLog"); + var copper = action.subaction("craft-rocket::RekeyCopper"); + var receipt = action.output("Swapped"); + receipt.set([ + ["swapped_at", state_header.block_timestamp] + ]); +} diff --git a/examples/swap-log-wood/manifest.toml b/examples/swap-log-wood/manifest.toml deleted file mode 100644 index 0bec4b69..00000000 --- a/examples/swap-log-wood/manifest.toml +++ /dev/null @@ -1,22 +0,0 @@ -# A plugin whose script composes another plugin's actions. -# -# There is nothing here about the dependency: the script's qualified -# `subaction("craft-basics::RekeyLog")` calls declare it by using it, and the -# catalog loads craft-basics first so its compiled batch is available. The -# module_hash below covers that import, so it moves whenever craft-basics does. - -[plugin] -name = "swap-log-wood" -version = "0.1.0" -# Rewritten by `cargo run -p pexe -- build examples/swap-log-wood`. -module_hash = "73666676f7531778615b77172f281f2f7a400f4e7feeb3102d1468c367826f00" - -[[classes]] -name = "Swapped" -emoji = "๐Ÿงพ" -description = "A receipt minted by a swap, stamped with the grounding block's timestamp." - -[[actions]] -name = "SwapLogWood" -emoji = "๐Ÿค" -description = "Re-key one log and one wood together and mint a receipt, in a single transaction." diff --git a/examples/swap-log-wood/plugin.rhai b/examples/swap-log-wood/plugin.rhai deleted file mode 100644 index fc63d547..00000000 --- a/examples/swap-log-wood/plugin.rhai +++ /dev/null @@ -1,24 +0,0 @@ -// A plugin that reaches into another one. -// -// `action.subaction("craft-basics::RekeyLog")` runs craft-basics' own action -// as a nested step of this one, which is what makes the log's class guard -// match: a class is the OR over the actions of the script that defines it, so -// `action.mutate("Log")` written here would mean *this* plugin's Log and -// could never spend a craft-basics one. -// -// Declaring the dependency is the call itself -- nothing to keep in step in -// the manifest. The cost is coupling: calling into craft-basics puts its -// batch id inside this plugin's, so this plugin's own classes rehash whenever -// craft-basics does. -// -// `Swapped` is a class this plugin owns, minted in the same transaction, so -// the receipt lands if and only if both rekeys do. - -fn SwapLogWood(action) { - var log = action.subaction("craft-basics::RekeyLog"); - var wood = action.subaction("craft-basics::RekeyWood"); - var receipt = action.output("Swapped"); - receipt.set([ - ["swapped_at", state_header.block_timestamp] - ]); -} diff --git a/libs/driver/src/pexe_catalog.rs b/libs/driver/src/pexe_catalog.rs index e6e30002..5fc3f68d 100644 --- a/libs/driver/src/pexe_catalog.rs +++ b/libs/driver/src/pexe_catalog.rs @@ -94,6 +94,24 @@ impl PexeCatalog { anyhow!("plugin {plugin_name} calls into {dep_name}, which is not installed") })?; let dep = self.load_plugin_module(sdk, dep_idx, cache)?; + // A declared import pin turns version drift into a message naming + // the dependency; without one the mismatch still fails, but only + // as the caller's own whole-module hash check. Import paths are a + // build-machine convenience and mean nothing here. + if let Some(pinned) = plugin + .manifest + .imports + .iter() + .find(|import| import.name == dep_name) + .and_then(|import| import.module_hash) + { + let compiled = dep.module().batch.id(); + if pinned != compiled { + return Err(anyhow!( + "plugin {plugin_name} pins import {dep_name} at {pinned:#}, but the installed {dep_name} compiles to {compiled:#}" + )); + } + } deps.insert(dep_name, dep); } let module = sdk @@ -464,11 +482,20 @@ pub(crate) fn test_plugin_bytes() -> Vec { } #[cfg(test)] -/// The bundled swap example, packed from source like the plugin above. Its -/// script reaches craft-basics with a qualified sub-action call. +/// The second bundled plugin, for cross-plugin composition tests. +pub(crate) fn test_rocket_bytes() -> Vec { + let manifest = include_str!("../../../examples/craft-rocket/manifest.toml"); + let script = include_str!("../../../examples/craft-rocket/plugin.rhai"); + pexe::pack(manifest, script).expect("test rocket packs") +} + +#[cfg(test)] +/// The bundled swap example, packed from source like the plugins above. Its +/// script reaches craft-basics AND craft-rocket with qualified sub-action +/// calls, and its manifest pins both through [[imports]]. pub(crate) fn bundled_swap_bytes() -> Vec { - let manifest = include_str!("../../../examples/swap-log-wood/manifest.toml"); - let script = include_str!("../../../examples/swap-log-wood/plugin.rhai"); + let manifest = include_str!("../../../examples/swap-log-copper/manifest.toml"); + let script = include_str!("../../../examples/swap-log-copper/plugin.rhai"); pexe::pack(manifest, script).expect("bundled swap packs") } @@ -890,38 +917,40 @@ description = "consume a Foo to make a Bar" Some(Hash(value.raw().0)) } - /// The bundled swap example calls into craft-basics from its script. - /// Its own `module_hash` covers that import, so any change to - /// craft-basics invalidates it until it is rebuilt -- which is what this - /// checks. + /// The bundled swap example calls into craft-basics and craft-rocket + /// from its script, and its manifest pins both via [[imports]]. Its own + /// `module_hash` covers those imports, so any change to either + /// dependency invalidates it until it is rebuilt -- which is what this + /// checks, with the pins turning drift into per-import messages. #[test] - fn test_bundled_swap_loads_against_bundled_plugin() { + fn test_bundled_swap_loads_against_bundled_plugins() { let catalog = PexeCatalog::from_bytes( [ - (PathBuf::from("swap-log-wood.pexe"), bundled_swap_bytes()), + (PathBuf::from("swap-log-copper.pexe"), bundled_swap_bytes()), (PathBuf::from("craft-basics.pexe"), test_plugin_bytes()), + (PathBuf::from("craft-rocket.pexe"), test_rocket_bytes()), ], true, ) - .expect("bundled swap loads -- if this fails, rebuild examples/swap-log-wood"); + .expect("bundled swap loads -- if this fails, rebuild examples/swap-log-copper"); let swap = catalog - .get_action(&QualifiedName::new("swap-log-wood", "SwapLogWood")) + .get_action(&QualifiedName::new("swap-log-copper", "SwapLogCopper")) .expect("swap is a catalog action"); let ids = |refs: &[ClassRef]| -> Vec { refs.iter().map(|r| r.class.id()).collect() }; - // Consumes craft-basics classes through the qualified calls, and + // Consumes the dependencies' classes through the qualified calls, and // produces those plus its own receipt. assert_eq!( ids(&swap.total_inputs), - vec!["craft-basics::Log", "craft-basics::Wood"] + vec!["craft-basics::Log", "craft-rocket::Copper"] ); assert_eq!( ids(&swap.total_outputs), vec![ "craft-basics::Log", - "craft-basics::Wood", - "swap-log-wood::Swapped" + "craft-rocket::Copper", + "swap-log-copper::Swapped" ] ); } @@ -934,7 +963,8 @@ description = "consume a Foo to make a Bar" let catalog = PexeCatalog::from_bytes( [ (PathBuf::from("craft-basics.pexe"), test_plugin_bytes()), - (PathBuf::from("swap-log-wood.pexe"), bundled_swap_bytes()), + (PathBuf::from("craft-rocket.pexe"), test_rocket_bytes()), + (PathBuf::from("swap-log-copper.pexe"), bundled_swap_bytes()), ], true, ) @@ -954,37 +984,36 @@ description = "consume a Foo to make a Bar" out }; let log = run(QualifiedName::new("craft-basics", "FindLog"), vec![]).obj(0); - let spare = run(QualifiedName::new("craft-basics", "FindLog"), vec![]).obj(0); - let wood = run(QualifiedName::new("craft-basics", "CraftWood"), vec![spare]).obj(0); + let copper = run(QualifiedName::new("craft-rocket", "MineCopper"), vec![]).obj(0); let out = run( - QualifiedName::new("swap-log-wood", "SwapLogWood"), - vec![log.clone(), wood.clone()], + QualifiedName::new("swap-log-copper", "SwapLogCopper"), + vec![log.clone(), copper.clone()], ); let nullifiers = out.tx.nullifier_hashes().unwrap(); - assert_eq!(nullifiers.len(), 2, "both claims spent their input"); + assert_eq!(nullifiers.len(), 2, "both rekeys spent their input"); assert!(nullifiers.contains(&txlib::object_nullifier_hash(&log.obj).unwrap())); - assert!(nullifiers.contains(&txlib::object_nullifier_hash(&wood.obj).unwrap())); + assert!(nullifiers.contains(&txlib::object_nullifier_hash(&copper.obj).unwrap())); - assert_eq!(out.objs.len(), 3, "log, wood, and the receipt"); + assert_eq!(out.objs.len(), 3, "log, copper, and the receipt"); let live = out.tx.live_commitments().unwrap(); for produced in &out.objs { assert!(live.contains(&produced.obj.commitment())); } - // The claimed objects keep craft-basics' classes; only the receipt - // carries this plugin's own. + // The rekeyed objects keep their defining plugins' classes; only the + // receipt carries this plugin's own. assert_eq!( obj_type_hash_for_test(&out.obj(0).obj).unwrap(), obj_type_hash_for_test(&log.obj).unwrap() ); assert_eq!( obj_type_hash_for_test(&out.obj(1).obj).unwrap(), - obj_type_hash_for_test(&wood.obj).unwrap() + obj_type_hash_for_test(&copper.obj).unwrap() ); let swapped = catalog - .get_class(&QualifiedName::new("swap-log-wood", "Swapped")) + .get_class(&QualifiedName::new("swap-log-copper", "Swapped")) .expect("Swapped class present"); assert_eq!( obj_type_hash_for_test(&out.obj(2).obj).unwrap(), diff --git a/libs/pexe/src/bin/pexe.rs b/libs/pexe/src/bin/pexe.rs index 71cae910..dbbb3a70 100644 --- a/libs/pexe/src/bin/pexe.rs +++ b/libs/pexe/src/bin/pexe.rs @@ -6,7 +6,8 @@ use anyhow::{Context, Result, anyhow}; use clap::{Parser, Subcommand}; use pexe::{ MANIFEST_FILE, PEXE_EXTENSION, PluginSource, compile_module_hash_with_deps, inspect, install, - pack, read_pexe_file, resolve_script_deps, set_manifest_hash, unpack, + pack, read_pexe_file, resolve_declared_imports, resolve_script_deps, set_manifest_hash, + unpack, }; // These names intentionally mirror `driver::paths::{DOBJ_HOME_DIR, ACTIONS_DIR}`. @@ -348,13 +349,19 @@ fn build_one( let plugin_name = manifest.plugin.name.clone(); // Compile the script to derive the real module hash from the pod2 batch id. - // Any plugin this script calls into is compiled first, from the install - // dir, since its batch id is part of this hash. - let dep_search_dir = match install_dir { - Some(dir) => dir.to_path_buf(), - None => default_install_dir()?, + // Any plugin this script calls into is compiled first, since its batch id + // is part of this hash: from the manifest's declared [[imports]] when + // present (each entry names its pexe path and may pin its hash), else by + // scanning the script and resolving each name from the install dir. + let deps = if manifest.imports.is_empty() { + let dep_search_dir = match install_dir { + Some(dir) => dir.to_path_buf(), + None => default_install_dir()?, + }; + resolve_script_deps(&source.script, &dep_search_dir)? + } else { + resolve_declared_imports(&source.root, &manifest.imports, &source.script)? }; - let deps = resolve_script_deps(&source.script, &dep_search_dir)?; let real_hash = compile_module_hash_with_deps(&manifest, &source.script, deps)?; let declared_hash = format!("{:#}", manifest.plugin.module_hash); let declared_hash = declared_hash.trim_start_matches("0x").to_lowercase(); diff --git a/libs/pexe/src/lib.rs b/libs/pexe/src/lib.rs index 40a88d0b..ac704089 100644 --- a/libs/pexe/src/lib.rs +++ b/libs/pexe/src/lib.rs @@ -11,8 +11,14 @@ use std::io::{Cursor, Read, Write}; use std::path::{Path, PathBuf}; +use std::collections::BTreeMap; +use std::rc::Rc; + use anyhow::{Context, Result, anyhow, bail}; -use sdk::{PluginDeps, Sdk, manifest::Manifest}; +use sdk::{ + PluginDeps, Sdk, SdkModule, + manifest::{Import, Manifest}, +}; use zip::{ZipArchive, ZipWriter, write::SimpleFileOptions}; pub mod fixtures; @@ -177,6 +183,102 @@ pub fn resolve_script_deps(script: &str, search_dir: &Path) -> Result Result { + let sdk = Sdk::default(); + + let mut declared: BTreeMap<&str, &Import> = BTreeMap::new(); + for import in imports { + if declared.insert(import.name.as_str(), import).is_some() { + return Err(anyhow!("[[imports]] declares {} twice", import.name)); + } + } + let scanned = sdk::script_dependencies(script); + for name in &scanned { + if !declared.contains_key(name.as_str()) { + return Err(anyhow!( + "this script calls into {name}, but [[imports]] does not declare it" + )); + } + } + for name in declared.keys() { + if !scanned.iter().any(|scan| scan == name) { + return Err(anyhow!( + "[[imports]] declares {name}, but the script never calls into it" + )); + } + } + + fn load_import( + sdk: &Sdk, + plugin_root: &Path, + declared: &BTreeMap<&str, &Import>, + name: &str, + cache: &mut PluginDeps, + ) -> Result> { + if let Some(module) = cache.get(name) { + return Ok(module.clone()); + } + let import = declared.get(name).ok_or_else(|| { + anyhow!("{name} is not declared in [[imports]]") + })?; + let path = plugin_root.join(&import.path); + let bytes = read_pexe_file(&path) + .with_context(|| format!("import {name}: no pexe at {}", path.display()))?; + let (manifest, dep_script) = unpack(&bytes)?; + if manifest.plugin.name != name { + return Err(anyhow!( + "import {name}: the pexe at {} is plugin {}", + path.display(), + manifest.plugin.name + )); + } + let mut dep_deps = PluginDeps::new(); + for transitive in sdk::script_dependencies(&dep_script) { + let module = load_import(sdk, plugin_root, declared, &transitive, cache) + .with_context(|| { + format!("import {name} composes {transitive}, which must also be declared") + })?; + dep_deps.insert(transitive, module); + } + let module = sdk + .load_module_from_manifest_deps(&dep_script, &manifest, dep_deps) + .map_err(|err| anyhow!("import {name}: failed to compile: {err}"))?; + if let Some(pinned) = import.module_hash { + let compiled = module.module().batch.id(); + if pinned != compiled { + return Err(anyhow!( + "import {name}: manifest pins module_hash {pinned:#}, but {} compiles to {compiled:#}", + path.display() + )); + } + } + cache.insert(name.to_string(), module.clone()); + Ok(module) + } + + let mut cache = PluginDeps::new(); + let mut deps = PluginDeps::new(); + for name in scanned { + let module = load_import(&sdk, plugin_root, &declared, &name, &mut cache)?; + deps.insert(name, module); + } + Ok(deps) +} + /// Rewrite the `module_hash` line in a manifest's TOML source to the given hash, /// preserving formatting of everything else. Adds the line under `[plugin]` if /// absent. diff --git a/libs/sdk/src/manifest.rs b/libs/sdk/src/manifest.rs index b67f66e3..21792d6c 100644 --- a/libs/sdk/src/manifest.rs +++ b/libs/sdk/src/manifest.rs @@ -6,6 +6,15 @@ pub struct Manifest { pub plugin: Plugin, pub classes: Vec, pub actions: Vec, + /// Declared dependencies. Optional: with no entries, builders fall back + /// to scanning the script for qualified sub-action calls and resolving + /// each plugin by name from an install directory. With entries, the + /// declaration is authoritative: it must cover the scanned set exactly, + /// each dependency loads from its `path`, and a declared `module_hash` + /// is verified per dependency -- so version drift names the offending + /// import instead of surfacing as a whole-plugin hash mismatch. + #[serde(default)] + pub imports: Vec, } #[derive(Debug, Deserialize)] @@ -31,6 +40,23 @@ pub struct Action { pub hidden: bool, } +#[derive(Debug, Deserialize)] +pub struct Import { + /// The dependency's plugin name, as it appears in qualified calls. + pub name: String, + /// Where the built `.pexe` lives, relative to this manifest's directory. + /// A build-time convenience only: installed catalogs still resolve + /// dependencies by name, so the path never needs to exist off the + /// machine that built the plugin. + pub path: String, + /// The dependency batch id to pin. Optional; when present, resolution + /// fails with a per-import message if the pexe at `path` (or, in an + /// installed catalog, the plugin of this name) compiles to a different + /// batch. + #[serde(default)] + pub module_hash: Option, +} + #[cfg(test)] mod tests { use super::*; From 20bb6daabd1d45de7b1cbb495464bf723efdb5dd Mon Sep 17 00:00:00 2001 From: Dhvani Patel Date: Mon, 17 Aug 2026 16:41:13 +0530 Subject: [PATCH 13/13] Make declared imports the only build-time dependency resolution --- examples/swap-log-copper/plugin.rhai | 12 +++---- examples/swap-log-sticks/manifest.toml | 5 +++ libs/pexe/src/bin/pexe.rs | 20 ++++------- libs/pexe/src/lib.rs | 48 ++++++-------------------- libs/sdk/src/manifest.rs | 14 ++++---- 5 files changed, 34 insertions(+), 65 deletions(-) diff --git a/examples/swap-log-copper/plugin.rhai b/examples/swap-log-copper/plugin.rhai index 2d23fc0c..ae244a44 100644 --- a/examples/swap-log-copper/plugin.rhai +++ b/examples/swap-log-copper/plugin.rhai @@ -7,12 +7,12 @@ // could never spend a craft-basics one. The copper side works the same way // through craft-rocket. // -// This example declares its dependencies in the manifest's [[imports]] -- -// each entry names the plugin, the pexe path to load it from, and the batch -// id to pin (see swap-log-sticks for the scan-only style, where the call -// itself is the declaration). Either way the coupling is the same: calling -// into a plugin puts its batch id inside this one's, so this plugin's own -// classes rehash whenever a dependency does. +// Dependencies are declared in the manifest's [[imports]] -- each entry +// names the plugin, the pexe path to load it from, and the batch id to pin; +// the declaration must match the script's qualified calls exactly. The +// coupling is unavoidable either way: calling into a plugin puts its batch +// id inside this one's, so this plugin's own classes rehash whenever a +// dependency does. // // `Swapped` is a class this plugin owns, minted in the same transaction, so // the receipt lands if and only if both rekeys do. diff --git a/examples/swap-log-sticks/manifest.toml b/examples/swap-log-sticks/manifest.toml index 79865a44..28919e0d 100644 --- a/examples/swap-log-sticks/manifest.toml +++ b/examples/swap-log-sticks/manifest.toml @@ -10,6 +10,11 @@ name = "swap-log-sticks" version = "0.1.0" module_hash = "cb4f2e337f85a82c903a9ce4be9ac9fac0687194f1e6e36d522ec677245e2fce" +[[imports]] +name = "craft-basics" +path = "../../target/pexe/craft-basics.pexe" +module_hash = "44d6fd33861e3ab021c0362a348243226cfa94345c912676102115a4864cd084" + [[actions]] name = "SwapLogSticks" emoji = "๐Ÿค" diff --git a/libs/pexe/src/bin/pexe.rs b/libs/pexe/src/bin/pexe.rs index dbbb3a70..dbcb6a15 100644 --- a/libs/pexe/src/bin/pexe.rs +++ b/libs/pexe/src/bin/pexe.rs @@ -6,8 +6,7 @@ use anyhow::{Context, Result, anyhow}; use clap::{Parser, Subcommand}; use pexe::{ MANIFEST_FILE, PEXE_EXTENSION, PluginSource, compile_module_hash_with_deps, inspect, install, - pack, read_pexe_file, resolve_declared_imports, resolve_script_deps, set_manifest_hash, - unpack, + pack, read_pexe_file, resolve_declared_imports, set_manifest_hash, unpack, }; // These names intentionally mirror `driver::paths::{DOBJ_HOME_DIR, ACTIONS_DIR}`. @@ -350,18 +349,11 @@ fn build_one( // Compile the script to derive the real module hash from the pod2 batch id. // Any plugin this script calls into is compiled first, since its batch id - // is part of this hash: from the manifest's declared [[imports]] when - // present (each entry names its pexe path and may pin its hash), else by - // scanning the script and resolving each name from the install dir. - let deps = if manifest.imports.is_empty() { - let dep_search_dir = match install_dir { - Some(dir) => dir.to_path_buf(), - None => default_install_dir()?, - }; - resolve_script_deps(&source.script, &dep_search_dir)? - } else { - resolve_declared_imports(&source.root, &manifest.imports, &source.script)? - }; + // is part of this hash. The manifest's [[imports]] is the ONLY dependency + // resolution: each entry names its pexe path and may pin its hash, and the + // declared set must match the script's qualified calls exactly. A script + // that composes nothing declares none. + let deps = resolve_declared_imports(&source.root, &manifest.imports, &source.script)?; let real_hash = compile_module_hash_with_deps(&manifest, &source.script, deps)?; let declared_hash = format!("{:#}", manifest.plugin.module_hash); let declared_hash = declared_hash.trim_start_matches("0x").to_lowercase(); diff --git a/libs/pexe/src/lib.rs b/libs/pexe/src/lib.rs index ac704089..240b0b76 100644 --- a/libs/pexe/src/lib.rs +++ b/libs/pexe/src/lib.rs @@ -155,44 +155,16 @@ pub fn compile_module_hash_with_deps( Ok(format!("{:#}", module.module().batch.id())) } -/// Compile the plugins a script calls into, reading them as installed -/// `.pexe` archives from `search_dir`. -/// -/// Build-time dependency resolution has to come from somewhere; the install -/// directory is the same place the driver loads from, so a plugin that -/// builds here is one that will also load there. -pub fn resolve_script_deps(script: &str, search_dir: &Path) -> Result { - let sdk = Sdk::default(); - let mut deps = PluginDeps::new(); - for plugin_name in sdk::script_dependencies(script) { - let path = search_dir.join(format!("{plugin_name}.{PEXE_EXTENSION}")); - let bytes = read_pexe_file(&path).with_context(|| { - format!( - "this script calls into {plugin_name}, which must be installed to build against; expected {}", - path.display() - ) - })?; - let (manifest, dep_script) = unpack(&bytes)?; - // A dependency may itself call into others, so resolve depth-first. - let dep_deps = resolve_script_deps(&dep_script, search_dir)?; - let module = sdk - .load_module_from_manifest_deps(&dep_script, &manifest, dep_deps) - .map_err(|err| anyhow!("failed to compile dependency {plugin_name}: {err}"))?; - deps.insert(plugin_name, module); - } - Ok(deps) -} - -/// Resolve dependencies from the manifest's declared `[[imports]]` instead of -/// an install-directory scan. The declaration must cover the script's -/// qualified calls exactly (a missing or unused entry is an error), each -/// import loads from its `path` relative to `plugin_root`, and a declared -/// `module_hash` is verified against what the loaded pexe compiles to -- -/// version drift names the offending import instead of surfacing later as a -/// whole-plugin hash mismatch. Imports may compose each other; anything an -/// import's own script calls into must itself be declared here, because a -/// path recorded inside a dependency's manifest was relative to the machine -/// that built IT. +/// Resolve build-time dependencies from the manifest's declared +/// `[[imports]]` -- the only resolution there is. The declaration must cover +/// the script's qualified calls exactly (a missing or unused entry is an +/// error), each import loads from its `path` relative to `plugin_root`, and +/// a declared `module_hash` is verified against what the loaded pexe +/// compiles to -- version drift names the offending import instead of +/// surfacing later as a whole-plugin hash mismatch. Imports may compose each +/// other; anything an import's own script calls into must itself be declared +/// here, because a path recorded inside a dependency's manifest was relative +/// to the machine that built IT. pub fn resolve_declared_imports( plugin_root: &Path, imports: &[Import], diff --git a/libs/sdk/src/manifest.rs b/libs/sdk/src/manifest.rs index 21792d6c..989ed0c7 100644 --- a/libs/sdk/src/manifest.rs +++ b/libs/sdk/src/manifest.rs @@ -6,13 +6,13 @@ pub struct Manifest { pub plugin: Plugin, pub classes: Vec, pub actions: Vec, - /// Declared dependencies. Optional: with no entries, builders fall back - /// to scanning the script for qualified sub-action calls and resolving - /// each plugin by name from an install directory. With entries, the - /// declaration is authoritative: it must cover the scanned set exactly, - /// each dependency loads from its `path`, and a declared `module_hash` - /// is verified per dependency -- so version drift names the offending - /// import instead of surfacing as a whole-plugin hash mismatch. + /// Declared dependencies -- the only build-time resolution there is. The + /// declaration must cover the script's qualified sub-action calls + /// exactly; each dependency loads from its `path`, and a declared + /// `module_hash` is verified per dependency, so version drift names the + /// offending import instead of surfacing as a whole-plugin hash + /// mismatch. A script that composes nothing declares no entries (the + /// section may be absent). #[serde(default)] pub imports: Vec, }