From d351217473e5cd463eaf34c507907c0a51292c24 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 7 Jun 2026 11:46:33 -0300 Subject: [PATCH] fix(lang): only capture a policy's ref where its script runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #332 captured a ref-backed policy's `ref` UTxO whenever the policy appeared in an address position, keyed on `is_address_expr()`. But that flag is also set for an output's `to`, so sending funds *to* a script address wrongly pulled the policy's reference script into the transaction — the script does not run when receiving, only when spending. Drive the capture solely off the explicit `capture_policy_ref` signal, set at the script-executing sites: an input's `from` and mint/burn amounts. Output `to` keeps its address lowering but no longer captures. Add a `send_to_policy` example tx that outputs to a ref-backed policy without spending from it, asserting it contributes no reference input. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tx3-lang/src/lowering.rs | 72 ++++++--------- ...policy_reference_script.send_to_policy.tir | 90 +++++++++++++++++++ examples/policy_reference_script.tx3 | 29 ++++-- 3 files changed, 136 insertions(+), 55 deletions(-) create mode 100644 examples/policy_reference_script.send_to_policy.tir diff --git a/crates/tx3-lang/src/lowering.rs b/crates/tx3-lang/src/lowering.rs index cfb785e..aaf41da 100644 --- a/crates/tx3-lang/src/lowering.rs +++ b/crates/tx3-lang/src/lowering.rs @@ -91,12 +91,8 @@ fn coerce_identifier_into_asset_def(identifier: &ast::Identifier) -> Result, @@ -115,13 +111,10 @@ pub(crate) struct Context { is_asset_expr: bool, is_datum_expr: bool, is_address_expr: bool, - // Sticky: a policy referenced anywhere in this subtree stands in for a - // script that must run (e.g. a mint/burn policy), so its ref UTxO should be - // captured even though the policy lowers to a plain hash here. Carried - // through the `enter_*` transitions like `script_refs`. + // Within this subtree, a ref-backed policy's script runs, so its ref UTxO + // is captured. Sticky across `enter_*`. capture_policy_ref: bool, - // Shared across all `enter_*`-derived contexts within one tx lowering so - // refs captured deep in an expression reach the top-level `Tx` assembly. + // Shared across `enter_*` clones so captures from any depth reach `Tx`. script_refs: Rc>, } @@ -156,9 +149,8 @@ impl Context { } } - /// Enter a subtree (e.g. a mint/burn amount) where any referenced policy's - /// script must run, so its ref UTxO should be captured as a reference - /// input. Sticky across nested `enter_*` transitions. + /// Mark this subtree as one where a referenced policy's script runs, so its + /// ref UTxO is captured. Sticky across nested `enter_*`. pub fn capturing_policy_refs(&self) -> Self { Self { capture_policy_ref: true, @@ -182,9 +174,7 @@ impl Context { self.capture_policy_ref } - /// Record a reference-script UTxO discovered during lowering. Dedups so the - /// same ref used by several inputs (or an explicit `reference` block) lands - /// once. + /// Record a reference-script UTxO, deduplicating. pub fn record_script_ref(&self, r#ref: ir::Expression) { self.script_refs.borrow_mut().record(r#ref); } @@ -263,11 +253,10 @@ impl IntoLower for ast::Identifier { ast::Symbol::PolicyDef(x) => { let policy = x.into_lower(ctx)?; - // The policy stands in for a script that must run — as a script - // credential in an address (`from`), or as a mint/burn policy. - // For a ref-backed policy, capture its `ref` UTxO so it lands in - // the tx's reference inputs. Hash-only policies yield `None`. - if ctx.is_address_expr() || ctx.captures_policy_refs() { + // Capture the ref UTxO only where the script runs, not in every + // address position (an output `to` receives funds without + // running the script). Hash-only policies yield `None`. + if ctx.captures_policy_refs() { if let Some(r#ref) = policy.script.as_utxo_ref() { ctx.record_script_ref(r#ref); } @@ -670,7 +659,8 @@ impl IntoLower for ast::InputBlockField { fn into_lower(&self, ctx: &Context) -> Result { match self { ast::InputBlockField::From(x) => { - let ctx = ctx.enter_address_expr(); + // Spending from a script address runs its script. + let ctx = ctx.enter_address_expr().capturing_policy_refs(); x.into_lower(&ctx) } ast::InputBlockField::DatumIs(_) => todo!(), @@ -793,9 +783,7 @@ impl IntoLower for ast::MintBlockField { fn into_lower(&self, ctx: &Context) -> Result { match self { - // A policy referenced in the minted/burned amount is the policy - // whose script must run, so capture its ref UTxO as a reference - // input (analogous to a script credential in an input's `from`). + // Minting/burning runs the asset's policy script. ast::MintBlockField::Amount(x) => x.into_lower(&ctx.capturing_policy_refs()), ast::MintBlockField::Redeemer(x) => x.into_lower(ctx), } @@ -936,16 +924,13 @@ impl IntoLower for ast::TxDef { type Output = ir::Tx; fn into_lower(&self, ctx: &Context) -> Result { - // Seed the ref accumulator with explicit `reference` blocks first, so a - // ref that is also implied by a policy used as `from` dedups to one - // entry (and explicit refs keep their leading position). + // Seed with explicit `reference` blocks first so they dedup against, + // and precede, refs that the body derives from ref-backed policies. for reference in self.references.iter() { let r#ref = reference.r#ref.into_lower(ctx)?; ctx.record_script_ref(r#ref); } - // Lowering the body populates the accumulator with the `ref` UTxOs of - // any ref-backed policies used in script-requiring positions. let inputs = self .inputs .iter() @@ -1137,27 +1122,20 @@ mod tests { test_lowering!(reference_script); test_lowering!(policy_reference_script, |txs| { - // A ref-backed policy used as `from` contributes its `ref` UTxO as a - // reference input. + // ref-backed policy as `from` assert_eq!(txs["spend"].references.len(), 1); - - // The same ref-backed policy across two inputs is deduped to one - // reference input. + // same ref across two inputs is deduped assert_eq!(txs["spend_two"].references.len(), 1); - - // A hash-only policy contributes no reference input. + // hash-only policy: nothing to reference assert!(txs["spend_hash_only"].references.is_empty()); - - // A ref-backed policy used to mint contributes its `ref` UTxO as a - // reference input. + // ref-backed mint assert_eq!(txs["mint_token"].references.len(), 1); - - // A ref-backed policy used to burn contributes its `ref` UTxO as a - // reference input. + // ref-backed burn assert_eq!(txs["burn_token"].references.len(), 1); - - // A hash-only policy used to mint contributes no reference input. + // hash-only mint: nothing to reference assert!(txs["mint_hash_only"].references.is_empty()); + // output recipient only: script does not run, no reference input + assert!(txs["send_to_policy"].references.is_empty()); }); test_lowering!(withdrawal); diff --git a/examples/policy_reference_script.send_to_policy.tir b/examples/policy_reference_script.send_to_policy.tir new file mode 100644 index 0000000..08fc76e --- /dev/null +++ b/examples/policy_reference_script.send_to_policy.tir @@ -0,0 +1,90 @@ +{ + "fees": { + "EvalParam": "ExpectFees" + }, + "references": [], + "inputs": [ + { + "name": "funding", + "utxos": { + "EvalParam": { + "ExpectInput": [ + "funding", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "receiver", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "EvalParam": { + "ExpectValue": [ + "quantity", + "Int" + ] + } + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + }, + "redeemer": "None" + } + ], + "outputs": [ + { + "address": { + "EvalCompiler": { + "BuildScriptAddress": { + "Bytes": [ + 171, + 205, + 239, + 18, + 52 + ] + } + } + }, + "datum": "None", + "amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "EvalParam": { + "ExpectValue": [ + "quantity", + "Int" + ] + } + } + } + ] + }, + "optional": false + } + ], + "validity": null, + "mints": [], + "burns": [], + "adhoc": [], + "collateral": [], + "signers": null, + "metadata": [] +} \ No newline at end of file diff --git a/examples/policy_reference_script.tx3 b/examples/policy_reference_script.tx3 index ab7a67d..dd16e0a 100644 --- a/examples/policy_reference_script.tx3 +++ b/examples/policy_reference_script.tx3 @@ -7,8 +7,7 @@ policy Validator { policy HashOnly = 0xABCDEF1234; -// A ref-backed policy used as `from` contributes its `ref` UTxO as a -// reference input. +// ref-backed policy as `from`: ref UTxO becomes a reference input tx spend( quantity: Int ) { @@ -43,7 +42,7 @@ tx spend_two() { } } -// A hash-only policy used as `from` contributes no reference input. +// hash-only policy as `from`: no reference input tx spend_hash_only() { input locked { from: HashOnly, @@ -56,8 +55,7 @@ tx spend_hash_only() { } } -// Minting under a ref-backed policy contributes its `ref` UTxO as a reference -// input. +// ref-backed mint policy: ref UTxO becomes a reference input tx mint_token() { mint { amount: AnyAsset(Validator, "TOKEN", 1), @@ -70,8 +68,7 @@ tx mint_token() { } } -// Burning under a ref-backed policy contributes its `ref` UTxO as a reference -// input. +// ref-backed burn policy: ref UTxO becomes a reference input tx burn_token() { input funding { from: Receiver, @@ -89,7 +86,7 @@ tx burn_token() { } } -// Minting under a hash-only policy contributes no reference input. +// hash-only mint policy: no reference input tx mint_hash_only() { mint { amount: AnyAsset(HashOnly, "TOKEN", 1), @@ -101,3 +98,19 @@ tx mint_hash_only() { amount: AnyAsset(HashOnly, "TOKEN", 1), } } + +// ref-backed policy as output recipient only: script does not run, no +// reference input +tx send_to_policy( + quantity: Int +) { + input funding { + from: Receiver, + min_amount: Ada(quantity), + } + + output { + to: Validator, + amount: Ada(quantity), + } +}