Skip to main content

seedelf_core/
eval.rs

1//! Script budgets measured inside the wallet, with Aiken's `uplc`, instead of
2//! asking Ogmios (through Koios) to evaluate a draft.
3//!
4//! The answer has the shape of Ogmios's `evaluateTransaction` answer, so
5//! [`crate::build::Budgets::from_ogmios`] reads either, and a failed script is
6//! put in words the same way.
7//!
8//! Evaluating locally is what lets a chain of transactions be built before any
9//! of it is on chain: a transaction can spend the outputs of one that hasn't
10//! been sent yet, since the wallet hands the evaluator every UTxO itself
11//! ([`resolve_outputs`]). `uplc` 1.1.23 matched the chain's budgets exactly on
12//! recorded preprod transactions (Seedelf spends, and Lovejoin's mixes and
13//! withdraws) under both the 297- and 350-parameter V3 cost models; 1.1.21
14//! didn't, which is why it must be kept current across hard forks.
15
16use crate::{cbor, references};
17use anyhow::{Context, Result, anyhow, bail};
18use pallas_addresses::Address;
19use pallas_codec::minicbor;
20use pallas_crypto::hash::Hash;
21use pallas_primitives::conway::{CostModels, Redeemer, RedeemerTag};
22use pallas_traverse::MultiEraTx;
23use seedelf_koios::koios::UtxoResponse;
24use serde_json::{Value, json};
25use std::collections::BTreeMap;
26
27/// The most a single script may use while measured. The ledger's
28/// per-transaction limit (10 billion steps, 17.5 million memory units on
29/// preprod and mainnet in 2026) caps the whole transaction; the builders check
30/// the total.
31const MEASURE_LIMIT: (u64, u64) = (14_000_000_000, 20_000_000);
32
33/// A UTxO the evaluator can see: where it is, and its output's CBOR.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Resolved {
36    pub tx_hash: [u8; 32],
37    pub index: u64,
38    pub output: Vec<u8>,
39}
40
41impl Resolved {
42    fn input_cbor(&self) -> Result<Vec<u8>> {
43        let mut e = minicbor::Encoder::new(Vec::new());
44        e.array(2)?.bytes(&self.tx_hash)?.u64(self.index)?;
45        Ok(e.into_writer())
46    }
47}
48
49/// Why the evaluator can't take `row`, in words, or `None` when it can. A
50/// reference script on the row isn't carried over, so such a row is refused
51/// rather than evaluated wrongly; anyone can send one, to a Seedelf too. So is
52/// a row whose datum is nested past [`cbor::MAX_DEPTH`]: the evaluator's
53/// decoder would overflow the stack on it. Nothing measured in the wallet
54/// (a Seedelf spend, a session's merged return, Lovejoin) can spend such a
55/// UTxO.
56pub fn refusal(row: &UtxoResponse) -> Option<String> {
57    if row.reference_script.is_some() {
58        return Some(format!(
59            "UTxO {}#{} holds a reference script, which the wallet can't evaluate with yet",
60            row.tx_hash, row.tx_index
61        ));
62    }
63    let too_deep = row
64        .inline_datum
65        .as_ref()
66        .and_then(|d| hex::decode(&d.bytes).ok())
67        .is_some_and(|datum| !cbor::within_depth(&datum, cbor::MAX_DEPTH));
68    too_deep.then(|| {
69        format!(
70            "UTxO {}#{} holds a datum the wallet can't read",
71            row.tx_hash, row.tx_index
72        )
73    })
74}
75
76/// A Koios UTxO row as the evaluator needs it, unless it's one the evaluator
77/// can't take ([`refusal`]).
78pub fn resolve_row(row: &UtxoResponse) -> Result<Resolved> {
79    let tx_hash: [u8; 32] = hex::decode(&row.tx_hash)?
80        .try_into()
81        .map_err(|_| anyhow!("UTxO {}#{} has a malformed hash", row.tx_hash, row.tx_index))?;
82    if let Some(why) = refusal(row) {
83        bail!(why);
84    }
85    let address = Address::from_bech32(&row.address)
86        .map_err(|e| {
87            anyhow!(
88                "UTxO {}#{} has an unreadable address: {e}",
89                row.tx_hash,
90                row.tx_index
91            )
92        })?
93        .to_vec();
94    let lovelace: u64 = row.value.parse().map_err(|_| {
95        anyhow!(
96            "UTxO {}#{} has an unreadable value",
97            row.tx_hash,
98            row.tx_index
99        )
100    })?;
101    // The ledger's value is a map ordered by policy, then by name, bytewise.
102    let mut tokens: BTreeMap<Vec<u8>, BTreeMap<Vec<u8>, u64>> = BTreeMap::new();
103    for asset in row.asset_list.iter().flatten() {
104        let quantity: u64 = asset.quantity.parse().map_err(|_| {
105            anyhow!(
106                "UTxO {}#{} has an unreadable token amount",
107                row.tx_hash,
108                row.tx_index
109            )
110        })?;
111        *tokens
112            .entry(hex::decode(&asset.policy_id)?)
113            .or_default()
114            .entry(hex::decode(&asset.asset_name)?)
115            .or_default() += quantity;
116    }
117    let inline = row
118        .inline_datum
119        .as_ref()
120        .map(|d| hex::decode(&d.bytes))
121        .transpose()?;
122    let datum_hash = row.datum_hash.as_ref().map(hex::decode).transpose()?;
123
124    let mut e = minicbor::Encoder::new(Vec::new());
125    let fields = 2 + u64::from(inline.is_some() || datum_hash.is_some());
126    e.map(fields)?.u8(0)?.bytes(&address)?.u8(1)?;
127    if tokens.is_empty() {
128        e.u64(lovelace)?;
129    } else {
130        e.array(2)?.u64(lovelace)?.map(tokens.len() as u64)?;
131        for (policy, names) in &tokens {
132            e.bytes(policy)?.map(names.len() as u64)?;
133            for (name, quantity) in names {
134                e.bytes(name)?.u64(*quantity)?;
135            }
136        }
137    }
138    match (inline, datum_hash) {
139        // [1, #6.24(bytes .cbor plutus_data)]
140        (Some(datum), _) => {
141            e.u8(2)?
142                .array(2)?
143                .u8(1)?
144                .tag(minicbor::data::Tag::new(24))?
145                .bytes(&datum)?;
146        }
147        (None, Some(hash)) => {
148            e.u8(2)?.array(2)?.u8(0)?.bytes(&hash)?;
149        }
150        (None, None) => {}
151    }
152    Ok(Resolved {
153        tx_hash,
154        index: row.tx_index,
155        output: e.into_writer(),
156    })
157}
158
159/// Every output of a built transaction, as UTxOs a later transaction in the
160/// same chain can spend before this one is on chain.
161pub fn resolve_outputs(tx_cbor: &[u8]) -> Result<Vec<Resolved>> {
162    let tx =
163        MultiEraTx::decode(tx_cbor).map_err(|e| anyhow!("The transaction can't be read: {e}"))?;
164    let tx_hash: [u8; 32] = *tx.hash();
165    Ok(tx
166        .outputs()
167        .iter()
168        .enumerate()
169        .map(|(index, output)| Resolved {
170            tx_hash,
171            index: index as u64,
172            output: output.encode(),
173        })
174        .collect())
175}
176
177/// The Seedelf contracts' reference UTxOs on a network (`true` is preprod,
178/// as everywhere in the workspace).
179pub fn seedelf_references(network_flag: bool) -> Result<Vec<Resolved>> {
180    let config = crate::constants::get_config(crate::constants::VARIANT, network_flag)?;
181    let (wallet, seedelf) = if network_flag {
182        (references::PREPROD_WALLET, references::PREPROD_SEEDELF)
183    } else {
184        (references::MAINNET_WALLET, references::MAINNET_SEEDELF)
185    };
186    Ok(vec![
187        Resolved {
188            tx_hash: config.reference.wallet_reference_utxo,
189            index: 1,
190            output: hex::decode(wallet)?,
191        },
192        Resolved {
193            tx_hash: config.reference.seedelf_reference_utxo,
194            index: 1,
195            output: hex::decode(seedelf)?,
196        },
197    ])
198}
199
200/// Where slot 0 of the Shelley era sits, for the time a script sees.
201fn slot_config(network_flag: bool) -> (u64, u64, u32) {
202    if network_flag {
203        (1_655_769_600_000, 86_400, 1_000)
204    } else {
205        (1_596_059_091_000, 4_492_800, 1_000)
206    }
207}
208
209/// The slot `unix_ms` (milliseconds since 1970) falls in, on preprod
210/// (`network_flag`) or mainnet: what a transaction's validity interval is
211/// counted in. A time before the Shelley era gives its first slot.
212pub fn slot_at(network_flag: bool, unix_ms: u64) -> u64 {
213    let (zero_time, zero_slot, slot_length) = slot_config(network_flag);
214    zero_slot + unix_ms.saturating_sub(zero_time) / u64::from(slot_length)
215}
216
217fn purpose(tag: &RedeemerTag) -> &'static str {
218    match tag {
219        RedeemerTag::Spend => "spend",
220        RedeemerTag::Mint => "mint",
221        RedeemerTag::Cert => "publish",
222        RedeemerTag::Reward => "withdraw",
223        RedeemerTag::Vote => "vote",
224        RedeemerTag::Propose => "propose",
225    }
226}
227
228/// Measures every script in `tx_cbor` against `known`, the UTxOs it spends
229/// and reads (from Koios rows, earlier transactions of a chain, or the bundled
230/// references). Answers as Ogmios does: `{ result: [{ validator: { purpose,
231/// index }, budget: { memory, cpu } }] }`, or `{ error: … }` naming the
232/// script that failed.
233pub fn evaluate(
234    tx_cbor: &[u8],
235    known: &[Resolved],
236    cost_model_v3: &[i64],
237    network_flag: bool,
238) -> Result<Value> {
239    let tx =
240        MultiEraTx::decode(tx_cbor).map_err(|e| anyhow!("The transaction can't be read: {e}"))?;
241    let mut pairs = Vec::new();
242    for input in tx.inputs().iter().chain(tx.reference_inputs().iter()) {
243        let hash: Hash<32> = *input.hash();
244        let index = input.index();
245        let resolved = known
246            .iter()
247            .find(|r| r.tx_hash == *hash && r.index == index)
248            .with_context(|| {
249                format!("The wallet doesn't have UTxO {hash}#{index}, which this transaction uses")
250            })?;
251        pairs.push((resolved.input_cbor()?, resolved.output.clone()));
252    }
253    let cost_models = CostModels {
254        plutus_v1: None,
255        plutus_v2: None,
256        plutus_v3: Some(cost_model_v3.to_vec()),
257    };
258    let cost_models = minicbor::to_vec(&cost_models)
259        .map_err(|e| anyhow!("The cost model can't be encoded: {e}"))?;
260
261    match uplc::tx::eval_phase_two_raw(
262        tx_cbor,
263        &pairs,
264        Some(&cost_models),
265        MEASURE_LIMIT,
266        slot_config(network_flag),
267        true,
268        |_| (),
269    ) {
270        Ok(results) => {
271            let mut rows = Vec::with_capacity(results.len());
272            for (redeemer, result) in results {
273                let redeemer: Redeemer = minicbor::decode(&redeemer)
274                    .map_err(|e| anyhow!("The evaluator returned an unreadable redeemer: {e}"))?;
275                let cost = result.cost();
276                rows.push(json!({
277                    "validator": { "purpose": purpose(&redeemer.tag), "index": redeemer.index },
278                    "budget": { "memory": cost.mem, "cpu": cost.cpu },
279                }));
280            }
281            Ok(json!({ "result": rows }))
282        }
283        Err(uplc::tx::error::Error::RedeemerError { tag, index, err }) => {
284            let reason = err.to_string();
285            let reason = reason.lines().next().unwrap_or("failed").to_string();
286            Ok(json!({
287                "error": {
288                    "code": 3010,
289                    "message": "Some scripts of the transaction terminated with error(s).",
290                    "data": [{
291                        "validator": { "purpose": ogmios_purpose(&tag), "index": index },
292                        "error": { "message": reason, "data": { "validationError": reason, "traces": [] } },
293                    }],
294                }
295            }))
296        }
297        Err(e) => bail!("The wallet couldn't evaluate the transaction: {e}"),
298    }
299}
300
301/// `uplc` names purposes as the redeemer tags are spelled in Rust.
302fn ogmios_purpose(tag: &str) -> &'static str {
303    match tag.to_ascii_lowercase().as_str() {
304        "spend" => "spend",
305        "mint" => "mint",
306        "cert" => "publish",
307        "reward" | "withdraw" => "withdraw",
308        "vote" => "vote",
309        _ => "propose",
310    }
311}
312
313/// A built transaction's outputs as Ogmios v6 UTxOs, for
314/// `evaluateTransaction`'s `additionalUtxo`: with them the network's own
315/// evaluator can measure a transaction whose parents aren't on chain yet (a
316/// chain's first mix, on its unsent deposit). That's the check that the
317/// wallet's evaluator still costs scripts as the network does.
318pub fn ogmios_utxos(tx_cbor: &[u8]) -> Result<Value> {
319    let tx =
320        MultiEraTx::decode(tx_cbor).map_err(|e| anyhow!("The transaction can't be read: {e}"))?;
321    let id = hex::encode(tx.hash());
322    let mut rows = Vec::new();
323    for (index, output) in tx.outputs().iter().enumerate() {
324        let address = output
325            .address()
326            .map_err(|e| anyhow!("An output's address can't be read: {e}"))?
327            .to_bech32()
328            .map_err(|e| anyhow!("An output's address can't be written: {e}"))?;
329        let value = output.value();
330        let mut amounts = serde_json::Map::new();
331        amounts.insert("ada".into(), json!({ "lovelace": value.coin() }));
332        for policy in value.assets() {
333            let mut names = serde_json::Map::new();
334            for asset in policy.assets() {
335                names.insert(
336                    hex::encode(asset.name()),
337                    json!(asset.output_coin().unwrap_or(0)),
338                );
339            }
340            amounts.insert(hex::encode(policy.policy()), Value::Object(names));
341        }
342        let mut row = json!({
343            "transaction": { "id": id },
344            "index": index,
345            "address": address,
346            "value": amounts,
347        });
348        match output.datum() {
349            Some(pallas_primitives::conway::PseudoDatumOption::Data(data)) => {
350                row["datum"] = json!(hex::encode(data.raw_cbor()));
351            }
352            Some(pallas_primitives::conway::PseudoDatumOption::Hash(hash)) => {
353                row["datumHash"] = json!(hex::encode(hash));
354            }
355            None => {}
356        }
357        rows.push(row);
358    }
359    Ok(Value::Array(rows))
360}
361
362/// Whether the budgets `tx_cbor` declares cover what `answer` measured
363/// (Ogmios's `evaluateTransaction` answer, or [`evaluate`]'s), redeemer by
364/// redeemer; the reason when they don't, a failed script included.
365pub fn declared_covers(tx_cbor: &[u8], answer: &Value) -> Result<std::result::Result<(), String>> {
366    let measured = match crate::build::Budgets::from_ogmios(answer) {
367        Ok(budgets) => budgets,
368        Err(e) => return Ok(Err(e.to_string())),
369    };
370    let tx =
371        MultiEraTx::decode(tx_cbor).map_err(|e| anyhow!("The transaction can't be read: {e}"))?;
372    let rows: Vec<Value> = tx
373        .redeemers()
374        .iter()
375        .map(|r| {
376            let units = r.ex_units();
377            json!({
378                "validator": { "purpose": purpose(&r.tag()), "index": r.index() },
379                "budget": { "memory": units.mem, "cpu": units.steps },
380            })
381        })
382        .collect();
383    let declared = crate::build::Budgets::from_ogmios(&json!({ "result": rows }))?;
384    Ok(if declared.covers(&measured) {
385        Ok(())
386    } else {
387        Err("the network measures its scripts above what the wallet declared".to_string())
388    })
389}