diff --git a/examples/craft-basics/manifest.toml b/examples/craft-basics/manifest.toml index 0211c8b6..204777fc 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 = "RekeyLog" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received log by rotating its key." + +[[actions]] +name = "RekeyWood" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received wood by rotating its key." + +[[actions]] +name = "RekeyStick" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received stick by rotating its key." + +[[actions]] +name = "RekeyStone" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received stone by rotating its key." + +[[actions]] +name = "RekeyWoodPick" +emoji = "๐Ÿ”‘" +description = "Take exclusive possession of a received wood pick by rotating its key." + +[[actions]] +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 649454bd..21b183b0 100644 --- a/examples/craft-basics/plugin.rhai +++ b/examples/craft-basics/plugin.rhai @@ -70,3 +70,50 @@ fn MineStoneWithStonePick(action) { var pick = action.subaction("UseStonePick"); var stone = action.output("Stone"); } + +// โ”€โ”€ 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 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(); + obj.update("key", key); +} + +fn RekeyLog(action) { + var log = action.mutate("Log"); + rekey(action, log); +} + +fn RekeyWood(action) { + var wood = action.mutate("Wood"); + rekey(action, wood); +} + +fn RekeyStick(action) { + var stick = action.mutate("Stick"); + rekey(action, stick); +} + +fn RekeyStone(action) { + var stone = action.mutate("Stone"); + rekey(action, stone); +} + +fn RekeyWoodPick(action) { + var pick = action.mutate("WoodPick"); + rekey(action, pick); +} + +fn RekeyStonePick(action) { + var pick = action.mutate("StonePick"); + 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..4bdfc45f 100644 --- a/examples/craft-rocket/plugin.rhai +++ b/examples/craft-rocket/plugin.rhai @@ -872,4 +872,285 @@ 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 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(); + 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-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..ae244a44 --- /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. +// +// 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. + +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-sticks/manifest.toml b/examples/swap-log-sticks/manifest.toml new file mode 100644 index 00000000..28919e0d --- /dev/null +++ b/examples/swap-log-sticks/manifest.toml @@ -0,0 +1,21 @@ +# 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 -- +# 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" + +[[imports]] +name = "craft-basics" +path = "../../target/pexe/craft-basics.pexe" +module_hash = "44d6fd33861e3ab021c0362a348243226cfa94345c912676102115a4864cd084" + +[[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..3a750796 --- /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 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::RekeyLog"); + var stick_a = action.subaction("craft-basics::RekeyStick"); + var stick_b = action.subaction("craft-basics::RekeyStick"); +} diff --git a/libs/driver/src/pexe_catalog.rs b/libs/driver/src/pexe_catalog.rs index cf5c8c66..5fc3f68d 100644 --- a/libs/driver/src/pexe_catalog.rs +++ b/libs/driver/src/pexe_catalog.rs @@ -9,22 +9,30 @@ //! 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 //! 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}; 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}; @@ -51,6 +59,68 @@ pub struct PexeCatalog { } impl PexeCatalog { + /// 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 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)?; + // 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 + .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. pub fn load(actions_dir: &Path) -> Result { let plugins = discover_plugins(actions_dir)?; @@ -103,11 +173,25 @@ impl PexeCatalog { let mut enriched_plugins: Vec = Vec::with_capacity(plugins.len()); let mut action_plugin_idx: 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(); + 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(&plugin.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"); @@ -178,33 +262,37 @@ 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::>>()?; - if meta.is_some_and(|m| m.hidden) { - continue; - } - let action_hash = module .action_hash(&bare) .map(|h| format!("{:#}", h)) @@ -213,7 +301,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,7 +311,11 @@ impl PexeCatalog { total_inputs, total_outputs, predicate_source, - }); + }; + if meta.is_some_and(|m| m.hidden) { + continue; + } + all_actions.push(summary); } enriched_plugins.push(plugin); @@ -314,21 +406,11 @@ 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); + + let module = self.load_module(&sdk, &action)?; + let executor = module.executor(self.mock_proofs, witness); Ok(executor.action(&action.name, inputs)?) } @@ -399,6 +481,70 @@ pub(crate) fn test_plugin_bytes() -> Vec { pexe::pack(manifest, script).expect("test plugin packs") } +#[cfg(test)] +/// 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-copper/manifest.toml"); + let script = include_str!("../../../examples/swap-log-copper/plugin.rhai"); + 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)] mod tests { use super::*; @@ -770,4 +916,131 @@ description = "consume a Foo to make a Bar" let value = obj.get(&pod2::middleware::StrKey::from("type")).ok()??; Some(Hash(value.raw().0)) } + + /// 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_plugins() { + let catalog = PexeCatalog::from_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-copper"); + + let swap = catalog + .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 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-rocket::Copper"] + ); + assert_eq!( + ids(&swap.total_outputs), + vec![ + "craft-basics::Log", + "craft-rocket::Copper", + "swap-log-copper::Swapped" + ] + ); + } + + /// 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_bundled_swap_executes() { + let catalog = PexeCatalog::from_bytes( + [ + (PathBuf::from("craft-basics.pexe"), test_plugin_bytes()), + (PathBuf::from("craft-rocket.pexe"), test_rocket_bytes()), + (PathBuf::from("swap-log-copper.pexe"), bundled_swap_bytes()), + ], + true, + ) + .expect("bundled swap loads"); + let mut state = payload::test_state::TestState::default(); + + let mut run = |action: QualifiedName, inputs: Vec| { + let commitments: Vec = inputs.iter().map(|i| i.obj.commitment()).collect(); + let witness = witness_for(&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 copper = run(QualifiedName::new("craft-rocket", "MineCopper"), vec![]).obj(0); + + let out = run( + QualifiedName::new("swap-log-copper", "SwapLogCopper"), + vec![log.clone(), copper.clone()], + ); + + let nullifiers = out.tx.nullifier_hashes().unwrap(); + 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(&copper.obj).unwrap())); + + 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 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(&copper.obj).unwrap() + ); + let swapped = catalog + .get_class(&QualifiedName::new("swap-log-copper", "Swapped")) + .expect("Swapped class present"); + assert_eq!( + obj_type_hash_for_test(&out.obj(2).obj).unwrap(), + decode_hash_hex(&swapped.hash).unwrap() + ); + } + + /// A grounding witness over `state` covering the given inputs. + fn witness_for( + 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, + ) + }, + ) + } } diff --git a/libs/pexe/src/bin/pexe.rs b/libs/pexe/src/bin/pexe.rs index 70476286..dbcb6a15 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_declared_imports, set_manifest_hash, unpack, }; // These names intentionally mirror `driver::paths::{DOBJ_HOME_DIR, ACTIONS_DIR}`. @@ -348,7 +348,13 @@ fn build_one( 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)?; + // Any plugin this script calls into is compiled first, since its batch id + // 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(); let real_hash_clean = real_hash.trim_start_matches("0x").to_lowercase(); diff --git a/libs/pexe/src/lib.rs b/libs/pexe/src/lib.rs index 3a7b1e24..240b0b76 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::{Sdk, manifest::Manifest}; +use sdk::{ + PluginDeps, Sdk, SdkModule, + manifest::{Import, Manifest}, +}; use zip::{ZipArchive, ZipWriter, write::SimpleFileOptions}; pub mod fixtures; @@ -130,14 +136,121 @@ 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())) } +/// 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], + script: &str, +) -> 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/pod2utils/src/macros.rs b/libs/pod2utils/src/macros.rs index 551a767d..075d8e60 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. +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")); + } +} diff --git a/libs/sdk/src/fmt_podlang.rs b/libs/sdk/src/fmt_podlang.rs index 2dfa7a36..ed8f1fb6 100644 --- a/libs/sdk/src/fmt_podlang.rs +++ b/libs/sdk/src/fmt_podlang.rs @@ -211,11 +211,59 @@ 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)) +} + +/// 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. 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 @@ -227,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) @@ -260,7 +313,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, @@ -273,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 @@ -288,10 +351,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,18 +375,16 @@ 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"]; let chain_next = chain.next(); let mut args: 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}")); - 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 93cbf279..e94d3f4c 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,75 @@ 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::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>; + +/// A sub-action target: an action in this script, or one in a dependency. +#[derive(Debug, Clone)] +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::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. +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 after = &rest[open + 1..]; + let Some(close) = after.find('"') 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 +2196,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 +2234,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 +2253,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 +2339,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 +2375,7 @@ impl Loader { ast, class_hashes, dependencies: self.dependencies, + plugin_deps: self.plugin_deps, } } } @@ -2199,6 +2402,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 +2507,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 +2521,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()}), @@ -2415,18 +2627,31 @@ 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(), - ]; + // 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 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()); + } + } + for dep in plugin.plugin_deps.values() { + queue.push(dep.clone()); + } + } Self { mock, params, vd_set, grounding_witness, prover, - pod_modules: modules, + pod_modules, module, } } @@ -2721,6 +2946,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(); @@ -2750,7 +2987,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))) } @@ -2759,9 +2996,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 diff --git a/libs/sdk/src/manifest.rs b/libs/sdk/src/manifest.rs index 8d1c8802..989ed0c7 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 -- 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, } #[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::*; @@ -41,7 +67,6 @@ mod tests { [plugin] name = "craft-wood-pick" version = "0.1.0" -imports = ["craft-wood", "craft-sticks"] module_hash = "b77a964de74c8569e6c6172692bb50147df9334fd9b572abc8d4d9c688a40e06" [[classes]] @@ -51,18 +76,18 @@ 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_eq!(manifest.classes.len(), 1); + assert_eq!(manifest.actions.len(), 2); + assert!(manifest.actions[1].hidden); } } diff --git a/libs/sdk/src/tests.rs b/libs/sdk/src/tests.rs index f0c82467..51577dab 100644 --- a/libs/sdk/src/tests.rs +++ b/libs/sdk/src/tests.rs @@ -886,3 +886,223 @@ fn test_sdk_state_header() { let [_ticker1] = res.objs(); apply_tx(&mut state, &ticker1_tx); } + +/// 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 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(); + 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()); +} + +/// 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 rekeyed. +#[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 RekeyGem(action) { + var gem = action.mutate("Gem"); + var key = action.random(); + gem.update("key", key); + } + "#; + // Reaches base::RekeyGem and mints a receipt of its own class, bound to + // the gem's stable identifier. + let swap_src = r#" + 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", "RekeyGem"]) + .unwrap(); + let mut deps = PluginDeps::new(); + deps.insert("base".to_string(), base.clone()); + let swap = sdk + .load_module_from_src_deps(swap_src, &["RekeyAndReceipt"], 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("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 [rekeyed, receipt] = res.objs(); + let live = res.tx.live_commitments().unwrap(); + assert!(live.contains(&rekeyed.obj.commitment())); + assert!(live.contains(&receipt.obj.commitment())); + + // 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(&rekeyed.obj), type_of(&gem.obj)); + assert_ne!(type_of(&receipt.obj), type_of(&rekeyed.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 RekeyGem(action) { + var gem = action.mutate("Gem"); + var key = action.random(); + gem.update("key", key); + } + 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 + // 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::RekeyGem"); + var coin = action.subaction("base::RekeyCoin"); + } + "#; + let sdk = Sdk::default(); + let base = sdk + .load_module_from_src_actions( + base_src, + &["SpawnGem", "SpawnCoin", "RekeyGem", "RekeyCoin"], + ) + .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()); +} diff --git a/libs/txlib/src/lib.rs b/libs/txlib/src/lib.rs index eafb3e07..32d8051a 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,120 @@ 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 + ); + } } 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 b5aa3f3a..69bd657c 100644 --- a/libs/txlib/src/predicates/mod.rs +++ b/libs/txlib/src/predicates/mod.rs @@ -15,6 +15,35 @@ 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") +} + +#[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 @@ -40,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(); 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) +)