From 9b7c9fe5c65a0e17577f08065da80891e9ef5852 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 15 Mar 2026 10:52:36 -0300 Subject: [PATCH 1/5] feat: introduce user-defined functions --- crates/tx3-lang/src/analyzing.rs | 167 +++++- crates/tx3-lang/src/ast.rs | 53 +- crates/tx3-lang/src/lowering.rs | 179 +++--- crates/tx3-lang/src/parsing.rs | 90 ++- crates/tx3-lang/src/tx3.pest | 9 +- examples/functions.ast | 926 +++++++++++++++++++++++++++++ examples/functions.tx3 | 46 ++ examples/functions.use_complex.tir | 120 ++++ examples/functions.use_double.tir | 99 +++ 9 files changed, 1582 insertions(+), 107 deletions(-) create mode 100644 examples/functions.ast create mode 100644 examples/functions.tx3 create mode 100644 examples/functions.use_complex.tir create mode 100644 examples/functions.use_double.tir diff --git a/crates/tx3-lang/src/analyzing.rs b/crates/tx3-lang/src/analyzing.rs index 55e50b02..32dba248 100644 --- a/crates/tx3-lang/src/analyzing.rs +++ b/crates/tx3-lang/src/analyzing.rs @@ -169,7 +169,7 @@ impl Error { Symbol::AssetDef(asset) => format!("AssetDef({})", asset.name.value), Symbol::EnvVar(name, _) => format!("EnvVar({})", name), Symbol::ParamVar(name, _) => format!("ParamVar({})", name), - Symbol::Function(name) => format!("Function({})", name), + Symbol::FunctionDef(fn_def) => format!("FunctionDef({})", fn_def.name.value), Symbol::LocalExpr(_) => "LocalExpr".to_string(), Symbol::Input(_) => "Input".to_string(), Symbol::Output(_) => "Output".to_string(), @@ -304,7 +304,63 @@ macro_rules! bail_report { }; } -const BUILTIN_FUNCTIONS: &[&str] = &["min_utxo", "tip_slot", "slot_to_time", "time_to_slot"]; +fn builtin_fn_defs() -> Vec { + vec![ + FnDef { + name: Identifier::new("min_utxo"), + parameters: ParameterList { + parameters: vec![ParamDef { + name: Identifier::new("output"), + r#type: Type::Int, + }], + span: Span::DUMMY, + }, + return_type: Type::AnyAsset, + body: None, + span: Span::DUMMY, + scope: None, + }, + FnDef { + name: Identifier::new("tip_slot"), + parameters: ParameterList { + parameters: vec![], + span: Span::DUMMY, + }, + return_type: Type::Int, + body: None, + span: Span::DUMMY, + scope: None, + }, + FnDef { + name: Identifier::new("slot_to_time"), + parameters: ParameterList { + parameters: vec![ParamDef { + name: Identifier::new("slot"), + r#type: Type::Int, + }], + span: Span::DUMMY, + }, + return_type: Type::Int, + body: None, + span: Span::DUMMY, + scope: None, + }, + FnDef { + name: Identifier::new("time_to_slot"), + parameters: ParameterList { + parameters: vec![ParamDef { + name: Identifier::new("time"), + r#type: Type::Int, + }], + span: Span::DUMMY, + }, + return_type: Type::Int, + body: None, + span: Span::DUMMY, + scope: None, + }, + ] +} impl Scope { pub fn new(parent: Option>) -> Self { @@ -377,6 +433,13 @@ impl Scope { ); } + pub fn track_fn_def(&mut self, fn_def: &FnDef) { + self.symbols.insert( + fn_def.name.value.clone(), + Symbol::FunctionDef(Box::new(fn_def.clone())), + ); + } + pub fn track_local_expr(&mut self, name: &str, expr: DataExpr) { self.symbols .insert(name.to_string(), Symbol::LocalExpr(Box::new(expr))); @@ -410,8 +473,6 @@ impl Scope { Some(symbol.clone()) } else if let Some(parent) = &self.parent { parent.resolve(name) - } else if BUILTIN_FUNCTIONS.contains(&name) { - Some(Symbol::Function(name.to_string())) } else { None } @@ -704,9 +765,6 @@ impl Analyzable for DataExpr { DataExpr::PropertyOp(x) => x.analyze(parent), DataExpr::AnyAssetConstructor(x) => x.analyze(parent), DataExpr::FnCall(x) => x.analyze(parent), - DataExpr::MinUtxo(x) => x.analyze(parent), - DataExpr::SlotToTime(x) => x.analyze(parent), - DataExpr::TimeToSlot(x) => x.analyze(parent), DataExpr::ConcatOp(x) => x.analyze(parent), _ => AnalyzeReport::default(), } @@ -724,9 +782,6 @@ impl Analyzable for DataExpr { DataExpr::PropertyOp(x) => x.is_resolved(), DataExpr::AnyAssetConstructor(x) => x.is_resolved(), DataExpr::FnCall(x) => x.is_resolved(), - DataExpr::MinUtxo(x) => x.is_resolved(), - DataExpr::SlotToTime(x) => x.is_resolved(), - DataExpr::TimeToSlot(x) => x.is_resolved(), DataExpr::ConcatOp(x) => x.is_resolved(), _ => true, } @@ -1210,6 +1265,77 @@ impl Analyzable for LocalsBlock { } } +impl Analyzable for LetBinding { + fn analyze(&mut self, parent: Option>) -> AnalyzeReport { + self.value.analyze(parent) + } + + fn is_resolved(&self) -> bool { + self.value.is_resolved() + } +} + +impl Analyzable for FnBody { + fn analyze(&mut self, parent: Option>) -> AnalyzeReport { + let mut report = AnalyzeReport::default(); + + for binding in &mut self.let_bindings { + report = report + binding.analyze(parent.clone()); + } + + report = report + self.result.analyze(parent); + + report + } + + fn is_resolved(&self) -> bool { + self.let_bindings.is_resolved() && self.result.is_resolved() + } +} + +impl Analyzable for FnDef { + fn analyze(&mut self, parent: Option>) -> AnalyzeReport { + let params_report = self.parameters.analyze(parent.clone()); + let return_type_report = self.return_type.analyze(parent.clone()); + + // Built-in functions have no body — nothing more to analyze + let body = match &mut self.body { + Some(body) => body, + None => return params_report + return_type_report, + }; + + let mut scope = Scope::new(parent); + + for param in self.parameters.parameters.iter() { + scope.track_param_var(¶m.name.value, param.r#type.clone()); + } + + // Add let-bindings sequentially - each binding creates a new scope layer + let mut current_scope = Rc::new(scope); + + let mut bindings_report = AnalyzeReport::default(); + + for binding in &mut body.let_bindings { + bindings_report = bindings_report + binding.analyze(Some(current_scope.clone())); + // Create a new scope layer with this binding available + let mut next_scope = Scope::new(Some(current_scope)); + next_scope.track_local_expr(&binding.name.value, binding.value.clone()); + current_scope = Rc::new(next_scope); + } + + let result_report = body.result.analyze(Some(current_scope.clone())); + + self.scope = Some(current_scope); + + params_report + return_type_report + bindings_report + result_report + } + + fn is_resolved(&self) -> bool { + self.parameters.is_resolved() + && self.body.as_ref().map_or(true, |b| b.is_resolved()) + } +} + impl Analyzable for ParamDef { fn analyze(&mut self, parent: Option>) -> AnalyzeReport { self.r#type.analyze(parent) @@ -1394,6 +1520,14 @@ impl Analyzable for Program { scope.track_alias_def(alias_def); } + for builtin in builtin_fn_defs().iter() { + scope.track_fn_def(builtin); + } + + for fn_def in self.functions.iter() { + scope.track_fn_def(fn_def); + } + self.scope = Some(Rc::new(scope)); let parties = self.parties.analyze(self.scope.clone()); @@ -1409,15 +1543,26 @@ impl Analyzable for Program { let (types, aliases) = resolve_types_and_aliases(scope_rc, &mut types, &mut aliases); + let functions = self.functions.analyze(self.scope.clone()); + + // Re-register analyzed FnDefs so txs resolve to analyzed versions + // (the originals in the scope are pre-analysis clones) + let mut fn_scope = Scope::new(self.scope.take()); + for fn_def in self.functions.iter() { + fn_scope.track_fn_def(fn_def); + } + self.scope = Some(Rc::new(fn_scope)); + let txs = self.txs.analyze(self.scope.clone()); - parties + policies + types + aliases + txs + assets + parties + policies + types + aliases + functions + txs + assets } fn is_resolved(&self) -> bool { self.policies.is_resolved() && self.types.is_resolved() && self.aliases.is_resolved() + && self.functions.is_resolved() && self.txs.is_resolved() && self.assets.is_resolved() } diff --git a/crates/tx3-lang/src/ast.rs b/crates/tx3-lang/src/ast.rs index 54578928..7708b467 100644 --- a/crates/tx3-lang/src/ast.rs +++ b/crates/tx3-lang/src/ast.rs @@ -31,7 +31,7 @@ pub enum Symbol { AliasDef(Box), RecordField(Box), VariantCase(Box), - Function(String), + FunctionDef(Box), Fees, } @@ -119,6 +119,13 @@ impl Symbol { } } + pub fn as_fn_def(&self) -> Option<&FnDef> { + match self { + Symbol::FunctionDef(x) => Some(x.as_ref()), + _ => None, + } + } + pub fn target_type(&self) -> Option { match self { Symbol::ParamVar(_, ty) => Some(ty.as_ref().clone()), @@ -180,6 +187,8 @@ pub struct Program { pub assets: Vec, pub parties: Vec, pub policies: Vec, + #[serde(default)] + pub functions: Vec, pub span: Span, // analysis @@ -734,10 +743,6 @@ pub enum DataExpr { MapConstructor(MapConstructor), AnyAssetConstructor(AnyAssetConstructor), Identifier(Identifier), - MinUtxo(Identifier), - ComputeTipSlot, - SlotToTime(Box), - TimeToSlot(Box), AddOp(AddOp), SubOp(SubOp), ConcatOp(ConcatOp), @@ -777,11 +782,12 @@ impl DataExpr { DataExpr::PropertyOp(x) => x.target_type(), DataExpr::AnyAssetConstructor(x) => x.target_type(), DataExpr::UtxoRef(_) => Some(Type::UtxoRef), - DataExpr::MinUtxo(_) => Some(Type::AnyAsset), - DataExpr::ComputeTipSlot => Some(Type::Int), - DataExpr::SlotToTime(_) => Some(Type::Int), - DataExpr::TimeToSlot(_) => Some(Type::Int), - DataExpr::FnCall(_) => None, // Function call return type determined by symbol resolution + DataExpr::FnCall(call) => call + .callee + .symbol + .as_ref() + .and_then(|s| s.as_fn_def()) + .map(|fn_def| fn_def.return_type.clone()), } } } @@ -963,6 +969,33 @@ pub struct AssetDef { pub span: Span, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LetBinding { + pub name: Identifier, + pub value: DataExpr, + pub span: Span, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FnBody { + pub let_bindings: Vec, + pub result: Box, + pub span: Span, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FnDef { + pub name: Identifier, + pub parameters: ParameterList, + pub return_type: Type, + pub body: Option, + pub span: Span, + + // analysis + #[serde(skip)] + pub(crate) scope: Option>, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum ChainSpecificBlock { Cardano(crate::cardano::CardanoBlock), diff --git a/crates/tx3-lang/src/lowering.rs b/crates/tx3-lang/src/lowering.rs index a49dfe79..28cea7a7 100644 --- a/crates/tx3-lang/src/lowering.rs +++ b/crates/tx3-lang/src/lowering.rs @@ -412,81 +412,116 @@ impl IntoLower for ast::FnCall { fn into_lower(&self, ctx: &Context) -> Result { let function_name = &self.callee.value; - match function_name.as_str() { - "min_utxo" => { - if self.args.len() != 1 { - return Err(Error::InvalidAst(format!( - "min_utxo expects 1 argument, got {}", - self.args.len() - ))); - } - let arg = self.args[0].into_lower(ctx)?; - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeMinUtxo(arg), - ))) - } - "tip_slot" => { - if !self.args.is_empty() { - return Err(Error::InvalidAst(format!( - "tip_slot expects 0 arguments, got {}", - self.args.len() - ))); - } - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeTipSlot, - ))) + // Check if callee resolves to a function definition + if let Some(symbol) = self.callee.symbol.as_ref() { + if let Some(fn_def) = symbol.as_fn_def() { + return if fn_def.body.is_some() { + inline_fn_call(fn_def, &self.args, ctx) + } else { + lower_builtin_fn_call(&self.callee.value, &self.args, ctx) + }; } - "slot_to_time" => { - if self.args.len() != 1 { - return Err(Error::InvalidAst(format!( - "slot_to_time expects 1 argument, got {}", - self.args.len() - ))); - } - let arg = self.args[0].into_lower(ctx)?; - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeSlotToTime(arg), - ))) + } + + // Try to coerce as asset - if it fails, we'll get an error + match coerce_identifier_into_asset_def(&self.callee) { + Ok(asset_def) => { + let policy = asset_def.policy.into_lower(ctx)?; + let asset_name = asset_def.asset_name.into_lower(ctx)?; + let amount = self.args[0].into_lower(ctx)?; + + Ok(ir::Expression::Assets(vec![ir::AssetExpr { + policy, + asset_name, + amount, + }])) } - "time_to_slot" => { - if self.args.len() != 1 { - return Err(Error::InvalidAst(format!( - "time_to_slot expects 1 argument, got {}", - self.args.len() - ))); - } + Err(_) => Err(Error::InvalidAst(format!( + "unknown function: {}", + function_name + ))), + } + } +} + +fn lower_builtin_fn_call( + name: &str, + args: &[ast::DataExpr], + ctx: &Context, +) -> Result { + match name { + "min_utxo" => { + let arg = args[0].into_lower(ctx)?; + Ok(ir::Expression::EvalCompiler(Box::new( + ir::CompilerOp::ComputeMinUtxo(arg), + ))) + } + "tip_slot" => Ok(ir::Expression::EvalCompiler(Box::new( + ir::CompilerOp::ComputeTipSlot, + ))), + "slot_to_time" => { + let arg = args[0].into_lower(ctx)?; + Ok(ir::Expression::EvalCompiler(Box::new( + ir::CompilerOp::ComputeSlotToTime(arg), + ))) + } + "time_to_slot" => { + let arg = args[0].into_lower(ctx)?; + Ok(ir::Expression::EvalCompiler(Box::new( + ir::CompilerOp::ComputeTimeToSlot(arg), + ))) + } + _ => Err(Error::InvalidAst(format!("unknown built-in function: {}", name))), + } +} - let arg = self.args[0].into_lower(ctx)?; +struct ParamSubstituter<'a> { + subs: &'a std::collections::HashMap, +} - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeTimeToSlot(arg), - ))) - } - _ => { - // Try to coerce as asset - if it fails, we'll get an error - - match coerce_identifier_into_asset_def(&self.callee) { - Ok(asset_def) => { - let policy = asset_def.policy.into_lower(ctx)?; - let asset_name = asset_def.asset_name.into_lower(ctx)?; - let amount = self.args[0].into_lower(ctx)?; - - Ok(ir::Expression::Assets(vec![ir::AssetExpr { - policy, - asset_name, - amount, - }])) - } - Err(_) => Err(Error::InvalidAst(format!( - "unknown function: {}", - function_name - ))), +impl tx3_tir::Visitor for ParamSubstituter<'_> { + fn reduce( + &mut self, + expr: ir::Expression, + ) -> Result { + if let ir::Expression::EvalParam(ref param) = expr { + if let ir::Param::ExpectValue(name, _) = param.as_ref() { + if let Some(replacement) = self.subs.get(name) { + return Ok(replacement.clone()); } } } + Ok(expr) } } +fn inline_fn_call( + fn_def: &ast::FnDef, + args: &[ast::DataExpr], + ctx: &Context, +) -> Result { + let body = fn_def.body.as_ref().ok_or_else(|| { + Error::InvalidAst(format!( + "cannot inline built-in function '{}'", + fn_def.name.value + )) + })?; + + let lowered_body = body.result.into_lower(ctx)?; + + // Build substitution map: lowercased param name → lowered arg + let mut subs = std::collections::HashMap::new(); + for (param, arg) in fn_def.parameters.parameters.iter().zip(args) { + subs.insert(param.name.value.to_lowercase(), arg.into_lower(ctx)?); + } + + use tx3_tir::Node; + let mut visitor = ParamSubstituter { subs: &subs }; + lowered_body + .apply(&mut visitor) + .map_err(|e| Error::InvalidAst(e.to_string())) +} + impl IntoLower for ast::PropertyOp { type Output = ir::Expression; @@ -566,18 +601,6 @@ impl IntoLower for ast::DataExpr { ast::DataExpr::PropertyOp(x) => x.into_lower(ctx)?, ast::DataExpr::UtxoRef(x) => x.into_lower(ctx)?, ast::DataExpr::FnCall(x) => x.into_lower(ctx)?, - ast::DataExpr::MinUtxo(x) => ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeMinUtxo(x.into_lower(ctx)?), - )), - ast::DataExpr::ComputeTipSlot => { - ir::Expression::EvalCompiler(Box::new(ir::CompilerOp::ComputeTipSlot)) - } - ast::DataExpr::SlotToTime(x) => ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeSlotToTime(x.into_lower(ctx)?), - )), - ast::DataExpr::TimeToSlot(x) => ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeTimeToSlot(x.into_lower(ctx)?), - )), }; Ok(out) @@ -1030,4 +1053,6 @@ mod tests { test_lowering!(list_concat); test_lowering!(buidler_fest_2026); + + test_lowering!(functions); } diff --git a/crates/tx3-lang/src/parsing.rs b/crates/tx3-lang/src/parsing.rs index 2b34fc1c..60dbd67e 100644 --- a/crates/tx3-lang/src/parsing.rs +++ b/crates/tx3-lang/src/parsing.rs @@ -12,10 +12,8 @@ use pest::{ }; use pest_derive::Parser; -use crate::{ - ast::*, - cardano::{PlutusWitnessBlock, PlutusWitnessField}, -}; +use crate::ast::*; + #[derive(Parser)] #[grammar = "tx3.pest"] pub(crate) struct Tx3Grammar; @@ -94,6 +92,7 @@ impl AstNode for Program { aliases: Vec::new(), parties: Vec::new(), policies: Vec::new(), + functions: Vec::new(), scope: None, span, }; @@ -108,6 +107,7 @@ impl AstNode for Program { Rule::alias_def => program.aliases.push(AliasDef::parse(pair)?), Rule::party_def => program.parties.push(PartyDef::parse(pair)?), Rule::policy_def => program.policies.push(PolicyDef::parse(pair)?), + Rule::fn_def => program.functions.push(FnDef::parse(pair)?), Rule::EOI => break, x => unreachable!("Unexpected rule in program: {:?}", x), } @@ -911,6 +911,81 @@ impl AstNode for crate::ast::FnCall { } } +impl AstNode for LetBinding { + const RULE: Rule = Rule::let_binding; + + fn parse(pair: Pair) -> Result { + let span = pair.as_span().into(); + let mut inner = pair.into_inner(); + + let name = Identifier::parse(inner.next().unwrap())?; + let value = DataExpr::parse(inner.next().unwrap())?; + + Ok(LetBinding { name, value, span }) + } + + fn span(&self) -> &Span { + &self.span + } +} + +impl AstNode for FnBody { + const RULE: Rule = Rule::fn_body; + + fn parse(pair: Pair) -> Result { + let span = pair.as_span().into(); + let inner = pair.into_inner(); + + let mut let_bindings = Vec::new(); + let mut result_expr = None; + + for pair in inner { + match pair.as_rule() { + Rule::let_binding => let_bindings.push(LetBinding::parse(pair)?), + Rule::data_expr => result_expr = Some(DataExpr::parse(pair)?), + x => unreachable!("Unexpected rule in fn_body: {:?}", x), + } + } + + Ok(FnBody { + let_bindings, + result: Box::new(result_expr.expect("fn_body must have a result expression")), + span, + }) + } + + fn span(&self) -> &Span { + &self.span + } +} + +impl AstNode for FnDef { + const RULE: Rule = Rule::fn_def; + + fn parse(pair: Pair) -> Result { + let span = pair.as_span().into(); + let mut inner = pair.into_inner(); + + let name = Identifier::parse(inner.next().unwrap())?; + let parameters = ParameterList::parse(inner.next().unwrap())?; + let return_type = Type::parse(inner.next().unwrap())?; + let body = FnBody::parse(inner.next().unwrap())?; + + Ok(FnDef { + name, + parameters, + return_type, + body: Some(body), + span, + scope: None, + }) + } + + fn span(&self) -> &Span { + &self.span + } +} + impl AstNode for RecordConstructorField { const RULE: Rule = Rule::record_constructor_field; @@ -1268,10 +1343,6 @@ impl AstNode for DataExpr { DataExpr::NegateOp(x) => &x.span, DataExpr::PropertyOp(x) => &x.span, DataExpr::UtxoRef(x) => x.span(), - DataExpr::MinUtxo(x) => x.span(), - DataExpr::SlotToTime(x) => x.span(), - DataExpr::TimeToSlot(x) => x.span(), - DataExpr::ComputeTipSlot => &Span::DUMMY, // TODO DataExpr::FnCall(x) => &x.span, } } @@ -2616,6 +2687,7 @@ mod tests { env: None, assets: vec![], policies: vec![], + functions: vec![], span: Span::DUMMY, scope: None, } @@ -2817,4 +2889,6 @@ mod tests { test_parsing!(list_concat); test_parsing!(buidler_fest_2026); + + test_parsing!(functions); } diff --git a/crates/tx3-lang/src/tx3.pest b/crates/tx3-lang/src/tx3.pest index a01471ff..a9286593 100644 --- a/crates/tx3-lang/src/tx3.pest +++ b/crates/tx3-lang/src/tx3.pest @@ -445,6 +445,13 @@ env_def = { "env" ~ "{" ~ (env_field ~ ",")+ ~ "}" } +// Function definitions +let_binding = { "let" ~ identifier ~ "=" ~ data_expr ~ ";" } +fn_body = { let_binding* ~ data_expr } +fn_def = { + "fn" ~ identifier ~ parameter_list ~ "->" ~ type ~ "{" ~ fn_body ~ "}" +} + // Transaction definition tx_def = { "tx" ~ identifier ~ parameter_list ~ "{" ~ tx_body_block* ~ "}" @@ -453,6 +460,6 @@ tx_def = { // Program program = { SOI ~ - (env_def | asset_def | party_def | policy_def | type_def | tx_def)* ~ + (env_def | asset_def | party_def | policy_def | type_def | fn_def | tx_def)* ~ EOI } diff --git a/examples/functions.ast b/examples/functions.ast new file mode 100644 index 00000000..3097d1ca --- /dev/null +++ b/examples/functions.ast @@ -0,0 +1,926 @@ +{ + "env": null, + "txs": [ + { + "name": { + "value": "use_double", + "span": { + "dummy": false, + "start": 422, + "end": 432 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "amount", + "span": { + "dummy": false, + "start": 433, + "end": 439 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 432, + "end": 445 + } + }, + "locals": null, + "references": [], + "inputs": [ + { + "name": "source", + "many": false, + "fields": [ + { + "From": { + "Identifier": { + "value": "Sender", + "span": { + "dummy": false, + "start": 481, + "end": 487 + } + } + } + }, + { + "MinAmount": { + "FnCall": { + "callee": { + "value": "Ada", + "span": { + "dummy": false, + "start": 509, + "end": 512 + } + }, + "args": [ + { + "Identifier": { + "value": "amount", + "span": { + "dummy": false, + "start": 513, + "end": 519 + } + } + } + ], + "span": { + "dummy": false, + "start": 509, + "end": 520 + } + } + } + } + ], + "span": { + "dummy": false, + "start": 452, + "end": 527 + } + } + ], + "outputs": [ + { + "name": null, + "optional": false, + "fields": [ + { + "To": { + "Identifier": { + "value": "Receiver", + "span": { + "dummy": false, + "start": 553, + "end": 561 + } + } + } + }, + { + "Amount": { + "FnCall": { + "callee": { + "value": "Ada", + "span": { + "dummy": false, + "start": 579, + "end": 582 + } + }, + "args": [ + { + "FnCall": { + "callee": { + "value": "double", + "span": { + "dummy": false, + "start": 583, + "end": 589 + } + }, + "args": [ + { + "Identifier": { + "value": "amount", + "span": { + "dummy": false, + "start": 590, + "end": 596 + } + } + } + ], + "span": { + "dummy": false, + "start": 583, + "end": 597 + } + } + } + ], + "span": { + "dummy": false, + "start": 579, + "end": 598 + } + } + } + } + ], + "span": { + "dummy": false, + "start": 532, + "end": 605 + } + } + ], + "validity": null, + "mints": [], + "burns": [], + "signers": null, + "adhoc": [], + "span": { + "dummy": false, + "start": 419, + "end": 607 + }, + "collateral": [], + "metadata": null + }, + { + "name": { + "value": "use_complex", + "span": { + "dummy": false, + "start": 612, + "end": 623 + } + }, + "parameters": { + "parameters": [], + "span": { + "dummy": false, + "start": 623, + "end": 625 + } + }, + "locals": null, + "references": [], + "inputs": [ + { + "name": "source", + "many": false, + "fields": [ + { + "From": { + "Identifier": { + "value": "Sender", + "span": { + "dummy": false, + "start": 661, + "end": 667 + } + } + } + }, + { + "MinAmount": { + "FnCall": { + "callee": { + "value": "Ada", + "span": { + "dummy": false, + "start": 689, + "end": 692 + } + }, + "args": [ + { + "Number": 2000000 + } + ], + "span": { + "dummy": false, + "start": 689, + "end": 701 + } + } + } + } + ], + "span": { + "dummy": false, + "start": 632, + "end": 708 + } + } + ], + "outputs": [ + { + "name": null, + "optional": false, + "fields": [ + { + "To": { + "Identifier": { + "value": "Receiver", + "span": { + "dummy": false, + "start": 734, + "end": 742 + } + } + } + }, + { + "Amount": { + "FnCall": { + "callee": { + "value": "Ada", + "span": { + "dummy": false, + "start": 760, + "end": 763 + } + }, + "args": [ + { + "FnCall": { + "callee": { + "value": "complex", + "span": { + "dummy": false, + "start": 764, + "end": 771 + } + }, + "args": [ + { + "Number": 50 + }, + { + "Number": 75 + } + ], + "span": { + "dummy": false, + "start": 764, + "end": 779 + } + } + } + ], + "span": { + "dummy": false, + "start": 760, + "end": 780 + } + } + } + } + ], + "span": { + "dummy": false, + "start": 713, + "end": 787 + } + } + ], + "validity": null, + "mints": [], + "burns": [], + "signers": null, + "adhoc": [], + "span": { + "dummy": false, + "start": 609, + "end": 789 + }, + "collateral": [], + "metadata": null + } + ], + "types": [ + { + "name": { + "value": "PoolState", + "span": { + "dummy": false, + "start": 36, + "end": 45 + } + }, + "cases": [ + { + "name": { + "value": "Default", + "span": { + "dummy": true, + "start": 0, + "end": 0 + } + }, + "fields": [ + { + "name": { + "value": "pair_a", + "span": { + "dummy": false, + "start": 52, + "end": 58 + } + }, + "type": "Int", + "span": { + "dummy": false, + "start": 52, + "end": 63 + } + }, + { + "name": { + "value": "pair_b", + "span": { + "dummy": false, + "start": 69, + "end": 75 + } + }, + "type": "Int", + "span": { + "dummy": false, + "start": 69, + "end": 80 + } + } + ], + "span": { + "dummy": false, + "start": 31, + "end": 83 + } + } + ], + "span": { + "dummy": false, + "start": 31, + "end": 83 + } + } + ], + "aliases": [], + "assets": [], + "parties": [ + { + "name": { + "value": "Sender", + "span": { + "dummy": false, + "start": 6, + "end": 12 + } + }, + "span": { + "dummy": false, + "start": 0, + "end": 13 + } + }, + { + "name": { + "value": "Receiver", + "span": { + "dummy": false, + "start": 20, + "end": 28 + } + }, + "span": { + "dummy": false, + "start": 14, + "end": 29 + } + } + ], + "policies": [], + "functions": [ + { + "name": { + "value": "double", + "span": { + "dummy": false, + "start": 88, + "end": 94 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "x", + "span": { + "dummy": false, + "start": 95, + "end": 96 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 94, + "end": 102 + } + }, + "return_type": "Int", + "body": { + "let_bindings": [], + "result": { + "AddOp": { + "lhs": { + "Identifier": { + "value": "x", + "span": { + "dummy": false, + "start": 116, + "end": 117 + } + } + }, + "rhs": { + "Identifier": { + "value": "x", + "span": { + "dummy": false, + "start": 120, + "end": 121 + } + } + }, + "span": { + "dummy": false, + "start": 118, + "end": 119 + } + } + }, + "span": { + "dummy": false, + "start": 116, + "end": 122 + } + }, + "span": { + "dummy": false, + "start": 85, + "end": 123 + } + }, + { + "name": { + "value": "complex", + "span": { + "dummy": false, + "start": 128, + "end": 135 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "a", + "span": { + "dummy": false, + "start": 136, + "end": 137 + } + }, + "type": "Int" + }, + { + "name": { + "value": "b", + "span": { + "dummy": false, + "start": 144, + "end": 145 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 135, + "end": 151 + } + }, + "return_type": "Int", + "body": { + "let_bindings": [ + { + "name": { + "value": "base", + "span": { + "dummy": false, + "start": 169, + "end": 173 + } + }, + "value": { + "AddOp": { + "lhs": { + "Identifier": { + "value": "a", + "span": { + "dummy": false, + "start": 176, + "end": 177 + } + } + }, + "rhs": { + "Identifier": { + "value": "b", + "span": { + "dummy": false, + "start": 180, + "end": 181 + } + } + }, + "span": { + "dummy": false, + "start": 178, + "end": 179 + } + } + }, + "span": { + "dummy": false, + "start": 165, + "end": 182 + } + }, + { + "name": { + "value": "adjusted", + "span": { + "dummy": false, + "start": 191, + "end": 199 + } + }, + "value": { + "SubOp": { + "lhs": { + "Identifier": { + "value": "base", + "span": { + "dummy": false, + "start": 202, + "end": 206 + } + } + }, + "rhs": { + "Number": 100 + }, + "span": { + "dummy": false, + "start": 207, + "end": 208 + } + } + }, + "span": { + "dummy": false, + "start": 187, + "end": 213 + } + } + ], + "result": { + "AddOp": { + "lhs": { + "Identifier": { + "value": "adjusted", + "span": { + "dummy": false, + "start": 218, + "end": 226 + } + } + }, + "rhs": { + "Identifier": { + "value": "adjusted", + "span": { + "dummy": false, + "start": 229, + "end": 237 + } + } + }, + "span": { + "dummy": false, + "start": 227, + "end": 228 + } + } + }, + "span": { + "dummy": false, + "start": 165, + "end": 238 + } + }, + "span": { + "dummy": false, + "start": 125, + "end": 239 + } + }, + { + "name": { + "value": "adjust_pool", + "span": { + "dummy": false, + "start": 244, + "end": 255 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "pool", + "span": { + "dummy": false, + "start": 256, + "end": 260 + } + }, + "type": { + "Custom": { + "value": "PoolState", + "span": { + "dummy": true, + "start": 0, + "end": 0 + } + } + } + }, + { + "name": { + "value": "delta_a", + "span": { + "dummy": false, + "start": 273, + "end": 280 + } + }, + "type": "Int" + }, + { + "name": { + "value": "delta_b", + "span": { + "dummy": false, + "start": 287, + "end": 294 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 255, + "end": 300 + } + }, + "return_type": { + "Custom": { + "value": "PoolState", + "span": { + "dummy": true, + "start": 0, + "end": 0 + } + } + }, + "body": { + "let_bindings": [], + "result": { + "StructConstructor": { + "type": { + "value": "PoolState", + "span": { + "dummy": false, + "start": 320, + "end": 329 + } + }, + "case": { + "name": { + "value": "Default", + "span": { + "dummy": true, + "start": 0, + "end": 0 + } + }, + "fields": [ + { + "name": { + "value": "pair_a", + "span": { + "dummy": false, + "start": 340, + "end": 346 + } + }, + "value": { + "AddOp": { + "lhs": { + "PropertyOp": { + "operand": { + "Identifier": { + "value": "pool", + "span": { + "dummy": false, + "start": 348, + "end": 352 + } + } + }, + "property": { + "Identifier": { + "value": "pair_a", + "span": { + "dummy": false, + "start": 353, + "end": 359 + } + } + }, + "span": { + "dummy": false, + "start": 352, + "end": 359 + } + } + }, + "rhs": { + "Identifier": { + "value": "delta_a", + "span": { + "dummy": false, + "start": 362, + "end": 369 + } + } + }, + "span": { + "dummy": false, + "start": 360, + "end": 361 + } + } + }, + "span": { + "dummy": false, + "start": 340, + "end": 369 + } + }, + { + "name": { + "value": "pair_b", + "span": { + "dummy": false, + "start": 379, + "end": 385 + } + }, + "value": { + "SubOp": { + "lhs": { + "PropertyOp": { + "operand": { + "Identifier": { + "value": "pool", + "span": { + "dummy": false, + "start": 387, + "end": 391 + } + } + }, + "property": { + "Identifier": { + "value": "pair_b", + "span": { + "dummy": false, + "start": 392, + "end": 398 + } + } + }, + "span": { + "dummy": false, + "start": 391, + "end": 398 + } + } + }, + "rhs": { + "Identifier": { + "value": "delta_b", + "span": { + "dummy": false, + "start": 401, + "end": 408 + } + } + }, + "span": { + "dummy": false, + "start": 399, + "end": 400 + } + } + }, + "span": { + "dummy": false, + "start": 379, + "end": 408 + } + } + ], + "spread": null, + "span": { + "dummy": false, + "start": 330, + "end": 415 + } + }, + "span": { + "dummy": false, + "start": 320, + "end": 415 + } + } + }, + "span": { + "dummy": false, + "start": 320, + "end": 416 + } + }, + "span": { + "dummy": false, + "start": 241, + "end": 417 + } + } + ], + "span": { + "dummy": false, + "start": 0, + "end": 790 + } +} \ No newline at end of file diff --git a/examples/functions.tx3 b/examples/functions.tx3 new file mode 100644 index 00000000..d575ad8e --- /dev/null +++ b/examples/functions.tx3 @@ -0,0 +1,46 @@ +party Sender; +party Receiver; + +type PoolState { + pair_a: Int, + pair_b: Int, +} + +fn double(x: Int) -> Int { + x + x +} + +fn complex(a: Int, b: Int) -> Int { + let base = a + b; + let adjusted = base - 100; + adjusted + adjusted +} + +fn adjust_pool(pool: PoolState, delta_a: Int, delta_b: Int) -> PoolState { + PoolState { + pair_a: pool.pair_a + delta_a, + pair_b: pool.pair_b - delta_b, + } +} + +tx use_double(amount: Int) { + input source { + from: Sender, + min_amount: Ada(amount), + } + output { + to: Receiver, + amount: Ada(double(amount)), + } +} + +tx use_complex() { + input source { + from: Sender, + min_amount: Ada(2000000), + } + output { + to: Receiver, + amount: Ada(complex(50, 75)), + } +} diff --git a/examples/functions.use_complex.tir b/examples/functions.use_complex.tir new file mode 100644 index 00000000..e1ee1e7b --- /dev/null +++ b/examples/functions.use_complex.tir @@ -0,0 +1,120 @@ +{ + "fees": { + "EvalParam": "ExpectFees" + }, + "references": [], + "inputs": [ + { + "name": "source", + "utxos": { + "EvalParam": { + "ExpectInput": [ + "source", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "Number": 2000000 + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + }, + "redeemer": "None" + } + ], + "outputs": [ + { + "address": { + "EvalParam": { + "ExpectValue": [ + "receiver", + "Address" + ] + } + }, + "datum": "None", + "amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "EvalBuiltIn": { + "Add": [ + { + "EvalBuiltIn": { + "Sub": [ + { + "EvalBuiltIn": { + "Add": [ + { + "Number": 50 + }, + { + "Number": 75 + } + ] + } + }, + { + "Number": 100 + } + ] + } + }, + { + "EvalBuiltIn": { + "Sub": [ + { + "EvalBuiltIn": { + "Add": [ + { + "Number": 50 + }, + { + "Number": 75 + } + ] + } + }, + { + "Number": 100 + } + ] + } + } + ] + } + } + } + ] + }, + "optional": false + } + ], + "validity": null, + "mints": [], + "burns": [], + "adhoc": [], + "collateral": [], + "signers": null, + "metadata": [] +} \ No newline at end of file diff --git a/examples/functions.use_double.tir b/examples/functions.use_double.tir new file mode 100644 index 00000000..32eb07b6 --- /dev/null +++ b/examples/functions.use_double.tir @@ -0,0 +1,99 @@ +{ + "fees": { + "EvalParam": "ExpectFees" + }, + "references": [], + "inputs": [ + { + "name": "source", + "utxos": { + "EvalParam": { + "ExpectInput": [ + "source", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "EvalParam": { + "ExpectValue": [ + "amount", + "Int" + ] + } + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + }, + "redeemer": "None" + } + ], + "outputs": [ + { + "address": { + "EvalParam": { + "ExpectValue": [ + "receiver", + "Address" + ] + } + }, + "datum": "None", + "amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "EvalBuiltIn": { + "Add": [ + { + "EvalParam": { + "ExpectValue": [ + "amount", + "Int" + ] + } + }, + { + "EvalParam": { + "ExpectValue": [ + "amount", + "Int" + ] + } + } + ] + } + } + } + ] + }, + "optional": false + } + ], + "validity": null, + "mints": [], + "burns": [], + "adhoc": [], + "collateral": [], + "signers": null, + "metadata": [] +} \ No newline at end of file From 51972c47748e8d92133468ff5cc31f68184a8a39 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 30 May 2026 18:16:05 -0300 Subject: [PATCH 2/5] test: cover functions parsing, record-returning inlining, and builtin lowering - exercise record-returning + property-access inlining via a use_pool tx in examples/functions.tx3 (nested make_pool/adjust_pool calls) - add test_lowering for tip_slot and posix_time so the builtin-as-FnDef reframing is guarded by golden TIR Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tx3-lang/src/lowering.rs | 4 + examples/functions.ast | 589 ++++++++++++++++---- examples/functions.tx3 | 19 + examples/functions.use_pool.tir | 186 +++++++ examples/posix_time.create_timestamp_tx.tir | 133 +++++ examples/tip_slot.create_timestamp_tx.tir | 134 +++++ 6 files changed, 958 insertions(+), 107 deletions(-) create mode 100644 examples/functions.use_pool.tir create mode 100644 examples/posix_time.create_timestamp_tx.tir create mode 100644 examples/tip_slot.create_timestamp_tx.tir diff --git a/crates/tx3-lang/src/lowering.rs b/crates/tx3-lang/src/lowering.rs index b057f8de..70c56785 100644 --- a/crates/tx3-lang/src/lowering.rs +++ b/crates/tx3-lang/src/lowering.rs @@ -1071,6 +1071,10 @@ mod tests { test_lowering!(min_utxo); + test_lowering!(tip_slot); + + test_lowering!(posix_time); + test_lowering!(donation); test_lowering!(list_concat); diff --git a/examples/functions.ast b/examples/functions.ast index 3097d1ca..10ea4ee5 100644 --- a/examples/functions.ast +++ b/examples/functions.ast @@ -6,8 +6,8 @@ "value": "use_double", "span": { "dummy": false, - "start": 422, - "end": 432 + "start": 529, + "end": 539 } }, "parameters": { @@ -17,8 +17,8 @@ "value": "amount", "span": { "dummy": false, - "start": 433, - "end": 439 + "start": 540, + "end": 546 } }, "type": "Int" @@ -26,8 +26,8 @@ ], "span": { "dummy": false, - "start": 432, - "end": 445 + "start": 539, + "end": 552 } }, "locals": null, @@ -43,8 +43,8 @@ "value": "Sender", "span": { "dummy": false, - "start": 481, - "end": 487 + "start": 588, + "end": 594 } } } @@ -56,8 +56,8 @@ "value": "Ada", "span": { "dummy": false, - "start": 509, - "end": 512 + "start": 616, + "end": 619 } }, "args": [ @@ -66,16 +66,16 @@ "value": "amount", "span": { "dummy": false, - "start": 513, - "end": 519 + "start": 620, + "end": 626 } } } ], "span": { "dummy": false, - "start": 509, - "end": 520 + "start": 616, + "end": 627 } } } @@ -83,8 +83,8 @@ ], "span": { "dummy": false, - "start": 452, - "end": 527 + "start": 559, + "end": 634 } } ], @@ -99,8 +99,8 @@ "value": "Receiver", "span": { "dummy": false, - "start": 553, - "end": 561 + "start": 660, + "end": 668 } } } @@ -112,8 +112,8 @@ "value": "Ada", "span": { "dummy": false, - "start": 579, - "end": 582 + "start": 686, + "end": 689 } }, "args": [ @@ -123,8 +123,8 @@ "value": "double", "span": { "dummy": false, - "start": 583, - "end": 589 + "start": 690, + "end": 696 } }, "args": [ @@ -133,24 +133,24 @@ "value": "amount", "span": { "dummy": false, - "start": 590, - "end": 596 + "start": 697, + "end": 703 } } } ], "span": { "dummy": false, - "start": 583, - "end": 597 + "start": 690, + "end": 704 } } } ], "span": { "dummy": false, - "start": 579, - "end": 598 + "start": 686, + "end": 705 } } } @@ -158,8 +158,8 @@ ], "span": { "dummy": false, - "start": 532, - "end": 605 + "start": 639, + "end": 712 } } ], @@ -170,8 +170,8 @@ "adhoc": [], "span": { "dummy": false, - "start": 419, - "end": 607 + "start": 526, + "end": 714 }, "collateral": [], "metadata": null @@ -181,16 +181,16 @@ "value": "use_complex", "span": { "dummy": false, - "start": 612, - "end": 623 + "start": 719, + "end": 730 } }, "parameters": { "parameters": [], "span": { "dummy": false, - "start": 623, - "end": 625 + "start": 730, + "end": 732 } }, "locals": null, @@ -206,8 +206,8 @@ "value": "Sender", "span": { "dummy": false, - "start": 661, - "end": 667 + "start": 768, + "end": 774 } } } @@ -219,8 +219,8 @@ "value": "Ada", "span": { "dummy": false, - "start": 689, - "end": 692 + "start": 796, + "end": 799 } }, "args": [ @@ -230,8 +230,8 @@ ], "span": { "dummy": false, - "start": 689, - "end": 701 + "start": 796, + "end": 808 } } } @@ -239,8 +239,8 @@ ], "span": { "dummy": false, - "start": 632, - "end": 708 + "start": 739, + "end": 815 } } ], @@ -255,8 +255,8 @@ "value": "Receiver", "span": { "dummy": false, - "start": 734, - "end": 742 + "start": 841, + "end": 849 } } } @@ -268,8 +268,8 @@ "value": "Ada", "span": { "dummy": false, - "start": 760, - "end": 763 + "start": 867, + "end": 870 } }, "args": [ @@ -279,8 +279,8 @@ "value": "complex", "span": { "dummy": false, - "start": 764, - "end": 771 + "start": 871, + "end": 878 } }, "args": [ @@ -293,16 +293,16 @@ ], "span": { "dummy": false, - "start": 764, - "end": 779 + "start": 871, + "end": 886 } } } ], "span": { "dummy": false, - "start": 760, - "end": 780 + "start": 867, + "end": 887 } } } @@ -310,8 +310,8 @@ ], "span": { "dummy": false, - "start": 713, - "end": 787 + "start": 820, + "end": 894 } } ], @@ -322,8 +322,234 @@ "adhoc": [], "span": { "dummy": false, - "start": 609, - "end": 789 + "start": 716, + "end": 896 + }, + "collateral": [], + "metadata": null + }, + { + "name": { + "value": "use_pool", + "span": { + "dummy": false, + "start": 901, + "end": 909 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "delta_a", + "span": { + "dummy": false, + "start": 910, + "end": 917 + } + }, + "type": "Int" + }, + { + "name": { + "value": "delta_b", + "span": { + "dummy": false, + "start": 924, + "end": 931 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 909, + "end": 937 + } + }, + "locals": null, + "references": [], + "inputs": [ + { + "name": "source", + "many": false, + "fields": [ + { + "From": { + "Identifier": { + "value": "Sender", + "span": { + "dummy": false, + "start": 973, + "end": 979 + } + } + } + }, + { + "MinAmount": { + "FnCall": { + "callee": { + "value": "Ada", + "span": { + "dummy": false, + "start": 1001, + "end": 1004 + } + }, + "args": [ + { + "Number": 2000000 + } + ], + "span": { + "dummy": false, + "start": 1001, + "end": 1013 + } + } + } + } + ], + "span": { + "dummy": false, + "start": 944, + "end": 1020 + } + } + ], + "outputs": [ + { + "name": null, + "optional": false, + "fields": [ + { + "To": { + "Identifier": { + "value": "Receiver", + "span": { + "dummy": false, + "start": 1046, + "end": 1054 + } + } + } + }, + { + "Amount": { + "SubOp": { + "lhs": { + "Identifier": { + "value": "source", + "span": { + "dummy": false, + "start": 1072, + "end": 1078 + } + } + }, + "rhs": { + "Identifier": { + "value": "fees", + "span": { + "dummy": false, + "start": 1081, + "end": 1085 + } + } + }, + "span": { + "dummy": false, + "start": 1079, + "end": 1080 + } + } + } + }, + { + "Datum": { + "FnCall": { + "callee": { + "value": "adjust_pool", + "span": { + "dummy": false, + "start": 1102, + "end": 1113 + } + }, + "args": [ + { + "FnCall": { + "callee": { + "value": "make_pool", + "span": { + "dummy": false, + "start": 1114, + "end": 1123 + } + }, + "args": [ + { + "Number": 1000 + }, + { + "Number": 2000 + } + ], + "span": { + "dummy": false, + "start": 1114, + "end": 1135 + } + } + }, + { + "Identifier": { + "value": "delta_a", + "span": { + "dummy": false, + "start": 1137, + "end": 1144 + } + } + }, + { + "Identifier": { + "value": "delta_b", + "span": { + "dummy": false, + "start": 1146, + "end": 1153 + } + } + } + ], + "span": { + "dummy": false, + "start": 1102, + "end": 1154 + } + } + } + } + ], + "span": { + "dummy": false, + "start": 1025, + "end": 1161 + } + } + ], + "validity": null, + "mints": [], + "burns": [], + "signers": null, + "adhoc": [], + "span": { + "dummy": false, + "start": 898, + "end": 1163 }, "collateral": [], "metadata": null @@ -674,11 +900,160 @@ }, { "name": { - "value": "adjust_pool", + "value": "make_pool", "span": { "dummy": false, "start": 244, - "end": 255 + "end": 253 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "a", + "span": { + "dummy": false, + "start": 254, + "end": 255 + } + }, + "type": "Int" + }, + { + "name": { + "value": "b", + "span": { + "dummy": false, + "start": 262, + "end": 263 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 253, + "end": 269 + } + }, + "return_type": { + "Custom": { + "value": "PoolState", + "span": { + "dummy": true, + "start": 0, + "end": 0 + } + } + }, + "body": { + "let_bindings": [], + "result": { + "StructConstructor": { + "type": { + "value": "PoolState", + "span": { + "dummy": false, + "start": 289, + "end": 298 + } + }, + "case": { + "name": { + "value": "Default", + "span": { + "dummy": true, + "start": 0, + "end": 0 + } + }, + "fields": [ + { + "name": { + "value": "pair_a", + "span": { + "dummy": false, + "start": 309, + "end": 315 + } + }, + "value": { + "Identifier": { + "value": "a", + "span": { + "dummy": false, + "start": 317, + "end": 318 + } + } + }, + "span": { + "dummy": false, + "start": 309, + "end": 318 + } + }, + { + "name": { + "value": "pair_b", + "span": { + "dummy": false, + "start": 328, + "end": 334 + } + }, + "value": { + "Identifier": { + "value": "b", + "span": { + "dummy": false, + "start": 336, + "end": 337 + } + } + }, + "span": { + "dummy": false, + "start": 328, + "end": 337 + } + } + ], + "spread": null, + "span": { + "dummy": false, + "start": 299, + "end": 344 + } + }, + "span": { + "dummy": false, + "start": 289, + "end": 344 + } + } + }, + "span": { + "dummy": false, + "start": 289, + "end": 345 + } + }, + "span": { + "dummy": false, + "start": 241, + "end": 346 + } + }, + { + "name": { + "value": "adjust_pool", + "span": { + "dummy": false, + "start": 351, + "end": 362 } }, "parameters": { @@ -688,8 +1063,8 @@ "value": "pool", "span": { "dummy": false, - "start": 256, - "end": 260 + "start": 363, + "end": 367 } }, "type": { @@ -708,8 +1083,8 @@ "value": "delta_a", "span": { "dummy": false, - "start": 273, - "end": 280 + "start": 380, + "end": 387 } }, "type": "Int" @@ -719,8 +1094,8 @@ "value": "delta_b", "span": { "dummy": false, - "start": 287, - "end": 294 + "start": 394, + "end": 401 } }, "type": "Int" @@ -728,8 +1103,8 @@ ], "span": { "dummy": false, - "start": 255, - "end": 300 + "start": 362, + "end": 407 } }, "return_type": { @@ -750,8 +1125,8 @@ "value": "PoolState", "span": { "dummy": false, - "start": 320, - "end": 329 + "start": 427, + "end": 436 } }, "case": { @@ -769,8 +1144,8 @@ "value": "pair_a", "span": { "dummy": false, - "start": 340, - "end": 346 + "start": 447, + "end": 453 } }, "value": { @@ -782,8 +1157,8 @@ "value": "pool", "span": { "dummy": false, - "start": 348, - "end": 352 + "start": 455, + "end": 459 } } }, @@ -792,15 +1167,15 @@ "value": "pair_a", "span": { "dummy": false, - "start": 353, - "end": 359 + "start": 460, + "end": 466 } } }, "span": { "dummy": false, - "start": 352, - "end": 359 + "start": 459, + "end": 466 } } }, @@ -809,22 +1184,22 @@ "value": "delta_a", "span": { "dummy": false, - "start": 362, - "end": 369 + "start": 469, + "end": 476 } } }, "span": { "dummy": false, - "start": 360, - "end": 361 + "start": 467, + "end": 468 } } }, "span": { "dummy": false, - "start": 340, - "end": 369 + "start": 447, + "end": 476 } }, { @@ -832,8 +1207,8 @@ "value": "pair_b", "span": { "dummy": false, - "start": 379, - "end": 385 + "start": 486, + "end": 492 } }, "value": { @@ -845,8 +1220,8 @@ "value": "pool", "span": { "dummy": false, - "start": 387, - "end": 391 + "start": 494, + "end": 498 } } }, @@ -855,15 +1230,15 @@ "value": "pair_b", "span": { "dummy": false, - "start": 392, - "end": 398 + "start": 499, + "end": 505 } } }, "span": { "dummy": false, - "start": 391, - "end": 398 + "start": 498, + "end": 505 } } }, @@ -872,55 +1247,55 @@ "value": "delta_b", "span": { "dummy": false, - "start": 401, - "end": 408 + "start": 508, + "end": 515 } } }, "span": { "dummy": false, - "start": 399, - "end": 400 + "start": 506, + "end": 507 } } }, "span": { "dummy": false, - "start": 379, - "end": 408 + "start": 486, + "end": 515 } } ], "spread": null, "span": { "dummy": false, - "start": 330, - "end": 415 + "start": 437, + "end": 522 } }, "span": { "dummy": false, - "start": 320, - "end": 415 + "start": 427, + "end": 522 } } }, "span": { "dummy": false, - "start": 320, - "end": 416 + "start": 427, + "end": 523 } }, "span": { "dummy": false, - "start": 241, - "end": 417 + "start": 348, + "end": 524 } } ], "span": { "dummy": false, "start": 0, - "end": 790 + "end": 1164 } } \ No newline at end of file diff --git a/examples/functions.tx3 b/examples/functions.tx3 index d575ad8e..86f4db4c 100644 --- a/examples/functions.tx3 +++ b/examples/functions.tx3 @@ -16,6 +16,13 @@ fn complex(a: Int, b: Int) -> Int { adjusted + adjusted } +fn make_pool(a: Int, b: Int) -> PoolState { + PoolState { + pair_a: a, + pair_b: b, + } +} + fn adjust_pool(pool: PoolState, delta_a: Int, delta_b: Int) -> PoolState { PoolState { pair_a: pool.pair_a + delta_a, @@ -44,3 +51,15 @@ tx use_complex() { amount: Ada(complex(50, 75)), } } + +tx use_pool(delta_a: Int, delta_b: Int) { + input source { + from: Sender, + min_amount: Ada(2000000), + } + output { + to: Receiver, + amount: source - fees, + datum: adjust_pool(make_pool(1000, 2000), delta_a, delta_b), + } +} diff --git a/examples/functions.use_pool.tir b/examples/functions.use_pool.tir new file mode 100644 index 00000000..f0a1bfb5 --- /dev/null +++ b/examples/functions.use_pool.tir @@ -0,0 +1,186 @@ +{ + "fees": { + "EvalParam": "ExpectFees" + }, + "references": [], + "inputs": [ + { + "name": "source", + "utxos": { + "EvalParam": { + "ExpectInput": [ + "source", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "Number": 2000000 + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + }, + "redeemer": "None" + } + ], + "outputs": [ + { + "address": { + "EvalParam": { + "ExpectValue": [ + "receiver", + "Address" + ] + } + }, + "datum": { + "Struct": { + "constructor": 0, + "fields": [ + { + "EvalBuiltIn": { + "Add": [ + { + "EvalBuiltIn": { + "Property": [ + { + "Struct": { + "constructor": 0, + "fields": [ + { + "Number": 1000 + }, + { + "Number": 2000 + } + ] + } + }, + { + "Number": 0 + } + ] + } + }, + { + "EvalParam": { + "ExpectValue": [ + "delta_a", + "Int" + ] + } + } + ] + } + }, + { + "EvalBuiltIn": { + "Sub": [ + { + "EvalBuiltIn": { + "Property": [ + { + "Struct": { + "constructor": 0, + "fields": [ + { + "Number": 1000 + }, + { + "Number": 2000 + } + ] + } + }, + { + "Number": 1 + } + ] + } + }, + { + "EvalParam": { + "ExpectValue": [ + "delta_b", + "Int" + ] + } + } + ] + } + } + ] + } + }, + "amount": { + "EvalBuiltIn": { + "Sub": [ + { + "EvalCoerce": { + "IntoAssets": { + "EvalParam": { + "ExpectInput": [ + "source", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "Number": 2000000 + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + } + } + }, + { + "EvalParam": "ExpectFees" + } + ] + } + }, + "optional": false + } + ], + "validity": null, + "mints": [], + "burns": [], + "adhoc": [], + "collateral": [], + "signers": null, + "metadata": [] +} \ No newline at end of file diff --git a/examples/posix_time.create_timestamp_tx.tir b/examples/posix_time.create_timestamp_tx.tir new file mode 100644 index 00000000..32c4c686 --- /dev/null +++ b/examples/posix_time.create_timestamp_tx.tir @@ -0,0 +1,133 @@ +{ + "fees": { + "EvalParam": "ExpectFees" + }, + "references": [], + "inputs": [ + { + "name": "source", + "utxos": { + "EvalParam": { + "ExpectInput": [ + "source", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "Number": 2000000 + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + }, + "redeemer": "None" + } + ], + "outputs": [ + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "datum": { + "Struct": { + "constructor": 0, + "fields": [ + { + "EvalCompiler": { + "ComputeSlotToTime": { + "EvalCompiler": "ComputeTipSlot" + } + } + }, + { + "EvalCompiler": { + "ComputeTimeToSlot": { + "EvalParam": { + "ExpectValue": [ + "deadline", + "Int" + ] + } + } + } + } + ] + } + }, + "amount": { + "EvalBuiltIn": { + "Sub": [ + { + "EvalCoerce": { + "IntoAssets": { + "EvalParam": { + "ExpectInput": [ + "source", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "Number": 2000000 + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + } + } + }, + { + "EvalParam": "ExpectFees" + } + ] + } + }, + "optional": false + } + ], + "validity": null, + "mints": [], + "burns": [], + "adhoc": [], + "collateral": [], + "signers": null, + "metadata": [] +} \ No newline at end of file diff --git a/examples/tip_slot.create_timestamp_tx.tir b/examples/tip_slot.create_timestamp_tx.tir new file mode 100644 index 00000000..3b607c56 --- /dev/null +++ b/examples/tip_slot.create_timestamp_tx.tir @@ -0,0 +1,134 @@ +{ + "fees": { + "EvalParam": "ExpectFees" + }, + "references": [], + "inputs": [ + { + "name": "source", + "utxos": { + "EvalParam": { + "ExpectInput": [ + "source", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "Number": 2000000 + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + }, + "redeemer": "None" + } + ], + "outputs": [ + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "datum": { + "Struct": { + "constructor": 0, + "fields": [ + { + "EvalCompiler": "ComputeTipSlot" + }, + { + "EvalBuiltIn": { + "Add": [ + { + "EvalCompiler": "ComputeTipSlot" + }, + { + "EvalParam": { + "ExpectValue": [ + "validity_period", + "Int" + ] + } + } + ] + } + } + ] + } + }, + "amount": { + "EvalBuiltIn": { + "Sub": [ + { + "EvalCoerce": { + "IntoAssets": { + "EvalParam": { + "ExpectInput": [ + "source", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "Number": 2000000 + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + } + } + }, + { + "EvalParam": "ExpectFees" + } + ] + } + }, + "optional": false + } + ], + "validity": null, + "mints": [], + "burns": [], + "adhoc": [], + "collateral": [], + "signers": null, + "metadata": [] +} \ No newline at end of file From 68cec90ed6126eed4f96fa6fc5a2db226f647a27 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 30 May 2026 18:20:36 -0300 Subject: [PATCH 3/5] docs(spec): specify user-defined functions in v1beta0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add fn_def to the grammar (§4.2.6), reserve fn/let (§3.6), add the domain-model §2.5 Functions overview, type-system §5.6.2 user-defined functions, static-semantics scoping/no-duplicate/no-recursion rules (§6.1, §6.2), and the inlining evaluation semantics (§7.13.5). Also reframe the four built-ins as functions sharing one call syntax and namespace, and drop the now-removed bare 'tip_slot' identifier form. Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/v1beta0/01-introduction.md | 4 +-- specs/v1beta0/02-domain-model.md | 41 ++++++++++++++++++----- specs/v1beta0/03-lexical-structure.md | 10 +++--- specs/v1beta0/04-syntactic-grammar.md | 24 +++++++++++-- specs/v1beta0/05-type-system.md | 36 ++++++++++++++++---- specs/v1beta0/06-static-semantics.md | 29 +++++++++++++--- specs/v1beta0/07-transaction-semantics.md | 33 ++++++++++++++++++ specs/v1beta0/09-conformance.md | 2 +- 8 files changed, 150 insertions(+), 29 deletions(-) diff --git a/specs/v1beta0/01-introduction.md b/specs/v1beta0/01-introduction.md index 4acbbdf5..7f168cf7 100644 --- a/specs/v1beta0/01-introduction.md +++ b/specs/v1beta0/01-introduction.md @@ -4,8 +4,8 @@ Tx3 is a domain-specific language for describing **transaction templates** on UTxO-based blockchains. A Tx3 program declares the shape of one or more -transactions together with the parties, assets, policies, types, and -environment values they refer to. It is not a general-purpose programming +transactions together with the parties, assets, policies, types, functions, +and environment values they refer to. It is not a general-purpose programming language and contains no constructs for unbounded loops, recursion, or side-effects. diff --git a/specs/v1beta0/02-domain-model.md b/specs/v1beta0/02-domain-model.md index 482e0029..5cf31573 100644 --- a/specs/v1beta0/02-domain-model.md +++ b/specs/v1beta0/02-domain-model.md @@ -16,6 +16,8 @@ source files. The top-level definitions, in their declared kinds, are: - *policy definitions* (`policy`): named minting/spending policies, either declared by hash literal or constructed from constituent fields. - *type definitions* (`type`): records, sum types (variants), and aliases. +- *function definitions* (`fn`): named, pure helpers that compute a data value + from typed parameters; see §2.5. - *transaction templates* (`tx`): parameterised templates for transactions. A program declares at most one `env` block. All other top-level kinds may @@ -54,13 +56,16 @@ a *data expression*. Data expressions cover: built-ins; - a restricted set of operators: addition (`+`), subtraction (`-`), prefix negation (`!`), property access (`.`), and indexing (`[…]`); -- a small set of built-in functions: `min_utxo`, `tip_slot`, `slot_to_time`, - `time_to_slot`, plus `concat` and the `AnyAsset` constructor. +- calls to *functions* — both the built-ins `min_utxo`, `tip_slot`, + `slot_to_time`, `time_to_slot` (plus `concat` and the `AnyAsset` constructor) + and user-defined functions (`fn`, §2.5) — using the same call syntax. -The expression language is intentionally restricted. It has no control flow, -no user-defined functions, no comparison operators, and no boolean -combinators. Everything that requires computation beyond simple arithmetic -and aggregation is the job of the resolver, not the source language. +The expression language is intentionally restricted. It has no control flow, no +comparison operators, and no boolean combinators. User-defined functions +(§2.5) are pure and non-recursive, and are eliminated by inlining before +resolution (§7); they add naming and reuse, not new runtime computation. +Everything that requires computation beyond simple arithmetic and aggregation +is the job of the resolver, not the source language. ## 2.4 Inputs, outputs, references, and named values @@ -82,7 +87,27 @@ Within the body of a `tx`, these names live in a single shared scope (see §6.1). Names need not be declared before use; in particular, output names may be referenced from other blocks regardless of textual order. -## 2.5 The compilation pipeline (informative) +## 2.5 Functions + +A *function* is a named, pure helper that maps typed parameters to a single +data value. A function declares a parameter list, an explicit return type, and +a body consisting of zero or more `let`-bindings followed by a result +expression (§4.2.6). Functions exist purely to name and reuse data-expression +fragments; they do not introduce new runtime behaviour. + +The language exposes a fixed set of *built-in* functions (`min_utxo`, +`tip_slot`, `slot_to_time`, `time_to_slot`) whose results are computed by the +resolver. *User-defined* functions are written with `fn` and are eliminated by +the compiler through inlining: each call site is replaced by the function's +result expression with the arguments substituted for the parameters (§7). As a +consequence: + +- functions MUST NOT be recursive, directly or indirectly; +- a function body is a data expression and MUST NOT contain transaction blocks; +- built-in and user-defined functions share one call syntax and one namespace + (§6.1), and are invoked wherever a data expression is allowed. + +## 2.6 The compilation pipeline (informative) A conforming compiler processes a Tx3 program in the following phases. The spec is normative only over the first two; lowering is described informatively @@ -99,7 +124,7 @@ Errors detected in phase 1 are *syntax errors*. Errors detected in phase 2 are *semantic errors*. A conforming compiler MUST emit at least one diagnostic for any program that violates a rule from §3–§8 (see §9). -## 2.6 Chain genericity +## 2.7 Chain genericity The core of the language (§3–§7) is intended to be chain-agnostic. Constructs that are specific to a particular target chain live under a dedicated diff --git a/specs/v1beta0/03-lexical-structure.md b/specs/v1beta0/03-lexical-structure.md index 82410c8a..703e33b0 100644 --- a/specs/v1beta0/03-lexical-structure.md +++ b/specs/v1beta0/03-lexical-structure.md @@ -60,8 +60,8 @@ where it becomes the *documentation string* of that construct: - a `party_def` (§4.2.3); - an `env_field` (§4.2.1); -- a `parameter` of a `tx_def` (§4.2.6); -- a `tx_def` (§4.2.6). +- a `parameter` of a `tx_def` (§4.2.7); +- a `tx_def` (§4.2.7). A `///` line that does not immediately precede one of the constructs above is a syntax error. Conforming compilers MAY relax this restriction as an @@ -182,9 +182,9 @@ user-defined identifiers: ``` env asset party policy type tx -input output mint burn reference collateral -validity signers metadata locals cardano bitcoin -true false +fn let input output mint burn +reference collateral validity signers metadata locals +cardano bitcoin true false Int Bool Bytes AnyAsset Address UtxoRef List Map ``` diff --git a/specs/v1beta0/04-syntactic-grammar.md b/specs/v1beta0/04-syntactic-grammar.md index c7bd8b46..62f7c14c 100644 --- a/specs/v1beta0/04-syntactic-grammar.md +++ b/specs/v1beta0/04-syntactic-grammar.md @@ -24,6 +24,7 @@ top_level_def ::= | party_def | policy_def | type_def + | fn_def | tx_def ``` @@ -111,7 +112,25 @@ Notes: variant-case field is required by the grammar above and is conformant Tx3 style. -### 4.2.6 Transaction +### 4.2.6 Function + +``` +fn_def ::= "fn" identifier parameter_list "->" type "{" fn_body "}" +fn_body ::= let_binding* data_expr +let_binding ::= "let" identifier "=" data_expr ";" +``` + +A *function definition* introduces a named, pure helper that computes a data +value. The `parameter_list` (§4.2.7) MAY be empty. The `type` after `->` is the +declared return type. A function body is an optional sequence of `let`-bindings +followed by a single result `data_expr`, whose static type MUST match the +declared return type (§5.7). Functions are top-level only: a function body MUST +NOT contain transaction blocks, and functions MUST NOT be nested. Function calls +use the `fn_call` production (§4.4) and MAY appear anywhere a `data_expr` is +valid. Static-semantic rules for functions are given in §6, and their +evaluation by inlining is specified in §7. + +### 4.2.7 Transaction ``` tx_def ::= docstring? "tx" identifier parameter_list "{" tx_body_block* "}" @@ -120,7 +139,8 @@ parameter ::= docstring? identifier ":" type ``` A *transaction definition* declares a parameterised template. The -`parameter_list` MAY be empty. Trailing commas are permitted. +`parameter_list` MAY be empty. Trailing commas are permitted. The +`parameter_list` production above is shared by `fn_def` (§4.2.6). ## 4.3 Types diff --git a/specs/v1beta0/05-type-system.md b/specs/v1beta0/05-type-system.md index d07908a5..25586039 100644 --- a/specs/v1beta0/05-type-system.md +++ b/specs/v1beta0/05-type-system.md @@ -197,10 +197,18 @@ The built-in `AnyAsset(policy, asset_name, amount)` yields a value of type `AnyAsset`. Its arguments MUST have types `Bytes`, `Bytes`, and `Int` respectively. -## 5.6 Built-in functions +## 5.6 Functions -The following identifiers, when used in `fn_call` position (§4.4), denote -built-in functions provided by every conforming compiler: +A *function* is invoked in `fn_call` position (§4.4). The result type of a call +is the function's declared (or, for built-ins, defined) return type. Each +argument MUST be assignable (§5.9) to the type of the corresponding parameter, +and the number of arguments MUST equal the number of parameters. Both built-in +and user-defined functions live in the single program-global namespace (§6.1). + +### 5.6.1 Built-in functions + +The following identifiers denote built-in functions provided by every +conforming compiler: | Name | Argument types | Result type | Notes | | ---------------- | ------------------------------- | ----------- | ----- | @@ -210,13 +218,29 @@ built-in functions provided by every conforming compiler: | `time_to_slot` | `(time : Int)` | `Int` | Converts a POSIX time to a slot number. | `min_utxo` is special: its single argument is treated as an identifier -reference rather than a general data expression. `tip_slot` MAY be invoked -either as `tip_slot()` or as the bare identifier `tip_slot` (the latter -form is accepted but using the call form is RECOMMENDED). +reference rather than a general data expression. Built-in functions MUST be +invoked using call syntax (e.g. `tip_slot()`); a bare identifier that resolves +to a built-in function is not a valid data expression. These names occupy the program-global namespace and SHOULD NOT be shadowed by user-defined identifiers. +### 5.6.2 User-defined functions + +A `fn_def` (§4.2.6) introduces a function whose signature is fully explicit: +each parameter is typed, and the return type follows `->`. Within the body, the +parameters are in scope (§6.1), and each `let`-binding introduces a name bound +to the static type of its initializer, visible to subsequent bindings and to +the result expression. The static type of the result expression MUST be +assignable (§5.9) to the declared return type; otherwise the program is +ill-typed. + +A function body is a pure data expression: it MUST NOT reference transaction +inputs, outputs, references, `fees`, or any `tx`-local symbol, and MUST NOT +contain transaction blocks. Functions MUST NOT be recursive (§6). Because +user-defined functions are eliminated by inlining (§7), they introduce no new +values or types beyond those expressible by their bodies. + ## 5.7 Built-in symbols In addition to the built-in functions above, the following bare identifiers diff --git a/specs/v1beta0/06-static-semantics.md b/specs/v1beta0/06-static-semantics.md index a3cb181f..a82f071f 100644 --- a/specs/v1beta0/06-static-semantics.md +++ b/specs/v1beta0/06-static-semantics.md @@ -17,6 +17,8 @@ The scope tree for a program has the following shape: ``` program scope +├── (per fn_def) function scope +│ └── (per let_binding) binding scope ├── (per tx_def) tx scope │ ├── (per struct/property/variant access) inner scope (§6.3) ``` @@ -30,8 +32,16 @@ The *program scope* contains: kind *asset*; - one symbol per `record_def` or `variant_def`, of kind *type*; - one symbol per `alias_def`, of kind *alias*; +- one symbol per `fn_def`, of kind *function*; - the built-in functions `min_utxo`, `tip_slot`, `slot_to_time`, - `time_to_slot` (§5.6). + `time_to_slot`, also of kind *function* (§5.6). + +A *function scope* (one per `fn_def`) extends the program scope with one symbol +per parameter, of kind *param-var*. The body's `let`-bindings are introduced +sequentially: each `let_binding` adds a *local-expr* symbol that is visible to +later bindings and to the result expression, but not to earlier bindings. A +function scope does not extend any `tx` scope, so a function body cannot +reference inputs, outputs, references, locals, or `fees` of any transaction. A *tx scope* (one per `tx_def`) extends the program scope with: @@ -70,13 +80,21 @@ the compiler MUST report a *not-in-scope* error. Within a single scope, an identifier MUST be bound by at most one definition. Multiple `record_def`/`variant_def`/`alias_def`/`party_def`/ -`policy_def`/`asset_def`/`env`-field/parameter/local/input/reference/named -output with the same name are *duplicate definitions* and MUST be -rejected. The error MAY name only the first or last occurrence. +`policy_def`/`asset_def`/`fn_def`/`env`-field/parameter/local/input/reference/ +named output with the same name are *duplicate definitions* and MUST be +rejected. The error MAY name only the first or last occurrence. A user-defined +`fn_def` that reuses the name of a built-in function (§5.6.1) is likewise a +duplicate definition. The implicit `Ada` asset definition is added by the compiler; redeclaring `Ada` at the top level is a duplicate-definition error (§5.8). +Functions MUST NOT be recursive: the call graph induced by `fn_call` +expressions in function bodies MUST be acyclic. A program containing a function +that calls itself, directly or through a cycle of other functions, is not +conforming; a conforming compiler SHOULD reject it with a diagnostic rather +than attempt the non-terminating inlining of §7. + ## 6.3 Type checking Every `data_expr` has a *static type* assigned by the analyzer. The static @@ -103,7 +121,8 @@ type is computed bottom-up as follows: static types of the first entry's key and value. - An `any_asset_constructor` has type `AnyAsset`. - A `concat_constructor` has the static type of its first argument. -- A `fn_call` to a built-in has the type listed in §5.6. +- A `fn_call` to a built-in has the type listed in §5.6.1; a `fn_call` to a + user-defined function has that function's declared return type (§5.6.2). - An `add_op` or `sub_op` has the static type of its left operand. - A `negate_op` has the static type of its operand. - A `property_op` `e.p` has the type of the property `p` on `e`'s type diff --git a/specs/v1beta0/07-transaction-semantics.md b/specs/v1beta0/07-transaction-semantics.md index 59c0ced2..dae629b9 100644 --- a/specs/v1beta0/07-transaction-semantics.md +++ b/specs/v1beta0/07-transaction-semantics.md @@ -309,6 +309,39 @@ implied by the policy. The semantics of `script` and `ref` fields are chain-specific and are described in §8 for Cardano targets. +### 7.13.5 `fn` + +```tx3 +fn double(x: Int) -> Int { + x + x +} + +fn discounted(base: Int, off: Int) -> Int { + let net = base - off; + net +} +``` + +A `fn_def` declares a pure, non-recursive helper (§2.5, §5.6.2). It contributes +no blocks or structure to any transaction; instead, every call to a +user-defined function is **inlined** during lowering: + +1. The function's result expression is taken as a template, with its + `let`-bindings substituted into it (each binding's name replaced by its + initializer, in declaration order). +2. Each parameter occurrence in that template is replaced by the corresponding + call argument expression, evaluated in the caller's scope. +3. The resulting expression is spliced in at the call site, as though the + programmer had written it inline. + +Because arguments are substituted positionally and functions are non-recursive +(§6.2), inlining terminates and yields an ordinary data expression. A call +therefore has no observable effect beyond that of its expansion: user-defined +functions are a source-level convenience and leave no trace in the lowered TIR. +Calls to built-in functions (§5.6.1) are not inlined; they lower to the +corresponding resolver operation (e.g. `min_utxo` → minimum-UTxO computation, +§7.5). + ## 7.14 Cross-block constraints Within a single `tx_def`, the following requirements apply across blocks: diff --git a/specs/v1beta0/09-conformance.md b/specs/v1beta0/09-conformance.md index d29072d3..2aa1eca6 100644 --- a/specs/v1beta0/09-conformance.md +++ b/specs/v1beta0/09-conformance.md @@ -27,7 +27,7 @@ source file: 1. **MUST** report no diagnostics, beyond optional warnings, when given a conforming program. The program MUST be processed up to and including - the static-semantic phase (§2.5) without rejection. + the static-semantic phase (§2.6) without rejection. 2. **MUST** emit at least one diagnostic in the appropriate category (§6.9) for any program that violates a MUST or MUST NOT from §3–§8. 3. **MUST** preserve the meaning of every conforming program when it From c7649c0cc663191a552b6e8dec631501dd611e1b Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 30 May 2026 18:47:27 -0300 Subject: [PATCH 4/5] fix(lang): resolve function-to-function calls Function bodies were analyzed against a scope holding pre-analysis clones of the FnDefs, so a call to another function embedded an unanalyzed callee whose body identifiers had no resolved symbols. Lowering inlines the callee's analyzed body, so such calls failed with MissingAnalyzePhase. Analyze functions to a fixed point: each pass re-registers the progressively-analyzed definitions and re-resolves call sites against them. Functions are non-recursive, so the definition count bounds the longest call chain and the iteration terminates. Covered by a new nested_functions example exercising a depth-2 chain. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tx3-lang/src/analyzing.rs | 23 +- crates/tx3-lang/src/lowering.rs | 2 + crates/tx3-lang/src/parsing.rs | 2 + examples/nested_functions.ast | 473 ++++++++++++++++++++++++++++++ examples/nested_functions.run.tir | 121 ++++++++ examples/nested_functions.tx3 | 25 ++ 6 files changed, 642 insertions(+), 4 deletions(-) create mode 100644 examples/nested_functions.ast create mode 100644 examples/nested_functions.run.tir create mode 100644 examples/nested_functions.tx3 diff --git a/crates/tx3-lang/src/analyzing.rs b/crates/tx3-lang/src/analyzing.rs index 13f0d399..70cf097a 100644 --- a/crates/tx3-lang/src/analyzing.rs +++ b/crates/tx3-lang/src/analyzing.rs @@ -1566,11 +1566,26 @@ impl Analyzable for Program { let (types, aliases) = resolve_types_and_aliases(scope_rc, &mut types, &mut aliases); - let functions = self.functions.analyze(self.scope.clone()); + // Functions may call other functions, and lowering inlines a callee's + // *analyzed* body. A single analysis pass leaves each call site holding + // a pre-analysis clone of its callee (whose own body is unresolved), so + // we analyze to a fixed point: each pass re-registers the + // progressively-analyzed definitions and re-resolves call sites against + // them. Functions are non-recursive (the call graph is acyclic), so the + // number of definitions is a sufficient upper bound on the longest call + // chain and the iteration terminates. + let program_scope = self.scope.clone(); + let mut functions = AnalyzeReport::default(); + for _ in 0..self.functions.len() { + let mut fn_scope = Scope::new(program_scope.clone()); + for fn_def in self.functions.iter() { + fn_scope.track_fn_def(fn_def); + } + functions = self.functions.analyze(Some(Rc::new(fn_scope))); + } - // Re-register analyzed FnDefs so txs resolve to analyzed versions - // (the originals in the scope are pre-analysis clones) - let mut fn_scope = Scope::new(self.scope.take()); + // Final scope: txs resolve calls to the fully-analyzed definitions. + let mut fn_scope = Scope::new(program_scope); for fn_def in self.functions.iter() { fn_scope.track_fn_def(fn_def); } diff --git a/crates/tx3-lang/src/lowering.rs b/crates/tx3-lang/src/lowering.rs index 70c56785..9c66af29 100644 --- a/crates/tx3-lang/src/lowering.rs +++ b/crates/tx3-lang/src/lowering.rs @@ -1083,6 +1083,8 @@ mod tests { test_lowering!(functions); + test_lowering!(nested_functions); + test_lowering!(param_field_shadow); test_lowering!(oracle_reference_datum); diff --git a/crates/tx3-lang/src/parsing.rs b/crates/tx3-lang/src/parsing.rs index c00df36a..77ae8cff 100644 --- a/crates/tx3-lang/src/parsing.rs +++ b/crates/tx3-lang/src/parsing.rs @@ -3046,4 +3046,6 @@ mod tests { test_parsing!(buidler_fest_2026); test_parsing!(functions); + + test_parsing!(nested_functions); } diff --git a/examples/nested_functions.ast b/examples/nested_functions.ast new file mode 100644 index 00000000..6b90394b --- /dev/null +++ b/examples/nested_functions.ast @@ -0,0 +1,473 @@ +{ + "env": null, + "txs": [ + { + "name": { + "value": "run", + "span": { + "dummy": false, + "start": 161, + "end": 164 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "amount", + "span": { + "dummy": false, + "start": 165, + "end": 171 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 164, + "end": 177 + } + }, + "locals": null, + "references": [], + "inputs": [ + { + "name": "source", + "many": false, + "fields": [ + { + "From": { + "Identifier": { + "value": "Sender", + "span": { + "dummy": false, + "start": 213, + "end": 219 + } + } + } + }, + { + "MinAmount": { + "FnCall": { + "callee": { + "value": "Ada", + "span": { + "dummy": false, + "start": 241, + "end": 244 + } + }, + "args": [ + { + "Identifier": { + "value": "amount", + "span": { + "dummy": false, + "start": 245, + "end": 251 + } + } + } + ], + "span": { + "dummy": false, + "start": 241, + "end": 252 + } + } + } + } + ], + "span": { + "dummy": false, + "start": 184, + "end": 259 + } + } + ], + "outputs": [ + { + "name": null, + "optional": false, + "fields": [ + { + "To": { + "Identifier": { + "value": "Receiver", + "span": { + "dummy": false, + "start": 285, + "end": 293 + } + } + } + }, + { + "Amount": { + "FnCall": { + "callee": { + "value": "Ada", + "span": { + "dummy": false, + "start": 311, + "end": 314 + } + }, + "args": [ + { + "FnCall": { + "callee": { + "value": "inc4", + "span": { + "dummy": false, + "start": 315, + "end": 319 + } + }, + "args": [ + { + "Identifier": { + "value": "amount", + "span": { + "dummy": false, + "start": 320, + "end": 326 + } + } + } + ], + "span": { + "dummy": false, + "start": 315, + "end": 327 + } + } + } + ], + "span": { + "dummy": false, + "start": 311, + "end": 328 + } + } + } + } + ], + "span": { + "dummy": false, + "start": 264, + "end": 335 + } + } + ], + "validity": null, + "mints": [], + "burns": [], + "signers": null, + "adhoc": [], + "span": { + "dummy": false, + "start": 158, + "end": 337 + }, + "collateral": [], + "metadata": null + } + ], + "types": [], + "aliases": [], + "assets": [], + "parties": [ + { + "name": { + "value": "Sender", + "span": { + "dummy": false, + "start": 6, + "end": 12 + } + }, + "span": { + "dummy": false, + "start": 0, + "end": 13 + } + }, + { + "name": { + "value": "Receiver", + "span": { + "dummy": false, + "start": 20, + "end": 28 + } + }, + "span": { + "dummy": false, + "start": 14, + "end": 29 + } + } + ], + "policies": [], + "functions": [ + { + "name": { + "value": "inc", + "span": { + "dummy": false, + "start": 34, + "end": 37 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "x", + "span": { + "dummy": false, + "start": 38, + "end": 39 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 37, + "end": 45 + } + }, + "return_type": "Int", + "body": { + "let_bindings": [], + "result": { + "AddOp": { + "lhs": { + "Identifier": { + "value": "x", + "span": { + "dummy": false, + "start": 59, + "end": 60 + } + } + }, + "rhs": { + "Number": 1 + }, + "span": { + "dummy": false, + "start": 61, + "end": 62 + } + } + }, + "span": { + "dummy": false, + "start": 59, + "end": 65 + } + }, + "span": { + "dummy": false, + "start": 31, + "end": 66 + } + }, + { + "name": { + "value": "inc2", + "span": { + "dummy": false, + "start": 71, + "end": 75 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "x", + "span": { + "dummy": false, + "start": 76, + "end": 77 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 75, + "end": 83 + } + }, + "return_type": "Int", + "body": { + "let_bindings": [], + "result": { + "FnCall": { + "callee": { + "value": "inc", + "span": { + "dummy": false, + "start": 97, + "end": 100 + } + }, + "args": [ + { + "FnCall": { + "callee": { + "value": "inc", + "span": { + "dummy": false, + "start": 101, + "end": 104 + } + }, + "args": [ + { + "Identifier": { + "value": "x", + "span": { + "dummy": false, + "start": 105, + "end": 106 + } + } + } + ], + "span": { + "dummy": false, + "start": 101, + "end": 107 + } + } + } + ], + "span": { + "dummy": false, + "start": 97, + "end": 108 + } + } + }, + "span": { + "dummy": false, + "start": 97, + "end": 109 + } + }, + "span": { + "dummy": false, + "start": 68, + "end": 110 + } + }, + { + "name": { + "value": "inc4", + "span": { + "dummy": false, + "start": 115, + "end": 119 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "x", + "span": { + "dummy": false, + "start": 120, + "end": 121 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 119, + "end": 127 + } + }, + "return_type": "Int", + "body": { + "let_bindings": [], + "result": { + "FnCall": { + "callee": { + "value": "inc2", + "span": { + "dummy": false, + "start": 141, + "end": 145 + } + }, + "args": [ + { + "FnCall": { + "callee": { + "value": "inc2", + "span": { + "dummy": false, + "start": 146, + "end": 150 + } + }, + "args": [ + { + "Identifier": { + "value": "x", + "span": { + "dummy": false, + "start": 151, + "end": 152 + } + } + } + ], + "span": { + "dummy": false, + "start": 146, + "end": 153 + } + } + } + ], + "span": { + "dummy": false, + "start": 141, + "end": 154 + } + } + }, + "span": { + "dummy": false, + "start": 141, + "end": 155 + } + }, + "span": { + "dummy": false, + "start": 112, + "end": 156 + } + } + ], + "span": { + "dummy": false, + "start": 0, + "end": 338 + } +} \ No newline at end of file diff --git a/examples/nested_functions.run.tir b/examples/nested_functions.run.tir new file mode 100644 index 00000000..1041b9f0 --- /dev/null +++ b/examples/nested_functions.run.tir @@ -0,0 +1,121 @@ +{ + "fees": { + "EvalParam": "ExpectFees" + }, + "references": [], + "inputs": [ + { + "name": "source", + "utxos": { + "EvalParam": { + "ExpectInput": [ + "source", + { + "address": { + "EvalParam": { + "ExpectValue": [ + "sender", + "Address" + ] + } + }, + "min_amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "EvalParam": { + "ExpectValue": [ + "amount", + "Int" + ] + } + } + } + ] + }, + "ref": "None", + "many": false, + "collateral": false + } + ] + } + }, + "redeemer": "None" + } + ], + "outputs": [ + { + "address": { + "EvalParam": { + "ExpectValue": [ + "receiver", + "Address" + ] + } + }, + "datum": "None", + "amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "EvalBuiltIn": { + "Add": [ + { + "EvalBuiltIn": { + "Add": [ + { + "EvalBuiltIn": { + "Add": [ + { + "EvalBuiltIn": { + "Add": [ + { + "EvalParam": { + "ExpectValue": [ + "amount", + "Int" + ] + } + }, + { + "Number": 1 + } + ] + } + }, + { + "Number": 1 + } + ] + } + }, + { + "Number": 1 + } + ] + } + }, + { + "Number": 1 + } + ] + } + } + } + ] + }, + "optional": false + } + ], + "validity": null, + "mints": [], + "burns": [], + "adhoc": [], + "collateral": [], + "signers": null, + "metadata": [] +} \ No newline at end of file diff --git a/examples/nested_functions.tx3 b/examples/nested_functions.tx3 new file mode 100644 index 00000000..81a3c794 --- /dev/null +++ b/examples/nested_functions.tx3 @@ -0,0 +1,25 @@ +party Sender; +party Receiver; + +fn inc(x: Int) -> Int { + x + 1 +} + +fn inc2(x: Int) -> Int { + inc(inc(x)) +} + +fn inc4(x: Int) -> Int { + inc2(inc2(x)) +} + +tx run(amount: Int) { + input source { + from: Sender, + min_amount: Ada(amount), + } + output { + to: Receiver, + amount: Ada(inc4(amount)), + } +} From 5e543d3f517093502074ab5506ad868e6b430aa6 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 30 May 2026 19:06:15 -0300 Subject: [PATCH 5/5] refactor(lang): tag built-in functions with a BuiltinFn enum Built-in functions were re-identified at lowering by re-matching the callee name string against a list duplicated from builtin_fn_defs(), with a fallible '_ => unknown built-in' arm. Carry an explicit BuiltinFn kind on FnDef instead: builtin_fn_defs() is now the single source of truth, FnCall lowering dispatches on the kind, and the builtin lowering match is exhaustive (the compiler enforces coverage, so the unreachable error arm is gone). The field is None for user-defined functions and skipped during serialization, so example AST goldens are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tx3-lang/src/analyzing.rs | 4 +++ crates/tx3-lang/src/ast.rs | 17 +++++++++++++ crates/tx3-lang/src/lowering.rs | 43 ++++++++++---------------------- crates/tx3-lang/src/parsing.rs | 1 + 4 files changed, 35 insertions(+), 30 deletions(-) diff --git a/crates/tx3-lang/src/analyzing.rs b/crates/tx3-lang/src/analyzing.rs index 70cf097a..f11ffb66 100644 --- a/crates/tx3-lang/src/analyzing.rs +++ b/crates/tx3-lang/src/analyzing.rs @@ -319,6 +319,7 @@ fn builtin_fn_defs() -> Vec { }, return_type: Type::AnyAsset, body: None, + builtin: Some(BuiltinFn::MinUtxo), span: Span::DUMMY, scope: None, }, @@ -330,6 +331,7 @@ fn builtin_fn_defs() -> Vec { }, return_type: Type::Int, body: None, + builtin: Some(BuiltinFn::TipSlot), span: Span::DUMMY, scope: None, }, @@ -345,6 +347,7 @@ fn builtin_fn_defs() -> Vec { }, return_type: Type::Int, body: None, + builtin: Some(BuiltinFn::SlotToTime), span: Span::DUMMY, scope: None, }, @@ -360,6 +363,7 @@ fn builtin_fn_defs() -> Vec { }, return_type: Type::Int, body: None, + builtin: Some(BuiltinFn::TimeToSlot), span: Span::DUMMY, scope: None, }, diff --git a/crates/tx3-lang/src/ast.rs b/crates/tx3-lang/src/ast.rs index c00e236e..dfdeb6da 100644 --- a/crates/tx3-lang/src/ast.rs +++ b/crates/tx3-lang/src/ast.rs @@ -994,12 +994,29 @@ pub struct FnBody { pub span: Span, } +/// A function provided by the compiler rather than declared in source. Each +/// variant lowers to a dedicated compiler operation (§7); see +/// `analyzing::builtin_fn_defs` for their signatures. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum BuiltinFn { + MinUtxo, + TipSlot, + SlotToTime, + TimeToSlot, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FnDef { pub name: Identifier, pub parameters: ParameterList, pub return_type: Type, + /// The inline body of a user-defined function. `None` for built-ins, which + /// carry a `builtin` kind instead. pub body: Option, + /// Set when this is a compiler-provided function; mutually exclusive with + /// `body`. User-defined functions always parse with `builtin: None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub builtin: Option, pub span: Span, // analysis diff --git a/crates/tx3-lang/src/lowering.rs b/crates/tx3-lang/src/lowering.rs index 9c66af29..05ff3e88 100644 --- a/crates/tx3-lang/src/lowering.rs +++ b/crates/tx3-lang/src/lowering.rs @@ -416,11 +416,10 @@ impl IntoLower for ast::FnCall { // Check if callee resolves to a function definition if let Some(symbol) = self.callee.symbol.as_ref() { if let Some(fn_def) = symbol.as_fn_def() { - return if fn_def.body.is_some() { - inline_fn_call(fn_def, &self.args, ctx) - } else { - lower_builtin_fn_call(&self.callee.value, &self.args, ctx) - }; + if let Some(builtin) = fn_def.builtin { + return lower_builtin_fn_call(builtin, &self.args, ctx); + } + return inline_fn_call(fn_def, &self.args, ctx); } } @@ -446,34 +445,18 @@ impl IntoLower for ast::FnCall { } fn lower_builtin_fn_call( - name: &str, + builtin: ast::BuiltinFn, args: &[ast::DataExpr], ctx: &Context, ) -> Result { - match name { - "min_utxo" => { - let arg = args[0].into_lower(ctx)?; - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeMinUtxo(arg), - ))) - } - "tip_slot" => Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeTipSlot, - ))), - "slot_to_time" => { - let arg = args[0].into_lower(ctx)?; - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeSlotToTime(arg), - ))) - } - "time_to_slot" => { - let arg = args[0].into_lower(ctx)?; - Ok(ir::Expression::EvalCompiler(Box::new( - ir::CompilerOp::ComputeTimeToSlot(arg), - ))) - } - _ => Err(Error::InvalidAst(format!("unknown built-in function: {}", name))), - } + let op = match builtin { + ast::BuiltinFn::MinUtxo => ir::CompilerOp::ComputeMinUtxo(args[0].into_lower(ctx)?), + ast::BuiltinFn::TipSlot => ir::CompilerOp::ComputeTipSlot, + ast::BuiltinFn::SlotToTime => ir::CompilerOp::ComputeSlotToTime(args[0].into_lower(ctx)?), + ast::BuiltinFn::TimeToSlot => ir::CompilerOp::ComputeTimeToSlot(args[0].into_lower(ctx)?), + }; + + Ok(ir::Expression::EvalCompiler(Box::new(op))) } struct ParamSubstituter<'a> { diff --git a/crates/tx3-lang/src/parsing.rs b/crates/tx3-lang/src/parsing.rs index 77ae8cff..09c8caef 100644 --- a/crates/tx3-lang/src/parsing.rs +++ b/crates/tx3-lang/src/parsing.rs @@ -1028,6 +1028,7 @@ impl AstNode for FnDef { parameters, return_type, body: Some(body), + builtin: None, span, scope: None, })