Skip to main content

seedelf_core/
withdraw_zero.rs

1//! A zero withdrawal from a script's reward account, with its redeemer: the
2//! "withdraw-zero" pattern, where a script registered as a stake credential
3//! runs once per transaction to check the whole of it. Lovejoin's `mix_logic`
4//! works this way: every mix and every withdraw of a box carries one.
5//!
6//! Pallas's builder can stage neither a withdrawal nor a `Reward` redeemer
7//! (`pallas-txbuilder` 0.33 to 1.4 all write `withdrawals: None`, and its
8//! `RedeemerPurpose` has no reward), so, as [`crate::staking`] does for
9//! certificates, a built transaction is decoded, given the withdrawal and the
10//! redeemer, its script data hash worked out again, and encoded. That changes
11//! the body's hash, so it's patched first and signed after.
12
13use anyhow::{Context, Result, anyhow, bail};
14use pallas_codec::minicbor;
15use pallas_codec::utils::{Bytes, MaybeIndefArray};
16use pallas_crypto::hash::{Hash, Hasher};
17use pallas_primitives::conway::{self, ExUnits, PlutusData, Redeemer, RedeemerTag, Redeemers};
18use pallas_primitives::{Fragment, NonEmptyKeyValuePairs};
19use pallas_txbuilder::BuiltTransaction;
20
21use crate::build::tx_id;
22
23/// A script's reward account (CIP-19 header `0b1111`), on preprod when
24/// `network_flag` is set, as everywhere in the workspace.
25pub fn reward_account(script_hash: &[u8; 28], network_flag: bool) -> Vec<u8> {
26    let mut account = Vec::with_capacity(29);
27    account.push(if network_flag { 0xf0 } else { 0xf1 });
28    account.extend_from_slice(script_hash);
29    account
30}
31
32/// The script data hash of a Plutus V3 transaction: blake2b-256 of its
33/// redeemers, its witness datums if it has any, and the V3 language view
34/// (`{2: cost model}`), each as the transaction encodes them. The same sum
35/// `pallas-txbuilder` makes, which keeps it private.
36pub fn script_data_hash(
37    redeemers_cbor: &[u8],
38    datums_cbor: Option<&[u8]>,
39    cost_model_v3: &[i64],
40) -> Result<Hash<32>> {
41    let mut preimage = redeemers_cbor.to_vec();
42    if let Some(datums) = datums_cbor {
43        preimage.extend_from_slice(datums);
44    }
45    let mut view = minicbor::Encoder::new(Vec::new());
46    view.map(1)
47        .and_then(|e| e.u8(2))
48        .and_then(|e| e.encode(cost_model_v3))
49        .map_err(|e| anyhow!("The language view can't be encoded: {e}"))?;
50    preimage.extend(view.into_writer());
51    Ok(Hasher::<256>::hash(&preimage))
52}
53
54/// A zero withdrawal from `account` whose script is run with `redeemer`.
55#[derive(Debug, Clone)]
56pub struct WithdrawZero {
57    pub account: Vec<u8>,
58    pub redeemer: PlutusData,
59}
60
61impl WithdrawZero {
62    pub fn new(script_hash: &[u8; 28], network_flag: bool, redeemer_cbor: &[u8]) -> Result<Self> {
63        let redeemer = PlutusData::decode_fragment(redeemer_cbor)
64            .map_err(|e| anyhow!("The withdrawal's redeemer isn't Plutus data: {e}"))?;
65        Ok(WithdrawZero {
66            account: reward_account(script_hash, network_flag),
67            redeemer,
68        })
69    }
70
71    /// Sets the withdrawal and its redeemer (with `budget`) in `built`, an
72    /// unsigned Plutus V3 transaction, and returns it with its new script
73    /// data hash and body hash. Patching again replaces the redeemer, which is
74    /// how a measured budget goes in after a draft.
75    pub fn patch(
76        &self,
77        mut built: BuiltTransaction,
78        budget: ExUnits,
79        cost_model_v3: &[i64],
80    ) -> Result<BuiltTransaction> {
81        if built.signatures.is_some() {
82            bail!("A transaction is patched before it's signed, not after");
83        }
84        let mut tx = conway::Tx::decode_fragment(&built.tx_bytes.0)
85            .map_err(|e| anyhow!("The built transaction isn't a Conway transaction: {e}"))?;
86        let body = &mut tx.transaction_body;
87        let account = Bytes::from(self.account.clone());
88        let others = body
89            .withdrawals
90            .as_ref()
91            .map(|w| w.iter().any(|(a, _)| *a != account))
92            .unwrap_or(false);
93        if others {
94            bail!("The transaction already withdraws from another account");
95        }
96        body.withdrawals = Some(NonEmptyKeyValuePairs::Def(vec![(account, 0)]));
97
98        let witness = &mut tx.transaction_witness_set;
99        if witness.plutus_data.is_some() {
100            bail!("A transaction with witness datums can't be given a withdraw-zero yet");
101        }
102        let mut redeemers = match witness.redeemer.take() {
103            None => Vec::new(),
104            Some(Redeemers::List(list)) => list.to_vec(),
105            Some(Redeemers::Map(_)) => {
106                bail!("The transaction's redeemers are a map, which this patch doesn't write")
107            }
108        };
109        // One withdrawal, so its redeemer points at index 0.
110        redeemers.retain(|r| r.tag != RedeemerTag::Reward);
111        redeemers.push(Redeemer {
112            tag: RedeemerTag::Reward,
113            index: 0,
114            data: self.redeemer.clone(),
115            ex_units: budget,
116        });
117        let redeemers = Redeemers::List(MaybeIndefArray::Def(redeemers));
118        let redeemers_cbor = minicbor::to_vec(&redeemers)
119            .map_err(|e| anyhow!("The redeemers can't be encoded: {e}"))?;
120        tx.transaction_body.script_data_hash =
121            Some(script_data_hash(&redeemers_cbor, None, cost_model_v3)?);
122        tx.transaction_witness_set.redeemer = Some(redeemers);
123
124        let bytes = tx
125            .encode_fragment()
126            .map_err(|e| anyhow!("Failed to encode the transaction: {e}"))?;
127        built.tx_hash.0 = *tx_id(&bytes).context("The patched transaction can't be hashed")?;
128        built.tx_bytes.0 = bytes;
129        Ok(built)
130    }
131}