From 752a29d204bf41725f9f05463ce711c2bd242263 Mon Sep 17 00:00:00 2001 From: Chris Beck Date: Thu, 9 Mar 2023 10:03:51 -0700 Subject: [PATCH] add support for mixed transactions, including those with SCIs in them --- Cargo.lock | 47 ++-- Cargo.toml | 1 + mobilecoind/api/proto/mobilecoind_api.proto | 64 ++++- mobilecoind/src/conversions.rs | 25 +- mobilecoind/src/payments.rs | 234 +++++++++++++++++- mobilecoind/src/service.rs | 104 ++++++++ transaction/builder/Cargo.toml | 1 + .../builder/src/transaction_builder.rs | 24 +- transaction/extra/Cargo.toml | 1 + .../extra/src/signed_contingent_input.rs | 115 ++++++++- util/u64-ratio/Cargo.toml | 10 + util/u64-ratio/src/lib.rs | 153 ++++++++++++ 12 files changed, 742 insertions(+), 37 deletions(-) create mode 100644 util/u64-ratio/Cargo.toml create mode 100644 util/u64-ratio/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 0a3e0927be..64a9f5d701 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2391,6 +2391,7 @@ dependencies = [ "rand_hc 0.3.1", "serde", "subtle", + "tempdir", "zeroize", ] @@ -2756,6 +2757,7 @@ dependencies = [ "secrecy", "serde", "sha2 0.10.6", + "tempdir", ] [[package]] @@ -3002,7 +3004,7 @@ dependencies = [ "serde", "serde_json", "serial_test", - "tempfile", + "tempdir", ] [[package]] @@ -3096,7 +3098,7 @@ dependencies = [ "serde", "serde_json", "serial_test", - "tempfile", + "tempdir", ] [[package]] @@ -3330,7 +3332,7 @@ dependencies = [ "signature", "static_assertions", "subtle", - "tempfile", + "tempdir", "x25519-dalek", "zeroize", ] @@ -3419,6 +3421,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "subtle", + "tempdir", "zeroize", ] @@ -3445,6 +3448,7 @@ dependencies = [ "rand_hc 0.3.1", "serde", "subtle", + "tempdir", "zeroize", ] @@ -3621,6 +3625,7 @@ dependencies = [ "rand 0.8.5", "retry", "serde_json", + "tempdir", ] [[package]] @@ -3795,6 +3800,7 @@ dependencies = [ "retry", "serde", "serde_json", + "tempdir", "url", ] @@ -3819,7 +3825,7 @@ dependencies = [ "mc-watcher", "rand_core 0.6.4", "rand_hc 0.3.1", - "tempfile", + "tempdir", "url", ] @@ -4003,7 +4009,7 @@ dependencies = [ "retry", "serde", "serde_json", - "tempfile", + "tempdir", "url", ] @@ -4049,7 +4055,7 @@ dependencies = [ "mc-util-uri", "mc-watcher", "retry", - "tempfile", + "tempdir", ] [[package]] @@ -4131,6 +4137,7 @@ dependencies = [ "retry", "rocket", "serde", + "tempdir", "url", ] @@ -4266,6 +4273,7 @@ dependencies = [ "serde", "serde_json", "signature", + "tempdir", "x509-signature", "zeroize", ] @@ -4431,6 +4439,7 @@ dependencies = [ "rand_core 0.6.4", "retry", "serde", + "tempdir", ] [[package]] @@ -4593,6 +4602,7 @@ dependencies = [ "mc-util-serial", "pkg-config", "serde", + "tempdir", ] [[package]] @@ -4746,6 +4756,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "serde_json", + "tempdir", ] [[package]] @@ -4774,7 +4785,7 @@ dependencies = [ "mockall", "prost", "rand 0.8.5", - "tempfile", + "tempdir", ] [[package]] @@ -4854,7 +4865,7 @@ dependencies = [ "reqwest", "retry", "serde", - "tempfile", + "tempdir", "url", ] @@ -4924,7 +4935,7 @@ dependencies = [ "reqwest", "retry", "serde_json", - "tempfile", + "tempdir", "tiny-bip39", ] @@ -5321,6 +5332,7 @@ dependencies = [ "mc-util-from-random", "mc-util-serial", "mc-util-test-helper", + "mc-util-u64-ratio", "prost", "rand 0.8.5", "rand_core 0.6.4", @@ -5375,6 +5387,7 @@ dependencies = [ "serde", "sha2 0.10.6", "subtle", + "tempdir", "zeroize", ] @@ -5418,6 +5431,7 @@ dependencies = [ "mc-util-repr-bytes", "mc-util-serial", "mc-util-test-helper", + "mc-util-u64-ratio", "mc-util-vec-map", "mc-util-zip-exact", "prost", @@ -5809,6 +5823,10 @@ dependencies = [ "syn", ] +[[package]] +name = "mc-util-u64-ratio" +version = "4.0.2" + [[package]] name = "mc-util-uri" version = "4.0.2" @@ -5896,7 +5914,7 @@ dependencies = [ "rayon", "serde", "serial_test", - "tempfile", + "tempdir", "toml", "url", ] @@ -8137,15 +8155,16 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.4.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af18f7ae1acd354b992402e9ec5864359d693cd8a79dcbef59f76891701c1e95" +checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" dependencies = [ "cfg-if 1.0.0", "fastrand", + "libc", "redox_syscall", - "rustix", - "windows-sys 0.42.0", + "remove_dir_all", + "winapi", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9b1b5a093a..48f2ee6cc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -161,6 +161,7 @@ members = [ "util/telemetry", "util/test-helper", "util/test-vector", + "util/u64-ratio", "util/uri", "util/vec-map", "util/zip-exact", diff --git a/mobilecoind/api/proto/mobilecoind_api.proto b/mobilecoind/api/proto/mobilecoind_api.proto index c7fa175799..b306a6e812 100644 --- a/mobilecoind/api/proto/mobilecoind_api.proto +++ b/mobilecoind/api/proto/mobilecoind_api.proto @@ -108,11 +108,20 @@ enum TxStatus { } // Structure used in specifying the list of outputs when generating a transaction. +// Here the token id is implied from context, and matches the fee token id. message Outlay { uint64 value = 1; external.PublicAddress receiver = 2; } +// Structure used in specifying the list of outputs in a transaction. +// Here the token id is explicit. +message OutlayV2 { + uint64 value = 1; + external.PublicAddress receiver = 2; + uint64 token_id = 3; +} + // Structure used to refer to a TxOut in the ledger that is presumed to be spendable. // The structure is annotated with extra information needed to spend the TxOut in a payment, calculated using the private keys that control the TxOut. message UnspentTxOut { @@ -142,6 +151,16 @@ message UnspentTxOut { bytes monitor_id = 10; } +// Structure used to refer to an SCI that we want to add to a transaction. +// The structure has additional information -- if it's a partial fill SCI, we need to know the partial fill amount. +message SciForTx { + /// The signed input we want to add + external.SignedContingentInput sci = 1; + + /// If it's a partial fill SCI, the value we wish to fill it for + uint64 partial_fill_value = 2; +} + // Structure used to refer to a prepared transaction message TxProposal { // List of inputs being spent. @@ -149,7 +168,7 @@ message TxProposal { // List of outputs being created. // This excludes the fee output. - repeated Outlay outlay_list = 2; + repeated OutlayV2 outlay_list = 2; // The actual transaction object. // Together with the private view/spend keys, this structure contains all information in existence about the transaction. @@ -493,12 +512,55 @@ message GenerateTxRequest { // Token id to use for the transaction. uint64 token_id = 7; + + // List of SCIs to be added to the transaction + repeated SciForTx scis = 8; } message GenerateTxResponse { TxProposal tx_proposal = 1; } +// Generate a transaction proposal object with mixed token types. +// Notes: +// - Sum of inputs needs to be greater than sum of outlays and fee. +// - The set of inputs to use would be chosen automatically by mobilecoind. +// - The fee field could be set to zero, in which case mobilecoind would choose a fee. +// Right now that fee is hardcoded. +message GenerateMixedTxRequest { + // Monitor id sending the funds. + bytes sender_monitor_id = 1; + + // Subaddress to return change to. + uint64 change_subaddress = 2; + + // List of UnspentTxOuts to be spent by the transaction. + // All UnspentTxOuts must belong to the same sender_monitor_id. + // mobilecoind would choose a subset of these inputs to construct the transaction. + // Total input amount must be >= sum of outlays + fees. + repeated UnspentTxOut input_list = 3; + + // List of SCIs to be added to the transaction + repeated SciForTx scis = 4; + + // Outputs to be generated by the transaction. This excludes change and fee. + repeated OutlayV2 outlay_list = 5; + + // Fee in picoMOB (setting to 0 causes mobilecoind to choose a value). + // The value used can be checked (but not changed) in tx_proposal.tx.prefix.fee + uint64 fee = 6; + + // Token id to use for the transaction. + uint64 fee_token_id = 7; + + // Tombstone block (setting to 0 causes mobilecoind to choose a value). + // The value used can be checked (but not changed) in tx_proposal.tx.prefix.tombstone_block + uint64 tombstone = 8; +} + +message GenerateMixedTxResponse { + TxProposal tx_proposal = 1; +} // Generate a transaction that merges a few UnspentTxOuts into one, in order to reduce wallet fragmentation. message GenerateOptimizationTxRequest { // Monitor Id to operate on. diff --git a/mobilecoind/src/conversions.rs b/mobilecoind/src/conversions.rs index fcfac9df8f..9583542891 100644 --- a/mobilecoind/src/conversions.rs +++ b/mobilecoind/src/conversions.rs @@ -4,7 +4,7 @@ //! types. use crate::{ - payments::{Outlay, TxProposal}, + payments::{Outlay, SciForTx, TxProposal}, utxo_store::UnspentTxOut, }; use mc_account_keys::PublicAddress; @@ -171,6 +171,29 @@ impl TryFrom<&api::TxProposal> for TxProposal { } } +impl From<&SciForTx> for api::SciForTx { + fn from(src: &SciForTx) -> Self { + let mut dst = Self::new(); + dst.set_sci((&src.sci).into()); + dst.set_partial_fill_value(src.partial_fill_value); + dst + } +} + +impl TryFrom<&api::SciForTx> for SciForTx { + type Error = ConversionError; + + fn try_from(src: &api::SciForTx) -> Result { + let sci = src.get_sci().try_into()?; + let partial_fill_value = src.partial_fill_value; + + Ok(Self { + sci, + partial_fill_value, + }) + } +} + #[cfg(test)] mod test { use super::*; diff --git a/mobilecoind/src/payments.rs b/mobilecoind/src/payments.rs index 773ecfd802..d8352e075a 100644 --- a/mobilecoind/src/payments.rs +++ b/mobilecoind/src/payments.rs @@ -90,6 +90,19 @@ impl TxProposal { } } +/// A SignedContingentInput which the client wants to add to a new Tx, with +/// data about what degree to fill it, if it is a partial fill SCI. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SciForTx { + /// The signed contingent input to add to the transaction + pub sci: SignedContingentInput, + /// The amount to take from the maximum allowed volume of this SCI. + /// The remainder is returned to the originator as change. + /// For partial fill SCIs, this + /// must be nonzero. For non-partial fill SCIs, this must be zero. + pub partial_fill_value: u64, +} + pub struct TransactionsManager< T: BlockchainConnection + UserTxConnection + 'static, FPR: FogPubkeyResolver, @@ -220,7 +233,7 @@ impl = outlays + .into_iter() + .map(|outlay_v1| OutlayV2 { + receiver: outlay_v1.receiver, + value: outlay_v1.value, + token_id, + }) + .collect(); + // Build and return the TxProposal object let mut rng = rand::thread_rng(); let tx_proposal = Self::build_tx_proposal( &selected_utxos_with_proofs, rings, + &[], block_version, token_id, fee, @@ -347,6 +371,162 @@ impl>, + ) -> Result { + let logger = self.logger.new(o!("sender_monitor_id" => sender_monitor_id.to_string(), "outlays" => format!("{outlays:?}"))); + log::trace!(logger, "Building pending transaction..."); + + // Must have at least one output + if outlays.is_empty() { + return Err(Error::TxBuild("Must have at least one destination".into())); + } + + // Get sender monitor data. + let sender_monitor_data = self.mobilecoind_db.get_monitor_data(sender_monitor_id)?; + + // Figure out the block version, fee and minimum fee map. + let (fee, fee_map, block_version) = + self.get_fee_info_and_block_version(last_block_infos, fee_token_id, opt_fee)?; + + // Compute what value of inputs we need to supply to satisfy the outputs and + // balance the transaction. + let mut balance_sheet = BTreeMap::::default(); + + for outlay in outlays { + balance_sheet.entry(outlay.token_id).get_or_default() += outlay.value; + } + + for sci_for_tx in scis { + let sci_summary = sci_for_tx.sci.validate()?; + sci_summary.add_to_balance_sheet(sci_for_tx.partial_fill_value, &mut balance_sheet); + } + + balance_sheet.entry(fee_token_id).get_or_default() += fee; + + // Select the UTXOs to be used for this transaction. + + let mut all_selected_utxos = vec![]; + for (token_id, val) in balance_sheet.iter() { + if val > 0 { + let remaining_input_slots = MAX_INPUTS as usize - all_selected_utxos.len(); + if remaining_input_slots == 0 { + return Err(Error::TxBuild( + "Ran out of input slots during input selection".to_string(), + )); + } + let selected_utxos = + Self::select_utxos(token_id, inputs, val, remaining_input_slots)?; + all_selected_utxos.extend(selected_utxos); + } + } + log::trace!( + logger, + "Selected {} utxos ({:?})", + selected_utxos.len(), + selected_utxos, + ); + + // The selected_utxos with corresponding proofs of membership. + let selected_utxos_with_proofs: Vec<(UnspentTxOut, TxOutMembershipProof)> = { + let outputs: Vec = selected_utxos + .iter() + .map(|utxo| utxo.tx_out.clone()) + .collect(); + let proofs = self.get_membership_proofs(&outputs)?; + + selected_utxos.into_iter().zip(proofs.into_iter()).collect() + }; + log::trace!(logger, "Got membership proofs"); + + // A ring of mixins for each UTXO. + let rings = { + let excluded_tx_out_indices: Vec = selected_utxos_with_proofs + .iter() + .map(|(_, proof)| proof.index) + .collect(); + + self.get_rings( + DEFAULT_RING_SIZE, // TODO configurable ring size + selected_utxos_with_proofs.len(), + &excluded_tx_out_indices, + )? + }; + log::trace!(logger, "Got {} rings", rings.len()); + + // Get membership proofs for scis. + // It's assumed that SCI's are usually passed around without membership proofs, + // and these are only added when we actually want to build a Tx. + let scis: Vec = { + scis.into_iter() + .map(|mut sci_for_tx| { + let proofs = self.get_membership_proofs(&sci_for_tx.sci.tx_in.ring)?; + sci_for_tx.sci.tx_in.proofs = proofs; + + sci_for_tx + }) + .collect()?; + }; + + // Come up with tombstone block. + let tombstone_block = if opt_tombstone > 0 { + opt_tombstone + } else { + let num_blocks_in_ledger = self.ledger_db.num_blocks()?; + num_blocks_in_ledger + DEFAULT_NEW_TX_BLOCK_ATTEMPTS + }; + log::trace!(logger, "Tombstone block set to {}", tombstone_block); + + // Build and return the TxProposal object + let mut rng = rand::thread_rng(); + let tx_proposal = Self::build_tx_proposal( + &selected_utxos_with_proofs, + rings, + &scis, + block_version, + fee_token_id, + fee, + &sender_monitor_data.account_key, + change_subaddress, + outlays, + tombstone_block, + &self.fog_resolver_factory, + opt_memo_builder, + fee_map, + &mut rng, + &self.logger, + )?; + log::trace!(logger, "Tx constructed, hash={}", tx_proposal.tx.tx_hash()); + + Ok(tx_proposal) + } + /// Create and return an SCI that offers to trade one of our inputs for a /// given amount of some currency. This SCI is in the form accepted by /// the deqs. @@ -930,6 +1110,8 @@ impl>, + scis: &[SciForTx], block_version: BlockVersion, token_id: TokenId, fee: u64, from_account_key: &AccountKey, change_subaddress: u64, - destinations: &[Outlay], + destinations: &[OutlayV2], tombstone_block: BlockIndex, fog_resolver_factory: &Arc Result + Send + Sync>, opt_memo_builder: Option>, @@ -1080,6 +1263,53 @@ impl partial_fill_change_amount.value { + return Err(Error::TxBuild( + "sci partial fill amount is invalid: {} > {}", + sci_for_tx.partial_fill_amount.value, + partial_fill_change_amount.value, + )); + } + let real_change_amount = Amount::new( + partial_fill_change_amount.value - sci_for_tx.partial_fill_amount.value, + partial_fill_change_amount.token_id, + ); + tx_builder.add_presigned_partial_fill_input(sci_for_tx.sci, real_change_amount)?; + } + } + // Add outputs to our destinations. let mut total_value = 0; let mut tx_out_to_outlay_index = HashMap::default(); diff --git a/mobilecoind/src/service.rs b/mobilecoind/src/service.rs index 9673a19345..a2baae1bd2 100644 --- a/mobilecoind/src/service.rs +++ b/mobilecoind/src/service.rs @@ -920,6 +920,110 @@ impl Result { + // Get sender monitor id from request. + let sender_monitor_id = MonitorId::try_from(&request.sender_monitor_id) + .map_err(|err| rpc_internal_error("monitor_id.try_from.bytes", err, &self.logger))?; + + // Get monitor data for this monitor. + let sender_monitor_data = self + .mobilecoind_db + .get_monitor_data(&sender_monitor_id) + .map_err(|err| { + rpc_internal_error("mobilecoind_db.get_monitor_data", err, &self.logger) + })?; + + // Check that change_subaddress is covered by this monitor. + if !sender_monitor_data + .subaddress_indexes() + .contains(&request.change_subaddress) + { + return Err(RpcStatus::with_message( + RpcStatusCode::INVALID_ARGUMENT, + "change_subaddress".into(), + )); + } + + // Get the list of potential inputs passed to. + let input_list: Vec = request + .get_input_list() + .iter() + .enumerate() + .map(|(i, proto_utxo)| { + // Proto -> Rust struct conversion. + let utxo = UnspentTxOut::try_from(proto_utxo).map_err(|err| { + rpc_internal_error("unspent_tx_out.try_from", err, &self.logger) + })?; + + // Verify token id matches. + if utxo.token_id != request.token_id { + return Err(RpcStatus::with_message( + RpcStatusCode::INVALID_ARGUMENT, + format!("input_list[{i}].token_id"), + )); + } + + // Verify this output belongs to the monitor. + let subaddress_id = self + .mobilecoind_db + .get_subaddress_id_by_utxo_id(&UtxoId::from(&utxo)) + .map_err(|err| { + rpc_internal_error( + "mobilecoind_db.get_subaddress_id_by_utxo_id", + err, + &self.logger, + ) + })?; + + if subaddress_id.monitor_id != sender_monitor_id { + return Err(RpcStatus::with_message( + RpcStatusCode::INVALID_ARGUMENT, + format!("input_list.{i}"), + )); + } + + // Success. + Ok(utxo) + }) + .collect::, RpcStatus>>()?; + + // Get the list of outlays. + let outlays: Vec = request + .get_outlay_list() + .iter() + .map(|outlay_proto| { + Outlay::try_from(outlay_proto) + .map_err(|err| rpc_internal_error("outlay.try_from", err, &self.logger)) + }) + .collect::, RpcStatus>>()?; + + // Attempt to construct a transaction. + let tx_proposal = self + .transactions_manager + .build_transaction( + &sender_monitor_id, + TokenId::from(request.token_id), + request.change_subaddress, + &input_list, + &outlays, + &self.get_last_block_infos(), + request.fee, + request.tombstone, + None, // opt_memo_builder + ) + .map_err(|err| { + rpc_internal_error("transactions_manager.build_transaction", err, &self.logger) + })?; + + // Success. + let mut response = api::GenerateTxResponse::new(); + response.set_tx_proposal((&tx_proposal).into()); + Ok(response) + } + fn generate_optimization_tx_impl( &mut self, request: api::GenerateOptimizationTxRequest, diff --git a/transaction/builder/Cargo.toml b/transaction/builder/Cargo.toml index ff4455a792..a51c5c0904 100644 --- a/transaction/builder/Cargo.toml +++ b/transaction/builder/Cargo.toml @@ -34,6 +34,7 @@ mc-transaction-extra = { path = "../../transaction/extra" } mc-transaction-types = { path = "../../transaction/types" } mc-util-from-random = { path = "../../util/from-random" } mc-util-serial = { path = "../../util/serial" } +mc-util-u64-ratio = { path = "../../util/u64-ratio" } curve25519-dalek = { version = "4.0.0-pre.2", default-features = false, features = ["nightly"] } diff --git a/transaction/builder/src/transaction_builder.rs b/transaction/builder/src/transaction_builder.rs index 084b9f298f..a066cdc90b 100644 --- a/transaction/builder/src/transaction_builder.rs +++ b/transaction/builder/src/transaction_builder.rs @@ -1,4 +1,4 @@ -// Copyright (c) 2018-2022 The MobileCoin Foundation +// Copyright (c) 2018-2023 The MobileCoin Foundation //! Utility for building and signing a transaction. //! @@ -33,6 +33,7 @@ use mc_transaction_extra::{ TxOutSummaryUnblindingData, UnsignedTx, }; use mc_util_from_random::FromRandom; +use mc_util_u64_ratio::U64Ratio; use rand_core::{CryptoRng, RngCore}; /// A trait used to compare the transaction outputs @@ -269,9 +270,11 @@ impl TransactionBuilder { return Err(SignedContingentInputError::ChangeLimitExceeded); } - let fill_fraction_num = - (partial_fill_change_amount.value - sci_change_amount.value) as u128; - let fill_fraction_denom = partial_fill_change_amount.value as u128; + let fill_fraction = U64Ratio::new( + partial_fill_change_amount.value - sci_change_amount.value, + partial_fill_change_amount.value, + ) + .ok_or(SignedContingentInputError::ZeroPartialFillChange)?; // Ensure that we can reveal all amounts let partial_fill_outputs_and_amounts: Vec<(&RevealedTxOut, Amount, Scalar)> = rules @@ -285,15 +288,6 @@ impl TransactionBuilder { ) .collect::>()?; - // Fill all partial fill outputs to precisely the smallest degree required - // This helper function function takes a partial fill output value, and returns - // num / denom * partial_fill_output_value, rounded up - fn division_helper(partial_fill_output_value: u64, num: u128, denom: u128) -> u64 { - let num_128 = partial_fill_output_value as u128 * num; - // Divide by fill_fraction_denom, rounding up, and truncate to u64 - ((num_128 + (denom - 1)) / denom) as u64 - } - // Add fractional outputs (corresponding to partial fill outputs) into the list // which is added to tx prefix let fractional_amounts = partial_fill_outputs_and_amounts @@ -301,7 +295,9 @@ impl TransactionBuilder { .map( |(r_tx_out, amount, blinding)| -> Result { let fractional_amount = Amount::new( - division_helper(amount.value, fill_fraction_num, fill_fraction_denom), + fill_fraction + .checked_mul_round_up(amount.value) + .expect("should be unreachable, because fill fraction is <= 1"), amount.token_id, ); let fractional_tx_out = r_tx_out.change_committed_amount(fractional_amount)?; diff --git a/transaction/extra/Cargo.toml b/transaction/extra/Cargo.toml index 5fb0fc4e00..4499bb178f 100644 --- a/transaction/extra/Cargo.toml +++ b/transaction/extra/Cargo.toml @@ -32,6 +32,7 @@ mc-transaction-types = { path = "../../transaction/types" } mc-util-from-random = { path = "../../util/from-random" } mc-util-repr-bytes = { path = "../../util/repr-bytes" } mc-util-serial = { path = "../../util/serial" } +mc-util-u64-ratio = { path = "../../util/u64-ratio" } mc-util-vec-map = { path = "../../util/vec-map" } mc-util-zip-exact = { path = "../../util/zip-exact" } diff --git a/transaction/extra/src/signed_contingent_input.rs b/transaction/extra/src/signed_contingent_input.rs index 3b672a30d1..95ac17df6c 100644 --- a/transaction/extra/src/signed_contingent_input.rs +++ b/transaction/extra/src/signed_contingent_input.rs @@ -1,8 +1,8 @@ -// Copyright (c) 2018-2022 The MobileCoin Foundation +// Copyright (c) 2018-2023 The MobileCoin Foundation //! A signed contingent input as described in MCIP #31 -use alloc::{string::String, vec::Vec}; +use alloc::{collections::BTreeMap, string::String, vec::Vec}; use displaydoc::Display; use mc_crypto_digestible::Digestible; use mc_crypto_ring_signature::{ @@ -13,6 +13,7 @@ use mc_transaction_core::{ tx::TxIn, Amount, AmountError, RevealedTxOutError, TokenId, TxOutConversionError, }; +use mc_util_u64_ratio::U64Ratio; use prost::Message; use serde::{Deserialize, Serialize}; use zeroize::Zeroize; @@ -82,11 +83,16 @@ impl SignedContingentInput { /// Note: This does check any other rules like tombstone block, or /// confirm proofs of membership, which are normally added only when this /// is incorporated into a transaction - pub fn validate(&self) -> Result<(), SignedContingentInputError> { + pub fn validate(&self) -> Result { if self.tx_out_global_indices.len() != self.tx_in.ring.len() { return Err(SignedContingentInputError::WrongNumberOfGlobalIndices); } + let mut result = SignedContingentInputAmounts { + pseudo_output: (&self.pseudo_output_amount).into(), + ..Default::default() + }; + let mut generator_cache = GeneratorCache::default(); let generator = generator_cache.get(TokenId::from(self.pseudo_output_amount.token_id)); @@ -117,6 +123,7 @@ impl SignedContingentInput { .iter() .zip(rules.required_outputs.iter()) { + result.required_outputs.push(Amount::from(amount)); let generator = generator_cache.get(TokenId::from(amount.token_id)); let expected_commitment = CompressedCommitment::from(&Commitment::new( @@ -142,17 +149,111 @@ impl SignedContingentInput { if amount.value == 0 { return Err(SignedContingentInputError::ZeroPartialFillChange); } + + result.partial_fill_change = Some(amount); + // Check that each output can actually be revealed for partial_fill_output in rules.partial_fill_outputs.iter() { - partial_fill_output.reveal_amount()?; + let (amount, _) = partial_fill_output.reveal_amount()?; + if amount.value == 0 { + return Err(SignedContingentInputError::ZeroPartialFillOutput); + } + result.partial_fill_outputs.push(amount); } } else if !rules.partial_fill_outputs.is_empty() || rules.min_partial_fill_value != 0 { return Err(SignedContingentInputError::MissingPartialFillChange); } } + Ok(result) + } +} + +/// This summary object is constructed during validation of an SCI, by recording +/// all the Amount objects that we successfully unmask. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SignedContingentInputAmounts { + /// The amount of the pseudo-output, i.e. the true input signed over in this + /// SCI + pub pseudo_output: Amount, + /// The amounts of the required outputs + pub required_outputs: Vec, + /// The amounts of the partial fill outputs. + pub partial_fill_outputs: Vec, + /// The amount of hte partial fill change if present. + pub partial_fill_change: Option, +} + +impl SignedContingentInputAmounts { + /// Computes the hypothetical change in balances that will occur if we fill + /// this SCI to a certain degree + /// + /// Add the outputs and inputs to a BTreemap which functions as a balance + /// sheet. Outputs from the SCI are positive, and the value of the input + /// is negative. + /// + /// Arguments: + /// partial_fill_value: The amount of the partial_fill_change we want to + /// keep. This should be zero if this is not a partial fill SCI. + /// balance_sheet: A list of tokens and +/- balance changes + /// + /// Returns: + /// An error if the partial fill value is too large for this SCI, or + /// something else is ill-formed. + pub fn add_to_balance_sheet( + &self, + partial_fill_value: u64, + balance_sheet: &mut BTreeMap, + ) -> Result<(), SignedContingentInputError> { + // The pseudo-output amount (the value of the input which was signed over) is + // subtracted from balance sheet, everything else is added + *balance_sheet + .entry(self.pseudo_output.token_id) + .or_default() -= self.pseudo_output.value as i128; + + // Required amount are added in full + for req_output in self.required_outputs.iter() { + *balance_sheet.entry(req_output.token_id).or_default() += req_output.value as i128; + } + + if let Some(partial_fill_change) = self.partial_fill_change.as_ref() { + // Compute fill fraction + let fill_fraction = U64Ratio::new(partial_fill_value, partial_fill_change.value) + .ok_or(SignedContingentInputError::ZeroPartialFillChange)?; + + // Compute value of fractional change output and add to balance sheet + let fractional_change_value = partial_fill_change + .value + .checked_sub(partial_fill_value) + .ok_or(SignedContingentInputError::PartialFillValueTooLarge)?; + *balance_sheet + .entry(partial_fill_change.token_id) + .or_default() += fractional_change_value as i128; + + // Compute value of each fractional output and add to balance sheet + for partial_fill_output in self.partial_fill_outputs.iter() { + let fractional_output_value = fill_fraction + .checked_mul_round_up(partial_fill_output.value) + .ok_or(SignedContingentInputError::PartialFillValueTooLarge)?; + *balance_sheet + .entry(partial_fill_output.token_id) + .or_default() += fractional_output_value as i128; + } + } else if partial_fill_value != 0 { + return Err(SignedContingentInputError::PartialFillValueTooLarge); + } Ok(()) } + + /// Compute the balance sheet just for this SCI. + pub fn compute_balance_sheet( + &self, + partial_fill_value: u64, + ) -> Result, SignedContingentInputError> { + let mut result = Default::default(); + self.add_to_balance_sheet(partial_fill_value, &mut result)?; + Ok(result) + } } impl From for PresignedInputRing { @@ -215,8 +316,10 @@ pub enum SignedContingentInputError { MissingPartialFillChange, /// Index out of bounds IndexOutOfBounds, - /// Fractional change amount was zero + /// Partial fill change amount was zero ZeroPartialFillChange, + /// Partial fill output amount was zero + ZeroPartialFillOutput, /// Min partial fill value exceeds partial fill change MinPartialFillValueExceedsPartialChange, /// Token id mismatch @@ -231,6 +334,8 @@ pub enum SignedContingentInputError { BlockVersionMismatch(u32, u32), /// Amount: {0} Amount(AmountError), + /// Partial fill Value is too large compared to partial fill change + PartialFillValueTooLarge, } impl From for SignedContingentInputError { diff --git a/util/u64-ratio/Cargo.toml b/util/u64-ratio/Cargo.toml new file mode 100644 index 0000000000..24aea17190 --- /dev/null +++ b/util/u64-ratio/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "mc-util-u64-ratio" +version = "4.0.2" +authors = ["MobileCoin"] +description = "A helper for computing with ratios of u64 numbers" +edition = "2021" +license = "Apache-2.0" +readme = "README.md" + +[dependencies] diff --git a/util/u64-ratio/src/lib.rs b/util/u64-ratio/src/lib.rs new file mode 100644 index 0000000000..3d048c75c8 --- /dev/null +++ b/util/u64-ratio/src/lib.rs @@ -0,0 +1,153 @@ +// Copyright (c) 2018-2023 The MobileCoin Foundation + +use core::cmp::{Ordering, PartialEq, PartialOrd}; + +/// A simple type which represents a ratio of two u64 numbers. +/// +/// This is fairly limited in scope and meant to support the implementation +/// of partial fill rules. +/// Don't really want to pull a decimal or rational class etc. into the enclave +/// if we can avoid it, this should be much simpler. +#[derive(Copy, Clone, Debug, Eq, Ord)] +pub struct U64Ratio { + // The u64 numerator of the ratio, which has been extended to a u128 + num: u128, + // The u64 denominator of the ratio, which has been extended to a u128 + denom: u128, +} + +impl U64Ratio { + /// Create a new U64Ratio from a numerator and denominator + /// + /// This can fail if the denominator is zero. + pub fn new(num: u64, denom: u64) -> Option { + if denom == 0 { + None + } else { + Some(Self { + num: num as u128, + denom: denom as u128, + }) + } + } + + /// Multiply a u64 number by the ratio, rounding down. + /// + /// This can fail if the result overflows a u64. + /// Note that this cannot fail if the ratio is <= 1. + pub fn checked_mul_round_down(&self, val: u64) -> Option { + ((val as u128 * self.num) / self.denom).try_into().ok() + } + + /// Multiply a u64 number by the ratio, rounding up. + /// Note that this cannot fail if the ratio is <= 1. + pub fn checked_mul_round_up(&self, val: u64) -> Option { + (((val as u128 * self.num) + (self.denom - 1)) / self.denom) + .try_into() + .ok() + } +} + +impl PartialEq for U64Ratio { + #[inline] + fn eq(&self, other: &Self) -> bool { + // Intuitively, to check if two u64 fractions are equal, we want to check + // if a/b = c/d as rational numbers. However, we would like to avoid the + // use of floating point numbers or more complex decimal classes, because + // they introduce more complex types of errors and imprecision. + // + // Instead, we multiply both sides of the equation by b and d to clear + // denominators, and test equality as u128's, which avoids overflow issues. + // + // This matches how fractions are defined in abstract algebra: + // https://en.wikipedia.org/wiki/Field_of_fractions + (self.num * other.denom).eq(&(other.num * self.denom)) + } +} + +impl PartialOrd for U64Ratio { + #[inline] + fn partial_cmp(&self, other: &Self) -> Option { + // Intuitively, to check if two u64 fractions are equal, we want to compare + // a/b and c/d as rational numbers. However, as before, we would like to + // avoid the use of more complex numeric types. + // + // Instead, observe that if we clear denominators, we have + // + // a/b < c/d + // iff + // a*d < c*b + // + // because we know both b and d are positive integers here. + // + // For the same reason, + // + // a/b > c/d + // iff + // a*d > c*b + Some((self.num * other.denom).cmp(&(other.num * self.denom))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ratio_eq() { + assert_eq!(U64Ratio::new(1, 4).unwrap(), U64Ratio::new(2, 8).unwrap()); + assert_eq!( + U64Ratio::new(200, 4).unwrap(), + U64Ratio::new(1000, 20).unwrap() + ); + assert_ne!(U64Ratio::new(1, 5).unwrap(), U64Ratio::new(1, 4).unwrap()); + assert_ne!( + U64Ratio::new(u64::MAX, u64::MAX).unwrap(), + U64Ratio::new(u64::MAX, u64::MAX - 1).unwrap() + ); + } + + #[test] + fn ratio_ord() { + assert!(U64Ratio::new(1, 5).unwrap() < U64Ratio::new(1, 4).unwrap()); + assert!(U64Ratio::new(3, 7).unwrap() > U64Ratio::new(2, 8).unwrap()); + assert!(U64Ratio::new(1, 4).unwrap() >= U64Ratio::new(2, 8).unwrap()); + assert!(U64Ratio::new(6, 10).unwrap() <= U64Ratio::new(13, 20).unwrap()); + assert!( + U64Ratio::new(u64::MAX, u64::MAX).unwrap() + < U64Ratio::new(u64::MAX, u64::MAX - 1).unwrap() + ); + assert!( + U64Ratio::new(u64::MAX - 1, u64::MAX).unwrap() + <= U64Ratio::new(u64::MAX, u64::MAX).unwrap() + ); + } + + #[test] + fn checked_mul() { + let r = U64Ratio::new(4, 8).unwrap(); + + assert_eq!(r.checked_mul_round_down(10), Some(5)); + assert_eq!(r.checked_mul_round_up(10), Some(5)); + assert_eq!(r.checked_mul_round_down(11), Some(5)); + assert_eq!(r.checked_mul_round_up(11), Some(6)); + assert_eq!(r.checked_mul_round_down(12), Some(6)); + assert_eq!(r.checked_mul_round_up(12), Some(6)); + assert_eq!(r.checked_mul_round_down(u64::MAX), Some(u64::MAX / 2)); + assert_eq!(r.checked_mul_round_up(u64::MAX), Some(u64::MAX / 2 + 1)); + + let r = U64Ratio::new(4, 7).unwrap(); + assert_eq!(r.checked_mul_round_down(100), Some(57)); + assert_eq!(r.checked_mul_round_up(100), Some(58)); + assert_eq!(r.checked_mul_round_down(101), Some(57)); + assert_eq!(r.checked_mul_round_up(101), Some(58)); + assert_eq!(r.checked_mul_round_down(102), Some(58)); + assert_eq!(r.checked_mul_round_up(102), Some(59)); + + assert_eq!( + r.checked_mul_round_down(u64::MAX), + Some(10540996613548315208) + ); + assert_eq!(r.checked_mul_round_up(u64::MAX), Some(10540996613548315209)); + } +}