diff --git a/Cargo.lock b/Cargo.lock index 2f570cc792..84e64868fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -283,6 +283,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atty" version = "0.2.14" @@ -923,6 +932,12 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "critical-section" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7059fff8937831a9ae6f0fe4d658ffabf58f2ca96aa9dec1c889f936f705f216" + [[package]] name = "crossbeam-channel" version = "0.5.12" @@ -1848,6 +1863,15 @@ version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7" +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hash32" version = "0.3.1" @@ -1898,13 +1922,26 @@ dependencies = [ "http", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "spin 0.9.6", + "stable_deref_trait", +] + [[package]] name = "heapless" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" dependencies = [ - "hash32", + "hash32 0.3.1", "stable_deref_trait", ] @@ -5968,15 +6005,19 @@ name = "mc-transaction-summary" version = "6.0.2" dependencies = [ "displaydoc", + "heapless 0.7.17", "mc-account-keys", "mc-core", "mc-crypto-digestible", "mc-crypto-keys", "mc-crypto-ring-signature", + "mc-transaction-core", "mc-transaction-types", + "mc-util-from-random", "mc-util-vec-map", "mc-util-zip-exact", "prost", + "rand", "serde", "subtle", "zeroize", @@ -6370,7 +6411,7 @@ name = "mc-util-vec-map" version = "6.0.2" dependencies = [ "displaydoc", - "heapless", + "heapless 0.8.0", ] [[package]] @@ -8538,6 +8579,9 @@ name = "spin" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5d6e0250b93c8427a177b849d144a96d5acc57006149479403d7861ab721e34" +dependencies = [ + "lock_api", +] [[package]] name = "spki" diff --git a/account-keys/Cargo.toml b/account-keys/Cargo.toml index c91d7a3f64..1708aa741a 100644 --- a/account-keys/Cargo.toml +++ b/account-keys/Cargo.toml @@ -10,7 +10,7 @@ rust-version = { workspace = true } [features] std = ["mc-util-repr-bytes/alloc"] prost = ["dep:prost", "mc-util-repr-bytes/prost", "mc-crypto-keys/prost"] -serde = ["mc-crypto-keys/serde"] +serde = ["dep:serde", "mc-crypto-keys/serde", "curve25519-dalek/serde"] default = ["std", "prost", "serde", "mc-util-serial", "mc-crypto-digestible/default", "mc-crypto-hashes/default", "mc-crypto-keys/default"] [dependencies] @@ -22,7 +22,7 @@ hex_fmt = "0.3" hkdf = "0.12.4" prost = { version = "0.12", optional = true, default-features = false, features = ["prost-derive"] } rand_core = { version = "0.6", default-features = false } -serde = { version = "1.0", default-features = false } +serde = { version = "1.0", optional = true, default-features = false, features = [ "alloc", "derive" ] } subtle = { version = "2", default-features = false } zeroize = { version = "1", default-features = false } diff --git a/account-keys/src/account_keys.rs b/account-keys/src/account_keys.rs index 595cf900bc..5cb75dfc71 100644 --- a/account-keys/src/account_keys.rs +++ b/account-keys/src/account_keys.rs @@ -1,4 +1,4 @@ -// Copyright (c) 2018-2022 The MobileCoin Foundation +// Copyright (c) 2018-2023 The MobileCoin Foundation //! MobileCoin account keys. //! @@ -37,6 +37,7 @@ use mc_util_from_random::FromRandom; #[cfg(feature = "prost")] use prost::Message; use rand_core::{CryptoRng, RngCore}; +#[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; use zeroize::Zeroize; @@ -49,10 +50,9 @@ pub use mc_core::{ }; /// A MobileCoin user's public subaddress. -#[derive( - Clone, Deserialize, Digestible, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Zeroize, -)] +#[derive(Clone, Digestible, Eq, Hash, Ord, PartialEq, PartialOrd, Zeroize)] #[cfg_attr(feature = "prost", derive(Message))] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct PublicAddress { /// The user's public subaddress view key 'C'. #[cfg_attr(feature = "prost", prost(message, required, tag = "1"))] diff --git a/transaction/extra/tests/verifier.rs b/transaction/extra/tests/verifier.rs index cc6e856020..68b1f2bac2 100644 --- a/transaction/extra/tests/verifier.rs +++ b/transaction/extra/tests/verifier.rs @@ -1,4 +1,4 @@ -// Copyright (c) 2018-2022 The MobileCoin Foundation +// Copyright (c) 2018-2023 The MobileCoin Foundation //! Tests of the streaming verifier @@ -18,7 +18,7 @@ use mc_transaction_core::{ Amount, BlockVersion, Token, TokenId, }; use mc_transaction_extra::UnsignedTx; -use mc_transaction_summary::{verify_tx_summary, TransactionEntity}; +use mc_transaction_summary::{verify_tx_summary, TotalKind, TransactionEntity}; use mc_util_from_random::FromRandom; use mc_util_serial::encode; use rand::{rngs::StdRng, SeedableRng}; @@ -204,6 +204,7 @@ fn test_max_size_tx_summary_verification() { &tx_summary, &tx_summary_unblinding_data, *sender.view_private_key(), + sender.change_subaddress(), ) .unwrap(); assert_eq!( @@ -212,17 +213,19 @@ fn test_max_size_tx_summary_verification() { ); let recipient_hash = ShortAddressHash::from(&recipient.default_subaddress()); - let balance_changes: Vec<_> = report.balance_changes.iter().collect(); assert_eq!( - balance_changes, - vec![ - (&(TransactionEntity::Ourself, TokenId::from(0)), &-16000), - ( - &(TransactionEntity::Address(recipient_hash), TokenId::from(0)), - &160 - ) - ] + &report.outputs, + &[( + TransactionEntity::OtherAddress(recipient_hash), + TokenId::from(0), + 160 + )] ); + assert_eq!( + &report.totals, + &[(TokenId::from(0), TotalKind::Ours, 16000),] + ); + assert_eq!(report.network_fee, Amount::new(15840, TokenId::from(0))); } @@ -239,6 +242,7 @@ fn test_min_size_tx_summary_verification() { &tx_summary, &tx_summary_unblinding_data, *sender.view_private_key(), + sender.change_subaddress(), ) .unwrap(); assert_eq!( @@ -247,16 +251,17 @@ fn test_min_size_tx_summary_verification() { ); let recipient_hash = ShortAddressHash::from(&recipient.default_subaddress()); - let balance_changes: Vec<_> = report.balance_changes.iter().collect(); assert_eq!( - balance_changes, - vec![ - (&(TransactionEntity::Ourself, TokenId::from(0)), &-1000), - ( - &(TransactionEntity::Address(recipient_hash), TokenId::from(0)), - &10 - ) - ] + &report.outputs, + &[( + TransactionEntity::OtherAddress(recipient_hash), + TokenId::from(0), + 10 + )] + ); + assert_eq!( + &report.totals, + &[(TokenId::from(0), TotalKind::Ours, 1000),] ); assert_eq!(report.network_fee, Amount::new(990, TokenId::from(0))); } @@ -333,6 +338,7 @@ fn test_two_input_tx_with_change_tx_summary_verification() { &tx_summary, &tx_summary_unblinding_data, *sender.view_private_key(), + sender.change_subaddress(), ) .unwrap(); assert_eq!( @@ -341,23 +347,22 @@ fn test_two_input_tx_with_change_tx_summary_verification() { ); let recipient_hash = ShortAddressHash::from(&recipient.default_subaddress()); - let balance_changes: Vec<_> = report - .balance_changes - .iter() - .map(|(x, y)| (x.clone(), *y)) - .collect(); - let expected = vec![ - ( - (TransactionEntity::Ourself, token_id), - -((value + value2 - change_value) as i64), - ), - ( - (TransactionEntity::Address(recipient_hash), token_id), - (value + value2 - change_value - Mob::MINIMUM_FEE) as i64, - ), - ]; - - assert_eq!(balance_changes, expected); + assert_eq!( + &report.totals, + &[( + token_id, + TotalKind::Ours, + (value + value2 - change_value) as i128 + ),] + ); + assert_eq!( + &report.outputs, + &[( + TransactionEntity::OtherAddress(recipient_hash), + token_id, + (value + value2 - change_value - Mob::MINIMUM_FEE) as u128 + ),] + ); assert_eq!(report.network_fee, Amount::new(Mob::MINIMUM_FEE, token_id)); } } @@ -425,6 +430,7 @@ fn test_simple_tx_with_change_tx_summary_verification() { &tx_summary, &tx_summary_unblinding_data, *sender.view_private_key(), + sender.change_subaddress(), ) .unwrap(); assert_eq!( @@ -433,23 +439,18 @@ fn test_simple_tx_with_change_tx_summary_verification() { ); let recipient_hash = ShortAddressHash::from(&recipient.default_subaddress()); - let balance_changes: Vec<_> = report - .balance_changes - .iter() - .map(|(x, y)| (x.clone(), *y)) - .collect(); - let expected = vec![ - ( - (TransactionEntity::Ourself, token_id), - -((value - change_value) as i64), - ), - ( - (TransactionEntity::Address(recipient_hash), token_id), - (value - change_value - Mob::MINIMUM_FEE) as i64, - ), - ]; - - assert_eq!(balance_changes, expected); + assert_eq!( + &report.totals, + &[(token_id, TotalKind::Ours, ((value - change_value) as i128)),] + ); + assert_eq!( + &report.outputs, + &[( + TransactionEntity::OtherAddress(recipient_hash), + token_id, + (value - change_value - Mob::MINIMUM_FEE) as u128 + ),] + ); assert_eq!(report.network_fee, Amount::new(Mob::MINIMUM_FEE, token_id)); } } @@ -520,6 +521,7 @@ fn test_two_output_tx_with_change_tx_summary_verification() { &tx_summary, &tx_summary_unblinding_data, *sender.view_private_key(), + sender.change_subaddress(), ) .unwrap(); assert_eq!( @@ -529,28 +531,28 @@ fn test_two_output_tx_with_change_tx_summary_verification() { let recipient_hash = ShortAddressHash::from(&recipient.default_subaddress()); let recipient2_hash = ShortAddressHash::from(&recipient2.default_subaddress()); - let balance_changes: Vec<_> = report - .balance_changes - .iter() - .map(|(x, y)| (x.clone(), *y)) - .collect(); - let mut expected = vec![ - ( - (TransactionEntity::Ourself, token_id), - -((value + value2 + Mob::MINIMUM_FEE) as i64), - ), + assert_eq!( + &report.totals, + &[( + token_id, + TotalKind::Ours, + (value + value2 + Mob::MINIMUM_FEE) as i128 + ),] + ); + let mut outputs = vec![ ( - (TransactionEntity::Address(recipient_hash), token_id), - (value as i64), + TransactionEntity::OtherAddress(recipient_hash), + token_id, + value as u128, ), ( - (TransactionEntity::Address(recipient2_hash), token_id), - (value2 as i64), + TransactionEntity::OtherAddress(recipient2_hash), + token_id, + value2 as u128, ), ]; - expected.sort(); - - assert_eq!(balance_changes, expected); + outputs.sort(); + assert_eq!(&report.outputs[..], &outputs[..]); assert_eq!(report.network_fee, Amount::new(Mob::MINIMUM_FEE, token_id)); } } @@ -637,6 +639,7 @@ fn test_sci_tx_summary_verification() { &mut rng, ) .unwrap(); + let bob_hash = ShortAddressHash::from(&bob.default_subaddress()); let unsigned_tx = builder .build_unsigned::() @@ -650,6 +653,7 @@ fn test_sci_tx_summary_verification() { &tx_summary, &tx_summary_unblinding_data, *bob.view_private_key(), + bob.change_subaddress(), ) .unwrap(); assert_eq!( @@ -657,23 +661,29 @@ fn test_sci_tx_summary_verification() { &signing_data.mlsag_signing_digest[..] ); - let balance_changes: Vec<_> = report - .balance_changes - .iter() - .map(|(x, y)| (x.clone(), *y)) - .collect(); - let mut expected = vec![ + // TODO: fix this test + assert_eq!( + &report.totals, + &[ + // Bob spends 3x worth of token id 2 in the transaction + (token2, TotalKind::Ours, value2 as i128), + // SCI inputs used in the transaction + (Mob::ID, TotalKind::Sci, value as i128), + ] + ); + let mut outputs = vec![ + // Output to swap counterparty + (TransactionEntity::Swap, token2, value2 as u128), + // Converted output to ourself ( - (TransactionEntity::Ourself, Mob::ID), - ((value - Mob::MINIMUM_FEE) as i64), + TransactionEntity::OurAddress(bob_hash), + Mob::ID, + (value - Mob::MINIMUM_FEE) as u128, ), - ((TransactionEntity::Ourself, token2), -(value2 as i64)), - ((TransactionEntity::Swap, Mob::ID), -(value as i64)), - ((TransactionEntity::Swap, token2), (value2 as i64)), ]; - expected.sort(); + outputs.sort(); + assert_eq!(&report.outputs[..], &outputs[..]); - assert_eq!(balance_changes, expected); assert_eq!(report.network_fee, Amount::new(Mob::MINIMUM_FEE, Mob::ID)); } @@ -773,6 +783,7 @@ fn test_sci_three_way_tx_summary_verification() { &tx_summary, &tx_summary_unblinding_data, *bob.view_private_key(), + bob.change_subaddress(), ) .unwrap(); assert_eq!( @@ -780,24 +791,29 @@ fn test_sci_three_way_tx_summary_verification() { &signing_data.mlsag_signing_digest[..] ); - let balance_changes: Vec<_> = report - .balance_changes - .iter() - .map(|(x, y)| (x.clone(), *y)) - .collect(); - let charlie_hash = ShortAddressHash::from(&charlie.default_subaddress()); - let mut expected = vec![ - ((TransactionEntity::Ourself, token2), -(value2 as i64)), + + assert_eq!( + &report.totals, + &[ + // Bob's spend to create the transaction + (token2, TotalKind::Ours, value2 as i128), + // SCI inputs used in the transaction + (Mob::ID, TotalKind::Sci, value as i128), + ] + ); + let mut outputs = vec![ + // Converted output to charlie, - fee paid from Mob input ( - (TransactionEntity::Address(charlie_hash), Mob::ID), - ((value - Mob::MINIMUM_FEE) as i64), + TransactionEntity::OtherAddress(charlie_hash), + Mob::ID, + (value - Mob::MINIMUM_FEE) as u128, ), - ((TransactionEntity::Swap, Mob::ID), -(value as i64)), - ((TransactionEntity::Swap, token2), (value2 as i64)), + // Output to swap counterparty + (TransactionEntity::Swap, token2, value2 as u128), ]; - expected.sort(); + outputs.sort(); + assert_eq!(&report.outputs[..], &outputs[..]); - assert_eq!(balance_changes, expected); assert_eq!(report.network_fee, Amount::new(Mob::MINIMUM_FEE, Mob::ID)); } diff --git a/transaction/summary/Cargo.toml b/transaction/summary/Cargo.toml index 3f547b1f11..2e005da05f 100644 --- a/transaction/summary/Cargo.toml +++ b/transaction/summary/Cargo.toml @@ -28,6 +28,7 @@ default = ["std", "serde", "prost", "mc-account-keys"] [dependencies] # External dependencies displaydoc = { version = "0.2", default-features = false } +heapless = { version = "0.7.16", default-features = false } # MobileCoin dependencies mc-account-keys = { path = "../../account-keys", optional = true, default-features = false } @@ -43,3 +44,8 @@ prost = { version = "0.12", optional = true, default-features = false, features serde = { version = "1.0", optional = true, default-features = false, features = ["derive"] } subtle = { version = "2.4.1", default-features = false, features = ["i128"] } zeroize = { version = "1", default-features = false } + +[dev-dependencies] +mc-transaction-core = { path = "../core", default-features = false, features = [] } +mc-util-from-random = { path = "../../util/from-random", default-features = false } +rand = "0.8.5" diff --git a/transaction/summary/src/data.rs b/transaction/summary/src/data.rs index 04c569af64..ac3a55a27a 100644 --- a/transaction/summary/src/data.rs +++ b/transaction/summary/src/data.rs @@ -3,9 +3,9 @@ use alloc::vec::Vec; use super::{Error, TxSummaryUnblindingReport}; -use crate::TxSummaryStreamingVerifierCtx; +use crate::{report::TransactionReport, TxSummaryStreamingVerifierCtx}; use mc_account_keys::PublicAddress; -use mc_core::account::ShortAddressHash; +use mc_core::account::{PublicSubaddress, RingCtAddress, ShortAddressHash}; use mc_crypto_digestible::Digestible; use mc_crypto_keys::RistrettoPrivate; use mc_transaction_types::{Amount, TxSummary, UnmaskedAmount}; @@ -76,6 +76,7 @@ pub fn verify_tx_summary( tx_summary: &TxSummary, unblinding_data: &TxSummaryUnblindingData, view_private_key: RistrettoPrivate, + change_address: impl RingCtAddress, ) -> Result<([u8; 32], TxSummaryUnblindingReport), Error> { let mut verifier = TxSummaryStreamingVerifierCtx::new( extended_message_digest, @@ -83,6 +84,10 @@ pub fn verify_tx_summary( tx_summary.outputs.len(), tx_summary.inputs.len(), view_private_key, + PublicSubaddress { + view_public: change_address.view_public_key(), + spend_public: change_address.spend_public_key(), + }, ); let mut report = TxSummaryUnblindingReport::default(); @@ -116,7 +121,9 @@ pub fn verify_tx_summary( tx_summary.tombstone_block, &mut digest, &mut report, - ); + )?; + + report.finalize()?; // In a debug build, confirm the digest by computing it in a non-streaming way // diff --git a/transaction/summary/src/error.rs b/transaction/summary/src/error.rs index 120071e7cc..06ab2c55f7 100644 --- a/transaction/summary/src/error.rs +++ b/transaction/summary/src/error.rs @@ -38,6 +38,8 @@ pub enum Error { Amount(AmountError), /// ZipExact error: {0} ZipExact(ZipExactError), + /// Missing address for own output + MissingOutputAddress, } impl From for Error { diff --git a/transaction/summary/src/lib.rs b/transaction/summary/src/lib.rs index cf6de2c0ba..302631b14a 100644 --- a/transaction/summary/src/lib.rs +++ b/transaction/summary/src/lib.rs @@ -2,7 +2,7 @@ // Copyright (c) 2018-2022 The MobileCoin Foundation -#![no_std] +#![cfg_attr(not(feature = "std"), no_std)] #![doc = include_str!("../README.md")] #![deny(missing_docs)] @@ -18,5 +18,5 @@ mod verifier; pub use data::{verify_tx_summary, TxOutSummaryUnblindingData, TxSummaryUnblindingData}; pub use error::Error; -pub use report::{TransactionEntity, TxSummaryUnblindingReport}; +pub use report::{TotalKind, TransactionEntity, TxSummaryUnblindingReport}; pub use verifier::TxSummaryStreamingVerifierCtx; diff --git a/transaction/summary/src/report.rs b/transaction/summary/src/report.rs index c81d915864..61a21013b7 100644 --- a/transaction/summary/src/report.rs +++ b/transaction/summary/src/report.rs @@ -3,90 +3,377 @@ //! A TxSummaryUnblindingReport, containing the set of verified information //! about a transaction. -use super::Error; use core::fmt::Display; + use displaydoc::Display; +use heapless::Vec; use mc_core::account::ShortAddressHash; use mc_transaction_types::{ constants::{MAX_INPUTS, MAX_OUTPUTS}, Amount, TokenId, }; -use mc_util_vec_map::VecMap; + +use super::Error; /// An entity with whom a transaction can interact, and who can be identified /// by the TxSummary verification process #[derive(Clone, Debug, Display, Eq, Ord, PartialEq, PartialOrd)] pub enum TransactionEntity { - /// Self - Ourself, - /// Address hash {0} - Address(ShortAddressHash), - /// Swap counterparty + /// Outputs to a non-change address that we control (hash {0}) + OurAddress(ShortAddressHash), + + /// Outputs to other accounts (hash {0}) + OtherAddress(ShortAddressHash), + + /// Outputs to swap counterparty Swap, } +/// Generic transaction report interface +// (There is at this time only one report implementation, however, this trait +// is particularly useful for eliding generics when using this and is expected +// to be helpful when building support for account info caching.) +pub trait TransactionReport { + /// Add value to the running transaction totals + fn input_add(&mut self, amount: Amount) -> Result<(), Error>; + + /// Subtract an amount from the transaction total, used for change outputs + /// and SCIs if enabled + fn change_sub(&mut self, amount: Amount) -> Result<(), Error>; + + /// Add SCI input not owned by our account + fn sci_add(&mut self, amount: Amount) -> Result<(), Error>; + + /// Add output value for a particular entity / address to the report + fn output_add(&mut self, entity: TransactionEntity, amount: Amount) -> Result<(), Error>; + + /// Set the network fee + fn network_fee_set(&mut self, amount: Amount) -> Result<(), Error>; + + /// Set the tombstone block + fn tombstone_block_set(&mut self, value: u64) -> Result<(), Error>; + + /// Finalise the report, checking balances and sorting report entries + fn finalize(&mut self) -> Result<(), Error>; +} + +/// [TransactionReport] impl for `&mut T` where `T: TransactionReport` +impl TransactionReport for &mut T { + fn input_add(&mut self, amount: Amount) -> Result<(), Error> { + ::input_add(self, amount) + } + + fn change_sub(&mut self, amount: Amount) -> Result<(), Error> { + ::change_sub(self, amount) + } + + fn sci_add(&mut self, amount: Amount) -> Result<(), Error> { + ::sci_add(self, amount) + } + + fn output_add(&mut self, entity: TransactionEntity, amount: Amount) -> Result<(), Error> { + ::output_add(self, entity, amount) + } + + fn network_fee_set(&mut self, amount: Amount) -> Result<(), Error> { + ::network_fee_set(self, amount) + } + + fn tombstone_block_set(&mut self, value: u64) -> Result<(), Error> { + ::tombstone_block_set(self, value) + } + + fn finalize(&mut self) -> Result<(), Error> { + ::finalize(self) + } +} + +/// Compute maximum number of outputs and inputs to be supported by a report pub const MAX_RECORDS: usize = MAX_OUTPUTS as usize + MAX_INPUTS as usize; -/// A report of the parties and balance changes due to a transaction. -/// This can be produced for a given TxSummary and TxSummaryUnblindingData. +/// Maximum number of currencies with totals supported in a single report. +/// +/// It is expected that _most_ transactions will contain one total, however, +/// this should be large enough to support SCIs with other token types +pub const MAX_TOTALS: usize = 4; + +/// A report of the parties and balance changes due to a transaction, +/// produced for a given TxSummary and TxSummaryUnblindingData. +/// +/// This uses a double-entry approach where outputs and totals should be +/// balanced. For each token, totals = our inputs - sum(change outputs) == +/// sum(other outputs) + fee +/// +/// SCI inputs are also summed, and can be elided from the report with +/// [TxSummaryUnblindingReport::elide_swap_totals] #[derive(Clone, Debug, Default)] -pub struct TxSummaryUnblindingReport { - /// The set of balance changes that we have observed - // Note: We can save about 210 bytes on the stack if we store TokenId as - // a [u8; 8] to avoid alignment requirements. TBD if that's worth it. - pub balance_changes: VecMap<(TransactionEntity, TokenId), i64, RECORDS>, - /// The network fee that we pay +pub struct TxSummaryUnblindingReport< + const RECORDS: usize = MAX_RECORDS, + const TOTALS: usize = MAX_TOTALS, +> { + /// Transaction outputs aggregated by address and token type + pub outputs: Vec<(TransactionEntity, TokenId, u128), RECORDS>, + + /// Total balance change for our account for each type of token in the + /// transaction. + /// + /// totals = inputs - sum(change outputs) + /// + /// Note that owned and swap inputs are split as most + /// applications are concerned only with the cost of + /// the transaction to the user. + /// + /// See [elide_swap_totals] for more detail. + pub totals: Vec<(TokenId, TotalKind, i128), TOTALS>, + + /// The network fee that we pay to execute the transaction pub network_fee: Amount, + /// The tombstone block associated to this transaction pub tombstone_block: u64, } -impl TxSummaryUnblindingReport { - /// Add value to the balance report, for some entity - pub fn balance_add( - &mut self, - entity: TransactionEntity, - token_id: TokenId, - value: u64, - ) -> Result<(), Error> { - let value = i64::try_from(value).map_err(|_| Error::NumericOverflow)?; - let stored = self - .balance_changes - .get_mut_or_insert_with(&(entity, token_id), || 0) - .map_err(|_| Error::BufferOverflow)?; - *stored = stored.checked_add(value).ok_or(Error::NumericOverflow)?; +/// Type of total balance, either `Ours` for inputs from our account or +/// `Sci` for inputs from a swap counterparty. +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub enum TotalKind { + /// Input owned by our account (less change), outgoing from our account + Ours, + /// Input owned by SCI counterparty (less change for partial swaps) incoming + /// to our account + Sci, +} + +impl TransactionReport + for TxSummaryUnblindingReport +{ + /// Add owned input, added to the transaction total + fn input_add(&mut self, amount: Amount) -> Result<(), Error> { + let Amount { token_id, value } = amount; + + // Ensure value will not overflow + let value = i128::try_from(value).map_err(|_| Error::NumericOverflow)?; + + // Check for existing total entry for this token + match self + .totals + .iter_mut() + .find(|(t, k, _)| t == &token_id && *k == TotalKind::Ours) + { + // If we have an entry, add the value to this + Some(v) => v.2 = v.2.checked_add(value).ok_or(Error::NumericOverflow)?, + // If we do not, create a new entry + None => self + .totals + .push((token_id, TotalKind::Ours, value)) + .map_err(|_| Error::BufferOverflow)?, + } + Ok(()) } - /// Subtract value from the balance report, for some entity - pub fn balance_subtract( - &mut self, - entity: TransactionEntity, - token_id: TokenId, - value: u64, - ) -> Result<(), Error> { - let value = i64::try_from(value).map_err(|_| Error::NumericOverflow)?; - let stored = self - .balance_changes - .get_mut_or_insert_with(&(entity, token_id), || 0) - .map_err(|_| Error::BufferOverflow)?; - *stored = stored.checked_sub(value).ok_or(Error::NumericOverflow)?; + /// Add change output, subtracted from the transaction total + fn change_sub(&mut self, amount: Amount) -> Result<(), Error> { + let Amount { token_id, value } = amount; + + // Ensure value will not overflow + let value = i128::try_from(value).map_err(|_| Error::NumericOverflow)?; + + // Check for existing total entry for this token + match self + .totals + .iter_mut() + .find(|(t, k, _)| t == &token_id && *k == TotalKind::Ours) + { + // If we have an entry, subtract the change value from this + Some(v) => v.2 = v.2.checked_sub(value).ok_or(Error::NumericOverflow)?, + // If we do not, create a new entry + None => self + .totals + .push((token_id, TotalKind::Ours, -value)) + .map_err(|_| Error::BufferOverflow)?, + } + Ok(()) } - /// This should be done before displaying the report + /// Add SCI (or other) input not owned by our account + fn sci_add(&mut self, amount: Amount) -> Result<(), Error> { + let Amount { token_id, value } = amount; + + // Ensure value will not overflow + let value = i128::try_from(value).map_err(|_| Error::NumericOverflow)?; + + // Check for existing total entry for this token + match self + .totals + .iter_mut() + .find(|(t, k, _)| t == &token_id && *k == TotalKind::Sci) + { + // If we have an entry, add the value to this + Some(v) => v.2 = v.2.checked_add(value).ok_or(Error::NumericOverflow)?, + // If we do not, create a new entry + None => self + .totals + .push((token_id, TotalKind::Sci, value)) + .map_err(|_| Error::BufferOverflow)?, + } + Ok(()) + } + + /// Add output value to a particular entity / address to the report + fn output_add(&mut self, entity: TransactionEntity, amount: Amount) -> Result<(), Error> { + let Amount { token_id, value } = amount; + + // Ensure value will not overflow + let value = u128::try_from(value).map_err(|_| Error::NumericOverflow)?; + + // Check for existing output for this address + match self + .outputs + .iter_mut() + .find(|(e, t, _)| t == &token_id && e == &entity) + { + // If we have an entry, subtract the change value from this + Some((_, _, v)) => *v = v.checked_add(value).ok_or(Error::NumericOverflow)?, + // If we do not, create a new entry + None => self + .outputs + .push((entity, token_id, value)) + .map_err(|_| Error::BufferOverflow)?, + } + + Ok(()) + } + + /// Add network fee to the report + fn network_fee_set(&mut self, amount: Amount) -> Result<(), Error> { + // Set fee value + self.network_fee = amount; + + Ok(()) + } + + /// Set tombstone block in the report + fn tombstone_block_set(&mut self, value: u64) -> Result<(), Error> { + self.tombstone_block = value; + Ok(()) + } + + /// Finalise report, checking and balancing totals and sorting report + /// entries + fn finalize(&mut self) -> Result<(), Error> { + // Sort outputs and totals + self.sort(); + + // For each token id, check that inputs match outputs + for (token_id, total_kind, value) in &mut self.totals { + // Sum outputs for this token id + let mut balance = 0i128; + for (e, id, v) in &self.outputs { + // Skip other tokens + if id != token_id { + continue; + } + + // Handle balance / values depending on whether the total is from us or a swap + // counterparty + match total_kind { + // If it's coming from our account, track total balance + TotalKind::Ours => { + balance = balance + .checked_add(*v as i128) + .ok_or(Error::NumericOverflow)?; + } + // If it's coming from an SCI, and returned to the counterparty, reduce total by + // outgoing value + TotalKind::Sci if e == &TransactionEntity::Swap => { + *value = value + .checked_sub(*v as i128) + .ok_or(Error::NumericOverflow)?; + } + // If it's coming from an SCI to us, add to total balance + TotalKind::Sci if e != &TransactionEntity::Swap => { + balance = balance + .checked_add(*v as i128) + .ok_or(Error::NumericOverflow)?; + } + _ => (), + } + } + + // Add network fee for matching token id + if &self.network_fee.token_id == token_id { + balance = balance + .checked_add(self.network_fee.value as i128) + .ok_or(Error::NumericOverflow)?; + } + + // Check that the balance matches the total + if balance != *value { + return Err(Error::AmountVerificationFailed); + } + } + + Ok(()) + } +} + +impl TxSummaryUnblindingReport { + /// Create a new report instance + pub fn new() -> Self { + Self { + outputs: Vec::new(), + totals: Vec::new(), + network_fee: Default::default(), + tombstone_block: 0, + } + } + + /// Sort balance changes and totals + /// + /// This should be called prior to displaying the report. pub fn sort(&mut self) { - self.balance_changes.sort(); + // TODO: should we remove zeroed balances / totals? + + self.outputs[..].sort_by_key(|(e, t, _)| (e.clone(), *t)); + self.totals[..].sort_by_key(|(t, k, _)| (*k, *t)); + } + + /// Elide SCI inputs and change outputs from a finalised report + /// + /// This transforms the report to only include outputs from _our_ + /// account in the totals for display to users. + pub fn elide_swap_totals(&mut self) { + // Find SCI inputs in totals + for (token_id, kind, _) in &self.totals[..] { + if *kind != TotalKind::Sci { + continue; + } + + // Remove corresponding change outputs for partial swap + // (change outputs are in the same token type as the swap, + // where the outputs to fulfil the swap are not) + self.outputs.retain(|(entity, token, _amount)| { + *entity != TransactionEntity::Swap || token != token_id + }); + } + + // Remove SCI inputs from totals + self.totals + .retain(|(_token_id, kind, _)| *kind != TotalKind::Sci) } } // This is a proof-of-concept, it doesn't map token id's to their symbol when // displaying. -impl Display for TxSummaryUnblindingReport { +impl Display + for TxSummaryUnblindingReport +{ fn fmt(&self, formatter: &mut core::fmt::Formatter) -> core::fmt::Result { let mut current_entity = None; - for ((entity, tok), val) in self.balance_changes.iter() { + for (entity, tok, val) in self.outputs.iter() { if Some(entity) != current_entity.as_ref() { writeln!(formatter, "{entity}:")?; current_entity = Some(entity.clone()); @@ -104,9 +391,152 @@ impl Display for TxSummaryUnblindingReport { #[cfg(test)] mod tests { + use rand::random; + use super::*; + #[test] - fn test_report_size_size() { - assert_eq!(core::mem::size_of::(), 1320); + fn test_report_size() { + assert_eq!(core::mem::size_of::(), 1704); + } + + #[test] + fn test_report_totals() { + let mut report = TxSummaryUnblindingReport::<16>::new(); + + let amounts = [ + Amount::new(50, TokenId::from(1)), + Amount::new(50, TokenId::from(1)), + Amount::new(100, TokenId::from(2)), + Amount::new(200, TokenId::from(2)), + ]; + + for a in amounts { + report.input_add(a).unwrap(); + } + + // Check total inputs + report.sort(); + assert_eq!( + &report.totals[..], + &[ + (TokenId::from(1), TotalKind::Ours, 100), + (TokenId::from(2), TotalKind::Ours, 300) + ] + ); + + // Subtract change amounts + report + .change_sub(Amount::new(25, TokenId::from(1))) + .unwrap(); + report + .change_sub(Amount::new(50, TokenId::from(2))) + .unwrap(); + + // Check total inputs - change + assert_eq!( + &report.totals[..], + &[ + (TokenId::from(1), TotalKind::Ours, 75), + (TokenId::from(2), TotalKind::Ours, 250) + ] + ); + } + + #[test] + fn test_report_balances() { + let mut report = TxSummaryUnblindingReport::<16>::new(); + + // Setup random addresses, sorted so these match the report entry order + let mut addrs = [ + TransactionEntity::OtherAddress(ShortAddressHash::from(random::<[u8; 16]>())), + TransactionEntity::OtherAddress(ShortAddressHash::from(random::<[u8; 16]>())), + ]; + addrs.sort(); + + let amounts = [ + (addrs[0].clone(), Amount::new(50, TokenId::from(1))), + (addrs[0].clone(), Amount::new(50, TokenId::from(1))), + (addrs[0].clone(), Amount::new(80, TokenId::from(2))), + (addrs[1].clone(), Amount::new(120, TokenId::from(2))), + (TransactionEntity::Swap, Amount::new(200, TokenId::from(2))), + ]; + + for (e, a) in amounts { + report.output_add(e, a).unwrap(); + } + + // Check total outputs + report.sort(); + assert_eq!( + &report.outputs[..], + &[ + (addrs[0].clone(), TokenId::from(1), 100), + (addrs[0].clone(), TokenId::from(2), 80), + (addrs[1].clone(), TokenId::from(2), 120), + (TransactionEntity::Swap, TokenId::from(2), 200), + ] + ); + } + + #[test] + fn test_flatten_sci() { + let mut report = TxSummaryUnblindingReport::<16>::new(); + + let addr = TransactionEntity::OurAddress(ShortAddressHash::from(random::<[u8; 16]>())); + + // Add SCI outputs + report + .output_add(TransactionEntity::Swap, Amount::new(10, TokenId::from(1))) + .unwrap(); + report + .output_add(TransactionEntity::Swap, Amount::new(50, TokenId::from(2))) + .unwrap(); + + // Add output to us + report + .output_add(addr.clone(), Amount::new(50, TokenId::from(2))) + .unwrap(); + + // Add input from SCI + report.sci_add(Amount::new(100, TokenId::from(2))).unwrap(); + + // Add owned input + report.input_add(Amount::new(10, TokenId::from(1))).unwrap(); + + // Finalise report + report.finalize().unwrap(); + + // Check full outputs / totals + assert_eq!( + &report.outputs[..], + &[ + (addr.clone(), TokenId::from(2), 50), + (TransactionEntity::Swap, TokenId::from(1), 10), + (TransactionEntity::Swap, TokenId::from(2), 50), + ] + ); + assert_eq!( + &report.totals[..], + &[ + (TokenId::from(1), TotalKind::Ours, 10), + (TokenId::from(2), TotalKind::Sci, 50), + ] + ); + + // Trim swap information (removes swap partial returns and totals) + report.elide_swap_totals(); + + assert_eq!( + &report.outputs[..], + &[ + (addr, TokenId::from(2), 50), + (TransactionEntity::Swap, TokenId::from(1), 10), + ] + ); + assert_eq!( + &report.totals[..], + &[(TokenId::from(1), TotalKind::Ours, 10),] + ); } } diff --git a/transaction/summary/src/verifier.rs b/transaction/summary/src/verifier.rs index 27a192e9d3..fc421384ad 100644 --- a/transaction/summary/src/verifier.rs +++ b/transaction/summary/src/verifier.rs @@ -10,8 +10,10 @@ //! To take the largest "step" (verifying an output) requires //! approximately 300 bytes + Fog url length -use super::{Error, TransactionEntity, TxSummaryUnblindingReport}; -use mc_core::account::{RingCtAddress, ShortAddressHash}; +use crate::report::TransactionReport; + +use super::{Error, TransactionEntity}; +use mc_core::account::{PublicSubaddress, RingCtAddress, ShortAddressHash}; use mc_crypto_digestible::{DigestTranscript, Digestible, MerlinTranscript}; use mc_crypto_keys::{RistrettoPrivate, RistrettoPublic}; use mc_crypto_ring_signature::{ @@ -51,6 +53,10 @@ pub struct TxSummaryStreamingVerifierCtx { // The account view private key of the transaction signer. // This is used to identify outputs addressed to ourselves regardless of subaddress view_private_key: RistrettoPrivate, + + // The account change address for matching outputs + change_address: PublicSubaddress, + // The block version that this transaction is targetting block_version: BlockVersion, // The merlin transcript which we maintain in order to produce the digest @@ -87,6 +93,7 @@ impl TxSummaryStreamingVerifierCtx { expected_num_outputs: usize, expected_num_inputs: usize, view_private_key: RistrettoPrivate, + change_address: PublicSubaddress, ) -> Self { let mut transcript = MerlinTranscript::new(EXTENDED_MESSAGE_AND_TX_SUMMARY_DOMAIN_TAG.as_bytes()); @@ -105,18 +112,19 @@ impl TxSummaryStreamingVerifierCtx { expected_num_inputs, output_count: 0, input_count: 0, + change_address, } } /// Stream the next TxOutSummary and matching unblinding data to the /// streaming verifier, which will verify and then digest it. - pub fn digest_output( + pub fn digest_output( &mut self, tx_out_summary: &TxOutSummary, unmasked_amount: &UnmaskedAmount, address: Option<(ShortAddressHash, impl RingCtAddress)>, tx_private_key: Option<&RistrettoPrivate>, - report: &mut TxSummaryUnblindingReport, + mut report: impl TransactionReport, ) -> Result<(), Error> { if self.output_count >= self.expected_num_outputs { return Err(Error::UnexpectedOutput); @@ -124,10 +132,31 @@ impl TxSummaryStreamingVerifierCtx { // Now try to verify the recipient. This is either ourselves, or someone else // with the listed address, or this is associated to an SCI. + + // If we view-key matched the output, then it belongs to one of our subaddresses if let Some(amount) = self.view_key_match(tx_out_summary)? { - // If we view-key matched the output, then it belongs to one of our subaddresses - report.balance_add(TransactionEntity::Ourself, amount.token_id, amount.value)?; + // If we have address information + if let Some((address_hash, address)) = address.as_ref() { + // Check whether this is to our change address + if address.view_public_key() == self.change_address.view_public_key() + && address.spend_public_key() == self.change_address.spend_public_key() + { + // If this is to our change address, subtract this from the total inputs + report.change_sub(amount)?; + } else { + // Otherwise, add this as an output to ourself + report.output_add(TransactionEntity::OurAddress(*address_hash), amount)?; + } + } else { + // If we _don't_ have address information but it's to our own address... + return Err(Error::MissingOutputAddress); + } + + // If we didn't match the output, and we have address information, this + // belongs to someone else } else if let Some((address_hash, address)) = address.as_ref() { + // Otherwise, this belongs to another address + let amount = Amount::new(unmasked_amount.value, unmasked_amount.token_id.into()); // In this case, we are given the address of who is supposed to have received // this. @@ -136,14 +165,14 @@ impl TxSummaryStreamingVerifierCtx { let expected = Self::expected_tx_out_summary(self.block_version, amount, address, tx_private_key)?; if &expected == tx_out_summary { - report.balance_add( - TransactionEntity::Address(*address_hash), - amount.token_id, - amount.value, - )?; + // Add as an output to the report + report.output_add(TransactionEntity::OtherAddress(*address_hash), amount)?; } else { return Err(Error::AddressVerificationFailed); } + + // If we didn't match the output, and we don't have address information, + // this is an SCI } else { if !tx_out_summary.associated_to_input_rules { return Err(Error::MissingDataRequiredToVerifyTxOutRecipient); @@ -165,7 +194,9 @@ impl TxSummaryStreamingVerifierCtx { { return Err(Error::AmountVerificationFailed); } - report.balance_add(TransactionEntity::Swap, token_id.into(), value)?; + + // Add outputs to swap counterparty to the report + report.output_add(TransactionEntity::Swap, unmasked_amount.into())?; } // We've now verified the tx_out_summary and added it to the report. @@ -186,11 +217,11 @@ impl TxSummaryStreamingVerifierCtx { /// Stream the next TxInSummary and matching unblinding data to the /// streaming verifier, which will verify and then digest it. - pub fn digest_input( + pub fn digest_input( &mut self, tx_in_summary: &TxInSummary, tx_in_summary_unblinding_data: &UnmaskedAmount, - report: &mut TxSummaryUnblindingReport, + mut report: impl TransactionReport, ) -> Result<(), Error> { if self.output_count != self.expected_num_outputs { return Err(Error::StillExpectingMoreOutputs); @@ -211,14 +242,16 @@ impl TxSummaryStreamingVerifierCtx { } // Now understand whose input this is. There are two cases - let entity = if tx_in_summary.input_rules_digest.is_empty() { - TransactionEntity::Ourself + if tx_in_summary.input_rules_digest.is_empty() { + // If we have no input rules digest, then this is a normal input + // add this to the report total + report.input_add(tx_in_summary_unblinding_data.into())?; } else { - TransactionEntity::Swap + // If we have input rules this is an SCI input and does not impact + // our balance, but we _can_ track this if required + report.sci_add(tx_in_summary_unblinding_data.into())?; }; - report.balance_subtract(entity, token_id.into(), value)?; - // We've now verified the tx_in_summary and added it to the report. // Now we need to add it to the digest // (See mc-crypto-digestible sources for details around b"") @@ -239,16 +272,15 @@ impl TxSummaryStreamingVerifierCtx { /// * extended-message-and-tx-summary digest /// * TxSummaryUnblindingReport, which details all balance changes for all /// parties to this Tx. - pub fn finalize( + pub fn finalize( mut self, fee: Amount, tombstone_block: u64, digest: &mut [u8; 32], - report: &mut TxSummaryUnblindingReport, - ) { - report.network_fee = fee; - report.tombstone_block = tombstone_block; - report.sort(); + mut report: impl TransactionReport, + ) -> Result<(), Error> { + report.network_fee_set(fee)?; + report.tombstone_block_set(tombstone_block)?; fee.value.append_to_transcript(b"fee", &mut self.transcript); (*fee.token_id).append_to_transcript(b"fee_token_id", &mut self.transcript); @@ -260,6 +292,8 @@ impl TxSummaryStreamingVerifierCtx { // Extract the digest self.transcript.extract_digest(digest); + + Ok(()) } // Internal: Check if TxOutSummary matches to our view private key @@ -321,17 +355,329 @@ impl TxSummaryStreamingVerifierCtx { mod tests { use super::*; + use alloc::{vec, vec::Vec}; + + use rand::rngs::OsRng; + + use crate::{report::TotalKind, TxSummaryUnblindingReport}; + use mc_account_keys::AccountKey; + use mc_transaction_core::{tx::TxOut, BlockVersion}; + use mc_transaction_types::TokenId; + use mc_util_from_random::FromRandom; + // Test the size of the streaming verifier on the stack. This is using heapless. #[test] fn test_streaming_verifier_size() { let s = core::mem::size_of::(); assert!( - s < 512, + s < 1024, "TxSummaryStreamingVerifierCtx exceeds size thresold {}/{}", s, - 512 + 1024 ); } - // Note: Most tests are in transaction/extra/tests to avoid build issues. + #[derive(Clone, Debug, PartialEq)] + struct TxOutReportTest { + /// Inputs spent in the transaction + inputs: Vec<(InputType, Amount)>, + /// Outputs produced by the transaction + outputs: Vec<(OutputTarget, Amount)>, + /// Totals / balances by token + totals: Vec<(TokenId, TotalKind, i128)>, + /// Changes produced by the transaction + changes: Vec<(TransactionEntity, TokenId, u128)>, + } + + #[derive(Clone, Debug, PartialEq)] + enum InputType { + /// An input we own, reducing our balance + Owned, + /// A SCI / SWAP input from another account + Sci, + } + + #[derive(Clone, Debug, PartialEq)] + enum OutputTarget { + /// An output to ourself (_not_ a change address) + Ourself, + /// An output to our change address + Change, + /// An output to a third party + Other, + /// A swap output (not used in existing reports) + Swap, + } + + #[test] + fn test_report_outputs() { + let mut rng = OsRng {}; + + // Setup accounts for test report + let sender = AccountKey::random(&mut rng); + let receiver = AccountKey::random(&mut rng); + let swap = AccountKey::random(&mut rng); + + let sender_subaddress = sender.default_subaddress(); + let change_subaddress = sender.change_subaddress(); + let target_subaddress = receiver.default_subaddress(); + let swap_subaddress = swap.default_subaddress(); + + // Set common token id / amounts for later use + let token_id = TokenId::from(9); + let amount = Amount::new(103_000, token_id); + let fee = 4000; + + // Setup tests + let tests = &[ + // Output to ourself, should show output to our address and total of output + fee + TxOutReportTest { + inputs: vec![(InputType::Owned, Amount::new(amount.value + fee, token_id))], + outputs: vec![(OutputTarget::Ourself, amount)], + changes: vec![( + TransactionEntity::OurAddress(ShortAddressHash::from(&sender_subaddress)), + token_id, + amount.value as u128, + )], + totals: vec![(token_id, TotalKind::Ours, (amount.value + fee) as i128)], + }, + // Output to our change address, should show no outputs with balance change = fee + TxOutReportTest { + inputs: vec![ + ( + InputType::Owned, + Amount::new(amount.value / 2 + fee, token_id), + ), + (InputType::Owned, Amount::new(amount.value / 2, token_id)), + ], + outputs: vec![(OutputTarget::Change, amount)], + changes: vec![ + //(TransactionEntity::Total, token_id, 0), + ], + totals: vec![(token_id, TotalKind::Ours, fee as i128)], + }, + // Output to someone else, should show their address and total of output + fee + TxOutReportTest { + inputs: vec![(InputType::Owned, Amount::new(amount.value + fee, token_id))], + outputs: vec![(OutputTarget::Other, amount)], + changes: vec![( + TransactionEntity::OtherAddress(ShortAddressHash::from(&target_subaddress)), + token_id, + amount.value as u128, + )], + totals: vec![(token_id, TotalKind::Ours, (amount.value + fee) as i128)], + }, + // Basic SCI. consuming entire swap, inputs should not count towards totals + TxOutReportTest { + inputs: vec![ + // Our input, sent to SCI + (InputType::Owned, Amount::new(10_000 + fee, token_id)), + // SCI input, sent to us + (InputType::Sci, Amount::new(200, TokenId::from(2))), + ], + outputs: vec![ + // We send the converted token to ourself + (OutputTarget::Ourself, Amount::new(200, TokenId::from(2))), + // While fulfilling the requirements of the SCI + (OutputTarget::Swap, Amount::new(10_000, token_id)), + ], + changes: vec![ + ( + TransactionEntity::OurAddress(ShortAddressHash::from(&sender_subaddress)), + TokenId::from(2), + 200_u128, + ), + (TransactionEntity::Swap, token_id, 10_000_u128), + ], + totals: vec![ + // The total is the change to _our_ balance spent during the transaction + (token_id, TotalKind::Ours, (10_000 + fee) as i128), + // And the SCI input + (TokenId::from(2), TotalKind::Sci, 200_i128), + ], + }, + // Partial SCI + TxOutReportTest { + inputs: vec![ + // Our input, owned by us + (InputType::Owned, Amount::new(7_500 + fee, token_id)), + // SCI input, owned by counterparty + (InputType::Sci, Amount::new(200, TokenId::from(2))), + ], + outputs: vec![ + // We send part of the converted token to ourself + (OutputTarget::Ourself, Amount::new(150, TokenId::from(2))), + // Returning the remaining portion to the swap counterparty + (OutputTarget::Swap, Amount::new(50, TokenId::from(2))), + // While fulfilling the requirements of the SCI + (OutputTarget::Swap, Amount::new(7_500, token_id)), + ], + changes: vec![ + ( + TransactionEntity::OurAddress(ShortAddressHash::from(&sender_subaddress)), + TokenId::from(2), + 150, + ), + (TransactionEntity::Swap, TokenId::from(2), 50), + (TransactionEntity::Swap, token_id, 7_500), + ], + totals: vec![ + // The total is the change to _our_ balance spent during the transaction + (token_id, TotalKind::Ours, (7_500 + fee) as i128), + // And the SCI input - partial value returned + (TokenId::from(2), TotalKind::Sci, 150_i128), + ], + }, + ]; + + // Run tests + for t in tests { + println!("Running test: {t:?}"); + + // Setup verifier + let mut report = TxSummaryUnblindingReport::<16>::default(); + let mut verifier = TxSummaryStreamingVerifierCtx::new( + &[0u8; 32], + BlockVersion::THREE, + t.outputs.len(), + t.inputs.len(), + *sender.view_private_key(), + PublicSubaddress { + view_public: (*change_subaddress.view_public_key()).into(), + spend_public: (*change_subaddress.spend_public_key()).into(), + }, + ); + + // Build and process TxOuts + for (target, amount) in &t.outputs { + println!("Add output {target:?}: {amount:?}"); + + // Select target address + let receive_subaddress = match target { + OutputTarget::Ourself => &sender_subaddress, + OutputTarget::Change => &change_subaddress, + OutputTarget::Other => &target_subaddress, + OutputTarget::Swap => &swap_subaddress, + }; + + // Setup keys for TxOut + let tx_private_key = RistrettoPrivate::from_random(&mut rng); + let txout_shared_secret = + create_shared_secret(receive_subaddress.view_public_key(), &tx_private_key); + + // Construct TxOut object + let tx_out = TxOut::new( + BlockVersion::THREE, + *amount, + receive_subaddress, + &tx_private_key, + Default::default(), + ) + .unwrap(); + + // Build TxOut unblinding + let masked_amount = tx_out.get_masked_amount().unwrap(); + let (amount, blinding) = masked_amount.get_value(&txout_shared_secret).unwrap(); + let unmasked_amount = UnmaskedAmount { + value: amount.value, + token_id: *amount.token_id, + blinding: blinding.into(), + }; + + // Build TxOut summary + let target_key = create_tx_out_target_key(&tx_private_key, receive_subaddress); + let tx_out_summary = TxOutSummary { + masked_amount: Some(masked_amount.clone()), + target_key: target_key.into(), + public_key: tx_out.public_key, + associated_to_input_rules: target == &OutputTarget::Swap, + }; + + // Set address for normal outputs, not provided for SCIs + let address = match target != &OutputTarget::Swap { + true => Some(( + ShortAddressHash::from(receive_subaddress), + receive_subaddress, + )), + false => None, + }; + + // Digest TxOout + Summary with verifier + verifier + .digest_output( + &tx_out_summary, + &unmasked_amount, + address, + Some(&tx_private_key), + &mut report, + ) + .unwrap(); + } + + // Build and process TxIns? + for (kind, amount) in &t.inputs { + println!("Add input: {amount:?}"); + + // Setup keys for TxOut (kx against sender key as this is an input) + let tx_private_key = RistrettoPrivate::from_random(&mut rng); + let txout_shared_secret = + create_shared_secret(sender_subaddress.view_public_key(), &tx_private_key); + + // Construct TxOut object + let tx_out = TxOut::new( + BlockVersion::THREE, + *amount, + &sender_subaddress, + &tx_private_key, + Default::default(), + ) + .unwrap(); + + let masked_amount = tx_out.get_masked_amount().unwrap(); + + // Build TxIn summary + let input_rules_digest = match kind { + InputType::Owned => Vec::new(), + InputType::Sci => vec![0u8; 32], + }; + let tx_in_summary = TxInSummary { + pseudo_output_commitment: *masked_amount.commitment(), + input_rules_digest, + }; + + // Build TxIn unblinding + let (amount, blinding) = masked_amount.get_value(&txout_shared_secret).unwrap(); + let unmasked_amount = UnmaskedAmount { + value: amount.value, + token_id: *amount.token_id, + blinding: blinding.into(), + }; + + // Digest transaction input + verifier + .digest_input(&tx_in_summary, &unmasked_amount, &mut report) + .unwrap(); + } + + // Finalize verifier + let mut digest = [0u8; 32]; + verifier + .finalize(Amount::new(fee, token_id), 1234, &mut digest, &mut report) + .unwrap(); + + report.finalize().unwrap(); + + // Check report totals + let totals: Vec<_> = report.totals.iter().map(|(t, k, v)| (*t, *k, *v)).collect(); + assert_eq!(&totals, &t.totals, "Total mismatch"); + + // Check report outputs + let changes: Vec<_> = report + .outputs + .iter() + .map(|(e, t, v)| (e.clone(), *t, *v)) + .collect(); + assert_eq!(&changes, &t.changes, "Output mismatch"); + } + } }