From 90e900e6bbd87c654657917bff4108eefa0d9e08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Wed, 3 Dec 2025 15:16:32 -0300 Subject: [PATCH 01/32] feat: inject types from parsed plutus.json files into tx3 pipeline --- crates/tx3-lang/src/cardano.rs | 52 ++++++++++++++++++++++++++++++++++ crates/tx3-lang/src/parsing.rs | 7 ++++- crates/tx3-lang/src/tx3.pest | 11 +++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/crates/tx3-lang/src/cardano.rs b/crates/tx3-lang/src/cardano.rs index a91c511f..6954c7a9 100644 --- a/crates/tx3-lang/src/cardano.rs +++ b/crates/tx3-lang/src/cardano.rs @@ -841,6 +841,58 @@ impl IntoLower for CardanoBlock { } } +pub fn load_externals(path: &str) -> Vec { + use crate::ast::{Identifier, RecordField, Span, Type, TypeDef, VariantCase}; + + vec![TypeDef { + name: Identifier { + value: "PlutusData".to_string(), + span: Span::DUMMY, + symbol: None, + }, + cases: vec![ + VariantCase { + name: Identifier::new("Constr"), + fields: vec![ + RecordField::new("constructor", Type::Int), + RecordField::new( + "fields", + Type::List(Box::new(Type::Custom(Identifier::new("PlutusData")))), + ), + ], + span: Span::DUMMY, + }, + VariantCase { + name: Identifier::new("Map"), + fields: vec![RecordField::new( + "entries", + Type::List(Box::new(Type::Custom(Identifier::new("PlutusData")))), + )], + span: Span::DUMMY, + }, + VariantCase { + name: Identifier::new("List"), + fields: vec![RecordField::new( + "items", + Type::List(Box::new(Type::Custom(Identifier::new("PlutusData")))), + )], + span: Span::DUMMY, + }, + VariantCase { + name: Identifier::new("Integer"), + fields: vec![RecordField::new("value", Type::Int)], + span: Span::DUMMY, + }, + VariantCase { + name: Identifier::new("Bytes"), + fields: vec![RecordField::new("value", Type::Bytes)], + span: Span::DUMMY, + }, + ], + span: Span::DUMMY, + }] +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tx3-lang/src/parsing.rs b/crates/tx3-lang/src/parsing.rs index 1e67ca80..d07b1c50 100644 --- a/crates/tx3-lang/src/parsing.rs +++ b/crates/tx3-lang/src/parsing.rs @@ -14,7 +14,7 @@ use pest_derive::Parser; use crate::{ ast::*, - cardano::{PlutusWitnessBlock, PlutusWitnessField}, + cardano::{load_externals, PlutusWitnessBlock, PlutusWitnessField}, }; #[derive(Parser)] #[grammar = "tx3.pest"] @@ -108,6 +108,11 @@ 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::cardano_import => { + let import_path = pair.into_inner().as_str(); + let external_types = load_externals(import_path); + program.types.extend(external_types); + } Rule::EOI => break, x => unreachable!("Unexpected rule in program: {:?}", x), } diff --git a/crates/tx3-lang/src/tx3.pest b/crates/tx3-lang/src/tx3.pest index 43ef45c1..4f787568 100644 --- a/crates/tx3-lang/src/tx3.pest +++ b/crates/tx3-lang/src/tx3.pest @@ -462,9 +462,20 @@ tx_def = { "tx" ~ identifier ~ parameter_list ~ "{" ~ tx_body_block* ~ "}" } +path_segment = @{ ASCII_ALPHANUMERIC+ | "." | "_" | "-" } + +file_ext = @{ "." ~ ASCII_ALPHANUMERIC+ } + +file_path = @{ path_segment ~ ("/" ~ path_segment)* ~ file_ext? } + +cardano_import = { + "cardano::import " ~ "\"" ~ file_path ~ "\"" ~ ";" +} + // Program program = { SOI ~ + cardano_import* ~ (env_def | asset_def | party_def | policy_def | type_def | tx_def)* ~ EOI } From 875e62d703e58e4fb4bc170544a091472591aef9 Mon Sep 17 00:00:00 2001 From: sofia-bobbiesi Date: Wed, 3 Dec 2025 16:18:38 -0300 Subject: [PATCH 02/32] feat: add CIP-57 parsing crate with blueprint and schema structures --- Cargo.lock | 9 + Cargo.toml | 7 +- crates/CIP-57/Cargo.toml | 19 ++ crates/CIP-57/src/blueprint.rs | 156 +++++++++++ crates/CIP-57/src/lib.rs | 482 +++++++++++++++++++++++++++++++++ crates/CIP-57/src/schema.rs | 267 ++++++++++++++++++ 6 files changed, 939 insertions(+), 1 deletion(-) create mode 100644 crates/CIP-57/Cargo.toml create mode 100644 crates/CIP-57/src/blueprint.rs create mode 100644 crates/CIP-57/src/lib.rs create mode 100644 crates/CIP-57/src/schema.rs diff --git a/Cargo.lock b/Cargo.lock index be1edebf..3a334efe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -313,6 +313,15 @@ dependencies = [ "half", ] +[[package]] +name = "cip57" +version = "0.13.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", +] + [[package]] name = "core-foundation" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index 42a07143..6cecbe46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,11 @@ [workspace] resolver = "2" -members = ["crates/tx3-cardano", "crates/tx3-lang", "crates/tx3-resolver"] +members = [ + "crates/tx3-cardano", + "crates/tx3-lang", + "crates/tx3-resolver", + "crates/CIP-57", +] [workspace.package] publish = true diff --git a/crates/CIP-57/Cargo.toml b/crates/CIP-57/Cargo.toml new file mode 100644 index 00000000..e7345fd9 --- /dev/null +++ b/crates/CIP-57/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "cip57" +description = "CIP-57 compatibility (JSON parsing and serialization)" +publish.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true +keywords.workspace = true +documentation.workspace = true +homepage.workspace = true +readme.workspace = true + + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" diff --git a/crates/CIP-57/src/blueprint.rs b/crates/CIP-57/src/blueprint.rs new file mode 100644 index 00000000..09b4147c --- /dev/null +++ b/crates/CIP-57/src/blueprint.rs @@ -0,0 +1,156 @@ +//! This module defines the structures for a Blueprint, including its preamble, validators, and definitions. + +use serde::{Deserialize, Serialize}; +use serde_json::Number; +use std::collections::BTreeMap; + +/// Represents a blueprint containing preamble, validators, and optional definitions. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Blueprint { + pub preamble: Preamble, + pub validators: Vec, + pub definitions: Option, +} + +/// Represents the preamble of a blueprint, including metadata such as title, description, version, and compiler information. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Preamble { + pub title: String, + pub description: Option, + pub version: String, + pub plutus_version: String, + pub compiler: Option, + pub license: Option, +} + +/// Represents the compiler information in the preamble. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Compiler { + pub name: String, + pub version: Option, +} + +/// Represents a validator in the blueprint, including its title, description, compiled code, hash, datum, redeemer, and parameters. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Validator { + pub title: String, + pub description: Option, + pub compiled_code: Option, + pub hash: Option, + pub datum: Option, + pub redeemer: Option, + pub parameters: Option>, +} + +/// Represents an argument in a validator, including its title, description, purpose, and schema reference. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Argument { + pub title: Option, + pub description: Option, + pub purpose: Option, + pub schema: Reference, +} + +/// Represents a purpose array which can be either a single purpose or an array of purposes. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(untagged)] +pub enum PurposeArray { + Single(Purpose), + Array(Vec), +} + +/// Represents the purpose of an argument, which can be spend, mint, withdraw, or publish. +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum Purpose { + Spend, + Mint, + Withdraw, + Publish, +} + +/// Represents a reference to a schema. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Reference { + #[serde(rename = "$ref")] + pub reference: Option, +} + +/// Represents a parameter in a validator, including its title, description, purpose, and schema reference. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Parameter { + pub title: Option, + pub description: Option, + pub purpose: Option, + pub schema: Reference, +} + +/// Represents the definitions in a blueprint, which is a map of definition names to their corresponding definitions. +#[derive(Debug, Default, Deserialize, Serialize, Clone)] +pub struct Definitions { + #[serde(flatten, default)] + pub inner: BTreeMap, +} + +/// Represents a definition in the blueprint, including its title, description, data type, any_of schemas, items, keys, and values. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Definition { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub data_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub any_of: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub keys: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub values: Option, +} + +/// Represents an array of references which can be either a single reference or an array of references. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(untagged)] +pub enum ReferencesArray { + Single(Reference), + Array(Vec), +} + +/// Represents a schema in a definition, including its title, description, data type, index, and fields. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Schema { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub data_type: DataType, + pub index: Number, + pub fields: Vec, +} + +/// Represents the data type of a schema, which can be integer, bytes, list, map, or constructor. +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum DataType { + Integer, + Bytes, + List, + Map, + Constructor, +} + +/// Represents a field in a schema, including its title and reference. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Field { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(rename = "$ref")] + pub reference: String, +} diff --git a/crates/CIP-57/src/lib.rs b/crates/CIP-57/src/lib.rs new file mode 100644 index 00000000..1fe7651a --- /dev/null +++ b/crates/CIP-57/src/lib.rs @@ -0,0 +1,482 @@ +//! This module provides functions to work with blueprints and schemas, including +//! parsing JSON, reading from files, and JSON-to-JSON conversion helpers. + +use anyhow::Result; +use schema::TypeName; +use serde_json; +use std::fs; +use tx3_lang::ir::{Expression, StructExpr}; + +pub mod blueprint; +pub mod schema; +// Templates are intentionally removed for a pure JSON-to-JSON crate. + +pub struct Codegen {} + +impl Codegen { + pub fn new() -> Codegen { + Codegen {} + } + + fn get_schema_name(&self, key: String) -> String { + // Keep a simple normalization without external deps (heck) + let normalized = key + .replace("#/definitions/", "") + .replace("~1", " ") + .replace("/", " ") + .replace("_", " ") + .replace("$", " "); + // Basic PascalCase conversion + normalized + .split_whitespace() + .map(|w| { + let mut chars = w.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } + }) + .collect::>() + .join("") + } + + fn parse_bytes_string(s: &str) -> Vec { + if let Some(hex) = s.strip_prefix("0x") { + return hex::decode(hex).unwrap_or_default(); + } + s.as_bytes().to_vec() + } + + /// Parses a JSON string into a `Blueprint`. + /// + /// # Arguments + /// + /// * `json` - The JSON data from a `plutus.json` file + /// + /// # Returns + /// + /// A `Blueprint` instance or an error. + pub fn get_blueprint_from_json(&self, json: String) -> Result { + let bp = serde_json::from_str(&json)?; + Ok(bp) + } + + /// Reads a JSON file from a specified path and parses it into a `Blueprint`. + /// + /// # Arguments + /// + /// * `path` - The `plutus.json` file path in the filesystem + /// + /// # Returns + /// + /// A `Blueprint` instance or an error. + pub fn get_blueprint_from_path(&self, path: String) -> Result { + let json = fs::read_to_string(path)?; + self.get_blueprint_from_json(json) + } + + /// Obtains the list of schemas from a given `Blueprint`. + /// + /// # Arguments + /// + /// * `blueprint` - A `Blueprint` from which to obtain the schemas. + /// + /// # Returns + /// + /// A vector of `Schema` from the blueprint. + pub fn get_schemas_from_blueprint( + &self, + blueprint: blueprint::Blueprint, + ) -> Vec { + let mut schemas: Vec = vec![]; + if blueprint.definitions.is_some() { + for definition in blueprint.definitions.unwrap().inner.iter() { + let definition_name = self.get_schema_name(definition.0.clone()); + let definition_json = serde_json::to_string(&definition.1).unwrap(); + if definition.1.data_type.is_some() { + match definition.1.data_type.unwrap() { + blueprint::DataType::Integer => { + schemas.push(schema::Schema::new_integer( + definition_name.clone(), + definition_json.clone(), + )); + } + blueprint::DataType::Bytes => { + schemas.push(schema::Schema::new_bytes( + definition_name.clone(), + definition_json.clone(), + )); + } + blueprint::DataType::List => { + if definition.1.items.is_some() { + match definition.1.items.as_ref().unwrap() { + blueprint::ReferencesArray::Single(reference) => { + if reference.reference.is_some() { + schemas.push(schema::Schema::new_list( + definition_name.clone(), + schema::Reference { + name: None, + schema_name: self.get_schema_name( + reference + .reference + .as_ref() + .unwrap() + .clone(), + ), + }, + definition_json.clone(), + )); + } + } + blueprint::ReferencesArray::Array(references) => { + let mut properties: Vec = vec![]; + for reference in references { + if reference.reference.is_some() { + properties.push(schema::Reference { + name: None, + schema_name: self.get_schema_name( + reference + .reference + .as_ref() + .unwrap() + .clone(), + ), + }); + } + } + schemas.push(schema::Schema::new_tuple( + definition_name.clone(), + properties, + definition_json.clone(), + )); + } + } + } + } + _ => {} + } + } + if definition.1.title.is_some() { + if definition.1.title.as_ref().unwrap() == "Data" && definition_name == "Data" { + schemas.push(schema::Schema::new_anydata(definition_json.clone())); + } + } + if definition.1.any_of.is_some() { + let mut internal_schemas: Vec = vec![]; + for (index, parameter) in + definition.1.any_of.as_ref().unwrap().iter().enumerate() + { + match parameter.data_type { + blueprint::DataType::Constructor => { + let schema_name = format!( + "{}{}", + definition_name, + parameter.title.clone().unwrap_or((index + 1).to_string()) + ); + let mut properties: Vec = vec![]; + for property in ¶meter.fields { + let mut schema_name = + self.get_schema_name(property.reference.clone()); + if schema_name == "Data" { + schema_name = "AnyData".to_string(); + } + properties.push(schema::Reference { + name: property.title.clone(), + schema_name, + }); + } + let schema: schema::Schema; + if properties.len().gt(&0) || parameter.title.is_none() { + if properties.iter().any(|p| p.name.is_none()) { + schema = schema::Schema::new_tuple( + schema_name, + properties, + definition_json.clone(), + ); + } else { + schema = schema::Schema::new_object( + schema_name, + properties, + definition_json.clone(), + ); + } + } else { + schema = schema::Schema::new_literal( + schema_name, + parameter.title.clone().unwrap(), + definition_json.clone(), + ); + } + internal_schemas.push(schema); + } + _ => {} + } + } + if internal_schemas.len().eq(&1) { + let mut schema = internal_schemas.first().unwrap().clone(); + schema.name = definition_name.clone(); + schemas.push(schema); + } + if internal_schemas.len().gt(&1) { + if internal_schemas.len().eq(&2) + && internal_schemas + .iter() + .any(|s| s.type_name.eq(&TypeName::Literal)) + && !internal_schemas + .iter() + .all(|s| s.type_name.eq(&TypeName::Literal)) + { + let reference = internal_schemas + .iter() + .find(|s| s.type_name.ne(&TypeName::Literal)); + schemas.push(reference.unwrap().clone()); + schemas.push(schema::Schema::new_nullable( + definition_name.clone(), + reference.unwrap().name.clone(), + definition_json.clone(), + )); + } else { + for schema in &internal_schemas { + schemas.push(schema.clone()); + } + schemas.push(schema::Schema::new_enum( + definition_name.clone(), + &internal_schemas, + definition_json.clone(), + )); + } + } + } + } + } + + schemas + } + + /// Obtains the list of validators from a given `Blueprint`. + /// + /// # Arguments + /// + /// * `blueprint` - A `Blueprint` from which to obtain the validators. + /// + /// # Returns + /// + /// A vector of `Validator` from the blueprint. + pub fn get_validators_from_blueprint( + &self, + blueprint: blueprint::Blueprint, + ) -> Vec { + let mut validators: Vec = vec![]; + for validator in blueprint.validators.iter() { + let mut datum: Option = None; + if validator.datum.is_some() + && validator.datum.as_ref().unwrap().schema.reference.is_some() + { + datum = Some(schema::Reference { + name: validator.datum.as_ref().unwrap().title.clone(), + schema_name: self.get_schema_name( + validator + .datum + .as_ref() + .unwrap() + .schema + .reference + .as_ref() + .unwrap() + .clone(), + ), + }); + } + let mut redeemer: Option = None; + if validator.redeemer.is_some() + && validator + .redeemer + .as_ref() + .unwrap() + .schema + .reference + .is_some() + { + redeemer = Some(schema::Reference { + name: validator.redeemer.as_ref().unwrap().title.clone(), + schema_name: self.get_schema_name( + validator + .redeemer + .as_ref() + .unwrap() + .schema + .reference + .as_ref() + .unwrap() + .clone(), + ), + }); + } + let mut parameters: Vec = vec![]; + if let Some(p) = &validator.parameters { + for parameter in p { + if parameter.schema.reference.is_some() { + parameters.push(schema::Reference { + name: parameter.title.clone(), + schema_name: self.get_schema_name( + parameter.schema.reference.as_ref().unwrap().clone(), + ), + }) + } + } + } + validators.push(schema::Validator { + name: validator.title.clone(), + datum: datum, + redeemer: redeemer, + parameters: parameters, + }); + } + validators + } + + /// Converts a `Blueprint` into a JSON value containing extracted schemas and validators. + + + /// Convert a JSON value according to a schema name into a tx3 IR Expression. + /// This expects `value` to match the referenced schema (by name) present in the blueprint definitions. + pub fn convert_value_by_schema_name( + &self, + blueprint: &blueprint::Blueprint, + schema_name: &str, + value: &serde_json::Value, + ) -> Result { + // Build a quick lookup of definitions by normalized schema name + let mut defs = std::collections::BTreeMap::new(); + if let Some(all) = &blueprint.definitions { + for (raw_name, def) in &all.inner { + let name = self.get_schema_name(raw_name.clone()); + defs.insert(name, def); + } + } + + let def = defs + .get(schema_name) + .ok_or_else(|| anyhow::anyhow!("unknown schema: {}", schema_name))?; + + // Primitive types + if let Some(dt) = def.data_type { + match dt { + blueprint::DataType::Integer => { + let num = match value { + serde_json::Value::Number(n) => n.as_i64().unwrap_or(0) as i128, + serde_json::Value::String(s) => s.parse::().unwrap_or(0), + _ => 0, + }; + return Ok(Expression::Number(num)); + } + blueprint::DataType::Bytes => { + let bytes = match value { + serde_json::Value::String(s) => Self::parse_bytes_string(s), + serde_json::Value::Array(arr) => arr + .iter() + .filter_map(|v| v.as_u64().map(|x| x as u8)) + .collect(), + _ => Vec::new(), + }; + return Ok(Expression::Bytes(bytes)); + } + blueprint::DataType::List => { + // For lists, expect a single reference in items (or tuple in Array case already handled elsewhere) + if let Some(items) = &def.items { + match items { + blueprint::ReferencesArray::Single(r) => { + let inner_name = r + .reference + .as_ref() + .map(|s| self.get_schema_name(s.clone())) + .ok_or_else(|| anyhow::anyhow!("list items missing $ref"))?; + let list = match value { + serde_json::Value::Array(arr) => { + let mut out = Vec::new(); + for v in arr { + out.push(self.convert_value_by_schema_name( + blueprint, + &inner_name, + v, + )?); + } + out + } + _ => Vec::new(), + }; + return Ok(Expression::List(list)); + } + blueprint::ReferencesArray::Array(_) => { + // Treat as tuple + let arr = value.as_array().cloned().unwrap_or_default(); + let mut fields = Vec::new(); + if let blueprint::ReferencesArray::Array(refs) = items { + for (i, r) in refs.iter().enumerate() { + let inner_name = r + .reference + .as_ref() + .map(|s| self.get_schema_name(s.clone())) + .ok_or_else(|| { + anyhow::anyhow!("tuple item missing $ref") + })?; + let v = + arr.get(i).cloned().unwrap_or(serde_json::Value::Null); + fields.push(self.convert_value_by_schema_name( + blueprint, + &inner_name, + &v, + )?); + } + } + return Ok(Expression::Struct(StructExpr { + constructor: 0, + fields, + })); + } + } + } + } + _ => {} + } + } + + // Constructors (anyOf with data_type = Constructor). We choose variant by matching expected shape or `index`. + if let Some(any_of) = &def.any_of { + // Strategy: if `value` is an object with fields, pick matching constructor by field count; else use first. + let chosen = any_of + .first() + .ok_or_else(|| anyhow::anyhow!("empty anyOf"))?; + let index = chosen.index.as_i64().unwrap_or(0) as usize; + let mut fields_expr = Vec::new(); + for f in &chosen.fields { + let ref_name = self.get_schema_name(f.reference.clone()); + // Try to fetch field by title from value object; fallback to Null + let field_json = match value { + serde_json::Value::Object(map) => { + if let Some(title) = &f.title { + map.get(title).cloned().unwrap_or(serde_json::Value::Null) + } else { + serde_json::Value::Null + } + } + _ => serde_json::Value::Null, + }; + fields_expr.push(self.convert_value_by_schema_name( + blueprint, + &ref_name, + &field_json, + )?); + } + return Ok(Expression::Struct(StructExpr { + constructor: index, + fields: fields_expr, + })); + } + + // Fallback + Ok(Expression::None) + } + +// (no free functions) +} diff --git a/crates/CIP-57/src/schema.rs b/crates/CIP-57/src/schema.rs new file mode 100644 index 00000000..8680e523 --- /dev/null +++ b/crates/CIP-57/src/schema.rs @@ -0,0 +1,267 @@ +//! This module defines the structures and implementations for schemas, including types, references, and validators. + +use serde::{Deserialize, Serialize}; +use std::str; + +/// Represents the different types a schema can have. +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum TypeName { + AnyData, + Integer, + Bytes, + Literal, + Nullable, + Object, + Enum, + Tuple, + List, +} + +/// Represents a schema with a name, type, optional properties, and JSON representation. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Schema { + pub name: String, + pub type_name: TypeName, + pub properties: Option>, + pub json: String, +} + +/// Represents a reference to another schema, including an optional name and schema name. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Reference { + pub name: Option, + pub schema_name: String, +} + +/// Represents a validator with a name, optional datum and redeemer, and parameters. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Validator { + pub name: String, + pub datum: Option, + pub redeemer: Option, + pub parameters: Vec, +} + +impl Schema { + /// Creates a new any data schema. + /// + /// # Arguments + /// + /// * `json` - The JSON representation of the schema. + /// + /// # Returns + /// + /// A new `Schema` instance with type `AnyData`. + pub fn new_anydata(json: String) -> Self { + Self { + name: "AnyData".to_string(), + type_name: TypeName::AnyData, + properties: None, + json, + } + } + + /// Creates a new integer schema. + /// + /// # Arguments + /// + /// * `name` - The name of the schema. + /// * `json` - The JSON representation of the schema. + /// + /// # Returns + /// + /// A new `Schema` instance with type `Integer`. + pub fn new_integer(name: String, json: String) -> Self { + Self { + name, + type_name: TypeName::Integer, + properties: None, + json, + } + } + + /// Creates a new bytes schema. + /// + /// # Arguments + /// + /// * `name` - The name of the schema. + /// * `json` - The JSON representation of the schema. + /// + /// # Returns + /// + /// A new `Schema` instance with type `Bytes`. + pub fn new_bytes(name: String, json: String) -> Self { + Self { + name, + type_name: TypeName::Bytes, + properties: None, + json, + } + } + + /// Creates a new literal schema. + /// + /// # Arguments + /// + /// * `name` - The name of the schema. + /// * `value` - The literal value of the schema. + /// * `json` - The JSON representation of the schema. + /// + /// # Returns + /// + /// A new `Schema` instance with type `Literal`. + pub fn new_literal(name: String, value: String, json: String) -> Self { + Self { + name, + type_name: TypeName::Literal, + properties: Some(vec![Reference { + name: None, + schema_name: value, + }]), + json, + } + } + + /// Creates a new nullable schema. + /// + /// # Arguments + /// + /// * `name` - The name of the schema. + /// * `reference` - The reference schema name. + /// * `json` - The JSON representation of the schema. + /// + /// # Returns + /// + /// A new `Schema` instance with type `Nullable`. + pub fn new_nullable(name: String, reference: String, json: String) -> Self { + Self { + name, + type_name: TypeName::Nullable, + properties: Some(vec![Reference { + name: None, + schema_name: reference, + }]), + json, + } + } + + /// Creates a new object schema. + /// + /// # Arguments + /// + /// * `name` - The name of the schema. + /// * `properties` - The properties of the object schema. + /// * `json` - The JSON representation of the schema. + /// + /// # Returns + /// + /// A new `Schema` instance with type `Object`. + pub fn new_object(name: String, properties: Vec, json: String) -> Self { + Self { + name, + type_name: TypeName::Object, + properties: Some(properties), + json, + } + } + + /// Creates a new enum schema. + /// + /// # Arguments + /// + /// * `name` - The name of the schema. + /// * `schemas` - The schemas that make up the enum. + /// * `json` - The JSON representation of the schema. + /// + /// # Returns + /// + /// A new `Schema` instance with type `Enum`. + pub fn new_enum(name: String, schemas: &Vec, json: String) -> Self { + Self { + name, + type_name: TypeName::Enum, + properties: Some( + schemas + .iter() + .map(|s| Reference { + name: None, + schema_name: s.name.clone(), + }) + .collect(), + ), + json, + } + } + + /// Creates a new tuple schema. + /// + /// # Arguments + /// + /// * `name` - The name of the schema. + /// * `properties` - The properties of the tuple schema. + /// * `json` - The JSON representation of the schema. + /// + /// # Returns + /// + /// A new `Schema` instance with type `Tuple`. + pub fn new_tuple(name: String, properties: Vec, json: String) -> Self { + Self { + name, + type_name: TypeName::Tuple, + properties: Some(properties), + json, + } + } + + /// Creates a new list schema. + /// + /// # Arguments + /// + /// * `name` - The name of the schema. + /// * `reference` - The reference schema. + /// * `json` - The JSON representation of the schema. + /// + /// # Returns + /// + /// A new `Schema` instance with type `List`. + pub fn new_list(name: String, reference: Reference, json: String) -> Self { + Self { + name, + type_name: TypeName::List, + properties: Some(vec![reference]), + json, + } + } +} + +impl str::FromStr for TypeName { + type Err = (); + + /// Converts a string to a `TypeName`. + /// + /// # Arguments + /// + /// * `input` - The string representation of the type name. + /// + /// # Returns + /// + /// A `Result` containing the `TypeName` or an error. + fn from_str(input: &str) -> Result { + match input { + "AnyData" => Ok(TypeName::AnyData), + "Integer" => Ok(TypeName::Integer), + "Bytes" => Ok(TypeName::Bytes), + "Literal" => Ok(TypeName::Literal), + "Nullable" => Ok(TypeName::Nullable), + "Object" => Ok(TypeName::Object), + "Enum" => Ok(TypeName::Enum), + "Tuple" => Ok(TypeName::Tuple), + "List" => Ok(TypeName::List), + _ => Err(()), + } + } +} From e8d4749080d2419c06c488667b9b07289de4114f Mon Sep 17 00:00:00 2001 From: sofia-bobbiesi Date: Thu, 4 Dec 2025 13:35:20 -0300 Subject: [PATCH 03/32] Refactor blueprint and schema modules: consolidate structures and remove unused code --- crates/CIP-57/examples/plutus.json | 634 +++++++++++++++++++++++++++++ crates/CIP-57/src/blueprint.rs | 156 ------- crates/CIP-57/src/lib.rs | 620 ++++++++-------------------- crates/CIP-57/src/schema.rs | 267 ------------ 4 files changed, 793 insertions(+), 884 deletions(-) create mode 100644 crates/CIP-57/examples/plutus.json delete mode 100644 crates/CIP-57/src/blueprint.rs delete mode 100644 crates/CIP-57/src/schema.rs diff --git a/crates/CIP-57/examples/plutus.json b/crates/CIP-57/examples/plutus.json new file mode 100644 index 00000000..97472029 --- /dev/null +++ b/crates/CIP-57/examples/plutus.json @@ -0,0 +1,634 @@ +{ + "preamble": { + "title": "txpipe/contract", + "description": "Aiken contracts for project 'txpipe/contract'", + "version": "0.0.0", + "plutusVersion": "v3", + "compiler": { + "name": "Aiken", + "version": "v1.1.17+c3a7fba" + }, + "license": "Apache-2.0" + }, + "validators": [ + { + "title": "githoney_contract.badges_contract.spend", + "datum": { + "title": "_datum", + "schema": { + "$ref": "#/definitions/cardano~1transaction~1Datum" + } + }, + "redeemer": { + "title": "_redeemer", + "schema": { + "$ref": "#/definitions/cardano~1transaction~1Redeemer" + } + }, + "parameters": [ + { + "title": "settings_policy_id", + "schema": { + "$ref": "#/definitions/cardano~1assets~1PolicyId" + } + } + ], + "compiledCode": "eedaa957c60268de", + "hash": "24b9b1964ce02550db270a1d6270b505b9c0342625ee766d77fab1f9" + }, + { + "title": "githoney_contract.badges_contract.else", + "redeemer": { + "schema": {} + }, + "parameters": [ + { + "title": "settings_policy_id", + "schema": { + "$ref": "#/definitions/cardano~1assets~1PolicyId" + } + } + ], + "compiledCode": "3fe9763bc5ea0108", + "hash": "24b9b1964ce02550db270a1d6270b505b9c0342625ee766d77fab1f9" + }, + { + "title": "githoney_contract.badges_policy.mint", + "redeemer": { + "title": "_redeemer", + "schema": { + "$ref": "#/definitions/cardano~1transaction~1Redeemer" + } + }, + "parameters": [ + { + "title": "utxo_ref", + "schema": { + "$ref": "#/definitions/cardano~1transaction~1OutputReference" + } + }, + { + "title": "nonce", + "schema": { + "$ref": "#/definitions/Int" + } + } + ], + "compiledCode": "c8db1de3fbbc8921", + "hash": "87d6bd0b40f204d49803dad8e0d70611918d22354125c8f226a42670" + }, + { + "title": "githoney_contract.badges_policy.else", + "redeemer": { + "schema": {} + }, + "parameters": [ + { + "title": "utxo_ref", + "schema": { + "$ref": "#/definitions/cardano~1transaction~1OutputReference" + } + }, + { + "title": "nonce", + "schema": { + "$ref": "#/definitions/Int" + } + } + ], + "compiledCode": "f143b98f13d5b12b", + "hash": "87d6bd0b40f204d49803dad8e0d70611918d22354125c8f226a42670" + }, + { + "title": "githoney_contract.githoney.spend", + "datum": { + "title": "datum", + "schema": { + "$ref": "#/definitions/types~1GithoneyDatum" + } + }, + "redeemer": { + "title": "redeemer", + "schema": { + "$ref": "#/definitions/types~1GithoneyContractRedeemers" + } + }, + "parameters": [ + { + "title": "settings_policy_id", + "schema": { + "$ref": "#/definitions/cardano~1assets~1PolicyId" + } + } + ], + "compiledCode": "f1a9d1de401b5004", + "hash": "7577273f99e7f3c9211d6342338d6316afc91689333c4d5099e7b2d1" + }, + { + "title": "githoney_contract.githoney.mint", + "redeemer": { + "title": "_redeemer", + "schema": { + "$ref": "#/definitions/cardano~1transaction~1Redeemer" + } + }, + "parameters": [ + { + "title": "settings_policy_id", + "schema": { + "$ref": "#/definitions/cardano~1assets~1PolicyId" + } + } + ], + "compiledCode": "f1f665b7b5ec44d6", + "hash": "7577273f99e7f3c9211d6342338d6316afc91689333c4d5099e7b2d1" + }, + { + "title": "githoney_contract.githoney.else", + "redeemer": { + "schema": {} + }, + "parameters": [ + { + "title": "settings_policy_id", + "schema": { + "$ref": "#/definitions/cardano~1assets~1PolicyId" + } + } + ], + "compiledCode": "8dc98413cbd1b43f", + "hash": "7577273f99e7f3c9211d6342338d6316afc91689333c4d5099e7b2d1" + }, + { + "title": "githoney_contract.settings.spend", + "datum": { + "title": "datum", + "schema": { + "$ref": "#/definitions/types~1SettingsDatum" + } + }, + "redeemer": { + "title": "redeemer", + "schema": { + "$ref": "#/definitions/types~1SettingsRedeemers" + } + }, + "compiledCode": "5eb78784a02ee1a0", + "hash": "049f1a09fb535089fdd9df98b4b0975d1081f9afc2d6f59ad2f9c208" + }, + { + "title": "githoney_contract.settings.else", + "redeemer": { + "schema": {} + }, + "compiledCode": "9813b3c286674b27", + "hash": "049f1a09fb535089fdd9df98b4b0975d1081f9afc2d6f59ad2f9c208" + }, + { + "title": "githoney_contract.settings_minting.mint", + "redeemer": { + "title": "_redeemer", + "schema": { + "$ref": "#/definitions/cardano~1transaction~1Redeemer" + } + }, + "parameters": [ + { + "title": "utxo_ref", + "schema": { + "$ref": "#/definitions/cardano~1transaction~1OutputReference" + } + }, + { + "title": "settings_script_addr", + "schema": { + "$ref": "#/definitions/cardano~1address~1Address" + } + } + ], + "compiledCode": "6396e66bd8b6ac88", + "hash": "15721f473d73adf918aa7541871739c2e8df0a60abe023411cf9f1b0" + }, + { + "title": "githoney_contract.settings_minting.else", + "redeemer": { + "schema": {} + }, + "parameters": [ + { + "title": "utxo_ref", + "schema": { + "$ref": "#/definitions/cardano~1transaction~1OutputReference" + } + }, + { + "title": "settings_script_addr", + "schema": { + "$ref": "#/definitions/cardano~1address~1Address" + } + } + ], + "compiledCode": "fa2dba3b69a8d00c", + "hash": "15721f473d73adf918aa7541871739c2e8df0a60abe023411cf9f1b0" + } + ], + "definitions": { + "Bool": { + "title": "Bool", + "anyOf": [ + { + "title": "False", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "True", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + }, + "ByteArray": { + "title": "ByteArray", + "dataType": "bytes" + }, + "Data": { + "title": "Data", + "description": "Any Plutus data." + }, + "Int": { + "dataType": "integer" + }, + "Option$cardano/address/Address": { + "title": "Option", + "anyOf": [ + { + "title": "Some", + "description": "An optional value.", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "$ref": "#/definitions/cardano~1address~1Address" + } + ] + }, + { + "title": "None", + "description": "Nothing.", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + }, + "Option$cardano/address/StakeCredential": { + "title": "Option", + "anyOf": [ + { + "title": "Some", + "description": "An optional value.", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "$ref": "#/definitions/cardano~1address~1StakeCredential" + } + ] + }, + { + "title": "None", + "description": "Nothing.", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + }, + "Pairs$cardano/assets/AssetName_Int": { + "title": "Pairs", + "dataType": "map", + "keys": { + "$ref": "#/definitions/cardano~1assets~1AssetName" + }, + "values": { + "$ref": "#/definitions/Int" + } + }, + "Pairs$cardano/assets/PolicyId_Pairs$cardano/assets/AssetName_Int": { + "title": "Pairs>", + "dataType": "map", + "keys": { + "$ref": "#/definitions/cardano~1assets~1PolicyId" + }, + "values": { + "$ref": "#/definitions/Pairs$cardano~1assets~1AssetName_Int" + } + }, + "aiken/crypto/DataHash": { + "title": "DataHash", + "dataType": "bytes" + }, + "aiken/crypto/ScriptHash": { + "title": "ScriptHash", + "dataType": "bytes" + }, + "aiken/crypto/VerificationKeyHash": { + "title": "VerificationKeyHash", + "dataType": "bytes" + }, + "cardano/address/Address": { + "title": "Address", + "description": "A Cardano `Address` typically holding one or two credential references.\n\n Note that legacy bootstrap addresses (a.k.a. 'Byron addresses') are\n completely excluded from Plutus contexts. Thus, from an on-chain\n perspective only exists addresses of type 00, 01, ..., 07 as detailed\n in [CIP-0019 :: Shelley Addresses](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0019/#shelley-addresses).", + "anyOf": [ + { + "title": "Address", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "payment_credential", + "$ref": "#/definitions/cardano~1address~1PaymentCredential" + }, + { + "title": "stake_credential", + "$ref": "#/definitions/Option$cardano~1address~1StakeCredential" + } + ] + } + ] + }, + "cardano/address/Credential": { + "title": "Credential", + "description": "A general structure for representing an on-chain `Credential`.\n\n Credentials are always one of two kinds: a direct public/private key\n pair, or a script (native or Plutus).", + "anyOf": [ + { + "title": "VerificationKey", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + ] + }, + { + "title": "Script", + "dataType": "constructor", + "index": 1, + "fields": [ + { + "$ref": "#/definitions/aiken~1crypto~1ScriptHash" + } + ] + } + ] + }, + "cardano/address/PaymentCredential": { + "title": "PaymentCredential", + "description": "A general structure for representing an on-chain `Credential`.\n\n Credentials are always one of two kinds: a direct public/private key\n pair, or a script (native or Plutus).", + "anyOf": [ + { + "title": "VerificationKey", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + ] + }, + { + "title": "Script", + "dataType": "constructor", + "index": 1, + "fields": [ + { + "$ref": "#/definitions/aiken~1crypto~1ScriptHash" + } + ] + } + ] + }, + "cardano/address/StakeCredential": { + "title": "StakeCredential", + "description": "Represent a type of object that can be represented either inline (by hash)\n or via a reference (i.e. a pointer to an on-chain location).\n\n This is mainly use for capturing pointers to a stake credential\n registration certificate in the case of so-called pointer addresses.", + "anyOf": [ + { + "title": "Inline", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "$ref": "#/definitions/cardano~1address~1Credential" + } + ] + }, + { + "title": "Pointer", + "dataType": "constructor", + "index": 1, + "fields": [ + { + "title": "slot_number", + "$ref": "#/definitions/Int" + }, + { + "title": "transaction_index", + "$ref": "#/definitions/Int" + }, + { + "title": "certificate_index", + "$ref": "#/definitions/Int" + } + ] + } + ] + }, + "cardano/assets/AssetName": { + "title": "AssetName", + "dataType": "bytes" + }, + "cardano/assets/PolicyId": { + "title": "PolicyId", + "dataType": "bytes" + }, + "cardano/transaction/Datum": { + "title": "Datum", + "description": "An output `Datum`.", + "anyOf": [ + { + "title": "NoDatum", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "DatumHash", + "description": "A datum referenced by its hash digest.", + "dataType": "constructor", + "index": 1, + "fields": [ + { + "$ref": "#/definitions/aiken~1crypto~1DataHash" + } + ] + }, + { + "title": "InlineDatum", + "description": "A datum completely inlined in the output.", + "dataType": "constructor", + "index": 2, + "fields": [ + { + "$ref": "#/definitions/Data" + } + ] + } + ] + }, + "cardano/transaction/OutputReference": { + "title": "OutputReference", + "description": "An `OutputReference` is a unique reference to an output on-chain. The `output_index`\n corresponds to the position in the output list of the transaction (identified by its id)\n that produced that output", + "anyOf": [ + { + "title": "OutputReference", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "transaction_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "output_index", + "$ref": "#/definitions/Int" + } + ] + } + ] + }, + "cardano/transaction/Redeemer": { + "title": "Redeemer", + "description": "Any Plutus data." + }, + "types/GithoneyContractRedeemers": { + "title": "GithoneyContractRedeemers", + "anyOf": [ + { + "title": "AddRewards", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "Assign", + "dataType": "constructor", + "index": 1, + "fields": [] + }, + { + "title": "Merge", + "dataType": "constructor", + "index": 2, + "fields": [] + }, + { + "title": "Close", + "dataType": "constructor", + "index": 3, + "fields": [] + }, + { + "title": "Claim", + "dataType": "constructor", + "index": 4, + "fields": [] + } + ] + }, + "types/GithoneyDatum": { + "title": "GithoneyDatum", + "anyOf": [ + { + "title": "GithoneyDatum", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "admin_payment_credential", + "$ref": "#/definitions/cardano~1address~1Credential" + }, + { + "title": "maintainer_address", + "$ref": "#/definitions/cardano~1address~1Address" + }, + { + "title": "contributor_address", + "$ref": "#/definitions/Option$cardano~1address~1Address" + }, + { + "title": "bounty_reward_fee", + "$ref": "#/definitions/Int" + }, + { + "title": "deadline", + "$ref": "#/definitions/Int" + }, + { + "title": "merged", + "$ref": "#/definitions/Bool" + }, + { + "title": "initial_value", + "$ref": "#/definitions/Pairs$cardano~1assets~1PolicyId_Pairs$cardano~1assets~1AssetName_Int" + } + ] + } + ] + }, + "types/SettingsDatum": { + "title": "SettingsDatum", + "anyOf": [ + { + "title": "SettingsDatum", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "githoney_address", + "$ref": "#/definitions/cardano~1address~1Address" + }, + { + "title": "bounty_creation_fee", + "$ref": "#/definitions/Int" + }, + { + "title": "bounty_reward_fee", + "$ref": "#/definitions/Int" + } + ] + } + ] + }, + "types/SettingsRedeemers": { + "title": "SettingsRedeemers", + "anyOf": [ + { + "title": "UpdateSettings", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "CloseSettings", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + } + } +} diff --git a/crates/CIP-57/src/blueprint.rs b/crates/CIP-57/src/blueprint.rs deleted file mode 100644 index 09b4147c..00000000 --- a/crates/CIP-57/src/blueprint.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! This module defines the structures for a Blueprint, including its preamble, validators, and definitions. - -use serde::{Deserialize, Serialize}; -use serde_json::Number; -use std::collections::BTreeMap; - -/// Represents a blueprint containing preamble, validators, and optional definitions. -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct Blueprint { - pub preamble: Preamble, - pub validators: Vec, - pub definitions: Option, -} - -/// Represents the preamble of a blueprint, including metadata such as title, description, version, and compiler information. -#[derive(Debug, Deserialize, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct Preamble { - pub title: String, - pub description: Option, - pub version: String, - pub plutus_version: String, - pub compiler: Option, - pub license: Option, -} - -/// Represents the compiler information in the preamble. -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct Compiler { - pub name: String, - pub version: Option, -} - -/// Represents a validator in the blueprint, including its title, description, compiled code, hash, datum, redeemer, and parameters. -#[derive(Debug, Deserialize, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct Validator { - pub title: String, - pub description: Option, - pub compiled_code: Option, - pub hash: Option, - pub datum: Option, - pub redeemer: Option, - pub parameters: Option>, -} - -/// Represents an argument in a validator, including its title, description, purpose, and schema reference. -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct Argument { - pub title: Option, - pub description: Option, - pub purpose: Option, - pub schema: Reference, -} - -/// Represents a purpose array which can be either a single purpose or an array of purposes. -#[derive(Debug, Deserialize, Serialize, Clone)] -#[serde(untagged)] -pub enum PurposeArray { - Single(Purpose), - Array(Vec), -} - -/// Represents the purpose of an argument, which can be spend, mint, withdraw, or publish. -#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq)] -#[serde(rename_all = "camelCase")] -pub enum Purpose { - Spend, - Mint, - Withdraw, - Publish, -} - -/// Represents a reference to a schema. -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct Reference { - #[serde(rename = "$ref")] - pub reference: Option, -} - -/// Represents a parameter in a validator, including its title, description, purpose, and schema reference. -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct Parameter { - pub title: Option, - pub description: Option, - pub purpose: Option, - pub schema: Reference, -} - -/// Represents the definitions in a blueprint, which is a map of definition names to their corresponding definitions. -#[derive(Debug, Default, Deserialize, Serialize, Clone)] -pub struct Definitions { - #[serde(flatten, default)] - pub inner: BTreeMap, -} - -/// Represents a definition in the blueprint, including its title, description, data type, any_of schemas, items, keys, and values. -#[derive(Debug, Deserialize, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct Definition { - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub data_type: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub any_of: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub items: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub keys: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub values: Option, -} - -/// Represents an array of references which can be either a single reference or an array of references. -#[derive(Debug, Deserialize, Serialize, Clone)] -#[serde(untagged)] -pub enum ReferencesArray { - Single(Reference), - Array(Vec), -} - -/// Represents a schema in a definition, including its title, description, data type, index, and fields. -#[derive(Debug, Deserialize, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct Schema { - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - pub data_type: DataType, - pub index: Number, - pub fields: Vec, -} - -/// Represents the data type of a schema, which can be integer, bytes, list, map, or constructor. -#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq)] -#[serde(rename_all = "camelCase")] -pub enum DataType { - Integer, - Bytes, - List, - Map, - Constructor, -} - -/// Represents a field in a schema, including its title and reference. -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct Field { - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, - #[serde(rename = "$ref")] - pub reference: String, -} diff --git a/crates/CIP-57/src/lib.rs b/crates/CIP-57/src/lib.rs index 1fe7651a..0e368fab 100644 --- a/crates/CIP-57/src/lib.rs +++ b/crates/CIP-57/src/lib.rs @@ -1,482 +1,180 @@ -//! This module provides functions to work with blueprints and schemas, including -//! parsing JSON, reading from files, and JSON-to-JSON conversion helpers. +//! This module defines the structures for a Blueprint, including its preamble, validators, and definitions. -use anyhow::Result; -use schema::TypeName; -use serde_json; +use serde::{Deserialize, Serialize}; +use serde_json::Number; +use std::collections::BTreeMap; use std::fs; -use tx3_lang::ir::{Expression, StructExpr}; +use std::path::PathBuf; + +/// Represents a blueprint containing preamble, validators, and optional definitions. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Blueprint { + pub preamble: Preamble, + pub validators: Vec, + pub definitions: Option, +} -pub mod blueprint; -pub mod schema; -// Templates are intentionally removed for a pure JSON-to-JSON crate. +/// Represents the preamble of a blueprint, including metadata such as title, description, version, and compiler information. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Preamble { + pub title: String, + pub description: Option, + pub version: String, + pub plutus_version: String, + pub compiler: Option, + pub license: Option, +} -pub struct Codegen {} +/// Represents the compiler information in the preamble. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Compiler { + pub name: String, + pub version: Option, +} -impl Codegen { - pub fn new() -> Codegen { - Codegen {} - } +/// Represents a validator in the blueprint, including its title, description, compiled code, hash, datum, redeemer, and parameters. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Validator { + pub title: String, + pub description: Option, + pub compiled_code: Option, + pub hash: Option, + pub datum: Option, + pub redeemer: Option, + pub parameters: Option>, +} - fn get_schema_name(&self, key: String) -> String { - // Keep a simple normalization without external deps (heck) - let normalized = key - .replace("#/definitions/", "") - .replace("~1", " ") - .replace("/", " ") - .replace("_", " ") - .replace("$", " "); - // Basic PascalCase conversion - normalized - .split_whitespace() - .map(|w| { - let mut chars = w.chars(); - match chars.next() { - Some(first) => first.to_uppercase().collect::() + chars.as_str(), - None => String::new(), - } - }) - .collect::>() - .join("") - } +/// Represents an argument in a validator, including its title, description, purpose, and schema reference. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Argument { + pub title: Option, + pub description: Option, + pub purpose: Option, + pub schema: Reference, +} - fn parse_bytes_string(s: &str) -> Vec { - if let Some(hex) = s.strip_prefix("0x") { - return hex::decode(hex).unwrap_or_default(); - } - s.as_bytes().to_vec() - } +/// Represents a purpose array which can be either a single purpose or an array of purposes. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(untagged)] +pub enum PurposeArray { + Single(Purpose), + Array(Vec), +} - /// Parses a JSON string into a `Blueprint`. - /// - /// # Arguments - /// - /// * `json` - The JSON data from a `plutus.json` file - /// - /// # Returns - /// - /// A `Blueprint` instance or an error. - pub fn get_blueprint_from_json(&self, json: String) -> Result { - let bp = serde_json::from_str(&json)?; - Ok(bp) - } +/// Represents the purpose of an argument, which can be spend, mint, withdraw, or publish. +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum Purpose { + Spend, + Mint, + Withdraw, + Publish, +} - /// Reads a JSON file from a specified path and parses it into a `Blueprint`. - /// - /// # Arguments - /// - /// * `path` - The `plutus.json` file path in the filesystem - /// - /// # Returns - /// - /// A `Blueprint` instance or an error. - pub fn get_blueprint_from_path(&self, path: String) -> Result { - let json = fs::read_to_string(path)?; - self.get_blueprint_from_json(json) - } +/// Represents a reference to a schema. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Reference { + #[serde(rename = "$ref")] + pub reference: Option, +} - /// Obtains the list of schemas from a given `Blueprint`. - /// - /// # Arguments - /// - /// * `blueprint` - A `Blueprint` from which to obtain the schemas. - /// - /// # Returns - /// - /// A vector of `Schema` from the blueprint. - pub fn get_schemas_from_blueprint( - &self, - blueprint: blueprint::Blueprint, - ) -> Vec { - let mut schemas: Vec = vec![]; - if blueprint.definitions.is_some() { - for definition in blueprint.definitions.unwrap().inner.iter() { - let definition_name = self.get_schema_name(definition.0.clone()); - let definition_json = serde_json::to_string(&definition.1).unwrap(); - if definition.1.data_type.is_some() { - match definition.1.data_type.unwrap() { - blueprint::DataType::Integer => { - schemas.push(schema::Schema::new_integer( - definition_name.clone(), - definition_json.clone(), - )); - } - blueprint::DataType::Bytes => { - schemas.push(schema::Schema::new_bytes( - definition_name.clone(), - definition_json.clone(), - )); - } - blueprint::DataType::List => { - if definition.1.items.is_some() { - match definition.1.items.as_ref().unwrap() { - blueprint::ReferencesArray::Single(reference) => { - if reference.reference.is_some() { - schemas.push(schema::Schema::new_list( - definition_name.clone(), - schema::Reference { - name: None, - schema_name: self.get_schema_name( - reference - .reference - .as_ref() - .unwrap() - .clone(), - ), - }, - definition_json.clone(), - )); - } - } - blueprint::ReferencesArray::Array(references) => { - let mut properties: Vec = vec![]; - for reference in references { - if reference.reference.is_some() { - properties.push(schema::Reference { - name: None, - schema_name: self.get_schema_name( - reference - .reference - .as_ref() - .unwrap() - .clone(), - ), - }); - } - } - schemas.push(schema::Schema::new_tuple( - definition_name.clone(), - properties, - definition_json.clone(), - )); - } - } - } - } - _ => {} - } - } - if definition.1.title.is_some() { - if definition.1.title.as_ref().unwrap() == "Data" && definition_name == "Data" { - schemas.push(schema::Schema::new_anydata(definition_json.clone())); - } - } - if definition.1.any_of.is_some() { - let mut internal_schemas: Vec = vec![]; - for (index, parameter) in - definition.1.any_of.as_ref().unwrap().iter().enumerate() - { - match parameter.data_type { - blueprint::DataType::Constructor => { - let schema_name = format!( - "{}{}", - definition_name, - parameter.title.clone().unwrap_or((index + 1).to_string()) - ); - let mut properties: Vec = vec![]; - for property in ¶meter.fields { - let mut schema_name = - self.get_schema_name(property.reference.clone()); - if schema_name == "Data" { - schema_name = "AnyData".to_string(); - } - properties.push(schema::Reference { - name: property.title.clone(), - schema_name, - }); - } - let schema: schema::Schema; - if properties.len().gt(&0) || parameter.title.is_none() { - if properties.iter().any(|p| p.name.is_none()) { - schema = schema::Schema::new_tuple( - schema_name, - properties, - definition_json.clone(), - ); - } else { - schema = schema::Schema::new_object( - schema_name, - properties, - definition_json.clone(), - ); - } - } else { - schema = schema::Schema::new_literal( - schema_name, - parameter.title.clone().unwrap(), - definition_json.clone(), - ); - } - internal_schemas.push(schema); - } - _ => {} - } - } - if internal_schemas.len().eq(&1) { - let mut schema = internal_schemas.first().unwrap().clone(); - schema.name = definition_name.clone(); - schemas.push(schema); - } - if internal_schemas.len().gt(&1) { - if internal_schemas.len().eq(&2) - && internal_schemas - .iter() - .any(|s| s.type_name.eq(&TypeName::Literal)) - && !internal_schemas - .iter() - .all(|s| s.type_name.eq(&TypeName::Literal)) - { - let reference = internal_schemas - .iter() - .find(|s| s.type_name.ne(&TypeName::Literal)); - schemas.push(reference.unwrap().clone()); - schemas.push(schema::Schema::new_nullable( - definition_name.clone(), - reference.unwrap().name.clone(), - definition_json.clone(), - )); - } else { - for schema in &internal_schemas { - schemas.push(schema.clone()); - } - schemas.push(schema::Schema::new_enum( - definition_name.clone(), - &internal_schemas, - definition_json.clone(), - )); - } - } - } - } - } +/// Represents a parameter in a validator, including its title, description, purpose, and schema reference. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Parameter { + pub title: Option, + pub description: Option, + pub purpose: Option, + pub schema: Reference, +} - schemas - } +/// Represents the definitions in a blueprint, which is a map of definition names to their corresponding definitions. +#[derive(Debug, Default, Deserialize, Serialize, Clone)] +pub struct Definitions { + #[serde(flatten, default)] + pub inner: BTreeMap, +} - /// Obtains the list of validators from a given `Blueprint`. - /// - /// # Arguments - /// - /// * `blueprint` - A `Blueprint` from which to obtain the validators. - /// - /// # Returns - /// - /// A vector of `Validator` from the blueprint. - pub fn get_validators_from_blueprint( - &self, - blueprint: blueprint::Blueprint, - ) -> Vec { - let mut validators: Vec = vec![]; - for validator in blueprint.validators.iter() { - let mut datum: Option = None; - if validator.datum.is_some() - && validator.datum.as_ref().unwrap().schema.reference.is_some() - { - datum = Some(schema::Reference { - name: validator.datum.as_ref().unwrap().title.clone(), - schema_name: self.get_schema_name( - validator - .datum - .as_ref() - .unwrap() - .schema - .reference - .as_ref() - .unwrap() - .clone(), - ), - }); - } - let mut redeemer: Option = None; - if validator.redeemer.is_some() - && validator - .redeemer - .as_ref() - .unwrap() - .schema - .reference - .is_some() - { - redeemer = Some(schema::Reference { - name: validator.redeemer.as_ref().unwrap().title.clone(), - schema_name: self.get_schema_name( - validator - .redeemer - .as_ref() - .unwrap() - .schema - .reference - .as_ref() - .unwrap() - .clone(), - ), - }); - } - let mut parameters: Vec = vec![]; - if let Some(p) = &validator.parameters { - for parameter in p { - if parameter.schema.reference.is_some() { - parameters.push(schema::Reference { - name: parameter.title.clone(), - schema_name: self.get_schema_name( - parameter.schema.reference.as_ref().unwrap().clone(), - ), - }) - } - } - } - validators.push(schema::Validator { - name: validator.title.clone(), - datum: datum, - redeemer: redeemer, - parameters: parameters, - }); - } - validators - } +/// Represents a definition in the blueprint, including its title, description, data type, any_of schemas, items, keys, and values. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Definition { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub data_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub any_of: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub keys: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub values: Option, +} + +/// Represents an array of references which can be either a single reference or an array of references. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(untagged)] +pub enum ReferencesArray { + Single(Reference), + Array(Vec), +} + +/// Represents a schema in a definition, including its title, description, data type, index, and fields. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Schema { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub data_type: DataType, + pub index: Number, + pub fields: Vec, +} - /// Converts a `Blueprint` into a JSON value containing extracted schemas and validators. +/// Represents the data type of a schema, which can be integer, bytes, list, map, or constructor. +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum DataType { + Integer, + Bytes, + List, + Map, + Constructor, +} +/// Represents a field in a schema, including its title and reference. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Field { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(rename = "$ref")] + pub reference: String, +} - /// Convert a JSON value according to a schema name into a tx3 IR Expression. - /// This expects `value` to match the referenced schema (by name) present in the blueprint definitions. - pub fn convert_value_by_schema_name( - &self, - blueprint: &blueprint::Blueprint, - schema_name: &str, - value: &serde_json::Value, - ) -> Result { - // Build a quick lookup of definitions by normalized schema name - let mut defs = std::collections::BTreeMap::new(); - if let Some(all) = &blueprint.definitions { - for (raw_name, def) in &all.inner { - let name = self.get_schema_name(raw_name.clone()); - defs.insert(name, def); - } - } +#[cfg(test)] +mod tests { + use super::*; - let def = defs - .get(schema_name) - .ok_or_else(|| anyhow::anyhow!("unknown schema: {}", schema_name))?; + #[test] + fn deserialize_plutus_json_into_blueprint() { + let manifest = env!("CARGO_MANIFEST_DIR"); + let path = PathBuf::from(manifest).join("examples").join("plutus.json"); - // Primitive types - if let Some(dt) = def.data_type { - match dt { - blueprint::DataType::Integer => { - let num = match value { - serde_json::Value::Number(n) => n.as_i64().unwrap_or(0) as i128, - serde_json::Value::String(s) => s.parse::().unwrap_or(0), - _ => 0, - }; - return Ok(Expression::Number(num)); - } - blueprint::DataType::Bytes => { - let bytes = match value { - serde_json::Value::String(s) => Self::parse_bytes_string(s), - serde_json::Value::Array(arr) => arr - .iter() - .filter_map(|v| v.as_u64().map(|x| x as u8)) - .collect(), - _ => Vec::new(), - }; - return Ok(Expression::Bytes(bytes)); - } - blueprint::DataType::List => { - // For lists, expect a single reference in items (or tuple in Array case already handled elsewhere) - if let Some(items) = &def.items { - match items { - blueprint::ReferencesArray::Single(r) => { - let inner_name = r - .reference - .as_ref() - .map(|s| self.get_schema_name(s.clone())) - .ok_or_else(|| anyhow::anyhow!("list items missing $ref"))?; - let list = match value { - serde_json::Value::Array(arr) => { - let mut out = Vec::new(); - for v in arr { - out.push(self.convert_value_by_schema_name( - blueprint, - &inner_name, - v, - )?); - } - out - } - _ => Vec::new(), - }; - return Ok(Expression::List(list)); - } - blueprint::ReferencesArray::Array(_) => { - // Treat as tuple - let arr = value.as_array().cloned().unwrap_or_default(); - let mut fields = Vec::new(); - if let blueprint::ReferencesArray::Array(refs) = items { - for (i, r) in refs.iter().enumerate() { - let inner_name = r - .reference - .as_ref() - .map(|s| self.get_schema_name(s.clone())) - .ok_or_else(|| { - anyhow::anyhow!("tuple item missing $ref") - })?; - let v = - arr.get(i).cloned().unwrap_or(serde_json::Value::Null); - fields.push(self.convert_value_by_schema_name( - blueprint, - &inner_name, - &v, - )?); - } - } - return Ok(Expression::Struct(StructExpr { - constructor: 0, - fields, - })); - } - } - } - } - _ => {} - } - } + let json = fs::read_to_string(&path) + .expect(&format!("failed to read example file: {}", path.display())); - // Constructors (anyOf with data_type = Constructor). We choose variant by matching expected shape or `index`. - if let Some(any_of) = &def.any_of { - // Strategy: if `value` is an object with fields, pick matching constructor by field count; else use first. - let chosen = any_of - .first() - .ok_or_else(|| anyhow::anyhow!("empty anyOf"))?; - let index = chosen.index.as_i64().unwrap_or(0) as usize; - let mut fields_expr = Vec::new(); - for f in &chosen.fields { - let ref_name = self.get_schema_name(f.reference.clone()); - // Try to fetch field by title from value object; fallback to Null - let field_json = match value { - serde_json::Value::Object(map) => { - if let Some(title) = &f.title { - map.get(title).cloned().unwrap_or(serde_json::Value::Null) - } else { - serde_json::Value::Null - } - } - _ => serde_json::Value::Null, - }; - fields_expr.push(self.convert_value_by_schema_name( - blueprint, - &ref_name, - &field_json, - )?); - } - return Ok(Expression::Struct(StructExpr { - constructor: index, - fields: fields_expr, - })); - } + let bp: Blueprint = serde_json::from_str(&json).expect("failed to deserialize blueprint"); - // Fallback - Ok(Expression::None) + assert!( + !bp.preamble.title.is_empty(), + "preamble.title should not be empty" + ); + assert!(!bp.validators.is_empty(), "expected at least one validator"); } - -// (no free functions) } diff --git a/crates/CIP-57/src/schema.rs b/crates/CIP-57/src/schema.rs deleted file mode 100644 index 8680e523..00000000 --- a/crates/CIP-57/src/schema.rs +++ /dev/null @@ -1,267 +0,0 @@ -//! This module defines the structures and implementations for schemas, including types, references, and validators. - -use serde::{Deserialize, Serialize}; -use std::str; - -/// Represents the different types a schema can have. -#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq)] -#[serde(rename_all = "camelCase")] -pub enum TypeName { - AnyData, - Integer, - Bytes, - Literal, - Nullable, - Object, - Enum, - Tuple, - List, -} - -/// Represents a schema with a name, type, optional properties, and JSON representation. -#[derive(Debug, Deserialize, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct Schema { - pub name: String, - pub type_name: TypeName, - pub properties: Option>, - pub json: String, -} - -/// Represents a reference to another schema, including an optional name and schema name. -#[derive(Debug, Deserialize, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct Reference { - pub name: Option, - pub schema_name: String, -} - -/// Represents a validator with a name, optional datum and redeemer, and parameters. -#[derive(Debug, Deserialize, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct Validator { - pub name: String, - pub datum: Option, - pub redeemer: Option, - pub parameters: Vec, -} - -impl Schema { - /// Creates a new any data schema. - /// - /// # Arguments - /// - /// * `json` - The JSON representation of the schema. - /// - /// # Returns - /// - /// A new `Schema` instance with type `AnyData`. - pub fn new_anydata(json: String) -> Self { - Self { - name: "AnyData".to_string(), - type_name: TypeName::AnyData, - properties: None, - json, - } - } - - /// Creates a new integer schema. - /// - /// # Arguments - /// - /// * `name` - The name of the schema. - /// * `json` - The JSON representation of the schema. - /// - /// # Returns - /// - /// A new `Schema` instance with type `Integer`. - pub fn new_integer(name: String, json: String) -> Self { - Self { - name, - type_name: TypeName::Integer, - properties: None, - json, - } - } - - /// Creates a new bytes schema. - /// - /// # Arguments - /// - /// * `name` - The name of the schema. - /// * `json` - The JSON representation of the schema. - /// - /// # Returns - /// - /// A new `Schema` instance with type `Bytes`. - pub fn new_bytes(name: String, json: String) -> Self { - Self { - name, - type_name: TypeName::Bytes, - properties: None, - json, - } - } - - /// Creates a new literal schema. - /// - /// # Arguments - /// - /// * `name` - The name of the schema. - /// * `value` - The literal value of the schema. - /// * `json` - The JSON representation of the schema. - /// - /// # Returns - /// - /// A new `Schema` instance with type `Literal`. - pub fn new_literal(name: String, value: String, json: String) -> Self { - Self { - name, - type_name: TypeName::Literal, - properties: Some(vec![Reference { - name: None, - schema_name: value, - }]), - json, - } - } - - /// Creates a new nullable schema. - /// - /// # Arguments - /// - /// * `name` - The name of the schema. - /// * `reference` - The reference schema name. - /// * `json` - The JSON representation of the schema. - /// - /// # Returns - /// - /// A new `Schema` instance with type `Nullable`. - pub fn new_nullable(name: String, reference: String, json: String) -> Self { - Self { - name, - type_name: TypeName::Nullable, - properties: Some(vec![Reference { - name: None, - schema_name: reference, - }]), - json, - } - } - - /// Creates a new object schema. - /// - /// # Arguments - /// - /// * `name` - The name of the schema. - /// * `properties` - The properties of the object schema. - /// * `json` - The JSON representation of the schema. - /// - /// # Returns - /// - /// A new `Schema` instance with type `Object`. - pub fn new_object(name: String, properties: Vec, json: String) -> Self { - Self { - name, - type_name: TypeName::Object, - properties: Some(properties), - json, - } - } - - /// Creates a new enum schema. - /// - /// # Arguments - /// - /// * `name` - The name of the schema. - /// * `schemas` - The schemas that make up the enum. - /// * `json` - The JSON representation of the schema. - /// - /// # Returns - /// - /// A new `Schema` instance with type `Enum`. - pub fn new_enum(name: String, schemas: &Vec, json: String) -> Self { - Self { - name, - type_name: TypeName::Enum, - properties: Some( - schemas - .iter() - .map(|s| Reference { - name: None, - schema_name: s.name.clone(), - }) - .collect(), - ), - json, - } - } - - /// Creates a new tuple schema. - /// - /// # Arguments - /// - /// * `name` - The name of the schema. - /// * `properties` - The properties of the tuple schema. - /// * `json` - The JSON representation of the schema. - /// - /// # Returns - /// - /// A new `Schema` instance with type `Tuple`. - pub fn new_tuple(name: String, properties: Vec, json: String) -> Self { - Self { - name, - type_name: TypeName::Tuple, - properties: Some(properties), - json, - } - } - - /// Creates a new list schema. - /// - /// # Arguments - /// - /// * `name` - The name of the schema. - /// * `reference` - The reference schema. - /// * `json` - The JSON representation of the schema. - /// - /// # Returns - /// - /// A new `Schema` instance with type `List`. - pub fn new_list(name: String, reference: Reference, json: String) -> Self { - Self { - name, - type_name: TypeName::List, - properties: Some(vec![reference]), - json, - } - } -} - -impl str::FromStr for TypeName { - type Err = (); - - /// Converts a string to a `TypeName`. - /// - /// # Arguments - /// - /// * `input` - The string representation of the type name. - /// - /// # Returns - /// - /// A `Result` containing the `TypeName` or an error. - fn from_str(input: &str) -> Result { - match input { - "AnyData" => Ok(TypeName::AnyData), - "Integer" => Ok(TypeName::Integer), - "Bytes" => Ok(TypeName::Bytes), - "Literal" => Ok(TypeName::Literal), - "Nullable" => Ok(TypeName::Nullable), - "Object" => Ok(TypeName::Object), - "Enum" => Ok(TypeName::Enum), - "Tuple" => Ok(TypeName::Tuple), - "List" => Ok(TypeName::List), - _ => Err(()), - } - } -} From cec9b3149bcb4d1feddda46f959d0b3fc7ce6734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Thu, 4 Dec 2025 14:10:34 -0300 Subject: [PATCH 04/32] chore: renaming, removing unnecessary deps & fixing warnings --- Cargo.lock | 3 +-- Cargo.toml | 8 ++++---- crates/{CIP-57 => cip-57}/Cargo.toml | 3 +-- crates/{CIP-57 => cip-57}/examples/plutus.json | 0 crates/{CIP-57 => cip-57}/src/lib.rs | 9 ++++----- 5 files changed, 10 insertions(+), 13 deletions(-) rename crates/{CIP-57 => cip-57}/Cargo.toml (93%) rename crates/{CIP-57 => cip-57}/examples/plutus.json (100%) rename crates/{CIP-57 => cip-57}/src/lib.rs (96%) diff --git a/Cargo.lock b/Cargo.lock index 3a334efe..d7c9129a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,10 +314,9 @@ dependencies = [ ] [[package]] -name = "cip57" +name = "cip-57" version = "0.13.0" dependencies = [ - "anyhow", "serde", "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 6cecbe46..2e313c3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [workspace] resolver = "2" members = [ - "crates/tx3-cardano", - "crates/tx3-lang", - "crates/tx3-resolver", - "crates/CIP-57", + "crates/tx3-cardano", + "crates/tx3-lang", + "crates/tx3-resolver", + "crates/cip-57", ] [workspace.package] diff --git a/crates/CIP-57/Cargo.toml b/crates/cip-57/Cargo.toml similarity index 93% rename from crates/CIP-57/Cargo.toml rename to crates/cip-57/Cargo.toml index e7345fd9..d70aa572 100644 --- a/crates/CIP-57/Cargo.toml +++ b/crates/cip-57/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "cip57" +name = "cip-57" description = "CIP-57 compatibility (JSON parsing and serialization)" publish.workspace = true authors.workspace = true @@ -16,4 +16,3 @@ readme.workspace = true [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -anyhow = "1" diff --git a/crates/CIP-57/examples/plutus.json b/crates/cip-57/examples/plutus.json similarity index 100% rename from crates/CIP-57/examples/plutus.json rename to crates/cip-57/examples/plutus.json diff --git a/crates/CIP-57/src/lib.rs b/crates/cip-57/src/lib.rs similarity index 96% rename from crates/CIP-57/src/lib.rs rename to crates/cip-57/src/lib.rs index 0e368fab..b1a4d97d 100644 --- a/crates/CIP-57/src/lib.rs +++ b/crates/cip-57/src/lib.rs @@ -3,9 +3,6 @@ use serde::{Deserialize, Serialize}; use serde_json::Number; use std::collections::BTreeMap; -use std::fs; -use std::path::PathBuf; - /// Represents a blueprint containing preamble, validators, and optional definitions. #[derive(Debug, Deserialize, Serialize, Clone)] pub struct Blueprint { @@ -160,14 +157,16 @@ pub struct Field { #[cfg(test)] mod tests { use super::*; + use std::fs; + use std::path::PathBuf; #[test] fn deserialize_plutus_json_into_blueprint() { let manifest = env!("CARGO_MANIFEST_DIR"); let path = PathBuf::from(manifest).join("examples").join("plutus.json"); - let json = fs::read_to_string(&path) - .expect(&format!("failed to read example file: {}", path.display())); + let failure_msg = format!("failed to read example file: {}", path.display()); + let json = fs::read_to_string(&path).expect(&failure_msg); let bp: Blueprint = serde_json::from_str(&json).expect("failed to deserialize blueprint"); From 015a8fcdcdea67c96396070d9d300fe7ae1802fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 5 Dec 2025 18:22:16 -0300 Subject: [PATCH 05/32] fix: cip57 purpose is oneOf object instead of array --- crates/cip-57/src/lib.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/cip-57/src/lib.rs b/crates/cip-57/src/lib.rs index b1a4d97d..d46fc5f8 100644 --- a/crates/cip-57/src/lib.rs +++ b/crates/cip-57/src/lib.rs @@ -52,12 +52,19 @@ pub struct Argument { pub schema: Reference, } -/// Represents a purpose array which can be either a single purpose or an array of purposes. +/// Represents a purpose which can be either a single purpose or an object with oneOf. #[derive(Debug, Deserialize, Serialize, Clone)] #[serde(untagged)] pub enum PurposeArray { Single(Purpose), - Array(Vec), + OneOf(PurposeOneOf), +} + +/// Represents a purpose object with a oneOf field containing an array of purposes. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PurposeOneOf { + pub one_of: Vec, } /// Represents the purpose of an argument, which can be spend, mint, withdraw, or publish. From dd9c4274774db5cca3dfc23fa7419a925cd4dfaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 5 Dec 2025 18:23:10 -0300 Subject: [PATCH 06/32] feat: mapping cip57 files into scope --- Cargo.lock | 1 + crates/tx3-lang/Cargo.toml | 5 +- crates/tx3-lang/src/analyzing.rs | 2 +- crates/tx3-lang/src/ast.rs | 7 ++ crates/tx3-lang/src/cardano.rs | 197 ++++++++++++++++++++++--------- crates/tx3-lang/src/parsing.rs | 14 ++- crates/tx3-lang/src/tx3.pest | 8 +- 7 files changed, 169 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d7c9129a..61c4ccf9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2162,6 +2162,7 @@ version = "0.13.0" dependencies = [ "assert-json-diff", "ciborium", + "cip-57", "hex", "miette", "paste", diff --git a/crates/tx3-lang/Cargo.toml b/crates/tx3-lang/Cargo.toml index 6e8735aa..60b8a95c 100644 --- a/crates/tx3-lang/Cargo.toml +++ b/crates/tx3-lang/Cargo.toml @@ -16,15 +16,16 @@ readme.workspace = true thiserror = { workspace = true } trait-variant = { workspace = true } hex = { workspace = true } - +cip-57 = { version = "0.13.0", path = "../cip-57" } +serde_json = "1.0.137" miette = { version = "7.4.0", features = ["fancy"] } pest = { version = "2.7.15", features = ["miette-error", "pretty-print"] } pest_derive = "2.7.15" serde = { version = "1.0.217", features = ["derive"] } ciborium = "0.2.2" + [dev-dependencies] assert-json-diff = "2.0.2" paste = "1.0.15" proptest = "1.7.0" -serde_json = "1.0.137" diff --git a/crates/tx3-lang/src/analyzing.rs b/crates/tx3-lang/src/analyzing.rs index 120d6366..2b1c1fec 100644 --- a/crates/tx3-lang/src/analyzing.rs +++ b/crates/tx3-lang/src/analyzing.rs @@ -1439,7 +1439,7 @@ impl Analyzable for Program { /// # Returns /// * `AnalyzeReport` of the analysis. Empty if no errors are found. pub fn analyze(ast: &mut Program) -> AnalyzeReport { - ast.analyze(None) + ast.analyze(ast.scope.clone()) } #[cfg(test)] diff --git a/crates/tx3-lang/src/ast.rs b/crates/tx3-lang/src/ast.rs index 1c89006a..fc11918d 100644 --- a/crates/tx3-lang/src/ast.rs +++ b/crates/tx3-lang/src/ast.rs @@ -907,6 +907,13 @@ pub struct AliasDef { } impl AliasDef { + pub fn new(name: &str, target: Type) -> Self { + Self { + name: Identifier::new(name), + alias_type: target, + span: Span::DUMMY, + } + } pub fn resolve_alias_chain(&self) -> Option<&TypeDef> { match &self.alias_type { Type::Custom(identifier) => match &identifier.symbol { diff --git a/crates/tx3-lang/src/cardano.rs b/crates/tx3-lang/src/cardano.rs index 6954c7a9..6251a53d 100644 --- a/crates/tx3-lang/src/cardano.rs +++ b/crates/tx3-lang/src/cardano.rs @@ -1,11 +1,11 @@ -use std::{collections::HashMap, rc::Rc}; +use std::{collections::HashMap, fs, rc::Rc}; use pest::iterators::Pair; use serde::{Deserialize, Serialize}; use crate::{ analyzing::{Analyzable, AnalyzeReport}, - ast::{DataExpr, Identifier, Scope, Span, Type}, + ast::{DataExpr, Identifier, RecordField, Scope, Span, Symbol, Type, TypeDef, VariantCase}, ir, lowering::IntoLower, parsing::{AstNode, Error, Rule}, @@ -841,65 +841,154 @@ impl IntoLower for CardanoBlock { } } -pub fn load_externals(path: &str) -> Vec { - use crate::ast::{Identifier, RecordField, Span, Type, TypeDef, VariantCase}; +/// Sanitizes a type name to be a valid tx3 identifier. +/// Replaces characters like `<`, `>`, `,`, `/`, `$`, `~1` with underscores. +fn sanitize_type_name(name: &str) -> String { + name.replace("~1", "_") // URL-encoded `/` in JSON references + .replace('/', "_") + .replace('$', "_") + .replace('<', "_") + .replace('>', "") + .replace(',', "_") + .replace(' ', "") +} - vec![TypeDef { - name: Identifier { - value: "PlutusData".to_string(), - span: Span::DUMMY, - symbol: None, - }, - cases: vec![ - VariantCase { - name: Identifier::new("Constr"), - fields: vec![ - RecordField::new("constructor", Type::Int), - RecordField::new( - "fields", - Type::List(Box::new(Type::Custom(Identifier::new("PlutusData")))), - ), - ], - span: Span::DUMMY, - }, - VariantCase { - name: Identifier::new("Map"), - fields: vec![RecordField::new( - "entries", - Type::List(Box::new(Type::Custom(Identifier::new("PlutusData")))), - )], - span: Span::DUMMY, - }, - VariantCase { - name: Identifier::new("List"), - fields: vec![RecordField::new( - "items", - Type::List(Box::new(Type::Custom(Identifier::new("PlutusData")))), - )], - span: Span::DUMMY, - }, - VariantCase { - name: Identifier::new("Integer"), - fields: vec![RecordField::new("value", Type::Int)], - span: Span::DUMMY, - }, - VariantCase { - name: Identifier::new("Bytes"), - fields: vec![RecordField::new("value", Type::Bytes)], - span: Span::DUMMY, +// TODO: add policies, and parameters as well +pub fn load_externals( + path: &str, +) -> Result, crate::parsing::Error> { + // TODO: add error handling + let json = fs::read_to_string(path).unwrap(); + let bp = serde_json::from_str::(&json).unwrap(); + + let ref_to_type = |r: &str| -> Type { + let sanitized = sanitize_type_name(r.strip_prefix("#/definitions/").unwrap_or(r)); + Type::Custom(Identifier::new(&sanitized)) + }; + + let mut symbols = HashMap::new(); + for (key, def) in bp + .definitions + .as_ref() + .map(|d| d.inner.iter()) + .into_iter() + .flatten() + { + let name = sanitize_type_name(key); + + let new = match def.data_type { + Some(cip_57::DataType::Integer) => Some(Symbol::AliasDef(Box::new( + crate::ast::AliasDef::new(&name, Type::Int), + ))), + Some(cip_57::DataType::Bytes) => Some(Symbol::AliasDef(Box::new( + crate::ast::AliasDef::new(&name, Type::Bytes), + ))), + Some(cip_57::DataType::Map) => { + let key_ty = def + .keys + .as_ref() + .and_then(|r| r.reference.as_ref()) + .map(|r| ref_to_type(r)); + let value = def + .values + .as_ref() + .and_then(|r| r.reference.as_ref()) + .map(|r| ref_to_type(r)); + + if let (Some(key_ty), Some(value)) = (key_ty, value) { + Some(Symbol::AliasDef(Box::new(crate::ast::AliasDef::new( + &name, + Type::Map(Box::new(key_ty), Box::new(value)), + )))) + } else { + None + } + } + Some(cip_57::DataType::List) => match &def.items { + Some(cip_57::ReferencesArray::Single(r)) => { + let name = name.clone(); + r.reference.as_ref().map(|r| { + Symbol::AliasDef(Box::new(crate::ast::AliasDef::new( + &name, + Type::List(Box::new(ref_to_type(r))), + ))) + }) + } + _ => None, }, - ], - span: Span::DUMMY, - }] + Some(cip_57::DataType::Constructor) => { + let mut cases = vec![]; + if let Some(any_of) = &def.any_of { + for schema in any_of { + let case_name = schema + .title + .clone() + .unwrap_or_else(|| format!("Constructor{}", schema.index)); + let mut fields = vec![]; + for (i, field) in schema.fields.iter().enumerate() { + let field_name = field + .title + .clone() + .unwrap_or_else(|| format!("field_{}", i)); + let field_ty = ref_to_type(&field.reference); + fields.push(RecordField::new(&field_name, field_ty)); + } + cases.push(VariantCase { + name: Identifier::new(case_name), + fields, + span: Span::default(), + }); + } + } + Some(Symbol::TypeDef(Box::new(TypeDef { + name: Identifier::new(&name), + cases, + span: Span::default(), + }))) + } + None if def.any_of.is_some() => { + let mut cases = vec![]; + if let Some(any_of) = &def.any_of { + for schema in any_of { + let case_name = schema + .title + .clone() + .unwrap_or_else(|| format!("Constructor{}", schema.index)); + let mut fields = vec![]; + for (i, field) in schema.fields.iter().enumerate() { + let field_name = field + .title + .clone() + .unwrap_or_else(|| format!("field_{}", i)); + let field_ty = ref_to_type(&field.reference); + fields.push(RecordField::new(&field_name, field_ty)); + } + cases.push(VariantCase { + name: Identifier::new(case_name), + fields, + span: Span::default(), + }); + } + } + Some(Symbol::TypeDef(Box::new(TypeDef { + name: Identifier::new(&name), + cases, + span: Span::default(), + }))) + } + None => None, + }; + if let Some(symbol) = new { + symbols.insert(name, symbol); + } + } + Ok(symbols) } #[cfg(test)] mod tests { use super::*; - use crate::{ - analyzing::analyze, - ast::{self, *}, - }; + use crate::{analyzing::analyze, ast::*}; use pest::Parser; macro_rules! input_to_ast_check { diff --git a/crates/tx3-lang/src/parsing.rs b/crates/tx3-lang/src/parsing.rs index d07b1c50..5c3a8e86 100644 --- a/crates/tx3-lang/src/parsing.rs +++ b/crates/tx3-lang/src/parsing.rs @@ -110,8 +110,18 @@ impl AstNode for Program { Rule::policy_def => program.policies.push(PolicyDef::parse(pair)?), Rule::cardano_import => { let import_path = pair.into_inner().as_str(); - let external_types = load_externals(import_path); - program.types.extend(external_types); + let external_types = load_externals(import_path)?; + dbg!(&external_types); + if let Some(ref mut scope) = program.scope { + if let Some(scope_mut) = std::rc::Rc::get_mut(scope) { + scope_mut.symbols.extend(external_types); + } + } else { + program.scope = Some(std::rc::Rc::new(Scope { + symbols: external_types, + parent: None, + })); + } } Rule::EOI => break, x => unreachable!("Unexpected rule in program: {:?}", x), diff --git a/crates/tx3-lang/src/tx3.pest b/crates/tx3-lang/src/tx3.pest index 4f787568..c1d693dd 100644 --- a/crates/tx3-lang/src/tx3.pest +++ b/crates/tx3-lang/src/tx3.pest @@ -462,11 +462,7 @@ tx_def = { "tx" ~ identifier ~ parameter_list ~ "{" ~ tx_body_block* ~ "}" } -path_segment = @{ ASCII_ALPHANUMERIC+ | "." | "_" | "-" } - -file_ext = @{ "." ~ ASCII_ALPHANUMERIC+ } - -file_path = @{ path_segment ~ ("/" ~ path_segment)* ~ file_ext? } +file_path = @{ (!"\"" ~ ANY)+ } cardano_import = { "cardano::import " ~ "\"" ~ file_path ~ "\"" ~ ";" @@ -475,7 +471,7 @@ cardano_import = { // Program program = { SOI ~ - cardano_import* ~ + (cardano_import)* ~ (env_def | asset_def | party_def | policy_def | type_def | tx_def)* ~ EOI } From e27169ae49dac2828b5a96452e50fc1d6a52078f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Tue, 9 Dec 2025 17:19:48 -0300 Subject: [PATCH 07/32] feat: testable imports of cip 57 files --- crates/tx3-lang/src/ast.rs | 1 + crates/tx3-lang/src/cardano.rs | 16 +- crates/tx3-lang/src/loading.rs | 58 +- crates/tx3-lang/src/parsing.rs | 27 +- crates/tx3-lang/src/tx3.pest | 4 +- examples/asteria.ast | 1 + examples/burn.ast | 1 + examples/cardano_witness.ast | 1 + examples/cardano_witness.mint_from_plutus.tir | 6 +- examples/cip_imports.tx3 | 34 + examples/disordered.ast | 1 + examples/donation.ast | 1 + examples/env_vars.ast | 1 + examples/faucet.ast | 1 + examples/imports/plutus.json | 634 ++++++++++++++++++ examples/input_datum.ast | 1 + examples/lang_tour.ast | 1 + examples/list_concat.ast | 1 + examples/local_vars.ast | 1 + examples/map.ast | 1 + examples/reference_script.ast | 1 + examples/reference_script.publish_native.tir | 26 +- examples/reference_script.publish_plutus.tir | 32 +- examples/swap.ast | 1 + examples/transfer.ast | 1 + examples/vesting.ast | 1 + examples/withdrawal.ast | 1 + examples/withdrawal.transfer.tir | 6 +- 28 files changed, 797 insertions(+), 64 deletions(-) create mode 100644 examples/cip_imports.tx3 create mode 100644 examples/imports/plutus.json diff --git a/crates/tx3-lang/src/ast.rs b/crates/tx3-lang/src/ast.rs index fc11918d..d2fd9576 100644 --- a/crates/tx3-lang/src/ast.rs +++ b/crates/tx3-lang/src/ast.rs @@ -179,6 +179,7 @@ pub struct Program { pub assets: Vec, pub parties: Vec, pub policies: Vec, + pub imports: Vec, pub span: Span, // analysis diff --git a/crates/tx3-lang/src/cardano.rs b/crates/tx3-lang/src/cardano.rs index 6251a53d..e46f42aa 100644 --- a/crates/tx3-lang/src/cardano.rs +++ b/crates/tx3-lang/src/cardano.rs @@ -853,13 +853,21 @@ fn sanitize_type_name(name: &str) -> String { .replace(' ', "") } -// TODO: add policies, and parameters as well +// TODO: add policies, and script parameters as well pub fn load_externals( path: &str, ) -> Result, crate::parsing::Error> { - // TODO: add error handling - let json = fs::read_to_string(path).unwrap(); - let bp = serde_json::from_str::(&json).unwrap(); + let json = fs::read_to_string(path).map_err(|e| crate::parsing::Error { + message: format!("Failed to read import file: {}", e), + src: "".to_string(), // TODO: propagate source? + span: crate::ast::Span::DUMMY, + })?; + let bp = + serde_json::from_str::(&json).map_err(|e| crate::parsing::Error { + message: format!("Failed to parse blueprint JSON: {}", e), + src: "".to_string(), // TODO: should I add path here? + span: crate::ast::Span::DUMMY, + })?; let ref_to_type = |r: &str| -> Type { let sanitized = sanitize_type_name(r.strip_prefix("#/definitions/").unwrap_or(r)); diff --git a/crates/tx3-lang/src/loading.rs b/crates/tx3-lang/src/loading.rs index 8d95808d..9965f8f5 100644 --- a/crates/tx3-lang/src/loading.rs +++ b/crates/tx3-lang/src/loading.rs @@ -21,6 +21,8 @@ pub enum Error { InvalidEnvFile(String), } +use crate::cardano::load_externals; + /// Parses a Tx3 source file into a Program AST. /// /// # Arguments @@ -46,10 +48,41 @@ pub enum Error { /// ``` pub fn parse_file(path: &str) -> Result { let input = std::fs::read_to_string(path)?; - let program = parsing::parse_string(&input)?; + let mut program = parsing::parse_string(&input)?; + // Should it be configurable by trix.toml? A path for imports like "../onchain" and all imports + // would be really clean + let base_path = std::path::Path::new(path) + .parent() + .unwrap_or(std::path::Path::new(".")); + process_imports(&mut program, base_path)?; Ok(program) } +fn process_imports(program: &mut ast::Program, base_path: &Path) -> Result<(), Error> { + for import_path in &program.imports { + let full_path = base_path.join(import_path); + let path_str = full_path.to_str().ok_or_else(|| { + Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Invalid path", + )) + })?; + let external_types = load_externals(path_str)?; + + if let Some(ref mut scope) = program.scope { + if let Some(scope_mut) = std::rc::Rc::get_mut(scope) { + scope_mut.symbols.extend(external_types); + } + } else { + program.scope = Some(std::rc::Rc::new(ast::Scope { + symbols: external_types, + parent: None, + })); + } + } + Ok(()) +} + pub type ArgMap = std::collections::HashMap; fn load_env_file(path: &Path) -> Result { @@ -132,14 +165,19 @@ impl ProtocolLoader { } pub fn load(self) -> Result { - let code = match (self.code_file, self.code_string) { + let code = match (&self.code_file, &self.code_string) { (Some(file), None) => std::fs::read_to_string(file)?, - (None, Some(code)) => code, + (None, Some(code)) => code.clone(), _ => unreachable!(), }; let mut ast = parsing::parse_string(&code)?; + if let Some(file) = &self.code_file { + let base_path = file.parent().unwrap_or(std::path::Path::new(".")); + process_imports(&mut ast, base_path)?; + } + if self.analyze { analyzing::analyze(&mut ast).ok()?; } @@ -173,4 +211,18 @@ pub mod tests { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let _ = parse_file(&format!("{}/../..//examples/transfer.tx3", manifest_dir)).unwrap(); } + + #[test] + fn test_cardano_import() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let path = format!("{}/../../examples/cip_imports.tx3", manifest_dir); + let program = parse_file(&path).unwrap(); + + assert!(program.scope.is_some()); + let scope = program.scope.as_ref().unwrap(); + + assert!(scope.symbols.contains_key("Int")); + assert!(scope.symbols.contains_key("cardano_assets_AssetName")); + assert!(scope.symbols.contains_key("cardano_transaction_Datum")); + } } diff --git a/crates/tx3-lang/src/parsing.rs b/crates/tx3-lang/src/parsing.rs index 5c3a8e86..b6d9ebc2 100644 --- a/crates/tx3-lang/src/parsing.rs +++ b/crates/tx3-lang/src/parsing.rs @@ -12,10 +12,7 @@ use pest::{ }; use pest_derive::Parser; -use crate::{ - ast::*, - cardano::{load_externals, PlutusWitnessBlock, PlutusWitnessField}, -}; +use crate::{ast::*, cardano::load_externals}; #[derive(Parser)] #[grammar = "tx3.pest"] pub(crate) struct Tx3Grammar; @@ -94,6 +91,7 @@ impl AstNode for Program { aliases: Vec::new(), parties: Vec::new(), policies: Vec::new(), + imports: Vec::new(), scope: None, span, }; @@ -109,19 +107,8 @@ impl AstNode for Program { Rule::party_def => program.parties.push(PartyDef::parse(pair)?), Rule::policy_def => program.policies.push(PolicyDef::parse(pair)?), Rule::cardano_import => { - let import_path = pair.into_inner().as_str(); - let external_types = load_externals(import_path)?; - dbg!(&external_types); - if let Some(ref mut scope) = program.scope { - if let Some(scope_mut) = std::rc::Rc::get_mut(scope) { - scope_mut.symbols.extend(external_types); - } - } else { - program.scope = Some(std::rc::Rc::new(Scope { - symbols: external_types, - parent: None, - })); - } + let import_path = pair.into_inner().next().unwrap().as_str().trim_matches('"'); + program.imports.push(import_path.to_string()); } Rule::EOI => break, x => unreachable!("Unexpected rule in program: {:?}", x), @@ -1553,8 +1540,9 @@ impl AstNode for ChainSpecificBlock { /// let program = parse_string("tx swap() {}").unwrap(); /// ``` pub fn parse_string(input: &str) -> Result { - let pairs = Tx3Grammar::parse(Rule::program, input)?; - Program::parse(pairs.into_iter().next().unwrap()) + let mut pairs = Tx3Grammar::parse(Rule::program, input)?; + let program = Program::parse(pairs.next().unwrap())?; + Ok(program) } #[cfg(test)] @@ -2642,6 +2630,7 @@ mod tests { env: None, assets: vec![], policies: vec![], + imports: vec![], span: Span::DUMMY, scope: None, } diff --git a/crates/tx3-lang/src/tx3.pest b/crates/tx3-lang/src/tx3.pest index c1d693dd..4a43ce27 100644 --- a/crates/tx3-lang/src/tx3.pest +++ b/crates/tx3-lang/src/tx3.pest @@ -462,10 +462,8 @@ tx_def = { "tx" ~ identifier ~ parameter_list ~ "{" ~ tx_body_block* ~ "}" } -file_path = @{ (!"\"" ~ ANY)+ } - cardano_import = { - "cardano::import " ~ "\"" ~ file_path ~ "\"" ~ ";" + "cardano::import " ~ string ~ ";" } // Program diff --git a/examples/asteria.ast b/examples/asteria.ast index 0d231790..a8555b4f 100644 --- a/examples/asteria.ast +++ b/examples/asteria.ast @@ -1081,6 +1081,7 @@ } ], "policies": [], + "imports": [], "span": { "dummy": false, "start": 0, diff --git a/examples/burn.ast b/examples/burn.ast index 6eafd087..f5c16afb 100644 --- a/examples/burn.ast +++ b/examples/burn.ast @@ -285,6 +285,7 @@ } ], "policies": [], + "imports": [], "span": { "dummy": false, "start": 0, diff --git a/examples/cardano_witness.ast b/examples/cardano_witness.ast index 9136d18d..93ccd6c0 100644 --- a/examples/cardano_witness.ast +++ b/examples/cardano_witness.ast @@ -652,6 +652,7 @@ } ], "policies": [], + "imports": [], "span": { "dummy": false, "start": 0, diff --git a/examples/cardano_witness.mint_from_plutus.tir b/examples/cardano_witness.mint_from_plutus.tir index c0f4530f..8ff6e2e2 100644 --- a/examples/cardano_witness.mint_from_plutus.tir +++ b/examples/cardano_witness.mint_from_plutus.tir @@ -202,9 +202,6 @@ { "name": "plutus_witness", "data": { - "version": { - "Number": 3 - }, "script": { "Bytes": [ 81, @@ -226,6 +223,9 @@ 174, 105 ] + }, + "version": { + "Number": 3 } } } diff --git a/examples/cip_imports.tx3 b/examples/cip_imports.tx3 new file mode 100644 index 00000000..9a88e668 --- /dev/null +++ b/examples/cip_imports.tx3 @@ -0,0 +1,34 @@ +cardano::import "imports/plutus.json"; + +party Sender; + +party Receiver; + +tx transfer_with_imports( + quantity: Int +) { + input source { + from: Sender, + min_amount: Ada(quantity), + } + + output { + to: Receiver, + amount: Ada(quantity), + } + + output { + to: Sender, + amount: source - Ada(quantity) - fees, + datum: types_SettingsDatum::SettingsDatum { + githoney_address: cardano_address_Address::Address{ + payment_credential: cardano_address_PaymentCredential::VerificationKey { + field_0: 0x123123, + }, + stake_credential: Option_cardano_address_StakeCredential::None {}, + }, + bounty_creation_fee: 0, + bounty_reward_fee: 0, + }, + } +} diff --git a/examples/disordered.ast b/examples/disordered.ast index 5dd0f5f3..5f2e9a7f 100644 --- a/examples/disordered.ast +++ b/examples/disordered.ast @@ -299,6 +299,7 @@ } ], "policies": [], + "imports": [], "span": { "dummy": false, "start": 0, diff --git a/examples/donation.ast b/examples/donation.ast index d7fd2b5b..54942850 100644 --- a/examples/donation.ast +++ b/examples/donation.ast @@ -316,6 +316,7 @@ } ], "policies": [], + "imports": [], "span": { "dummy": false, "start": 0, diff --git a/examples/env_vars.ast b/examples/env_vars.ast index cef2b474..5958754d 100644 --- a/examples/env_vars.ast +++ b/examples/env_vars.ast @@ -255,6 +255,7 @@ } ], "policies": [], + "imports": [], "span": { "dummy": false, "start": 0, diff --git a/examples/faucet.ast b/examples/faucet.ast index ef4e9653..51d509c8 100644 --- a/examples/faucet.ast +++ b/examples/faucet.ast @@ -320,6 +320,7 @@ } } ], + "imports": [], "span": { "dummy": false, "start": 0, diff --git a/examples/imports/plutus.json b/examples/imports/plutus.json new file mode 100644 index 00000000..97472029 --- /dev/null +++ b/examples/imports/plutus.json @@ -0,0 +1,634 @@ +{ + "preamble": { + "title": "txpipe/contract", + "description": "Aiken contracts for project 'txpipe/contract'", + "version": "0.0.0", + "plutusVersion": "v3", + "compiler": { + "name": "Aiken", + "version": "v1.1.17+c3a7fba" + }, + "license": "Apache-2.0" + }, + "validators": [ + { + "title": "githoney_contract.badges_contract.spend", + "datum": { + "title": "_datum", + "schema": { + "$ref": "#/definitions/cardano~1transaction~1Datum" + } + }, + "redeemer": { + "title": "_redeemer", + "schema": { + "$ref": "#/definitions/cardano~1transaction~1Redeemer" + } + }, + "parameters": [ + { + "title": "settings_policy_id", + "schema": { + "$ref": "#/definitions/cardano~1assets~1PolicyId" + } + } + ], + "compiledCode": "eedaa957c60268de", + "hash": "24b9b1964ce02550db270a1d6270b505b9c0342625ee766d77fab1f9" + }, + { + "title": "githoney_contract.badges_contract.else", + "redeemer": { + "schema": {} + }, + "parameters": [ + { + "title": "settings_policy_id", + "schema": { + "$ref": "#/definitions/cardano~1assets~1PolicyId" + } + } + ], + "compiledCode": "3fe9763bc5ea0108", + "hash": "24b9b1964ce02550db270a1d6270b505b9c0342625ee766d77fab1f9" + }, + { + "title": "githoney_contract.badges_policy.mint", + "redeemer": { + "title": "_redeemer", + "schema": { + "$ref": "#/definitions/cardano~1transaction~1Redeemer" + } + }, + "parameters": [ + { + "title": "utxo_ref", + "schema": { + "$ref": "#/definitions/cardano~1transaction~1OutputReference" + } + }, + { + "title": "nonce", + "schema": { + "$ref": "#/definitions/Int" + } + } + ], + "compiledCode": "c8db1de3fbbc8921", + "hash": "87d6bd0b40f204d49803dad8e0d70611918d22354125c8f226a42670" + }, + { + "title": "githoney_contract.badges_policy.else", + "redeemer": { + "schema": {} + }, + "parameters": [ + { + "title": "utxo_ref", + "schema": { + "$ref": "#/definitions/cardano~1transaction~1OutputReference" + } + }, + { + "title": "nonce", + "schema": { + "$ref": "#/definitions/Int" + } + } + ], + "compiledCode": "f143b98f13d5b12b", + "hash": "87d6bd0b40f204d49803dad8e0d70611918d22354125c8f226a42670" + }, + { + "title": "githoney_contract.githoney.spend", + "datum": { + "title": "datum", + "schema": { + "$ref": "#/definitions/types~1GithoneyDatum" + } + }, + "redeemer": { + "title": "redeemer", + "schema": { + "$ref": "#/definitions/types~1GithoneyContractRedeemers" + } + }, + "parameters": [ + { + "title": "settings_policy_id", + "schema": { + "$ref": "#/definitions/cardano~1assets~1PolicyId" + } + } + ], + "compiledCode": "f1a9d1de401b5004", + "hash": "7577273f99e7f3c9211d6342338d6316afc91689333c4d5099e7b2d1" + }, + { + "title": "githoney_contract.githoney.mint", + "redeemer": { + "title": "_redeemer", + "schema": { + "$ref": "#/definitions/cardano~1transaction~1Redeemer" + } + }, + "parameters": [ + { + "title": "settings_policy_id", + "schema": { + "$ref": "#/definitions/cardano~1assets~1PolicyId" + } + } + ], + "compiledCode": "f1f665b7b5ec44d6", + "hash": "7577273f99e7f3c9211d6342338d6316afc91689333c4d5099e7b2d1" + }, + { + "title": "githoney_contract.githoney.else", + "redeemer": { + "schema": {} + }, + "parameters": [ + { + "title": "settings_policy_id", + "schema": { + "$ref": "#/definitions/cardano~1assets~1PolicyId" + } + } + ], + "compiledCode": "8dc98413cbd1b43f", + "hash": "7577273f99e7f3c9211d6342338d6316afc91689333c4d5099e7b2d1" + }, + { + "title": "githoney_contract.settings.spend", + "datum": { + "title": "datum", + "schema": { + "$ref": "#/definitions/types~1SettingsDatum" + } + }, + "redeemer": { + "title": "redeemer", + "schema": { + "$ref": "#/definitions/types~1SettingsRedeemers" + } + }, + "compiledCode": "5eb78784a02ee1a0", + "hash": "049f1a09fb535089fdd9df98b4b0975d1081f9afc2d6f59ad2f9c208" + }, + { + "title": "githoney_contract.settings.else", + "redeemer": { + "schema": {} + }, + "compiledCode": "9813b3c286674b27", + "hash": "049f1a09fb535089fdd9df98b4b0975d1081f9afc2d6f59ad2f9c208" + }, + { + "title": "githoney_contract.settings_minting.mint", + "redeemer": { + "title": "_redeemer", + "schema": { + "$ref": "#/definitions/cardano~1transaction~1Redeemer" + } + }, + "parameters": [ + { + "title": "utxo_ref", + "schema": { + "$ref": "#/definitions/cardano~1transaction~1OutputReference" + } + }, + { + "title": "settings_script_addr", + "schema": { + "$ref": "#/definitions/cardano~1address~1Address" + } + } + ], + "compiledCode": "6396e66bd8b6ac88", + "hash": "15721f473d73adf918aa7541871739c2e8df0a60abe023411cf9f1b0" + }, + { + "title": "githoney_contract.settings_minting.else", + "redeemer": { + "schema": {} + }, + "parameters": [ + { + "title": "utxo_ref", + "schema": { + "$ref": "#/definitions/cardano~1transaction~1OutputReference" + } + }, + { + "title": "settings_script_addr", + "schema": { + "$ref": "#/definitions/cardano~1address~1Address" + } + } + ], + "compiledCode": "fa2dba3b69a8d00c", + "hash": "15721f473d73adf918aa7541871739c2e8df0a60abe023411cf9f1b0" + } + ], + "definitions": { + "Bool": { + "title": "Bool", + "anyOf": [ + { + "title": "False", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "True", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + }, + "ByteArray": { + "title": "ByteArray", + "dataType": "bytes" + }, + "Data": { + "title": "Data", + "description": "Any Plutus data." + }, + "Int": { + "dataType": "integer" + }, + "Option$cardano/address/Address": { + "title": "Option", + "anyOf": [ + { + "title": "Some", + "description": "An optional value.", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "$ref": "#/definitions/cardano~1address~1Address" + } + ] + }, + { + "title": "None", + "description": "Nothing.", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + }, + "Option$cardano/address/StakeCredential": { + "title": "Option", + "anyOf": [ + { + "title": "Some", + "description": "An optional value.", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "$ref": "#/definitions/cardano~1address~1StakeCredential" + } + ] + }, + { + "title": "None", + "description": "Nothing.", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + }, + "Pairs$cardano/assets/AssetName_Int": { + "title": "Pairs", + "dataType": "map", + "keys": { + "$ref": "#/definitions/cardano~1assets~1AssetName" + }, + "values": { + "$ref": "#/definitions/Int" + } + }, + "Pairs$cardano/assets/PolicyId_Pairs$cardano/assets/AssetName_Int": { + "title": "Pairs>", + "dataType": "map", + "keys": { + "$ref": "#/definitions/cardano~1assets~1PolicyId" + }, + "values": { + "$ref": "#/definitions/Pairs$cardano~1assets~1AssetName_Int" + } + }, + "aiken/crypto/DataHash": { + "title": "DataHash", + "dataType": "bytes" + }, + "aiken/crypto/ScriptHash": { + "title": "ScriptHash", + "dataType": "bytes" + }, + "aiken/crypto/VerificationKeyHash": { + "title": "VerificationKeyHash", + "dataType": "bytes" + }, + "cardano/address/Address": { + "title": "Address", + "description": "A Cardano `Address` typically holding one or two credential references.\n\n Note that legacy bootstrap addresses (a.k.a. 'Byron addresses') are\n completely excluded from Plutus contexts. Thus, from an on-chain\n perspective only exists addresses of type 00, 01, ..., 07 as detailed\n in [CIP-0019 :: Shelley Addresses](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0019/#shelley-addresses).", + "anyOf": [ + { + "title": "Address", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "payment_credential", + "$ref": "#/definitions/cardano~1address~1PaymentCredential" + }, + { + "title": "stake_credential", + "$ref": "#/definitions/Option$cardano~1address~1StakeCredential" + } + ] + } + ] + }, + "cardano/address/Credential": { + "title": "Credential", + "description": "A general structure for representing an on-chain `Credential`.\n\n Credentials are always one of two kinds: a direct public/private key\n pair, or a script (native or Plutus).", + "anyOf": [ + { + "title": "VerificationKey", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + ] + }, + { + "title": "Script", + "dataType": "constructor", + "index": 1, + "fields": [ + { + "$ref": "#/definitions/aiken~1crypto~1ScriptHash" + } + ] + } + ] + }, + "cardano/address/PaymentCredential": { + "title": "PaymentCredential", + "description": "A general structure for representing an on-chain `Credential`.\n\n Credentials are always one of two kinds: a direct public/private key\n pair, or a script (native or Plutus).", + "anyOf": [ + { + "title": "VerificationKey", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + ] + }, + { + "title": "Script", + "dataType": "constructor", + "index": 1, + "fields": [ + { + "$ref": "#/definitions/aiken~1crypto~1ScriptHash" + } + ] + } + ] + }, + "cardano/address/StakeCredential": { + "title": "StakeCredential", + "description": "Represent a type of object that can be represented either inline (by hash)\n or via a reference (i.e. a pointer to an on-chain location).\n\n This is mainly use for capturing pointers to a stake credential\n registration certificate in the case of so-called pointer addresses.", + "anyOf": [ + { + "title": "Inline", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "$ref": "#/definitions/cardano~1address~1Credential" + } + ] + }, + { + "title": "Pointer", + "dataType": "constructor", + "index": 1, + "fields": [ + { + "title": "slot_number", + "$ref": "#/definitions/Int" + }, + { + "title": "transaction_index", + "$ref": "#/definitions/Int" + }, + { + "title": "certificate_index", + "$ref": "#/definitions/Int" + } + ] + } + ] + }, + "cardano/assets/AssetName": { + "title": "AssetName", + "dataType": "bytes" + }, + "cardano/assets/PolicyId": { + "title": "PolicyId", + "dataType": "bytes" + }, + "cardano/transaction/Datum": { + "title": "Datum", + "description": "An output `Datum`.", + "anyOf": [ + { + "title": "NoDatum", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "DatumHash", + "description": "A datum referenced by its hash digest.", + "dataType": "constructor", + "index": 1, + "fields": [ + { + "$ref": "#/definitions/aiken~1crypto~1DataHash" + } + ] + }, + { + "title": "InlineDatum", + "description": "A datum completely inlined in the output.", + "dataType": "constructor", + "index": 2, + "fields": [ + { + "$ref": "#/definitions/Data" + } + ] + } + ] + }, + "cardano/transaction/OutputReference": { + "title": "OutputReference", + "description": "An `OutputReference` is a unique reference to an output on-chain. The `output_index`\n corresponds to the position in the output list of the transaction (identified by its id)\n that produced that output", + "anyOf": [ + { + "title": "OutputReference", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "transaction_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "output_index", + "$ref": "#/definitions/Int" + } + ] + } + ] + }, + "cardano/transaction/Redeemer": { + "title": "Redeemer", + "description": "Any Plutus data." + }, + "types/GithoneyContractRedeemers": { + "title": "GithoneyContractRedeemers", + "anyOf": [ + { + "title": "AddRewards", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "Assign", + "dataType": "constructor", + "index": 1, + "fields": [] + }, + { + "title": "Merge", + "dataType": "constructor", + "index": 2, + "fields": [] + }, + { + "title": "Close", + "dataType": "constructor", + "index": 3, + "fields": [] + }, + { + "title": "Claim", + "dataType": "constructor", + "index": 4, + "fields": [] + } + ] + }, + "types/GithoneyDatum": { + "title": "GithoneyDatum", + "anyOf": [ + { + "title": "GithoneyDatum", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "admin_payment_credential", + "$ref": "#/definitions/cardano~1address~1Credential" + }, + { + "title": "maintainer_address", + "$ref": "#/definitions/cardano~1address~1Address" + }, + { + "title": "contributor_address", + "$ref": "#/definitions/Option$cardano~1address~1Address" + }, + { + "title": "bounty_reward_fee", + "$ref": "#/definitions/Int" + }, + { + "title": "deadline", + "$ref": "#/definitions/Int" + }, + { + "title": "merged", + "$ref": "#/definitions/Bool" + }, + { + "title": "initial_value", + "$ref": "#/definitions/Pairs$cardano~1assets~1PolicyId_Pairs$cardano~1assets~1AssetName_Int" + } + ] + } + ] + }, + "types/SettingsDatum": { + "title": "SettingsDatum", + "anyOf": [ + { + "title": "SettingsDatum", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "githoney_address", + "$ref": "#/definitions/cardano~1address~1Address" + }, + { + "title": "bounty_creation_fee", + "$ref": "#/definitions/Int" + }, + { + "title": "bounty_reward_fee", + "$ref": "#/definitions/Int" + } + ] + } + ] + }, + "types/SettingsRedeemers": { + "title": "SettingsRedeemers", + "anyOf": [ + { + "title": "UpdateSettings", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "CloseSettings", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + } + } +} diff --git a/examples/input_datum.ast b/examples/input_datum.ast index c0cb2672..dc43f438 100644 --- a/examples/input_datum.ast +++ b/examples/input_datum.ast @@ -364,6 +364,7 @@ } ], "policies": [], + "imports": [], "span": { "dummy": false, "start": 0, diff --git a/examples/lang_tour.ast b/examples/lang_tour.ast index 882ba085..71060e61 100644 --- a/examples/lang_tour.ast +++ b/examples/lang_tour.ast @@ -1582,6 +1582,7 @@ } } ], + "imports": [], "span": { "dummy": false, "start": 0, diff --git a/examples/list_concat.ast b/examples/list_concat.ast index f8b6c17f..65d268bf 100644 --- a/examples/list_concat.ast +++ b/examples/list_concat.ast @@ -305,6 +305,7 @@ } ], "policies": [], + "imports": [], "span": { "dummy": false, "start": 0, diff --git a/examples/local_vars.ast b/examples/local_vars.ast index 432bb4ff..c950a292 100644 --- a/examples/local_vars.ast +++ b/examples/local_vars.ast @@ -258,6 +258,7 @@ } ], "policies": [], + "imports": [], "span": { "dummy": false, "start": 0, diff --git a/examples/map.ast b/examples/map.ast index 7f8e3eee..abcc336c 100644 --- a/examples/map.ast +++ b/examples/map.ast @@ -439,6 +439,7 @@ } ], "policies": [], + "imports": [], "span": { "dummy": false, "start": 0, diff --git a/examples/reference_script.ast b/examples/reference_script.ast index 9c312a18..22ada355 100644 --- a/examples/reference_script.ast +++ b/examples/reference_script.ast @@ -564,6 +564,7 @@ } ], "policies": [], + "imports": [], "span": { "dummy": false, "start": 0, diff --git a/examples/reference_script.publish_native.tir b/examples/reference_script.publish_native.tir index 90fdd3b9..73821504 100644 --- a/examples/reference_script.publish_native.tir +++ b/examples/reference_script.publish_native.tir @@ -137,16 +137,6 @@ { "name": "cardano_publish", "data": { - "script": { - "Bytes": [ - 130, - 1, - 129, - 130, - 4, - 0 - ] - }, "amount": { "Assets": [ { @@ -163,6 +153,19 @@ } ] }, + "script": { + "Bytes": [ + 130, + 1, + 129, + 130, + 4, + 0 + ] + }, + "version": { + "Number": 0 + }, "to": { "EvalParam": { "ExpectValue": [ @@ -170,9 +173,6 @@ "Address" ] } - }, - "version": { - "Number": 0 } } } diff --git a/examples/reference_script.publish_plutus.tir b/examples/reference_script.publish_plutus.tir index 7506c0f5..81087f15 100644 --- a/examples/reference_script.publish_plutus.tir +++ b/examples/reference_script.publish_plutus.tir @@ -140,22 +140,6 @@ "version": { "Number": 3 }, - "amount": { - "Assets": [ - { - "policy": "None", - "asset_name": "None", - "amount": { - "EvalParam": { - "ExpectValue": [ - "quantity", - "Int" - ] - } - } - } - ] - }, "script": { "Bytes": [ 81, @@ -185,6 +169,22 @@ "Address" ] } + }, + "amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "EvalParam": { + "ExpectValue": [ + "quantity", + "Int" + ] + } + } + } + ] } } } diff --git a/examples/swap.ast b/examples/swap.ast index 8d915d0f..d1ff5dd8 100644 --- a/examples/swap.ast +++ b/examples/swap.ast @@ -743,6 +743,7 @@ } ], "policies": [], + "imports": [], "span": { "dummy": false, "start": 0, diff --git a/examples/transfer.ast b/examples/transfer.ast index b100c14b..acc911e2 100644 --- a/examples/transfer.ast +++ b/examples/transfer.ast @@ -280,6 +280,7 @@ } ], "policies": [], + "imports": [], "span": { "dummy": false, "start": 0, diff --git a/examples/vesting.ast b/examples/vesting.ast index 5c40088d..692c4570 100644 --- a/examples/vesting.ast +++ b/examples/vesting.ast @@ -754,6 +754,7 @@ } } ], + "imports": [], "span": { "dummy": false, "start": 0, diff --git a/examples/withdrawal.ast b/examples/withdrawal.ast index ca893dd5..5c79f5a0 100644 --- a/examples/withdrawal.ast +++ b/examples/withdrawal.ast @@ -314,6 +314,7 @@ } ], "policies": [], + "imports": [], "span": { "dummy": false, "start": 0, diff --git a/examples/withdrawal.transfer.tir b/examples/withdrawal.transfer.tir index facdbb3c..ec19f1e1 100644 --- a/examples/withdrawal.transfer.tir +++ b/examples/withdrawal.transfer.tir @@ -165,6 +165,9 @@ { "name": "withdrawal", "data": { + "amount": { + "Number": 0 + }, "credential": { "EvalParam": { "ExpectValue": [ @@ -173,9 +176,6 @@ ] } }, - "amount": { - "Number": 0 - }, "redeemer": { "Struct": { "constructor": 0, From c5ec2ef5bc69367c0b818d1b7b4a310f27c93d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Tue, 9 Dec 2025 18:01:42 -0300 Subject: [PATCH 08/32] feat: remove unnecessary change on parsing --- crates/tx3-lang/src/parsing.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/tx3-lang/src/parsing.rs b/crates/tx3-lang/src/parsing.rs index b6d9ebc2..2d885441 100644 --- a/crates/tx3-lang/src/parsing.rs +++ b/crates/tx3-lang/src/parsing.rs @@ -1540,9 +1540,8 @@ impl AstNode for ChainSpecificBlock { /// let program = parse_string("tx swap() {}").unwrap(); /// ``` pub fn parse_string(input: &str) -> Result { - let mut pairs = Tx3Grammar::parse(Rule::program, input)?; - let program = Program::parse(pairs.next().unwrap())?; - Ok(program) + let pairs = Tx3Grammar::parse(Rule::program, input)?; + Program::parse(pairs.into_iter().next().unwrap()) } #[cfg(test)] From 61649b8897b20a0e742e6c163f4881c6356f513e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 12 Dec 2025 19:01:45 -0300 Subject: [PATCH 09/32] feat: generalize syntax and improve parsing --- crates/tx3-lang/src/ast.rs | 15 +++++++++++++- crates/tx3-lang/src/cardano.rs | 22 +++++++++++++++++---- crates/tx3-lang/src/loading.rs | 4 ++-- crates/tx3-lang/src/parsing.rs | 36 ++++++++++++++++++++++++++++++---- crates/tx3-lang/src/tx3.pest | 15 +++++++++++--- examples/cip_imports.tx3 | 4 ++-- 6 files changed, 80 insertions(+), 16 deletions(-) diff --git a/crates/tx3-lang/src/ast.rs b/crates/tx3-lang/src/ast.rs index d2fd9576..2898f2bc 100644 --- a/crates/tx3-lang/src/ast.rs +++ b/crates/tx3-lang/src/ast.rs @@ -170,6 +170,19 @@ impl AsRef for Identifier { } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ImportKind { + Cip57, + Tx3, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Import { + pub span: Span, + pub path: String, + pub kind: ImportKind, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct Program { pub env: Option, @@ -179,7 +192,7 @@ pub struct Program { pub assets: Vec, pub parties: Vec, pub policies: Vec, - pub imports: Vec, + pub imports: Vec, pub span: Span, // analysis diff --git a/crates/tx3-lang/src/cardano.rs b/crates/tx3-lang/src/cardano.rs index e46f42aa..cd813e65 100644 --- a/crates/tx3-lang/src/cardano.rs +++ b/crates/tx3-lang/src/cardano.rs @@ -843,7 +843,7 @@ impl IntoLower for CardanoBlock { /// Sanitizes a type name to be a valid tx3 identifier. /// Replaces characters like `<`, `>`, `,`, `/`, `$`, `~1` with underscores. -fn sanitize_type_name(name: &str) -> String { +fn generic_sanitizer(name: &str) -> String { name.replace("~1", "_") // URL-encoded `/` in JSON references .replace('/', "_") .replace('$', "_") @@ -853,7 +853,21 @@ fn sanitize_type_name(name: &str) -> String { .replace(' ', "") } -// TODO: add policies, and script parameters as well +pub enum LoadKind { + Cip57, +} + +pub enum Compiler { + Aiken, + Unknown, +} + +pub struct ExternalLoader { + pub path: String, + pub kind: LoadKind, + pub compiler: Compiler, +} + pub fn load_externals( path: &str, ) -> Result, crate::parsing::Error> { @@ -870,7 +884,7 @@ pub fn load_externals( })?; let ref_to_type = |r: &str| -> Type { - let sanitized = sanitize_type_name(r.strip_prefix("#/definitions/").unwrap_or(r)); + let sanitized = generic_sanitizer(r.strip_prefix("#/definitions/").unwrap_or(r)); Type::Custom(Identifier::new(&sanitized)) }; @@ -882,7 +896,7 @@ pub fn load_externals( .into_iter() .flatten() { - let name = sanitize_type_name(key); + let name = generic_sanitizer(key); let new = match def.data_type { Some(cip_57::DataType::Integer) => Some(Symbol::AliasDef(Box::new( diff --git a/crates/tx3-lang/src/loading.rs b/crates/tx3-lang/src/loading.rs index 9965f8f5..ab81f7b6 100644 --- a/crates/tx3-lang/src/loading.rs +++ b/crates/tx3-lang/src/loading.rs @@ -59,8 +59,8 @@ pub fn parse_file(path: &str) -> Result { } fn process_imports(program: &mut ast::Program, base_path: &Path) -> Result<(), Error> { - for import_path in &program.imports { - let full_path = base_path.join(import_path); + for import in &program.imports { + let full_path = base_path.join(&import.path); let path_str = full_path.to_str().ok_or_else(|| { Error::Io(std::io::Error::new( std::io::ErrorKind::InvalidInput, diff --git a/crates/tx3-lang/src/parsing.rs b/crates/tx3-lang/src/parsing.rs index 2d885441..819f8bbb 100644 --- a/crates/tx3-lang/src/parsing.rs +++ b/crates/tx3-lang/src/parsing.rs @@ -76,6 +76,37 @@ pub trait AstNode: Sized { fn span(&self) -> &Span; } +impl AstNode for Import { + const RULE: Rule = Rule::import; + + fn parse(pair: Pair) -> Result { + let span = pair.as_span().into(); + let mut inner = pair.into_inner(); + + let import_rule = inner.next().unwrap(); + match import_rule.as_rule() { + Rule::cip57_import => { + let path = import_rule + .into_inner() + .as_str() + .trim_matches('"') + .to_string(); + Ok(Import { + span, + path, + kind: ImportKind::Cip57, + }) + } + Rule::tx3_import => todo!(), + x => unreachable!("Unexpected rule in import: {:?}", x), + } + } + + fn span(&self) -> &Span { + &self.span + } +} + impl AstNode for Program { const RULE: Rule = Rule::program; @@ -106,10 +137,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::cardano_import => { - let import_path = pair.into_inner().next().unwrap().as_str().trim_matches('"'); - program.imports.push(import_path.to_string()); - } + Rule::import => program.imports.push(Import::parse(pair)?), Rule::EOI => break, x => unreachable!("Unexpected rule in program: {:?}", x), } diff --git a/crates/tx3-lang/src/tx3.pest b/crates/tx3-lang/src/tx3.pest index 4a43ce27..3cccb5b4 100644 --- a/crates/tx3-lang/src/tx3.pest +++ b/crates/tx3-lang/src/tx3.pest @@ -462,14 +462,23 @@ tx_def = { "tx" ~ identifier ~ parameter_list ~ "{" ~ tx_body_block* ~ "}" } -cardano_import = { - "cardano::import " ~ string ~ ";" +cip57_import = { + "cip57::import" ~ string ~ ";" +} + +tx3_import = { + "tx3::import" ~ string ~ ";" +} + +import = { + cip57_import | + tx3_import } // Program program = { SOI ~ - (cardano_import)* ~ + import* ~ (env_def | asset_def | party_def | policy_def | type_def | tx_def)* ~ EOI } diff --git a/examples/cip_imports.tx3 b/examples/cip_imports.tx3 index 9a88e668..b8d96d75 100644 --- a/examples/cip_imports.tx3 +++ b/examples/cip_imports.tx3 @@ -1,4 +1,4 @@ -cardano::import "imports/plutus.json"; +cip57::import "imports/plutus.json"; party Sender; @@ -11,7 +11,7 @@ tx transfer_with_imports( from: Sender, min_amount: Ada(quantity), } - + output { to: Receiver, amount: Ada(quantity), From 1d6b5ca6dbd519b768ac9e9ce60ea59606006f83 Mon Sep 17 00:00:00 2001 From: sofia-bobbiesi Date: Mon, 15 Dec 2025 15:18:17 -0300 Subject: [PATCH 10/32] feat: add scope inspection methods to Scope and Program structs --- crates/tx3-lang/src/ast.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/tx3-lang/src/ast.rs b/crates/tx3-lang/src/ast.rs index 1c89006a..0483bc13 100644 --- a/crates/tx3-lang/src/ast.rs +++ b/crates/tx3-lang/src/ast.rs @@ -17,6 +17,16 @@ pub struct Scope { pub(crate) parent: Option>, } +impl Scope { + pub fn symbols(&self) -> &HashMap { + &self.symbols + } + + pub fn parent(&self) -> Option<&Rc> { + self.parent.as_ref() + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Symbol { EnvVar(String, Box), @@ -186,6 +196,12 @@ pub struct Program { pub(crate) scope: Option>, } +impl Program { + pub fn scope(&self) -> Option<&Rc> { + self.scope.as_ref() + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct EnvField { pub name: String, From 3837fe6de0d46a6c88018a0bf3b98e1145db51cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Wed, 17 Dec 2025 16:34:47 -0300 Subject: [PATCH 11/32] refactor: cip57 -> blueprint & cardano God file split up into digestible parts --- crates/tx3-lang/src/ast.rs | 2 +- crates/tx3-lang/src/cardano/analyzing.rs | 271 +++++++ crates/tx3-lang/src/cardano/ast.rs | 120 +++ crates/tx3-lang/src/cardano/blueprint.rs | 247 ++++++ crates/tx3-lang/src/cardano/lowering.rs | 228 ++++++ crates/tx3-lang/src/cardano/mod.rs | 8 + .../src/{cardano.rs => cardano/parsing.rs} | 757 +++--------------- crates/tx3-lang/src/loading.rs | 5 +- crates/tx3-lang/src/parsing.rs | 6 +- crates/tx3-lang/src/tx3.pest | 6 +- examples/cip_imports.tx3 | 10 +- 11 files changed, 1022 insertions(+), 638 deletions(-) create mode 100644 crates/tx3-lang/src/cardano/analyzing.rs create mode 100644 crates/tx3-lang/src/cardano/ast.rs create mode 100644 crates/tx3-lang/src/cardano/blueprint.rs create mode 100644 crates/tx3-lang/src/cardano/lowering.rs create mode 100644 crates/tx3-lang/src/cardano/mod.rs rename crates/tx3-lang/src/{cardano.rs => cardano/parsing.rs} (51%) diff --git a/crates/tx3-lang/src/ast.rs b/crates/tx3-lang/src/ast.rs index 69da470c..acbb48e0 100644 --- a/crates/tx3-lang/src/ast.rs +++ b/crates/tx3-lang/src/ast.rs @@ -182,7 +182,7 @@ impl AsRef for Identifier { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum ImportKind { - Cip57, + Blueprint, Tx3, } diff --git a/crates/tx3-lang/src/cardano/analyzing.rs b/crates/tx3-lang/src/cardano/analyzing.rs new file mode 100644 index 00000000..c4c74f3d --- /dev/null +++ b/crates/tx3-lang/src/cardano/analyzing.rs @@ -0,0 +1,271 @@ +use std::rc::Rc; + +use crate::{ + analyzing::{Analyzable, AnalyzeReport}, + ast::{Scope, Type}, +}; + +use super::ast::*; + +impl Analyzable for WithdrawalField { + fn analyze(&mut self, parent: Option>) -> AnalyzeReport { + match self { + WithdrawalField::From(x) => x.analyze(parent), + WithdrawalField::Amount(x) => { + let amount = x.analyze(parent.clone()); + let amount_type = AnalyzeReport::expect_data_expr_type(x, &Type::Int); + amount + amount_type + } + WithdrawalField::Redeemer(x) => x.analyze(parent), + } + } + + fn is_resolved(&self) -> bool { + match self { + WithdrawalField::From(x) => x.is_resolved(), + WithdrawalField::Amount(x) => x.is_resolved(), + WithdrawalField::Redeemer(x) => x.is_resolved(), + } + } +} + +impl Analyzable for WithdrawalBlock { + fn analyze(&mut self, parent: Option>) -> AnalyzeReport { + self.fields.analyze(parent) + } + + fn is_resolved(&self) -> bool { + self.fields.is_resolved() + } +} + +impl Analyzable for VoteDelegationCertificate { + fn analyze(&mut self, parent: Option>) -> AnalyzeReport { + let drep = self.drep.analyze(parent.clone()); + let stake = self.stake.analyze(parent.clone()); + + drep + stake + } + + fn is_resolved(&self) -> bool { + self.drep.is_resolved() && self.stake.is_resolved() + } +} + +impl Analyzable for StakeDelegationCertificate { + fn analyze(&mut self, parent: Option>) -> AnalyzeReport { + let pool = self.pool.analyze(parent.clone()); + let stake = self.stake.analyze(parent.clone()); + + pool + stake + } + + fn is_resolved(&self) -> bool { + self.pool.is_resolved() && self.stake.is_resolved() + } +} + +impl Analyzable for PlutusWitnessField { + fn analyze(&mut self, parent: Option>) -> AnalyzeReport { + match self { + PlutusWitnessField::Version(x, _) => x.analyze(parent), + PlutusWitnessField::Script(x, _) => x.analyze(parent), + } + } + + fn is_resolved(&self) -> bool { + match self { + PlutusWitnessField::Version(x, _) => x.is_resolved(), + PlutusWitnessField::Script(x, _) => x.is_resolved(), + } + } +} + +impl Analyzable for PlutusWitnessBlock { + fn analyze(&mut self, parent: Option>) -> AnalyzeReport { + self.fields.analyze(parent) + } + + fn is_resolved(&self) -> bool { + self.fields.is_resolved() + } +} + +impl Analyzable for NativeWitnessField { + fn analyze(&mut self, parent: Option>) -> AnalyzeReport { + match self { + NativeWitnessField::Script(x, _) => x.analyze(parent), + } + } + + fn is_resolved(&self) -> bool { + match self { + NativeWitnessField::Script(x, _) => x.is_resolved(), + } + } +} + +impl Analyzable for NativeWitnessBlock { + fn analyze(&mut self, parent: Option>) -> AnalyzeReport { + self.fields.analyze(parent) + } + + fn is_resolved(&self) -> bool { + self.fields.is_resolved() + } +} + +impl Analyzable for TreasuryDonationBlock { + fn analyze(&mut self, parent: Option>) -> AnalyzeReport { + let coin = self.coin.analyze(parent); + let coin_type = AnalyzeReport::expect_data_expr_type(&self.coin, &Type::Int); + + coin + coin_type + } + + fn is_resolved(&self) -> bool { + self.coin.is_resolved() + } +} + +impl Analyzable for CardanoPublishBlockField { + fn analyze(&mut self, parent: Option>) -> AnalyzeReport { + match self { + CardanoPublishBlockField::To(x) => x.analyze(parent), + CardanoPublishBlockField::Amount(x) => x.analyze(parent), + CardanoPublishBlockField::Datum(x) => x.analyze(parent), + CardanoPublishBlockField::Version(x) => x.analyze(parent), + CardanoPublishBlockField::Script(x) => x.analyze(parent), + } + } + + fn is_resolved(&self) -> bool { + match self { + CardanoPublishBlockField::To(x) => x.is_resolved(), + CardanoPublishBlockField::Amount(x) => x.is_resolved(), + CardanoPublishBlockField::Datum(x) => x.is_resolved(), + CardanoPublishBlockField::Version(x) => x.is_resolved(), + CardanoPublishBlockField::Script(x) => x.is_resolved(), + } + } +} + +impl Analyzable for CardanoPublishBlock { + fn analyze(&mut self, parent: Option>) -> AnalyzeReport { + self.fields.analyze(parent) + } + + fn is_resolved(&self) -> bool { + self.fields.is_resolved() + } +} + +impl Analyzable for CardanoBlock { + fn analyze(&mut self, parent: Option>) -> AnalyzeReport { + match self { + CardanoBlock::VoteDelegationCertificate(x) => x.analyze(parent), + CardanoBlock::StakeDelegationCertificate(x) => x.analyze(parent), + CardanoBlock::Withdrawal(x) => x.analyze(parent), + CardanoBlock::PlutusWitness(x) => x.analyze(parent), + CardanoBlock::NativeWitness(x) => x.analyze(parent), + CardanoBlock::TreasuryDonation(x) => x.analyze(parent), + CardanoBlock::Publish(x) => x.analyze(parent), + } + } + + fn is_resolved(&self) -> bool { + match self { + CardanoBlock::VoteDelegationCertificate(x) => x.is_resolved(), + CardanoBlock::StakeDelegationCertificate(x) => x.is_resolved(), + CardanoBlock::Withdrawal(x) => x.is_resolved(), + CardanoBlock::PlutusWitness(x) => x.is_resolved(), + CardanoBlock::NativeWitness(x) => x.is_resolved(), + Self::TreasuryDonation(x) => x.is_resolved(), + CardanoBlock::Publish(x) => x.is_resolved(), + } + } +} + +#[cfg(test)] +mod tests { + + use crate::analyzing::analyze; + + #[test] + fn test_treasury_donation_type() { + let mut ast = crate::parsing::parse_string( + r#" + tx test(quantity: Int) { + cardano::treasury_donation { + coin: quantity, + } + } + "#, + ) + .unwrap(); + + let result = analyze(&mut ast); + assert!(result.errors.is_empty()); + } + + #[test] + fn test_treasury_donation_type_not_ok() { + let mut ast = crate::parsing::parse_string( + r#" + tx test(quantity: Bytes) { + cardano::treasury_donation { + coin: quantity, + } + } + "#, + ) + .unwrap(); + + let result = analyze(&mut ast); + assert!(!result.errors.is_empty()); + } + + #[test] + fn test_publish_type_ok() { + let mut ast = crate::parsing::parse_string( + r#" + party Receiver; + + tx test(quantity: Int) { + cardano::publish { + to: Receiver, + amount: Ada(quantity), + version: 3, + script: 0xABCDEF, + } + } + "#, + ) + .unwrap(); + + let result = analyze(&mut ast); + assert!(result.errors.is_empty()); + } + + #[test] + fn test_publish_type_with_name_ok() { + let mut ast = crate::parsing::parse_string( + r#" + party Receiver; + + tx test(quantity: Int) { + cardano::publish deploy { + to: Receiver, + amount: Ada(quantity), + version: 3, + script: 0xABCDEF, + } + } + "#, + ) + .unwrap(); + + let result = analyze(&mut ast); + assert!(result.errors.is_empty()); + } +} diff --git a/crates/tx3-lang/src/cardano/ast.rs b/crates/tx3-lang/src/cardano/ast.rs new file mode 100644 index 00000000..5cf45473 --- /dev/null +++ b/crates/tx3-lang/src/cardano/ast.rs @@ -0,0 +1,120 @@ +use serde::{Deserialize, Serialize}; + +use crate::ast::{DataExpr, Identifier, Span}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum WithdrawalField { + From(Box), + Amount(Box), + Redeemer(Box), +} + +impl WithdrawalField { + pub(crate) fn key(&self) -> &str { + match self { + WithdrawalField::From(_) => "from", + WithdrawalField::Amount(_) => "amount", + WithdrawalField::Redeemer(_) => "redeemer", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WithdrawalBlock { + pub fields: Vec, + pub span: Span, +} + +impl WithdrawalBlock { + pub(crate) fn find(&self, key: &str) -> Option<&WithdrawalField> { + self.fields.iter().find(|x| x.key() == key) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VoteDelegationCertificate { + pub drep: DataExpr, + pub stake: DataExpr, + pub span: Span, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StakeDelegationCertificate { + pub pool: DataExpr, + pub stake: DataExpr, + pub span: Span, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum PlutusWitnessField { + Version(DataExpr, Span), + Script(DataExpr, Span), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PlutusWitnessBlock { + pub fields: Vec, + pub span: Span, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum NativeWitnessField { + Script(DataExpr, Span), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NativeWitnessBlock { + pub fields: Vec, + pub span: Span, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TreasuryDonationBlock { + pub coin: DataExpr, + pub span: Span, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum CardanoPublishBlockField { + To(Box), + Amount(Box), + Datum(Box), + Version(Box), + Script(Box), +} + +impl CardanoPublishBlockField { + pub(crate) fn key(&self) -> &str { + match self { + CardanoPublishBlockField::To(_) => "to", + CardanoPublishBlockField::Amount(_) => "amount", + CardanoPublishBlockField::Datum(_) => "datum", + CardanoPublishBlockField::Version(_) => "version", + CardanoPublishBlockField::Script(_) => "script", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CardanoPublishBlock { + pub name: Option, + pub fields: Vec, + pub span: Span, +} + +impl CardanoPublishBlock { + pub(crate) fn find(&self, key: &str) -> Option<&CardanoPublishBlockField> { + self.fields.iter().find(|x| x.key() == key) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum CardanoBlock { + VoteDelegationCertificate(VoteDelegationCertificate), + StakeDelegationCertificate(StakeDelegationCertificate), + Withdrawal(WithdrawalBlock), + PlutusWitness(PlutusWitnessBlock), + NativeWitness(NativeWitnessBlock), + TreasuryDonation(TreasuryDonationBlock), + Publish(CardanoPublishBlock), +} diff --git a/crates/tx3-lang/src/cardano/blueprint.rs b/crates/tx3-lang/src/cardano/blueprint.rs new file mode 100644 index 00000000..d9746221 --- /dev/null +++ b/crates/tx3-lang/src/cardano/blueprint.rs @@ -0,0 +1,247 @@ +use std::collections::HashMap; +use std::collections::HashSet; + +/// Decodes JSON pointer escapes (~1 -> /, ~0 -> ~). +fn decode_json_pointer_escapes(s: &str) -> String { + s.replace("~1", "/").replace("~0", "~") +} + +/// Strips known prefixes (Option, Pairs) and extracts the last segment +pub fn aiken_prettify_name(name: &str) -> String { + let decoded = decode_json_pointer_escapes(name); + + if let Some(inner) = decoded.strip_prefix("Option$") { + let inner_pretty = aiken_prettify_name(inner); + return format!("Option{}", inner_pretty); + } + + if let Some(inner) = decoded.strip_prefix("Option<") { + let inner = inner.strip_suffix(">").unwrap_or(inner); + let inner_pretty = aiken_prettify_name(inner); + return format!("Option{}", inner_pretty); + } + + if let Some(inner) = decoded.strip_prefix("Pairs$") { + let parts: Vec<&str> = inner.split('_').collect(); + let prettified: Vec = parts.iter().map(|p| aiken_prettify_name(p)).collect(); + return format!("Pairs{}", prettified.join("")); + } + + if let Some(inner) = decoded.strip_prefix("Pairs<") { + let inner = inner.strip_suffix(">").unwrap_or(inner); + let parts: Vec<&str> = inner.split(',').collect(); + let prettified: Vec = parts + .iter() + .map(|p| aiken_prettify_name(p.trim())) + .collect(); + return format!("Pairs{}", prettified.join("")); + } + + extract_last_segment(&decoded) +} + +fn extract_last_segment(path: &str) -> String { + path.rsplit('/').next().unwrap_or(path).to_string() +} + +/// Converts a path to upper camel case handling nested generic types like Option and Pairs +pub fn path_to_upper_camel(name: &str) -> String { + let decoded = decode_json_pointer_escapes(name); + + if let Some(inner) = decoded.strip_prefix("Option$") { + let inner_camel = path_to_upper_camel(inner); + return format!("Option{}", inner_camel); + } + + if let Some(inner) = decoded.strip_prefix("Option<") { + let inner = inner.strip_suffix(">").unwrap_or(inner); + let inner_camel = path_to_upper_camel(inner); + return format!("Option{}", inner_camel); + } + + if let Some(inner) = decoded.strip_prefix("Pairs$") { + let parts: Vec<&str> = inner.split('_').collect(); + let prettified: Vec = parts.iter().map(|p| path_to_upper_camel(p)).collect(); + return format!("Pairs{}", prettified.join("")); + } + + if let Some(inner) = decoded.strip_prefix("Pairs<") { + let inner = inner.strip_suffix(">").unwrap_or(inner); + let parts: Vec<&str> = inner.split(',').collect(); + let prettified: Vec = parts + .iter() + .map(|p| path_to_upper_camel(p.trim())) + .collect(); + return format!("Pairs{}", prettified.join("")); + } + + path_segments_to_camel(&decoded) +} + +fn path_segments_to_camel(path: &str) -> String { + path.split('/') + .map(|segment| { + let mut chars = segment.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().to_string() + chars.as_str(), + } + }) + .collect() +} + +pub fn build_aiken_name_mapping(keys: &[&str]) -> HashMap { + // collect simplified names and detect clashes + let mut simple_to_originals: HashMap> = HashMap::new(); + + for &key in keys { + let simple = aiken_prettify_name(key); + simple_to_originals + .entry(simple) + .or_default() + .push(key.to_string()); + } + + let mut result = HashMap::new(); + let mut used_names: HashSet = HashSet::new(); + + for &key in keys { + let simple = aiken_prettify_name(key); + let originals = simple_to_originals.get(&simple).unwrap(); + + let final_name = if originals.len() == 1 { + simple.clone() + } else { + path_to_upper_camel(key) + }; + + // ensure uniqueness + let mut unique_name = final_name.clone(); + let mut counter = 1; + while used_names.contains(&unique_name) { + unique_name = format!("{}{}", final_name, counter); + counter += 1; + } + + used_names.insert(unique_name.clone()); + result.insert(key.to_string(), unique_name); + } + + result +} + +/// Sanitizes a type name to be a valid tx3 identifier +pub fn generic_sanitizer(name: &str) -> String { + name.replace("~1", "_") + .replace('/', "_") + .replace('$', "_") + .replace('<', "_") + .replace('>', "") + .replace(',', "_") + .replace(' ', "") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_aiken_prettify_name_basic() { + assert_eq!(aiken_prettify_name("cardano/address/Address"), "Address"); + assert_eq!(aiken_prettify_name("aiken/crypto/ScriptHash"), "ScriptHash"); + assert_eq!(aiken_prettify_name("types/SettingsDatum"), "SettingsDatum"); + + assert_eq!(aiken_prettify_name("Int"), "Int"); + assert_eq!(aiken_prettify_name("Bool"), "Bool"); + assert_eq!(aiken_prettify_name("ByteArray"), "ByteArray"); + } + + #[test] + fn test_aiken_prettify_name_option_and_pairs() { + assert_eq!( + aiken_prettify_name("Option$cardano/address/Address"), + "OptionAddress" + ); + assert_eq!( + aiken_prettify_name("Option$cardano/address/StakeCredential"), + "OptionStakeCredential" + ); + + assert_eq!( + aiken_prettify_name("Pairs$cardano/assets/AssetName_Int"), + "PairsAssetNameInt" + ); + + assert_eq!( + aiken_prettify_name("Pairs$cardano/assets/PolicyId_Pairs$cardano/assets/AssetName_Int"), + "PairsPolicyIdPairsAssetNameInt" + ); + } + + #[test] + fn test_aiken_prettify_name_url_encoded() { + assert_eq!(aiken_prettify_name("cardano~1address~1Address"), "Address"); + } + + #[test] + fn test_aiken_prettify_name_generics() { + assert_eq!( + aiken_prettify_name("Option"), + "OptionStakeCredential" + ); + + assert_eq!( + aiken_prettify_name("Pairs"), + "PairsAssetNameInt" + ); + assert_eq!( + aiken_prettify_name("Pairs"), + "PairsAssetNameInt" + ); + } + + #[test] + fn test_path_to_upper_camel_generics() { + assert_eq!( + path_to_upper_camel("Option"), + "OptionCardanoAddressStakeCredential" + ); + + assert_eq!( + path_to_upper_camel("Pairs"), + "PairsCardanoAssetsAssetNameInt" + ); + } + + #[test] + fn test_build_aiken_name_mapping_no_clashes() { + let keys = vec![ + "cardano/address/Address", + "cardano/assets/AssetName", + "types/SettingsDatum", + "Int", + ]; + let mapping = build_aiken_name_mapping(&keys); + + assert_eq!(mapping.get("cardano/address/Address").unwrap(), "Address"); + assert_eq!( + mapping.get("cardano/assets/AssetName").unwrap(), + "AssetName" + ); + assert_eq!(mapping.get("types/SettingsDatum").unwrap(), "SettingsDatum"); + assert_eq!(mapping.get("Int").unwrap(), "Int"); + } + + #[test] + fn test_build_aiken_name_mapping_with_clashes() { + // Both cardano/transaction/Datum and types/Datum map into Datum + let keys = vec!["cardano/transaction/Datum", "types/Datum"]; + let mapping = build_aiken_name_mapping(&keys); + + assert_eq!( + mapping.get("cardano/transaction/Datum").unwrap(), + "CardanoTransactionDatum" + ); + assert_eq!(mapping.get("types/Datum").unwrap(), "TypesDatum"); + } +} diff --git a/crates/tx3-lang/src/cardano/lowering.rs b/crates/tx3-lang/src/cardano/lowering.rs new file mode 100644 index 00000000..ad607d5d --- /dev/null +++ b/crates/tx3-lang/src/cardano/lowering.rs @@ -0,0 +1,228 @@ +use std::collections::HashMap; + +use crate::{ir, lowering::IntoLower}; + +use super::ast::*; + +impl IntoLower for WithdrawalField { + type Output = ir::Expression; + + fn into_lower( + &self, + ctx: &crate::lowering::Context, + ) -> Result { + match self { + WithdrawalField::From(x) => x.into_lower(ctx), + WithdrawalField::Amount(x) => x.into_lower(ctx), + WithdrawalField::Redeemer(x) => x.into_lower(ctx), + } + } +} + +impl IntoLower for WithdrawalBlock { + type Output = ir::AdHocDirective; + + fn into_lower( + &self, + ctx: &crate::lowering::Context, + ) -> Result { + let credential = self + .find("from") + .ok_or_else(|| { + crate::lowering::Error::MissingRequiredField("from".to_string(), "WithdrawalBlock") + })? + .into_lower(ctx)?; + + let amount = self + .find("amount") + .ok_or_else(|| { + crate::lowering::Error::MissingRequiredField( + "amount".to_string(), + "WithdrawalBlock", + ) + })? + .into_lower(ctx)?; + + let redeemer = self + .find("redeemer") + .map(|r| r.into_lower(ctx)) + .transpose()? + .unwrap_or(ir::Expression::None); + + Ok(ir::AdHocDirective { + name: "withdrawal".to_string(), + data: std::collections::HashMap::from([ + ("credential".to_string(), credential), + ("amount".to_string(), amount), + ("redeemer".to_string(), redeemer), + ]), + }) + } +} + +impl IntoLower for VoteDelegationCertificate { + type Output = ir::AdHocDirective; + + fn into_lower( + &self, + ctx: &crate::lowering::Context, + ) -> Result { + Ok(ir::AdHocDirective { + name: "vote_delegation_certificate".to_string(), + data: HashMap::from([ + ("drep".to_string(), self.drep.into_lower(ctx)?), + ("stake".to_string(), self.stake.into_lower(ctx)?), + ]), + }) + } +} + +impl IntoLower for StakeDelegationCertificate { + type Output = ir::AdHocDirective; + + fn into_lower( + &self, + _ctx: &crate::lowering::Context, + ) -> Result { + todo!("StakeDelegationCertificate lowering not implemented") + } +} + +impl IntoLower for PlutusWitnessField { + type Output = (String, ir::Expression); + + fn into_lower( + &self, + ctx: &crate::lowering::Context, + ) -> Result { + match self { + PlutusWitnessField::Version(x, _) => Ok(("version".to_string(), x.into_lower(ctx)?)), + PlutusWitnessField::Script(x, _) => Ok(("script".to_string(), x.into_lower(ctx)?)), + } + } +} + +impl IntoLower for PlutusWitnessBlock { + type Output = ir::AdHocDirective; + + fn into_lower( + &self, + ctx: &crate::lowering::Context, + ) -> Result { + let data = self + .fields + .iter() + .map(|x| x.into_lower(ctx)) + .collect::>()?; + + Ok(ir::AdHocDirective { + name: "plutus_witness".to_string(), + data, + }) + } +} + +impl IntoLower for NativeWitnessField { + type Output = (String, ir::Expression); + + fn into_lower( + &self, + ctx: &crate::lowering::Context, + ) -> Result { + match self { + NativeWitnessField::Script(x, _) => Ok(("script".to_string(), x.into_lower(ctx)?)), + } + } +} + +impl IntoLower for NativeWitnessBlock { + type Output = ir::AdHocDirective; + + fn into_lower( + &self, + ctx: &crate::lowering::Context, + ) -> Result { + let data = self + .fields + .iter() + .map(|x| x.into_lower(ctx)) + .collect::>()?; + + Ok(ir::AdHocDirective { + name: "native_witness".to_string(), + data, + }) + } +} + +impl IntoLower for TreasuryDonationBlock { + type Output = ir::AdHocDirective; + + fn into_lower( + &self, + ctx: &crate::lowering::Context, + ) -> Result { + let coin = self.coin.into_lower(ctx)?; + + Ok(ir::AdHocDirective { + name: "treasury_donation".to_string(), + data: std::collections::HashMap::from([("coin".to_string(), coin)]), + }) + } +} + +impl IntoLower for CardanoPublishBlockField { + type Output = (String, ir::Expression); + + fn into_lower( + &self, + ctx: &crate::lowering::Context, + ) -> Result { + match self { + CardanoPublishBlockField::To(x) => Ok(("to".to_string(), x.into_lower(ctx)?)), + CardanoPublishBlockField::Amount(x) => Ok(("amount".to_string(), x.into_lower(ctx)?)), + CardanoPublishBlockField::Datum(x) => Ok(("datum".to_string(), x.into_lower(ctx)?)), + CardanoPublishBlockField::Version(x) => Ok(("version".to_string(), x.into_lower(ctx)?)), + CardanoPublishBlockField::Script(x) => Ok(("script".to_string(), x.into_lower(ctx)?)), + } + } +} + +impl IntoLower for CardanoPublishBlock { + type Output = ir::AdHocDirective; + + fn into_lower( + &self, + ctx: &crate::lowering::Context, + ) -> Result { + let data = self + .fields + .iter() + .map(|x| x.into_lower(ctx)) + .collect::>()?; + + Ok(ir::AdHocDirective { + name: "cardano_publish".to_string(), + data, + }) + } +} + +impl IntoLower for CardanoBlock { + type Output = ir::AdHocDirective; + + fn into_lower( + &self, + ctx: &crate::lowering::Context, + ) -> Result<::Output, crate::lowering::Error> { + match self { + CardanoBlock::VoteDelegationCertificate(x) => x.into_lower(ctx), + CardanoBlock::StakeDelegationCertificate(x) => x.into_lower(ctx), + CardanoBlock::Withdrawal(x) => x.into_lower(ctx), + CardanoBlock::PlutusWitness(x) => x.into_lower(ctx), + CardanoBlock::NativeWitness(x) => x.into_lower(ctx), + CardanoBlock::TreasuryDonation(x) => x.into_lower(ctx), + CardanoBlock::Publish(x) => x.into_lower(ctx), + } + } +} diff --git a/crates/tx3-lang/src/cardano/mod.rs b/crates/tx3-lang/src/cardano/mod.rs new file mode 100644 index 00000000..5d2da74e --- /dev/null +++ b/crates/tx3-lang/src/cardano/mod.rs @@ -0,0 +1,8 @@ +pub mod analyzing; +pub mod ast; +pub mod blueprint; +pub mod lowering; +pub mod parsing; + +pub use ast::*; +pub use parsing::load_externals; diff --git a/crates/tx3-lang/src/cardano.rs b/crates/tx3-lang/src/cardano/parsing.rs similarity index 51% rename from crates/tx3-lang/src/cardano.rs rename to crates/tx3-lang/src/cardano/parsing.rs index cd813e65..34219fe2 100644 --- a/crates/tx3-lang/src/cardano.rs +++ b/crates/tx3-lang/src/cardano/parsing.rs @@ -1,44 +1,13 @@ -use std::{collections::HashMap, fs, rc::Rc}; +use std::{collections::HashMap, fs}; use pest::iterators::Pair; -use serde::{Deserialize, Serialize}; use crate::{ - analyzing::{Analyzable, AnalyzeReport}, - ast::{DataExpr, Identifier, RecordField, Scope, Span, Symbol, Type, TypeDef, VariantCase}, - ir, - lowering::IntoLower, + ast::{DataExpr, Identifier, RecordField, Span, Symbol, Type, TypeDef, VariantCase}, parsing::{AstNode, Error, Rule}, }; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum WithdrawalField { - From(Box), - Amount(Box), - Redeemer(Box), -} - -impl WithdrawalField { - fn key(&self) -> &str { - match self { - WithdrawalField::From(_) => "from", - WithdrawalField::Amount(_) => "amount", - WithdrawalField::Redeemer(_) => "redeemer", - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct WithdrawalBlock { - pub fields: Vec, - pub span: Span, -} - -impl WithdrawalBlock { - pub(crate) fn find(&self, key: &str) -> Option<&WithdrawalField> { - self.fields.iter().find(|x| x.key() == key) - } -} +use super::{ast::*, blueprint}; impl AstNode for WithdrawalField { const RULE: Rule = Rule::cardano_withdrawal_field; @@ -89,101 +58,6 @@ impl AstNode for WithdrawalBlock { } } -impl Analyzable for WithdrawalField { - fn analyze(&mut self, parent: Option>) -> AnalyzeReport { - match self { - WithdrawalField::From(x) => x.analyze(parent), - WithdrawalField::Amount(x) => { - let amount = x.analyze(parent.clone()); - let amount_type = AnalyzeReport::expect_data_expr_type(x, &Type::Int); - amount + amount_type - } - WithdrawalField::Redeemer(x) => x.analyze(parent), - } - } - - fn is_resolved(&self) -> bool { - match self { - WithdrawalField::From(x) => x.is_resolved(), - WithdrawalField::Amount(x) => x.is_resolved(), - WithdrawalField::Redeemer(x) => x.is_resolved(), - } - } -} - -impl Analyzable for WithdrawalBlock { - fn analyze(&mut self, parent: Option>) -> AnalyzeReport { - self.fields.analyze(parent) - } - - fn is_resolved(&self) -> bool { - self.fields.is_resolved() - } -} - -impl IntoLower for WithdrawalField { - type Output = ir::Expression; - - fn into_lower( - &self, - ctx: &crate::lowering::Context, - ) -> Result { - match self { - WithdrawalField::From(x) => x.into_lower(ctx), - WithdrawalField::Amount(x) => x.into_lower(ctx), - WithdrawalField::Redeemer(x) => x.into_lower(ctx), - } - } -} - -impl IntoLower for WithdrawalBlock { - type Output = ir::AdHocDirective; - - fn into_lower( - &self, - ctx: &crate::lowering::Context, - ) -> Result { - let credential = self - .find("from") - .ok_or_else(|| { - crate::lowering::Error::MissingRequiredField("from".to_string(), "WithdrawalBlock") - })? - .into_lower(ctx)?; - - let amount = self - .find("amount") - .ok_or_else(|| { - crate::lowering::Error::MissingRequiredField( - "amount".to_string(), - "WithdrawalBlock", - ) - })? - .into_lower(ctx)?; - - let redeemer = self - .find("redeemer") - .map(|r| r.into_lower(ctx)) - .transpose()? - .unwrap_or(ir::Expression::None); - - Ok(ir::AdHocDirective { - name: "withdrawal".to_string(), - data: std::collections::HashMap::from([ - ("credential".to_string(), credential), - ("amount".to_string(), amount), - ("redeemer".to_string(), redeemer), - ]), - }) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct VoteDelegationCertificate { - pub drep: DataExpr, - pub stake: DataExpr, - pub span: Span, -} - impl AstNode for VoteDelegationCertificate { const RULE: Rule = Rule::cardano_vote_delegation_certificate; @@ -203,43 +77,6 @@ impl AstNode for VoteDelegationCertificate { } } -impl Analyzable for VoteDelegationCertificate { - fn analyze(&mut self, parent: Option>) -> AnalyzeReport { - let drep = self.drep.analyze(parent.clone()); - let stake = self.stake.analyze(parent.clone()); - - drep + stake - } - - fn is_resolved(&self) -> bool { - self.drep.is_resolved() && self.stake.is_resolved() - } -} - -impl IntoLower for VoteDelegationCertificate { - type Output = ir::AdHocDirective; - - fn into_lower( - &self, - ctx: &crate::lowering::Context, - ) -> Result { - Ok(ir::AdHocDirective { - name: "vote_delegation_certificate".to_string(), - data: HashMap::from([ - ("drep".to_string(), self.drep.into_lower(ctx)?), - ("stake".to_string(), self.stake.into_lower(ctx)?), - ]), - }) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct StakeDelegationCertificate { - pub pool: DataExpr, - pub stake: DataExpr, - pub span: Span, -} - impl AstNode for StakeDelegationCertificate { const RULE: Rule = Rule::cardano_stake_delegation_certificate; @@ -259,50 +96,6 @@ impl AstNode for StakeDelegationCertificate { } } -impl Analyzable for StakeDelegationCertificate { - fn analyze(&mut self, parent: Option>) -> AnalyzeReport { - let pool = self.pool.analyze(parent.clone()); - let stake = self.stake.analyze(parent.clone()); - - pool + stake - } - - fn is_resolved(&self) -> bool { - self.pool.is_resolved() && self.stake.is_resolved() - } -} - -impl IntoLower for StakeDelegationCertificate { - type Output = ir::AdHocDirective; - - fn into_lower( - &self, - _ctx: &crate::lowering::Context, - ) -> Result { - todo!("StakeDelegationCertificate lowering not implemented") - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum PlutusWitnessField { - Version(DataExpr, Span), - Script(DataExpr, Span), -} - -impl IntoLower for PlutusWitnessField { - type Output = (String, ir::Expression); - - fn into_lower( - &self, - ctx: &crate::lowering::Context, - ) -> Result { - match self { - PlutusWitnessField::Version(x, _) => Ok(("version".to_string(), x.into_lower(ctx)?)), - PlutusWitnessField::Script(x, _) => Ok(("script".to_string(), x.into_lower(ctx)?)), - } - } -} - impl AstNode for PlutusWitnessField { const RULE: Rule = Rule::cardano_plutus_witness_field; @@ -328,28 +121,6 @@ impl AstNode for PlutusWitnessField { } } -impl Analyzable for PlutusWitnessField { - fn analyze(&mut self, parent: Option>) -> AnalyzeReport { - match self { - PlutusWitnessField::Version(x, _) => x.analyze(parent), - PlutusWitnessField::Script(x, _) => x.analyze(parent), - } - } - - fn is_resolved(&self) -> bool { - match self { - PlutusWitnessField::Version(x, _) => x.is_resolved(), - PlutusWitnessField::Script(x, _) => x.is_resolved(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct PlutusWitnessBlock { - pub fields: Vec, - pub span: Span, -} - impl AstNode for PlutusWitnessBlock { const RULE: Rule = Rule::cardano_plutus_witness_block; @@ -369,54 +140,6 @@ impl AstNode for PlutusWitnessBlock { } } -impl Analyzable for PlutusWitnessBlock { - fn analyze(&mut self, parent: Option>) -> AnalyzeReport { - self.fields.analyze(parent) - } - - fn is_resolved(&self) -> bool { - self.fields.is_resolved() - } -} - -impl IntoLower for PlutusWitnessBlock { - type Output = ir::AdHocDirective; - - fn into_lower( - &self, - ctx: &crate::lowering::Context, - ) -> Result { - let data = self - .fields - .iter() - .map(|x| x.into_lower(ctx)) - .collect::>()?; - - Ok(ir::AdHocDirective { - name: "plutus_witness".to_string(), - data, - }) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum NativeWitnessField { - Script(DataExpr, Span), -} - -impl IntoLower for NativeWitnessField { - type Output = (String, ir::Expression); - - fn into_lower( - &self, - ctx: &crate::lowering::Context, - ) -> Result { - match self { - NativeWitnessField::Script(x, _) => Ok(("script".to_string(), x.into_lower(ctx)?)), - } - } -} - impl AstNode for NativeWitnessField { const RULE: Rule = Rule::cardano_native_witness_field; @@ -438,26 +161,6 @@ impl AstNode for NativeWitnessField { } } -impl Analyzable for NativeWitnessField { - fn analyze(&mut self, parent: Option>) -> AnalyzeReport { - match self { - NativeWitnessField::Script(x, _) => x.analyze(parent), - } - } - - fn is_resolved(&self) -> bool { - match self { - NativeWitnessField::Script(x, _) => x.is_resolved(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct NativeWitnessBlock { - pub fields: Vec, - pub span: Span, -} - impl AstNode for NativeWitnessBlock { const RULE: Rule = Rule::cardano_native_witness_block; @@ -477,42 +180,6 @@ impl AstNode for NativeWitnessBlock { } } -impl Analyzable for NativeWitnessBlock { - fn analyze(&mut self, parent: Option>) -> AnalyzeReport { - self.fields.analyze(parent) - } - - fn is_resolved(&self) -> bool { - self.fields.is_resolved() - } -} - -impl IntoLower for NativeWitnessBlock { - type Output = ir::AdHocDirective; - - fn into_lower( - &self, - ctx: &crate::lowering::Context, - ) -> Result { - let data = self - .fields - .iter() - .map(|x| x.into_lower(ctx)) - .collect::>()?; - - Ok(ir::AdHocDirective { - name: "native_witness".to_string(), - data, - }) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct TreasuryDonationBlock { - pub coin: DataExpr, - pub span: Span, -} - impl AstNode for TreasuryDonationBlock { const RULE: Rule = Rule::cardano_treasury_donation_block; @@ -530,69 +197,6 @@ impl AstNode for TreasuryDonationBlock { } } -impl Analyzable for TreasuryDonationBlock { - fn analyze(&mut self, parent: Option>) -> AnalyzeReport { - let coin = self.coin.analyze(parent); - let coin_type = AnalyzeReport::expect_data_expr_type(&self.coin, &Type::Int); - - coin + coin_type - } - - fn is_resolved(&self) -> bool { - self.coin.is_resolved() - } -} - -impl IntoLower for TreasuryDonationBlock { - type Output = ir::AdHocDirective; - - fn into_lower( - &self, - ctx: &crate::lowering::Context, - ) -> Result { - let coin = self.coin.into_lower(ctx)?; - - Ok(ir::AdHocDirective { - name: "treasury_donation".to_string(), - data: std::collections::HashMap::from([("coin".to_string(), coin)]), - }) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum CardanoPublishBlockField { - To(Box), - Amount(Box), - Datum(Box), - Version(Box), - Script(Box), -} - -impl CardanoPublishBlockField { - fn key(&self) -> &str { - match self { - CardanoPublishBlockField::To(_) => "to", - CardanoPublishBlockField::Amount(_) => "amount", - CardanoPublishBlockField::Datum(_) => "datum", - CardanoPublishBlockField::Version(_) => "version", - CardanoPublishBlockField::Script(_) => "script", - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CardanoPublishBlock { - pub name: Option, - pub fields: Vec, - pub span: Span, -} - -impl CardanoPublishBlock { - pub(crate) fn find(&self, key: &str) -> Option<&CardanoPublishBlockField> { - self.fields.iter().find(|x| x.key() == key) - } -} - impl AstNode for CardanoPublishBlockField { const RULE: Rule = Rule::cardano_publish_block_field; @@ -670,86 +274,6 @@ impl AstNode for CardanoPublishBlock { } } -impl Analyzable for CardanoPublishBlockField { - fn analyze(&mut self, parent: Option>) -> AnalyzeReport { - match self { - CardanoPublishBlockField::To(x) => x.analyze(parent), - CardanoPublishBlockField::Amount(x) => x.analyze(parent), - CardanoPublishBlockField::Datum(x) => x.analyze(parent), - CardanoPublishBlockField::Version(x) => x.analyze(parent), - CardanoPublishBlockField::Script(x) => x.analyze(parent), - } - } - - fn is_resolved(&self) -> bool { - match self { - CardanoPublishBlockField::To(x) => x.is_resolved(), - CardanoPublishBlockField::Amount(x) => x.is_resolved(), - CardanoPublishBlockField::Datum(x) => x.is_resolved(), - CardanoPublishBlockField::Version(x) => x.is_resolved(), - CardanoPublishBlockField::Script(x) => x.is_resolved(), - } - } -} - -impl Analyzable for CardanoPublishBlock { - fn analyze(&mut self, parent: Option>) -> AnalyzeReport { - self.fields.analyze(parent) - } - - fn is_resolved(&self) -> bool { - self.fields.is_resolved() - } -} - -impl IntoLower for CardanoPublishBlockField { - type Output = (String, ir::Expression); - - fn into_lower( - &self, - ctx: &crate::lowering::Context, - ) -> Result { - match self { - CardanoPublishBlockField::To(x) => Ok(("to".to_string(), x.into_lower(ctx)?)), - CardanoPublishBlockField::Amount(x) => Ok(("amount".to_string(), x.into_lower(ctx)?)), - CardanoPublishBlockField::Datum(x) => Ok(("datum".to_string(), x.into_lower(ctx)?)), - CardanoPublishBlockField::Version(x) => Ok(("version".to_string(), x.into_lower(ctx)?)), - CardanoPublishBlockField::Script(x) => Ok(("script".to_string(), x.into_lower(ctx)?)), - } - } -} - -impl IntoLower for CardanoPublishBlock { - type Output = ir::AdHocDirective; - - fn into_lower( - &self, - ctx: &crate::lowering::Context, - ) -> Result { - let data = self - .fields - .iter() - .map(|x| x.into_lower(ctx)) - .collect::>()?; - - Ok(ir::AdHocDirective { - name: "cardano_publish".to_string(), - data, - }) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum CardanoBlock { - VoteDelegationCertificate(VoteDelegationCertificate), - StakeDelegationCertificate(StakeDelegationCertificate), - Withdrawal(WithdrawalBlock), - PlutusWitness(PlutusWitnessBlock), - NativeWitness(NativeWitnessBlock), - TreasuryDonation(TreasuryDonationBlock), - Publish(CardanoPublishBlock), -} - impl AstNode for CardanoBlock { const RULE: Rule = Rule::cardano_block; @@ -796,95 +320,56 @@ impl AstNode for CardanoBlock { } } -impl Analyzable for CardanoBlock { - fn analyze(&mut self, parent: Option>) -> AnalyzeReport { - match self { - CardanoBlock::VoteDelegationCertificate(x) => x.analyze(parent), - CardanoBlock::StakeDelegationCertificate(x) => x.analyze(parent), - CardanoBlock::Withdrawal(x) => x.analyze(parent), - CardanoBlock::PlutusWitness(x) => x.analyze(parent), - CardanoBlock::NativeWitness(x) => x.analyze(parent), - CardanoBlock::TreasuryDonation(x) => x.analyze(parent), - CardanoBlock::Publish(x) => x.analyze(parent), - } - } - - fn is_resolved(&self) -> bool { - match self { - CardanoBlock::VoteDelegationCertificate(x) => x.is_resolved(), - CardanoBlock::StakeDelegationCertificate(x) => x.is_resolved(), - CardanoBlock::Withdrawal(x) => x.is_resolved(), - CardanoBlock::PlutusWitness(x) => x.is_resolved(), - CardanoBlock::NativeWitness(x) => x.is_resolved(), - Self::TreasuryDonation(x) => x.is_resolved(), - CardanoBlock::Publish(x) => x.is_resolved(), - } - } -} - -impl IntoLower for CardanoBlock { - type Output = ir::AdHocDirective; - - fn into_lower( - &self, - ctx: &crate::lowering::Context, - ) -> Result<::Output, crate::lowering::Error> { - match self { - CardanoBlock::VoteDelegationCertificate(x) => x.into_lower(ctx), - CardanoBlock::StakeDelegationCertificate(x) => x.into_lower(ctx), - CardanoBlock::Withdrawal(x) => x.into_lower(ctx), - CardanoBlock::PlutusWitness(x) => x.into_lower(ctx), - CardanoBlock::NativeWitness(x) => x.into_lower(ctx), - CardanoBlock::TreasuryDonation(x) => x.into_lower(ctx), - CardanoBlock::Publish(x) => x.into_lower(ctx), - } - } -} - -/// Sanitizes a type name to be a valid tx3 identifier. -/// Replaces characters like `<`, `>`, `,`, `/`, `$`, `~1` with underscores. -fn generic_sanitizer(name: &str) -> String { - name.replace("~1", "_") // URL-encoded `/` in JSON references - .replace('/', "_") - .replace('$', "_") - .replace('<', "_") - .replace('>', "") - .replace(',', "_") - .replace(' ', "") -} - -pub enum LoadKind { - Cip57, -} - -pub enum Compiler { - Aiken, - Unknown, -} - -pub struct ExternalLoader { - pub path: String, - pub kind: LoadKind, - pub compiler: Compiler, -} - pub fn load_externals( path: &str, ) -> Result, crate::parsing::Error> { let json = fs::read_to_string(path).map_err(|e| crate::parsing::Error { message: format!("Failed to read import file: {}", e), - src: "".to_string(), // TODO: propagate source? + src: path.to_string(), span: crate::ast::Span::DUMMY, })?; let bp = serde_json::from_str::(&json).map_err(|e| crate::parsing::Error { message: format!("Failed to parse blueprint JSON: {}", e), - src: "".to_string(), // TODO: should I add path here? + src: "".to_string(), span: crate::ast::Span::DUMMY, })?; + let is_aiken = bp + .preamble + .compiler + .as_ref() + .is_some_and(|c| c.name.to_lowercase() == "aiken"); + + let name_mapping: HashMap = if is_aiken { + let keys: Vec<&str> = bp + .definitions + .as_ref() + .map(|d| d.inner.keys().map(|s| s.as_str()).collect()) + .unwrap_or_default(); + blueprint::build_aiken_name_mapping(&keys) + } else { + bp.definitions + .as_ref() + .map(|d| { + d.inner + .keys() + .map(|k| (k.clone(), blueprint::generic_sanitizer(k))) + .collect() + }) + .unwrap_or_default() + }; + let ref_to_type = |r: &str| -> Type { - let sanitized = generic_sanitizer(r.strip_prefix("#/definitions/").unwrap_or(r)); + let key = r.strip_prefix("#/definitions/").unwrap_or(r); + let decoded_key = key.replace("~1", "/"); + let sanitized = name_mapping.get(&decoded_key).cloned().unwrap_or_else(|| { + if is_aiken { + blueprint::aiken_prettify_name(key) + } else { + blueprint::generic_sanitizer(key) + } + }); Type::Custom(Identifier::new(&sanitized)) }; @@ -896,7 +381,13 @@ pub fn load_externals( .into_iter() .flatten() { - let name = generic_sanitizer(key); + let name = name_mapping.get(key).cloned().unwrap_or_else(|| { + if is_aiken { + blueprint::aiken_prettify_name(key) + } else { + blueprint::generic_sanitizer(key) + } + }); let new = match def.data_type { Some(cip_57::DataType::Integer) => Some(Symbol::AliasDef(Box::new( @@ -941,11 +432,17 @@ pub fn load_externals( Some(cip_57::DataType::Constructor) => { let mut cases = vec![]; if let Some(any_of) = &def.any_of { + let single = any_of.len() == 1; for schema in any_of { - let case_name = schema + let original_case_name = schema .title .clone() .unwrap_or_else(|| format!("Constructor{}", schema.index)); + let case_name = if single && original_case_name == name { + "Default".to_string() + } else { + original_case_name + }; let mut fields = vec![]; for (i, field) in schema.fields.iter().enumerate() { let field_name = field @@ -971,11 +468,17 @@ pub fn load_externals( None if def.any_of.is_some() => { let mut cases = vec![]; if let Some(any_of) = &def.any_of { + let single = any_of.len() == 1; for schema in any_of { - let case_name = schema + let original_case_name = schema .title .clone() .unwrap_or_else(|| format!("Constructor{}", schema.index)); + let case_name = if single && original_case_name == name { + "Default".to_string() + } else { + original_case_name + }; let mut fields = vec![]; for (i, field) in schema.fields.iter().enumerate() { let field_name = field @@ -1010,7 +513,7 @@ pub fn load_externals( #[cfg(test)] mod tests { use super::*; - use crate::{analyzing::analyze, ast::*}; + use crate::ast::*; use pest::Parser; macro_rules! input_to_ast_check { @@ -1137,80 +640,86 @@ mod tests { ); #[test] - fn test_treasury_donation_type() { - let mut ast = crate::parsing::parse_string( - r#" - tx test(quantity: Int) { - cardano::treasury_donation { - coin: quantity, + fn test_single_constructor_alias() { + let json = r##"{ + "preamble": { + "title": "Test", + "description": "Test", + "version": "1.0.0", + "plutusVersion": "v2", + "compiler": { + "name": "Aiken", + "version": "1.0.0" + }, + "license": "Apache-2.0" + }, + "validators": [], + "definitions": { + "ticketer/types/TicketerDatum": { + "title": "TicketerDatum", + "anyOf": [ + { + "title": "TicketerDatum", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "ticket_counter", + "$ref": "#/definitions/Int" + } + ] + } + ] + }, + "ticketer/types/TicketerRedeemer": { + "title": "TicketerRedeemer", + "anyOf": [ + { + "title": "BuyTicket", + "dataType": "constructor", + "index": 0, + "fields": [] + } + ] + }, + "Int": { + "dataType": "integer" } } - "#, - ) - .unwrap(); + }"##; - let result = analyze(&mut ast); - assert!(result.errors.is_empty()); - } + use std::io::Write; - #[test] - fn test_treasury_donation_type_not_ok() { - let mut ast = crate::parsing::parse_string( - r#" - tx test(quantity: Bytes) { - cardano::treasury_donation { - coin: quantity, - } - } - "#, - ) - .unwrap(); + let mut path = std::env::temp_dir(); + path.push(format!("tx3_test_{}.json", std::process::id())); - let result = analyze(&mut ast); - assert!(!result.errors.is_empty()); - } + { + let mut file = std::fs::File::create(&path).unwrap(); + file.write_all(json.as_bytes()).unwrap(); + } - #[test] - fn test_publish_type_ok() { - let mut ast = crate::parsing::parse_string( - r#" - party Receiver; - - tx test(quantity: Int) { - cardano::publish { - to: Receiver, - amount: Ada(quantity), - version: 3, - script: 0xABCDEF, - } - } - "#, - ) - .unwrap(); + let symbols = load_externals(path.to_str().unwrap()).unwrap(); - let result = analyze(&mut ast); - assert!(result.errors.is_empty()); - } + // Cleanup + let _ = std::fs::remove_file(&path); - #[test] - fn test_publish_type_with_name_ok() { - let mut ast = crate::parsing::parse_string( - r#" - party Receiver; - - tx test(quantity: Int) { - cardano::publish deploy { - to: Receiver, - amount: Ada(quantity), - version: 3, - script: 0xABCDEF, - } - } - "#, - ) - .unwrap(); + let datum = symbols.get("TicketerDatum").unwrap(); + + if let Symbol::TypeDef(def) = datum { + assert_eq!(def.cases.len(), 1); + // Matches type name -> Default + assert_eq!(def.cases[0].name.value, "Default"); + } else { + panic!("Expected TypeDef for TicketerDatum, got {:?}", datum); + } - let result = analyze(&mut ast); - assert!(result.errors.is_empty()); + let redeemer = symbols.get("TicketerRedeemer").unwrap(); + if let Symbol::TypeDef(def) = redeemer { + assert_eq!(def.cases.len(), 1); + // Does NOT match type name -> Keep original name + assert_eq!(def.cases[0].name.value, "BuyTicket"); + } else { + panic!("Expected TypeDef for TicketerRedeemer, got {:?}", redeemer); + } } } diff --git a/crates/tx3-lang/src/loading.rs b/crates/tx3-lang/src/loading.rs index ab81f7b6..e282eba3 100644 --- a/crates/tx3-lang/src/loading.rs +++ b/crates/tx3-lang/src/loading.rs @@ -79,6 +79,7 @@ fn process_imports(program: &mut ast::Program, base_path: &Path) -> Result<(), E parent: None, })); } + dbg!(&program.scope.clone().unwrap().symbols.clone().into_keys()); } Ok(()) } @@ -222,7 +223,7 @@ pub mod tests { let scope = program.scope.as_ref().unwrap(); assert!(scope.symbols.contains_key("Int")); - assert!(scope.symbols.contains_key("cardano_assets_AssetName")); - assert!(scope.symbols.contains_key("cardano_transaction_Datum")); + assert!(scope.symbols.contains_key("AssetName")); + assert!(scope.symbols.contains_key("Datum")); } } diff --git a/crates/tx3-lang/src/parsing.rs b/crates/tx3-lang/src/parsing.rs index 819f8bbb..48d15293 100644 --- a/crates/tx3-lang/src/parsing.rs +++ b/crates/tx3-lang/src/parsing.rs @@ -12,7 +12,7 @@ use pest::{ }; use pest_derive::Parser; -use crate::{ast::*, cardano::load_externals}; +use crate::ast::*; #[derive(Parser)] #[grammar = "tx3.pest"] pub(crate) struct Tx3Grammar; @@ -85,7 +85,7 @@ impl AstNode for Import { let import_rule = inner.next().unwrap(); match import_rule.as_rule() { - Rule::cip57_import => { + Rule::blueprint_import => { let path = import_rule .into_inner() .as_str() @@ -94,7 +94,7 @@ impl AstNode for Import { Ok(Import { span, path, - kind: ImportKind::Cip57, + kind: ImportKind::Blueprint, }) } Rule::tx3_import => todo!(), diff --git a/crates/tx3-lang/src/tx3.pest b/crates/tx3-lang/src/tx3.pest index 3cccb5b4..4bb62d6e 100644 --- a/crates/tx3-lang/src/tx3.pest +++ b/crates/tx3-lang/src/tx3.pest @@ -462,8 +462,8 @@ tx_def = { "tx" ~ identifier ~ parameter_list ~ "{" ~ tx_body_block* ~ "}" } -cip57_import = { - "cip57::import" ~ string ~ ";" +blueprint_import = { + "blueprint::import" ~ string ~ ";" } tx3_import = { @@ -471,7 +471,7 @@ tx3_import = { } import = { - cip57_import | + blueprint_import | tx3_import } diff --git a/examples/cip_imports.tx3 b/examples/cip_imports.tx3 index b8d96d75..eedc3608 100644 --- a/examples/cip_imports.tx3 +++ b/examples/cip_imports.tx3 @@ -1,4 +1,4 @@ -cip57::import "imports/plutus.json"; +blueprint::import "imports/plutus.json"; party Sender; @@ -20,12 +20,12 @@ tx transfer_with_imports( output { to: Sender, amount: source - Ada(quantity) - fees, - datum: types_SettingsDatum::SettingsDatum { - githoney_address: cardano_address_Address::Address{ - payment_credential: cardano_address_PaymentCredential::VerificationKey { + datum: SettingsDatum::SettingsDatum { + githoney_address: Address::Address{ + payment_credential: PaymentCredential::VerificationKey { field_0: 0x123123, }, - stake_credential: Option_cardano_address_StakeCredential::None {}, + stake_credential: OptionStakeCredential::None {}, }, bounty_creation_fee: 0, bounty_reward_fee: 0, From 227632d60b76c17e152282134412e6f95c2b15ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Wed, 17 Dec 2025 16:47:09 -0300 Subject: [PATCH 12/32] feat: Sanitize generic type names in external blueprint definitions and add corresponding tests. --- crates/tx3-lang/src/cardano/blueprint.rs | 11 +++++++ crates/tx3-lang/src/cardano/parsing.rs | 39 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/crates/tx3-lang/src/cardano/blueprint.rs b/crates/tx3-lang/src/cardano/blueprint.rs index d9746221..c97d9f62 100644 --- a/crates/tx3-lang/src/cardano/blueprint.rs +++ b/crates/tx3-lang/src/cardano/blueprint.rs @@ -244,4 +244,15 @@ mod tests { ); assert_eq!(mapping.get("types/Datum").unwrap(), "TypesDatum"); } + #[test] + fn test_generic_sanitizer() { + assert_eq!(generic_sanitizer("simple"), "simple"); + assert_eq!(generic_sanitizer("path/to/something"), "path_to_something"); + assert_eq!(generic_sanitizer("Option"), "Option_Int"); + assert_eq!(generic_sanitizer("Map"), "Map_K_V"); + assert_eq!( + generic_sanitizer("some ~1 weird ~0 thing"), + "some_weird~0thing" + ); + } } diff --git a/crates/tx3-lang/src/cardano/parsing.rs b/crates/tx3-lang/src/cardano/parsing.rs index 34219fe2..95fb28aa 100644 --- a/crates/tx3-lang/src/cardano/parsing.rs +++ b/crates/tx3-lang/src/cardano/parsing.rs @@ -722,4 +722,43 @@ mod tests { panic!("Expected TypeDef for TicketerRedeemer, got {:?}", redeemer); } } + #[test] + fn test_load_externals_generic_compiler() { + let json = r##"{ + "preamble": { + "title": "Test", + "description": "Test", + "version": "1.0.0", + "plutusVersion": "v1", + "compiler": { + "name": "Test", + "version": "0.0.0" + }, + "license": "MIT" + }, + "validators": [], + "definitions": { + "Option": { + "dataType": "integer" + } + } + }"##; + + use std::io::Write; + + let mut path = std::env::temp_dir(); + path.push(format!("tx3_test_generic_{}.json", std::process::id())); + + { + let mut file = std::fs::File::create(&path).unwrap(); + file.write_all(json.as_bytes()).unwrap(); + } + + let symbols = load_externals(path.to_str().unwrap()).unwrap(); + + let _ = std::fs::remove_file(&path); + + assert!(symbols.contains_key("Option_Int")); + assert!(!symbols.contains_key("OptionInt")); + } } From f96c768b1c0645a17bb050bae1e659ca8715bc5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Wed, 17 Dec 2025 17:08:44 -0300 Subject: [PATCH 13/32] feat: remove unnecessary log --- crates/tx3-lang/src/cardano/blueprint.rs | 4 ++++ crates/tx3-lang/src/loading.rs | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/tx3-lang/src/cardano/blueprint.rs b/crates/tx3-lang/src/cardano/blueprint.rs index c97d9f62..3aed3596 100644 --- a/crates/tx3-lang/src/cardano/blueprint.rs +++ b/crates/tx3-lang/src/cardano/blueprint.rs @@ -254,5 +254,9 @@ mod tests { generic_sanitizer("some ~1 weird ~0 thing"), "some_weird~0thing" ); + assert_eq!( + generic_sanitizer("Result, String>"), + "Result_Option_Int_String" + ); } } diff --git a/crates/tx3-lang/src/loading.rs b/crates/tx3-lang/src/loading.rs index e282eba3..77b6fd0e 100644 --- a/crates/tx3-lang/src/loading.rs +++ b/crates/tx3-lang/src/loading.rs @@ -79,7 +79,6 @@ fn process_imports(program: &mut ast::Program, base_path: &Path) -> Result<(), E parent: None, })); } - dbg!(&program.scope.clone().unwrap().symbols.clone().into_keys()); } Ok(()) } From 82b199b4536e2d80fd5b8cc3587c73b3c1299c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Thu, 5 Feb 2026 23:52:15 -0300 Subject: [PATCH 14/32] feat: first draft at aiken imports --- Cargo.lock | 1 + crates/tx3-lang/Cargo.toml | 2 + crates/tx3-lang/src/ast.rs | 9 + crates/tx3-lang/src/facade.rs | 17 +- crates/tx3-lang/src/interop.rs | 255 ++++++++++++++++++ crates/tx3-lang/src/lib.rs | 1 + crates/tx3-lang/src/parsing.rs | 23 ++ crates/tx3-lang/src/tx3.pest | 6 +- .../tx3-lang/tests/fixtures/import_test.tx3 | 6 + crates/tx3-lang/tests/imports.rs | 80 ++++++ plutus_import_plan.md | 74 +++++ 11 files changed, 470 insertions(+), 4 deletions(-) create mode 100644 crates/tx3-lang/src/interop.rs create mode 100644 crates/tx3-lang/tests/fixtures/import_test.tx3 create mode 100644 crates/tx3-lang/tests/imports.rs create mode 100644 plutus_import_plan.md diff --git a/Cargo.lock b/Cargo.lock index c46551a3..aa1d25e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2358,6 +2358,7 @@ version = "0.14.3" dependencies = [ "assert-json-diff", "ciborium", + "cip-57", "hex", "miette", "paste", diff --git a/crates/tx3-lang/Cargo.toml b/crates/tx3-lang/Cargo.toml index 7aa4f807..b5d423dd 100644 --- a/crates/tx3-lang/Cargo.toml +++ b/crates/tx3-lang/Cargo.toml @@ -19,6 +19,8 @@ hex = { workspace = true } serde = { workspace = true } tx3-tir = { version = "0.14.3", path = "../tx3-tir" } +cip-57 = { path = "../cip-57" } +serde_json = "1.0.137" miette = { version = "7.4.0", features = ["fancy"] } pest = { version = "2.7.15", features = ["miette-error", "pretty-print"] } diff --git a/crates/tx3-lang/src/ast.rs b/crates/tx3-lang/src/ast.rs index 54578928..c34e43fe 100644 --- a/crates/tx3-lang/src/ast.rs +++ b/crates/tx3-lang/src/ast.rs @@ -173,6 +173,8 @@ impl AsRef for Identifier { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct Program { + #[serde(default)] + pub imports: Vec, pub env: Option, pub txs: Vec, pub types: Vec, @@ -187,6 +189,13 @@ pub struct Program { pub(crate) scope: Option>, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ImportDef { + pub path: StringLiteral, + pub alias: Option, + pub span: Span, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct EnvField { pub name: String, diff --git a/crates/tx3-lang/src/facade.rs b/crates/tx3-lang/src/facade.rs index 39f507d3..d56a62d0 100644 --- a/crates/tx3-lang/src/facade.rs +++ b/crates/tx3-lang/src/facade.rs @@ -1,8 +1,9 @@ use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; use tx3_tir::reduce::{Apply, ArgValue}; -use crate::{analyzing, ast, lowering, parsing}; +use crate::{analyzing, ast, interop, lowering, parsing}; #[derive(Debug, thiserror::Error, miette::Diagnostic)] pub enum Error { @@ -16,6 +17,10 @@ pub enum Error { #[diagnostic(transparent)] Parsing(#[from] parsing::Error), + #[error("Import error: {0}")] + #[diagnostic(transparent)] + Interop(#[from] interop::Error), + #[error("Analyzing error")] Analyzing(#[from] analyzing::AnalyzeReport), @@ -27,6 +32,7 @@ pub type Code = String; pub struct Workspace { main: Option, + root: Option, ast: Option, analisis: Option, tir: HashMap, @@ -34,10 +40,13 @@ pub struct Workspace { impl Workspace { pub fn from_file(main: impl AsRef) -> Result { - let main = std::fs::read_to_string(main.as_ref())?; + let path = main.as_ref(); + let main = std::fs::read_to_string(path)?; + let root = path.parent().map(PathBuf::from); Ok(Self { main: Some(main), + root, ast: None, analisis: None, tir: HashMap::new(), @@ -47,6 +56,7 @@ impl Workspace { pub fn from_string(main: Code) -> Self { Self { main: Some(main), + root: None, ast: None, analisis: None, tir: HashMap::new(), @@ -63,7 +73,8 @@ impl Workspace { pub fn parse(&mut self) -> Result<(), Error> { let main = self.ensure_main()?; - let ast = parsing::parse_string(main)?; + let mut ast = parsing::parse_string(main)?; + interop::resolve_imports(&mut ast, self.root.as_deref())?; self.ast = Some(ast); Ok(()) } diff --git a/crates/tx3-lang/src/interop.rs b/crates/tx3-lang/src/interop.rs new file mode 100644 index 00000000..79e2c6da --- /dev/null +++ b/crates/tx3-lang/src/interop.rs @@ -0,0 +1,255 @@ +//! Resolves plutus.json (CIP-57) imports and maps blueprint definitions to tx3 types. + +use std::collections::HashSet; +use std::path::Path; + +use cip_57::{Blueprint, DataType, Definition, Definitions, Field, ReferencesArray, Schema}; + +use crate::ast::{Identifier, Program, RecordField, Span, Type, TypeDef, VariantCase}; + +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum Error { + #[error("cannot resolve imports without a root path (use Workspace::from_file instead of from_string)")] + #[diagnostic(code(tx3::interop::missing_root))] + MissingRoot, + + #[error("I/O error reading import file: {0}")] + #[diagnostic(code(tx3::interop::io))] + Io(#[from] std::io::Error), + + #[error("invalid JSON in plutus file: {0}")] + #[diagnostic(code(tx3::interop::json))] + Json(#[from] serde_json::Error), + + #[error("duplicate type name: {name}")] + #[diagnostic(code(tx3::interop::duplicate_type))] + DuplicateType { + name: String, + + #[source_code] + src: Option, + + #[label("type already defined here or from another import")] + span: Span, + }, + + #[error("plutus schema error: {message}")] + #[diagnostic(code(tx3::interop::schema))] + Schema { message: String }, +} + +impl Error { + fn duplicate_type(name: String, span: Span) -> Self { + Error::DuplicateType { + name, + src: None, + span, + } + } +} + +/// Resolves `#/definitions/cardano~1address~1Address` to definition key `cardano/address/Address`. +/// JSON pointer uses ~1 for / and ~0 for ~. +fn ref_to_key_owned(r: &str) -> String { + let prefix = "#/definitions/"; + let s = r + .strip_prefix(prefix) + .unwrap_or(r) + .replace("~1", "/") + .replace("~0", "~"); + s +} + +/// Normalize definition key to a single identifier: replace `/` and `$` with `_`. +fn key_to_normalized_name(key: &str) -> String { + key.replace('/', "_").replace('$', "_") +} + +/// Full type name for an imported definition: with alias use `Alias_NormalizedKey`, else `NormalizedKey`. +fn import_type_name(key: &str, alias: Option<&str>) -> String { + let base = key_to_normalized_name(key); + match alias { + Some(a) => format!("{}_{}", a, base), + None => base, + } +} + +/// Resolve a $ref to a tx3 Type using definitions and the current import alias. +fn resolve_ref_to_type( + ref_str: &str, + definitions: &Definitions, + alias: Option<&str>, +) -> Result { + let key = ref_to_key_owned(ref_str); + let def = definitions + .inner + .get(&key) + .ok_or_else(|| Error::Schema { + message: format!("definition not found: {}", key), + })?; + + if let Some(dt) = &def.data_type { + match dt { + DataType::Integer => return Ok(Type::Int), + DataType::Bytes => return Ok(Type::Bytes), + DataType::List => { + let inner = match &def.items { + Some(ReferencesArray::Single(r)) => { + r.reference.as_ref().map(|s| resolve_ref_to_type(s, definitions, alias)) + } + Some(ReferencesArray::Array(arr)) => arr.first().and_then(|r| { + r.reference.as_ref().map(|s| resolve_ref_to_type(s, definitions, alias)) + }), + None => None, + }; + let inner = inner.ok_or_else(|| Error::Schema { + message: "list without items".to_string(), + })?; + return Ok(Type::List(Box::new(inner?))); + } + DataType::Map => { + let k = def + .keys + .as_ref() + .and_then(|r| r.reference.as_ref()) + .ok_or_else(|| Error::Schema { + message: "map without keys".to_string(), + })?; + let v = def + .values + .as_ref() + .and_then(|r| r.reference.as_ref()) + .ok_or_else(|| Error::Schema { + message: "map without values".to_string(), + })?; + let key_ty = resolve_ref_to_type(k, definitions, alias)?; + let val_ty = resolve_ref_to_type(v, definitions, alias)?; + return Ok(Type::Map(Box::new(key_ty), Box::new(val_ty))); + } + DataType::Constructor => {} + } + } + + if def.any_of.is_some() { + return Ok(Type::Custom(Identifier::new(import_type_name(&key, alias)))); + } + + if def.data_type == Some(DataType::Integer) { + return Ok(Type::Int); + } + if def.data_type == Some(DataType::Bytes) { + return Ok(Type::Bytes); + } + + Ok(Type::Custom(Identifier::new(import_type_name(&key, alias)))) +} + +fn field_to_record_field(f: &Field, definitions: &Definitions, alias: Option<&str>) -> Result { + let name = f + .title + .as_deref() + .unwrap_or("field") + .to_string(); + let r#type = resolve_ref_to_type(&f.reference, definitions, alias)?; + Ok(RecordField { + name: Identifier::new(name), + r#type, + span: Span::DUMMY, + }) +} + +fn schema_to_variant_case(schema: &Schema, definitions: &Definitions, alias: Option<&str>) -> Result { + let name = schema + .title + .as_deref() + .unwrap_or("Variant") + .to_string(); + let fields: Vec = schema + .fields + .iter() + .map(|f| field_to_record_field(f, definitions, alias)) + .collect::, _>>()?; + Ok(VariantCase { + name: Identifier::new(name), + fields, + span: Span::DUMMY, + }) +} + +/// Convert a CIP-57 Definition to a tx3 TypeDef if it is a product or sum type. Primitives and raw List/Map return None. +fn definition_to_type_def( + key: &str, + def: &Definition, + definitions: &Definitions, + alias: Option<&str>, +) -> Result, Error> { + if let Some(dt) = &def.data_type { + match dt { + DataType::Integer | DataType::Bytes => return Ok(None), + DataType::List | DataType::Map => return Ok(None), + DataType::Constructor => {} + } + } + + let cases = if let Some(any_of) = &def.any_of { + any_of + .iter() + .map(|s| schema_to_variant_case(s, definitions, alias)) + .collect::, _>>()? + } else { + return Ok(None); + }; + + if cases.is_empty() { + return Ok(None); + } + + let type_name = import_type_name(key, alias); + Ok(Some(TypeDef { + name: Identifier::new(type_name), + cases, + span: Span::DUMMY, + })) +} + +/// Resolve all plutus.json imports: read files, parse blueprints, map definitions to TypeDefs, append to program.types with collision checks. +pub fn resolve_imports(program: &mut Program, root: Option<&Path>) -> Result<(), Error> { + if program.imports.is_empty() { + return Ok(()); + } + let root = root.ok_or(Error::MissingRoot)?; + + let existing_names: HashSet = program + .types + .iter() + .map(|t| t.name.value.clone()) + .chain(program.aliases.iter().map(|a| a.name.value.clone())) + .collect(); + let mut added_names = HashSet::::new(); + + for import in &program.imports { + let path = root.join(import.path.value.as_str()); + let json = std::fs::read_to_string(&path)?; + let blueprint: Blueprint = serde_json::from_str(&json)?; + + let definitions = match &blueprint.definitions { + Some(d) => d, + None => continue, + }; + + let alias = import.alias.as_ref().map(|a| a.value.as_str()); + + for (key, def) in &definitions.inner { + if let Some(type_def) = definition_to_type_def(key, def, definitions, alias)? { + let name = type_def.name.value.clone(); + if existing_names.contains(&name) || added_names.contains(&name) { + return Err(Error::duplicate_type(name, import.span.clone())); + } + added_names.insert(name.clone()); + program.types.push(type_def); + } + } + } + + Ok(()) +} diff --git a/crates/tx3-lang/src/lib.rs b/crates/tx3-lang/src/lib.rs index 9d044437..3fbd5699 100644 --- a/crates/tx3-lang/src/lib.rs +++ b/crates/tx3-lang/src/lib.rs @@ -26,6 +26,7 @@ pub mod analyzing; pub mod ast; +pub mod interop; pub mod lowering; pub mod parsing; diff --git a/crates/tx3-lang/src/parsing.rs b/crates/tx3-lang/src/parsing.rs index 2b34fc1c..9244ff32 100644 --- a/crates/tx3-lang/src/parsing.rs +++ b/crates/tx3-lang/src/parsing.rs @@ -87,6 +87,7 @@ impl AstNode for Program { let inner = pair.into_inner(); let mut program = Self { + imports: Vec::new(), env: None, txs: Vec::new(), assets: Vec::new(), @@ -100,6 +101,7 @@ impl AstNode for Program { for pair in inner { match pair.as_rule() { + Rule::import_def => program.imports.push(ImportDef::parse(pair)?), Rule::env_def => program.env = Some(EnvDef::parse(pair)?), Rule::tx_def => program.txs.push(TxDef::parse(pair)?), Rule::asset_def => program.assets.push(AssetDef::parse(pair)?), @@ -121,6 +123,26 @@ impl AstNode for Program { } } +impl AstNode for ImportDef { + const RULE: Rule = Rule::import_def; + + fn parse(pair: Pair) -> Result { + let span = pair.as_span().into(); + let mut inner = pair.into_inner(); + let path = StringLiteral::parse(inner.next().unwrap())?; + let alias = inner.next().map(Identifier::parse).transpose()?; + Ok(ImportDef { + path, + alias, + span, + }) + } + + fn span(&self) -> &Span { + &self.span + } +} + impl AstNode for EnvField { const RULE: Rule = Rule::env_field; @@ -2587,6 +2609,7 @@ mod tests { "basic", "party Abc; tx my_tx() {}", Program { + imports: vec![], parties: vec![PartyDef { name: Identifier::new("Abc"), span: Span::DUMMY, diff --git a/crates/tx3-lang/src/tx3.pest b/crates/tx3-lang/src/tx3.pest index a01471ff..18ca5af4 100644 --- a/crates/tx3-lang/src/tx3.pest +++ b/crates/tx3-lang/src/tx3.pest @@ -439,6 +439,10 @@ tx_body_block = _{ validity_block } +import_def = { + "cardano" ~ "::" ~ "import" ~ string ~ ("as" ~ identifier)? ~ ";" +} + env_field = { identifier ~ ":" ~ type } env_def = { @@ -453,6 +457,6 @@ tx_def = { // Program program = { SOI ~ - (env_def | asset_def | party_def | policy_def | type_def | tx_def)* ~ + (import_def | env_def | asset_def | party_def | policy_def | type_def | tx_def)* ~ EOI } diff --git a/crates/tx3-lang/tests/fixtures/import_test.tx3 b/crates/tx3-lang/tests/fixtures/import_test.tx3 new file mode 100644 index 00000000..fd4aeb85 --- /dev/null +++ b/crates/tx3-lang/tests/fixtures/import_test.tx3 @@ -0,0 +1,6 @@ +cardano::import "../../../cip-57/examples/plutus.json" as types; + +party Sender; +party Receiver; + +tx dummy() {} diff --git a/crates/tx3-lang/tests/imports.rs b/crates/tx3-lang/tests/imports.rs new file mode 100644 index 00000000..8186173b --- /dev/null +++ b/crates/tx3-lang/tests/imports.rs @@ -0,0 +1,80 @@ +//! Tests for plutus.json import support. + +use std::path::Path; + +use tx3_lang::Workspace; + +#[test] +fn import_with_alias_adds_types() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let path = Path::new(manifest_dir).join("tests/fixtures/import_test.tx3"); + let mut workspace = Workspace::from_file(&path).unwrap(); + workspace.parse().unwrap(); + let ast = workspace.ast().unwrap(); + + let type_names: Vec = ast.types.iter().map(|t| t.name.value.clone()).collect(); + assert!( + type_names.iter().any(|n| n.starts_with("types_")), + "expected at least one type prefixed with 'types_', got: {:?}", + type_names + ); +} + +#[test] +fn import_without_root_errors() { + let src = r#" +cardano::import "some/file.json"; +party X; +tx dummy() {} +"#; + let mut workspace = Workspace::from_string(src.to_string()); + let res = workspace.parse(); + assert!(res.is_err(), "expected error when importing without root"); + let err = res.unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("root") || msg.contains("import"), + "expected error about root or import, got: {}", + msg + ); +} + +#[test] +fn duplicate_type_name_from_imports_errors() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let path = Path::new(manifest_dir).join("tests/fixtures/import_test.tx3"); + let content = std::fs::read_to_string(&path).unwrap(); + let content_twice = format!( + "{}\ncardano::import \"../../../cip-57/examples/plutus.json\" as types;\n{}", + content, + "tx dummy2() {}" + ); + let fixtures = Path::new(manifest_dir).join("tests/fixtures"); + let temp_tx3 = fixtures.join("temp_duplicate_import.tx3"); + std::fs::write(&temp_tx3, content_twice).unwrap(); + let res = Workspace::from_file(&temp_tx3).and_then(|mut w| w.parse()); + let _ = std::fs::remove_file(&temp_tx3); + assert!(res.is_err(), "expected error for duplicate type names from two imports"); + let err = res.unwrap_err(); + assert!( + err.to_string().contains("duplicate") || err.to_string().contains("type"), + "expected duplicate/type error, got: {}", + err + ); +} + +#[test] +fn invalid_import_path_errors() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let fixtures = Path::new(manifest_dir).join("tests/fixtures"); + let temp_tx3 = fixtures.join("temp_nonexistent_import.tx3"); + let content = r#" +cardano::import "nonexistent/plutus.json" as types; +party X; +tx dummy() {} +"#; + std::fs::write(&temp_tx3, content).unwrap(); + let res = Workspace::from_file(&temp_tx3).and_then(|mut w| w.parse()); + let _ = std::fs::remove_file(&temp_tx3); + assert!(res.is_err(), "expected error for missing import file"); +} diff --git a/plutus_import_plan.md b/plutus_import_plan.md new file mode 100644 index 00000000..e4ba54d0 --- /dev/null +++ b/plutus_import_plan.md @@ -0,0 +1,74 @@ +# Implementation Plan - Support Plutus JSON Imports + +The goal is to allow importing types from `plutus.json` files (CIP-57) into the Tx3 DSL using the `cardano::import` statement. + +## User Review Approved + +> [!NOTE] +> Coupling `tx3-lang` to the `cip-57` implementation is confirmed as acceptable. +> +> Types imported from `plutus.json` will be mapped to `tx3` `TypeDef` and `VariantCase`. +> - Product types (single constructor) -> `TypeDef` with one `VariantCase`. +> - Sum types (anyOf) -> `TypeDef` with multiple `VariantCase`s. +> - Primitive types map to `Int`, `Bytes`, `List`, `Map`. +> +> Naming and Collision Handling: +> - If `as Alias` is provided, imported types are prefixed: `Alias_TypeName`. +> - If no alias is provided, types are imported as is. +> - **CRITICAL: Any naming collision (with existing types or other imports) MUST throw an error.** + +## Proposed Changes + +### `crates/tx3-lang` + +#### [MODIFY] `Cargo.toml` +- Add `cip-57` to `[dependencies]`. + +#### [MODIFY] `tx3.pest` +- Add `import_def` rule: `import_def = { "cardano" ~ "::" ~ "import" ~ string ~ ("as" ~ identifier)? ~ ";" }` +- Add `import_def` to `program` rule. + +#### [MODIFY] `ast.rs` +- Add `ImportDef` struct. +- Add `imports: Vec` to `Program` struct. + +#### [MODIFY] `parsing.rs` +- Implement `AstNode` for `ImportDef`. +- Update `Program::parse` to parse imports. + +#### [NEW] `interop.rs` +- Create `resolve_imports(program: &mut Program, root: &Path) -> Result<(), Error>`. +- Logic: + - Iterate `program.imports`. + - Resolve path relative to `root`. + - Read file (IO). + - Parse `cip_57::Blueprint`. + - Iterate `blueprint.definitions`. + - Map `Definition` to `TypeDef` (flattening `Schema`s). + - Apply alias prefix if present. + - Append to `program.types`. + - **Verify no collisions** with existing types or other imports. + +#### [MODIFY] `facade.rs` +- Update `Workspace` struct to store `root: Option`. +- Update `Workspace::from_file` to store the path. +- Update `Workspace::parse` (or a helper) to call `interop::resolve_imports` after parsing. + +#### [MODIFY] `lib.rs` +- Export `interop` module. + +## Verification Plan + +### Automated Tests +- Create `crates/tx3-lang/tests/imports.rs`. +- Test cases: + 1. Successful import with and without alias. + 2. Import with alias prefixing. + 3. Collision detection between two imports. + 4. Collision detection between import and local type. + 5. Invalid JSON file handling. + +### Manual Verification +- Use `bin/tx3c` to build a sample project. + - Create `examples/import_test.tx3`. + - Run `cargo run -p tx3-cli -- build examples/import_test.tx3`. From 84eb7d4d6aaefd67c140969b98c22693c2a05cbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 6 Feb 2026 00:46:58 -0300 Subject: [PATCH 15/32] refactor: decouple loading with from parser --- crates/tx3-lang/src/facade.rs | 3 +- crates/tx3-lang/src/interop.rs | 161 +++++++++++++++++++++++++------ crates/tx3-lang/src/parsing.rs | 6 +- crates/tx3-lang/tests/imports.rs | 8 +- 4 files changed, 138 insertions(+), 40 deletions(-) diff --git a/crates/tx3-lang/src/facade.rs b/crates/tx3-lang/src/facade.rs index d56a62d0..195d8b7d 100644 --- a/crates/tx3-lang/src/facade.rs +++ b/crates/tx3-lang/src/facade.rs @@ -74,7 +74,8 @@ impl Workspace { pub fn parse(&mut self) -> Result<(), Error> { let main = self.ensure_main()?; let mut ast = parsing::parse_string(main)?; - interop::resolve_imports(&mut ast, self.root.as_deref())?; + let loader = self.root.clone().map(interop::FsLoader::new); + interop::resolve_imports(&mut ast, loader.as_ref())?; self.ast = Some(ast); Ok(()) } diff --git a/crates/tx3-lang/src/interop.rs b/crates/tx3-lang/src/interop.rs index 79e2c6da..0e5badb1 100644 --- a/crates/tx3-lang/src/interop.rs +++ b/crates/tx3-lang/src/interop.rs @@ -1,7 +1,7 @@ //! Resolves plutus.json (CIP-57) imports and maps blueprint definitions to tx3 types. use std::collections::HashSet; -use std::path::Path; +use std::path::PathBuf; use cip_57::{Blueprint, DataType, Definition, Definitions, Field, ReferencesArray, Schema}; @@ -48,6 +48,27 @@ impl Error { } } +pub trait ImportLoader { + fn load_source(&self, path: &str) -> std::io::Result; +} + +pub struct FsLoader { + root: PathBuf, +} + +impl FsLoader { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } +} + +impl ImportLoader for FsLoader { + fn load_source(&self, path: &str) -> std::io::Result { + let full_path = self.root.join(path); + std::fs::read_to_string(full_path) + } +} + /// Resolves `#/definitions/cardano~1address~1Address` to definition key `cardano/address/Address`. /// JSON pointer uses ~1 for / and ~0 for ~. fn ref_to_key_owned(r: &str) -> String { @@ -65,7 +86,6 @@ fn key_to_normalized_name(key: &str) -> String { key.replace('/', "_").replace('$', "_") } -/// Full type name for an imported definition: with alias use `Alias_NormalizedKey`, else `NormalizedKey`. fn import_type_name(key: &str, alias: Option<&str>) -> String { let base = key_to_normalized_name(key); match alias { @@ -74,19 +94,15 @@ fn import_type_name(key: &str, alias: Option<&str>) -> String { } } -/// Resolve a $ref to a tx3 Type using definitions and the current import alias. fn resolve_ref_to_type( ref_str: &str, definitions: &Definitions, alias: Option<&str>, ) -> Result { let key = ref_to_key_owned(ref_str); - let def = definitions - .inner - .get(&key) - .ok_or_else(|| Error::Schema { - message: format!("definition not found: {}", key), - })?; + let def = definitions.inner.get(&key).ok_or_else(|| Error::Schema { + message: format!("definition not found: {}", key), + })?; if let Some(dt) = &def.data_type { match dt { @@ -94,11 +110,14 @@ fn resolve_ref_to_type( DataType::Bytes => return Ok(Type::Bytes), DataType::List => { let inner = match &def.items { - Some(ReferencesArray::Single(r)) => { - r.reference.as_ref().map(|s| resolve_ref_to_type(s, definitions, alias)) - } + Some(ReferencesArray::Single(r)) => r + .reference + .as_ref() + .map(|s| resolve_ref_to_type(s, definitions, alias)), Some(ReferencesArray::Array(arr)) => arr.first().and_then(|r| { - r.reference.as_ref().map(|s| resolve_ref_to_type(s, definitions, alias)) + r.reference + .as_ref() + .map(|s| resolve_ref_to_type(s, definitions, alias)) }), None => None, }; @@ -144,12 +163,12 @@ fn resolve_ref_to_type( Ok(Type::Custom(Identifier::new(import_type_name(&key, alias)))) } -fn field_to_record_field(f: &Field, definitions: &Definitions, alias: Option<&str>) -> Result { - let name = f - .title - .as_deref() - .unwrap_or("field") - .to_string(); +fn field_to_record_field( + f: &Field, + definitions: &Definitions, + alias: Option<&str>, +) -> Result { + let name = f.title.as_deref().unwrap_or("field").to_string(); let r#type = resolve_ref_to_type(&f.reference, definitions, alias)?; Ok(RecordField { name: Identifier::new(name), @@ -158,12 +177,12 @@ fn field_to_record_field(f: &Field, definitions: &Definitions, alias: Option<&st }) } -fn schema_to_variant_case(schema: &Schema, definitions: &Definitions, alias: Option<&str>) -> Result { - let name = schema - .title - .as_deref() - .unwrap_or("Variant") - .to_string(); +fn schema_to_variant_case( + schema: &Schema, + definitions: &Definitions, + alias: Option<&str>, +) -> Result { + let name = schema.title.as_deref().unwrap_or("Variant").to_string(); let fields: Vec = schema .fields .iter() @@ -176,7 +195,6 @@ fn schema_to_variant_case(schema: &Schema, definitions: &Definitions, alias: Opt }) } -/// Convert a CIP-57 Definition to a tx3 TypeDef if it is a product or sum type. Primitives and raw List/Map return None. fn definition_to_type_def( key: &str, def: &Definition, @@ -212,12 +230,14 @@ fn definition_to_type_def( })) } -/// Resolve all plutus.json imports: read files, parse blueprints, map definitions to TypeDefs, append to program.types with collision checks. -pub fn resolve_imports(program: &mut Program, root: Option<&Path>) -> Result<(), Error> { +pub fn resolve_imports( + program: &mut Program, + loader: Option<&impl ImportLoader>, +) -> Result<(), Error> { if program.imports.is_empty() { return Ok(()); } - let root = root.ok_or(Error::MissingRoot)?; + let loader = loader.ok_or(Error::MissingRoot)?; let existing_names: HashSet = program .types @@ -228,8 +248,7 @@ pub fn resolve_imports(program: &mut Program, root: Option<&Path>) -> Result<(), let mut added_names = HashSet::::new(); for import in &program.imports { - let path = root.join(import.path.value.as_str()); - let json = std::fs::read_to_string(&path)?; + let json = loader.load_source(import.path.value.as_str())?; let blueprint: Blueprint = serde_json::from_str(&json)?; let definitions = match &blueprint.definitions { @@ -253,3 +272,83 @@ pub fn resolve_imports(program: &mut Program, root: Option<&Path>) -> Result<(), Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ast::{Identifier, ImportDef, Program, Span, StringLiteral}; + + #[derive(Default, Clone)] + pub struct InMemoryLoader { + map: std::collections::HashMap, + } + + impl InMemoryLoader { + pub fn new() -> Self { + Self { + map: std::collections::HashMap::new(), + } + } + + pub fn add(&mut self, path: impl Into, contents: impl Into) -> &mut Self { + self.map.insert(path.into(), contents.into()); + self + } + } + + impl ImportLoader for InMemoryLoader { + fn load_source(&self, path: &str) -> std::io::Result { + self.map.get(path).cloned().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("import not found: {}", path), + ) + }) + } + } + + #[test] + fn resolve_imports_with_in_memory_loader() { + let mut program = Program { + imports: vec![ImportDef { + path: StringLiteral::new("test.json"), + alias: Some(Identifier::new("types")), + span: Span::DUMMY, + }], + env: None, + txs: vec![], + types: vec![], + aliases: vec![], + assets: vec![], + parties: vec![], + policies: vec![], + span: Span::DUMMY, + scope: None, + }; + + let json = r#"{ + "preamble": { "title": "test", "version": "0", "plutusVersion": "v3" }, + "validators": [], + "definitions": { + "Bool": { + "title": "Bool", + "anyOf": [ + { "title": "False", "dataType": "constructor", "index": 0, "fields": [] }, + { "title": "True", "dataType": "constructor", "index": 1, "fields": [] } + ] + } + } + }"#; + + let mut loader = InMemoryLoader::new(); + loader.add("test.json", json); + resolve_imports(&mut program, Some(&loader)).unwrap(); + + let type_names: Vec = program.types.iter().map(|t| t.name.value.clone()).collect(); + assert!( + type_names.contains(&"types_Bool".to_string()), + "expected types_Bool in program.types, got: {:?}", + type_names + ); + } +} diff --git a/crates/tx3-lang/src/parsing.rs b/crates/tx3-lang/src/parsing.rs index 9244ff32..3b9c4276 100644 --- a/crates/tx3-lang/src/parsing.rs +++ b/crates/tx3-lang/src/parsing.rs @@ -131,11 +131,7 @@ impl AstNode for ImportDef { let mut inner = pair.into_inner(); let path = StringLiteral::parse(inner.next().unwrap())?; let alias = inner.next().map(Identifier::parse).transpose()?; - Ok(ImportDef { - path, - alias, - span, - }) + Ok(ImportDef { path, alias, span }) } fn span(&self) -> &Span { diff --git a/crates/tx3-lang/tests/imports.rs b/crates/tx3-lang/tests/imports.rs index 8186173b..6e4bb05d 100644 --- a/crates/tx3-lang/tests/imports.rs +++ b/crates/tx3-lang/tests/imports.rs @@ -46,15 +46,17 @@ fn duplicate_type_name_from_imports_errors() { let content = std::fs::read_to_string(&path).unwrap(); let content_twice = format!( "{}\ncardano::import \"../../../cip-57/examples/plutus.json\" as types;\n{}", - content, - "tx dummy2() {}" + content, "tx dummy2() {}" ); let fixtures = Path::new(manifest_dir).join("tests/fixtures"); let temp_tx3 = fixtures.join("temp_duplicate_import.tx3"); std::fs::write(&temp_tx3, content_twice).unwrap(); let res = Workspace::from_file(&temp_tx3).and_then(|mut w| w.parse()); let _ = std::fs::remove_file(&temp_tx3); - assert!(res.is_err(), "expected error for duplicate type names from two imports"); + assert!( + res.is_err(), + "expected error for duplicate type names from two imports" + ); let err = res.unwrap_err(); assert!( err.to_string().contains("duplicate") || err.to_string().contains("type"), From 5ec5bd3aec7ea7d4f4d9f2715ab8567dba06a8fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 6 Feb 2026 01:16:08 -0300 Subject: [PATCH 16/32] fix: cip57 purpose is oneOf object instead of array --- crates/cip-57/src/lib.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/cip-57/src/lib.rs b/crates/cip-57/src/lib.rs index b1a4d97d..d46fc5f8 100644 --- a/crates/cip-57/src/lib.rs +++ b/crates/cip-57/src/lib.rs @@ -52,12 +52,19 @@ pub struct Argument { pub schema: Reference, } -/// Represents a purpose array which can be either a single purpose or an array of purposes. +/// Represents a purpose which can be either a single purpose or an object with oneOf. #[derive(Debug, Deserialize, Serialize, Clone)] #[serde(untagged)] pub enum PurposeArray { Single(Purpose), - Array(Vec), + OneOf(PurposeOneOf), +} + +/// Represents a purpose object with a oneOf field containing an array of purposes. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PurposeOneOf { + pub one_of: Vec, } /// Represents the purpose of an argument, which can be spend, mint, withdraw, or publish. From a2fb44fc656af4b4b20088fd5fbe50c8949dc043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 6 Feb 2026 01:19:17 -0300 Subject: [PATCH 17/32] chore: remove import plan --- plutus_import_plan.md | 74 ------------------------------------------- 1 file changed, 74 deletions(-) delete mode 100644 plutus_import_plan.md diff --git a/plutus_import_plan.md b/plutus_import_plan.md deleted file mode 100644 index e4ba54d0..00000000 --- a/plutus_import_plan.md +++ /dev/null @@ -1,74 +0,0 @@ -# Implementation Plan - Support Plutus JSON Imports - -The goal is to allow importing types from `plutus.json` files (CIP-57) into the Tx3 DSL using the `cardano::import` statement. - -## User Review Approved - -> [!NOTE] -> Coupling `tx3-lang` to the `cip-57` implementation is confirmed as acceptable. -> -> Types imported from `plutus.json` will be mapped to `tx3` `TypeDef` and `VariantCase`. -> - Product types (single constructor) -> `TypeDef` with one `VariantCase`. -> - Sum types (anyOf) -> `TypeDef` with multiple `VariantCase`s. -> - Primitive types map to `Int`, `Bytes`, `List`, `Map`. -> -> Naming and Collision Handling: -> - If `as Alias` is provided, imported types are prefixed: `Alias_TypeName`. -> - If no alias is provided, types are imported as is. -> - **CRITICAL: Any naming collision (with existing types or other imports) MUST throw an error.** - -## Proposed Changes - -### `crates/tx3-lang` - -#### [MODIFY] `Cargo.toml` -- Add `cip-57` to `[dependencies]`. - -#### [MODIFY] `tx3.pest` -- Add `import_def` rule: `import_def = { "cardano" ~ "::" ~ "import" ~ string ~ ("as" ~ identifier)? ~ ";" }` -- Add `import_def` to `program` rule. - -#### [MODIFY] `ast.rs` -- Add `ImportDef` struct. -- Add `imports: Vec` to `Program` struct. - -#### [MODIFY] `parsing.rs` -- Implement `AstNode` for `ImportDef`. -- Update `Program::parse` to parse imports. - -#### [NEW] `interop.rs` -- Create `resolve_imports(program: &mut Program, root: &Path) -> Result<(), Error>`. -- Logic: - - Iterate `program.imports`. - - Resolve path relative to `root`. - - Read file (IO). - - Parse `cip_57::Blueprint`. - - Iterate `blueprint.definitions`. - - Map `Definition` to `TypeDef` (flattening `Schema`s). - - Apply alias prefix if present. - - Append to `program.types`. - - **Verify no collisions** with existing types or other imports. - -#### [MODIFY] `facade.rs` -- Update `Workspace` struct to store `root: Option`. -- Update `Workspace::from_file` to store the path. -- Update `Workspace::parse` (or a helper) to call `interop::resolve_imports` after parsing. - -#### [MODIFY] `lib.rs` -- Export `interop` module. - -## Verification Plan - -### Automated Tests -- Create `crates/tx3-lang/tests/imports.rs`. -- Test cases: - 1. Successful import with and without alias. - 2. Import with alias prefixing. - 3. Collision detection between two imports. - 4. Collision detection between import and local type. - 5. Invalid JSON file handling. - -### Manual Verification -- Use `bin/tx3c` to build a sample project. - - Create `examples/import_test.tx3`. - - Run `cargo run -p tx3-cli -- build examples/import_test.tx3`. From 7174b1491557945871e6768963137a8a9990cf9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 6 Feb 2026 01:32:19 -0300 Subject: [PATCH 18/32] fix: remove redundant branches of code --- crates/tx3-lang/src/interop.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/crates/tx3-lang/src/interop.rs b/crates/tx3-lang/src/interop.rs index 0e5badb1..8a2c617d 100644 --- a/crates/tx3-lang/src/interop.rs +++ b/crates/tx3-lang/src/interop.rs @@ -153,13 +153,6 @@ fn resolve_ref_to_type( return Ok(Type::Custom(Identifier::new(import_type_name(&key, alias)))); } - if def.data_type == Some(DataType::Integer) { - return Ok(Type::Int); - } - if def.data_type == Some(DataType::Bytes) { - return Ok(Type::Bytes); - } - Ok(Type::Custom(Identifier::new(import_type_name(&key, alias)))) } From c2b4dca5a52148a70007373e22073628031f1406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 6 Feb 2026 01:33:40 -0300 Subject: [PATCH 19/32] fix: some warnings are gone --- crates/tx3-lang/src/cardano.rs | 2 +- crates/tx3-lang/src/parsing.rs | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/tx3-lang/src/cardano.rs b/crates/tx3-lang/src/cardano.rs index 036ab79f..ac11eb39 100644 --- a/crates/tx3-lang/src/cardano.rs +++ b/crates/tx3-lang/src/cardano.rs @@ -847,7 +847,7 @@ mod tests { use super::*; use crate::{ analyzing::analyze, - ast::{self, *}, + ast::*, }; use pest::Parser; diff --git a/crates/tx3-lang/src/parsing.rs b/crates/tx3-lang/src/parsing.rs index 3b9c4276..b92190fe 100644 --- a/crates/tx3-lang/src/parsing.rs +++ b/crates/tx3-lang/src/parsing.rs @@ -12,10 +12,7 @@ use pest::{ }; use pest_derive::Parser; -use crate::{ - ast::*, - cardano::{PlutusWitnessBlock, PlutusWitnessField}, -}; +use crate::ast::*; #[derive(Parser)] #[grammar = "tx3.pest"] pub(crate) struct Tx3Grammar; From c20187aedabf6c9b2428753663e8ff84455a2d5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 6 Feb 2026 11:33:46 -0300 Subject: [PATCH 20/32] refactor: rename `interop` module to `importing` --- crates/tx3-lang/src/facade.rs | 8 ++++---- crates/tx3-lang/src/{interop.rs => importing.rs} | 10 +++++----- crates/tx3-lang/src/lib.rs | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) rename crates/tx3-lang/src/{interop.rs => importing.rs} (97%) diff --git a/crates/tx3-lang/src/facade.rs b/crates/tx3-lang/src/facade.rs index 195d8b7d..13d34eb7 100644 --- a/crates/tx3-lang/src/facade.rs +++ b/crates/tx3-lang/src/facade.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use tx3_tir::reduce::{Apply, ArgValue}; -use crate::{analyzing, ast, interop, lowering, parsing}; +use crate::{analyzing, ast, importing, lowering, parsing}; #[derive(Debug, thiserror::Error, miette::Diagnostic)] pub enum Error { @@ -19,7 +19,7 @@ pub enum Error { #[error("Import error: {0}")] #[diagnostic(transparent)] - Interop(#[from] interop::Error), + Importing(#[from] importing::Error), #[error("Analyzing error")] Analyzing(#[from] analyzing::AnalyzeReport), @@ -74,8 +74,8 @@ impl Workspace { pub fn parse(&mut self) -> Result<(), Error> { let main = self.ensure_main()?; let mut ast = parsing::parse_string(main)?; - let loader = self.root.clone().map(interop::FsLoader::new); - interop::resolve_imports(&mut ast, loader.as_ref())?; + let loader = self.root.clone().map(importing::FsLoader::new); + importing::resolve_imports(&mut ast, loader.as_ref())?; self.ast = Some(ast); Ok(()) } diff --git a/crates/tx3-lang/src/interop.rs b/crates/tx3-lang/src/importing.rs similarity index 97% rename from crates/tx3-lang/src/interop.rs rename to crates/tx3-lang/src/importing.rs index 8a2c617d..5abfb6a1 100644 --- a/crates/tx3-lang/src/interop.rs +++ b/crates/tx3-lang/src/importing.rs @@ -10,19 +10,19 @@ use crate::ast::{Identifier, Program, RecordField, Span, Type, TypeDef, VariantC #[derive(Debug, thiserror::Error, miette::Diagnostic)] pub enum Error { #[error("cannot resolve imports without a root path (use Workspace::from_file instead of from_string)")] - #[diagnostic(code(tx3::interop::missing_root))] + #[diagnostic(code(tx3::importing::missing_root))] MissingRoot, #[error("I/O error reading import file: {0}")] - #[diagnostic(code(tx3::interop::io))] + #[diagnostic(code(tx3::importing::io))] Io(#[from] std::io::Error), #[error("invalid JSON in plutus file: {0}")] - #[diagnostic(code(tx3::interop::json))] + #[diagnostic(code(tx3::importing::json))] Json(#[from] serde_json::Error), #[error("duplicate type name: {name}")] - #[diagnostic(code(tx3::interop::duplicate_type))] + #[diagnostic(code(tx3::importing::duplicate_type))] DuplicateType { name: String, @@ -34,7 +34,7 @@ pub enum Error { }, #[error("plutus schema error: {message}")] - #[diagnostic(code(tx3::interop::schema))] + #[diagnostic(code(tx3::importing::schema))] Schema { message: String }, } diff --git a/crates/tx3-lang/src/lib.rs b/crates/tx3-lang/src/lib.rs index 3fbd5699..ec6686c3 100644 --- a/crates/tx3-lang/src/lib.rs +++ b/crates/tx3-lang/src/lib.rs @@ -26,7 +26,7 @@ pub mod analyzing; pub mod ast; -pub mod interop; +pub mod importing; pub mod lowering; pub mod parsing; From 2f44dd83e455017928804c4a11167a73f9c3f661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 6 Feb 2026 11:43:41 -0300 Subject: [PATCH 21/32] chore: remove cardano prefix on imports --- crates/tx3-lang/src/tx3.pest | 2 +- crates/tx3-lang/tests/fixtures/import_test.tx3 | 2 +- crates/tx3-lang/tests/imports.rs | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/tx3-lang/src/tx3.pest b/crates/tx3-lang/src/tx3.pest index 18ca5af4..a1fe6987 100644 --- a/crates/tx3-lang/src/tx3.pest +++ b/crates/tx3-lang/src/tx3.pest @@ -440,7 +440,7 @@ tx_body_block = _{ } import_def = { - "cardano" ~ "::" ~ "import" ~ string ~ ("as" ~ identifier)? ~ ";" + "import" ~ string ~ ("as" ~ identifier)? ~ ";" } env_field = { identifier ~ ":" ~ type } diff --git a/crates/tx3-lang/tests/fixtures/import_test.tx3 b/crates/tx3-lang/tests/fixtures/import_test.tx3 index fd4aeb85..6c26249e 100644 --- a/crates/tx3-lang/tests/fixtures/import_test.tx3 +++ b/crates/tx3-lang/tests/fixtures/import_test.tx3 @@ -1,4 +1,4 @@ -cardano::import "../../../cip-57/examples/plutus.json" as types; +import "../../../cip-57/examples/plutus.json" as types; party Sender; party Receiver; diff --git a/crates/tx3-lang/tests/imports.rs b/crates/tx3-lang/tests/imports.rs index 6e4bb05d..a432a55e 100644 --- a/crates/tx3-lang/tests/imports.rs +++ b/crates/tx3-lang/tests/imports.rs @@ -23,7 +23,7 @@ fn import_with_alias_adds_types() { #[test] fn import_without_root_errors() { let src = r#" -cardano::import "some/file.json"; +import "some/file.json"; party X; tx dummy() {} "#; @@ -45,7 +45,7 @@ fn duplicate_type_name_from_imports_errors() { let path = Path::new(manifest_dir).join("tests/fixtures/import_test.tx3"); let content = std::fs::read_to_string(&path).unwrap(); let content_twice = format!( - "{}\ncardano::import \"../../../cip-57/examples/plutus.json\" as types;\n{}", + "{}\nimport \"../../../cip-57/examples/plutus.json\" as types;\n{}", content, "tx dummy2() {}" ); let fixtures = Path::new(manifest_dir).join("tests/fixtures"); @@ -71,7 +71,7 @@ fn invalid_import_path_errors() { let fixtures = Path::new(manifest_dir).join("tests/fixtures"); let temp_tx3 = fixtures.join("temp_nonexistent_import.tx3"); let content = r#" -cardano::import "nonexistent/plutus.json" as types; +import "nonexistent/plutus.json" as types; party X; tx dummy() {} "#; From 8ebf3181f09f28dbc01df9f232162b3a6ac7c260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 6 Feb 2026 12:47:01 -0300 Subject: [PATCH 22/32] refactor: move import tests with the rest of tests --- crates/tx3-lang/src/cardano.rs | 5 +- crates/tx3-lang/src/importing.rs | 90 ++++ crates/tx3-lang/src/parsing.rs | 2 + .../tx3-lang/tests/fixtures/import_test.tx3 | 6 - crates/tx3-lang/tests/imports.rs | 82 ---- examples/imported_datum.ast | 414 ++++++++++++++++++ examples/imported_datum.tx3 | 27 ++ 7 files changed, 534 insertions(+), 92 deletions(-) delete mode 100644 crates/tx3-lang/tests/fixtures/import_test.tx3 delete mode 100644 crates/tx3-lang/tests/imports.rs create mode 100644 examples/imported_datum.ast create mode 100644 examples/imported_datum.tx3 diff --git a/crates/tx3-lang/src/cardano.rs b/crates/tx3-lang/src/cardano.rs index ac11eb39..985db0d9 100644 --- a/crates/tx3-lang/src/cardano.rs +++ b/crates/tx3-lang/src/cardano.rs @@ -845,10 +845,7 @@ impl IntoLower for CardanoBlock { #[cfg(test)] mod tests { use super::*; - use crate::{ - analyzing::analyze, - ast::*, - }; + use crate::{analyzing::analyze, ast::*}; use pest::Parser; macro_rules! input_to_ast_check { diff --git a/crates/tx3-lang/src/importing.rs b/crates/tx3-lang/src/importing.rs index 5abfb6a1..d8504db0 100644 --- a/crates/tx3-lang/src/importing.rs +++ b/crates/tx3-lang/src/importing.rs @@ -344,4 +344,94 @@ mod tests { type_names ); } + + #[test] + fn import_with_alias_adds_types() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let root = std::path::Path::new(manifest_dir); + + let mut program = + crate::parsing::parse_string(r#"import "../cip-57/examples/plutus.json" as types;"#) + .unwrap(); + + let loader = FsLoader::new(root); + resolve_imports(&mut program, Some(&loader)).unwrap(); + + let type_names: Vec = program.types.iter().map(|t| t.name.value.clone()).collect(); + assert!( + type_names.iter().any(|n| n.starts_with("types_")), + "expected at least one type prefixed with 'types_', got: {:?}", + type_names + ); + } + + #[test] + fn import_without_root_errors() { + let src = r#" + import "some/file.json"; + party X; + tx dummy() {} + "#; + let mut program = crate::parsing::parse_string(src).unwrap(); + let res = resolve_imports(&mut program, None::<&InMemoryLoader>); + + assert!(res.is_err(), "expected error when importing without root"); + let err = res.unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("root") || msg.contains("import"), + "expected error about root or import, got: {}", + msg + ); + } + + #[test] + fn duplicate_type_name_from_imports_errors() { + let json = r#"{ + "preamble": { "title": "test", "version": "0", "plutusVersion": "v3" }, + "validators": [], + "definitions": { + "One": { + "title": "One", + "anyOf": [ { "title": "A", "dataType": "constructor", "index": 0, "fields": [] } ] + } + } + }"#; + + let src = r#" + import "schema1.json" as types; + import "schema2.json" as types; + "#; + + let mut program = crate::parsing::parse_string(src).unwrap(); + let mut loader = InMemoryLoader::new(); + loader.add("schema1.json", json); + loader.add("schema2.json", json); + let res = resolve_imports(&mut program, Some(&loader)); + + assert!( + res.is_err(), + "expected error for duplicate type names from two imports" + ); + let err = res.unwrap_err(); + assert!( + err.to_string().contains("duplicate") || err.to_string().contains("type"), + "expected duplicate/type error, got: {}", + err + ); + } + + #[test] + fn invalid_import_path_errors() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let root = std::path::Path::new(manifest_dir); + let src = r#" + import "nonexistent/plutus.json" as types; + "#; + let mut program = crate::parsing::parse_string(src).unwrap(); + let loader = FsLoader::new(root); + + let res = resolve_imports(&mut program, Some(&loader)); + assert!(res.is_err(), "expected error for missing import file"); + } } diff --git a/crates/tx3-lang/src/parsing.rs b/crates/tx3-lang/src/parsing.rs index b92190fe..116ede21 100644 --- a/crates/tx3-lang/src/parsing.rs +++ b/crates/tx3-lang/src/parsing.rs @@ -2833,4 +2833,6 @@ mod tests { test_parsing!(list_concat); test_parsing!(buidler_fest_2026); + + test_parsing!(imported_datum); } diff --git a/crates/tx3-lang/tests/fixtures/import_test.tx3 b/crates/tx3-lang/tests/fixtures/import_test.tx3 deleted file mode 100644 index 6c26249e..00000000 --- a/crates/tx3-lang/tests/fixtures/import_test.tx3 +++ /dev/null @@ -1,6 +0,0 @@ -import "../../../cip-57/examples/plutus.json" as types; - -party Sender; -party Receiver; - -tx dummy() {} diff --git a/crates/tx3-lang/tests/imports.rs b/crates/tx3-lang/tests/imports.rs deleted file mode 100644 index a432a55e..00000000 --- a/crates/tx3-lang/tests/imports.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! Tests for plutus.json import support. - -use std::path::Path; - -use tx3_lang::Workspace; - -#[test] -fn import_with_alias_adds_types() { - let manifest_dir = env!("CARGO_MANIFEST_DIR"); - let path = Path::new(manifest_dir).join("tests/fixtures/import_test.tx3"); - let mut workspace = Workspace::from_file(&path).unwrap(); - workspace.parse().unwrap(); - let ast = workspace.ast().unwrap(); - - let type_names: Vec = ast.types.iter().map(|t| t.name.value.clone()).collect(); - assert!( - type_names.iter().any(|n| n.starts_with("types_")), - "expected at least one type prefixed with 'types_', got: {:?}", - type_names - ); -} - -#[test] -fn import_without_root_errors() { - let src = r#" -import "some/file.json"; -party X; -tx dummy() {} -"#; - let mut workspace = Workspace::from_string(src.to_string()); - let res = workspace.parse(); - assert!(res.is_err(), "expected error when importing without root"); - let err = res.unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("root") || msg.contains("import"), - "expected error about root or import, got: {}", - msg - ); -} - -#[test] -fn duplicate_type_name_from_imports_errors() { - let manifest_dir = env!("CARGO_MANIFEST_DIR"); - let path = Path::new(manifest_dir).join("tests/fixtures/import_test.tx3"); - let content = std::fs::read_to_string(&path).unwrap(); - let content_twice = format!( - "{}\nimport \"../../../cip-57/examples/plutus.json\" as types;\n{}", - content, "tx dummy2() {}" - ); - let fixtures = Path::new(manifest_dir).join("tests/fixtures"); - let temp_tx3 = fixtures.join("temp_duplicate_import.tx3"); - std::fs::write(&temp_tx3, content_twice).unwrap(); - let res = Workspace::from_file(&temp_tx3).and_then(|mut w| w.parse()); - let _ = std::fs::remove_file(&temp_tx3); - assert!( - res.is_err(), - "expected error for duplicate type names from two imports" - ); - let err = res.unwrap_err(); - assert!( - err.to_string().contains("duplicate") || err.to_string().contains("type"), - "expected duplicate/type error, got: {}", - err - ); -} - -#[test] -fn invalid_import_path_errors() { - let manifest_dir = env!("CARGO_MANIFEST_DIR"); - let fixtures = Path::new(manifest_dir).join("tests/fixtures"); - let temp_tx3 = fixtures.join("temp_nonexistent_import.tx3"); - let content = r#" -import "nonexistent/plutus.json" as types; -party X; -tx dummy() {} -"#; - std::fs::write(&temp_tx3, content).unwrap(); - let res = Workspace::from_file(&temp_tx3).and_then(|mut w| w.parse()); - let _ = std::fs::remove_file(&temp_tx3); - assert!(res.is_err(), "expected error for missing import file"); -} diff --git a/examples/imported_datum.ast b/examples/imported_datum.ast new file mode 100644 index 00000000..4a5c6fd5 --- /dev/null +++ b/examples/imported_datum.ast @@ -0,0 +1,414 @@ +{ + "imports": [ + { + "path": { + "value": "../crates/cip-57/examples/plutus.json", + "span": { + "dummy": false, + "start": 7, + "end": 46 + } + }, + "alias": { + "value": "plutus", + "span": { + "dummy": false, + "start": 50, + "end": 56 + } + }, + "span": { + "dummy": false, + "start": 0, + "end": 57 + } + } + ], + "env": null, + "txs": [ + { + "name": { + "value": "create_settings", + "span": { + "dummy": false, + "start": 76, + "end": 91 + } + }, + "parameters": { + "parameters": [ + { + "name": { + "value": "now", + "span": { + "dummy": false, + "start": 97, + "end": 100 + } + }, + "type": "Int" + } + ], + "span": { + "dummy": false, + "start": 91, + "end": 108 + } + }, + "locals": null, + "references": [], + "inputs": [ + { + "name": "fee_source", + "many": false, + "fields": [ + { + "From": { + "Identifier": { + "value": "Admin", + "span": { + "dummy": false, + "start": 148, + "end": 153 + } + } + } + }, + { + "MinAmount": { + "Number": 1000000 + } + } + ], + "span": { + "dummy": false, + "start": 115, + "end": 189 + } + } + ], + "outputs": [ + { + "name": { + "value": "settings", + "span": { + "dummy": false, + "start": 202, + "end": 210 + } + }, + "optional": false, + "fields": [ + { + "To": { + "Identifier": { + "value": "Admin", + "span": { + "dummy": false, + "start": 225, + "end": 230 + } + } + } + }, + { + "Datum": { + "StructConstructor": { + "type": { + "value": "plutus_types_SettingsDatum", + "span": { + "dummy": false, + "start": 247, + "end": 273 + } + }, + "case": { + "name": { + "value": "SettingsDatum", + "span": { + "dummy": false, + "start": 275, + "end": 288 + } + }, + "fields": [ + { + "name": { + "value": "githoney_address", + "span": { + "dummy": false, + "start": 303, + "end": 319 + } + }, + "value": { + "StructConstructor": { + "type": { + "value": "plutus_cardano_address_Address", + "span": { + "dummy": false, + "start": 321, + "end": 351 + } + }, + "case": { + "name": { + "value": "Address", + "span": { + "dummy": false, + "start": 353, + "end": 360 + } + }, + "fields": [ + { + "name": { + "value": "payment_credential", + "span": { + "dummy": false, + "start": 379, + "end": 397 + } + }, + "value": { + "StructConstructor": { + "type": { + "value": "plutus_cardano_address_PaymentCredential", + "span": { + "dummy": false, + "start": 399, + "end": 439 + } + }, + "case": { + "name": { + "value": "VerificationKey", + "span": { + "dummy": false, + "start": 441, + "end": 456 + } + }, + "fields": [ + { + "name": { + "value": "field", + "span": { + "dummy": false, + "start": 479, + "end": 484 + } + }, + "value": { + "HexString": { + "value": "12345678901234567890123456789012345678901234567890123456", + "span": { + "dummy": false, + "start": 486, + "end": 544 + } + } + }, + "span": { + "dummy": false, + "start": 479, + "end": 544 + } + } + ], + "spread": null, + "span": { + "dummy": false, + "start": 439, + "end": 563 + } + }, + "span": { + "dummy": false, + "start": 399, + "end": 563 + } + } + }, + "span": { + "dummy": false, + "start": 379, + "end": 563 + } + }, + { + "name": { + "value": "stake_credential", + "span": { + "dummy": false, + "start": 581, + "end": 597 + } + }, + "value": { + "StructConstructor": { + "type": { + "value": "plutus_Option_cardano_address_StakeCredential", + "span": { + "dummy": false, + "start": 599, + "end": 644 + } + }, + "case": { + "name": { + "value": "None", + "span": { + "dummy": false, + "start": 646, + "end": 650 + } + }, + "fields": [], + "spread": null, + "span": { + "dummy": false, + "start": 644, + "end": 653 + } + }, + "span": { + "dummy": false, + "start": 599, + "end": 653 + } + } + }, + "span": { + "dummy": false, + "start": 581, + "end": 653 + } + } + ], + "spread": null, + "span": { + "dummy": false, + "start": 351, + "end": 668 + } + }, + "span": { + "dummy": false, + "start": 321, + "end": 668 + } + } + }, + "span": { + "dummy": false, + "start": 303, + "end": 668 + } + }, + { + "name": { + "value": "bounty_creation_fee", + "span": { + "dummy": false, + "start": 682, + "end": 701 + } + }, + "value": { + "Number": 500 + }, + "span": { + "dummy": false, + "start": 682, + "end": 706 + } + }, + { + "name": { + "value": "bounty_reward_fee", + "span": { + "dummy": false, + "start": 720, + "end": 737 + } + }, + "value": { + "Number": 100 + }, + "span": { + "dummy": false, + "start": 720, + "end": 742 + } + } + ], + "spread": null, + "span": { + "dummy": false, + "start": 273, + "end": 753 + } + }, + "span": { + "dummy": false, + "start": 247, + "end": 753 + } + } + } + }, + { + "Amount": { + "Number": 2000000 + } + } + ], + "span": { + "dummy": false, + "start": 195, + "end": 785 + } + } + ], + "validity": null, + "mints": [], + "burns": [], + "signers": null, + "adhoc": [], + "span": { + "dummy": false, + "start": 73, + "end": 787 + }, + "collateral": [], + "metadata": null + } + ], + "types": [], + "aliases": [], + "assets": [], + "parties": [ + { + "name": { + "value": "Admin", + "span": { + "dummy": false, + "start": 65, + "end": 70 + } + }, + "span": { + "dummy": false, + "start": 59, + "end": 71 + } + } + ], + "policies": [], + "span": { + "dummy": false, + "start": 0, + "end": 788 + } +} \ No newline at end of file diff --git a/examples/imported_datum.tx3 b/examples/imported_datum.tx3 new file mode 100644 index 00000000..5d0637c1 --- /dev/null +++ b/examples/imported_datum.tx3 @@ -0,0 +1,27 @@ +import "../crates/cip-57/examples/plutus.json" as plutus; + +party Admin; + +tx create_settings( + now: Int, +) { + input fee_source { + from: Admin, + min_amount: 1000000, + } + + output settings { + to: Admin, + datum: plutus_types_SettingsDatum::SettingsDatum { + githoney_address: plutus_cardano_address_Address::Address { + payment_credential: plutus_cardano_address_PaymentCredential::VerificationKey { + field: 0x12345678901234567890123456789012345678901234567890123456, + }, + stake_credential: plutus_Option_cardano_address_StakeCredential::None {}, + }, + bounty_creation_fee: 500, + bounty_reward_fee: 100, + }, + amount: 2000000, + } +} From 8fbf3c936be5a01af9acf0d3bdc9595894043f21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 6 Feb 2026 14:15:56 -0300 Subject: [PATCH 23/32] feat: single variant types have no explicit constructor --- crates/tx3-lang/src/importing.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/tx3-lang/src/importing.rs b/crates/tx3-lang/src/importing.rs index d8504db0..36bf4f60 100644 --- a/crates/tx3-lang/src/importing.rs +++ b/crates/tx3-lang/src/importing.rs @@ -215,6 +215,11 @@ fn definition_to_type_def( return Ok(None); } + let mut cases = cases; + if cases.len() == 1 { + cases[0].name = Identifier::new("Default"); + } + let type_name = import_type_name(key, alias); Ok(Some(TypeDef { name: Identifier::new(type_name), From 912952c74d15e0078f55da8e27cc23107ccf5cbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 6 Feb 2026 14:16:53 -0300 Subject: [PATCH 24/32] fix: Data has no internal structure Workaround turn it into an empty struct basically --- crates/tx3-lang/src/importing.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/tx3-lang/src/importing.rs b/crates/tx3-lang/src/importing.rs index 36bf4f60..421aeaaf 100644 --- a/crates/tx3-lang/src/importing.rs +++ b/crates/tx3-lang/src/importing.rs @@ -208,6 +208,19 @@ fn definition_to_type_def( .map(|s| schema_to_variant_case(s, definitions, alias)) .collect::, _>>()? } else { + // Data type has no structure but should still be imported. + // TODO: There is no Any for our type system, so for now we'll just import it as a single empty variant case. + if key == "Data" { + return Ok(Some(TypeDef { + name: Identifier::new(import_type_name(key, alias)), + cases: vec![VariantCase { + name: Identifier::new("Default"), + fields: vec![], + span: Span::DUMMY, + }], + span: Span::DUMMY, + })); + } return Ok(None); }; From 1601a1c2523376dfdf886a5fd69e3fb03752670a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 6 Feb 2026 14:17:20 -0300 Subject: [PATCH 25/32] feat: parsing import example with analyze phase to make sure that it works great e2e --- crates/tx3-lang/src/importing.rs | 70 ++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/crates/tx3-lang/src/importing.rs b/crates/tx3-lang/src/importing.rs index 421aeaaf..a13ed7fb 100644 --- a/crates/tx3-lang/src/importing.rs +++ b/crates/tx3-lang/src/importing.rs @@ -452,4 +452,74 @@ mod tests { let res = resolve_imports(&mut program, Some(&loader)); assert!(res.is_err(), "expected error for missing import file"); } + + #[test] + fn single_variant_type_import_with_analyze() { + let dollar = "$"; + let hash = "#"; + let json = format!(r#"{{ + "preamble": {{ "title": "test", "version": "0", "plutusVersion": "v3" }}, + "validators": [], + "definitions": {{ + "OutputReference": {{ + "title": "OutputReference", + "anyOf": [ + {{ + "title": "OutputReference", + "dataType": "constructor", + "index": 0, + "fields": [ + {{ + "title": "transaction_id", + "{}ref": "{}/definitions/ByteArray" + }}, + {{ + "title": "output_index", + "{}ref": "{}/definitions/Int" + }} + ] + }} + ] + }}, + "ByteArray": {{ + "title": "ByteArray", + "dataType": "bytes" + }}, + "Int": {{ + "title": "Int", + "dataType": "integer" + }} + }} + }}"#, dollar, hash, dollar, hash); + + let src = r#" + import "test.json" as types; + party Alice; + tx test(outref: types_OutputReference) { + output my_output { + to: Alice, + amount: 1000000, + } + } + "#; + + let mut program = crate::parsing::parse_string(src).unwrap(); + let mut loader = InMemoryLoader::new(); + loader.add("test.json", json); + + resolve_imports(&mut program, Some(&loader)).unwrap(); + + let output_ref_type = program.types.iter() + .find(|t| t.name.value == "types_OutputReference") + .expect("types_OutputReference should be imported"); + assert_eq!(output_ref_type.cases.len(), 1); + assert_eq!(output_ref_type.cases[0].name.value, "Default"); + + let report = crate::analyzing::analyze(&mut program); + assert!( + report.errors.is_empty(), + "expected no analysis errors, got: {:?}", + report.errors + ); + } } From 20ce92b43ffefe32cf2babe34523476c3a08a93a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 6 Feb 2026 14:52:22 -0300 Subject: [PATCH 26/32] chore: formating --- crates/tx3-lang/src/importing.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/tx3-lang/src/importing.rs b/crates/tx3-lang/src/importing.rs index a13ed7fb..7836dd16 100644 --- a/crates/tx3-lang/src/importing.rs +++ b/crates/tx3-lang/src/importing.rs @@ -457,7 +457,8 @@ mod tests { fn single_variant_type_import_with_analyze() { let dollar = "$"; let hash = "#"; - let json = format!(r#"{{ + let json = format!( + r#"{{ "preamble": {{ "title": "test", "version": "0", "plutusVersion": "v3" }}, "validators": [], "definitions": {{ @@ -490,7 +491,9 @@ mod tests { "dataType": "integer" }} }} - }}"#, dollar, hash, dollar, hash); + }}"#, + dollar, hash, dollar, hash + ); let src = r#" import "test.json" as types; @@ -506,10 +509,12 @@ mod tests { let mut program = crate::parsing::parse_string(src).unwrap(); let mut loader = InMemoryLoader::new(); loader.add("test.json", json); - + resolve_imports(&mut program, Some(&loader)).unwrap(); - let output_ref_type = program.types.iter() + let output_ref_type = program + .types + .iter() .find(|t| t.name.value == "types_OutputReference") .expect("types_OutputReference should be imported"); assert_eq!(output_ref_type.cases.len(), 1); From 0e79d5089935eab6a6e22c4a7733b06442640165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 6 Feb 2026 14:57:38 -0300 Subject: [PATCH 27/32] fix: use the same serde_json lib as everywhere else --- crates/cip-57/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cip-57/Cargo.toml b/crates/cip-57/Cargo.toml index d70aa572..a339ae1f 100644 --- a/crates/cip-57/Cargo.toml +++ b/crates/cip-57/Cargo.toml @@ -15,4 +15,4 @@ readme.workspace = true [dependencies] serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = "1.0.137" From e42d42a4928752b1a99839d03fa31021f5363a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 6 Feb 2026 15:02:53 -0300 Subject: [PATCH 28/32] chore: remove unnecessary code branch --- crates/tx3-lang/src/importing.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/tx3-lang/src/importing.rs b/crates/tx3-lang/src/importing.rs index 7836dd16..f56b3a59 100644 --- a/crates/tx3-lang/src/importing.rs +++ b/crates/tx3-lang/src/importing.rs @@ -149,10 +149,6 @@ fn resolve_ref_to_type( } } - if def.any_of.is_some() { - return Ok(Type::Custom(Identifier::new(import_type_name(&key, alias)))); - } - Ok(Type::Custom(Identifier::new(import_type_name(&key, alias)))) } From 5be14b80009ccbfb12f756960cf962774dd019b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 6 Feb 2026 15:25:28 -0300 Subject: [PATCH 29/32] fix: ast & tir files up-to-date --- examples/asteria.ast | 1 + examples/buidler_fest_2026.ast | 1 + examples/burn.ast | 1 + examples/cardano_witness.ast | 1 + examples/cardano_witness.mint_from_plutus.tir | 6 +- examples/disordered.ast | 1 + examples/donation.ast | 1 + examples/env_vars.ast | 1 + examples/faucet.ast | 1 + examples/imported_datum.ast | 124 +++++++++--------- examples/imported_datum.tx3 | 4 +- examples/input_datum.ast | 1 + examples/lang_tour.ast | 1 + examples/lang_tour.my_tx.tir | 22 ++-- examples/list_concat.ast | 1 + examples/local_vars.ast | 1 + examples/map.ast | 1 + examples/reference_script.ast | 1 + examples/reference_script.publish_native.tir | 22 ++-- examples/reference_script.publish_plutus.tir | 38 +++--- examples/swap.ast | 1 + examples/transfer.ast | 1 + examples/vesting.ast | 1 + examples/withdrawal.ast | 1 + examples/withdrawal.transfer.tir | 12 +- 25 files changed, 132 insertions(+), 114 deletions(-) diff --git a/examples/asteria.ast b/examples/asteria.ast index 4b556cb1..6e284ff7 100644 --- a/examples/asteria.ast +++ b/examples/asteria.ast @@ -1,4 +1,5 @@ { + "imports": [], "env": null, "txs": [ { diff --git a/examples/buidler_fest_2026.ast b/examples/buidler_fest_2026.ast index c0c0f570..96b13316 100644 --- a/examples/buidler_fest_2026.ast +++ b/examples/buidler_fest_2026.ast @@ -1,4 +1,5 @@ { + "imports": [], "env": { "fields": [ { diff --git a/examples/burn.ast b/examples/burn.ast index 6eafd087..1ed27f63 100644 --- a/examples/burn.ast +++ b/examples/burn.ast @@ -1,4 +1,5 @@ { + "imports": [], "env": null, "txs": [ { diff --git a/examples/cardano_witness.ast b/examples/cardano_witness.ast index 9136d18d..4fe2ba60 100644 --- a/examples/cardano_witness.ast +++ b/examples/cardano_witness.ast @@ -1,4 +1,5 @@ { + "imports": [], "env": null, "txs": [ { diff --git a/examples/cardano_witness.mint_from_plutus.tir b/examples/cardano_witness.mint_from_plutus.tir index c0f4530f..8ff6e2e2 100644 --- a/examples/cardano_witness.mint_from_plutus.tir +++ b/examples/cardano_witness.mint_from_plutus.tir @@ -202,9 +202,6 @@ { "name": "plutus_witness", "data": { - "version": { - "Number": 3 - }, "script": { "Bytes": [ 81, @@ -226,6 +223,9 @@ 174, 105 ] + }, + "version": { + "Number": 3 } } } diff --git a/examples/disordered.ast b/examples/disordered.ast index de9c48f3..0983c7f2 100644 --- a/examples/disordered.ast +++ b/examples/disordered.ast @@ -1,4 +1,5 @@ { + "imports": [], "env": null, "txs": [ { diff --git a/examples/donation.ast b/examples/donation.ast index f82e5c24..0660f4f4 100644 --- a/examples/donation.ast +++ b/examples/donation.ast @@ -1,4 +1,5 @@ { + "imports": [], "env": null, "txs": [ { diff --git a/examples/env_vars.ast b/examples/env_vars.ast index cef2b474..373b6ebc 100644 --- a/examples/env_vars.ast +++ b/examples/env_vars.ast @@ -1,4 +1,5 @@ { + "imports": [], "env": { "fields": [ { diff --git a/examples/faucet.ast b/examples/faucet.ast index e961c1d4..f82b8247 100644 --- a/examples/faucet.ast +++ b/examples/faucet.ast @@ -1,4 +1,5 @@ { + "imports": [], "env": null, "txs": [ { diff --git a/examples/imported_datum.ast b/examples/imported_datum.ast index 4a5c6fd5..5b2abd96 100644 --- a/examples/imported_datum.ast +++ b/examples/imported_datum.ast @@ -124,11 +124,11 @@ }, "case": { "name": { - "value": "SettingsDatum", + "value": "Default", "span": { - "dummy": false, - "start": 275, - "end": 288 + "dummy": true, + "start": 0, + "end": 0 } }, "fields": [ @@ -137,8 +137,8 @@ "value": "githoney_address", "span": { "dummy": false, - "start": 303, - "end": 319 + "start": 288, + "end": 304 } }, "value": { @@ -147,17 +147,17 @@ "value": "plutus_cardano_address_Address", "span": { "dummy": false, - "start": 321, - "end": 351 + "start": 306, + "end": 336 } }, "case": { "name": { - "value": "Address", + "value": "Default", "span": { - "dummy": false, - "start": 353, - "end": 360 + "dummy": true, + "start": 0, + "end": 0 } }, "fields": [ @@ -166,8 +166,8 @@ "value": "payment_credential", "span": { "dummy": false, - "start": 379, - "end": 397 + "start": 355, + "end": 373 } }, "value": { @@ -176,8 +176,8 @@ "value": "plutus_cardano_address_PaymentCredential", "span": { "dummy": false, - "start": 399, - "end": 439 + "start": 375, + "end": 415 } }, "case": { @@ -185,8 +185,8 @@ "value": "VerificationKey", "span": { "dummy": false, - "start": 441, - "end": 456 + "start": 417, + "end": 432 } }, "fields": [ @@ -195,8 +195,8 @@ "value": "field", "span": { "dummy": false, - "start": 479, - "end": 484 + "start": 455, + "end": 460 } }, "value": { @@ -204,36 +204,36 @@ "value": "12345678901234567890123456789012345678901234567890123456", "span": { "dummy": false, - "start": 486, - "end": 544 + "start": 462, + "end": 520 } } }, "span": { "dummy": false, - "start": 479, - "end": 544 + "start": 455, + "end": 520 } } ], "spread": null, "span": { "dummy": false, - "start": 439, - "end": 563 + "start": 415, + "end": 539 } }, "span": { "dummy": false, - "start": 399, - "end": 563 + "start": 375, + "end": 539 } } }, "span": { "dummy": false, - "start": 379, - "end": 563 + "start": 355, + "end": 539 } }, { @@ -241,8 +241,8 @@ "value": "stake_credential", "span": { "dummy": false, - "start": 581, - "end": 597 + "start": 557, + "end": 573 } }, "value": { @@ -251,8 +251,8 @@ "value": "plutus_Option_cardano_address_StakeCredential", "span": { "dummy": false, - "start": 599, - "end": 644 + "start": 575, + "end": 620 } }, "case": { @@ -260,50 +260,50 @@ "value": "None", "span": { "dummy": false, - "start": 646, - "end": 650 + "start": 622, + "end": 626 } }, "fields": [], "spread": null, "span": { "dummy": false, - "start": 644, - "end": 653 + "start": 620, + "end": 629 } }, "span": { "dummy": false, - "start": 599, - "end": 653 + "start": 575, + "end": 629 } } }, "span": { "dummy": false, - "start": 581, - "end": 653 + "start": 557, + "end": 629 } } ], "spread": null, "span": { "dummy": false, - "start": 351, - "end": 668 + "start": 337, + "end": 644 } }, "span": { "dummy": false, - "start": 321, - "end": 668 + "start": 306, + "end": 644 } } }, "span": { "dummy": false, - "start": 303, - "end": 668 + "start": 288, + "end": 644 } }, { @@ -311,8 +311,8 @@ "value": "bounty_creation_fee", "span": { "dummy": false, - "start": 682, - "end": 701 + "start": 658, + "end": 677 } }, "value": { @@ -320,8 +320,8 @@ }, "span": { "dummy": false, - "start": 682, - "end": 706 + "start": 658, + "end": 682 } }, { @@ -329,8 +329,8 @@ "value": "bounty_reward_fee", "span": { "dummy": false, - "start": 720, - "end": 737 + "start": 696, + "end": 713 } }, "value": { @@ -338,22 +338,22 @@ }, "span": { "dummy": false, - "start": 720, - "end": 742 + "start": 696, + "end": 718 } } ], "spread": null, "span": { "dummy": false, - "start": 273, - "end": 753 + "start": 274, + "end": 729 } }, "span": { "dummy": false, "start": 247, - "end": 753 + "end": 729 } } } @@ -367,7 +367,7 @@ "span": { "dummy": false, "start": 195, - "end": 785 + "end": 761 } } ], @@ -379,7 +379,7 @@ "span": { "dummy": false, "start": 73, - "end": 787 + "end": 763 }, "collateral": [], "metadata": null @@ -409,6 +409,6 @@ "span": { "dummy": false, "start": 0, - "end": 788 + "end": 764 } } \ No newline at end of file diff --git a/examples/imported_datum.tx3 b/examples/imported_datum.tx3 index 5d0637c1..339f3aea 100644 --- a/examples/imported_datum.tx3 +++ b/examples/imported_datum.tx3 @@ -12,8 +12,8 @@ tx create_settings( output settings { to: Admin, - datum: plutus_types_SettingsDatum::SettingsDatum { - githoney_address: plutus_cardano_address_Address::Address { + datum: plutus_types_SettingsDatum { + githoney_address: plutus_cardano_address_Address { payment_credential: plutus_cardano_address_PaymentCredential::VerificationKey { field: 0x12345678901234567890123456789012345678901234567890123456, }, diff --git a/examples/input_datum.ast b/examples/input_datum.ast index c0cb2672..55b334ae 100644 --- a/examples/input_datum.ast +++ b/examples/input_datum.ast @@ -1,4 +1,5 @@ { + "imports": [], "env": null, "txs": [ { diff --git a/examples/lang_tour.ast b/examples/lang_tour.ast index cfe0e4cd..2a3b452b 100644 --- a/examples/lang_tour.ast +++ b/examples/lang_tour.ast @@ -1,4 +1,5 @@ { + "imports": [], "env": { "fields": [ { diff --git a/examples/lang_tour.my_tx.tir b/examples/lang_tour.my_tx.tir index 525dcb75..f60eca2f 100644 --- a/examples/lang_tour.my_tx.tir +++ b/examples/lang_tour.my_tx.tir @@ -645,14 +645,6 @@ { "name": "vote_delegation_certificate", "data": { - "stake": { - "Bytes": [ - 135, - 101, - 67, - 33 - ] - }, "drep": { "Bytes": [ 18, @@ -660,6 +652,14 @@ 86, 120 ] + }, + "stake": { + "Bytes": [ + 135, + 101, + 67, + 33 + ] } } }, @@ -688,6 +688,9 @@ { "name": "plutus_witness", "data": { + "version": { + "Number": 2 + }, "script": { "Bytes": [ 171, @@ -696,9 +699,6 @@ 18, 52 ] - }, - "version": { - "Number": 2 } } }, diff --git a/examples/list_concat.ast b/examples/list_concat.ast index f8b6c17f..2708bb0d 100644 --- a/examples/list_concat.ast +++ b/examples/list_concat.ast @@ -1,4 +1,5 @@ { + "imports": [], "env": null, "txs": [ { diff --git a/examples/local_vars.ast b/examples/local_vars.ast index 432bb4ff..8f52defe 100644 --- a/examples/local_vars.ast +++ b/examples/local_vars.ast @@ -1,4 +1,5 @@ { + "imports": [], "env": null, "txs": [ { diff --git a/examples/map.ast b/examples/map.ast index 148694e0..224baad4 100644 --- a/examples/map.ast +++ b/examples/map.ast @@ -1,4 +1,5 @@ { + "imports": [], "env": null, "txs": [ { diff --git a/examples/reference_script.ast b/examples/reference_script.ast index ccb5f6db..3764b1ab 100644 --- a/examples/reference_script.ast +++ b/examples/reference_script.ast @@ -1,4 +1,5 @@ { + "imports": [], "env": null, "txs": [ { diff --git a/examples/reference_script.publish_native.tir b/examples/reference_script.publish_native.tir index 90fdd3b9..644738e7 100644 --- a/examples/reference_script.publish_native.tir +++ b/examples/reference_script.publish_native.tir @@ -147,6 +147,17 @@ 0 ] }, + "to": { + "EvalParam": { + "ExpectValue": [ + "receiver", + "Address" + ] + } + }, + "version": { + "Number": 0 + }, "amount": { "Assets": [ { @@ -162,17 +173,6 @@ } } ] - }, - "to": { - "EvalParam": { - "ExpectValue": [ - "receiver", - "Address" - ] - } - }, - "version": { - "Number": 0 } } } diff --git a/examples/reference_script.publish_plutus.tir b/examples/reference_script.publish_plutus.tir index 7506c0f5..7a17ad71 100644 --- a/examples/reference_script.publish_plutus.tir +++ b/examples/reference_script.publish_plutus.tir @@ -137,25 +137,6 @@ { "name": "cardano_publish", "data": { - "version": { - "Number": 3 - }, - "amount": { - "Assets": [ - { - "policy": "None", - "asset_name": "None", - "amount": { - "EvalParam": { - "ExpectValue": [ - "quantity", - "Int" - ] - } - } - } - ] - }, "script": { "Bytes": [ 81, @@ -178,6 +159,22 @@ 105 ] }, + "amount": { + "Assets": [ + { + "policy": "None", + "asset_name": "None", + "amount": { + "EvalParam": { + "ExpectValue": [ + "quantity", + "Int" + ] + } + } + } + ] + }, "to": { "EvalParam": { "ExpectValue": [ @@ -185,6 +182,9 @@ "Address" ] } + }, + "version": { + "Number": 3 } } } diff --git a/examples/swap.ast b/examples/swap.ast index 8d915d0f..db090bc7 100644 --- a/examples/swap.ast +++ b/examples/swap.ast @@ -1,4 +1,5 @@ { + "imports": [], "env": null, "txs": [ { diff --git a/examples/transfer.ast b/examples/transfer.ast index a21a5413..0fce4dc3 100644 --- a/examples/transfer.ast +++ b/examples/transfer.ast @@ -1,4 +1,5 @@ { + "imports": [], "env": null, "txs": [ { diff --git a/examples/vesting.ast b/examples/vesting.ast index dac2dfa9..1ae0698a 100644 --- a/examples/vesting.ast +++ b/examples/vesting.ast @@ -1,4 +1,5 @@ { + "imports": [], "env": null, "txs": [ { diff --git a/examples/withdrawal.ast b/examples/withdrawal.ast index 0f0e7382..ce7cc4d1 100644 --- a/examples/withdrawal.ast +++ b/examples/withdrawal.ast @@ -1,4 +1,5 @@ { + "imports": [], "env": null, "txs": [ { diff --git a/examples/withdrawal.transfer.tir b/examples/withdrawal.transfer.tir index facdbb3c..48a914f8 100644 --- a/examples/withdrawal.transfer.tir +++ b/examples/withdrawal.transfer.tir @@ -165,6 +165,12 @@ { "name": "withdrawal", "data": { + "redeemer": { + "Struct": { + "constructor": 0, + "fields": [] + } + }, "credential": { "EvalParam": { "ExpectValue": [ @@ -175,12 +181,6 @@ }, "amount": { "Number": 0 - }, - "redeemer": { - "Struct": { - "constructor": 0, - "fields": [] - } } } } From 23a74299e1d1a9ada36e538ef38f8727dc3e01b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 6 Feb 2026 17:52:14 -0300 Subject: [PATCH 30/32] feat: TxDef to tx3 source code --- crates/tx3-lang/src/ast.rs | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/tx3-lang/src/ast.rs b/crates/tx3-lang/src/ast.rs index c34e43fe..7d172b00 100644 --- a/crates/tx3-lang/src/ast.rs +++ b/crates/tx3-lang/src/ast.rs @@ -943,6 +943,28 @@ impl TypeDef { pub(crate) fn find_case(&self, case: &str) -> Option<&VariantCase> { self.cases.iter().find(|x| x.name.value == case) } + + pub fn to_tx3_source(&self) -> String { + let name = &self.name.value; + // Implicit cases don't have an explicit constructor on its usage + if self.cases.len() == 1 && self.cases[0].name.value == "Default" { + let fields = &self.cases[0].fields; + let fields_str = fields + .iter() + .map(|f| format!("{}: {}", f.name.value, f.r#type)) + .collect::>() + .join(", "); + format!("type {} {{ {} }}", name, fields_str) + } else { + let cases_str = self + .cases + .iter() + .map(VariantCase::to_tx3_source) + .collect::>() + .join(", "); + format!("type {} {{ {} }}", name, cases_str) + } + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -962,6 +984,21 @@ impl VariantCase { pub(crate) fn find_field(&self, field: &str) -> Option<&RecordField> { self.fields.iter().find(|x| x.name.value == field) } + + fn to_tx3_source(&self) -> String { + let name = &self.name.value; + if self.fields.is_empty() { + name.clone() + } else { + let fields_str = self + .fields + .iter() + .map(|f| format!("{}: {}", f.name.value, f.r#type)) + .collect::>() + .join(", "); + format!("{} {{ {} }}", name, fields_str) + } + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] From 13bada89949ffb27d303e283edaff077c046fff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 6 Feb 2026 17:54:14 -0300 Subject: [PATCH 31/32] refactor: resolve types from blueprint logic extracted into function --- crates/tx3-lang/src/importing.rs | 38 ++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/crates/tx3-lang/src/importing.rs b/crates/tx3-lang/src/importing.rs index f56b3a59..6f1369b7 100644 --- a/crates/tx3-lang/src/importing.rs +++ b/crates/tx3-lang/src/importing.rs @@ -237,6 +237,23 @@ fn definition_to_type_def( })) } +pub fn type_defs_from_blueprint( + blueprint: &Blueprint, + alias: Option<&str>, +) -> Result, Error> { + let definitions = match &blueprint.definitions { + Some(d) => d, + None => return Ok(vec![]), + }; + + let mut type_defs = Vec::new(); + for (key, def) in &definitions.inner { + if let Some(type_def) = definition_to_type_def(key, def, definitions, alias)? { + type_defs.push(type_def); + } + } + Ok(type_defs) +} pub fn resolve_imports( program: &mut Program, loader: Option<&impl ImportLoader>, @@ -257,23 +274,16 @@ pub fn resolve_imports( for import in &program.imports { let json = loader.load_source(import.path.value.as_str())?; let blueprint: Blueprint = serde_json::from_str(&json)?; - - let definitions = match &blueprint.definitions { - Some(d) => d, - None => continue, - }; - let alias = import.alias.as_ref().map(|a| a.value.as_str()); + let type_defs = type_defs_from_blueprint(&blueprint, alias)?; - for (key, def) in &definitions.inner { - if let Some(type_def) = definition_to_type_def(key, def, definitions, alias)? { - let name = type_def.name.value.clone(); - if existing_names.contains(&name) || added_names.contains(&name) { - return Err(Error::duplicate_type(name, import.span.clone())); - } - added_names.insert(name.clone()); - program.types.push(type_def); + for type_def in type_defs { + let name = type_def.name.value.clone(); + if existing_names.contains(&name) || added_names.contains(&name) { + return Err(Error::duplicate_type(name, import.span.clone())); } + added_names.insert(name.clone()); + program.types.push(type_def); } } From 726b4571f3650eff384b18e3c4a9e26bb8a0364b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 6 Feb 2026 17:54:55 -0300 Subject: [PATCH 32/32] feat: types from plutus public on importing crate --- crates/tx3-lang/src/importing.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/tx3-lang/src/importing.rs b/crates/tx3-lang/src/importing.rs index 6f1369b7..7fc94ced 100644 --- a/crates/tx3-lang/src/importing.rs +++ b/crates/tx3-lang/src/importing.rs @@ -254,6 +254,17 @@ pub fn type_defs_from_blueprint( } Ok(type_defs) } + +pub fn types_from_plutus( + path: &str, + alias: Option<&str>, + loader: &impl ImportLoader, +) -> Result, Error> { + let json = loader.load_source(path)?; + let blueprint: Blueprint = serde_json::from_str(&json)?; + type_defs_from_blueprint(&blueprint, alias) +} + pub fn resolve_imports( program: &mut Program, loader: Option<&impl ImportLoader>,