diff --git a/crates/cnidarium/src/gen/proto_descriptor.bin.no_lfs b/crates/cnidarium/src/gen/proto_descriptor.bin.no_lfs index 09181ca63e..cb5f8b9377 100644 Binary files a/crates/cnidarium/src/gen/proto_descriptor.bin.no_lfs and b/crates/cnidarium/src/gen/proto_descriptor.bin.no_lfs differ diff --git a/crates/core/asset/src/balance.rs b/crates/core/asset/src/balance.rs index d41be59b55..8b8e290dba 100644 --- a/crates/core/asset/src/balance.rs +++ b/crates/core/asset/src/balance.rs @@ -1,9 +1,9 @@ +use anyhow::anyhow; use ark_r1cs_std::prelude::*; use ark_r1cs_std::uint8::UInt8; use ark_relations::r1cs::SynthesisError; use penumbra_num::{Amount, AmountVar}; use serde::{Deserialize, Serialize}; -use serde_with::serde_as; use std::{ collections::{btree_map, BTreeMap}, fmt::{self, Debug, Formatter}, @@ -26,18 +26,86 @@ mod imbalance; mod iter; use commitment::VALUE_BLINDING_GENERATOR; use decaf377::{r1cs::ElementVar, Fq, Fr}; -use imbalance::Imbalance; +use imbalance::{Imbalance, Sign}; use self::commitment::BalanceCommitmentVar; +use penumbra_proto::{penumbra::core::asset::v1 as pb, DomainType}; /// A `Balance` is a "vector of [`Value`]s", where some values may be required, while others may be /// provided. For a transaction to be valid, its balance must be zero. -#[serde_as] #[derive(Clone, Eq, Default, Serialize, Deserialize)] +#[serde(try_from = "pb::Balance", into = "pb::Balance")] pub struct Balance { - negated: bool, - #[serde_as(as = "Vec<(_, _)>")] - balance: BTreeMap>, + pub negated: bool, + pub balance: BTreeMap>, +} + +impl Balance { + fn from_signed_value(negated: bool, value: Value) -> Option { + let non_zero = NonZeroU128::try_from(value.amount.value()).ok()?; + Some(Self { + negated: false, + balance: BTreeMap::from([( + value.asset_id, + if negated { + Imbalance::Required(non_zero) + } else { + Imbalance::Provided(non_zero) + }, + )]), + }) + } +} + +impl DomainType for Balance { + type Proto = pb::Balance; +} + +/// Serialization should normalize the `Balance`, where the top-level +/// negated field is excluded during serialization. Rather, the +/// sign information is captured in the `SignedValue` pairs. +/// +/// Since the underlying BTreeMap can't hold multiple imbalances for +/// the same asset ID, we implement an ordering-agnostic accumulation +/// scheme that explicitly combines imbalances. +impl TryFrom for Balance { + type Error = anyhow::Error; + + fn try_from(balance: pb::Balance) -> Result { + let mut out = Self::default(); + for v in balance.values { + let value = v.value.ok_or_else(|| anyhow!("missing value"))?; + if let Some(b) = Balance::from_signed_value(v.negated, Value::try_from(value)?) { + out += b; + } + } + Ok(out) + } +} + +impl From for pb::Balance { + fn from(v: Balance) -> Self { + let values = v + .balance + .into_iter() + .map(|(id, imbalance)| { + // Decompose imbalance into it sign and magnitude, and convert + // magnitude into raw amount and determine negation based on the sign. + let (sign, magnitude) = if v.negated { -imbalance } else { imbalance }.into_inner(); + let amount = u128::from(magnitude); + + pb::balance::SignedValue { + value: Some(pb::Value { + asset_id: Some(id.into()), + amount: Some(Amount::from(amount).into()), + }), + negated: matches!(sign, Sign::Required), + } + }) + .collect(); + + pb::Balance { values } + } } impl Debug for Balance { @@ -395,11 +463,16 @@ impl std::ops::Add for BalanceVar { #[cfg(test)] mod test { - use crate::{asset::Metadata, STAKING_TOKEN_ASSET_ID}; + use crate::{ + asset::{self, Metadata}, + STAKING_TOKEN_ASSET_ID, + }; use ark_ff::Zero; use decaf377::Fr; use once_cell::sync::Lazy; + use penumbra_proto::core::num::v1::Amount as ProtoAmount; use proptest::prelude::*; + use rand_core::OsRng; use super::*; @@ -562,4 +635,300 @@ mod test { assert_eq!(commitment, balance_commitment); } } + + /// Implement fallible conversion (protobuf to domain type) for multiple entries + /// with the same asset ID. + #[test] + fn try_from_fallible_conversion_same_asset_id() { + let proto_balance_0 = pb::Balance { + values: vec![ + pb::balance::SignedValue { + value: Some(pb::Value { + asset_id: Some((*STAKING_TOKEN_ASSET_ID).into()), + amount: Some(Amount::from(100u128).into()), + }), + negated: true, + }, + pb::balance::SignedValue { + value: Some(pb::Value { + asset_id: Some((*STAKING_TOKEN_ASSET_ID).into()), + amount: Some(Amount::from(50u128).into()), + }), + negated: false, + }, + ], + }; + + let proto_balance_1 = pb::Balance { + values: vec![ + pb::balance::SignedValue { + value: Some(pb::Value { + asset_id: Some((*STAKING_TOKEN_ASSET_ID).into()), + amount: Some(Amount::from(100u128).into()), + }), + negated: true, + }, + pb::balance::SignedValue { + value: Some(pb::Value { + asset_id: Some((*STAKING_TOKEN_ASSET_ID).into()), + amount: Some(Amount::from(200u128).into()), + }), + negated: false, + }, + ], + }; + + let proto_balance_2 = pb::Balance { + values: vec![ + pb::balance::SignedValue { + value: Some(pb::Value { + asset_id: Some((*STAKING_TOKEN_ASSET_ID).into()), + amount: Some(Amount::from(100u128).into()), + }), + negated: true, + }, + pb::balance::SignedValue { + value: Some(pb::Value { + asset_id: Some((*STAKING_TOKEN_ASSET_ID).into()), + amount: Some(Amount::from(200u128).into()), + }), + negated: true, + }, + ], + }; + + let proto_balance_3 = pb::Balance { + values: vec![ + pb::balance::SignedValue { + value: Some(pb::Value { + asset_id: Some((*STAKING_TOKEN_ASSET_ID).into()), + amount: Some(Amount::from(100u128).into()), + }), + negated: false, + }, + pb::balance::SignedValue { + value: Some(pb::Value { + asset_id: Some((*STAKING_TOKEN_ASSET_ID).into()), + amount: Some(Amount::from(50u128).into()), + }), + negated: true, + }, + ], + }; + + let proto_balance_4 = pb::Balance { + values: vec![ + pb::balance::SignedValue { + value: Some(pb::Value { + asset_id: Some((*STAKING_TOKEN_ASSET_ID).into()), + amount: Some(Amount::from(100u128).into()), + }), + negated: false, + }, + pb::balance::SignedValue { + value: Some(pb::Value { + asset_id: Some((*STAKING_TOKEN_ASSET_ID).into()), + amount: Some(Amount::from(200u128).into()), + }), + negated: true, + }, + ], + }; + + let proto_balance_5 = pb::Balance { + values: vec![ + pb::balance::SignedValue { + value: Some(pb::Value { + asset_id: Some((*STAKING_TOKEN_ASSET_ID).into()), + amount: Some(Amount::from(100u128).into()), + }), + negated: false, + }, + pb::balance::SignedValue { + value: Some(pb::Value { + asset_id: Some((*STAKING_TOKEN_ASSET_ID).into()), + amount: Some(Amount::from(200u128).into()), + }), + negated: false, + }, + ], + }; + + let balance_0 = Balance::try_from(proto_balance_0).expect("fallible conversion"); + let balance_1 = Balance::try_from(proto_balance_1).expect("fallible conversion"); + let balance_2 = Balance::try_from(proto_balance_2).expect("fallible conversion"); + let balance_3 = Balance::try_from(proto_balance_3).expect("fallible conversion"); + let balance_4 = Balance::try_from(proto_balance_4).expect("fallible conversion"); + let balance_5 = Balance::try_from(proto_balance_5).expect("fallible conversion"); + + assert!(matches!( + balance_0.balance.get(&STAKING_TOKEN_ASSET_ID), + Some(Imbalance::Required(amount)) if amount == &NonZeroU128::new(50).unwrap() + )); + + assert!(matches!( + balance_1.balance.get(&STAKING_TOKEN_ASSET_ID), + Some(Imbalance::Provided(amount)) if amount == &NonZeroU128::new(100).unwrap() + )); + + assert!(matches!( + balance_2.balance.get(&STAKING_TOKEN_ASSET_ID), + Some(Imbalance::Required(amount)) if amount == &NonZeroU128::new(300).unwrap() + )); + + assert!(matches!( + balance_3.balance.get(&STAKING_TOKEN_ASSET_ID), + Some(Imbalance::Provided(amount)) if amount == &NonZeroU128::new(50).unwrap() + )); + + assert!(matches!( + balance_4.balance.get(&STAKING_TOKEN_ASSET_ID), + Some(Imbalance::Required(amount)) if amount == &NonZeroU128::new(100).unwrap() + )); + + assert!(matches!( + balance_5.balance.get(&STAKING_TOKEN_ASSET_ID), + Some(Imbalance::Provided(amount)) if amount == &NonZeroU128::new(300).unwrap() + )); + } + + /// Implement fallible conversion (protobuf to domain type) for multiple entries + /// with different asset IDs. + #[test] + fn try_from_fallible_conversion_different_asset_id() { + let rand_asset_id = Id(Fq::rand(&mut OsRng)); + + let proto_balance = pb::Balance { + values: vec![ + pb::balance::SignedValue { + value: Some(pb::Value { + asset_id: Some((*STAKING_TOKEN_ASSET_ID).into()), + amount: Some(Amount::from(100u128).into()), + }), + negated: true, + }, + pb::balance::SignedValue { + value: Some(pb::Value { + asset_id: Some(rand_asset_id.into()), + amount: Some(Amount::from(50u128).into()), + }), + negated: false, + }, + ], + }; + + let balance = Balance::try_from(proto_balance).expect("fallible conversion"); + + assert!(matches!( + balance.balance.get(&STAKING_TOKEN_ASSET_ID), + Some(Imbalance::Required(amount)) if amount == &NonZeroU128::new(100).unwrap() + )); + + assert!(matches!( + balance.balance.get(&rand_asset_id), + Some(Imbalance::Provided(amount)) if amount == &NonZeroU128::new(50).unwrap() + )); + } + + /// Implement fallible conversion (protobuf to domain type) with missing fields. + #[test] + fn try_from_fallible_conversion_failure() { + let proto_balance = pb::Balance { + values: vec![pb::balance::SignedValue { + value: None, + negated: false, + }], + }; + + assert!(Balance::try_from(proto_balance).is_err()); + } + + /// Implement infallible conversion (domain type to protobuf). + #[test] + fn from_infallible_conversion() { + let rand_asset_id = Id(Fq::rand(&mut OsRng)); + + let balance = Balance { + negated: false, + balance: [ + ( + *STAKING_TOKEN_ASSET_ID, + Imbalance::Provided(NonZeroU128::new(100).unwrap()), + ), + ( + rand_asset_id, + Imbalance::Required(NonZeroU128::new(200).unwrap()), + ), + ] + .iter() + .cloned() + .collect(), + }; + + let proto_balance: pb::Balance = balance.into(); + + let first_value = proto_balance + .values + .iter() + .find(|v| v.value.as_ref().unwrap().asset_id == Some((*STAKING_TOKEN_ASSET_ID).into())) + .expect("asset should exist"); + let second_value = proto_balance + .values + .iter() + .find(|v| v.value.as_ref().unwrap().asset_id == Some(rand_asset_id.into())) + .expect("asset should exist"); + + assert_eq!(proto_balance.values.len(), 2); + + assert_eq!( + first_value.value.as_ref().unwrap().asset_id, + Some((*STAKING_TOKEN_ASSET_ID).into()) + ); + assert_eq!( + second_value.value.as_ref().unwrap().asset_id, + Some(rand_asset_id.into()) + ); + + let proto_amount: ProtoAmount = Amount::from(100u128).into(); + assert_eq!( + first_value.value.as_ref().unwrap().amount, + Some(proto_amount) + ); + + let proto_amount: ProtoAmount = Amount::from(200u128).into(); + assert_eq!( + second_value.value.as_ref().unwrap().amount, + Some(proto_amount) + ); + } + + fn test_balance_serialization_round_tripping_example( + parts: Vec<(u8, i64)>, + ) -> anyhow::Result<()> { + let proto = pb::Balance { + values: parts + .into_iter() + .map(|(asset, amount)| pb::balance::SignedValue { + value: Some(pb::Value { + amount: Some(Amount::from(amount.abs() as u64).into()), + asset_id: Some(asset::Id::try_from([asset; 32]).unwrap().into()), + }), + negated: amount < 0, + }) + .collect(), + }; + let p_to_d = Balance::try_from(proto)?; + let p_to_d_to_p = pb::Balance::from(p_to_d.clone()); + let p_to_d_to_p_to_d = Balance::try_from(p_to_d_to_p)?; + assert_eq!(p_to_d, p_to_d_to_p_to_d); + Ok(()) + } + + proptest! { + #[test] + fn test_balance_serialization_roundtripping(parts in proptest::collection::vec(((0u8..16u8), any::()), 0..100)) { + // To get better errors + assert!(matches!(test_balance_serialization_round_tripping_example(parts), Ok(_))); + } + } } diff --git a/crates/core/keys/src/address/view.rs b/crates/core/keys/src/address/view.rs index fd1cf6db0a..932d80368a 100644 --- a/crates/core/keys/src/address/view.rs +++ b/crates/core/keys/src/address/view.rs @@ -1,3 +1,5 @@ +use std::cmp::Ordering; + use serde::{Deserialize, Serialize}; use penumbra_proto::{penumbra::core::keys::v1 as pb, DomainType}; @@ -99,6 +101,76 @@ impl TryFrom for AddressView { } } +// Canonical ordering for serialization +impl PartialOrd for AddressView { + fn partial_cmp(&self, other: &Self) -> Option { + use AddressView::*; + // Opaque < Decoded + match (self, other) { + (Opaque { .. }, Decoded { .. }) => Some(Ordering::Less), + (Decoded { .. }, Opaque { .. }) => Some(Ordering::Greater), + (Opaque { address: a1 }, Opaque { address: a2 }) => a1.partial_cmp(a2), + ( + Decoded { + address: a1, + index: i1, + wallet_id: w1, + }, + Decoded { + address: a2, + index: i2, + wallet_id: w2, + }, + ) => (a1, i1, w1).partial_cmp(&(a2, i2, w2)), + } + } +} + +impl Ord for AddressView { + fn cmp(&self, other: &Self) -> Ordering { + // Opaque < Decoded + match (self, other) { + (AddressView::Opaque { address: a1 }, AddressView::Opaque { address: a2 }) => { + a1.cmp(a2) + } + ( + AddressView::Decoded { + address: a1, + index: i1, + wallet_id: w1, + }, + AddressView::Decoded { + address: a2, + index: i2, + wallet_id: w2, + }, + ) => match a1.cmp(a2) { + Ordering::Equal => match i1.cmp(i2) { + Ordering::Equal => w1.cmp(w2), + ord => ord, + }, + ord => ord, + }, + ( + AddressView::Opaque { address: _ }, + AddressView::Decoded { + address: _, + index: _, + wallet_id: _, + }, + ) => Ordering::Less, + ( + AddressView::Decoded { + address: _, + index: _, + wallet_id: _, + }, + AddressView::Opaque { address: _ }, + ) => Ordering::Greater, + } + } +} + #[cfg(test)] mod tests { use rand_core::OsRng; diff --git a/crates/core/transaction/src/transaction.rs b/crates/core/transaction/src/transaction.rs index f3ff17e8e4..39a7113248 100644 --- a/crates/core/transaction/src/transaction.rs +++ b/crates/core/transaction/src/transaction.rs @@ -7,6 +7,7 @@ use anyhow::{Context, Error}; use ark_ff::Zero; use decaf377::Fr; use decaf377_rdsa::{Binding, Signature, VerificationKey, VerificationKeyBytes}; +use penumbra_asset::Balance; use penumbra_community_pool::{CommunityPoolDeposit, CommunityPoolOutput, CommunityPoolSpend}; use penumbra_dex::{ lp::action::{PositionClose, PositionOpen}, @@ -14,7 +15,7 @@ use penumbra_dex::{ }; use penumbra_governance::{DelegatorVote, ProposalSubmit, ProposalWithdraw, ValidatorVote}; use penumbra_ibc::IbcRelay; -use penumbra_keys::{FullViewingKey, PayloadKey}; +use penumbra_keys::{AddressView, FullViewingKey, PayloadKey}; use penumbra_proto::{ core::transaction::v1::{self as pbt}, DomainType, Message, @@ -44,6 +45,20 @@ pub struct TransactionBody { pub memo: Option, } +/// Represents a transaction summary containing multiple effects. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(try_from = "pbt::TransactionSummary", into = "pbt::TransactionSummary")] +pub struct TransactionSummary { + pub effects: Vec, +} + +/// Represents an individual effect of a transaction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransactionEffect { + pub address: AddressView, + pub balance: Balance, +} + impl EffectingData for TransactionBody { fn effect_hash(&self) -> EffectHash { let mut state = blake2b_simd::Params::new() @@ -591,6 +606,50 @@ impl Transaction { } } +impl DomainType for TransactionSummary { + type Proto = pbt::TransactionSummary; +} + +impl From for pbt::TransactionSummary { + fn from(summary: TransactionSummary) -> Self { + pbt::TransactionSummary { + effects: summary + .effects + .into_iter() + .map(|effect| pbt::transaction_summary::Effects { + address: Some(effect.address.into()), + balance: Some(effect.balance.into()), + }) + .collect(), + } + } +} + +impl TryFrom for TransactionSummary { + type Error = anyhow::Error; + + fn try_from(pbt: pbt::TransactionSummary) -> Result { + let effects = pbt + .effects + .into_iter() + .map(|effect| { + Ok(TransactionEffect { + address: effect + .address + .ok_or_else(|| anyhow::anyhow!("missing address field"))? + .try_into()?, + balance: effect + .balance + .ok_or_else(|| anyhow::anyhow!("missing balance field"))? + .try_into()?, + }) + }) + .collect::, anyhow::Error>>()?; + + Ok(Self { effects }) + } +} + impl DomainType for TransactionBody { type Proto = pbt::TransactionBody; } @@ -662,7 +721,12 @@ impl From for pbt::Transaction { impl From<&Transaction> for pbt::Transaction { fn from(msg: &Transaction) -> Self { - msg.into() + Transaction { + transaction_body: msg.transaction_body.clone(), + anchor: msg.anchor.clone(), + binding_sig: msg.binding_sig.clone(), + } + .into() } } diff --git a/crates/core/transaction/src/view.rs b/crates/core/transaction/src/view.rs index 91563b136b..14cbe2c915 100644 --- a/crates/core/transaction/src/view.rs +++ b/crates/core/transaction/src/view.rs @@ -1,8 +1,10 @@ use anyhow::Context; use decaf377_rdsa::{Binding, Signature}; +use penumbra_asset::{Balance, Value}; +use penumbra_dex::{swap::SwapView, swap_claim::SwapClaimView}; use penumbra_keys::AddressView; use penumbra_proto::{core::transaction::v1 as pbt, DomainType}; - +use penumbra_shielded_pool::{OutputView, SpendView}; use serde::{Deserialize, Serialize}; pub mod action_view; @@ -13,8 +15,9 @@ use penumbra_tct as tct; pub use transaction_perspective::TransactionPerspective; use crate::{ - memo::MemoCiphertext, Action, DetectionData, Transaction, TransactionBody, - TransactionParameters, + memo::MemoCiphertext, + transaction::{TransactionEffect, TransactionSummary}, + Action, DetectionData, Transaction, TransactionBody, TransactionParameters, }; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -94,6 +97,136 @@ impl TransactionView { pub fn action_views(&self) -> impl Iterator { self.body_view.action_views.iter() } + + /// Acts as a higher-order translator that summarizes a TransactionSummary by consolidating + /// effects for each unique address. + fn accumulate_effects(summary: TransactionSummary) -> TransactionSummary { + use std::collections::BTreeMap; + let mut keyed_effects: BTreeMap = BTreeMap::new(); + for effect in summary.effects { + *keyed_effects.entry(effect.address).or_default() += effect.balance; + } + TransactionSummary { + effects: keyed_effects + .into_iter() + .map(|(address, balance)| TransactionEffect { address, balance }) + .collect(), + } + } + + /// Produces a TransactionSummary, iterating through each visible action and collecting the effects of the transaction. + pub fn summary(&self) -> TransactionSummary { + let mut effects = Vec::new(); + + for action_view in &self.body_view.action_views { + match action_view { + ActionView::Spend(spend_view) => match spend_view { + SpendView::Visible { spend: _, note } => { + // Provided imbalance (+) + let balance = Balance::from(note.value.value()); + + let address = AddressView::Opaque { + address: note.address(), + }; + + effects.push(TransactionEffect { address, balance }); + } + SpendView::Opaque { spend: _ } => continue, + }, + ActionView::Output(output_view) => match output_view { + OutputView::Visible { + output: _, + note, + payload_key: _, + } => { + // Required imbalance (-) + let balance = -Balance::from(note.value.value()); + + let address = AddressView::Opaque { + address: note.address(), + }; + + effects.push(TransactionEffect { address, balance }); + } + OutputView::Opaque { output: _ } => continue, + }, + ActionView::Swap(swap_view) => match swap_view { + SwapView::Visible { + swap: _, + swap_plaintext, + output_1, + output_2: _, + claim_tx: _, + asset_1_metadata: _, + asset_2_metadata: _, + batch_swap_output_data: _, + } => { + let address = AddressView::Opaque { + address: output_1.clone().expect("sender address").address(), + }; + + let value_fee = Value { + amount: swap_plaintext.claim_fee.amount(), + asset_id: swap_plaintext.claim_fee.asset_id(), + }; + let value_1 = Value { + amount: swap_plaintext.delta_1_i, + asset_id: swap_plaintext.trading_pair.asset_1(), + }; + let value_2 = Value { + amount: swap_plaintext.delta_2_i, + asset_id: swap_plaintext.trading_pair.asset_2(), + }; + + // Required imbalance (-) + let mut balance = Balance::default(); + balance -= value_1; + balance -= value_2; + balance -= value_fee; + + effects.push(TransactionEffect { address, balance }); + } + SwapView::Opaque { + swap: _, + batch_swap_output_data: _, + output_1: _, + output_2: _, + asset_1_metadata: _, + asset_2_metadata: _, + } => continue, + }, + ActionView::SwapClaim(swap_claim_view) => match swap_claim_view { + SwapClaimView::Visible { + swap_claim, + output_1, + output_2: _, + swap_tx: _, + } => { + let address = AddressView::Opaque { + address: output_1.address(), + }; + + let value_fee = Value { + amount: swap_claim.body.fee.amount(), + asset_id: swap_claim.body.fee.asset_id(), + }; + + // Provided imbalance (+) + let mut balance = Balance::default(); + balance += value_fee; + + effects.push(TransactionEffect { address, balance }); + } + SwapClaimView::Opaque { swap_claim: _ } => continue, + }, + _ => {} // Fill in other action views as neccessary + } + } + + let summary = TransactionSummary { effects }; + + Self::accumulate_effects(summary) + } } impl DomainType for TransactionView { @@ -294,3 +427,458 @@ impl TryFrom for MemoPlaintextView { }) } } + +#[cfg(test)] +mod test { + use super::*; + + use decaf377::Fr; + use decaf377::{Element, Fq}; + use decaf377_rdsa::{Domain, VerificationKey}; + use penumbra_asset::{ + asset::{self, Cache, Id}, + balance::Commitment, + STAKING_TOKEN_ASSET_ID, + }; + use penumbra_dex::swap::proof::SwapProof; + use penumbra_dex::swap::{SwapCiphertext, SwapPayload}; + use penumbra_dex::Swap; + use penumbra_dex::{ + swap::{SwapPlaintext, SwapPlan}, + TradingPair, + }; + use penumbra_fee::Fee; + use penumbra_keys::keys::Bip44Path; + use penumbra_keys::keys::{SeedPhrase, SpendKey}; + use penumbra_keys::{ + symmetric::{OvkWrappedKey, WrappedMemoKey}, + test_keys, Address, FullViewingKey, PayloadKey, + }; + use penumbra_num::Amount; + use penumbra_proof_params::GROTH16_PROOF_LENGTH_BYTES; + use penumbra_sct::Nullifier; + use penumbra_shielded_pool::Rseed; + use penumbra_shielded_pool::{output, spend, Note, NoteView, OutputPlan, SpendPlan}; + use penumbra_tct::structure::Hash; + use penumbra_tct::StateCommitment; + use rand_core::OsRng; + use std::ops::Deref; + + use crate::{ + plan::{CluePlan, DetectionDataPlan}, + view, ActionPlan, TransactionPlan, + }; + + #[cfg(test)] + fn dummy_sig() -> Signature { + Signature::from([0u8; 64]) + } + + #[cfg(test)] + fn dummy_pk() -> VerificationKey { + VerificationKey::try_from(Element::default().vartime_compress().0) + .expect("creating a dummy verification key should work") + } + + #[cfg(test)] + fn dummy_commitment() -> Commitment { + Commitment(Element::default()) + } + + #[cfg(test)] + fn dummy_proof_spend() -> spend::SpendProof { + spend::SpendProof::try_from( + penumbra_proto::penumbra::core::component::shielded_pool::v1::ZkSpendProof { + inner: vec![0u8; GROTH16_PROOF_LENGTH_BYTES], + }, + ) + .expect("creating a dummy proof should work") + } + + #[cfg(test)] + fn dummy_proof_output() -> output::OutputProof { + output::OutputProof::try_from( + penumbra_proto::penumbra::core::component::shielded_pool::v1::ZkOutputProof { + inner: vec![0u8; GROTH16_PROOF_LENGTH_BYTES], + }, + ) + .expect("creating a dummy proof should work") + } + + #[cfg(test)] + fn dummy_proof_swap() -> SwapProof { + SwapProof::try_from( + penumbra_proto::penumbra::core::component::dex::v1::ZkSwapProof { + inner: vec![0u8; GROTH16_PROOF_LENGTH_BYTES], + }, + ) + .expect("creating a dummy proof should work") + } + + #[cfg(test)] + fn dummy_spend() -> spend::Spend { + spend::Spend { + body: spend::Body { + balance_commitment: dummy_commitment(), + nullifier: Nullifier(Fq::default()), + rk: dummy_pk(), + }, + auth_sig: dummy_sig(), + proof: dummy_proof_spend(), + } + } + + #[cfg(test)] + fn dummy_output() -> output::Output { + output::Output { + body: output::Body { + note_payload: penumbra_shielded_pool::NotePayload { + note_commitment: penumbra_shielded_pool::note::StateCommitment(Fq::default()), + ephemeral_key: [0u8; 32] + .as_slice() + .try_into() + .expect("can create dummy ephemeral key"), + encrypted_note: penumbra_shielded_pool::NoteCiphertext([0u8; 176]), + }, + balance_commitment: dummy_commitment(), + ovk_wrapped_key: OvkWrappedKey([0u8; 48]), + wrapped_memo_key: WrappedMemoKey([0u8; 48]), + }, + proof: dummy_proof_output(), + } + } + + #[cfg(test)] + fn dummy_swap_plaintext() -> SwapPlaintext { + let seed_phrase = SeedPhrase::generate(OsRng); + let sk_recipient = SpendKey::from_seed_phrase_bip44(seed_phrase, &Bip44Path::new(0)); + let fvk_recipient = sk_recipient.full_viewing_key(); + let ivk_recipient = fvk_recipient.incoming(); + let (claim_address, _dtk_d) = ivk_recipient.payment_address(0u32.into()); + + let gm = asset::Cache::with_known_assets().get_unit("gm").unwrap(); + let gn = asset::Cache::with_known_assets().get_unit("gn").unwrap(); + let trading_pair = TradingPair::new(gm.id(), gn.id()); + + let delta_1 = Amount::from(1u64); + let delta_2 = Amount::from(0u64); + let fee = Fee::default(); + + let swap_plaintext = SwapPlaintext::new( + &mut OsRng, + trading_pair, + delta_1, + delta_2, + fee, + claim_address, + ); + + swap_plaintext + } + + #[cfg(test)] + fn dummy_swap() -> Swap { + use penumbra_dex::swap::Body; + + let seed_phrase = SeedPhrase::generate(OsRng); + let sk_recipient = SpendKey::from_seed_phrase_bip44(seed_phrase, &Bip44Path::new(0)); + let fvk_recipient = sk_recipient.full_viewing_key(); + let ivk_recipient = fvk_recipient.incoming(); + let (claim_address, _dtk_d) = ivk_recipient.payment_address(0u32.into()); + + let gm = asset::Cache::with_known_assets().get_unit("gm").unwrap(); + let gn = asset::Cache::with_known_assets().get_unit("gn").unwrap(); + let trading_pair = TradingPair::new(gm.id(), gn.id()); + + let delta_1 = Amount::from(1u64); + let delta_2 = Amount::from(0u64); + let fee = Fee::default(); + + let swap_plaintext = SwapPlaintext::new( + &mut OsRng, + trading_pair, + delta_1, + delta_2, + fee, + claim_address, + ); + + let fee_blinding = Fr::from(0u64); + let fee_commitment = swap_plaintext.claim_fee.commit(fee_blinding); + + let swap_payload = SwapPayload { + encrypted_swap: SwapCiphertext([0u8; 272]), + commitment: StateCommitment::try_from([0; 32]).expect("state commitment"), + }; + + Swap { + body: Body { + trading_pair: trading_pair, + delta_1_i: delta_1, + delta_2_i: delta_2, + fee_commitment: fee_commitment, + payload: swap_payload, + }, + proof: dummy_proof_swap(), + } + } + + #[cfg(test)] + fn dummy_note_view( + address: Address, + value: Value, + cache: &Cache, + fvk: &FullViewingKey, + ) -> NoteView { + let note = Note::from_parts(address, value, Rseed::generate(&mut OsRng)) + .expect("generate dummy note"); + + NoteView { + value: note.value().view_with_cache(cache), + rseed: note.rseed(), + address: fvk.view_address(note.address()), + } + } + + #[cfg(test)] + fn convert_note(cache: &Cache, fvk: &FullViewingKey, note: &Note) -> NoteView { + NoteView { + value: note.value().view_with_cache(cache), + rseed: note.rseed(), + address: fvk.view_address(note.address()), + } + } + + #[cfg(test)] + fn convert_action( + cache: &Cache, + fvk: &FullViewingKey, + action: &ActionPlan, + ) -> Option { + use view::action_view::SpendView; + + match action { + ActionPlan::Output(x) => Some(ActionView::Output( + penumbra_shielded_pool::OutputView::Visible { + output: dummy_output(), + note: convert_note(cache, fvk, &x.output_note()), + payload_key: PayloadKey::from([0u8; 32]), + }, + )), + ActionPlan::Spend(x) => Some(ActionView::Spend(SpendView::Visible { + spend: dummy_spend(), + note: convert_note(cache, fvk, &x.note), + })), + ActionPlan::ValidatorDefinition(_) => None, + ActionPlan::Swap(x) => Some(ActionView::Swap(SwapView::Visible { + swap: dummy_swap(), + swap_plaintext: dummy_swap_plaintext(), + output_1: Some(dummy_note_view( + x.swap_plaintext.claim_address.clone(), + x.swap_plaintext.claim_fee.0, + cache, + fvk, + )), + output_2: None, + claim_tx: None, + asset_1_metadata: None, + asset_2_metadata: None, + batch_swap_output_data: None, + })), + ActionPlan::SwapClaim(_) => None, + ActionPlan::ProposalSubmit(_) => None, + ActionPlan::ProposalWithdraw(_) => None, + ActionPlan::DelegatorVote(_) => None, + ActionPlan::ValidatorVote(_) => None, + ActionPlan::ProposalDepositClaim(_) => None, + ActionPlan::PositionOpen(_) => None, + ActionPlan::PositionClose(_) => None, + ActionPlan::PositionWithdraw(_) => None, + ActionPlan::Delegate(_) => None, + ActionPlan::Undelegate(_) => None, + ActionPlan::UndelegateClaim(_) => None, + ActionPlan::Ics20Withdrawal(_) => None, + ActionPlan::CommunityPoolSpend(_) => None, + ActionPlan::CommunityPoolOutput(_) => None, + ActionPlan::CommunityPoolDeposit(_) => None, + ActionPlan::ActionDutchAuctionSchedule(_) => None, + ActionPlan::ActionDutchAuctionEnd(_) => None, + ActionPlan::ActionDutchAuctionWithdraw(_) => None, + ActionPlan::IbcAction(_) => todo!(), + } + } + + #[test] + fn test_internal_transfer_transaction_summary() { + // Generate two notes controlled by the test address. + let value = Value { + amount: 100u64.into(), + asset_id: *STAKING_TOKEN_ASSET_ID, + }; + let note = Note::generate(&mut OsRng, &test_keys::ADDRESS_0, value); + + let value2 = Value { + amount: 50u64.into(), + asset_id: Id(Fq::rand(&mut OsRng)), + }; + let note2 = Note::generate(&mut OsRng, &test_keys::ADDRESS_0, value2); + + let value3 = Value { + amount: 75u64.into(), + asset_id: *STAKING_TOKEN_ASSET_ID, + }; + + // Record that note in an SCT, where we can generate an auth path. + let mut sct = tct::Tree::new(); + for _ in 0..5 { + let random_note = Note::generate(&mut OsRng, &test_keys::ADDRESS_0, value); + sct.insert(tct::Witness::Keep, random_note.commit()) + .unwrap(); + } + sct.insert(tct::Witness::Keep, note.commit()).unwrap(); + sct.insert(tct::Witness::Keep, note2.commit()).unwrap(); + + let auth_path = sct.witness(note.commit()).unwrap(); + let auth_path2 = sct.witness(note2.commit()).unwrap(); + + // Add a single spend and output to the transaction plan such that the + // transaction balances. + let plan = TransactionPlan { + transaction_parameters: TransactionParameters { + expiry_height: 0, + fee: Fee::default(), + chain_id: "".into(), + }, + actions: vec![ + SpendPlan::new(&mut OsRng, note, auth_path.position()).into(), + SpendPlan::new(&mut OsRng, note2, auth_path2.position()).into(), + OutputPlan::new(&mut OsRng, value3, test_keys::ADDRESS_1.deref().clone()).into(), + ], + detection_data: Some(DetectionDataPlan { + clue_plans: vec![CluePlan::new( + &mut OsRng, + test_keys::ADDRESS_1.deref().clone(), + 1.try_into().unwrap(), + )], + }), + memo: None, + }; + + let transaction_view = TransactionView { + anchor: penumbra_tct::Root(Hash::zero()), + binding_sig: Signature::from([0u8; 64]), + body_view: TransactionBodyView { + action_views: plan + .actions + .iter() + .filter_map(|x| { + convert_action(&Cache::with_known_assets(), &test_keys::FULL_VIEWING_KEY, x) + }) + .collect(), + transaction_parameters: plan.transaction_parameters.clone(), + detection_data: None, + memo_view: None, + }, + }; + + let transaction_summary = TransactionView::summary(&transaction_view); + + assert_eq!(transaction_summary.effects.len(), 2); + } + + #[test] + fn test_swap_transaction_summary() { + // Generate two notes controlled by the test address. + let value = Value { + amount: 100u64.into(), + asset_id: *STAKING_TOKEN_ASSET_ID, + }; + let note = Note::generate(&mut OsRng, &test_keys::ADDRESS_0, value); + + let value2 = Value { + amount: 50u64.into(), + asset_id: Id(Fq::rand(&mut OsRng)), + }; + let note2 = Note::generate(&mut OsRng, &test_keys::ADDRESS_0, value2); + + let value3 = Value { + amount: 75u64.into(), + asset_id: *STAKING_TOKEN_ASSET_ID, + }; + + // Record that note in an SCT, where we can generate an auth path. + let mut sct = tct::Tree::new(); + for _ in 0..5 { + let random_note = Note::generate(&mut OsRng, &test_keys::ADDRESS_0, value); + sct.insert(tct::Witness::Keep, random_note.commit()) + .unwrap(); + } + sct.insert(tct::Witness::Keep, note.commit()).unwrap(); + sct.insert(tct::Witness::Keep, note2.commit()).unwrap(); + + let auth_path = sct.witness(note.commit()).unwrap(); + let auth_path2 = sct.witness(note2.commit()).unwrap(); + + let gm = asset::Cache::with_known_assets().get_unit("gm").unwrap(); + let gn = asset::Cache::with_known_assets().get_unit("gn").unwrap(); + let trading_pair = TradingPair::new(gm.id(), gn.id()); + + let delta_1 = Amount::from(100_000u64); + let delta_2 = Amount::from(0u64); + let fee = Fee::default(); + let claim_address: Address = test_keys::ADDRESS_0.deref().clone(); + let plaintext = SwapPlaintext::new( + &mut OsRng, + trading_pair, + delta_1, + delta_2, + fee, + claim_address, + ); + + // Add a single spend and output to the transaction plan such that the + // transaction balances. + let plan = TransactionPlan { + transaction_parameters: TransactionParameters { + expiry_height: 0, + fee: Fee::default(), + chain_id: "".into(), + }, + actions: vec![ + SpendPlan::new(&mut OsRng, note, auth_path.position()).into(), + SpendPlan::new(&mut OsRng, note2, auth_path2.position()).into(), + OutputPlan::new(&mut OsRng, value3, test_keys::ADDRESS_1.deref().clone()).into(), + SwapPlan::new(&mut OsRng, plaintext.clone()).into(), + ], + detection_data: Some(DetectionDataPlan { + clue_plans: vec![CluePlan::new( + &mut OsRng, + test_keys::ADDRESS_1.deref().clone(), + 1.try_into().unwrap(), + )], + }), + memo: None, + }; + + let transaction_view = TransactionView { + anchor: penumbra_tct::Root(Hash::zero()), + binding_sig: Signature::from([0u8; 64]), + body_view: TransactionBodyView { + action_views: plan + .actions + .iter() + .filter_map(|x| { + convert_action(&Cache::with_known_assets(), &test_keys::FULL_VIEWING_KEY, x) + }) + .collect(), + transaction_parameters: plan.transaction_parameters.clone(), + detection_data: None, + memo_view: None, + }, + }; + + let transaction_summary = TransactionView::summary(&transaction_view); + + assert_eq!(transaction_summary.effects.len(), 2); + } +} diff --git a/crates/proto/src/gen/penumbra.core.asset.v1.rs b/crates/proto/src/gen/penumbra.core.asset.v1.rs index 9ec732783c..7865016a58 100644 --- a/crates/proto/src/gen/penumbra.core.asset.v1.rs +++ b/crates/proto/src/gen/penumbra.core.asset.v1.rs @@ -141,6 +141,38 @@ impl ::prost::Name for Value { ::prost::alloc::format!("penumbra.core.asset.v1.{}", Self::NAME) } } +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Balance { + /// Represents the vector of 'Value's in the balance. + #[prost(message, repeated, tag = "1")] + pub values: ::prost::alloc::vec::Vec, +} +/// Nested message and enum types in `Balance`. +pub mod balance { + #[allow(clippy::derive_partial_eq_without_eq)] + #[derive(Clone, PartialEq, ::prost::Message)] + pub struct SignedValue { + #[prost(message, optional, tag = "1")] + pub value: ::core::option::Option, + #[prost(bool, tag = "2")] + pub negated: bool, + } + impl ::prost::Name for SignedValue { + const NAME: &'static str = "SignedValue"; + const PACKAGE: &'static str = "penumbra.core.asset.v1"; + fn full_name() -> ::prost::alloc::string::String { + ::prost::alloc::format!("penumbra.core.asset.v1.Balance.{}", Self::NAME) + } + } +} +impl ::prost::Name for Balance { + const NAME: &'static str = "Balance"; + const PACKAGE: &'static str = "penumbra.core.asset.v1"; + fn full_name() -> ::prost::alloc::string::String { + ::prost::alloc::format!("penumbra.core.asset.v1.{}", Self::NAME) + } +} /// Represents a value of a known or unknown denomination. #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] diff --git a/crates/proto/src/gen/penumbra.core.asset.v1.serde.rs b/crates/proto/src/gen/penumbra.core.asset.v1.serde.rs index bf0faaf4d8..d760c768a6 100644 --- a/crates/proto/src/gen/penumbra.core.asset.v1.serde.rs +++ b/crates/proto/src/gen/penumbra.core.asset.v1.serde.rs @@ -392,6 +392,213 @@ impl<'de> serde::Deserialize<'de> for asset_image::Theme { deserializer.deserialize_struct("penumbra.core.asset.v1.AssetImage.Theme", FIELDS, GeneratedVisitor) } } +impl serde::Serialize for Balance { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.values.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("penumbra.core.asset.v1.Balance", len)?; + if !self.values.is_empty() { + struct_ser.serialize_field("values", &self.values)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for Balance { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "values", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Values, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "values" => Ok(GeneratedField::Values), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = Balance; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct penumbra.core.asset.v1.Balance") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut values__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Values => { + if values__.is_some() { + return Err(serde::de::Error::duplicate_field("values")); + } + values__ = Some(map_.next_value()?); + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(Balance { + values: values__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("penumbra.core.asset.v1.Balance", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for balance::SignedValue { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.value.is_some() { + len += 1; + } + if self.negated { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("penumbra.core.asset.v1.Balance.SignedValue", len)?; + if let Some(v) = self.value.as_ref() { + struct_ser.serialize_field("value", v)?; + } + if self.negated { + struct_ser.serialize_field("negated", &self.negated)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for balance::SignedValue { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "value", + "negated", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Value, + Negated, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "value" => Ok(GeneratedField::Value), + "negated" => Ok(GeneratedField::Negated), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = balance::SignedValue; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct penumbra.core.asset.v1.Balance.SignedValue") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut value__ = None; + let mut negated__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Value => { + if value__.is_some() { + return Err(serde::de::Error::duplicate_field("value")); + } + value__ = map_.next_value()?; + } + GeneratedField::Negated => { + if negated__.is_some() { + return Err(serde::de::Error::duplicate_field("negated")); + } + negated__ = Some(map_.next_value()?); + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(balance::SignedValue { + value: value__, + negated: negated__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("penumbra.core.asset.v1.Balance.SignedValue", FIELDS, GeneratedVisitor) + } +} impl serde::Serialize for BalanceCommitment { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result diff --git a/crates/proto/src/gen/penumbra.core.transaction.v1.rs b/crates/proto/src/gen/penumbra.core.transaction.v1.rs index b981d684dd..0cf2173a8b 100644 --- a/crates/proto/src/gen/penumbra.core.transaction.v1.rs +++ b/crates/proto/src/gen/penumbra.core.transaction.v1.rs @@ -71,6 +71,41 @@ impl ::prost::Name for TransactionParameters { ::prost::alloc::format!("penumbra.core.transaction.v1.{}", Self::NAME) } } +/// Represents a transaction summary containing multiple effects. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct TransactionSummary { + #[prost(message, repeated, tag = "1")] + pub effects: ::prost::alloc::vec::Vec, +} +/// Nested message and enum types in `TransactionSummary`. +pub mod transaction_summary { + /// Represents an individual effect of a transaction. + #[allow(clippy::derive_partial_eq_without_eq)] + #[derive(Clone, PartialEq, ::prost::Message)] + pub struct Effects { + #[prost(message, optional, tag = "1")] + pub address: ::core::option::Option, + #[prost(message, optional, tag = "2")] + pub balance: ::core::option::Option, + } + impl ::prost::Name for Effects { + const NAME: &'static str = "Effects"; + const PACKAGE: &'static str = "penumbra.core.transaction.v1"; + fn full_name() -> ::prost::alloc::string::String { + ::prost::alloc::format!( + "penumbra.core.transaction.v1.TransactionSummary.{}", Self::NAME + ) + } + } +} +impl ::prost::Name for TransactionSummary { + const NAME: &'static str = "TransactionSummary"; + const PACKAGE: &'static str = "penumbra.core.transaction.v1"; + fn full_name() -> ::prost::alloc::string::String { + ::prost::alloc::format!("penumbra.core.transaction.v1.{}", Self::NAME) + } +} /// Detection data used by a detection server performing Fuzzy Message Detection. #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] diff --git a/crates/proto/src/gen/penumbra.core.transaction.v1.serde.rs b/crates/proto/src/gen/penumbra.core.transaction.v1.serde.rs index 63d055c45a..bd6767749d 100644 --- a/crates/proto/src/gen/penumbra.core.transaction.v1.serde.rs +++ b/crates/proto/src/gen/penumbra.core.transaction.v1.serde.rs @@ -4062,6 +4062,213 @@ impl<'de> serde::Deserialize<'de> for TransactionPlan { deserializer.deserialize_struct("penumbra.core.transaction.v1.TransactionPlan", FIELDS, GeneratedVisitor) } } +impl serde::Serialize for TransactionSummary { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.effects.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("penumbra.core.transaction.v1.TransactionSummary", len)?; + if !self.effects.is_empty() { + struct_ser.serialize_field("effects", &self.effects)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for TransactionSummary { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "effects", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Effects, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "effects" => Ok(GeneratedField::Effects), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = TransactionSummary; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct penumbra.core.transaction.v1.TransactionSummary") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut effects__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Effects => { + if effects__.is_some() { + return Err(serde::de::Error::duplicate_field("effects")); + } + effects__ = Some(map_.next_value()?); + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(TransactionSummary { + effects: effects__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("penumbra.core.transaction.v1.TransactionSummary", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for transaction_summary::Effects { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.address.is_some() { + len += 1; + } + if self.balance.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("penumbra.core.transaction.v1.TransactionSummary.Effects", len)?; + if let Some(v) = self.address.as_ref() { + struct_ser.serialize_field("address", v)?; + } + if let Some(v) = self.balance.as_ref() { + struct_ser.serialize_field("balance", v)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for transaction_summary::Effects { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "address", + "balance", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Address, + Balance, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "address" => Ok(GeneratedField::Address), + "balance" => Ok(GeneratedField::Balance), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = transaction_summary::Effects; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct penumbra.core.transaction.v1.TransactionSummary.Effects") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut address__ = None; + let mut balance__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Address => { + if address__.is_some() { + return Err(serde::de::Error::duplicate_field("address")); + } + address__ = map_.next_value()?; + } + GeneratedField::Balance => { + if balance__.is_some() { + return Err(serde::de::Error::duplicate_field("balance")); + } + balance__ = map_.next_value()?; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(transaction_summary::Effects { + address: address__, + balance: balance__, + }) + } + } + deserializer.deserialize_struct("penumbra.core.transaction.v1.TransactionSummary.Effects", FIELDS, GeneratedVisitor) + } +} impl serde::Serialize for TransactionView { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result diff --git a/crates/proto/src/gen/proto_descriptor.bin.no_lfs b/crates/proto/src/gen/proto_descriptor.bin.no_lfs index f2452b1c4b..0fbc2f4ef0 100644 Binary files a/crates/proto/src/gen/proto_descriptor.bin.no_lfs and b/crates/proto/src/gen/proto_descriptor.bin.no_lfs differ diff --git a/proto/penumbra/penumbra/core/asset/v1/asset.proto b/proto/penumbra/penumbra/core/asset/v1/asset.proto index 2b9bdbd9a6..375ee9016c 100644 --- a/proto/penumbra/penumbra/core/asset/v1/asset.proto +++ b/proto/penumbra/penumbra/core/asset/v1/asset.proto @@ -92,6 +92,16 @@ message Value { AssetId asset_id = 2; } +message Balance { + message SignedValue { + Value value = 1; + bool negated = 2; + } + // Represents the vector of 'Value's in the balance. + repeated SignedValue values = 1; +} + + // Represents a value of a known or unknown denomination. message ValueView { // A value whose asset ID is known and has metadata. diff --git a/proto/penumbra/penumbra/core/transaction/v1/transaction.proto b/proto/penumbra/penumbra/core/transaction/v1/transaction.proto index 8ea68e85b9..beffedd441 100644 --- a/proto/penumbra/penumbra/core/transaction/v1/transaction.proto +++ b/proto/penumbra/penumbra/core/transaction/v1/transaction.proto @@ -54,6 +54,16 @@ message TransactionParameters { component.fee.v1.Fee fee = 3; } +// Represents a transaction summary containing multiple effects. +message TransactionSummary { + // Represents an individual effect of a transaction. + message Effects { + keys.v1.AddressView address = 1; + asset.v1.Balance balance = 2; + } + repeated Effects effects = 1; +} + // Detection data used by a detection server performing Fuzzy Message Detection. message DetectionData { // A list of clues for use with Fuzzy Message Detection.