Merge pull request #891 from nuttycom/proposal-ffi

Add protobuf representation for transaction proposals
This commit is contained in:
Kris Nuttycombe 2023-11-15 15:47:46 -07:00 committed by GitHub
commit 236cd569ee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 984 additions and 59 deletions

1
.gitattributes vendored
View File

@ -1,2 +1,3 @@
zcash_client_backend/src/proto/compact_formats.rs linguist-generated=true
zcash_client_backend/src/proto/service.rs linguist-generated=true
zcash_client_backend/src/proto/proposal.rs linguist-generated=true

View File

@ -15,6 +15,7 @@ and this library adheres to Rust's notion of
- `ScannedBlock::sapling_tree_size`.
- `ScannedBlock::orchard_tree_size`.
- `wallet::propose_standard_transfer_to_address`
- `wallet::input_selection::Proposal::from_parts`
- `wallet::input_selection::SaplingInputs`
- `wallet::input_selection::ShieldingSelector` has been
factored out from the `InputSelector` trait to separate out transparent
@ -23,6 +24,22 @@ and this library adheres to Rust's notion of
- `zcash_client_backend::wallet`:
- `ReceivedSaplingNote::from_parts`
- `ReceivedSaplingNote::{txid, output_index, diversifier, rseed, note_commitment_tree_position}`
- `zcash_client_backend::zip321::TransactionRequest::total`
- `zcash_client_backend::zip321::parse::Param::name`
- `zcash_client_backend::proto::`
- `PROPOSAL_SER_V1`
- `ProposalError`
- `proposal` module, for parsing and serializing transaction proposals.
- `impl Clone for zcash_client_backend::{
zip321::{Payment, TransactionRequest, Zip321Error, parse::Param, parse::IndexedParam},
wallet::{ReceivedSaplingNote, WalletTransparentOutput},
wallet::input_selection::{Proposal, SaplingInputs},
}`
- `impl {PartialEq, Eq} for zcash_client_backend::{
zip321::{Zip321Error, parse::Param, parse::IndexedParam},
wallet::{ReceivedSaplingNote, WalletTransparentOutput},
wallet::input_selection::{Proposal, SaplingInputs},
}`
### Changed
- `zcash_client_backend::data_api`:
@ -42,11 +59,11 @@ and this library adheres to Rust's notion of
backend-specific note identifier. The related `NoteRef` type parameter has
been removed from `error::Error`.
- A new variant `UnsupportedPoolType` has been added.
- `wallet::shield_transparent_funds` no longer
takes a `memo` argument; instead, memos to be associated with the shielded
outputs should be specified in the construction of the value of the
`input_selector` argument, which is used to construct the proposed shielded
values as internal "change" outputs.
- `wallet::shield_transparent_funds` no longer takes a `memo` argument;
instead, memos to be associated with the shielded outputs should be
specified in the construction of the value of the `input_selector`
argument, which is used to construct the proposed shielded values as
internal "change" outputs.
- `wallet::create_proposed_transaction` no longer takes a
`change_memo` argument; instead, change memos are represented in the
individual values of the `proposed_change` field of the `Proposal`'s

View File

@ -4,6 +4,9 @@ use std::io;
use std::path::{Path, PathBuf};
const COMPACT_FORMATS_PROTO: &str = "proto/compact_formats.proto";
const PROPOSAL_PROTO: &str = "proto/proposal.proto";
const SERVICE_PROTO: &str = "proto/service.proto";
fn main() -> io::Result<()> {
@ -71,6 +74,15 @@ fn build() -> io::Result<()> {
)
.compile(&[SERVICE_PROTO], &["proto/"])?;
// Build the proposal types.
tonic_build::compile_protos(PROPOSAL_PROTO)?;
// Copy the generated types into the source tree so changes can be committed.
fs::copy(
out.join("cash.z.wallet.sdk.ffi.rs"),
"src/proto/proposal.rs",
)?;
// Copy the generated types into the source tree so changes can be committed. The
// file has the same name as for the compact format types because they have the
// same package, but we've set things up so this only contains the service types.

View File

@ -0,0 +1,86 @@
// Copyright (c) 2023 The Zcash developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or https://www.opensource.org/licenses/mit-license.php .
syntax = "proto3";
package cash.z.wallet.sdk.ffi;
// A data structure that describes the inputs to be consumed and outputs to
// be produced in a proposed transaction.
message Proposal {
uint32 protoVersion = 1;
// ZIP 321 serialized transaction request
string transactionRequest = 2;
// The transparent UTXOs to use as inputs to the transaction.
repeated ProposedInput transparentInputs = 3;
// The Sapling input notes and anchor height to be used in creating the transaction.
SaplingInputs saplingInputs = 4;
// The total value, fee amount, and change outputs of the proposed
// transaction
TransactionBalance balance = 5;
// The fee rule used in constructing this proposal
FeeRule feeRule = 6;
// The target height for which the proposal was constructed
//
// The chain must contain at least this many blocks in order for the proposal to
// be executed.
uint32 minTargetHeight = 7;
// A flag indicating whether the proposal is for a shielding transaction,
// used for determining which OVK to select for wallet-internal outputs.
bool isShielding = 8;
}
message SaplingInputs {
// The Sapling anchor height to be used in creating the transaction
uint32 anchorHeight = 1;
// The unique identifier and amount for each proposed Sapling input
repeated ProposedInput inputs = 2;
}
// The unique identifier and amount for each proposed input.
message ProposedInput {
bytes txid = 1;
uint32 index = 2;
uint64 value = 3;
}
// The fee rule used in constructing a Proposal
enum FeeRule {
// Protobuf requires that enums have a zero discriminant as the default
// value. However, we need to require that a known fee rule is selected,
// and we do not want to fall back to any default, so sending the
// FeeRuleNotSpecified value will be treated as an error.
FeeRuleNotSpecified = 0;
// 10000 ZAT
PreZip313 = 1;
// 1000 ZAT
Zip313 = 2;
// MAX(10000, 5000 * logical_actions) ZAT
Zip317 = 3;
}
// The proposed change outputs and fee amount.
message TransactionBalance {
repeated ChangeValue proposedChange = 1;
uint64 feeRequired = 2;
}
// An enumeration of change value types.
message ChangeValue {
oneof value {
SaplingChange saplingValue = 1;
}
}
// An object wrapper for memo bytes, to facilitate representing the
// `change_memo == None` case.
message MemoBytes {
bytes value = 1;
}
// The amount and memo for a proposed Sapling change output.
message SaplingChange {
uint64 amount = 1;
MemoBytes memo = 2;
}

View File

@ -17,7 +17,10 @@ use zcash_primitives::{
memo::{Memo, MemoBytes},
sapling::{self, Node, NOTE_COMMITMENT_TREE_DEPTH},
transaction::{
components::amount::{Amount, NonNegativeAmount},
components::{
amount::{Amount, NonNegativeAmount},
OutPoint,
},
Transaction, TxId,
},
zip32::AccountId,
@ -31,9 +34,6 @@ use crate::{
wallet::{ReceivedSaplingNote, WalletTransparentOutput, WalletTx},
};
#[cfg(feature = "transparent-inputs")]
use zcash_primitives::transaction::components::OutPoint;
use self::chain::CommitmentTreeRoot;
use self::scanning::ScanRange;
@ -222,6 +222,17 @@ pub trait SaplingInputSource {
/// or a UUID.
type NoteRef: Copy + Debug + Eq + Ord;
/// Fetches a spendable Sapling note by indexing into the specified transaction's
/// [`sapling::Bundle::shielded_outputs`].
///
/// Returns `Ok(None)` if the note is not known to belong to the wallet or if the note
/// is not spendable.
fn get_spendable_sapling_note(
&self,
txid: &TxId,
index: u32,
) -> Result<Option<ReceivedSaplingNote<Self::NoteRef>>, Self::Error>;
/// Returns a list of spendable Sapling notes sufficient to cover the specified target value,
/// if possible.
fn select_spendable_sapling_notes(
@ -235,11 +246,19 @@ pub trait SaplingInputSource {
/// A trait representing the capability to query a data store for unspent transparent UTXOs
/// belonging to a wallet.
#[cfg(feature = "transparent-inputs")]
pub trait TransparentInputSource {
/// The type of errors produced by a wallet backend.
type Error;
/// Fetches a spendable transparent output.
///
/// Returns `Ok(None)` if the UTXO is not known to belong to the wallet or is not
/// spendable.
fn get_unspent_transparent_output(
&self,
outpoint: &OutPoint,
) -> Result<Option<WalletTransparentOutput>, Self::Error>;
/// Returns a list of unspent transparent UTXOs that appear in the chain at heights up to and
/// including `max_height`.
fn get_unspent_transparent_outputs(
@ -1008,6 +1027,14 @@ pub mod testing {
type Error = ();
type NoteRef = u32;
fn get_spendable_sapling_note(
&self,
_txid: &TxId,
_index: u32,
) -> Result<Option<ReceivedSaplingNote<Self::NoteRef>>, Self::Error> {
Ok(None)
}
fn select_spendable_sapling_notes(
&self,
_account: AccountId,
@ -1023,6 +1050,13 @@ pub mod testing {
impl TransparentInputSource for MockWalletDb {
type Error = ();
fn get_unspent_transparent_output(
&self,
_outpoint: &OutPoint,
) -> Result<Option<WalletTransparentOutput>, Self::Error> {
Ok(None)
}
fn get_unspent_transparent_outputs(
&self,
_address: &TransparentAddress,

View File

@ -81,7 +81,7 @@ where
FE: fmt::Display,
{
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match &self {
match self {
Error::DataSource(e) => {
write!(
f,

View File

@ -1,7 +1,7 @@
//! Types related to the process of selecting inputs to be spent given a transaction request.
use core::marker::PhantomData;
use std::fmt::{self, Debug};
use std::fmt::{self, Debug, Display};
use nonempty::NonEmpty;
use zcash_primitives::{
@ -79,6 +79,7 @@ impl<DE: fmt::Display, SE: fmt::Display> fmt::Display for InputSelectorError<DE,
}
/// The inputs to be consumed and outputs to be produced in a proposed transaction.
#[derive(Clone, PartialEq, Eq)]
pub struct Proposal<FeeRuleT, NoteRef> {
transaction_request: TransactionRequest,
transparent_inputs: Vec<WalletTransparentOutput>,
@ -89,7 +90,119 @@ pub struct Proposal<FeeRuleT, NoteRef> {
is_shielding: bool,
}
/// Errors that can occur in construction of a [`Proposal`].
#[derive(Debug, Clone)]
pub enum ProposalError {
/// The total output value of the transaction request is not a valid Zcash amount.
RequestTotalInvalid,
/// The total of transaction inputs overflows the valid range of Zcash values.
Overflow,
/// The input total and output total of the payment request are not equal to one another. The
/// sum of transaction outputs, change, and fees is required to be exactly equal to the value
/// of provided inputs.
BalanceError {
input_total: NonNegativeAmount,
output_total: NonNegativeAmount,
},
/// The `is_shielding` flag may only be set to `true` under the following conditions:
/// * The total of transparent inputs is nonzero
/// * There exist no Sapling inputs
/// * There provided transaction request is empty; i.e. the only output values specified
/// are change and fee amounts.
ShieldingInvalid,
}
impl Display for ProposalError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ProposalError::RequestTotalInvalid => write!(
f,
"The total requested output value is not a valid Zcash amount."
),
ProposalError::Overflow => write!(
f,
"The total of transaction inputs overflows the valid range of Zcash values."
),
ProposalError::BalanceError {
input_total,
output_total,
} => write!(
f,
"Balance error: the output total {} was not equal to the input total {}",
u64::from(*output_total),
u64::from(*input_total)
),
ProposalError::ShieldingInvalid => write!(
f,
"The proposal violates the rules for a shielding transaction."
),
}
}
}
impl std::error::Error for ProposalError {}
impl<FeeRuleT, NoteRef> Proposal<FeeRuleT, NoteRef> {
/// Constructs a validated [`Proposal`] from its constituent parts.
///
/// This operation validates the proposal for balance consistency and agreement between
/// the `is_shielding` flag and the structure of the proposal.
#[allow(clippy::too_many_arguments)]
pub fn from_parts(
transaction_request: TransactionRequest,
transparent_inputs: Vec<WalletTransparentOutput>,
sapling_inputs: Option<SaplingInputs<NoteRef>>,
balance: TransactionBalance,
fee_rule: FeeRuleT,
min_target_height: BlockHeight,
is_shielding: bool,
) -> Result<Self, ProposalError> {
let transparent_input_total = transparent_inputs
.iter()
.map(|out| out.txout().value)
.fold(Ok(NonNegativeAmount::ZERO), |acc, a| {
(acc? + a).ok_or(ProposalError::Overflow)
})?;
let sapling_input_total = sapling_inputs
.iter()
.flat_map(|s_in| s_in.notes().iter())
.map(|out| out.value())
.fold(Some(NonNegativeAmount::ZERO), |acc, a| (acc? + a))
.ok_or(ProposalError::Overflow)?;
let input_total =
(transparent_input_total + sapling_input_total).ok_or(ProposalError::Overflow)?;
let request_total = transaction_request
.total()
.map_err(|_| ProposalError::RequestTotalInvalid)?;
let output_total = (request_total + balance.total()).ok_or(ProposalError::Overflow)?;
if is_shielding
&& (transparent_input_total == NonNegativeAmount::ZERO
|| sapling_input_total > NonNegativeAmount::ZERO
|| request_total > NonNegativeAmount::ZERO)
{
return Err(ProposalError::ShieldingInvalid);
}
if input_total == output_total {
Ok(Self {
transaction_request,
transparent_inputs,
sapling_inputs,
balance,
fee_rule,
min_target_height,
is_shielding,
})
} else {
Err(ProposalError::BalanceError {
input_total,
output_total,
})
}
}
/// Returns the transaction request that describes the payments to be made.
pub fn transaction_request(&self) -> &TransactionRequest {
&self.transaction_request
@ -147,12 +260,24 @@ impl<FeeRuleT, NoteRef> Debug for Proposal<FeeRuleT, NoteRef> {
}
/// The Sapling inputs to a proposed transaction.
#[derive(Clone, PartialEq, Eq)]
pub struct SaplingInputs<NoteRef> {
anchor_height: BlockHeight,
notes: NonEmpty<ReceivedSaplingNote<NoteRef>>,
}
impl<NoteRef> SaplingInputs<NoteRef> {
/// Constructs a [`SaplingInputs`] from its constituent parts.
pub fn from_parts(
anchor_height: BlockHeight,
notes: NonEmpty<ReceivedSaplingNote<NoteRef>>,
) -> Self {
Self {
anchor_height,
notes,
}
}
/// Returns the anchor height for Sapling inputs that should be used when constructing the
/// proposed transaction.
pub fn anchor_height(&self) -> BlockHeight {

View File

@ -54,6 +54,9 @@ impl ChangeValue {
pub struct TransactionBalance {
proposed_change: Vec<ChangeValue>,
fee_required: NonNegativeAmount,
// A cache for the sum of proposed change and fee; we compute it on construction anyway, so we
// cache the resulting value.
total: NonNegativeAmount,
}

View File

@ -1,23 +1,48 @@
//! Generated code for handling light client protobuf structs.
use std::io;
use std::{
array::TryFromSliceError,
fmt::{self, Display},
io,
};
use incrementalmerkletree::frontier::CommitmentTree;
use nonempty::NonEmpty;
use zcash_primitives::{
block::{BlockHash, BlockHeader},
consensus::BlockHeight,
consensus::{self, BlockHeight, Parameters},
memo::{self, MemoBytes},
merkle_tree::read_commitment_tree,
sapling::{self, note::ExtractedNoteCommitment, Node, Nullifier, NOTE_COMMITMENT_TREE_DEPTH},
transaction::TxId,
transaction::{
components::{amount::NonNegativeAmount, OutPoint},
fees::StandardFeeRule,
TxId,
},
};
use zcash_note_encryption::{EphemeralKeyBytes, COMPACT_NOTE_SIZE};
use crate::{
data_api::{
wallet::input_selection::{Proposal, ProposalError, SaplingInputs},
PoolType, SaplingInputSource, ShieldedProtocol, TransparentInputSource,
},
fees::{ChangeValue, TransactionBalance},
zip321::{TransactionRequest, Zip321Error},
};
#[rustfmt::skip]
#[allow(unknown_lints)]
#[allow(clippy::derive_partial_eq_without_eq)]
pub mod compact_formats;
#[rustfmt::skip]
#[allow(unknown_lints)]
#[allow(clippy::derive_partial_eq_without_eq)]
pub mod proposal;
#[rustfmt::skip]
#[allow(unknown_lints)]
#[allow(clippy::derive_partial_eq_without_eq)]
@ -183,3 +208,305 @@ impl service::TreeState {
read_commitment_tree::<Node, _, NOTE_COMMITMENT_TREE_DEPTH>(&sapling_tree_bytes[..])
}
}
/// Constant for the V1 proposal serialization version.
pub const PROPOSAL_SER_V1: u32 = 1;
/// Errors that can occur in the process of decoding a [`Proposal`] from its protobuf
/// representation.
#[derive(Debug, Clone)]
pub enum ProposalDecodingError<DbError> {
/// The ZIP 321 transaction request URI was invalid.
Zip321(Zip321Error),
/// A transaction identifier string did not decode to a valid transaction ID.
TxIdInvalid(TryFromSliceError),
/// A failure occurred trying to retrieve an unspent note or UTXO from the wallet database.
InputRetrieval(DbError),
/// The unspent note or UTXO corresponding to a proposal input was not found in the wallet
/// database.
InputNotFound(TxId, PoolType, u32),
/// The transaction balance, or a component thereof, failed to decode correctly.
BalanceInvalid,
/// Failed to decode a ZIP-302-compliant memo from the provided memo bytes.
MemoInvalid(memo::Error),
/// The serialization version returned by the protobuf was not recognized.
VersionInvalid(u32),
/// The proposal did not correctly specify a standard fee rule.
FeeRuleNotSpecified,
/// The proposal violated balance or structural constraints.
ProposalInvalid(ProposalError),
/// An inputs field for the given protocol was present, but contained no input note references.
EmptyShieldedInputs(ShieldedProtocol),
}
impl<E> From<Zip321Error> for ProposalDecodingError<E> {
fn from(value: Zip321Error) -> Self {
Self::Zip321(value)
}
}
impl<E: Display> Display for ProposalDecodingError<E> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ProposalDecodingError::Zip321(err) => write!(f, "Transaction request invalid: {}", err),
ProposalDecodingError::TxIdInvalid(err) => {
write!(f, "Invalid transaction id: {:?}", err)
}
ProposalDecodingError::InputRetrieval(err) => write!(
f,
"An error occurred retrieving a transaction input: {}",
err
),
ProposalDecodingError::InputNotFound(txid, pool, idx) => write!(
f,
"No {} input found for txid {}, index {}",
pool, txid, idx
),
ProposalDecodingError::BalanceInvalid => {
write!(f, "An error occurred decoding the proposal balance.")
}
ProposalDecodingError::MemoInvalid(err) => {
write!(f, "An error occurred decoding a proposed memo: {}", err)
}
ProposalDecodingError::VersionInvalid(v) => {
write!(f, "Unrecognized proposal version {}", v)
}
ProposalDecodingError::FeeRuleNotSpecified => {
write!(f, "Proposal did not specify a known fee rule.")
}
ProposalDecodingError::ProposalInvalid(err) => write!(f, "{}", err),
ProposalDecodingError::EmptyShieldedInputs(protocol) => write!(
f,
"An inputs field was present for {:?}, but contained no note references.",
protocol
),
}
}
}
impl<E: std::error::Error + 'static> std::error::Error for ProposalDecodingError<E> {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ProposalDecodingError::Zip321(e) => Some(e),
ProposalDecodingError::InputRetrieval(e) => Some(e),
ProposalDecodingError::MemoInvalid(e) => Some(e),
_ => None,
}
}
}
impl proposal::ProposedInput {
pub fn parse_txid(&self) -> Result<TxId, TryFromSliceError> {
Ok(TxId::from_bytes(self.txid[..].try_into()?))
}
}
impl proposal::Proposal {
/// Serializes a [`Proposal`] based upon a supported [`StandardFeeRule`] to its protobuf
/// representation.
pub fn from_standard_proposal<P: Parameters, NoteRef>(
params: &P,
value: &Proposal<StandardFeeRule, NoteRef>,
) -> Option<Self> {
let transaction_request = value.transaction_request().to_uri(params)?;
let transparent_inputs = value
.transparent_inputs()
.iter()
.map(|utxo| proposal::ProposedInput {
txid: utxo.outpoint().hash().to_vec(),
index: utxo.outpoint().n(),
value: utxo.txout().value.into(),
})
.collect();
let sapling_inputs = value
.sapling_inputs()
.map(|sapling_inputs| proposal::SaplingInputs {
anchor_height: sapling_inputs.anchor_height().into(),
inputs: sapling_inputs
.notes()
.iter()
.map(|rec_note| proposal::ProposedInput {
txid: rec_note.txid().as_ref().to_vec(),
index: rec_note.output_index().into(),
value: rec_note.value().into(),
})
.collect(),
});
let balance = Some(proposal::TransactionBalance {
proposed_change: value
.balance()
.proposed_change()
.iter()
.map(|change| match change {
ChangeValue::Sapling { value, memo } => proposal::ChangeValue {
value: Some(proposal::change_value::Value::SaplingValue(
proposal::SaplingChange {
amount: (*value).into(),
memo: memo.as_ref().map(|memo_bytes| proposal::MemoBytes {
value: memo_bytes.as_slice().to_vec(),
}),
},
)),
},
})
.collect(),
fee_required: value.balance().fee_required().into(),
});
#[allow(deprecated)]
Some(proposal::Proposal {
proto_version: PROPOSAL_SER_V1,
transaction_request,
transparent_inputs,
sapling_inputs,
balance,
fee_rule: match value.fee_rule() {
StandardFeeRule::PreZip313 => proposal::FeeRule::PreZip313,
StandardFeeRule::Zip313 => proposal::FeeRule::Zip313,
StandardFeeRule::Zip317 => proposal::FeeRule::Zip317,
}
.into(),
min_target_height: value.min_target_height().into(),
is_shielding: value.is_shielding(),
})
}
/// Attempts to parse a [`Proposal`] based upon a supported [`StandardFeeRule`] from its
/// protobuf representation.
pub fn try_into_standard_proposal<P: consensus::Parameters, DbT, DbError>(
&self,
params: &P,
wallet_db: &DbT,
) -> Result<Proposal<StandardFeeRule, DbT::NoteRef>, ProposalDecodingError<DbError>>
where
DbT: TransparentInputSource<Error = DbError> + SaplingInputSource<Error = DbError>,
{
match self.proto_version {
PROPOSAL_SER_V1 => {
#[allow(deprecated)]
let fee_rule = match self.fee_rule() {
proposal::FeeRule::PreZip313 => StandardFeeRule::PreZip313,
proposal::FeeRule::Zip313 => StandardFeeRule::Zip313,
proposal::FeeRule::Zip317 => StandardFeeRule::Zip317,
proposal::FeeRule::NotSpecified => {
return Err(ProposalDecodingError::FeeRuleNotSpecified);
}
};
let transaction_request =
TransactionRequest::from_uri(params, &self.transaction_request)?;
let transparent_inputs = self
.transparent_inputs
.iter()
.map(|t_in| {
let txid = t_in
.parse_txid()
.map_err(ProposalDecodingError::TxIdInvalid)?;
let outpoint = OutPoint::new(txid.into(), t_in.index);
wallet_db
.get_unspent_transparent_output(&outpoint)
.map_err(ProposalDecodingError::InputRetrieval)?
.ok_or({
ProposalDecodingError::InputNotFound(
txid,
PoolType::Transparent,
t_in.index,
)
})
})
.collect::<Result<Vec<_>, _>>()?;
let sapling_inputs = self.sapling_inputs.as_ref().map(|s_in| {
s_in.inputs
.iter()
.map(|s_in| {
let txid = s_in
.parse_txid()
.map_err(ProposalDecodingError::TxIdInvalid)?;
wallet_db
.get_spendable_sapling_note(&txid, s_in.index)
.map_err(ProposalDecodingError::InputRetrieval)
.and_then(|opt| {
opt.ok_or({
ProposalDecodingError::InputNotFound(
txid,
PoolType::Shielded(ShieldedProtocol::Sapling),
s_in.index,
)
})
})
})
.collect::<Result<Vec<_>, _>>()
.and_then(|notes| {
NonEmpty::from_vec(notes)
.map(|notes| {
SaplingInputs::from_parts(s_in.anchor_height.into(), notes)
})
.ok_or({
ProposalDecodingError::EmptyShieldedInputs(
ShieldedProtocol::Sapling,
)
})
})
});
let proto_balance = self
.balance
.as_ref()
.ok_or(ProposalDecodingError::BalanceInvalid)?;
let balance = TransactionBalance::new(
proto_balance
.proposed_change
.iter()
.filter_map(|change| {
// An empty `value` field can be treated as though the whole
// `ChangeValue` was absent; this optionality is an artifact of
// protobuf encoding.
change.value.as_ref().map(
|cv| -> Result<ChangeValue, ProposalDecodingError<_>> {
match cv {
proposal::change_value::Value::SaplingValue(sc) => {
Ok(ChangeValue::sapling(
NonNegativeAmount::from_u64(sc.amount).map_err(
|_| ProposalDecodingError::BalanceInvalid,
)?,
sc.memo
.as_ref()
.map(|bytes| {
MemoBytes::from_bytes(&bytes.value).map_err(
ProposalDecodingError::MemoInvalid,
)
})
.transpose()?,
))
}
}
},
)
})
.collect::<Result<Vec<_>, _>>()?,
NonNegativeAmount::from_u64(proto_balance.fee_required)
.map_err(|_| ProposalDecodingError::BalanceInvalid)?,
)
.map_err(|_| ProposalDecodingError::BalanceInvalid)?;
Proposal::from_parts(
transaction_request,
transparent_inputs,
sapling_inputs.transpose()?,
balance,
fee_rule,
self.min_target_height.into(),
self.is_shielding,
)
.map_err(ProposalDecodingError::ProposalInvalid)
}
other => Err(ProposalDecodingError::VersionInvalid(other)),
}
}
}

137
zcash_client_backend/src/proto/proposal.rs generated Normal file
View File

@ -0,0 +1,137 @@
/// A data structure that describes the inputs to be consumed and outputs to
/// be produced in a proposed transaction.
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Proposal {
#[prost(uint32, tag = "1")]
pub proto_version: u32,
/// ZIP 321 serialized transaction request
#[prost(string, tag = "2")]
pub transaction_request: ::prost::alloc::string::String,
/// The transparent UTXOs to use as inputs to the transaction.
#[prost(message, repeated, tag = "3")]
pub transparent_inputs: ::prost::alloc::vec::Vec<ProposedInput>,
/// The Sapling input notes and anchor height to be used in creating the transaction.
#[prost(message, optional, tag = "4")]
pub sapling_inputs: ::core::option::Option<SaplingInputs>,
/// The total value, fee amount, and change outputs of the proposed
/// transaction
#[prost(message, optional, tag = "5")]
pub balance: ::core::option::Option<TransactionBalance>,
/// The fee rule used in constructing this proposal
#[prost(enumeration = "FeeRule", tag = "6")]
pub fee_rule: i32,
/// The target height for which the proposal was constructed
///
/// The chain must contain at least this many blocks in order for the proposal to
/// be executed.
#[prost(uint32, tag = "7")]
pub min_target_height: u32,
/// A flag indicating whether the proposal is for a shielding transaction,
/// used for determining which OVK to select for wallet-internal outputs.
#[prost(bool, tag = "8")]
pub is_shielding: bool,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct SaplingInputs {
/// Returns the Sapling anchor height to be used in creating the transaction.
#[prost(uint32, tag = "1")]
pub anchor_height: u32,
/// The unique identifier and amount for each proposed Sapling input
#[prost(message, repeated, tag = "2")]
pub inputs: ::prost::alloc::vec::Vec<ProposedInput>,
}
/// The unique identifier and amount for each proposed input.
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ProposedInput {
#[prost(bytes = "vec", tag = "1")]
pub txid: ::prost::alloc::vec::Vec<u8>,
#[prost(uint32, tag = "2")]
pub index: u32,
#[prost(uint64, tag = "3")]
pub value: u64,
}
/// The proposed change outputs and fee amount.
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct TransactionBalance {
#[prost(message, repeated, tag = "1")]
pub proposed_change: ::prost::alloc::vec::Vec<ChangeValue>,
#[prost(uint64, tag = "2")]
pub fee_required: u64,
}
/// An enumeration of change value types.
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ChangeValue {
#[prost(oneof = "change_value::Value", tags = "1")]
pub value: ::core::option::Option<change_value::Value>,
}
/// Nested message and enum types in `ChangeValue`.
pub mod change_value {
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Oneof)]
pub enum Value {
#[prost(message, tag = "1")]
SaplingValue(super::SaplingChange),
}
}
/// An object wrapper for memo bytes, to facilitate representing the
/// `change_memo == None` case.
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct MemoBytes {
#[prost(bytes = "vec", tag = "1")]
pub value: ::prost::alloc::vec::Vec<u8>,
}
/// The amount and memo for a proposed Sapling change output.
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct SaplingChange {
#[prost(uint64, tag = "1")]
pub amount: u64,
#[prost(message, optional, tag = "2")]
pub memo: ::core::option::Option<MemoBytes>,
}
/// The fee rule used in constructing a Proposal
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum FeeRule {
/// Protobuf requires that enums have a zero descriminant as the default
/// value. However, we need to require that a known fee rule is selected,
/// and we do not want to fall back to any default, so sending the
/// FeeRuleNotSpecified value will be treated as an error.
NotSpecified = 0,
/// 10000 ZAT
PreZip313 = 1,
/// 1000 ZAT
Zip313 = 2,
/// MAX(10000, 5000 * logical_actions) ZAT
Zip317 = 3,
}
impl FeeRule {
/// String value of the enum field names used in the ProtoBuf definition.
///
/// The values are not transformed in any way and thus are considered stable
/// (if the ProtoBuf definition does not change) and safe for programmatic use.
pub fn as_str_name(&self) -> &'static str {
match self {
FeeRule::NotSpecified => "FeeRuleNotSpecified",
FeeRule::PreZip313 => "PreZip313",
FeeRule::Zip313 => "Zip313",
FeeRule::Zip317 => "Zip317",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
match value {
"FeeRuleNotSpecified" => Some(Self::NotSpecified),
"PreZip313" => Some(Self::PreZip313),
"Zip313" => Some(Self::Zip313),
"Zip317" => Some(Self::Zip317),
_ => None,
}
}
}

View File

@ -29,7 +29,7 @@ pub struct WalletTx<N> {
pub sapling_outputs: Vec<WalletSaplingOutput<N>>,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WalletTransparentOutput {
outpoint: OutPoint,
txout: TxOut,
@ -175,7 +175,7 @@ impl<N> WalletSaplingOutput<N> {
/// Information about a note that is tracked by the wallet that is available for spending,
/// with sufficient information for use in note selection.
#[derive(Debug)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReceivedSaplingNote<NoteRef> {
note_id: NoteRef,
txid: TxId,

View File

@ -5,7 +5,10 @@
//!
//! The specification for ZIP 321 URIs may be found at <https://zips.z.cash/zip-0321>
use core::fmt::Debug;
use std::collections::HashMap;
use std::{
collections::HashMap,
fmt::{self, Display},
};
use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine};
use nom::{
@ -24,7 +27,7 @@ use std::cmp::Ordering;
use crate::address::RecipientAddress;
/// Errors that may be produced in decoding of payment requests.
#[derive(Debug)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Zip321Error {
/// A memo field in the ZIP 321 URI was not properly base-64 encoded
InvalidBase64(base64::DecodeError),
@ -45,6 +48,51 @@ pub enum Zip321Error {
ParseError(String),
}
impl Display for Zip321Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Zip321Error::InvalidBase64(err) => {
write!(f, "Memo value was not correctly base64-encoded: {:?}", err)
}
Zip321Error::MemoBytesError(err) => write!(
f,
"Memo exceeded maximum length or violated UTF-8 encoding restrictions: {:?}",
err
),
Zip321Error::TooManyPayments(n) => write!(
f,
"Cannot create a Zcash transaction containing {} payments",
n
),
Zip321Error::DuplicateParameter(param, idx) => write!(
f,
"There is a duplicate {} parameter at index {}",
param.name(),
idx
),
Zip321Error::TransparentMemo(idx) => write!(
f,
"Payment {} is invalid: cannot send a memo to a transparent recipient address",
idx
),
Zip321Error::RecipientMissing(idx) => {
write!(f, "Payment {} is missing its recipient address", idx)
}
Zip321Error::ParseError(s) => write!(f, "Parse failure: {}", s),
}
}
}
impl std::error::Error for Zip321Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Zip321Error::InvalidBase64(err) => Some(err),
Zip321Error::MemoBytesError(err) => Some(err),
_ => None,
}
}
}
/// Converts a [`MemoBytes`] value to a ZIP 321 compatible base64-encoded string.
///
/// [`MemoBytes`]: zcash_primitives::memo::MemoBytes
@ -63,7 +111,7 @@ pub fn memo_from_base64(s: &str) -> Result<MemoBytes, Zip321Error> {
}
/// A single payment being requested.
#[derive(Debug, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Payment {
/// The payment address to which the payment should be sent.
pub recipient_address: RecipientAddress,
@ -121,7 +169,7 @@ impl Payment {
/// When constructing a transaction in response to such a request,
/// a separate output should be added to the transaction for each
/// payment value in the request.
#[derive(Debug, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransactionRequest {
payments: Vec<Payment>,
}
@ -152,6 +200,21 @@ impl TransactionRequest {
&self.payments[..]
}
/// Returns the total value of payments to be made.
///
/// Returns `Err` in the case of overflow, or if the value is
/// outside the range `0..=MAX_MONEY` zatoshis.
pub fn total(&self) -> Result<NonNegativeAmount, ()> {
if self.payments.is_empty() {
Ok(NonNegativeAmount::ZERO)
} else {
self.payments
.iter()
.map(|p| p.amount)
.fold(Ok(NonNegativeAmount::ZERO), |acc, a| (acc? + a).ok_or(()))
}
}
/// A utility for use in tests to help check round-trip serialization properties.
#[cfg(any(test, feature = "test-dependencies"))]
pub(in crate::zip321) fn normalize<P: consensus::Parameters>(&mut self, params: &P) {
@ -416,7 +479,7 @@ mod parse {
/// A data type that defines the possible parameter types which may occur within a
/// ZIP 321 URI.
#[derive(Debug, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Param {
Addr(Box<RecipientAddress>),
Amount(NonNegativeAmount),
@ -426,8 +489,22 @@ mod parse {
Other(String, String),
}
impl Param {
/// Returns the name of the parameter from which this value was parsed.
pub fn name(&self) -> String {
match self {
Param::Addr(_) => "address".to_owned(),
Param::Amount(_) => "amount".to_owned(),
Param::Memo(_) => "memo".to_owned(),
Param::Label(_) => "label".to_owned(),
Param::Message(_) => "message".to_owned(),
Param::Other(name, _) => name.clone(),
}
}
}
/// A [`Param`] value with its associated index.
#[derive(Debug)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexedParam {
pub param: Param,
pub payment_index: usize,

View File

@ -173,6 +173,14 @@ impl<C: Borrow<rusqlite::Connection>, P: consensus::Parameters> SaplingInputSour
type Error = SqliteClientError;
type NoteRef = ReceivedNoteId;
fn get_spendable_sapling_note(
&self,
txid: &TxId,
index: u32,
) -> Result<Option<ReceivedSaplingNote<Self::NoteRef>>, Self::Error> {
wallet::sapling::get_spendable_sapling_note(self.conn.borrow(), txid, index)
}
fn select_spendable_sapling_notes(
&self,
account: AccountId,
@ -196,6 +204,13 @@ impl<C: Borrow<rusqlite::Connection>, P: consensus::Parameters> TransparentInput
{
type Error = SqliteClientError;
fn get_unspent_transparent_output(
&self,
outpoint: &OutPoint,
) -> Result<Option<WalletTransparentOutput>, Self::Error> {
wallet::get_unspent_transparent_output(self.conn.borrow(), outpoint)
}
fn get_unspent_transparent_outputs(
&self,
address: &TransparentAddress,

View File

@ -14,7 +14,6 @@ use tempfile::NamedTempFile;
#[cfg(feature = "unstable")]
use tempfile::TempDir;
use zcash_client_backend::fees::{standard, DustOutputPolicy};
#[allow(deprecated)]
use zcash_client_backend::{
address::RecipientAddress,
@ -37,6 +36,10 @@ use zcash_client_backend::{
wallet::OvkPolicy,
zip321,
};
use zcash_client_backend::{
fees::{standard, DustOutputPolicy},
proto::proposal,
};
use zcash_note_encryption::Domain;
use zcash_primitives::{
block::BlockHash,
@ -555,7 +558,7 @@ impl<Cache> TestState<Cache> {
>,
> {
let params = self.network();
propose_standard_transfer_to_address::<_, _, CommitmentTreeErrT>(
let result = propose_standard_transfer_to_address::<_, _, CommitmentTreeErrT>(
&mut self.db_data,
&params,
fee_rule,
@ -565,7 +568,13 @@ impl<Cache> TestState<Cache> {
amount,
memo,
change_memo,
)
);
if let Ok(proposal) = &result {
check_proposal_serialization_roundtrip(self.wallet(), proposal);
}
result
}
/// Invokes [`propose_shielding`] with the given arguments.
@ -1061,3 +1070,17 @@ pub(crate) fn input_selector(
let change_strategy = standard::SingleOutputChangeStrategy::new(fee_rule, change_memo);
GreedyInputSelector::new(change_strategy, DustOutputPolicy::default())
}
// Checks that a protobuf proposal serialized from the provided proposal value correctly parses to
// the same proposal value.
pub(crate) fn check_proposal_serialization_roundtrip(
db_data: &WalletDb<rusqlite::Connection, Network>,
proposal: &Proposal<StandardFeeRule, ReceivedNoteId>,
) {
let proposal_proto = proposal::Proposal::from_standard_proposal(&db_data.params, proposal);
assert_matches!(proposal_proto, Some(_));
let deserialized_proposal = proposal_proto
.unwrap()
.try_into_standard_proposal(&db_data.params, db_data);
assert_matches!(deserialized_proposal, Ok(r) if &r == proposal);
}

View File

@ -110,6 +110,7 @@ use self::scanning::{parse_priority_code, replace_queue_entries};
#[cfg(feature = "transparent-inputs")]
use {
crate::UtxoId,
rusqlite::Row,
std::collections::BTreeSet,
zcash_client_backend::{address::AddressMetadata, wallet::WalletTransparentOutput},
zcash_primitives::{
@ -1287,6 +1288,65 @@ pub(crate) fn truncate_to_height<P: consensus::Parameters>(
Ok(())
}
#[cfg(feature = "transparent-inputs")]
fn to_unspent_transparent_output(row: &Row) -> Result<WalletTransparentOutput, SqliteClientError> {
let txid: Vec<u8> = row.get(0)?;
let mut txid_bytes = [0u8; 32];
txid_bytes.copy_from_slice(&txid);
let index: u32 = row.get(1)?;
let script_pubkey = Script(row.get(2)?);
let raw_value: i64 = row.get(3)?;
let value = NonNegativeAmount::from_nonnegative_i64(raw_value).map_err(|_| {
SqliteClientError::CorruptedData(format!("Invalid UTXO value: {}", raw_value))
})?;
let height: u32 = row.get(4)?;
let outpoint = OutPoint::new(txid_bytes, index);
WalletTransparentOutput::from_parts(
outpoint,
TxOut {
value,
script_pubkey,
},
BlockHeight::from(height),
)
.ok_or_else(|| {
SqliteClientError::CorruptedData(
"Txout script_pubkey value did not correspond to a P2PKH or P2SH address".to_string(),
)
})
}
#[cfg(feature = "transparent-inputs")]
pub(crate) fn get_unspent_transparent_output(
conn: &rusqlite::Connection,
outpoint: &OutPoint,
) -> Result<Option<WalletTransparentOutput>, SqliteClientError> {
let mut stmt_select_utxo = conn.prepare_cached(
"SELECT u.prevout_txid, u.prevout_idx, u.script, u.value_zat, u.height
FROM utxos u
LEFT OUTER JOIN transactions tx
ON tx.id_tx = u.spent_in_tx
WHERE u.prevout_txid = :txid
AND u.prevout_idx = :output_index
AND tx.block IS NULL",
)?;
let result: Result<Option<WalletTransparentOutput>, SqliteClientError> = stmt_select_utxo
.query_and_then(
named_params![
":txid": outpoint.hash(),
":output_index": outpoint.n()
],
to_unspent_transparent_output,
)?
.next()
.transpose();
result
}
/// Returns unspent transparent outputs that have been received by this wallet at the given
/// transparent address, such that the block that included the transaction was mined at a
/// height less than or equal to the provided `max_height`.
@ -1303,7 +1363,7 @@ pub(crate) fn get_unspent_transparent_outputs<P: consensus::Parameters>(
.unwrap_or(max_height)
.saturating_sub(PRUNING_DEPTH);
let mut stmt_blocks = conn.prepare(
let mut stmt_utxos = conn.prepare(
"SELECT u.prevout_txid, u.prevout_idx, u.script,
u.value_zat, u.height
FROM utxos u
@ -1317,45 +1377,18 @@ pub(crate) fn get_unspent_transparent_outputs<P: consensus::Parameters>(
let addr_str = address.encode(params);
let mut utxos = Vec::<WalletTransparentOutput>::new();
let mut rows = stmt_blocks.query(named_params![
let mut rows = stmt_utxos.query(named_params![
":address": addr_str,
":max_height": u32::from(max_height),
":stable_height": u32::from(stable_height),
])?;
let excluded: BTreeSet<OutPoint> = exclude.iter().cloned().collect();
while let Some(row) = rows.next()? {
let txid: Vec<u8> = row.get(0)?;
let mut txid_bytes = [0u8; 32];
txid_bytes.copy_from_slice(&txid);
let index: u32 = row.get(1)?;
let script_pubkey = Script(row.get(2)?);
let value_raw: i64 = row.get(3)?;
let value = NonNegativeAmount::from_nonnegative_i64(value_raw).map_err(|_| {
SqliteClientError::CorruptedData(format!("Negative utxo value: {}", value_raw))
})?;
let height: u32 = row.get(4)?;
let outpoint = OutPoint::new(txid_bytes, index);
if excluded.contains(&outpoint) {
let output = to_unspent_transparent_output(row)?;
if excluded.contains(output.outpoint()) {
continue;
}
let output = WalletTransparentOutput::from_parts(
outpoint,
TxOut {
value,
script_pubkey,
},
BlockHeight::from(height),
)
.ok_or_else(|| {
SqliteClientError::CorruptedData(
"Txout script_pubkey value did not correspond to a P2PKH or P2SH address"
.to_string(),
)
})?;
utxos.push(output);
}

View File

@ -135,6 +135,38 @@ fn to_spendable_note(row: &Row) -> Result<ReceivedSaplingNote<ReceivedNoteId>, S
))
}
// The `clippy::let_and_return` lint is explicitly allowed here because a bug in Clippy
// (https://github.com/rust-lang/rust-clippy/issues/11308) means it fails to identify that the `result` temporary
// is required in order to resolve the borrows involved in the `query_and_then` call.
#[allow(clippy::let_and_return)]
pub(crate) fn get_spendable_sapling_note(
conn: &Connection,
txid: &TxId,
index: u32,
) -> Result<Option<ReceivedSaplingNote<ReceivedNoteId>>, SqliteClientError> {
let mut stmt_select_note = conn.prepare_cached(
"SELECT id_note, txid, output_index, diversifier, value, rcm, commitment_tree_position
FROM sapling_received_notes
INNER JOIN transactions ON transactions.id_tx = sapling_received_notes.tx
WHERE txid = :txid
AND output_index = :output_index
AND spent IS NULL",
)?;
let result = stmt_select_note
.query_and_then(
named_params![
":txid": txid.as_ref(),
":output_index": index,
],
to_spendable_note,
)?
.next()
.transpose();
result
}
/// Utility method for determining whether we have any spendable notes
///
/// If the tip shard has unscanned ranges below the anchor height and greater than or equal to

View File

@ -82,6 +82,9 @@ and this library adheres to Rust's notion of
- `impl From<NonNegativeAmount> for zcash_primitives::sapling::value::NoteValue`
- `impl Sum<NonNegativeAmount> for Option<NonNegativeAmount>`
- `impl<'a> Sum<&'a NonNegativeAmount> for Option<NonNegativeAmount>`
- `impl {Clone, PartialEq, Eq} for zcash_primitives::memo::Error`
- `impl {PartialEq, Eq} for zcash_primitives::sapling::note::Rseed`
- `impl From<TxId> for [u8; 32]`
### Changed
- `zcash_primitives::sapling`:

View File

@ -28,7 +28,7 @@ where
}
/// Errors that may result from attempting to construct an invalid memo.
#[derive(Debug, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
InvalidUtf8(std::str::Utf8Error),
TooLong(usize),

View File

@ -16,7 +16,7 @@ pub(super) mod nullifier;
/// Before ZIP 212, the note commitment trapdoor `rcm` must be a scalar value.
/// After ZIP 212, the note randomness `rseed` is a 32-byte sequence, used to derive
/// both the note commitment trapdoor `rcm` and the ephemeral private key `esk`.
#[derive(Copy, Clone, Debug)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Rseed {
BeforeZip212(jubjub::Fr),
AfterZip212([u8; 32]),