From 6e901de2f092f2e9a1b119afa84533b91fb8d5a4 Mon Sep 17 00:00:00 2001 From: Harper Date: Mon, 19 Sep 2022 04:31:30 +0100 Subject: [PATCH] feat: implement script-related ledger checks for Tx Simulate (#57) * feat: functions for extraneous/missing redeemers checks * chore: typos * feat: implement function to check for missing/extraneous scripts * feat: check for missing/extraneous redeemers and scripts in eval_tx * chore: add tests for missing/extraneous redeemers * chore: remove duplicate file --- crates/uplc/src/tx.rs | 129 ++++++++++++- crates/uplc/src/tx/eval.rs | 36 +++- crates/uplc/src/tx/phase_one.rs | 325 ++++++++++++++++++++++++++++++++ 3 files changed, 480 insertions(+), 10 deletions(-) create mode 100644 crates/uplc/src/tx/phase_one.rs diff --git a/crates/uplc/src/tx.rs b/crates/uplc/src/tx.rs index 9e4b6126..833f924b 100644 --- a/crates/uplc/src/tx.rs +++ b/crates/uplc/src/tx.rs @@ -4,13 +4,15 @@ use pallas_primitives::{ }; use pallas_traverse::{Era, MultiEraTx}; -use error::Error; -use script_context::{ResolvedInput, SlotConfig}; +use crate::Error; + +use self::{script_context::{ResolvedInput, SlotConfig}, phase_one::eval_phase_one}; mod error; mod eval; pub mod script_context; mod to_plutus_data; +mod phase_one; pub fn eval( tx: &MintedTx, @@ -22,6 +24,9 @@ pub fn eval( let lookup_table = eval::get_script_and_datum_lookup_table(tx, utxos); + // subset of phase 1 check on redeemers and scripts + eval_phase_one(tx, utxos, &lookup_table)?; + match redeemers { Some(rs) => { let mut collected_redeemers = vec![]; @@ -633,4 +638,124 @@ mod tests { _ => unreachable!(), }; } + + #[test] + fn eval_missing_redeemer() { + let tx_bytes = hex::decode("84a30082825820275b5da338c8b899035081eb34bfa950b634911a5dd3271b3ad6cf4c2bba0c5000825820275b5da338c8b899035081eb34bfa950b634911a5dd3271b3ad6cf4c2bba0c50010181825839000af00cc47500bb64cfffb783e8c42f746b4e8b8a70ede9c08c7113acf3bde34d1041f5a2076ef9aa6cf4539ab1a96ed462a0300acbdb65d51a02cf47c8021a00028d89a1068149480100002221200101f5f6").unwrap(); + + let multi_era_tx = MultiEraTx::decode(Era::Babbage, &tx_bytes) + .or_else(|_| MultiEraTx::decode(Era::Alonzo, &tx_bytes)) + .unwrap(); + + let inputs = multi_era_tx.as_babbage().unwrap().transaction_body.inputs.clone(); + + let raw_outputs = hex::decode("82825839000af00cc47500bb64cfffb783e8c42f746b4e8b8a70ede9c08c7113acf3bde34d1041f5a2076ef9aa6cf4539ab1a96ed462a0300acbdb65d51a02b3603082581d703a888d65f16790950a72daee1f63aa05add6d268434107cfa5b677121a001e8480").unwrap(); + + let outputs = MaybeIndefArray::::decode_fragment(&raw_outputs).unwrap(); + + let utxos: MaybeIndefArray = MaybeIndefArray::Indef( + inputs + .iter() + .zip(outputs.iter()) + .map(|(input, output)| ResolvedInput { + input: input.clone(), + output: output.clone(), + }) + .collect(), + ); + + let slot_config = SlotConfig { + zero_time: 1660003200000, // Preview network + slot_length: 1000, + }; + + let costs: Vec = vec![ + 205665, 812, 1, 1, 1000, 571, 0, 1, 1000, 24177, 4, 1, 1000, 32, 117366, 10475, 4, + 23000, 100, 23000, 100, 23000, 100, 23000, 100, 23000, 100, 23000, 100, 100, 100, + 23000, 100, 19537, 32, 175354, 32, 46417, 4, 221973, 511, 0, 1, 89141, 32, 497525, + 14068, 4, 2, 196500, 453240, 220, 0, 1, 1, 1000, 28662, 4, 2, 245000, 216773, 62, 1, + 1060367, 12586, 1, 208512, 421, 1, 187000, 1000, 52998, 1, 80436, 32, 43249, 32, 1000, + 32, 80556, 1, 57667, 4, 1000, 10, 197145, 156, 1, 197145, 156, 1, 204924, 473, 1, + 208896, 511, 1, 52467, 32, 64832, 32, 65493, 32, 22558, 32, 16563, 32, 76511, 32, + 196500, 453240, 220, 0, 1, 1, 69522, 11687, 0, 1, 60091, 32, 196500, 453240, 220, 0, 1, + 1, 196500, 453240, 220, 0, 1, 1, 806990, 30482, 4, 1927926, 82523, 4, 265318, 0, 4, 0, + 85931, 32, 205665, 812, 1, 1, 41182, 32, 212342, 32, 31220, 32, 32696, 32, 43357, 32, + 32247, 32, 38314, 32, 9462713, 1021, 10, + ]; + + let cost_mdl = CostMdls { + plutus_v1: Some(costs), + plutus_v2: None, + }; + + let multi_era_tx = MultiEraTx::decode(Era::Babbage, &tx_bytes) + .or_else(|_| MultiEraTx::decode(Era::Alonzo, &tx_bytes)) + .unwrap(); + match multi_era_tx { + MultiEraTx::Babbage(tx) => { + eval(&tx, &utxos, Some(&cost_mdl), &slot_config).unwrap(); + } + _ => unreachable!(), + }; + } + + #[test] + fn eval_extraneous_redeemer() { + let tx_bytes = hex::decode("84a70082825820275b5da338c8b899035081eb34bfa950b634911a5dd3271b3ad6cf4c2bba0c5000825820275b5da338c8b899035081eb34bfa950b634911a5dd3271b3ad6cf4c2bba0c50010181825839000af00cc47500bb64cfffb783e8c42f746b4e8b8a70ede9c08c7113acf3bde34d1041f5a2076ef9aa6cf4539ab1a96ed462a0300acbdb65d51a02cf2b47021a0002aa0a0b5820fc54f302cff3a8a1cb374f5e4979e18a1d3627dcf4539637b03f5959eb8565bf0d81825820275b5da338c8b899035081eb34bfa950b634911a5dd3271b3ad6cf4c2bba0c500110825839000af00cc47500bb64cfffb783e8c42f746b4e8b8a70ede9c08c7113acf3bde34d1041f5a2076ef9aa6cf4539ab1a96ed462a0300acbdb65d51a02af51c2111a0003ff0fa40081825820065dd553fbe4e240a8f819bb9e333a7483de4a22b65c7fb6a95ce9450f84dff758402c26125a057a696079d08f2c8c9d2b8ccda9fe7cf7360c1a86712b85a91db82a3b80996b30ba6f4b2f969c93eb50694e0f6ea0bcf129080dcc07ecd9e605f00a049fd87980ff0582840000d879808219044c1a000382d48401001864821903e81903e8068149480100002221200101f5f6").unwrap(); + + let multi_era_tx = MultiEraTx::decode(Era::Babbage, &tx_bytes) + .or_else(|_| MultiEraTx::decode(Era::Alonzo, &tx_bytes)) + .unwrap(); + + let inputs = multi_era_tx.as_babbage().unwrap().transaction_body.inputs.clone(); + + let raw_outputs = hex::decode("82825839000af00cc47500bb64cfffb783e8c42f746b4e8b8a70ede9c08c7113acf3bde34d1041f5a2076ef9aa6cf4539ab1a96ed462a0300acbdb65d51a02b3603082581d703a888d65f16790950a72daee1f63aa05add6d268434107cfa5b677121a001e8480").unwrap(); + + let outputs = MaybeIndefArray::::decode_fragment(&raw_outputs).unwrap(); + + let utxos: MaybeIndefArray = MaybeIndefArray::Indef( + inputs + .iter() + .zip(outputs.iter()) + .map(|(input, output)| ResolvedInput { + input: input.clone(), + output: output.clone(), + }) + .collect(), + ); + + let slot_config = SlotConfig { + zero_time: 1660003200000, // Preview network + slot_length: 1000, + }; + + let costs: Vec = vec![ + 205665, 812, 1, 1, 1000, 571, 0, 1, 1000, 24177, 4, 1, 1000, 32, 117366, 10475, 4, + 23000, 100, 23000, 100, 23000, 100, 23000, 100, 23000, 100, 23000, 100, 100, 100, + 23000, 100, 19537, 32, 175354, 32, 46417, 4, 221973, 511, 0, 1, 89141, 32, 497525, + 14068, 4, 2, 196500, 453240, 220, 0, 1, 1, 1000, 28662, 4, 2, 245000, 216773, 62, 1, + 1060367, 12586, 1, 208512, 421, 1, 187000, 1000, 52998, 1, 80436, 32, 43249, 32, 1000, + 32, 80556, 1, 57667, 4, 1000, 10, 197145, 156, 1, 197145, 156, 1, 204924, 473, 1, + 208896, 511, 1, 52467, 32, 64832, 32, 65493, 32, 22558, 32, 16563, 32, 76511, 32, + 196500, 453240, 220, 0, 1, 1, 69522, 11687, 0, 1, 60091, 32, 196500, 453240, 220, 0, 1, + 1, 196500, 453240, 220, 0, 1, 1, 806990, 30482, 4, 1927926, 82523, 4, 265318, 0, 4, 0, + 85931, 32, 205665, 812, 1, 1, 41182, 32, 212342, 32, 31220, 32, 32696, 32, 43357, 32, + 32247, 32, 38314, 32, 9462713, 1021, 10, + ]; + + let cost_mdl = CostMdls { + plutus_v1: Some(costs), + plutus_v2: None, + }; + + let multi_era_tx = MultiEraTx::decode(Era::Babbage, &tx_bytes) + .or_else(|_| MultiEraTx::decode(Era::Alonzo, &tx_bytes)) + .unwrap(); + match multi_era_tx { + MultiEraTx::Babbage(tx) => { + eval(&tx, &utxos, Some(&cost_mdl), &slot_config).unwrap(); + } + _ => unreachable!(), + }; + } } diff --git a/crates/uplc/src/tx/eval.rs b/crates/uplc/src/tx/eval.rs index ab3f8595..e3ec011a 100644 --- a/crates/uplc/src/tx/eval.rs +++ b/crates/uplc/src/tx/eval.rs @@ -9,7 +9,7 @@ use pallas_crypto::hash::Hash; use pallas_primitives::babbage::{ Certificate, CostMdls, DatumHash, DatumOption, ExUnits, Language, Mint, MintedTx, PlutusV1Script, PlutusV2Script, PolicyId, Redeemer, RedeemerTag, RewardAccount, Script, - StakeCredential, TransactionInput, TransactionOutput, Value, Withdrawals, + StakeCredential, TransactionInput, TransactionOutput, Value, Withdrawals, NativeScript, }; use pallas_traverse::{ComputeHash, OriginalHash}; use std::{collections::HashMap, convert::TryInto, ops::Deref, vec}; @@ -40,7 +40,8 @@ fn slot_range_to_posix_time_range(slot_range: TimeRange, sc: &SlotConfig) -> Tim } #[derive(Debug, PartialEq, Clone)] -enum ScriptVersion { +pub enum ScriptVersion { + Native(NativeScript), V1(PlutusV1Script), V2(PlutusV2Script), } @@ -56,6 +57,12 @@ pub struct DataLookupTable { scripts: HashMap, } +impl DataLookupTable { + pub fn scripts(&self) -> HashMap { + self.scripts.clone() + } +} + pub fn get_tx_in_info_v1( inputs: &[TransactionInput], utxos: &[ResolvedInput], @@ -184,15 +191,15 @@ fn get_script_purpose( .as_ref() .unwrap_or(&KeyValuePairs::Indef(vec![])) .iter() - .map(|(policy_id, _)| policy_id.clone()) + .map(|(racnt, _)| racnt.clone()) .collect::>(); reward_accounts.sort(); let reward_account = match reward_accounts.get(index as usize) { Some(ra) => ra.clone(), None => unreachable!("Script purpose not found for redeemer."), }; - let addresss = Address::from_bytes(&reward_account)?; - let credential = match addresss { + let address = Address::from_bytes(&reward_account)?; + let credential = match address { Address::Stake(stake_address) => match stake_address.payload() { StakePayload::Script(script_hash) => { StakeCredential::Scripthash(*script_hash) @@ -501,6 +508,12 @@ pub fn get_script_and_datum_lookup_table( .clone() .unwrap_or_default(); + let scripts_native_witnesses = tx + .transaction_witness_set + .native_script + .clone() + .unwrap_or_default(); + let scripts_v1_witnesses = tx .transaction_witness_set .plutus_v1_script @@ -517,14 +530,17 @@ pub fn get_script_and_datum_lookup_table( datum.insert(plutus_data.original_hash(), plutus_data.clone().unwrap()); } + for script in scripts_native_witnesses.iter() { + scripts.insert(script.compute_hash(), ScriptVersion::Native(script.clone())); + // TODO: implement `original_hash` for native scripts in pallas + } + for script in scripts_v1_witnesses.iter() { scripts.insert(script.compute_hash(), ScriptVersion::V1(script.clone())); - // TODO: fix hashing bug in pallas } for script in scripts_v2_witnesses.iter() { scripts.insert(script.compute_hash(), ScriptVersion::V2(script.clone())); - // TODO: fix hashing bug in pallas } // discovery in utxos (script ref) @@ -535,13 +551,15 @@ pub fn get_script_and_datum_lookup_table( TransactionOutput::PostAlonzo(output) => { if let Some(script) = &output.script_ref { match &script.0 { + Script::NativeScript(ns) => { + scripts.insert(ns.compute_hash(), ScriptVersion::Native(ns.clone())); + } Script::PlutusV1Script(v1) => { scripts.insert(v1.compute_hash(), ScriptVersion::V1(v1.clone())); } Script::PlutusV2Script(v2) => { scripts.insert(v2.compute_hash(), ScriptVersion::V2(v2.clone())); } - _ => {} } } } @@ -663,6 +681,7 @@ pub fn eval_redeemer( Ok(new_redeemer) } + ScriptVersion::Native(_) => unreachable!("Native script can't be executed in phase-two.") }, ExecutionPurpose::NoDatum(script_version) => match script_version { ScriptVersion::V1(script) => { @@ -755,6 +774,7 @@ pub fn eval_redeemer( Ok(new_redeemer) } + ScriptVersion::Native(_) => unreachable!("Native script can't be executed in phase-two.") }, } } diff --git a/crates/uplc/src/tx/phase_one.rs b/crates/uplc/src/tx/phase_one.rs new file mode 100644 index 00000000..f6bb93b6 --- /dev/null +++ b/crates/uplc/src/tx/phase_one.rs @@ -0,0 +1,325 @@ +use std::collections::HashMap; + +use pallas_addresses::{ScriptHash, Address, ShelleyPaymentPart, StakePayload}; +use pallas_codec::utils::{KeyValuePairs, MaybeIndefArray}; +use pallas_primitives::babbage::{MintedTx, TransactionOutput, StakeCredential, Certificate, RedeemerTag, RewardAccount, PolicyId}; + +use super::{script_context::{ScriptPurpose, ResolvedInput}, eval::{ScriptVersion, DataLookupTable}}; + +// TODO: include in pallas eventually? +#[derive(Debug, PartialEq, Clone)] +struct RedeemerPtr { + tag: RedeemerTag, + index: u32, +} + +type AlonzoScriptsNeeded = Vec<(ScriptPurpose, ScriptHash)>; + +// subset of phase-1 ledger checks related to scripts +pub fn eval_phase_one( + tx: &MintedTx, + utxos: &[ResolvedInput], + lookup_table: &DataLookupTable, +) -> anyhow::Result<()> { + let scripts_needed = scripts_needed(tx, utxos); + + validate_missing_scripts(&scripts_needed, lookup_table.scripts())?; + + has_exact_set_of_redeemers(tx, &scripts_needed, lookup_table.scripts())?; + + Ok(()) +} + +pub fn validate_missing_scripts( + needed: &AlonzoScriptsNeeded, + txscripts: HashMap, +) -> anyhow::Result<()> { + let received_hashes = txscripts + .keys() + .map(|x| *x) + .collect::>(); + + let needed_hashes = needed + .iter() + .map(|x| x.1) + .collect::>(); + + let missing: Vec<_> = needed_hashes + .clone() + .into_iter() + .filter(|x| !received_hashes.contains(x)) + .map(|x| format!( + "[Missing (sh: {})]", + x + )) + .collect(); + + let extra: Vec<_> = received_hashes + .into_iter() + .filter(|x| !needed_hashes.contains(x)) + .map(|x| format!( + "[Extraneous (sh: {:?})]", + x + )) + .collect(); + + if missing.len() > 0 || extra.len() > 0 { + let missing_errors = missing.join(" "); + let extra_errors = extra.join(" "); + + unreachable!("Mismatch in required scripts: {} {}", missing_errors, extra_errors); + } + + Ok(()) +} + +pub fn scripts_needed( + tx: &MintedTx, + utxos: &[ResolvedInput], +) -> AlonzoScriptsNeeded { + let mut needed = Vec::new(); + + let txb = tx.transaction_body.clone(); + + let mut spend = txb.inputs + .iter() + .map(|input| { + let utxo = match utxos.iter().find(|utxo| utxo.input == *input) { + Some(u) => u, + None => panic!("Resolved input not found."), + }; + let address = Address::from_bytes(match &utxo.output { + TransactionOutput::Legacy(output) => output.address.as_ref(), + TransactionOutput::PostAlonzo(output) => output.address.as_ref(), + }) + .unwrap(); + + if let Address::Shelley(a) = address { + if let ShelleyPaymentPart::Script(h) = a.payment() { + return Some((ScriptPurpose::Spending(input.clone()), *h)) + } + } + + None + }) + .flatten() + .collect::(); + + let mut reward = txb.withdrawals + .as_ref() + .unwrap_or(&KeyValuePairs::Indef(vec![])) + .iter() + .map(|(acnt, _)| { + let address = Address::from_bytes(acnt).unwrap(); + + if let Address::Stake(a) = address { + if let StakePayload::Script(h) = a.payload() { + let cred = StakeCredential::Scripthash(*h); + return Some((ScriptPurpose::Rewarding(cred), *h)) + } + } + + None + }) + .flatten() + .collect::(); + + let mut cert = txb.certificates + .clone() + .unwrap_or_default() + .iter() + .map(|cert| { + // only Dereg and Deleg certs can require scripts + match cert { + Certificate::StakeDeregistration(StakeCredential::Scripthash(h)) => { + Some((ScriptPurpose::Certifying(cert.clone()), *h)) + }, + Certificate::StakeDelegation(StakeCredential::Scripthash(h), _) => { + Some((ScriptPurpose::Certifying(cert.clone()), *h)) + }, + _ => None + } + }) + .flatten() + .collect::(); + + let mut mint = txb.mint + .as_ref() + .unwrap_or(&KeyValuePairs::Indef(vec![])) + .iter() + .map(|(policy_id, _)| { + (ScriptPurpose::Minting(*policy_id), *policy_id) + }) + .collect::(); + + needed.append(&mut spend); + needed.append(&mut reward); + needed.append(&mut cert); + needed.append(&mut mint); + + needed +} + +/// hasExactSetOfRedeemers in Ledger Spec, but we pass `txscripts` directly +pub fn has_exact_set_of_redeemers( + tx: &MintedTx, + needed: &AlonzoScriptsNeeded, + txscripts: HashMap, +) -> anyhow::Result<()> { + let redeemers_needed = needed + .iter() + .map(|(sp, sh)| { + let rp = rdptr(tx, sp); + let script = txscripts.get(&sh); + + match (rp, script) { + (Some(ptr), Some(script)) => match script { + ScriptVersion::V1(_) => Some((ptr, sp.clone(), *sh)), + ScriptVersion::V2(_) => Some((ptr, sp.clone(), *sh)), + ScriptVersion::Native(_) => None, + }, + _ => None + } + }) + .flatten() + .collect::>(); + + let wits_rdptrs = tx + .transaction_witness_set + .redeemer + .as_ref() + .unwrap_or(&MaybeIndefArray::Indef(vec![])) + .iter() + .map(|r| { + RedeemerPtr { tag: r.tag.clone(), index: r.index } + }) + .collect::>(); + + let needed_rdptrs = redeemers_needed + .iter() + .map(|x| x.0.clone()) + .collect::>(); + + let missing: Vec<_> = redeemers_needed + .into_iter() + .filter(|x| !wits_rdptrs.contains(&x.0)) + .map(|x| format!( + "[Missing (rp: {:?}, sp: {:?}, sh: {})]", + x.0, + x.1, + x.2.to_string(), + )) + .collect(); + + let extra: Vec<_> = wits_rdptrs + .into_iter() + .filter(|x| !needed_rdptrs.contains(x)) + .map(|x| format!( + "[Extraneous (rp: {:?})]", + x + )) + .collect(); + + if missing.len() > 0 || extra.len() > 0 { + let missing_errors = missing.join(" "); + let extra_errors = extra.join(" "); + + unreachable!("Mismatch in required redeemers: {} {}", missing_errors, extra_errors); + } + + Ok(()) +} + +/// builds a redeemer pointer (tag, index) from a script purpose by setting the tag +/// according to the type of the script purpose, and the index according to the +/// placement of script purpose inside its container. +fn rdptr( + tx: &MintedTx, + sp: &ScriptPurpose, +) -> Option { + let txb = tx.transaction_body.clone(); + + match sp { + ScriptPurpose::Minting(hash) => { + let mut policy_ids = txb.mint + .as_ref() + .unwrap_or(&KeyValuePairs::Indef(vec![])) + .iter() + .map(|(policy_id, _)| *policy_id) + .collect::>(); + + policy_ids.sort(); + + let maybe_idx = policy_ids.iter().position(|x| x == hash); + + match maybe_idx { + Some(idx) => Some(RedeemerPtr { tag: RedeemerTag::Mint, index: idx as u32 }), + None => None, + } + } + ScriptPurpose::Spending(txin) => { + let mut inputs = txb.inputs.to_vec(); + inputs.sort_by( + |i_a, i_b| match i_a.transaction_id.cmp(&i_b.transaction_id) { + std::cmp::Ordering::Less => std::cmp::Ordering::Less, + std::cmp::Ordering::Equal => i_a.index.cmp(&i_b.index), + std::cmp::Ordering::Greater => std::cmp::Ordering::Greater, + }, + ); + + let maybe_idx = inputs.iter().position(|x| x == txin); + + match maybe_idx { + Some(idx) => Some(RedeemerPtr { tag: RedeemerTag::Spend, index: idx as u32 }), + None => None, + } + }, + ScriptPurpose::Rewarding(racnt) => { + let mut reward_accounts = txb.withdrawals + .as_ref() + .unwrap_or(&KeyValuePairs::Indef(vec![])) + .iter() + .map(|(acnt, _)| acnt.clone()) + .collect::>(); + + reward_accounts.sort(); + + let maybe_idx = reward_accounts.iter().position(|x| { + let cred = match Address::from_bytes(x).unwrap() { + Address::Stake(a) => match a.payload() { + StakePayload::Script(sh) => { + StakeCredential::Scripthash(*sh) + } + StakePayload::Stake(_) => { + unreachable!( + "This is impossible. A key hash cannot be the hash of a script." + ); + } + }, + _ => unreachable!( + "This is impossible. Only shelley reward addresses can be a part of withdrawals." + ), + }; + + cred == *racnt + }); + + match maybe_idx { + Some(idx) => Some(RedeemerPtr { tag: RedeemerTag::Reward, index: idx as u32 }), + None => None, + } + }, + ScriptPurpose::Certifying(d) => { + let maybe_idx = txb.certificates + .as_ref() + .unwrap_or(&MaybeIndefArray::Indef(vec![])) + .iter() + .position(|x| x == d); + + match maybe_idx { + Some(idx) => Some(RedeemerPtr{ tag: RedeemerTag::Cert, index: idx as u32 }), + None => None + } + }, + } +}