Skip to main content

amaru_kernel/cardano/
transaction_body.rs

1// Copyright 2026 PRAGMA
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::mem;
16
17#[cfg(any(test, feature = "test-utils"))]
18use crate::to_cbor;
19use crate::{
20    AssetName, Bytes, Certificate, Hash, Hasher, Lovelace, MemoizedTransactionOutput, NULL_HASH32, NetworkId,
21    NonEmptyKeyValuePairs, NonEmptySet, NonZeroInt, PositiveCoin, Proposal, ProposalId, RewardAccount, Set,
22    TransactionInput, Voter, VotingProcedure, cbor,
23    size::{CREDENTIAL, KEY},
24};
25
26/// A multi-era transaction body. This type is meant to represent all transaction body in eras that
27/// we may encounter.
28///
29///
30// NOTE: Obsolete fields from previous eras:
31//
32// 6: governance updates, prior to Conway.
33// 10: has somewhat never existed, or existed but was removed without having been used.
34// 12: same
35#[derive(Debug, Clone, PartialEq, Eq, cbor::Encode, serde::Serialize, serde::Deserialize)]
36#[cbor(map)]
37pub struct TransactionBody {
38    #[cbor(skip)]
39    hash: Hash<{ TransactionBody::HASH_SIZE }>,
40
41    #[cbor(skip)]
42    original_size: u64,
43
44    #[n(0)]
45    pub inputs: Set<TransactionInput>,
46
47    #[n(1)]
48    pub outputs: Vec<MemoizedTransactionOutput>,
49
50    #[n(2)]
51    pub fee: Lovelace,
52
53    #[n(3)]
54    pub validity_interval_end: Option<u64>,
55
56    #[n(4)]
57    pub certificates: Option<NonEmptySet<Certificate>>,
58
59    #[n(5)]
60    pub withdrawals: Option<NonEmptyKeyValuePairs<RewardAccount, Lovelace>>,
61
62    #[n(7)]
63    pub auxiliary_data_hash: Option<Bytes>,
64
65    #[n(8)]
66    pub validity_interval_start: Option<u64>,
67
68    #[n(9)]
69    pub mint: Option<NonEmptyKeyValuePairs<Hash<CREDENTIAL>, NonEmptyKeyValuePairs<AssetName, NonZeroInt>>>,
70
71    #[n(11)]
72    pub script_data_hash: Option<Hash<32>>,
73
74    #[n(13)]
75    pub collateral: Option<NonEmptySet<TransactionInput>>,
76
77    #[n(14)]
78    pub required_signers: Option<NonEmptySet<Hash<KEY>>>,
79
80    #[n(15)]
81    pub network_id: Option<NetworkId>,
82
83    #[n(16)]
84    pub collateral_return: Option<MemoizedTransactionOutput>,
85
86    #[n(17)]
87    pub total_collateral: Option<Lovelace>,
88
89    #[n(18)]
90    pub reference_inputs: Option<NonEmptySet<TransactionInput>>,
91
92    #[n(19)]
93    pub votes: Option<NonEmptyKeyValuePairs<Voter, NonEmptyKeyValuePairs<ProposalId, VotingProcedure>>>,
94
95    #[n(20)]
96    pub proposals: Option<NonEmptySet<Proposal>>,
97
98    #[n(21)]
99    pub treasury_value: Option<Lovelace>,
100
101    #[n(22)]
102    pub donation: Option<PositiveCoin>,
103}
104
105impl TransactionBody {
106    // Hash digest size, in bytes.
107    pub const HASH_SIZE: usize = 32;
108
109    /// The original id (i.e. blake2b-256 hash digest) of the transaction body. Note that the hash
110    /// computed from the original bytes and memoized; it is not re-computed from re-serialised
111    /// data.
112    pub fn id(&self) -> Hash<{ Self::HASH_SIZE }> {
113        self.hash
114    }
115
116    #[allow(clippy::len_without_is_empty)]
117    pub fn len(&self) -> u64 {
118        self.original_size
119    }
120
121    /// Artificially construct a transaction from its required constituents; Good enough for
122    /// testing but unsuitable for production.
123    #[cfg(any(test, feature = "test-utils"))]
124    pub fn new(
125        inputs: impl IntoIterator<Item = TransactionInput>,
126        outputs: impl IntoIterator<Item = MemoizedTransactionOutput>,
127        fee: Lovelace,
128    ) -> Self {
129        let mut body = Self {
130            inputs: Set::from(inputs.into_iter().collect::<Vec<_>>()),
131            outputs: outputs.into_iter().collect(),
132            fee,
133            ..Self::default()
134        };
135
136        let bytes = to_cbor(&body);
137
138        body.original_size = bytes.len() as u64;
139        body.hash = Hasher::<{ 8 * Self::HASH_SIZE }>::hash(&bytes[..]);
140
141        body
142    }
143}
144
145impl Default for TransactionBody {
146    fn default() -> Self {
147        Self {
148            hash: NULL_HASH32,
149            original_size: 0,
150            inputs: Set::from(vec![]),
151            outputs: vec![],
152            fee: 0,
153            validity_interval_end: None,
154            certificates: None,
155            withdrawals: None,
156            auxiliary_data_hash: None,
157            validity_interval_start: None,
158            mint: None,
159            script_data_hash: None,
160            collateral: None,
161            required_signers: None,
162            network_id: None,
163            collateral_return: None,
164            total_collateral: None,
165            reference_inputs: None,
166            votes: None,
167            proposals: None,
168            treasury_value: None,
169            donation: None,
170        }
171    }
172}
173
174// NOTE: Multi-era transaction decoding.
175//
176// Parsing of transactions must be done according to a specific era, and the exact decoding
177// rules may vary per era.
178//
179// The following decoder assumes Conway as an era since that's all we support at the moment.
180// Yet, this means that we will end up rejected perfectly well-formed transactions from other
181// eras.
182//
183// For example, empty but present fields were generally allowed prior to Conway.
184//
185// Ultimately, we have to suppose multi-era decoders, and promote transactions into a common
186// model.
187impl<'b, C> cbor::Decode<'b, C> for TransactionBody {
188    fn decode(d: &mut cbor::Decoder<'b>, ctx: &mut C) -> Result<Self, cbor::decode::Error> {
189        #[derive(Default)]
190        struct RequiredFields {
191            inputs: Option<Set<TransactionInput>>,
192            outputs: Option<Vec<MemoizedTransactionOutput>>,
193            fee: Option<Lovelace>,
194        }
195
196        #[derive(Default)]
197        struct State {
198            optional: TransactionBody,
199            required: RequiredFields,
200        }
201
202        let original_bytes = d.input();
203        let start_position = d.position();
204
205        let mut state = State::default();
206
207        cbor::heterogeneous_map(
208            d,
209            &mut state,
210            |d| d.u64(),
211            |d, st, k| {
212                match k {
213                    0 => blanket(&mut st.required.inputs, k, d, ctx)?,
214                    1 => blanket(&mut st.required.outputs, k, d, ctx)?,
215                    2 => blanket(&mut st.required.fee, k, d, ctx)?,
216                    3 => blanket(&mut st.optional.validity_interval_end, k, d, ctx)?,
217                    4 => blanket(&mut st.optional.certificates, k, d, ctx)?,
218                    5 => blanket(&mut st.optional.withdrawals, k, d, ctx)?,
219                    // 6: governance updates, obsolete in Conway
220                    7 => blanket(&mut st.optional.auxiliary_data_hash, k, d, ctx)?,
221                    8 => blanket(&mut st.optional.validity_interval_start, k, d, ctx)?,
222                    9 => blanket(&mut st.optional.mint, k, d, ctx)?,
223                    // 10: there's no 10, has never been used.
224                    11 => blanket(&mut st.optional.script_data_hash, k, d, ctx)?,
225                    // 12: there's no 12, has never been used.
226                    13 => blanket(&mut st.optional.collateral, k, d, ctx)?,
227                    14 => blanket(&mut st.optional.required_signers, k, d, ctx)?,
228                    15 => blanket(&mut st.optional.network_id, k, d, ctx)?,
229                    16 => blanket(&mut st.optional.collateral_return, k, d, ctx)?,
230                    17 => blanket(&mut st.optional.total_collateral, k, d, ctx)?,
231                    18 => blanket(&mut st.optional.reference_inputs, k, d, ctx)?,
232                    19 => blanket(&mut st.optional.votes, k, d, ctx)?,
233                    20 => blanket(&mut st.optional.proposals, k, d, ctx)?,
234                    21 => blanket(&mut st.optional.treasury_value, k, d, ctx)?,
235                    22 => blanket(&mut st.optional.donation, k, d, ctx)?,
236                    _ => {
237                        let position = d.position();
238                        return Err(cbor::decode::Error::message(format!("unrecognised field key: {k}")).at(position));
239                    }
240                };
241
242                Ok(())
243            },
244        )?;
245
246        let end_position = d.position();
247
248        Ok(TransactionBody {
249            hash: Hasher::<{ TransactionBody::HASH_SIZE * 8 }>::hash(&original_bytes[start_position..end_position]),
250            original_size: (end_position - start_position) as u64, // from usize
251            inputs: expect_field(mem::take(&mut state.required.inputs), 0, "inputs")?,
252            outputs: expect_field(mem::take(&mut state.required.outputs), 1, "outputs")?,
253            fee: expect_field(mem::take(&mut state.required.fee), 2, "fee")?,
254            ..state.optional
255        })
256    }
257}
258
259fn blanket<'d, C, T>(
260    field: &mut Option<T>,
261    k: u64,
262    d: &mut cbor::Decoder<'d>,
263    ctx: &mut C,
264) -> Result<(), cbor::decode::Error>
265where
266    T: cbor::Decode<'d, C>,
267{
268    if field.is_some() {
269        return Err(cbor::decode::Error::message(format!("duplicate field entry with key {k}")));
270    }
271
272    *field = Some(d.decode_with(ctx)?);
273
274    Ok(())
275}
276
277fn expect_field<T>(decoded: Option<T>, key: u64, name: &str) -> Result<T, cbor::decode::Error> {
278    decoded.ok_or(cbor::decode::Error::message(format!("missing expected field '{name}' at key {key}")))
279}
280
281#[cfg(test)]
282mod tests {
283    use test_case::test_case;
284
285    use super::TransactionBody;
286    use crate::cbor;
287
288    macro_rules! fixture {
289        // Allowed eras
290        ("conway", $id:expr) => { fixture!(@inner "conway", $id) };
291
292        // Catch-all: invalid era
293        ($era:literal, $id:expr) => {
294            compile_error!(
295                "invalid era: expected one of: \"conway\""
296            );
297        };
298
299        (@inner $era:literal, $id:expr) => {{
300            $crate::try_include_cbor!(concat!(
301                "cbor.decode/transaction_body/",
302                $era,
303                "/",
304                $id,
305                "/sample.cbor",
306            ))
307        }};
308    }
309
310    #[test_case(
311        fixture!("conway", "70beb79b18459ff5b826ebeea82ecf566ab79e166ff5749f761ed402ad459466"), 86;
312        "simple input -> output payout"
313    )]
314    #[test_case(
315        fixture!("conway", "950bde838976daf2f0019ac6cc5a995e86d99f5ff1b2ffddaaa9ef44b558e4db"), 48;
316        "present but empty inputs set"
317    )]
318    #[test_case(
319        fixture!("conway", "ab17a2693346d625ea9afd9bdb9f9b357e60533b3e4cb7576e2c117b94d1c180"), 49;
320        "present but empty outputs set"
321    )]
322    #[test_case(
323        fixture!("conway", "c20c7e395ef81d8a6172510408446afc240d533bff18f9dca905e78187c2bcd8"), 80;
324        "null fee"
325    )]
326    fn decode_wellformed(result: Result<TransactionBody, cbor::decode::Error>, expected_size: u64) {
327        match result {
328            Err(err) => panic!("{err}"),
329            Ok(body) => assert_eq!(body.len(), expected_size),
330        }
331    }
332
333    #[test_case(
334        fixture!("conway", "d36a2619a672494604e11bb447cbcf5231e9f2ba25c2169177edc941bd50ad6c"),
335        "decode error: missing expected field 'inputs' at key 0";
336        "empty body"
337    )]
338    #[test_case(
339        fixture!("conway", "b563891d222561e435b475632a3bdcca58cc3c8ec80ab6b51e0a5c96b6a35e1b"),
340        "decode error: missing expected field 'outputs' at key 1";
341        "missing outputs"
342    )]
343    #[test_case(
344        fixture!("conway", "9d34025191e23c5996e20c2c0d1718f5cb1d9c4a37a5cb153cbd03c66b59128f"),
345        "decode error: missing expected field 'fee' at key 2";
346        "missing fee"
347    )]
348    #[test_case(
349        fixture!("conway", "c5f2d5b7e9b8f615c52296e04b3050cf35ad4e8a457a25adaeb2a933de1bf624"),
350        "decode error at position 81: empty set when expecting at least one element";
351        "empty certificates"
352    )]
353    #[test_case(
354        fixture!("conway", "3b5478c6446496b6ff71c738c83fbf251841dd45cda074b0ac935b1428a52f66"),
355        "unexpected type map at position 81: expected array";
356        "malformed certificates"
357    )]
358    #[test_case(
359        fixture!("conway", "5123113da4c8e2829748dbcd913ac69f572516836731810c2fc1f8b86351bfee"),
360        "decode error at position 87: empty map when expecting at least one key/value pair";
361        "empty votes"
362    )]
363    #[test_case(
364        fixture!("conway", "6c6596eda4e61f6f294b522c17f3c9fb6fbddcfac0e55af88ddc96747b3e0478"),
365        "unexpected type array at position 87: expected map";
366        "malformed votes"
367    )]
368    #[test_case(
369        fixture!("conway", "402a8a9024d4160928e574c73aa66c66d92f9856c3fa2392242f7a92b8e9c347"),
370        "decode error at position 81: empty map when expecting at least one key/value pair";
371        "empty mint"
372    )]
373    #[test_case(
374        fixture!("conway", "48d5440656ceefda1ac25506dcd175e77a486113733a89e48a5a2f401d2cbfda"),
375        "decode error at position 87: empty set when expecting at least one element";
376        "empty collateral inputs"
377    )]
378    #[test_case(
379        fixture!("conway", "5cbed05f218d893dac6d9af847aa7429576019a1314b633e3fde55cb74e43be1"),
380        "decode error at position 87: empty set when expecting at least one element";
381        "empty required signers"
382    )]
383    #[test_case(
384        fixture!("conway", "71d780bdcc0cf8d1a8dafc6641797d46f1be835be6dd63b2b4bb5651df808d79"),
385        "decode error at position 81: empty map when expecting at least one key/value pair";
386        "empty withdrawals"
387    )]
388    #[test_case(
389        fixture!("conway", "477981b76e218802d5ce8c673abefe0b4031f09b0be5283a5b577ca109671771"),
390        "decode error at position 87: empty set when expecting at least one element";
391        "empty proposals"
392    )]
393    #[test_case(
394        fixture!("conway", "675954a2fe5ad3638a360902a4c7307a598d6e13b977279df640a663023c14bd"),
395        "decode error: decoding 0 as PositiveCoin";
396        "null donation"
397    )]
398    #[test_case(
399        fixture!("conway", "5280ac2b10897dd26c9d7377ae681a6ea1dc3eec197563ab5bf3ab7907e0e709"),
400        "decode error: duplicate field entry with key 2";
401        "duplicate fields keys"
402    )]
403    fn decode_malformed(result: Result<TransactionBody, cbor::decode::Error>, expected_error: &str) {
404        assert_eq!(result.map_err(|e| e.to_string()), Err(expected_error.to_string()));
405    }
406}