diff --git a/Cargo.lock b/Cargo.lock index 9bfd8076e2..67a2b623ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2631,14 +2631,15 @@ dependencies = [ "curve25519-dalek", "displaydoc", "generic-array 0.14.5", + "maplit", "mc-account-keys", "mc-attest-core", "mc-crypto-keys", "mc-crypto-multisig", "mc-crypto-x509-test-vectors", + "mc-fog-report-validation-test-utils", "mc-test-vectors-b58-encodings", "mc-transaction-core", - "mc-transaction-core-test-utils", "mc-transaction-std", "mc-util-build-grpc", "mc-util-build-script", @@ -5194,6 +5195,7 @@ dependencies = [ name = "mc-transaction-std" version = "1.3.0-pre0" dependencies = [ + "assert_matches", "cfg-if 1.0.0", "curve25519-dalek", "displaydoc", diff --git a/api/Cargo.toml b/api/Cargo.toml index 92d4bbb2a9..1de846f797 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -36,14 +36,15 @@ cargo-emit = "0.2.1" [dev-dependencies] mc-crypto-x509-test-vectors = { path = "../crypto/x509/test-vectors" } mc-test-vectors-b58-encodings = { path = "../test-vectors/b58-encodings" } -mc-transaction-core-test-utils = { path = "../transaction/core/test-utils" } -mc-transaction-std = { path = "../transaction/std" } +mc-fog-report-validation-test-utils = { path = "../fog/report/validation/test-utils" } +mc-transaction-std = { path = "../transaction/std", features = ["test-only"] } mc-util-from-random = { path = "../util/from-random" } mc-util-test-helper = { path = "../util/test-helper" } mc-util-test-vector = { path = "../util/test-vector" } mc-util-test-with-data = { path = "../util/test-with-data" } generic-array = "0.14" +maplit = "1.0" pem = "1.0" prost = { version = "0.10", default-features = false } rand = "0.8" diff --git a/api/proto/external.proto b/api/proto/external.proto index 702d889fd7..e0ea159973 100644 --- a/api/proto/external.proto +++ b/api/proto/external.proto @@ -231,6 +231,7 @@ message TxIn { InputRules input_rules = 3; } +// Rules enforced on a transaction by a signed input within it (MCIP #31) message InputRules { // Outputs required to appear in the TxPrefix for the Tx to be valid repeated TxOut required_outputs = 1; @@ -403,3 +404,34 @@ message ValidatedMintConfigTx { MintConfigTx mint_config_tx = 1; Ed25519SignerSet signer_set = 2; } + +// The amount and blinding factor of a TxOut +message UnmaskedAmount { + // The value of the amount commitment + fixed64 value = 1; + + // The token_id of the amount commitment + fixed64 token_id = 2; + + // The blinding factor of the amount commitment + CurveScalar blinding = 3; +} + +// A pre-signed transaction input with associated rules, as described in MCIP #31 +message SignedContingentInput { + // The tx_in which was signed + TxIn tx_in = 1; + + // The Ring MLSAG signature, conferring spending authority + RingMLSAG mlsag = 2; + + // The amount and blinding of the pseudo-output of the MLSAG + UnmaskedAmount pseudo_output_amount = 3; + + /// The amount and blinding of any TxOut required by the input rules + repeated UnmaskedAmount required_output_amounts = 4; + + /// The tx_out global index of each ring member + /// This helps the recipient of this payload construct proofs of membership for the ring + repeated fixed64 tx_out_global_indices = 5; +} diff --git a/api/src/convert/tx.rs b/api/src/convert/tx.rs index 45459dbf0a..b8eee9c687 100644 --- a/api/src/convert/tx.rs +++ b/api/src/convert/tx.rs @@ -28,21 +28,20 @@ impl TryFrom<&external::Tx> for tx::Tx { #[cfg(test)] mod tests { use super::*; - use mc_account_keys::{AccountKey, PublicAddress}; - use mc_crypto_keys::RistrettoPublic; + use mc_account_keys::AccountKey; + use mc_fog_report_validation_test_utils::MockFogResolver; use mc_transaction_core::{ - onetime_keys::recover_onetime_private_key, - tokens::Mob, - tx::{Tx, TxOut, TxOutMembershipProof}, - Amount, BlockVersion, Token, + constants::MILLIMOB_TO_PICOMOB, tokens::Mob, tx::Tx, Amount, BlockVersion, Token, TokenId, + }; + use mc_transaction_std::{ + test_utils::get_input_credentials, ChangeDestination, EmptyMemoBuilder, + SignedContingentInputBuilder, TransactionBuilder, }; - use mc_transaction_core_test_utils::MockFogResolver; - use mc_transaction_std::{EmptyMemoBuilder, InputCredentials, TransactionBuilder}; use protobuf::Message; use rand::{rngs::StdRng, SeedableRng}; #[test] - /// Tx --> externalTx --> Tx should be the identity function. + /// Tx --> externalTx --> Tx should be the identity function, for simple tx fn test_convert_tx() { // Generate a Tx to test with. This is copied from // transaction_builder.rs::test_simple_transaction @@ -51,69 +50,144 @@ mod tests { for block_version in BlockVersion::iterator() { let alice = AccountKey::random(&mut rng); let bob = AccountKey::random(&mut rng); - let charlie = AccountKey::random(&mut rng); - let minted_outputs: Vec = { - // Mint an initial collection of outputs, including one belonging to - // `sender_account`. - let recipient_and_amounts: Vec<(PublicAddress, u64)> = vec![ - (alice.default_subaddress(), 65536), - // Some outputs belonging to this account will be used as mix-ins. - (charlie.default_subaddress(), 65536), - (charlie.default_subaddress(), 65536), - ]; - - mc_transaction_core_test_utils::get_outputs( - block_version, - &recipient_and_amounts, - &mut rng, - ) - }; + let fpr = MockFogResolver::default(); let mut transaction_builder = TransactionBuilder::new( block_version, Amount::new(Mob::MINIMUM_FEE, Mob::ID), - MockFogResolver::default(), + fpr.clone(), EmptyMemoBuilder::default(), ) .unwrap(); - let ring: Vec = minted_outputs.clone(); - let public_key = RistrettoPublic::try_from(&minted_outputs[0].public_key).unwrap(); - let onetime_private_key = recover_onetime_private_key( - &public_key, - alice.view_private_key(), - &alice.default_subaddress_spend_private(), + transaction_builder.add_input(get_input_credentials( + block_version, + Amount::new(65536 + Mob::MINIMUM_FEE, Mob::ID), + &alice, + &fpr, + &mut rng, + )); + transaction_builder + .add_output( + Amount::new(65536, Mob::ID), + &bob.default_subaddress(), + &mut rng, + ) + .unwrap(); + + let tx = transaction_builder.build(&mut rng).unwrap(); + + // decode(encode(tx)) should be the identity function. + { + let bytes = mc_util_serial::encode(&tx); + let recovered_tx = mc_util_serial::decode(&bytes).unwrap(); + assert_eq!(tx, recovered_tx); + } + + // Converting mc_transaction_core::Tx -> external::Tx -> mc_transaction_core::Tx + // should be the identity function. + { + let external_tx: external::Tx = external::Tx::from(&tx); + let recovered_tx: Tx = Tx::try_from(&external_tx).unwrap(); + assert_eq!(tx, recovered_tx); + } + + // Encoding with prost, decoding with protobuf should be the identity function. + { + let bytes = mc_util_serial::encode(&tx); + let recovered_tx = external::Tx::parse_from_bytes(&bytes).unwrap(); + assert_eq!(recovered_tx, external::Tx::from(&tx)); + } + + // Encoding with protobuf, decoding with prost should be the identity function. + { + let external_tx: external::Tx = external::Tx::from(&tx); + let bytes = external_tx.write_to_bytes().unwrap(); + let recovered_tx: Tx = mc_util_serial::decode(&bytes).unwrap(); + assert_eq!(tx, recovered_tx); + } + } + } + + #[test] + /// Tx --> externalTx --> Tx should be the identity function, for tx with + /// input rules + fn test_convert_tx_with_input_rules() { + // Generate a Tx to test with. This is copied from + // transaction_builder.rs::test_simple_transaction + let mut rng: StdRng = SeedableRng::from_seed([1u8; 32]); + + for block_version in BlockVersion::iterator().skip(3) { + let alice = AccountKey::random(&mut rng); + let bob = AccountKey::random(&mut rng); + let charlie = AccountKey::random(&mut rng); + + let token2 = TokenId::from(2); + + let fpr = MockFogResolver::default(); + + // Charlie makes a signed contingent input, offering 1000 token2's for 1 MOB + let input_credentials = get_input_credentials( + block_version, + Amount::new(1000, token2), + &charlie, + &fpr, + &mut rng, + ); + let mut sci_builder = SignedContingentInputBuilder::new( + block_version, + input_credentials, + vec![59], + fpr.clone(), + EmptyMemoBuilder::default(), ); - let membership_proofs: Vec = ring - .iter() - .map(|_tx_out| { - // TransactionBuilder does not validate membership proofs, but does require one - // for each ring member. - TxOutMembershipProof::new(0, 0, Default::default()) - }) - .collect(); - - let input_credentials = InputCredentials::new( - ring.clone(), - membership_proofs, - 0, - onetime_private_key, - *alice.view_private_key(), + sci_builder + .add_output( + Amount::new(1000 * MILLIMOB_TO_PICOMOB, Mob::ID), + &charlie.default_subaddress(), + &mut rng, + ) + .unwrap(); + + let sci = sci_builder.build(&mut rng).unwrap(); + + // Alice sends this token2 amount to Bob from Charlie, paying Charlie 1000 MOB + // as he desires. + let mut transaction_builder = TransactionBuilder::new( + block_version, + Amount::new(Mob::MINIMUM_FEE, Mob::ID), + fpr.clone(), + EmptyMemoBuilder::default(), ) .unwrap(); - transaction_builder.add_input(input_credentials); - transaction_builder.set_fee(0).unwrap(); + transaction_builder.add_input(get_input_credentials( + block_version, + Amount::new(1475 * MILLIMOB_TO_PICOMOB, Mob::ID), + &alice, + &fpr, + &mut rng, + )); + transaction_builder.add_presigned_input(sci).unwrap(); + transaction_builder .add_output( - Amount::new(65536, Mob::ID), + Amount::new(1000, token2), &bob.default_subaddress(), &mut rng, ) .unwrap(); + transaction_builder + .add_change_output( + Amount::new(475 * MILLIMOB_TO_PICOMOB - Mob::MINIMUM_FEE, Mob::ID), + &ChangeDestination::from(&alice), + &mut rng, + ) + .unwrap(); + let tx = transaction_builder.build(&mut rng).unwrap(); // decode(encode(tx)) should be the identity function. diff --git a/api/tests/prost.rs b/api/tests/prost.rs index 47630f3a0c..b73a992686 100644 --- a/api/tests/prost.rs +++ b/api/tests/prost.rs @@ -1,8 +1,15 @@ //! Tests that prost-versions of structures round-trip with the versions //! generated from external.proto +use maplit::btreemap; use mc_account_keys::{AccountKey, PublicAddress, RootIdentity}; use mc_api::external; +use mc_fog_report_validation_test_utils::{FullyValidatedFogPubkey, MockFogResolver}; +use mc_transaction_core::{Amount, BlockVersion, SignedContingentInput}; +use mc_transaction_std::{ + test_utils::get_input_credentials, ChangeDestination, EmptyMemoBuilder, + SignedContingentInputBuilder, +}; use mc_util_from_random::FromRandom; use mc_util_test_helper::{run_with_several_seeds, CryptoRng, RngCore}; use prost::Message as ProstMessage; @@ -36,6 +43,99 @@ fn root_identity_examples(rng: &mut T) -> Vec( + block_version: BlockVersion, + rng: &mut T, +) -> Vec { + let mut result = Vec::new(); + + let sender = AccountKey::random(rng); + let recipient = AccountKey::random(rng).default_subaddress(); + let recipient2 = AccountKey::random_with_fog(rng).default_subaddress(); + let sender_change_dest = ChangeDestination::from(&sender); + + let fpr = MockFogResolver(btreemap! { + recipient2 + .fog_report_url() + .unwrap() + .to_string() + => + FullyValidatedFogPubkey { + pubkey: FromRandom::from_random(rng), + pubkey_expiry: 1000, + }, + }); + + let input_credentials = get_input_credentials( + block_version, + Amount::new(200, 1.into()), + &sender, + &fpr, + rng, + ); + let mut builder = SignedContingentInputBuilder::new( + block_version, + input_credentials, + vec![0u64, 1u64], + fpr.clone(), + EmptyMemoBuilder::default(), + ); + builder + .add_output(Amount::new(400, 0.into()), &recipient, rng) + .unwrap(); + result.push(builder.build(rng).unwrap()); + + let input_credentials = get_input_credentials( + block_version, + Amount::new(200, 1.into()), + &sender, + &fpr, + rng, + ); + let mut builder = SignedContingentInputBuilder::new( + block_version, + input_credentials, + vec![0u64, 1u64], + fpr.clone(), + EmptyMemoBuilder::default(), + ); + builder + .add_output(Amount::new(400, 0.into()), &recipient, rng) + .unwrap(); + builder + .add_output(Amount::new(600, 2.into()), &recipient2, rng) + .unwrap(); + result.push(builder.build(rng).unwrap()); + + let input_credentials = get_input_credentials( + block_version, + Amount::new(300, 1.into()), + &sender, + &fpr, + rng, + ); + let mut builder = SignedContingentInputBuilder::new( + block_version, + input_credentials, + vec![0u64, 1u64], + fpr.clone(), + EmptyMemoBuilder::default(), + ); + builder + .add_output(Amount::new(400, 0.into()), &recipient, rng) + .unwrap(); + builder + .add_output(Amount::new(600, 2.into()), &recipient2, rng) + .unwrap(); + builder + .add_change_output(Amount::new(100, 1.into()), &sender_change_dest, rng) + .unwrap(); + result.push(builder.build(rng).unwrap()); + + result +} + // Test that RootIdentity roundtrips through .proto structure #[test] fn root_identity_round_trip() { @@ -67,3 +167,17 @@ fn public_address_round_trip() { } }) } + +// Test that a SignedContingentInput round trips through .proto structure +#[test] +fn signed_contingent_input_round_trip() { + run_with_several_seeds(|mut rng| { + for block_version in BlockVersion::iterator().skip(3) { + for example in signed_contingent_input_examples(block_version, &mut rng) { + round_trip_message::( + &example, + ); + } + } + }) +} diff --git a/transaction/core/src/input_rules.rs b/transaction/core/src/input_rules.rs index 4fdccbf3e9..e05d1b200d 100644 --- a/transaction/core/src/input_rules.rs +++ b/transaction/core/src/input_rules.rs @@ -14,7 +14,7 @@ use crate::{ }; use alloc::vec::Vec; use displaydoc::Display; -use mc_crypto_digestible::Digestible; +use mc_crypto_digestible::{Digestible, MerlinTranscript}; use prost::Message; use serde::{Deserialize, Serialize}; @@ -39,6 +39,12 @@ pub struct InputRules { impl InputRules { /// Verify that a Tx conforms to the rules. pub fn verify(&self, _block_version: BlockVersion, tx: &Tx) -> Result<(), InputRuleError> { + // Verify max_tombstone_block + if self.max_tombstone_block != 0 { + if tx.prefix.tombstone_block > self.max_tombstone_block { + return Err(InputRuleError::MaxTombstoneBlockExceeded); + } + } // Verify required_outputs for required_output in self.required_outputs.iter() { if tx @@ -51,14 +57,13 @@ impl InputRules { return Err(InputRuleError::MissingRequiredOutput); } } - // Verify max_tombstone_block - if self.max_tombstone_block != 0 { - if tx.prefix.tombstone_block > self.max_tombstone_block { - return Err(InputRuleError::MaxTombstoneBlockExceeded); - } - } Ok(()) } + + /// Create a 32-byte digest of the rules + pub fn digest(&self) -> [u8; 32] { + self.digest32::(b"mc-input-rules") + } } /// An error that occurs when checking input rules diff --git a/transaction/core/src/lib.rs b/transaction/core/src/lib.rs index 6843da8174..fead8547a8 100644 --- a/transaction/core/src/lib.rs +++ b/transaction/core/src/lib.rs @@ -23,6 +23,7 @@ mod blockchain; mod domain_separators; mod input_rules; mod memo; +mod signed_contingent_input; mod token; mod tx_error; @@ -44,6 +45,9 @@ pub use amount::{Amount, AmountError, Commitment, CompressedCommitment, MaskedAm pub use blockchain::*; pub use input_rules::{InputRuleError, InputRules}; pub use memo::{EncryptedMemo, MemoError, MemoPayload}; +pub use signed_contingent_input::{ + SignedContingentInput, SignedContingentInputError, UnmaskedAmount, +}; pub use token::{tokens, Token, TokenId}; pub use tx::MemoContext; pub use tx_error::{NewMemoError, NewTxError, ViewKeyMatchError}; diff --git a/transaction/core/src/ring_signature/error.rs b/transaction/core/src/ring_signature/error.rs index 3d86a69763..7fe7666218 100644 --- a/transaction/core/src/ring_signature/error.rs +++ b/transaction/core/src/ring_signature/error.rs @@ -84,6 +84,12 @@ pub enum Error { /// No commitments were found for {0}, this is a logic error NoCommitmentsForTokenId(TokenId), + + /// All rings were presigned, but this is not allowed + AllRingsPresigned, + + /// Signed input rules not allowed at this revision + SignedInputRulesNotAllowed, } impl From for Error { diff --git a/transaction/core/src/ring_signature/rct_bulletproofs.rs b/transaction/core/src/ring_signature/rct_bulletproofs.rs index b2f9d4e73b..11a70d30b2 100644 --- a/transaction/core/src/ring_signature/rct_bulletproofs.rs +++ b/transaction/core/src/ring_signature/rct_bulletproofs.rs @@ -49,7 +49,41 @@ pub struct SignableInputRing { pub input_rules: Option, } -/// The secrets corresponding to an input needed to create a signature +/// A presigned RingMLSAG and ancillary data needed to incorporate it into a +/// signature +#[derive(Clone, Debug)] +pub struct PresignedInputRing { + /// The mlsag signature authorizing the spending of an input + pub mlsag: RingMLSAG, + /// The amount and blinding factor of the pseudo output + pub pseudo_output_secret: OutputSecret, +} + +/// An enum which is either a PresignedInputRing or a SignableInputRing +/// +/// This enum is needed because all TxIn's are required to appear in sorted +/// order, regardless of if they are presigned. This gives the signer a way to +/// control the order in which they will appear. +#[derive(Clone, Debug)] +pub enum InputRing { + /// A signable input ring + Signable(SignableInputRing), + /// A presigned input ring + Presigned(PresignedInputRing), +} + +impl InputRing { + /// Get the amount of the pseudo-output of this ring + pub fn amount(&self) -> &Amount { + match self { + InputRing::Signable(ring) => &ring.input_secret.amount, + InputRing::Presigned(ring) => &ring.pseudo_output_secret.amount, + } + } +} + +/// The secrets needed to create a signature that spends an existing output as +/// an input #[derive(Clone, Debug)] pub struct InputSecret { /// The one-time private key for the output we are trying to spend @@ -60,7 +94,8 @@ pub struct InputSecret { pub blinding: Scalar, } -/// The secrets corresponding to an output needed to create a signature +/// The secrets corresponding to an output that we are trying to authorize +/// creation of #[derive(Clone, Debug)] pub struct OutputSecret { /// The amount of the output we are creating @@ -69,7 +104,7 @@ pub struct OutputSecret { pub blinding: Scalar, } -/// The parts of a signed input ring needed to validate an MLSAG +/// The parts of a TxIn needed to validate a corresponding MLSAG #[derive(Clone, Debug)] pub struct SignedInputRing { /// A reduced representation of the TxOut's in the ring. For each ring @@ -140,7 +175,7 @@ impl SignatureRctBulletproofs { pub fn sign( block_version: BlockVersion, message: &[u8; 32], - signable_input_rings: &[SignableInputRing], + input_rings: &[InputRing], output_secrets: &[OutputSecret], fee: Amount, rng: &mut CSPRNG, @@ -148,7 +183,7 @@ impl SignatureRctBulletproofs { sign_with_balance_check( block_version, message, - signable_input_rings, + input_rings, output_secrets, fee, true, @@ -433,8 +468,8 @@ impl SignatureRctBulletproofs { /// * `message` - The messages to be signed, e.g. Hash(TxPrefix). /// * `rings` - One or more rings of one-time addresses and amount commitments, /// with secrets for the real input -/// * `output_values_and_blindings` - Output secret for each output amount -/// commitment. +/// * `presigned_rings` - Zero or more pre-signed input rings +/// * `output_secrets` - Output secret for each output amount commitment. /// * `fee` - Value of the implicit fee output. /// * `fee_token_id` - Token id of the fee output. /// * `check_value_is_preserved` - If true, check that the value of inputs @@ -442,7 +477,7 @@ impl SignatureRctBulletproofs { fn sign_with_balance_check( block_version: BlockVersion, message: &[u8; 32], - rings: &[SignableInputRing], + rings: &[InputRing], output_secrets: &[OutputSecret], fee: Amount, check_value_is_preserved: bool, @@ -457,7 +492,7 @@ fn sign_with_balance_check( if !block_version.mixed_transactions_are_supported() { if rings .iter() - .any(|ring| ring.input_secret.amount.token_id != fee.token_id) + .any(|ring| ring.amount().token_id != fee.token_id) { return Err(Error::MixedTransactionsNotAllowed); } @@ -469,49 +504,67 @@ fn sign_with_balance_check( } } + // Presigned rings and input rules cannot be used if signed input rules are not + // enabled + if !block_version.signed_input_rules_are_supported() { + for ring in rings { + match ring { + InputRing::Presigned(_) => { + return Err(Error::SignedInputRulesNotAllowed); + } + InputRing::Signable(ring) => { + if ring.input_rules.is_some() { + return Err(Error::SignedInputRulesNotAllowed); + } + } + }; + } + } + if rings.is_empty() { return Err(Error::NoInputs); } - let num_inputs = rings.len(); - let ring_size = rings[0].members.len(); + let ring_size = rings + .iter() + .filter_map(|ring| { + if let InputRing::Signable(ring) = ring { + Some(ring.members.len()) + } else { + None + } + }) + .next() + .ok_or(Error::AllRingsPresigned)?; + if ring_size == 0 { return Err(Error::InvalidRingSize(0)); } for ring in rings { - // Each ring must have the same size. - if ring.members.len() != ring_size { - return Err(Error::InvalidRingSize(ring.members.len())); - } - // Each `real_input_index` must be in [0,ring_size - 1]. - if ring.real_input_index >= ring_size { - return Err(Error::IndexOutOfBounds); + if let InputRing::Signable(ring) = ring { + // Each ring must have the same size. + if ring.members.len() != ring_size { + return Err(Error::InvalidRingSize(ring.members.len())); + } + // Each `real_input_index` must be in [0,ring_size - 1]. + if ring.real_input_index >= ring_size { + return Err(Error::IndexOutOfBounds); + } } } - // Blindings for pseudo_outputs. All but the last are random. - // Constructing blindings in this way ensures that sum_of_outputs - - // sum_of_pseudo_outputs = 0 if the sum of outputs and the sum of - // pseudo_outputs have equal value. - let mut pseudo_output_blindings: Vec = Vec::new(); - for _i in 0..num_inputs - 1 { - pseudo_output_blindings.push(Scalar::random(rng)); - } - // The implicit fee output is ommitted because its blinding is zero. - // - // Note: This implicit fee output is not the same as the accumulated fee output - // produced by the enclave -- the blinding of that output is not zero. - let sum_of_output_blindings: Scalar = output_secrets.iter().map(|secret| secret.blinding).sum(); - - let sum_of_pseudo_output_blindings: Scalar = pseudo_output_blindings.iter().sum(); - let last_blinding: Scalar = sum_of_output_blindings - sum_of_pseudo_output_blindings; - pseudo_output_blindings.push(last_blinding); + // This computes a sequence of appropriate pseudo output blindings, one for each + // ring. For Signable rings, this will be a random number, but the last is + // chosen so that the sum of all blinding factors is zero. + // For pre-signed rings, we cannot change blinding, so we have to use what was + // signed. + let pseudo_output_blindings = compute_pseudo_output_blindings(rings, output_secrets, rng)?; // Create Range proofs for outputs and pseudo-outputs. let pseudo_output_values_and_blindings: Vec<(u64, Scalar)> = rings .iter() .zip(pseudo_output_blindings.iter()) - .map(|(ring, blinding)| (ring.input_secret.amount.value, *blinding)) + .map(|(ring, blinding)| (ring.amount().value, *blinding)) .collect(); // Create a pedersen generator cache @@ -544,7 +597,7 @@ fn sign_with_balance_check( let mut token_ids = BTreeSet::default(); token_ids.insert(fee.token_id); for ring in rings { - token_ids.insert(ring.input_secret.amount.token_id); + token_ids.insert(ring.amount().token_id); } for secret in output_secrets { token_ids.insert(secret.amount.token_id); @@ -560,8 +613,8 @@ fn sign_with_balance_check( .iter() .zip(pseudo_output_blindings.iter()) .filter_map(|(ring, blinding)| { - if ring.input_secret.amount.token_id == token_id { - Some((ring.input_secret.amount.value, *blinding)) + if ring.amount().token_id == token_id { + Some((ring.amount().value, *blinding)) } else { None } @@ -595,8 +648,8 @@ fn sign_with_balance_check( .zip(pseudo_output_blindings.iter()) .map(|(ring, blinding)| { generator_cache - .get(ring.input_secret.amount.token_id) - .commit(Scalar::from(ring.input_secret.amount.value), *blinding) + .get(ring.amount().token_id) + .commit(Scalar::from(ring.amount().value), *blinding) }) .collect(); @@ -645,39 +698,46 @@ fn sign_with_balance_check( // Prove that the signer is allowed to spend a public key in each ring, and that // the input's value equals the value of the pseudo_output. - let mut ring_signatures: Vec = Vec::new(); - for (ring, pseudo_output_blinding) in rings.iter().zip(pseudo_output_blindings) { - let mut rules_digest = [0u8; 32]; - if let Some(rules) = &ring.input_rules { - rules_digest - .copy_from_slice(&rules.digest32::(b"mc-input-rules")[..]); - } - - let sign_this: &[u8] = if ring.input_rules.is_some() { - &rules_digest - } else { - &extended_message_digest - }; - - let generator = generator_cache.get(ring.input_secret.amount.token_id); - let ring_signature = RingMLSAG::sign( - sign_this, - &ring.members, - ring.real_input_index, - &ring.input_secret.onetime_private_key, - ring.input_secret.amount.value, - &ring.input_secret.blinding, - &pseudo_output_blinding, - generator, - rng, - )?; - ring_signatures.push(ring_signature); - } - - let mut pseudo_output_token_ids: Vec = rings + let ring_signatures: Vec = rings .iter() - .map(|ring| *ring.input_secret.amount.token_id) - .collect(); + .zip(pseudo_output_blindings) + .map( + |(ring, pseudo_output_blinding)| -> Result { + Ok(match ring { + InputRing::Signable(ring) => { + let rules_digest = ring + .input_rules + .as_ref() + .map(|rules| rules.digest()) + .unwrap_or_default(); + + let sign_this: &[u8] = if ring.input_rules.is_some() { + &rules_digest + } else { + &extended_message_digest + }; + + let generator = generator_cache.get(ring.input_secret.amount.token_id); + RingMLSAG::sign( + sign_this, + &ring.members, + ring.real_input_index, + &ring.input_secret.onetime_private_key, + ring.input_secret.amount.value, + &ring.input_secret.blinding, + &pseudo_output_blinding, + generator, + rng, + )? + } + InputRing::Presigned(ring) => ring.mlsag.clone(), + }) + }, + ) + .collect::>()?; + + let mut pseudo_output_token_ids: Vec = + rings.iter().map(|ring| *ring.amount().token_id).collect(); let mut output_token_ids: Vec = output_secrets .iter() .map(|secret| *secret.amount.token_id) @@ -703,6 +763,76 @@ fn sign_with_balance_check( }) } +/// Computes appropriate pseudo-output blinding values for each input ring. +/// +/// Arguments: +/// * The input rings, both pre-signed and signable. +/// * The output secrets +/// +/// Returns: +/// * The blinding factor for each pseudo output, corresponding to each ring. +/// +/// Requirements: +/// * At least one ring must not be pre-signed. +/// +/// Post-conditions: +/// * The sum_of_output blindings - sum_of_pseudo_output blindings = 0 +fn compute_pseudo_output_blindings( + rings: &[InputRing], + output_secrets: &[OutputSecret], + rng: &mut CSPRNG, +) -> Result, Error> { + // The implicit fee output is ommitted because its blinding is zero. + // + // Note: This implicit fee output is not the same as the accumulated fee output + // produced by the enclave -- the blinding of that output is not zero. + let sum_of_output_blindings: Scalar = output_secrets.iter().map(|secret| secret.blinding).sum(); + let sum_of_presigned_pseudo_output_blindings: Scalar = rings + .iter() + .filter_map(|ring| { + if let InputRing::Presigned(ring) = ring { + Some(ring.pseudo_output_secret.blinding) + } else { + None + } + }) + .sum(); + + let last_unsigned_ring_index = rings + .iter() + .rposition(|x| matches!(x, InputRing::Signable(_))) + .ok_or(Error::AllRingsPresigned)?; + + // The sum of all presigned pseudo output blindings, plus any that we have + // chosen randomly for signable pseudo outputs. + let mut running_sum = sum_of_presigned_pseudo_output_blindings; + + // Blindings for pseudo_outputs. All but the last are random. + // Constructing blindings in this way ensures that sum_of_outputs - + // sum_of_pseudo_outputs = 0 if the sum of outputs and the sum of + // pseudo_outputs have equal value. + Ok(rings + .iter() + .enumerate() + .map(|(idx, ring)| { + match ring { + InputRing::Signable(_ring) => { + if idx == last_unsigned_ring_index { + // At this point, the running sum is the sum of all pseudo output blindings, + // except this one. + sum_of_output_blindings - running_sum + } else { + let random = Scalar::random(rng); + running_sum += random; + random + } + } + InputRing::Presigned(ring) => ring.pseudo_output_secret.blinding, + } + }) + .collect()) +} + /// Toggles between old-style and new-style extended message fn compute_extended_message_either_version( block_version: BlockVersion, @@ -930,6 +1060,14 @@ mod rct_bulletproofs_tests { self.rings.iter().map(SignedInputRing::from).collect() } + fn get_input_rings(&self) -> Vec { + self.rings + .iter() + .cloned() + .map(InputRing::Signable) + .collect() + } + fn sign( &self, fee: u64, @@ -938,7 +1076,7 @@ mod rct_bulletproofs_tests { SignatureRctBulletproofs::sign( self.block_version, &self.message, - &self.rings, + &self.get_input_rings(), &self.output_secrets, Amount::new(fee, self.fee_token_id), rng, @@ -953,7 +1091,7 @@ mod rct_bulletproofs_tests { sign_with_balance_check( self.block_version, &self.message, - &self.rings, + &self.get_input_rings(), &self.output_secrets, Amount::new(fee, self.fee_token_id), false, diff --git a/transaction/core/src/signed_contingent_input.rs b/transaction/core/src/signed_contingent_input.rs new file mode 100644 index 0000000000..4d7a0fa33f --- /dev/null +++ b/transaction/core/src/signed_contingent_input.rs @@ -0,0 +1,171 @@ +// Copyright (c) 2022 The MobileCoin Foundation + +//! A signed contingent input as described in MCIP #31 + +use crate::{ + ring_signature::{ + CurveScalar, Error as RingSignatureError, GeneratorCache, KeyImage, OutputSecret, + PresignedInputRing, RingMLSAG, SignedInputRing, + }, + tx::TxIn, + Amount, Commitment, CompressedCommitment, TokenId, +}; +use alloc::vec::Vec; +use displaydoc::Display; +use prost::Message; + +/// The "unmasked" data of an amount commitment +#[derive(Clone, Eq, Message, PartialEq)] +pub struct UnmaskedAmount { + /// The value of the amount commitment + #[prost(fixed64, tag = "1")] + pub value: u64, + + /// The token id of the amount commitment + #[prost(fixed64, tag = "2")] + pub token_id: u64, + + /// The blinding factor of the amount commitment + #[prost(message, required, tag = "3")] + pub blinding: CurveScalar, +} + +/// A signed contingent input is a "transaction fragment" which can be +/// incorporated into a transaction signed by a counterparty. See MCIP #31 for +/// motivation. +#[derive(Clone, Eq, Message, PartialEq)] +pub struct SignedContingentInput { + /// The tx_in which was signed over + #[prost(message, required, tag = "1")] + pub tx_in: TxIn, + + /// The Ring MLSAG signature, conferring spending authority + #[prost(message, required, tag = "2")] + pub mlsag: RingMLSAG, + + /// The amount and blinding of the pseudo-output of the MLSAG + #[prost(message, required, tag = "3")] + pub pseudo_output_amount: UnmaskedAmount, + + /// The amount and blinding of any TxOut required by the input rules + #[prost(message, repeated, tag = "4")] + pub required_output_amounts: Vec, + + /// The tx_out global index of each ring member + /// This helps the recipient of this payload construct proofs of membership + /// for the ring + #[prost(fixed64, repeated, tag = "5")] + pub tx_out_global_indices: Vec, +} + +impl SignedContingentInput { + /// The key image of the input which has been signed. If this key image + /// appears already in the ledger, then the signed contingent input is + /// no longer valid. + pub fn key_image(&self) -> KeyImage { + self.mlsag.key_image + } + + /// Validation checks that a signed contingent input is well-formed. + /// + /// * The ring MLSAG actually signs the pseudo-output as claimed + /// * The required output amounts actually correspond to the required + /// outputs + pub fn validate(&self) -> Result<(), SignedContingentInputError> { + let mut generator_cache = GeneratorCache::default(); + let generator = generator_cache.get(TokenId::from(self.pseudo_output_amount.token_id)); + + let pseudo_output = CompressedCommitment::from(&Commitment::new( + self.pseudo_output_amount.value, + self.pseudo_output_amount.blinding.into(), + &generator, + )); + + let rules_digest = self + .tx_in + .input_rules + .as_ref() + .map(|rules| rules.digest()) + .ok_or(SignedContingentInputError::MissingRules)?; + + let signed_input_ring = SignedInputRing::from(&self.tx_in); + + self.mlsag + .verify(&rules_digest, &signed_input_ring.members, &pseudo_output)?; + + if let Some(rules) = &self.tx_in.input_rules { + if self.required_output_amounts.len() != rules.required_outputs.len() { + return Err(SignedContingentInputError::WrongNumberOfRequiredOutputAmounts); + } + + for (amount, output) in self + .required_output_amounts + .iter() + .zip(rules.required_outputs.iter()) + { + let generator = generator_cache.get(TokenId::from(amount.token_id)); + + let expected_commitment = CompressedCommitment::from(&Commitment::new( + amount.value, + amount.blinding.into(), + &generator, + )); + if expected_commitment != output.masked_amount.commitment { + return Err(SignedContingentInputError::RequiredOutputMismatch); + } + } + } + + Ok(()) + } +} + +impl From for PresignedInputRing { + fn from(src: SignedContingentInput) -> Self { + Self { + mlsag: src.mlsag, + pseudo_output_secret: src.pseudo_output_amount.into(), + } + } +} + +impl From for OutputSecret { + fn from(src: UnmaskedAmount) -> Self { + Self { + amount: Amount { + value: src.value, + token_id: src.token_id.into(), + }, + blinding: src.blinding.into(), + } + } +} + +impl From for UnmaskedAmount { + fn from(src: OutputSecret) -> Self { + Self { + value: src.amount.value, + token_id: *src.amount.token_id, + blinding: src.blinding.into(), + } + } +} + +/// An error which can occur when validating a signed contingent input +#[derive(Display, Debug, Clone)] +pub enum SignedContingentInputError { + /// The number of required outputs did not match to the number of amounts + WrongNumberOfRequiredOutputAmounts, + /// The amount of a required output was incorrect + RequiredOutputMismatch, + /// Input rules are missing + MissingRules, + /// Invalid Ring Signature: {0} + RingSignature(RingSignatureError), +} + +impl From for SignedContingentInputError { + fn from(src: RingSignatureError) -> Self { + Self::RingSignature(src) + } +} diff --git a/transaction/core/src/validation/mod.rs b/transaction/core/src/validation/mod.rs index bf8ca838ae..31b4f21744 100644 --- a/transaction/core/src/validation/mod.rs +++ b/transaction/core/src/validation/mod.rs @@ -7,5 +7,9 @@ mod validate; pub use self::{ error::{TransactionValidationError, TransactionValidationResult}, - validate::{validate, validate_signature, validate_tombstone, validate_tx_out}, + validate::{ + validate, validate_all_input_rules, validate_inputs_are_sorted, + validate_outputs_are_sorted, validate_ring_elements_are_sorted, validate_signature, + validate_tombstone, validate_tx_out, + }, }; diff --git a/transaction/core/src/validation/validate.rs b/transaction/core/src/validation/validate.rs index 47704e387c..6fd75972c1 100644 --- a/transaction/core/src/validation/validate.rs +++ b/transaction/core/src/validation/validate.rs @@ -194,7 +194,7 @@ fn validate_ring_elements_are_unique(tx_prefix: &TxPrefix) -> TransactionValidat } /// Elements in a ring must be sorted. -fn validate_ring_elements_are_sorted(tx_prefix: &TxPrefix) -> TransactionValidationResult<()> { +pub fn validate_ring_elements_are_sorted(tx_prefix: &TxPrefix) -> TransactionValidationResult<()> { for tx_in in &tx_prefix.inputs { check_sorted( &tx_in.ring, @@ -208,7 +208,7 @@ fn validate_ring_elements_are_sorted(tx_prefix: &TxPrefix) -> TransactionValidat /// Inputs must be sorted by the public key of the first ring element of each /// input. -fn validate_inputs_are_sorted(tx_prefix: &TxPrefix) -> TransactionValidationResult<()> { +pub fn validate_inputs_are_sorted(tx_prefix: &TxPrefix) -> TransactionValidationResult<()> { check_sorted( &tx_prefix.inputs, |a, b| { @@ -218,7 +218,8 @@ fn validate_inputs_are_sorted(tx_prefix: &TxPrefix) -> TransactionValidationResu ) } -fn validate_outputs_are_sorted(tx_prefix: &TxPrefix) -> TransactionValidationResult<()> { +/// Outputs must be sorted by the tx public key +pub fn validate_outputs_are_sorted(tx_prefix: &TxPrefix) -> TransactionValidationResult<()> { check_sorted( &tx_prefix.outputs, |a, b| a.public_key < b.public_key, @@ -513,7 +514,7 @@ mod tests { tokens::Mob, tx::{Tx, TxOutMembershipHash, TxOutMembershipProof}, validation::error::TransactionValidationError, - Token, + InputRules, Token, }; use crate::membership_proofs::Range; @@ -1392,4 +1393,50 @@ mod tests { ) } } + + // Test that input rules validation is working + #[test] + fn test_input_rules_validation() { + let block_version = BlockVersion::THREE; + + for _ in 0..3 { + let (mut tx, _ledger) = create_test_tx(block_version); + + // Check that the Tx is following input rules (vacuously) + validate_all_input_rules(block_version, &tx).unwrap(); + + // Modify the Tx to have some input rules. + // (This invalidates the signature, but we aren't checking that here) + let first_tx_out = tx.prefix.outputs[0].clone(); + + // Declare the first tx out as a required output + tx.prefix.inputs[0].input_rules = Some(InputRules { + required_outputs: vec![first_tx_out], + max_tombstone_block: 0, + }); + + // Check that the Tx is following input rules (vacuously) + validate_all_input_rules(block_version, &tx).unwrap(); + + // Modify the input rules to refer to a non-existent tx out + let rules = tx.prefix.inputs[0].input_rules.as_mut().unwrap(); + rules.required_outputs[0].masked_amount.masked_value += 1; + + assert!(validate_all_input_rules(block_version, &tx).is_err()); + + // Set masked value back, now modify tombstone block + let rules = tx.prefix.inputs[0].input_rules.as_mut().unwrap(); + rules.required_outputs[0].masked_amount.masked_value -= 1; + rules.max_tombstone_block = tx.prefix.tombstone_block - 1; + + assert!(validate_all_input_rules(block_version, &tx).is_err()); + + // Set the tombstone block limit to be more permissive, now everything should be + // good + let rules = tx.prefix.inputs[0].input_rules.as_mut().unwrap(); + rules.max_tombstone_block = tx.prefix.tombstone_block; + + validate_all_input_rules(block_version, &tx).unwrap(); + } + } } diff --git a/transaction/std/Cargo.toml b/transaction/std/Cargo.toml index 168a4ef48b..f5c73edf00 100644 --- a/transaction/std/Cargo.toml +++ b/transaction/std/Cargo.toml @@ -34,6 +34,7 @@ curve25519-dalek = { version = "4.0.0-pre.2", default-features = false, features curve25519-dalek = { version = "4.0.0-pre.2", default-features = false, features = ["nightly", "u64_backend"] } [dev-dependencies] +assert_matches = "1.5" maplit = "1.0" yaml-rust = "0.4" diff --git a/transaction/std/src/error.rs b/transaction/std/src/error.rs index b6a79175c7..b786464515 100644 --- a/transaction/std/src/error.rs +++ b/transaction/std/src/error.rs @@ -56,6 +56,12 @@ pub enum TxBuilderError { /// Feature is not supported at this block version ({0}): {1} FeatureNotSupportedAtBlockVersion(u32, &'static str), + + /// Signed input rules not allowed at this block version + SignedInputRulesNotAllowed, + + /// Missing membership proof + MissingMembershipProofs, } impl From for TxBuilderError { diff --git a/transaction/std/src/input_credentials.rs b/transaction/std/src/input_credentials.rs index 9c23fadbf4..889ece0e5b 100644 --- a/transaction/std/src/input_credentials.rs +++ b/transaction/std/src/input_credentials.rs @@ -5,8 +5,7 @@ use mc_crypto_keys::{RistrettoPrivate, RistrettoPublic}; use mc_transaction_core::{ onetime_keys::create_shared_secret, ring_signature::{InputSecret, SignableInputRing}, - tx::{TxOut, TxOutMembershipProof}, - AmountError, + tx::{TxIn, TxOut, TxOutMembershipProof}, }; use std::convert::TryFrom; use zeroize::Zeroize; @@ -23,11 +22,8 @@ pub struct InputCredentials { /// Index in `ring` of the "real" output being spent. pub real_index: usize, - /// Private key for the "real" output being spent. - pub onetime_private_key: RistrettoPrivate, - - /// TxOut shared secret - pub tx_out_shared_secret: RistrettoPublic, + /// Secrets needed to spend the real output + pub input_secret: InputSecret, } impl InputCredentials { @@ -84,45 +80,52 @@ impl InputCredentials { .position(|element| *element == real_input) .expect("Must still contain real input"); + let masked_amount = &ring[real_index].masked_amount; + let (amount, blinding) = masked_amount.get_value(&tx_out_shared_secret)?; + + let input_secret = InputSecret { + onetime_private_key, + amount, + blinding, + }; + Ok(InputCredentials { ring, membership_proofs, real_index, - onetime_private_key, - tx_out_shared_secret, + input_secret, }) } } -impl TryFrom<&InputCredentials> for SignableInputRing { - type Error = AmountError; - fn try_from(src: &InputCredentials) -> Result { - let masked_amount = &src.ring[src.real_index].masked_amount; - let (amount, blinding) = masked_amount.get_value(&src.tx_out_shared_secret)?; - - let input_secret = InputSecret { - onetime_private_key: src.onetime_private_key, - amount, - blinding, - }; - - Ok(SignableInputRing { +impl From for SignableInputRing { + fn from(src: InputCredentials) -> SignableInputRing { + SignableInputRing { members: src .ring .iter() .map(|tx_out| (tx_out.target_key, tx_out.masked_amount.commitment)) .collect(), real_input_index: src.real_index, - input_secret, + input_secret: src.input_secret.clone(), input_rules: None, - }) + } + } +} + +impl From<&InputCredentials> for TxIn { + fn from(input_credential: &InputCredentials) -> TxIn { + TxIn { + ring: input_credential.ring.clone(), + proofs: input_credential.membership_proofs.clone(), + input_rules: None, + } } } impl Zeroize for InputCredentials { fn zeroize(&mut self) { self.real_index.zeroize(); - self.onetime_private_key.zeroize(); } } diff --git a/transaction/std/src/input_materials.rs b/transaction/std/src/input_materials.rs new file mode 100644 index 0000000000..291e6de38a --- /dev/null +++ b/transaction/std/src/input_materials.rs @@ -0,0 +1,87 @@ +// Copyright (c) 2022 The MobileCoin Foundation + +//! Input Materials is a helper struct for the transaction builder. +//! The transaction builder can get inputs either from input credentials, +//! or signed contingent inputs. In one case the input is being signed by us, +//! and in the other it is already signed. +//! +//! The transaction builder is required to sort these in order of tx public key +//! of the first element of the ring being signed, then hand them off to build +//! the actual signatures. This enum makes it convenient to do this. + +use crate::InputCredentials; +use mc_crypto_keys::CompressedRistrettoPublic; +use mc_transaction_core::{ring_signature::InputRing, tx::TxIn, Amount, SignedContingentInput}; + +/// Material that can be used by the transaction builder to create an input to +/// a transaction. +#[derive(Debug, Clone)] +pub enum InputMaterials { + /// Signable input materials + Signable(InputCredentials), + /// Presigned input materials + Presigned(SignedContingentInput), +} + +impl From for InputMaterials { + fn from(src: InputCredentials) -> Self { + Self::Signable(src) + } +} + +impl From for InputMaterials { + fn from(src: SignedContingentInput) -> Self { + Self::Presigned(src) + } +} + +impl InputMaterials { + /// Get the sort key for whichever type of input this is + pub fn sort_key(&self) -> &CompressedRistrettoPublic { + match self { + InputMaterials::Signable(cred) => &cred.ring[0].public_key, + InputMaterials::Presigned(input) => &input.tx_in.ring[0].public_key, + } + } + + /// Get the amount for whichever type of input this is + pub fn amount(&self) -> Amount { + match self { + InputMaterials::Signable(cred) => cred.input_secret.amount, + InputMaterials::Presigned(input) => Amount { + value: input.pseudo_output_amount.value, + token_id: input.pseudo_output_amount.token_id.into(), + }, + } + } + + /// Get the ring size for whichever type of input this is + pub fn ring_size(&self) -> usize { + match self { + InputMaterials::Signable(cred) => cred.ring.len(), + InputMaterials::Presigned(input) => input.tx_in.ring.len(), + } + } +} + +// Helper which converts from InputMaterials (TransactionBuilder type) to +// InputRing (rct_bulletproofs type) +impl From for InputRing { + fn from(src: InputMaterials) -> InputRing { + match src { + InputMaterials::Signable(creds) => InputRing::Signable(creds.into()), + InputMaterials::Presigned(input) => InputRing::Presigned(input.into()), + } + } +} + +// Helper which converts from InputMaterials (TransactionBuilder type) to TxIn +// (blockchain type) +impl From<&InputMaterials> for TxIn { + fn from(src: &InputMaterials) -> TxIn { + match src { + InputMaterials::Signable(ref creds) => creds.into(), + InputMaterials::Presigned(input) => input.tx_in.clone(), + } + } +} diff --git a/transaction/std/src/lib.rs b/transaction/std/src/lib.rs index 331de5e3cc..274dd52e17 100644 --- a/transaction/std/src/lib.rs +++ b/transaction/std/src/lib.rs @@ -10,8 +10,10 @@ extern crate core; mod change_destination; mod error; mod input_credentials; +mod input_materials; mod memo; mod memo_builder; +mod signed_contingent_input_builder; mod transaction_builder; #[cfg(any(test, feature = "test-only"))] @@ -26,6 +28,7 @@ pub use memo::{ UnusedMemo, }; pub use memo_builder::{EmptyMemoBuilder, MemoBuilder, RTHMemoBuilder}; +pub use signed_contingent_input_builder::SignedContingentInputBuilder; pub use transaction_builder::{DefaultTxOutputsOrdering, TransactionBuilder, TxOutputsOrdering}; // Re-export this to help the exported macros work diff --git a/transaction/std/src/signed_contingent_input_builder.rs b/transaction/std/src/signed_contingent_input_builder.rs new file mode 100644 index 0000000000..243621ff91 --- /dev/null +++ b/transaction/std/src/signed_contingent_input_builder.rs @@ -0,0 +1,2154 @@ +//// Copyright (c) 2022 The MobileCoin Foundation + +//! A builder object for signed contingent inputs (see MCIP #31) +//! This plays a similar role to the transaction builder. + +use crate::{ChangeDestination, InputCredentials, MemoBuilder, TxBuilderError}; +use core::cmp::min; +use mc_account_keys::PublicAddress; +use mc_fog_report_validation::FogPubkeyResolver; +use mc_transaction_core::{ + ring_signature::{GeneratorCache, OutputSecret, RingMLSAG, Scalar, SignableInputRing}, + tx::{TxIn, TxOut, TxOutConfirmationNumber}, + Amount, BlockVersion, InputRules, MemoContext, MemoPayload, NewMemoError, + SignedContingentInput, UnmaskedAmount, +}; +use rand_core::{CryptoRng, RngCore}; + +/// Helper utility for creating signed contingent inputs with required outputs, +/// and attaching fog hint and memos as appropriate. +/// +/// This is generic over FogPubkeyResolver because there are several reasonable +/// implementations of that. +/// +/// This is generic over MemoBuilder to allow injecting a policy for how to +/// use the memos in the TxOuts. +#[derive(Debug)] +pub struct SignedContingentInputBuilder { + /// The block version that we are targeting for this transaction + block_version: BlockVersion, + /// The input material used to form the transaction + input_credentials: InputCredentials, + /// Global indices for the tx out's in the ring + tx_out_global_indices: Vec, + /// The outputs created by the transaction, and associated secrets + outputs_and_secrets: Vec<(TxOut, OutputSecret)>, + /// The tombstone_block value, a block index in which the transaction + /// expires, and can no longer be added to the blockchain + tombstone_block: u64, + /// The source of validated fog pubkeys used for this transaction + fog_resolver: FPR, + /// The limit on the tombstone block value imposed pubkey_expiry values in + /// fog pubkeys used so far + fog_tombstone_block_limit: u64, + /// An policy object implementing MemoBuilder which constructs memos for + /// this transaction. + /// + /// This is an Option in order to allow working around the borrow checker. + /// Box is used because having more generic parameters creates more + /// types that SDKs must bind to if they support multiple memo builder + /// types. + memo_builder: Option>, +} + +impl SignedContingentInputBuilder { + /// Initializes a new SignedContingentInputBuilder. + /// + /// # Arguments + /// * `block_version` - The block version rules to use when signing the + /// input + /// * `input_credentials` - Credentials for the input we are signing + /// * `tx_out_global_indices` - Global indices for the tx out's in the ring + /// * `fog_resolver` - Source of validated fog keys to use with this + /// transaction + /// * `memo_builder` - An object which creates memos for the TxOuts in this + /// transaction + pub fn new( + block_version: BlockVersion, + input_credentials: InputCredentials, + tx_out_global_indices: Vec, + fog_resolver: FPR, + memo_builder: MB, + ) -> Self { + Self::new_with_box( + block_version, + input_credentials, + tx_out_global_indices, + fog_resolver, + Box::new(memo_builder), + ) + } + + /// Initializes a new SignedContingentInputBuilder, using a Box instead of statically typed + /// + /// # Arguments + /// * `block_version` - The block version to use when signing the input + /// * `input_credentials` - Credentials for the input we are signing + /// * `tx_out_global_indices` - Global indices for the tx out's in the ring + /// * `fog_resolver` - Source of validated fog keys to use with this signed + /// contingent input + /// * `memo_builder` - An object which creates memos for the TxOuts in this + /// signed contingent input + pub fn new_with_box( + block_version: BlockVersion, + input_credentials: InputCredentials, + tx_out_global_indices: Vec, + fog_resolver: FPR, + memo_builder: Box, + ) -> Self { + Self { + block_version, + input_credentials, + tx_out_global_indices, + outputs_and_secrets: Vec::new(), + tombstone_block: u64::max_value(), + fog_resolver, + fog_tombstone_block_limit: u64::max_value(), + memo_builder: Some(memo_builder), + } + } + + /// Add a non-change output to the transaction. + /// + /// If a sender memo credential has been set, this will create an + /// authenticated sender memo for the TxOut. Otherwise the memo will be + /// unused. + /// + /// # Arguments + /// * `amount` - The amount of this output + /// * `recipient` - The recipient's public address + /// * `rng` - RNG used to generate blinding for commitment + pub fn add_output( + &mut self, + amount: Amount, + recipient: &PublicAddress, + rng: &mut RNG, + ) -> Result<(TxOut, TxOutConfirmationNumber), TxBuilderError> { + // Taking self.memo_builder here means that we can call functions on &mut self, + // and pass them something that has captured the memo builder. + // Calling take() on Option is just moving a pointer. + let mut mb = self + .memo_builder + .take() + .expect("memo builder is missing, this is a logic error"); + let block_version = self.block_version; + let result = self.add_output_with_fog_hint_address( + amount, + recipient, + recipient, + |memo_ctxt| { + if block_version.e_memo_feature_is_supported() { + Some(mb.make_memo_for_output(amount, recipient, memo_ctxt)).transpose() + } else { + Ok(None) + } + }, + rng, + ); + // Put the memo builder back + self.memo_builder = Some(mb); + result + } + + /// Add a standard change output to the transaction. + /// + /// The change output is meant to send any value in the inputs not already + /// sent via outputs or fee, back to the sender's address. + /// The caller should ensure that the math adds up, and that + /// change_value + total_outlays + fee = total_input_value + /// + /// (Here, outlay means a non-change output). + /// + /// A change output should be sent to the dedicated change subaddress of the + /// sender. + /// + /// If provided, a Destination memo is attached to this output, which allows + /// for recoverable transaction history. + /// + /// The use of dedicated change subaddress for change outputs allows to + /// authenticate the contents of destination memos, which are otherwise + /// unauthenticated. + /// + /// # Arguments + /// * `amount` - The amount of this change output. + /// * `change_destination` - An object including both a primary address and + /// a change subaddress to use to create this change output. The primary + /// address is used for the fog hint, the change subaddress owns the + /// change output. These can both be obtained from an account key, but + /// this API does not require the account key. + /// * `rng` - RNG used to generate blinding for commitment + pub fn add_change_output( + &mut self, + amount: Amount, + change_destination: &ChangeDestination, + rng: &mut RNG, + ) -> Result<(TxOut, TxOutConfirmationNumber), TxBuilderError> { + // Taking self.memo_builder here means that we can call functions on &mut self, + // and pass them something that has captured the memo builder. + // Calling take() on Option is just moving a pointer. + let mut mb = self + .memo_builder + .take() + .expect("memo builder is missing, this is a logic error"); + let block_version = self.block_version; + let result = self.add_output_with_fog_hint_address( + amount, + &change_destination.change_subaddress, + &change_destination.primary_address, + |memo_ctxt| { + if block_version.e_memo_feature_is_supported() { + Some(mb.make_memo_for_change_output(amount, change_destination, memo_ctxt)) + .transpose() + } else { + Ok(None) + } + }, + rng, + ); + // Put the memo builder back + self.memo_builder = Some(mb); + result + } + + /// Add an output to the transaction, using `fog_hint_address` to construct + /// the fog hint. + /// + /// This is a private implementation detail, and generally, fog users expect + /// that the transactions that they recieve from fog belong to the account + /// that they are using. The only known use-case where recipient and + /// fog_hint_address are different is when sending change transactions + /// to oneself, when oneself is a fog user. Sending the change to the + /// main subaddress means that you don't have to hit fog once for the + /// main subaddress and once for the change subaddress, so it cuts the + /// number of requests in half. + /// + /// # Arguments + /// * `amount` - The amount of this output + /// * `recipient` - The recipient's public address + /// * `fog_hint_address` - The public address used to create the fog hint + /// * `memo_fn` - The memo function to use (see TxOut::new_with_memo) + /// * `rng` - RNG used to generate blinding for commitment + fn add_output_with_fog_hint_address( + &mut self, + amount: Amount, + recipient: &PublicAddress, + fog_hint_address: &PublicAddress, + memo_fn: impl FnOnce(MemoContext) -> Result, NewMemoError>, + rng: &mut RNG, + ) -> Result<(TxOut, TxOutConfirmationNumber), TxBuilderError> { + let (hint, pubkey_expiry) = + crate::transaction_builder::create_fog_hint(fog_hint_address, &self.fog_resolver, rng)?; + + let (tx_out, shared_secret) = crate::transaction_builder::create_output_with_fog_hint( + self.block_version, + amount, + recipient, + hint, + memo_fn, + rng, + )?; + + let (amount, blinding) = tx_out + .masked_amount + .get_value(&shared_secret) + .expect("TransactionBuilder created an invalid Amount"); + let output_secret = OutputSecret { amount, blinding }; + + self.impose_tombstone_block_limit(pubkey_expiry); + + self.outputs_and_secrets + .push((tx_out.clone(), output_secret)); + + let confirmation = TxOutConfirmationNumber::from(&shared_secret); + + Ok((tx_out, confirmation)) + } + + /// Sets the tombstone block, clamping to smallest pubkey expiry value. + /// + /// # Arguments + /// * `tombstone_block` - Tombstone block number. + pub fn set_tombstone_block(&mut self, tombstone_block: u64) -> u64 { + self.tombstone_block = min(tombstone_block, self.fog_tombstone_block_limit); + self.tombstone_block + } + + /// Reduce the fog_tombstone_block_limit value by the amount specified, + /// and propagate this constraint to self.tombstone_block + fn impose_tombstone_block_limit(&mut self, pubkey_expiry: u64) { + // Reduce fog tombstone block limit value if necessary + self.fog_tombstone_block_limit = min(self.fog_tombstone_block_limit, pubkey_expiry); + // Reduce tombstone_block value if necessary + self.tombstone_block = min(self.fog_tombstone_block_limit, self.tombstone_block); + } + + /// Consume the builder and return the transaction. + pub fn build( + mut self, + rng: &mut RNG, + ) -> Result { + if !self.block_version.signed_input_rules_are_supported() + || !self.block_version.mixed_transactions_are_supported() + { + return Err(TxBuilderError::BlockVersionTooOld( + *self.block_version, + *BlockVersion::THREE, + )); + } + + if self.block_version > BlockVersion::MAX { + return Err(TxBuilderError::BlockVersionTooNew( + *self.block_version, + *BlockVersion::MAX, + )); + } + + self.outputs_and_secrets + .sort_by(|(a, _), (b, _)| a.public_key.cmp(&b.public_key)); + + let (outputs, output_secrets): (Vec, Vec<_>) = + self.outputs_and_secrets.drain(..).unzip(); + + let input_rules = InputRules { + required_outputs: outputs, + max_tombstone_block: if self.tombstone_block == u64::max_value() { + 0 + } else { + self.tombstone_block + }, + }; + + // Now we can create the mlsag + let mut tx_in = TxIn::from(&self.input_credentials); + tx_in.input_rules = Some(input_rules.clone()); + let mut ring = SignableInputRing::from(self.input_credentials); + ring.input_rules = Some(input_rules.clone()); + + let pseudo_output_blinding = Scalar::random(rng); + + let mut generator_cache = GeneratorCache::default(); + let generator = generator_cache.get(ring.input_secret.amount.token_id); + + let mlsag = RingMLSAG::sign( + &input_rules.digest(), + &ring.members, + ring.real_input_index, + &ring.input_secret.onetime_private_key, + ring.input_secret.amount.value, + &ring.input_secret.blinding, + &pseudo_output_blinding, + generator, + rng, + )?; + + let pseudo_output_amount = UnmaskedAmount { + value: ring.input_secret.amount.value, + token_id: *ring.input_secret.amount.token_id, + blinding: pseudo_output_blinding.into(), + }; + + let required_output_amounts: Vec = + output_secrets.into_iter().map(Into::into).collect(); + + Ok(SignedContingentInput { + tx_in, + mlsag, + pseudo_output_amount, + required_output_amounts, + tx_out_global_indices: self.tx_out_global_indices, + }) + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + use maplit::btreemap; + + use crate::{ + test_utils::get_input_credentials, EmptyMemoBuilder, MemoType, TransactionBuilder, + }; + use assert_matches::assert_matches; + use core::convert::TryFrom; + use mc_account_keys::{AccountKey, CHANGE_SUBADDRESS_INDEX, DEFAULT_SUBADDRESS_INDEX}; + use mc_crypto_keys::{CompressedRistrettoPublic, RistrettoPrivate, RistrettoPublic}; + use mc_fog_report_validation_test_utils::{FullyValidatedFogPubkey, MockFogResolver}; + use mc_transaction_core::{ + constants::MILLIMOB_TO_PICOMOB, + fog_hint::FogHint, + get_tx_out_shared_secret, + ring_signature::{Error as RingSignatureError, KeyImage}, + subaddress_matches_tx_out, + tokens::Mob, + validation::{ + validate_all_input_rules, validate_inputs_are_sorted, validate_outputs_are_sorted, + validate_ring_elements_are_sorted, validate_signature, validate_tx_out, + TransactionValidationError, + }, + Amount, InputRuleError, SignedContingentInputError, Token, TokenId, + }; + use mc_util_from_random::FromRandom; + use rand::{rngs::StdRng, SeedableRng}; + + #[test] + // Test a signed contingent input with a fog recipient + fn test_simple_fog_signed_contingent_input() { + let mut rng: StdRng = SeedableRng::from_seed([1u8; 32]); + + for block_version in 3..=*BlockVersion::MAX { + let block_version = BlockVersion::try_from(block_version).unwrap(); + + let sender = AccountKey::random(&mut rng); + let recipient = AccountKey::random_with_fog(&mut rng); + let ingest_private_key = RistrettoPrivate::from_random(&mut rng); + + let fog_resolver = MockFogResolver(btreemap! { + recipient + .default_subaddress() + .fog_report_url() + .unwrap() + .to_string() + => + FullyValidatedFogPubkey { + pubkey: RistrettoPublic::from(&ingest_private_key), + pubkey_expiry: 1000, + }, + }); + + let value = 1475 * MILLIMOB_TO_PICOMOB; + let amount = Amount::new(value, Mob::ID); + let amount2 = Amount::new(100_000, 2.into()); + + let input_credentials = + get_input_credentials(block_version, amount, &sender, &fog_resolver, &mut rng); + + let key_image = KeyImage::from(&input_credentials.input_secret.onetime_private_key); + + let mut builder = SignedContingentInputBuilder::new( + block_version, + input_credentials, + vec![3], + fog_resolver, + EmptyMemoBuilder::default(), + ); + + let (_txout, confirmation) = builder + .add_output(amount2, &recipient.default_subaddress(), &mut rng) + .unwrap(); + + builder.set_tombstone_block(2000); + + let sci = builder.build(&mut rng).unwrap(); + + // The contingent input should have a valid signature. + sci.validate().unwrap(); + + // The contingent input should have the correct key image. + assert_eq!(sci.key_image(), key_image); + + // The contingent input rules should respect fog pubkey expiry limit + assert_eq!( + sci.tx_in.input_rules.as_ref().unwrap().max_tombstone_block, + 1000 + ); + + // The contingent input should have one output. + assert_eq!( + sci.tx_in + .input_rules + .as_ref() + .unwrap() + .required_outputs + .len(), + 1 + ); + + let output = sci.tx_in.input_rules.as_ref().unwrap().required_outputs[0].clone(); + + validate_tx_out(block_version, &output).unwrap(); + + // The output should belong to the correct recipient. + assert!( + subaddress_matches_tx_out(&recipient, DEFAULT_SUBADDRESS_INDEX, &output).unwrap() + ); + + // The output should have the correct value and confirmation number + { + let public_key = RistrettoPublic::try_from(&output.public_key).unwrap(); + assert!(confirmation.validate(&public_key, recipient.view_private_key())); + } + + // The output's fog hint should contain the correct public key. + { + let mut output_fog_hint = FogHint::new(RistrettoPublic::from_random(&mut rng)); + assert!(bool::from(FogHint::ct_decrypt( + &ingest_private_key, + &output.e_fog_hint, + &mut output_fog_hint, + ))); + assert_eq!( + output_fog_hint.get_view_pubkey(), + &CompressedRistrettoPublic::from( + recipient.default_subaddress().view_public_key() + ) + ); + } + } + } + + #[test] + // Test a signed contingent input with two fog recipients + fn test_two_fogs_signed_contingent_input() { + let mut rng: StdRng = SeedableRng::from_seed([1u8; 32]); + + for block_version in 3..=*BlockVersion::MAX { + let block_version = BlockVersion::try_from(block_version).unwrap(); + + let sender = AccountKey::new_with_fog( + &FromRandom::from_random(&mut rng), + &FromRandom::from_random(&mut rng), + "fog://demo.com".to_string(), + Default::default(), + vec![], + ); + let recipient = AccountKey::random_with_fog(&mut rng); + let ingest_private_key1 = RistrettoPrivate::from_random(&mut rng); + let ingest_private_key2 = RistrettoPrivate::from_random(&mut rng); + + let fog_resolver = MockFogResolver(btreemap! { + sender + .default_subaddress() + .fog_report_url() + .unwrap() + .to_string() + => + FullyValidatedFogPubkey { + pubkey: RistrettoPublic::from(&ingest_private_key1), + pubkey_expiry: 1000, + }, + recipient + .default_subaddress() + .fog_report_url() + .unwrap() + .to_string() + => + FullyValidatedFogPubkey { + pubkey: RistrettoPublic::from(&ingest_private_key2), + pubkey_expiry: 1500, + }, + }); + + let value = 1475 * MILLIMOB_TO_PICOMOB; + let amount = Amount::new(value, Mob::ID); + let amount2 = Amount::new(100_000, 2.into()); + + let input_credentials = + get_input_credentials(block_version, amount, &sender, &fog_resolver, &mut rng); + + let key_image = KeyImage::from(&input_credentials.input_secret.onetime_private_key); + + let mut builder = SignedContingentInputBuilder::new( + block_version, + input_credentials, + vec![3], + fog_resolver, + EmptyMemoBuilder::default(), + ); + + let (_txout, confirmation) = builder + .add_output(amount2, &recipient.default_subaddress(), &mut rng) + .unwrap(); + + builder.set_tombstone_block(2000); + + let sci = builder.build(&mut rng).unwrap(); + + // The contingent input should have a valid signature. + sci.validate().unwrap(); + + // The contingent input should have the correct key image. + assert_eq!(sci.key_image(), key_image); + + // The contingent input rules should respect fog pubkey expiry limit, + // choosing the recipient's fog pubkey expiry + assert_eq!( + sci.tx_in.input_rules.as_ref().unwrap().max_tombstone_block, + 1500 + ); + + // The contingent input should have one output. + assert_eq!( + sci.tx_in + .input_rules + .as_ref() + .unwrap() + .required_outputs + .len(), + 1 + ); + + let output = sci.tx_in.input_rules.as_ref().unwrap().required_outputs[0].clone(); + + validate_tx_out(block_version, &output).unwrap(); + + // The output should belong to the correct recipient. + assert!( + subaddress_matches_tx_out(&recipient, DEFAULT_SUBADDRESS_INDEX, &output).unwrap() + ); + + // The output should have the correct value and confirmation number + { + let public_key = RistrettoPublic::try_from(&output.public_key).unwrap(); + assert!(confirmation.validate(&public_key, recipient.view_private_key())); + } + + // The output's fog hint should contain the correct public key. + { + let mut output_fog_hint = FogHint::new(RistrettoPublic::from_random(&mut rng)); + assert!(bool::from(FogHint::ct_decrypt( + &ingest_private_key2, + &output.e_fog_hint, + &mut output_fog_hint, + ))); + assert_eq!( + output_fog_hint.get_view_pubkey(), + &CompressedRistrettoPublic::from( + recipient.default_subaddress().view_public_key() + ) + ); + } + } + } + + #[test] + // Test that a signed contingent input with a fog recipient is spendable by Tx + // builder + fn test_fog_contingent_input_spendable_no_memos() { + let mut rng: StdRng = SeedableRng::from_seed([1u8; 32]); + + for block_version in 3..=*BlockVersion::MAX { + let block_version = BlockVersion::try_from(block_version).unwrap(); + + let alice = AccountKey::random(&mut rng); + let bob = AccountKey::random_with_fog(&mut rng); + let ingest_private_key = RistrettoPrivate::from_random(&mut rng); + + let fog_resolver = MockFogResolver(btreemap! { + bob + .default_subaddress() + .fog_report_url() + .unwrap() + .to_string() + => + FullyValidatedFogPubkey { + pubkey: RistrettoPublic::from(&ingest_private_key), + pubkey_expiry: 1000, + }, + }); + + let value = 1475 * MILLIMOB_TO_PICOMOB; + let amount = Amount::new(value, Mob::ID); + let token2 = TokenId::from(2); + let amount2 = Amount::new(100_000, token2); + + // Alice provides amount of Mob + let input_credentials = + get_input_credentials(block_version, amount, &alice, &fog_resolver, &mut rng); + + let key_image = KeyImage::from(&input_credentials.input_secret.onetime_private_key); + + let mut builder = SignedContingentInputBuilder::new( + block_version, + input_credentials, + vec![3], + fog_resolver.clone(), + EmptyMemoBuilder::default(), + ); + + // Alice requests amount2 worth of token id 2 in exchange + let (_txout, _confirmation) = builder + .add_output(amount2, &alice.default_subaddress(), &mut rng) + .unwrap(); + + let sci = builder.build(&mut rng).unwrap(); + + // The contingent input should have a valid signature. + sci.validate().unwrap(); + + // The contingent input should have the expected key image + assert_eq!(sci.key_image(), key_image); + + // Bob has 3x worth of token id 2 + let input_credentials = get_input_credentials( + block_version, + Amount::new(300_000, token2), + &bob, + &fog_resolver, + &mut rng, + ); + + let mut builder = TransactionBuilder::new( + block_version, + Amount::new(Mob::MINIMUM_FEE, Mob::ID), + fog_resolver, + EmptyMemoBuilder::default(), + ) + .unwrap(); + + // Bob supplies his (excess) token id 2 + builder.add_input(input_credentials); + + // Bob adds the presigned input, which also adds the required outputs + builder.add_presigned_input(sci).unwrap(); + + let bob_change_dest = ChangeDestination::from(&bob); + + // Bob keeps the change from token id 2 + builder + .add_change_output(Amount::new(200_000, token2), &bob_change_dest, &mut rng) + .unwrap(); + + // Bob keeps the Mob that Alice supplies, less fees + builder + .add_output( + Amount::new(value - Mob::MINIMUM_FEE, Mob::ID), + &bob.default_subaddress(), + &mut rng, + ) + .unwrap(); + + let tx = builder.build(&mut rng).unwrap(); + + // tx should have a valid signature, and pass all input rule checks + validate_signature(block_version, &tx, &mut rng).unwrap(); + validate_all_input_rules(block_version, &tx).unwrap(); + + // tx inputs and outputs should be sorted + validate_inputs_are_sorted(&tx.prefix).unwrap(); + validate_ring_elements_are_sorted(&tx.prefix).unwrap(); + validate_outputs_are_sorted(&tx.prefix).unwrap(); + + // The transaction should have two inputs. + assert_eq!(tx.prefix.inputs.len(), 2); + + // The transaction should have three outputs. + assert_eq!(tx.prefix.outputs.len(), 3); + + // The tombstone block should be the min of what the user requested, and what + // fog limits it to + assert_eq!(tx.prefix.tombstone_block, 1000); + + let bob_output = tx + .prefix + .outputs + .iter() + .find(|tx_out| { + subaddress_matches_tx_out(&bob, DEFAULT_SUBADDRESS_INDEX, tx_out).unwrap() + }) + .expect("Didn't find bob's MOB output"); + + let bob_change = tx + .prefix + .outputs + .iter() + .find(|tx_out| { + subaddress_matches_tx_out(&bob, CHANGE_SUBADDRESS_INDEX, tx_out).unwrap() + }) + .expect("Didn't find bob's T2 output"); + + let alice_output = tx + .prefix + .outputs + .iter() + .find(|tx_out| { + subaddress_matches_tx_out(&alice, DEFAULT_SUBADDRESS_INDEX, tx_out).unwrap() + }) + .expect("Didn't find alice's output"); + + validate_tx_out(block_version, bob_output).unwrap(); + validate_tx_out(block_version, bob_change).unwrap(); + validate_tx_out(block_version, alice_output).unwrap(); + + // Bob's MOB output should belong to the correct recipient and have correct + // amount and have correct memo + { + let ss = get_tx_out_shared_secret( + bob.view_private_key(), + &RistrettoPublic::try_from(&bob_output.public_key).unwrap(), + ); + let (amount, _) = bob_output.masked_amount.get_value(&ss).unwrap(); + assert_eq!(amount, Amount::new(value - Mob::MINIMUM_FEE, Mob::ID)); + + let memo = bob_output.e_memo.unwrap().decrypt(&ss); + assert_matches!( + MemoType::try_from(&memo).expect("Couldn't decrypt memo"), + MemoType::Unused(_) + ); + } + + // Bob's T2 change should belong to the correct recipient and have correct + // amount and have correct memo + { + let ss = get_tx_out_shared_secret( + bob.view_private_key(), + &RistrettoPublic::try_from(&bob_change.public_key).unwrap(), + ); + let (amount, _) = bob_change.masked_amount.get_value(&ss).unwrap(); + assert_eq!(amount, Amount::new(200_000, token2)); + + let memo = bob_change.e_memo.unwrap().decrypt(&ss); + assert_matches!( + MemoType::try_from(&memo).expect("Couldn't decrypt memo"), + MemoType::Unused(_) + ); + } + + // Alice's T2 output should belong to the correct recipient and have correct + // amount and have correct memo + { + let ss = get_tx_out_shared_secret( + alice.view_private_key(), + &RistrettoPublic::try_from(&alice_output.public_key).unwrap(), + ); + let (amount, _) = alice_output.masked_amount.get_value(&ss).unwrap(); + assert_eq!(amount, amount2); + + let memo = alice_output.e_memo.unwrap().decrypt(&ss); + assert_matches!( + MemoType::try_from(&memo).expect("Couldn't decrypt memo"), + MemoType::Unused(_) + ); + } + + // Bob's Mob output fog hint should contain the correct public key. + { + let mut output_fog_hint = FogHint::new(RistrettoPublic::from_random(&mut rng)); + assert!(bool::from(FogHint::ct_decrypt( + &ingest_private_key, + &bob_output.e_fog_hint, + &mut output_fog_hint, + ))); + assert_eq!( + output_fog_hint.get_view_pubkey(), + &CompressedRistrettoPublic::from(bob.default_subaddress().view_public_key()) + ); + } + + // Bob's change output fog hint should contain the correct public key. + { + let mut output_fog_hint = FogHint::new(RistrettoPublic::from_random(&mut rng)); + assert!(bool::from(FogHint::ct_decrypt( + &ingest_private_key, + &bob_change.e_fog_hint, + &mut output_fog_hint, + ))); + assert_eq!( + output_fog_hint.get_view_pubkey(), + &CompressedRistrettoPublic::from(bob.default_subaddress().view_public_key()) + ); + } + } + } + + #[test] + // Test that a signed contingent input with fog recipient is spendable by Tx + // builder, by another fog user + fn test_two_fogs_contingent_input_spendable_no_memos() { + let mut rng: StdRng = SeedableRng::from_seed([1u8; 32]); + + for block_version in 3..=*BlockVersion::MAX { + let block_version = BlockVersion::try_from(block_version).unwrap(); + + let alice = AccountKey::new_with_fog( + &FromRandom::from_random(&mut rng), + &FromRandom::from_random(&mut rng), + "fog://alice.com".to_string(), + Default::default(), + vec![], + ); + let bob = AccountKey::random_with_fog(&mut rng); + let ingest_private_key = RistrettoPrivate::from_random(&mut rng); + + let fog_resolver = MockFogResolver(btreemap! { + alice + .default_subaddress() + .fog_report_url() + .unwrap() + .to_string() + => + FullyValidatedFogPubkey { + pubkey: RistrettoPublic::from(&ingest_private_key), + pubkey_expiry: 1000, + }, + bob + .default_subaddress() + .fog_report_url() + .unwrap() + .to_string() + => + FullyValidatedFogPubkey { + pubkey: RistrettoPublic::from(&ingest_private_key), + pubkey_expiry: 1500, + }, + }); + + let value = 1475 * MILLIMOB_TO_PICOMOB; + let amount = Amount::new(value, Mob::ID); + let token2 = TokenId::from(2); + let amount2 = Amount::new(100_000, token2); + + // Alice provides amount of Mob + let input_credentials = + get_input_credentials(block_version, amount, &alice, &fog_resolver, &mut rng); + + let key_image = KeyImage::from(&input_credentials.input_secret.onetime_private_key); + + let mut builder = SignedContingentInputBuilder::new( + block_version, + input_credentials, + vec![3], + fog_resolver.clone(), + EmptyMemoBuilder::default(), + ); + + // Alice requests amount2 worth of token id 2 in exchange + let (_txout, _confirmation) = builder + .add_output(amount2, &alice.default_subaddress(), &mut rng) + .unwrap(); + + let sci = builder.build(&mut rng).unwrap(); + + // The contingent input should have a valid signature. + sci.validate().unwrap(); + + // The contingent input should have the expected key image + assert_eq!(sci.key_image(), key_image); + + // Bob has 3x worth of token id 2 + let input_credentials = get_input_credentials( + block_version, + Amount::new(300_000, token2), + &bob, + &fog_resolver, + &mut rng, + ); + + let mut builder = TransactionBuilder::new( + block_version, + Amount::new(Mob::MINIMUM_FEE, Mob::ID), + fog_resolver, + EmptyMemoBuilder::default(), + ) + .unwrap(); + + // Bob supplies his (excess) token id 2 + builder.add_input(input_credentials); + + // Bob adds the presigned input, which also adds the required outputs + builder.add_presigned_input(sci).unwrap(); + + let bob_change_dest = ChangeDestination::from(&bob); + + // Bob keeps the change from token id 2 + builder + .add_change_output(Amount::new(200_000, token2), &bob_change_dest, &mut rng) + .unwrap(); + + // Bob keeps the Mob that Alice supplies, less fees + builder + .add_output( + Amount::new(value - Mob::MINIMUM_FEE, Mob::ID), + &bob.default_subaddress(), + &mut rng, + ) + .unwrap(); + + let tx = builder.build(&mut rng).unwrap(); + + // tx should have a valid signature, and pass all input rule checks + validate_signature(block_version, &tx, &mut rng).unwrap(); + validate_all_input_rules(block_version, &tx).unwrap(); + + // tx inputs and outputs should be sorted + validate_inputs_are_sorted(&tx.prefix).unwrap(); + validate_ring_elements_are_sorted(&tx.prefix).unwrap(); + validate_outputs_are_sorted(&tx.prefix).unwrap(); + + // The transaction should have two inputs. + assert_eq!(tx.prefix.inputs.len(), 2); + + // The transaction should have three outputs. + assert_eq!(tx.prefix.outputs.len(), 3); + + // The tombstone block should be the min of what the user requested, and what + // fog limits it to, for both fogs + assert_eq!(tx.prefix.tombstone_block, 1000); + + let bob_output = tx + .prefix + .outputs + .iter() + .find(|tx_out| { + subaddress_matches_tx_out(&bob, DEFAULT_SUBADDRESS_INDEX, tx_out).unwrap() + }) + .expect("Didn't find bob's MOB output"); + + let bob_change = tx + .prefix + .outputs + .iter() + .find(|tx_out| { + subaddress_matches_tx_out(&bob, CHANGE_SUBADDRESS_INDEX, tx_out).unwrap() + }) + .expect("Didn't find bob's T2 output"); + + let alice_output = tx + .prefix + .outputs + .iter() + .find(|tx_out| { + subaddress_matches_tx_out(&alice, DEFAULT_SUBADDRESS_INDEX, tx_out).unwrap() + }) + .expect("Didn't find alice's output"); + + validate_tx_out(block_version, bob_output).unwrap(); + validate_tx_out(block_version, bob_change).unwrap(); + validate_tx_out(block_version, alice_output).unwrap(); + + // Bob's MOB output should belong to the correct recipient and have correct + // amount and have correct memo + { + let ss = get_tx_out_shared_secret( + bob.view_private_key(), + &RistrettoPublic::try_from(&bob_output.public_key).unwrap(), + ); + let (amount, _) = bob_output.masked_amount.get_value(&ss).unwrap(); + assert_eq!(amount, Amount::new(value - Mob::MINIMUM_FEE, Mob::ID)); + + let memo = bob_output.e_memo.unwrap().decrypt(&ss); + assert_matches!( + MemoType::try_from(&memo).expect("Couldn't decrypt memo"), + MemoType::Unused(_) + ); + } + + // Bob's T2 change should belong to the correct recipient and have correct + // amount and have correct memo + { + let ss = get_tx_out_shared_secret( + bob.view_private_key(), + &RistrettoPublic::try_from(&bob_change.public_key).unwrap(), + ); + let (amount, _) = bob_change.masked_amount.get_value(&ss).unwrap(); + assert_eq!(amount, Amount::new(200_000, token2)); + + let memo = bob_change.e_memo.unwrap().decrypt(&ss); + assert_matches!( + MemoType::try_from(&memo).expect("Couldn't decrypt memo"), + MemoType::Unused(_) + ); + } + + // Alice's T2 output should belong to the correct recipient and have correct + // amount and have correct memo + { + let ss = get_tx_out_shared_secret( + alice.view_private_key(), + &RistrettoPublic::try_from(&alice_output.public_key).unwrap(), + ); + let (amount, _) = alice_output.masked_amount.get_value(&ss).unwrap(); + assert_eq!(amount, amount2); + + let memo = alice_output.e_memo.unwrap().decrypt(&ss); + assert_matches!( + MemoType::try_from(&memo).expect("Couldn't decrypt memo"), + MemoType::Unused(_) + ); + } + + // Bob's Mob output fog hint should contain the correct public key. + { + let mut output_fog_hint = FogHint::new(RistrettoPublic::from_random(&mut rng)); + assert!(bool::from(FogHint::ct_decrypt( + &ingest_private_key, + &bob_output.e_fog_hint, + &mut output_fog_hint, + ))); + assert_eq!( + output_fog_hint.get_view_pubkey(), + &CompressedRistrettoPublic::from(bob.default_subaddress().view_public_key()) + ); + } + + // Bob's change output fog hint should contain the correct public key. + { + let mut output_fog_hint = FogHint::new(RistrettoPublic::from_random(&mut rng)); + assert!(bool::from(FogHint::ct_decrypt( + &ingest_private_key, + &bob_change.e_fog_hint, + &mut output_fog_hint, + ))); + assert_eq!( + output_fog_hint.get_view_pubkey(), + &CompressedRistrettoPublic::from(bob.default_subaddress().view_public_key()) + ); + } + } + } + + #[test] + // Test that two signed contingent inputs can be added to a single Tx and spent + fn test_two_contingent_inputs_spendable_no_memos() { + let mut rng: StdRng = SeedableRng::from_seed([1u8; 32]); + + for block_version in 3..=*BlockVersion::MAX { + let block_version = BlockVersion::try_from(block_version).unwrap(); + + let alice = AccountKey::random(&mut rng); + let bob = AccountKey::random(&mut rng); + let charlie = AccountKey::random(&mut rng); + + let fog_resolver = MockFogResolver(Default::default()); + + let value = 1475 * MILLIMOB_TO_PICOMOB; + let amount = Amount::new(value, Mob::ID); + let token2 = TokenId::from(2); + let token3 = TokenId::from(3); + + // Alice provides amount of Mob + let input_credentials = + get_input_credentials(block_version, amount, &alice, &fog_resolver, &mut rng); + + let mut builder = SignedContingentInputBuilder::new( + block_version, + input_credentials, + vec![3], + fog_resolver.clone(), + EmptyMemoBuilder::default(), + ); + + // Alice requests 100_000 token2 in exchange + let (_txout, _confirmation) = builder + .add_output( + Amount::new(100_000, token2), + &alice.default_subaddress(), + &mut rng, + ) + .unwrap(); + + let sci = builder.build(&mut rng).unwrap(); + + // The contingent input should have a valid signature. + sci.validate().unwrap(); + + // Bob has 300_000 worth of token id 2, happens to offer 100,000 of it for 666 + // token 3 + let input_credentials = get_input_credentials( + block_version, + Amount::new(300_000, token2), + &bob, + &fog_resolver, + &mut rng, + ); + + let mut builder = SignedContingentInputBuilder::new( + block_version, + input_credentials, + vec![4], + fog_resolver.clone(), + EmptyMemoBuilder::default(), + ); + + // Bob keeps the change from token id 2 + let bob_change_dest = ChangeDestination::from(&bob); + builder + .add_change_output(Amount::new(200_000, token2), &bob_change_dest, &mut rng) + .unwrap(); + + // Bob wants 666 of token id 3 + builder + .add_output( + Amount::new(666, token3), + &bob.default_subaddress(), + &mut rng, + ) + .unwrap(); + + let sci2 = builder.build(&mut rng).unwrap(); + + // The contingent input should have a valid signature. + sci2.validate().unwrap(); + + // Charlie wants to fill both orders + let mut builder = TransactionBuilder::new( + block_version, + Amount::new(Mob::MINIMUM_FEE, Mob::ID), + fog_resolver.clone(), + EmptyMemoBuilder::default(), + ) + .unwrap(); + + builder.add_presigned_input(sci).unwrap(); + builder.add_presigned_input(sci2).unwrap(); + + // Charlie supplies 999 token id 3 + builder.add_input(get_input_credentials( + block_version, + Amount::new(999, token3), + &charlie, + &fog_resolver, + &mut rng, + )); + + // Charlie keeps 333 as change, leaving 666 for Bob + let charlie_change_dest = ChangeDestination::from(&charlie); + builder + .add_change_output(Amount::new(333, token3), &charlie_change_dest, &mut rng) + .unwrap(); + + // Charlie keeps the Mob that Alice supplies, less fees + builder + .add_output( + Amount::new(value - Mob::MINIMUM_FEE, Mob::ID), + &charlie.default_subaddress(), + &mut rng, + ) + .unwrap(); + + builder.set_tombstone_block(8088); + + let tx = builder.build(&mut rng).unwrap(); + + // tx should have a valid signature, and pass all input rule checks + validate_signature(block_version, &tx, &mut rng).unwrap(); + validate_all_input_rules(block_version, &tx).unwrap(); + + // tx inputs and outputs should be sorted + validate_inputs_are_sorted(&tx.prefix).unwrap(); + validate_ring_elements_are_sorted(&tx.prefix).unwrap(); + validate_outputs_are_sorted(&tx.prefix).unwrap(); + + // The transaction should have two inputs. + assert_eq!(tx.prefix.inputs.len(), 3); + + // The transaction should have five outputs. + assert_eq!(tx.prefix.outputs.len(), 5); + + // The tombstone block should be what it was configured to + assert_eq!(tx.prefix.tombstone_block, 8088); + + let bob_output = tx + .prefix + .outputs + .iter() + .find(|tx_out| { + subaddress_matches_tx_out(&bob, DEFAULT_SUBADDRESS_INDEX, tx_out).unwrap() + }) + .expect("Didn't find bob's T3 output"); + + let bob_change = tx + .prefix + .outputs + .iter() + .find(|tx_out| { + subaddress_matches_tx_out(&bob, CHANGE_SUBADDRESS_INDEX, tx_out).unwrap() + }) + .expect("Didn't find bob's T2 output"); + + let charlie_output = tx + .prefix + .outputs + .iter() + .find(|tx_out| { + subaddress_matches_tx_out(&charlie, DEFAULT_SUBADDRESS_INDEX, tx_out).unwrap() + }) + .expect("Didn't find charlie's MOB output"); + + let charlie_change = tx + .prefix + .outputs + .iter() + .find(|tx_out| { + subaddress_matches_tx_out(&charlie, CHANGE_SUBADDRESS_INDEX, tx_out).unwrap() + }) + .expect("Didn't find charlie's T3 output"); + + let alice_output = tx + .prefix + .outputs + .iter() + .find(|tx_out| { + subaddress_matches_tx_out(&alice, DEFAULT_SUBADDRESS_INDEX, tx_out).unwrap() + }) + .expect("Didn't find alice's output"); + + validate_tx_out(block_version, bob_output).unwrap(); + validate_tx_out(block_version, bob_change).unwrap(); + validate_tx_out(block_version, charlie_output).unwrap(); + validate_tx_out(block_version, charlie_change).unwrap(); + validate_tx_out(block_version, alice_output).unwrap(); + + // Bob's T3 output should belong to the correct recipient and have correct + // amount and have correct memo + { + let ss = get_tx_out_shared_secret( + bob.view_private_key(), + &RistrettoPublic::try_from(&bob_output.public_key).unwrap(), + ); + let (amount, _) = bob_output.masked_amount.get_value(&ss).unwrap(); + assert_eq!(amount, Amount::new(666, token3)); + + let memo = bob_output.e_memo.unwrap().decrypt(&ss); + assert_matches!( + MemoType::try_from(&memo).expect("Couldn't decrypt memo"), + MemoType::Unused(_) + ); + } + + // Bob's T2 change should belong to the correct recipient and have correct + // amount and have correct memo + { + let ss = get_tx_out_shared_secret( + bob.view_private_key(), + &RistrettoPublic::try_from(&bob_change.public_key).unwrap(), + ); + let (amount, _) = bob_change.masked_amount.get_value(&ss).unwrap(); + assert_eq!(amount, Amount::new(200_000, token2)); + + let memo = bob_change.e_memo.unwrap().decrypt(&ss); + assert_matches!( + MemoType::try_from(&memo).expect("Couldn't decrypt memo"), + MemoType::Unused(_) + ); + } + + // Charlie's MOB output should belong to the correct recipient and have correct + // amount and have correct memo + { + let ss = get_tx_out_shared_secret( + charlie.view_private_key(), + &RistrettoPublic::try_from(&charlie_output.public_key).unwrap(), + ); + let (amount, _) = charlie_output.masked_amount.get_value(&ss).unwrap(); + assert_eq!(amount, Amount::new(value - Mob::MINIMUM_FEE, Mob::ID)); + + let memo = charlie_output.e_memo.unwrap().decrypt(&ss); + assert_matches!( + MemoType::try_from(&memo).expect("Couldn't decrypt memo"), + MemoType::Unused(_) + ); + } + + // Charlie's T3 change should belong to the correct recipient and have correct + // amount and have correct memo + { + let ss = get_tx_out_shared_secret( + charlie.view_private_key(), + &RistrettoPublic::try_from(&charlie_change.public_key).unwrap(), + ); + let (amount, _) = charlie_change.masked_amount.get_value(&ss).unwrap(); + assert_eq!(amount, Amount::new(333, token3)); + + let memo = charlie_change.e_memo.unwrap().decrypt(&ss); + assert_matches!( + MemoType::try_from(&memo).expect("Couldn't decrypt memo"), + MemoType::Unused(_) + ); + } + + // Alice's T2 output should belong to the correct recipient and have correct + // amount and have correct memo + { + let ss = get_tx_out_shared_secret( + alice.view_private_key(), + &RistrettoPublic::try_from(&alice_output.public_key).unwrap(), + ); + let (amount, _) = alice_output.masked_amount.get_value(&ss).unwrap(); + assert_eq!(amount, Amount::new(100_000, token2)); + + let memo = alice_output.e_memo.unwrap().decrypt(&ss); + assert_matches!( + MemoType::try_from(&memo).expect("Couldn't decrypt memo"), + MemoType::Unused(_) + ); + } + } + } + + #[test] + // Test that if you add a signed contingent input, but don't add any of your own + // input credentials, it fails with "AllRingsPresigned". + // (This is expected because (1) there would be no reason for someone to make + // signed inputs like this, and (2) there needs to be at least one + // pseudo-output where the transaction builder can choose a blinding factor + // for it.) + fn test_contingent_input_rules_no_input_credentials_doesnt_work() { + let mut rng: StdRng = SeedableRng::from_seed([1u8; 32]); + + for block_version in 3..=*BlockVersion::MAX { + let block_version = BlockVersion::try_from(block_version).unwrap(); + + let alice = AccountKey::random(&mut rng); + let bob = AccountKey::random_with_fog(&mut rng); + let ingest_private_key = RistrettoPrivate::from_random(&mut rng); + + let fog_resolver = MockFogResolver(btreemap! { + bob + .default_subaddress() + .fog_report_url() + .unwrap() + .to_string() + => + FullyValidatedFogPubkey { + pubkey: RistrettoPublic::from(&ingest_private_key), + pubkey_expiry: 1000, + }, + }); + + let value = 1475 * MILLIMOB_TO_PICOMOB; + let amount = Amount::new(value, Mob::ID); + let token2 = TokenId::from(2); + let amount2 = Amount::new(100_000, token2); + + // Alice provides amount of Mob + let input_credentials = + get_input_credentials(block_version, amount, &alice, &fog_resolver, &mut rng); + + let key_image = KeyImage::from(&input_credentials.input_secret.onetime_private_key); + + let mut builder = SignedContingentInputBuilder::new( + block_version, + input_credentials, + vec![3], + fog_resolver.clone(), + EmptyMemoBuilder::default(), + ); + + // Alice requests amount2 worth of token id 2 in exchange + let (_txout, _confirmation) = builder + .add_output(amount2, &alice.default_subaddress(), &mut rng) + .unwrap(); + + let sci = builder.build(&mut rng).unwrap(); + + // The contingent input should have a valid signature. + sci.validate().unwrap(); + assert_eq!(sci.key_image(), key_image); + + let mut builder = TransactionBuilder::new( + block_version, + Amount::new(Mob::MINIMUM_FEE, Mob::ID), + fog_resolver, + EmptyMemoBuilder::default(), + ) + .unwrap(); + + // Bob adds the presigned input (raw), without adding required outputs + builder.add_presigned_input_raw(sci); + + // Bob keeps the Mob that Alice supplies, less fees + builder + .add_output( + Amount::new(value - Mob::MINIMUM_FEE, Mob::ID), + &bob.default_subaddress(), + &mut rng, + ) + .unwrap(); + + builder.set_tombstone_block(1000); + + // The transaction is balanced, but it fails because all rings were presigned + assert_matches!( + builder.build(&mut rng), + Err(TxBuilderError::RingSignatureFailed( + RingSignatureError::AllRingsPresigned + )) + ); + } + } + + #[test] + // Test that if you add a signed contingent input, but don't respect the rules + // and try to send the required output to yourself, it fails with + // MissingRequiredOutput. + fn test_contingent_input_rules_ignoring_required_outputs_doesnt_work() { + let mut rng: StdRng = SeedableRng::from_seed([1u8; 32]); + + for block_version in 3..=*BlockVersion::MAX { + let block_version = BlockVersion::try_from(block_version).unwrap(); + + let alice = AccountKey::random(&mut rng); + let bob = AccountKey::random_with_fog(&mut rng); + let ingest_private_key = RistrettoPrivate::from_random(&mut rng); + + let fog_resolver = MockFogResolver(btreemap! { + bob + .default_subaddress() + .fog_report_url() + .unwrap() + .to_string() + => + FullyValidatedFogPubkey { + pubkey: RistrettoPublic::from(&ingest_private_key), + pubkey_expiry: 1000, + }, + }); + + let value = 1475 * MILLIMOB_TO_PICOMOB; + let amount = Amount::new(value, Mob::ID); + let token2 = TokenId::from(2); + let amount2 = Amount::new(100_000, token2); + let amount_meowb = Amount::new(1, 3.into()); + + // Alice provides amount of Mob + let input_credentials = + get_input_credentials(block_version, amount, &alice, &fog_resolver, &mut rng); + + let key_image = KeyImage::from(&input_credentials.input_secret.onetime_private_key); + + let mut builder = SignedContingentInputBuilder::new( + block_version, + input_credentials, + vec![3], + fog_resolver.clone(), + EmptyMemoBuilder::default(), + ); + + // Alice requests amount2 worth of token id 2 in exchange + let (_txout, _confirmation) = builder + .add_output(amount2, &alice.default_subaddress(), &mut rng) + .unwrap(); + + let sci = builder.build(&mut rng).unwrap(); + + // The contingent input should have a valid signature. + sci.validate().unwrap(); + assert_eq!(sci.key_image(), key_image); + + let mut builder = TransactionBuilder::new( + block_version, + Amount::new(Mob::MINIMUM_FEE, Mob::ID), + fog_resolver.clone(), + EmptyMemoBuilder::default(), + ) + .unwrap(); + + // Bob adds the presigned input (raw), without adding required outputs + builder.add_presigned_input_raw(sci); + + // Bob adds a nominal amount of Meowblecoin, to avoid "all rings presigned" + // error + builder.add_input(get_input_credentials( + block_version, + amount_meowb, + &bob, + &fog_resolver, + &mut rng, + )); + + // Bob keeps the Mob that Alice supplies, less fees + builder + .add_output( + Amount::new(value - Mob::MINIMUM_FEE, Mob::ID), + &bob.default_subaddress(), + &mut rng, + ) + .unwrap(); + + // Bob pays back the nominal amount of meowb to himself, to have a balanced tx + builder + .add_output(amount_meowb, &bob.default_subaddress(), &mut rng) + .unwrap(); + + builder.set_tombstone_block(1000); + + // The transaction is balanced, so this should build + let tx = builder.build(&mut rng).unwrap(); + + assert_eq!(tx.prefix.tombstone_block, 1000); + + // tx does not pass input rule checks + assert_matches!( + validate_all_input_rules(block_version, &tx), + Err(TransactionValidationError::InputRule( + InputRuleError::MissingRequiredOutput + )) + ); + } + } + + #[test] + // Test that if you add a signed contingent input, but don't respect the rules + // and try to send the required output to yourself, it fails with + // MissingRequiredOutput. + fn test_contingent_input_rules_redirecting_required_outputs_doesnt_work() { + let mut rng: StdRng = SeedableRng::from_seed([1u8; 32]); + + for block_version in 3..=*BlockVersion::MAX { + let block_version = BlockVersion::try_from(block_version).unwrap(); + + let alice = AccountKey::random(&mut rng); + let bob = AccountKey::random_with_fog(&mut rng); + let ingest_private_key = RistrettoPrivate::from_random(&mut rng); + + let fog_resolver = MockFogResolver(btreemap! { + bob + .default_subaddress() + .fog_report_url() + .unwrap() + .to_string() + => + FullyValidatedFogPubkey { + pubkey: RistrettoPublic::from(&ingest_private_key), + pubkey_expiry: 1000, + }, + }); + + let value = 1475 * MILLIMOB_TO_PICOMOB; + let amount = Amount::new(value, Mob::ID); + let token2 = TokenId::from(2); + let amount2 = Amount::new(100_000, token2); + + // Alice provides amount of Mob + let input_credentials = + get_input_credentials(block_version, amount, &alice, &fog_resolver, &mut rng); + + let key_image = KeyImage::from(&input_credentials.input_secret.onetime_private_key); + + let mut builder = SignedContingentInputBuilder::new( + block_version, + input_credentials, + vec![3], + fog_resolver.clone(), + EmptyMemoBuilder::default(), + ); + + // Alice requests amount2 worth of token id 2 in exchange + let (_txout, _confirmation) = builder + .add_output(amount2, &alice.default_subaddress(), &mut rng) + .unwrap(); + + let sci = builder.build(&mut rng).unwrap(); + + // The contingent input should have a valid signature. + sci.validate().unwrap(); + assert_eq!(sci.key_image(), key_image); + + // Bob has 100_000 worth of token id 2 + let input_credentials = + get_input_credentials(block_version, amount2, &bob, &fog_resolver, &mut rng); + + let mut builder = TransactionBuilder::new( + block_version, + Amount::new(Mob::MINIMUM_FEE, Mob::ID), + fog_resolver, + EmptyMemoBuilder::default(), + ) + .unwrap(); + + // Bob adds the presigned input (raw), without adding required outputs + builder.add_presigned_input_raw(sci); + + builder.add_input(input_credentials); + + // Bob keeps the Mob that Alice supplies, less fees + builder + .add_output( + Amount::new(value - Mob::MINIMUM_FEE, Mob::ID), + &bob.default_subaddress(), + &mut rng, + ) + .unwrap(); + + // Bob keeps the token id2 that he supplied also, instead of giving it to Alice + builder + .add_output(amount2, &bob.default_subaddress(), &mut rng) + .unwrap(); + + builder.set_tombstone_block(1000); + + // The transaction is balanced, so this should build + let tx = builder.build(&mut rng).unwrap(); + + assert_eq!(tx.prefix.tombstone_block, 1000); + + // tx does not pass input rule checks + assert_matches!( + validate_all_input_rules(block_version, &tx), + Err(TransactionValidationError::InputRule( + InputRuleError::MissingRequiredOutput + )) + ); + } + } + + #[test] + // Test that if you add a signed contingent input, and give the user the value, + // but don't give them the expected output, with expected memo etc., it + // fails with MissingRequiredOutput. + fn test_contingent_input_rules_sending_value_but_not_exact_required_output_doesnt_work() { + let mut rng: StdRng = SeedableRng::from_seed([1u8; 32]); + + for block_version in 3..=*BlockVersion::MAX { + let block_version = BlockVersion::try_from(block_version).unwrap(); + + let alice = AccountKey::random_with_fog(&mut rng); + let bob = AccountKey::random(&mut rng); + let ingest_private_key = RistrettoPrivate::from_random(&mut rng); + + let fog_resolver = MockFogResolver(btreemap! { + alice + .default_subaddress() + .fog_report_url() + .unwrap() + .to_string() + => + FullyValidatedFogPubkey { + pubkey: RistrettoPublic::from(&ingest_private_key), + pubkey_expiry: 1000, + }, + }); + + let value = 1475 * MILLIMOB_TO_PICOMOB; + let amount = Amount::new(value, Mob::ID); + let token2 = TokenId::from(2); + let amount2 = Amount::new(100_000, token2); + + // Alice provides amount of Mob + let input_credentials = + get_input_credentials(block_version, amount, &alice, &fog_resolver, &mut rng); + + let key_image = KeyImage::from(&input_credentials.input_secret.onetime_private_key); + + let mut builder = SignedContingentInputBuilder::new( + block_version, + input_credentials, + vec![3], + fog_resolver.clone(), + EmptyMemoBuilder::default(), + ); + + // Alice requests amount2 worth of token id 2 in exchange + let (_txout, _confirmation) = builder + .add_output(amount2, &alice.default_subaddress(), &mut rng) + .unwrap(); + + let sci = builder.build(&mut rng).unwrap(); + + // The contingent input should have a valid signature. + sci.validate().unwrap(); + assert_eq!(sci.key_image(), key_image); + + let mut builder = TransactionBuilder::new( + block_version, + Amount::new(Mob::MINIMUM_FEE, Mob::ID), + fog_resolver.clone(), + EmptyMemoBuilder::default(), + ) + .unwrap(); + + // Bob adds the presigned input (raw), without adding required outputs + builder.add_presigned_input_raw(sci); + + // Bob adds the token id 2 amount that alice requests + builder.add_input(get_input_credentials( + block_version, + amount2, + &bob, + &fog_resolver, + &mut rng, + )); + + // Bob gives the value that alice requests (note, it's not the same output + // actually) + builder + .add_output(amount2, &alice.default_subaddress(), &mut rng) + .unwrap(); + + // Bob keeps the Mob that Alice supplies, less fees + builder + .add_output( + Amount::new(value - Mob::MINIMUM_FEE, Mob::ID), + &bob.default_subaddress(), + &mut rng, + ) + .unwrap(); + + builder.set_tombstone_block(1000); + + // The transaction is balanced, so this should build + let tx = builder.build(&mut rng).unwrap(); + + assert_eq!(tx.prefix.tombstone_block, 1000); + + // tx does not pass input rule checks + assert_matches!( + validate_all_input_rules(block_version, &tx), + Err(TransactionValidationError::InputRule( + InputRuleError::MissingRequiredOutput + )) + ); + } + } + + #[test] + // Test that if you delete the rules from a signed contingent input, it fails + // with a ring signature error + fn test_contingent_input_rules_modifying_required_output_rules_doesnt_work() { + let mut rng: StdRng = SeedableRng::from_seed([1u8; 32]); + + for block_version in 3..=*BlockVersion::MAX { + let block_version = BlockVersion::try_from(block_version).unwrap(); + + let alice = AccountKey::random_with_fog(&mut rng); + let bob = AccountKey::random(&mut rng); + let ingest_private_key = RistrettoPrivate::from_random(&mut rng); + + let fog_resolver = MockFogResolver(btreemap! { + alice + .default_subaddress() + .fog_report_url() + .unwrap() + .to_string() + => + FullyValidatedFogPubkey { + pubkey: RistrettoPublic::from(&ingest_private_key), + pubkey_expiry: 1000, + }, + }); + + let value = 1475 * MILLIMOB_TO_PICOMOB; + let amount = Amount::new(value, Mob::ID); + let token2 = TokenId::from(2); + let amount2 = Amount::new(100_000, token2); + + // Alice provides amount of Mob + let input_credentials = + get_input_credentials(block_version, amount, &alice, &fog_resolver, &mut rng); + + let key_image = KeyImage::from(&input_credentials.input_secret.onetime_private_key); + + let mut builder = SignedContingentInputBuilder::new( + block_version, + input_credentials, + vec![3], + fog_resolver.clone(), + EmptyMemoBuilder::default(), + ); + + // Alice requests amount2 worth of token id 2 in exchange + let (_txout, _confirmation) = builder + .add_output(amount2, &alice.default_subaddress(), &mut rng) + .unwrap(); + + let mut sci = builder.build(&mut rng).unwrap(); + + // The contingent input should have a valid signature. + sci.validate().unwrap(); + assert_eq!(sci.key_image(), key_image); + + // Now we modify it to remove the required output + sci.tx_in + .input_rules + .as_mut() + .unwrap() + .required_outputs + .clear(); + + // (Sanity check: the sci fails its own validation now, because the signature is + // invalid) + assert_matches!( + sci.validate(), + Err(SignedContingentInputError::RingSignature(_)) + ); + + let mut builder = TransactionBuilder::new( + block_version, + Amount::new(Mob::MINIMUM_FEE, Mob::ID), + fog_resolver.clone(), + EmptyMemoBuilder::default(), + ) + .unwrap(); + + // Bob adds the presigned input (raw), without adding required outputs + builder.add_presigned_input_raw(sci); + + // Bob adds the token id 2 amount that alice requests + builder.add_input(get_input_credentials( + block_version, + amount2, + &bob, + &fog_resolver, + &mut rng, + )); + + // Bob gives the value that alice requests (note, it's not the same output + // actually) + builder + .add_output(amount2, &alice.default_subaddress(), &mut rng) + .unwrap(); + + // Bob keeps the Mob that Alice supplies, less fees + builder + .add_output( + Amount::new(value - Mob::MINIMUM_FEE, Mob::ID), + &bob.default_subaddress(), + &mut rng, + ) + .unwrap(); + + builder.set_tombstone_block(1000); + + // The transaction is balanced, so this should build + let tx = builder.build(&mut rng).unwrap(); + + assert_eq!(tx.prefix.tombstone_block, 1000); + + // tx does pass input rule checks (we deleted the rules) + validate_all_input_rules(block_version, &tx).unwrap(); + // tx fails signature check (one signature is over the rules we deleted) + assert_matches!( + validate_signature(block_version, &tx, &mut rng), + Err(TransactionValidationError::InvalidTransactionSignature(_)) + ); + } + } + + #[test] + // Test that if you add a signed contingent input, but don't respect tombstone + // block rules, it fails with MaxTombstoneBlockExceeded + fn test_contingent_input_rules_ignoring_tombstone_block_doesnt_work() { + let mut rng: StdRng = SeedableRng::from_seed([1u8; 32]); + + for block_version in 3..=*BlockVersion::MAX { + let block_version = BlockVersion::try_from(block_version).unwrap(); + + let alice = AccountKey::random_with_fog(&mut rng); + let bob = AccountKey::random(&mut rng); + let ingest_private_key = RistrettoPrivate::from_random(&mut rng); + + let fog_resolver = MockFogResolver(btreemap! { + alice + .default_subaddress() + .fog_report_url() + .unwrap() + .to_string() + => + FullyValidatedFogPubkey { + pubkey: RistrettoPublic::from(&ingest_private_key), + pubkey_expiry: 1000, + }, + }); + + let value = 1475 * MILLIMOB_TO_PICOMOB; + let amount = Amount::new(value, Mob::ID); + let token2 = TokenId::from(2); + let amount2 = Amount::new(100_000, token2); + + // Alice provides amount of Mob + let input_credentials = + get_input_credentials(block_version, amount, &alice, &fog_resolver, &mut rng); + + let key_image = KeyImage::from(&input_credentials.input_secret.onetime_private_key); + + let mut builder = SignedContingentInputBuilder::new( + block_version, + input_credentials, + vec![3], + fog_resolver.clone(), + EmptyMemoBuilder::default(), + ); + + // Alice requests amount2 worth of token id 2 in exchange + let (_txout, _confirmation) = builder + .add_output(amount2, &alice.default_subaddress(), &mut rng) + .unwrap(); + + let sci = builder.build(&mut rng).unwrap(); + + // The contingent input should have a valid signature. + sci.validate().unwrap(); + assert_eq!(sci.key_image(), key_image); + + let mut builder = TransactionBuilder::new( + block_version, + Amount::new(Mob::MINIMUM_FEE, Mob::ID), + fog_resolver.clone(), + EmptyMemoBuilder::default(), + ) + .unwrap(); + + // Bob adds the presigned input (raw), without adding required outputs + builder.add_presigned_input_raw(sci); + + // Bob adds the token id 2 amount that alice requests + builder.add_input(get_input_credentials( + block_version, + amount2, + &bob, + &fog_resolver, + &mut rng, + )); + + // Bob keeps the value that alice requests + builder + .add_output(amount2, &bob.default_subaddress(), &mut rng) + .unwrap(); + + // Bob keeps the Mob that Alice supplies, less fees + builder + .add_output( + Amount::new(value - Mob::MINIMUM_FEE, Mob::ID), + &bob.default_subaddress(), + &mut rng, + ) + .unwrap(); + + // Bob also doesn't respect Alice's tombstone block limit of 2000 + builder.set_tombstone_block(2000); + + // The transaction is balanced, so this should build + let tx = builder.build(&mut rng).unwrap(); + + assert_eq!(tx.prefix.tombstone_block, 2000); + + // tx does not pass input rule checks + assert_matches!( + validate_all_input_rules(block_version, &tx), + Err(TransactionValidationError::InputRule( + InputRuleError::MaxTombstoneBlockExceeded + )) + ); + } + } + + #[test] + // Test that if you add a signed contingent input, but don't respect tombstone + // block rules, and modify tombstone block rules, it fails with Ring + // signature error + fn test_contingent_input_rules_modifying_tombstone_block_rules_doesnt_work() { + let mut rng: StdRng = SeedableRng::from_seed([1u8; 32]); + + for block_version in 3..=*BlockVersion::MAX { + let block_version = BlockVersion::try_from(block_version).unwrap(); + + let alice = AccountKey::random_with_fog(&mut rng); + let bob = AccountKey::random(&mut rng); + let ingest_private_key = RistrettoPrivate::from_random(&mut rng); + + let fog_resolver = MockFogResolver(btreemap! { + alice + .default_subaddress() + .fog_report_url() + .unwrap() + .to_string() + => + FullyValidatedFogPubkey { + pubkey: RistrettoPublic::from(&ingest_private_key), + pubkey_expiry: 1000, + }, + }); + + let value = 1475 * MILLIMOB_TO_PICOMOB; + let amount = Amount::new(value, Mob::ID); + let token2 = TokenId::from(2); + let amount2 = Amount::new(100_000, token2); + + // Alice provides amount of Mob + let input_credentials = + get_input_credentials(block_version, amount, &alice, &fog_resolver, &mut rng); + + let key_image = KeyImage::from(&input_credentials.input_secret.onetime_private_key); + + let mut builder = SignedContingentInputBuilder::new( + block_version, + input_credentials, + vec![3], + fog_resolver.clone(), + EmptyMemoBuilder::default(), + ); + + // Alice requests amount2 worth of token id 2 in exchange + let (_txout, _confirmation) = builder + .add_output(amount2, &alice.default_subaddress(), &mut rng) + .unwrap(); + + let mut sci = builder.build(&mut rng).unwrap(); + + // The contingent input should have a valid signature. + sci.validate().unwrap(); + assert_eq!(sci.key_image(), key_image); + assert_eq!( + sci.tx_in.input_rules.as_mut().unwrap().max_tombstone_block, + 1000 + ); + + // Now we modify it to increase the max tombstone block limit + sci.tx_in.input_rules.as_mut().unwrap().max_tombstone_block = 2000; + + // (Sanity check: the sci fails its own validation now, because the signature is + // invalid) + assert_matches!( + sci.validate(), + Err(SignedContingentInputError::RingSignature(_)) + ); + + let mut builder = TransactionBuilder::new( + block_version, + Amount::new(Mob::MINIMUM_FEE, Mob::ID), + fog_resolver.clone(), + EmptyMemoBuilder::default(), + ) + .unwrap(); + + // Bob adds the presigned input (raw), without adding required outputs + builder.add_presigned_input_raw(sci); + + // Bob adds the token id 2 amount that alice requests + builder.add_input(get_input_credentials( + block_version, + amount2, + &bob, + &fog_resolver, + &mut rng, + )); + + // Bob keeps the value that alice requests + builder + .add_output(amount2, &bob.default_subaddress(), &mut rng) + .unwrap(); + + // Bob keeps the Mob that Alice supplies, less fees + builder + .add_output( + Amount::new(value - Mob::MINIMUM_FEE, Mob::ID), + &bob.default_subaddress(), + &mut rng, + ) + .unwrap(); + + // Bob also doesn't respect Alice's tombstone block limit of 2000 + builder.set_tombstone_block(2000); + + // The transaction is balanced, so this should build + let tx = builder.build(&mut rng).unwrap(); + + assert_eq!(tx.prefix.tombstone_block, 2000); + + // tx doesn't complain about tombstone block since we changed the rules, now + // complains about missing required outputs + assert_matches!( + validate_all_input_rules(block_version, &tx), + Err(TransactionValidationError::InputRule( + InputRuleError::MissingRequiredOutput + )) + ); + // tx fails signature check (one signature is over the rules we deleted) + assert_matches!( + validate_signature(block_version, &tx, &mut rng), + Err(TransactionValidationError::InvalidTransactionSignature(_)) + ); + } + } +} diff --git a/transaction/std/src/test_utils.rs b/transaction/std/src/test_utils.rs index ef18d28256..7f46c65744 100644 --- a/transaction/std/src/test_utils.rs +++ b/transaction/std/src/test_utils.rs @@ -4,7 +4,7 @@ use crate::{EmptyMemoBuilder, InputCredentials, MemoPayload, TransactionBuilder, use core::convert::TryFrom; use mc_account_keys::{AccountKey, PublicAddress, DEFAULT_SUBADDRESS_INDEX}; use mc_crypto_keys::RistrettoPublic; -use mc_fog_report_validation_test_utils::FogPubkeyResolver; +use mc_fog_report_validation::FogPubkeyResolver; use mc_transaction_core::{ onetime_keys::*, tokens::Mob, @@ -151,7 +151,7 @@ pub fn get_input_credentials( .unwrap() } -// Uses TransactionBuilder to build a transaction. +/// Uses TransactionBuilder to build a transaction, for testing purposes. pub fn get_transaction( block_version: BlockVersion, token_id: TokenId, diff --git a/transaction/std/src/transaction_builder.rs b/transaction/std/src/transaction_builder.rs index 749aa91a29..930342eb16 100644 --- a/transaction/std/src/transaction_builder.rs +++ b/transaction/std/src/transaction_builder.rs @@ -4,7 +4,10 @@ //! //! See https://cryptonote.org/img/cryptonote_transaction.png -use crate::{ChangeDestination, InputCredentials, MemoBuilder, TxBuilderError}; +use crate::{ + input_materials::InputMaterials, ChangeDestination, InputCredentials, MemoBuilder, + TxBuilderError, +}; use core::{cmp::min, fmt::Debug}; use mc_account_keys::PublicAddress; use mc_crypto_keys::{CompressedRistrettoPublic, RistrettoPrivate, RistrettoPublic}; @@ -13,14 +16,15 @@ use mc_transaction_core::{ encrypted_fog_hint::EncryptedFogHint, fog_hint::FogHint, onetime_keys::create_shared_secret, - ring_signature::{OutputSecret, SignableInputRing, SignatureRctBulletproofs}, + ring_signature::{InputRing, OutputSecret, SignatureRctBulletproofs}, tokens::Mob, tx::{Tx, TxIn, TxOut, TxOutConfirmationNumber, TxPrefix}, - Amount, BlockVersion, MemoContext, MemoPayload, NewMemoError, Token, TokenId, + Amount, BlockVersion, MemoContext, MemoPayload, NewMemoError, SignedContingentInput, + SignedContingentInputError, Token, TokenId, }; use mc_util_from_random::FromRandom; use rand_core::{CryptoRng, RngCore}; -use std::{cmp::Ordering, convert::TryFrom}; +use std::cmp::Ordering; /// A trait used to compare the transaction outputs pub trait TxOutputsOrdering { @@ -49,10 +53,10 @@ impl TxOutputsOrdering for DefaultTxOutputsOrdering { pub struct TransactionBuilder { /// The block version that we are targeting for this transaction block_version: BlockVersion, - /// The input credentials used to form the transaction - input_credentials: Vec, - /// The outputs created by the transaction, and associated shared secrets - outputs_and_shared_secrets: Vec<(TxOut, RistrettoPublic)>, + /// The input material used to form the transaction + input_materials: Vec, + /// The outputs created by the transaction, and associated output secret + outputs_and_secrets: Vec<(TxOut, OutputSecret)>, /// The tombstone_block value, a block index in which the transaction /// expires, and can no longer be added to the blockchain tombstone_block: u64, @@ -121,8 +125,8 @@ impl TransactionBuilder { memo_builder.set_fee(fee)?; Ok(TransactionBuilder { block_version, - input_credentials: Vec::new(), - outputs_and_shared_secrets: Vec::new(), + input_materials: Vec::new(), + outputs_and_secrets: Vec::new(), tombstone_block: u64::max_value(), fee, fog_resolver, @@ -137,7 +141,73 @@ impl TransactionBuilder { /// * `input_credentials` - Credentials required to construct a ring /// signature for an input. pub fn add_input(&mut self, input_credentials: InputCredentials) { - self.input_credentials.push(input_credentials); + self.input_materials + .push(InputMaterials::Signable(input_credentials)); + } + + /// Add a pre-signed Input to the transaction, also fulfilling any + /// requirements imposed by the signed rules, so that our transaction + /// will be valid. + /// + /// Note: Before adding a signed_contingent_input, you probably want to: + /// * validate it (call .validate()) + /// * check if key image appreared already (call .key_image()) + /// * provide merkle proofs of membership for each ring member (see + /// .tx_out_global_indices) + /// + /// # Arguments + /// * `signed_contingent_input` - The pre-signed input we are adding + pub fn add_presigned_input( + &mut self, + sci: SignedContingentInput, + ) -> Result<(), SignedContingentInputError> { + if let Some(rules) = sci.tx_in.input_rules.as_ref() { + // Enforce all rules so that our transaction will be valid + if rules.required_outputs.len() != sci.required_output_amounts.len() { + return Err(SignedContingentInputError::WrongNumberOfRequiredOutputAmounts); + } + // 1. Required outputs + for (required_output, unmasked_amount) in rules + .required_outputs + .iter() + .zip(sci.required_output_amounts.iter()) + { + // Check if the required output is already there + if self + .outputs_and_secrets + .iter() + .find(|(output, _sec)| output == required_output) + .is_none() + { + // If not, add it + self.outputs_and_secrets + .push((required_output.clone(), unmasked_amount.clone().into())); + } + } + // 2. Max tombstone block + if rules.max_tombstone_block != 0 { + self.impose_tombstone_block_limit(rules.max_tombstone_block); + } + } + + self.add_presigned_input_raw(sci); + Ok(()) + } + + /// Add a pre-signed Input to the transaction, without also fulfilling + /// any of its rules. You will have to add any required outputs, adjust + /// tombstone block, etc., for the transaction to be valid. + /// + /// Note: Before adding a signed_contingent_input, you probably want to: + /// * validate it (call .validate()) + /// * check if key image appreared already (call .key_image()) + /// * provide merkle proofs of membership for each ring member (see + /// .tx_out_global_indices) + /// + /// # Arguments + /// * `signed_contingent_input` - The pre-signed input we are adding + pub fn add_presigned_input_raw(&mut self, sci: SignedContingentInput) { + self.input_materials.push(InputMaterials::Presigned(sci)); } /// Add a non-change output to the transaction. @@ -282,10 +352,16 @@ impl TransactionBuilder { let (tx_out, shared_secret) = create_output_with_fog_hint(self.block_version, amount, recipient, hint, memo_fn, rng)?; + let (amount, blinding) = tx_out + .masked_amount + .get_value(&shared_secret) + .expect("TransactionBuilder created an invalid Amount"); + let output_secret = OutputSecret { amount, blinding }; + self.impose_tombstone_block_limit(pubkey_expiry); - self.outputs_and_shared_secrets - .push((tx_out.clone(), shared_secret)); + self.outputs_and_secrets + .push((tx_out.clone(), output_secret)); let confirmation = TxOutConfirmationNumber::from(&shared_secret); @@ -386,78 +462,76 @@ impl TransactionBuilder { )); } - if self.input_credentials.is_empty() { + if self.input_materials.is_empty() { return Err(TxBuilderError::NoInputs); } // All inputs must have rings of the same size. if self - .input_credentials + .input_materials .windows(2) - .any(|win| win[0].ring.len() != win[1].ring.len()) + .any(|win| win[0].ring_size() != win[1].ring_size()) { return Err(TxBuilderError::InvalidRingSize); } + for input in self.input_materials.iter() { + if !self.block_version.mixed_transactions_are_supported() { + if input.amount().token_id != self.fee.token_id { + return Err(TxBuilderError::MixedTransactionsNotAllowed( + self.fee.token_id, + input.amount().token_id, + )); + } + } + + match input { + InputMaterials::Presigned(input) => { + if !self.block_version.signed_input_rules_are_supported() { + return Err(TxBuilderError::SignedInputRulesNotAllowed); + } + // TODO: Also validate membership proofs? + if input.tx_in.ring.len() != input.tx_in.proofs.len() { + return Err(TxBuilderError::MissingMembershipProofs); + } + } + InputMaterials::Signable(input) => { + // TODO: Also validate membership proofs? + if input.ring.len() != input.membership_proofs.len() { + return Err(TxBuilderError::MissingMembershipProofs); + } + } + } + } + // Construct a list of sorted inputs. // Inputs are sorted by the first ring element's public key. Note that each ring // is also sorted. - self.input_credentials - .sort_by(|a, b| a.ring[0].public_key.cmp(&b.ring[0].public_key)); - - let inputs: Vec = self - .input_credentials - .iter() - .map(|input_credential| TxIn { - ring: input_credential.ring.clone(), - proofs: input_credential.membership_proofs.clone(), - input_rules: None, - }) - .collect(); - - self.outputs_and_shared_secrets + self.input_materials + .sort_by(|a, b| a.sort_key().cmp(&b.sort_key())); + + let inputs: Vec = self.input_materials.iter().map(TxIn::from).collect(); + + // Outputs are sorted according to the rule (but generally by public key) + self.outputs_and_secrets .sort_by(|(a, _), (b, _)| O::cmp(&a.public_key, &b.public_key)); - let output_secrets: Vec = self - .outputs_and_shared_secrets - .iter() - .map(|(tx_out, shared_secret)| { - let masked_amount = &tx_out.masked_amount; - let (amount, blinding) = masked_amount - .get_value(shared_secret) - .expect("TransactionBuilder created an invalid Amount"); - OutputSecret { amount, blinding } - }) - .collect(); - - let (outputs, _shared_secrets): (Vec, Vec<_>) = - self.outputs_and_shared_secrets.drain(..).unzip(); + let (outputs, output_secrets): (Vec, Vec<_>) = + self.outputs_and_secrets.drain(..).unzip(); let tx_prefix = TxPrefix::new(inputs, outputs, self.fee, self.tombstone_block); - let signable_input_rings = self - .input_credentials - .iter() - .map(|cred| { - let result = SignableInputRing::try_from(cred)?; - - if !self.block_version.mixed_transactions_are_supported() - && result.input_secret.amount.token_id != self.fee.token_id - { - return Err(TxBuilderError::MixedTransactionsNotAllowed( - self.fee.token_id, - result.input_secret.amount.token_id, - )); - } - Ok(result) - }) - .collect::, _>>()?; + let input_rings = self + .input_materials + .into_iter() + .map(Into::into) + .collect::>(); let message = tx_prefix.hash().0; let signature = SignatureRctBulletproofs::sign( self.block_version, &message, - &signable_input_rings, + &input_rings, &output_secrets, self.fee, rng, @@ -586,7 +660,7 @@ pub mod transaction_builder_tests { get_input_credentials(block_version, amount, &sender, &fpr, &mut rng); let membership_proofs = input_credentials.membership_proofs.clone(); - let key_image = KeyImage::from(&input_credentials.onetime_private_key); + let key_image = KeyImage::from(&input_credentials.input_secret.onetime_private_key); let mut transaction_builder = TransactionBuilder::new( block_version, @@ -668,7 +742,7 @@ pub mod transaction_builder_tests { get_input_credentials(block_version, amount, &sender, &fog_resolver, &mut rng); let membership_proofs = input_credentials.membership_proofs.clone(); - let key_image = KeyImage::from(&input_credentials.onetime_private_key); + let key_image = KeyImage::from(&input_credentials.input_secret.onetime_private_key); let mut transaction_builder = TransactionBuilder::new( block_version,