From 0f01ba1be83ac21793f312043b9f001b733144d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 13 Feb 2026 10:57:29 -0300 Subject: [PATCH 1/4] feat: language views with multiple plutus languages --- pallas-primitives/src/conway/script_data.rs | 80 ++++++++++++--------- 1 file changed, 46 insertions(+), 34 deletions(-) diff --git a/pallas-primitives/src/conway/script_data.rs b/pallas-primitives/src/conway/script_data.rs index 8ff349b74..255bf8a44 100644 --- a/pallas-primitives/src/conway/script_data.rs +++ b/pallas-primitives/src/conway/script_data.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use super::{CostModel, PlutusData, Redeemers, WitnessSet}; use pallas_codec::minicbor::{self, Encode}; use pallas_codec::utils::{KeepRaw, NonEmptySet}; @@ -5,38 +7,51 @@ use serde::{Deserialize, Serialize}; pub type PlutusVersion = u8; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct LanguageView(pub PlutusVersion, pub CostModel); +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct LanguageViews(pub BTreeMap); -impl Encode for LanguageView { +impl FromIterator<(PlutusVersion, CostModel)> for LanguageViews { + fn from_iter>(iter: I) -> Self { + Self(iter.into_iter().collect()) + } +} + +impl Encode for LanguageViews { fn encode( &self, e: &mut minicbor::Encoder, ctx: &mut C, ) -> Result<(), minicbor::encode::Error> { - match self.0 { - 0 => { - let mut inner = vec![]; - let mut sub = minicbor::Encoder::new(&mut inner); - - sub.begin_array().unwrap(); - for v in self.1.iter() { - sub.encode_with(v, ctx).unwrap(); - } - sub.end().unwrap(); + let order: Vec = self.0.keys().copied().collect(); + let mut canonical_order: Vec = order.into_iter().filter(|&k| k != 0).collect(); + canonical_order.sort(); + // PlutusV1 is CBOR encoded as 0x4100 so it goes last + if self.0.contains_key(&0) { + canonical_order.push(0); + } - e.map(1)?; - e.bytes(&minicbor::to_vec(0).unwrap())?; - e.bytes(&inner)?; - Ok(()) - } - _ => { - e.map(1)?; - e.encode(self.0)?; - e.encode(&self.1)?; - Ok(()) + e.map(self.0.len() as u64)?; + for lang in canonical_order { + let cost_model = self.0.get(&lang).unwrap(); + match lang { + 0 => { + let mut inner = vec![]; + let mut sub = minicbor::Encoder::new(&mut inner); + sub.begin_array().unwrap(); + for v in cost_model.iter() { + sub.encode_with(v, ctx).unwrap(); + } + sub.end().unwrap(); + e.bytes(&minicbor::to_vec(0).unwrap())?; + e.bytes(&inner)?; + } + _ => { + e.encode(lang)?; + e.encode(cost_model)?; + } } } + Ok(()) } } @@ -44,7 +59,7 @@ impl Encode for LanguageView { pub struct ScriptData<'b> { pub redeemers: Option, pub datums: Option>>>, - pub language_view: Option, + pub language_views: Option, } impl ScriptData<'_> { @@ -61,8 +76,8 @@ impl ScriptData<'_> { minicbor::encode(datums, &mut buf).unwrap(); // infallible } - if let Some(language_view) = &self.language_view { - minicbor::encode(language_view, &mut buf).unwrap(); // infallible + if let Some(language_views) = &self.language_views { + minicbor::encode(language_views, &mut buf).unwrap(); // infallible } else { buf.push(0xa0); } @@ -74,20 +89,17 @@ impl ScriptData<'_> { impl<'b> ScriptData<'b> { pub fn build_for( witness: &WitnessSet<'b>, - language_view_opt: &Option, + language_views_opt: &Option, ) -> Option { let redeemers = witness.redeemer.as_ref().map(|x| x.to_owned().unwrap()); let datums = witness.plutus_data.clone(); - // Only return None if both redeemers and datums are None if redeemers.is_none() && datums.is_none() { return None; } - // When redeemers are present, include the language view - // When only datums are present, language view should be None (empty map in hash) - let language_view = if redeemers.is_some() && language_view_opt.is_some() { - language_view_opt.clone() + let language_views = if redeemers.is_some() && language_views_opt.is_some() { + language_views_opt.clone() } else { None }; @@ -95,7 +107,7 @@ impl<'b> ScriptData<'b> { Some(ScriptData { redeemers, datums, - language_view, + language_views, }) } } @@ -108,7 +120,7 @@ mod tests { use super::*; - const COST_MODEL_PLUTUS_V1: LazyLock> = LazyLock::new(|| { + static COST_MODEL_PLUTUS_V1: LazyLock> = LazyLock::new(|| { vec![ 100788, 420, 1, 1, 1000, 173, 0, 1, 1000, 59957, 4, 1, 11183, 32, 201305, 8356, 4, 16000, 100, 16000, 100, 16000, 100, 16000, 100, 16000, 100, 16000, 100, 100, 100, From 7d86caccd495e94a59a73767f3c200de30f04f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 13 Feb 2026 10:57:58 -0300 Subject: [PATCH 2/4] feat: tests for script data with multiple plutus languages --- pallas-primitives/src/conway/script_data.rs | 58 ++++++++++++++++++--- test_data/conway9.tx | 1 + 2 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 test_data/conway9.tx diff --git a/pallas-primitives/src/conway/script_data.rs b/pallas-primitives/src/conway/script_data.rs index 255bf8a44..019760f38 100644 --- a/pallas-primitives/src/conway/script_data.rs +++ b/pallas-primitives/src/conway/script_data.rs @@ -152,33 +152,75 @@ mod tests { ] }); - const TEST_VECTORS: LazyLock, Option)>> = LazyLock::new(|| { + static COST_MODEL_PLUTUS_V3: LazyLock> = LazyLock::new(|| { + vec![ + 100788, 420, 1, 1, 1000, 173, 0, 1, 1000, 59957, 4, 1, 11183, 32, 201305, 8356, 4, + 16000, 100, 16000, 100, 16000, 100, 16000, 100, 16000, 100, 16000, 100, 100, 100, + 16000, 100, 94375, 32, 132994, 32, 61462, 4, 72010, 178, 0, 1, 22151, 32, 91189, 769, + 4, 2, 85848, 123203, 7305, -900, 1716, 549, 57, 85848, 0, 1, 1, 1000, 42921, 4, 2, + 24548, 29498, 38, 1, 898148, 27279, 1, 51775, 558, 1, 39184, 1000, 60594, 1, 141895, + 32, 83150, 32, 15299, 32, 76049, 1, 13169, 4, 22100, 10, 28999, 74, 1, 28999, 74, 1, + 43285, 552, 1, 44749, 541, 1, 33852, 32, 68246, 32, 72362, 32, 7243, 32, 7391, 32, + 11546, 32, 85848, 123203, 7305, -900, 1716, 549, 57, 85848, 0, 1, 90434, 519, 0, 1, + 74433, 32, 85848, 123203, 7305, -900, 1716, 549, 57, 85848, 0, 1, 1, 85848, 123203, + 7305, -900, 1716, 549, 57, 85848, 0, 1, 955506, 213312, 0, 2, 270652, 22588, 4, + 1457325, 64566, 4, 20467, 1, 4, 0, 141992, 32, 100788, 420, 1, 1, 81663, 32, 59498, 32, + 20142, 32, 24588, 32, 20744, 32, 25933, 32, 24623, 32, 43053543, 10, 53384111, 14333, + 10, 43574283, 26308, 10, 16000, 100, 16000, 100, 962335, 18, 2780678, 6, 442008, 1, + 52538055, 3756, 18, 267929, 18, 76433006, 8868, 18, 52948122, 18, 1995836, 36, 3227919, + 12, 901022, 1, 166917843, 4307, 36, 284546, 36, 158221314, 26549, 36, 74698472, 36, + 333849714, 1, 254006273, 72, 2174038, 72, 2261318, 64571, 4, 207616, 8310, 4, 1293828, + 28716, 63, 0, 1, 1006041, 43623, 251, 0, 1, 100181, 726, 719, 0, 1, 100181, 726, 719, + 0, 1, 100181, 726, 719, 0, 1, 107878, 680, 0, 1, 95336, 1, 281145, 18848, 0, 1, 180194, + 159, 1, 1, 158519, 8942, 0, 1, 159378, 8813, 0, 1, 107490, 3298, 1, 106057, 655, 1, + 1964219, 24520, 3, + ] + }); + + static TEST_VECTORS: LazyLock, Option)>> = LazyLock::new(|| { vec![ ( hex::decode(include_str!("../../../test_data/conway1.tx")).unwrap(), - Some(LanguageView(1, COST_MODEL_PLUTUS_V2.clone())), + Some(LanguageViews::from_iter([( + 1, + COST_MODEL_PLUTUS_V2.clone(), + )])), ), ( hex::decode(include_str!("../../../test_data/conway2.tx")).unwrap(), - Some(LanguageView(0, COST_MODEL_PLUTUS_V1.clone())), + Some(LanguageViews::from_iter([( + 0, + COST_MODEL_PLUTUS_V1.clone(), + )])), ), ( hex::decode(include_str!("../../../test_data/hydra-init.tx")).unwrap(), - Some(LanguageView(1, COST_MODEL_PLUTUS_V2.clone())), + Some(LanguageViews::from_iter([( + 1, + COST_MODEL_PLUTUS_V2.clone(), + )])), ), ( hex::decode(include_str!("../../../test_data/datum-only.tx")).unwrap(), None, ), + ( + hex::decode(include_str!("../../../test_data/conway9.tx")).unwrap(), + Some(LanguageViews::from_iter([ + (0, COST_MODEL_PLUTUS_V1.clone()), + (1, COST_MODEL_PLUTUS_V2.clone()), + (2, COST_MODEL_PLUTUS_V3.clone()), + ])), + ), ] }); - fn assert_script_data_hash_matches(bytes: &[u8], language_view_opt: &Option) { + fn assert_script_data_hash_matches(bytes: &[u8], language_views_opt: &Option) { let tx: Tx = pallas_codec::minicbor::decode(bytes).unwrap(); let witness = tx.transaction_witness_set.clone().unwrap(); - let script_data = ScriptData::build_for(&witness, language_view_opt).unwrap(); + let script_data = ScriptData::build_for(&witness, language_views_opt).unwrap(); let obtained = script_data.hash(); @@ -189,8 +231,8 @@ mod tests { #[test] fn test_script_data_hash() { - for (bytes, language_view_opt) in TEST_VECTORS.iter() { - assert_script_data_hash_matches(bytes, language_view_opt); + for (bytes, language_views_opt) in TEST_VECTORS.iter() { + assert_script_data_hash_matches(bytes, language_views_opt); } } } diff --git a/test_data/conway9.tx b/test_data/conway9.tx new file mode 100644 index 000000000..35cc1ff5c --- /dev/null +++ b/test_data/conway9.tx @@ -0,0 +1 @@ +84a800d90102818258205173c41edccb5d79b811f965058ce867e80565a7e361d9332e51ebb841fa1dd1000181825839004693c0ac525d045cb0a4e75bd3adbd6956b3b744e88d21e041fc9b630df092006419e469e0c77876a499124bf903735b434c7989f7a8090a821b000000025400ada8a3581c186e32faa80a26810392fda6d559c7ed4721a65ce1c9d4ef3e1c87b4a146534f4449544102581c39c520d0627aafa728f7e4dd10142b77c257813c36f57e2cb88f72a5a146534f4449544102581c67f33146617a5e61936081db3b2117cbf59bd2123748f58ac9678656a146534f4449544102021a0002ef7909a3581c186e32faa80a26810392fda6d559c7ed4721a65ce1c9d4ef3e1c87b4a146534f4449544101581c39c520d0627aafa728f7e4dd10142b77c257813c36f57e2cb88f72a5a146534f4449544101581c67f33146617a5e61936081db3b2117cbf59bd2123748f58ac9678656a146534f44495441010b5820fcaa431d492db613dd7568732889ba12952507a1ea7abab1838c5c4a869ea22f0dd90102818258205173c41edccb5d79b811f965058ce867e80565a7e361d9332e51ebb841fa1dd10010825839004693c0ac525d045cb0a4e75bd3adbd6956b3b744e88d21e041fc9b630df092006419e469e0c77876a499124bf903735b434c7989f7a8090a821b0000000253ff35eba3581c186e32faa80a26810392fda6d559c7ed4721a65ce1c9d4ef3e1c87b4a146534f4449544101581c39c520d0627aafa728f7e4dd10142b77c257813c36f57e2cb88f72a5a146534f4449544101581c67f33146617a5e61936081db3b2117cbf59bd2123748f58ac9678656a146534f4449544101111a00046736a500d9010281825820e6e75c7876c610afd28a19c2947e72c1a629ab7903283e14915badee7c55402e5840b2717517a1df898c0a78d23714e719f8658e1e861ee142a26055989af58a5b30a3977b937ad48c225573ac8629c02f489da24076b1bb3c64e5bae806408bfa0903d90102814e4d0100003322222005120012001105a38201008200821901f419fa648201018200821904b01a0002afe48201028200821905781a00032ce406d901028152510100003222253330044a229309b2b2b9a107d901028146450101002499f5f6 \ No newline at end of file From b8753ed2d8adc1218d23fce5e66361e419d16c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 13 Feb 2026 10:58:29 -0300 Subject: [PATCH 3/4] feat: use language views instead of single language view per tx --- pallas-txbuilder/src/conway.rs | 4 +- pallas-txbuilder/src/transaction/model.rs | 30 ++++++++---- pallas-txbuilder/src/transaction/serialise.rs | 5 +- pallas-validate/src/phase1/conway.rs | 48 ++++++++++--------- 4 files changed, 53 insertions(+), 34 deletions(-) diff --git a/pallas-txbuilder/src/conway.rs b/pallas-txbuilder/src/conway.rs index 6602bcc8c..82da80399 100644 --- a/pallas-txbuilder/src/conway.rs +++ b/pallas-txbuilder/src/conway.rs @@ -227,11 +227,11 @@ impl BuildConway for StagingTransaction { None }; - let script_data_hash = self.language_view.map(|language_view| { + let script_data_hash = self.language_views.as_ref().map(|language_views| { let dta = pallas_primitives::conway::ScriptData { redeemers: Some(witness_set_redeemers.clone()), datums: witness_set_datums.clone(), - language_view: Some(language_view), + language_views: Some(language_views.clone()), }; dta.hash() diff --git a/pallas-txbuilder/src/transaction/model.rs b/pallas-txbuilder/src/transaction/model.rs index 8e663decc..92015aae9 100644 --- a/pallas-txbuilder/src/transaction/model.rs +++ b/pallas-txbuilder/src/transaction/model.rs @@ -41,7 +41,7 @@ pub struct StagingTransaction { pub script_data_hash: Option, pub signature_amount_override: Option, pub change_address: Option
, - pub language_view: Option, + pub language_views: Option, pub auxiliary_data: Option, // pub certificates: TODO // pub withdrawals: TODO @@ -288,14 +288,28 @@ impl StagingTransaction { self } - pub fn language_view(mut self, plutus_version: ScriptKind, cost_model: Vec) -> Self { - self.language_view = match plutus_version { - ScriptKind::PlutusV1 => Some(pallas_primitives::conway::LanguageView(0, cost_model)), - ScriptKind::PlutusV2 => Some(pallas_primitives::conway::LanguageView(1, cost_model)), - ScriptKind::PlutusV3 => Some(pallas_primitives::conway::LanguageView(2, cost_model)), - ScriptKind::Native => None, - }; + pub fn language_views( + mut self, + views: pallas_primitives::conway::LanguageViews, + ) -> Self { + self.language_views = Some(views); + self + } + pub fn add_language(mut self, plutus_version: ScriptKind, cost_model: Vec) -> Self { + let version = match plutus_version { + ScriptKind::PlutusV1 => 0, + ScriptKind::PlutusV2 => 1, + ScriptKind::PlutusV3 => 2, + ScriptKind::Native => return self, + }; + let mut map = self + .language_views + .as_ref() + .map(|v| v.0.clone()) + .unwrap_or_default(); + map.insert(version, cost_model); + self.language_views = Some(pallas_primitives::conway::LanguageViews(map)); self } diff --git a/pallas-txbuilder/src/transaction/serialise.rs b/pallas-txbuilder/src/transaction/serialise.rs index 58f7d6e5c..73357b834 100644 --- a/pallas-txbuilder/src/transaction/serialise.rs +++ b/pallas-txbuilder/src/transaction/serialise.rs @@ -479,7 +479,10 @@ mod tests { signature_amount_override: Some(5), change_address: Some(Address(PallasAddress::from_str("addr1g9ekml92qyvzrjmawxkh64r2w5xr6mg9ngfmxh2khsmdrcudevsft64mf887333adamant").unwrap())), script_data_hash: Some(Bytes32([0; 32])), - language_view: Some(pallas_primitives::conway::LanguageView(1, vec![1, 2, 3])), + language_views: Some(pallas_primitives::conway::LanguageViews::from_iter([( + 1, + vec![1, 2, 3], + )])), auxiliary_data: None, }; diff --git a/pallas-validate/src/phase1/conway.rs b/pallas-validate/src/phase1/conway.rs index 37f62821a..848b8672d 100644 --- a/pallas-validate/src/phase1/conway.rs +++ b/pallas-validate/src/phase1/conway.rs @@ -17,7 +17,7 @@ use pallas_codec::utils::{Bytes, KeepRaw, NonEmptySet}; use pallas_primitives::{ babbage, conway::{ - DatumOption, Language, LanguageView, Mint, Redeemers, RedeemersKey, RequiredSigners, + DatumOption, Language, LanguageViews, Mint, Redeemers, RedeemersKey, RequiredSigners, ScriptRef, TransactionBody, TransactionOutput, Tx, VKeyWitness, Value, WitnessSet, }, AddrKeyhash, Hash, PlutusData, PlutusScript, PolicyId, PositiveCoin, TransactionInput, @@ -1511,17 +1511,17 @@ fn tx_languages(mtx: &Tx, utxos: &UTxOs) -> Vec { } } } - if !v1_scripts && !v2_scripts && !v3_scripts { - vec![] - } else if v1_scripts && !v2_scripts && !v3_scripts { - vec![Language::PlutusV1] - } else if !v1_scripts && v2_scripts && !v3_scripts { - vec![Language::PlutusV2] - } else if !v1_scripts && !v2_scripts && v3_scripts { - vec![Language::PlutusV3] - } else { - vec![Language::PlutusV1, Language::PlutusV2] + let mut langs = vec![]; + if v1_scripts { + langs.push(Language::PlutusV1); + } + if v2_scripts { + langs.push(Language::PlutusV2); } + if v3_scripts { + langs.push(Language::PlutusV3); + } + langs } // The metadata of the transaction is valid. @@ -1557,13 +1557,13 @@ fn check_script_data_hash( } }; - let Some(language_view) = cost_model_for_tx(&tx_languages, prot_pps) else { + let Some(language_views) = cost_model_for_tx(&tx_languages, prot_pps) else { return Err(PostAlonzo(ScriptIntegrityHash)); }; let expected = pallas_primitives::conway::ScriptData::build_for( &mtx.transaction_witness_set, - &Some(language_view), + &Some(language_views), ) .ok_or(PostAlonzo(ScriptIntegrityHash))? .hash(); @@ -1578,14 +1578,16 @@ fn check_script_data_hash( fn cost_model_for_tx( tx_languages: &[Language], prot_pps: &ConwayProtParams, -) -> Option { - let lang = itertools::max(tx_languages.iter())?; - - let costs = match lang { - Language::PlutusV1 => prot_pps.cost_models_for_script_languages.plutus_v1.clone(), - Language::PlutusV2 => prot_pps.cost_models_for_script_languages.plutus_v2.clone(), - Language::PlutusV3 => prot_pps.cost_models_for_script_languages.plutus_v3.clone(), - }; - - costs.map(|costs| LanguageView(lang.clone() as u8, costs)) +) -> Option { + let cost_models = &prot_pps.cost_models_for_script_languages; + let mut map = std::collections::BTreeMap::new(); + for lang in tx_languages { + let costs = match lang { + Language::PlutusV1 => cost_models.plutus_v1.clone()?, + Language::PlutusV2 => cost_models.plutus_v2.clone()?, + Language::PlutusV3 => cost_models.plutus_v3.clone()?, + }; + map.insert(lang.clone() as u8, costs); + } + Some(LanguageViews(map)) } From 414f6163fd182b777243e496e64d4f223ebdf469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Ludue=C3=B1a?= Date: Fri, 13 Feb 2026 11:11:18 -0300 Subject: [PATCH 4/4] fix: formatting --- pallas-txbuilder/src/transaction/model.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pallas-txbuilder/src/transaction/model.rs b/pallas-txbuilder/src/transaction/model.rs index 92015aae9..abecd21c4 100644 --- a/pallas-txbuilder/src/transaction/model.rs +++ b/pallas-txbuilder/src/transaction/model.rs @@ -288,10 +288,7 @@ impl StagingTransaction { self } - pub fn language_views( - mut self, - views: pallas_primitives::conway::LanguageViews, - ) -> Self { + pub fn language_views(mut self, views: pallas_primitives::conway::LanguageViews) -> Self { self.language_views = Some(views); self }