diff --git a/CHANGELOG.md b/CHANGELOG.md index d9b6bc034..b787a21b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## v0.5.0 (TBD) * Added the Web Client Crate +* Added validations in transaction requests (#447). * [BREAKING] Refactored `Client` to merge submit_transaction and prove_transaction (#445) * Tracked token symbols with config file (#441). * [BREAKING] Refactored `TransactionRequest` to represent a generalized transaction (#438). diff --git a/crates/rust-client/Cargo.toml b/crates/rust-client/Cargo.toml index e43e9d075..f369bc6be 100644 --- a/crates/rust-client/Cargo.toml +++ b/crates/rust-client/Cargo.toml @@ -58,7 +58,7 @@ miden-objects = {default-features = false, features = ["serde", "testing"], git uuid = { version = "1.9", features = ["serde", "v4"] } [build-dependencies] -miden-rpc-proto = { version = "0.4" } +miden-rpc-proto = { git = "https://github.com/0xPolygonMiden/miden-node/", branch = "next" } miette = { version = "7.2", features = ["fancy"] } prost = { version = "0.12", default-features = false, features = ["derive"] } prost-build = { version = "0.12", default-features = false } diff --git a/crates/rust-client/src/rpc/tonic_client/generated/requests.rs b/crates/rust-client/src/rpc/tonic_client/generated/requests.rs index 7ce1e69ac..a75da6f50 100644 --- a/crates/rust-client/src/rpc/tonic_client/generated/requests.rs +++ b/crates/rust-client/src/rpc/tonic_client/generated/requests.rs @@ -5,6 +5,18 @@ pub struct ApplyBlockRequest { #[prost(bytes = "vec", tag = "1")] pub block: ::prost::alloc::vec::Vec, } +/// Returns a list of nullifiers that match the specified prefixes and are recorded in the node. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CheckNullifiersByPrefixRequest { + /// Number of bits used for nullifier prefix. Currently the only supported value is 16. + #[prost(uint32, tag = "1")] + pub prefix_len: u32, + /// List of nullifiers to check. Each nullifier is specified by its prefix with length equal + /// to prefix_len + #[prost(uint32, repeated, tag = "2")] + pub nullifiers: ::prost::alloc::vec::Vec, +} #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct CheckNullifiersRequest { @@ -47,20 +59,29 @@ pub struct SyncStateRequest { /// it won't be included in the response. #[prost(message, repeated, tag = "2")] pub account_ids: ::prost::alloc::vec::Vec, - /// Determines the tags which the client is interested in. These are only the 16high bits of the - /// note's complete tag. - /// - /// The above means it is not possible to request an specific note, but only a "note family", - /// this is done to increase the privacy of the client, by hiding the note's the client is - /// intereted on. - #[prost(uint32, repeated, tag = "3")] + /// Specifies the tags which the client is interested in. + #[prost(fixed32, repeated, tag = "3")] pub note_tags: ::prost::alloc::vec::Vec, - /// Determines the nullifiers the client is interested in. - /// - /// Similarly to the note_tags, this determins only the 16high bits of the target nullifier. + /// Determines the nullifiers the client is interested in by specifying the 16high bits of the + /// target nullifier. #[prost(uint32, repeated, tag = "4")] pub nullifiers: ::prost::alloc::vec::Vec, } +/// Note synchronization request. +/// +/// Specifies note tags that client is intersted in. The server will return the first block which +/// contains a note matching `note_tags` or the chain tip. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SyncNoteRequest { + /// Last block known by the client. The response will contain data starting from the next block, + /// until the first block which contains a note of matching the requested tag. + #[prost(fixed32, tag = "1")] + pub block_num: u32, + /// Specifies the tags which the client is interested in. + #[prost(fixed32, repeated, tag = "2")] + pub note_tags: ::prost::alloc::vec::Vec, +} #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GetBlockInputsRequest { @@ -122,3 +143,18 @@ pub struct GetBlockByNumberRequest { #[prost(fixed32, tag = "1")] pub block_num: u32, } +/// Returns delta of the account states in the range from `from_block_num` (exclusive) to +/// `to_block_num` (inclusive). +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetAccountStateDeltaRequest { + /// ID of the account for which the delta is requested. + #[prost(message, optional, tag = "1")] + pub account_id: ::core::option::Option, + /// Block number from which the delta is requested (exclusive). + #[prost(fixed32, tag = "2")] + pub from_block_num: u32, + /// Block number up to which the delta is requested (inclusive). + #[prost(fixed32, tag = "3")] + pub to_block_num: u32, +} diff --git a/crates/rust-client/src/rpc/tonic_client/generated/responses.rs b/crates/rust-client/src/rpc/tonic_client/generated/responses.rs index fce40c797..e5cc8d2f9 100644 --- a/crates/rust-client/src/rpc/tonic_client/generated/responses.rs +++ b/crates/rust-client/src/rpc/tonic_client/generated/responses.rs @@ -11,6 +11,13 @@ pub struct CheckNullifiersResponse { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct CheckNullifiersByPrefixResponse { + /// List of nullifiers matching the prefixes specified in the request. + #[prost(message, repeated, tag = "1")] + pub nullifiers: ::prost::alloc::vec::Vec, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct GetBlockHeaderByNumberResponse { /// The requested block header #[prost(message, optional, tag = "1")] @@ -56,6 +63,27 @@ pub struct SyncStateResponse { #[prost(message, repeated, tag = "8")] pub nullifiers: ::prost::alloc::vec::Vec, } +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SyncNoteResponse { + /// Number of the latest block in the chain + #[prost(fixed32, tag = "1")] + pub chain_tip: u32, + /// Block header of the block with the first note matching the specified criteria + #[prost(message, optional, tag = "2")] + pub block_header: ::core::option::Option, + /// Proof for block header's MMR with respect to the chain tip. + /// + /// More specifically, the full proof consists of `forest`, `position` and `path` components. This + /// value constitutes the `path`. The other two components can be obtained as follows: + /// - `position` is simply `resopnse.block_header.block_num` + /// - `forest` is the same as `response.chain_tip + 1` + #[prost(message, optional, tag = "3")] + pub mmr_path: ::core::option::Option, + /// List of all notes together with the Merkle paths from `response.block_header.note_root` + #[prost(message, repeated, tag = "4")] + pub notes: ::prost::alloc::vec::Vec, +} /// An account returned as a response to the GetBlockInputs #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] @@ -170,3 +198,10 @@ pub struct GetBlockByNumberResponse { #[prost(bytes = "vec", optional, tag = "1")] pub block: ::core::option::Option<::prost::alloc::vec::Vec>, } +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetAccountStateDeltaResponse { + /// The calculated `AccountStateDelta` encoded using miden native format + #[prost(bytes = "vec", optional, tag = "1")] + pub delta: ::core::option::Option<::prost::alloc::vec::Vec>, +} diff --git a/crates/rust-client/src/rpc/tonic_client/generated/rpc.rs b/crates/rust-client/src/rpc/tonic_client/generated/rpc.rs index 70e5a6627..a5c25f2fe 100644 --- a/crates/rust-client/src/rpc/tonic_client/generated/rpc.rs +++ b/crates/rust-client/src/rpc/tonic_client/generated/rpc.rs @@ -108,6 +108,33 @@ pub mod api_client { req.extensions_mut().insert(GrpcMethod::new("rpc.Api", "CheckNullifiers")); self.inner.unary(req, path, codec).await } + pub async fn check_nullifiers_by_prefix( + &mut self, + request: impl tonic::IntoRequest< + super::super::requests::CheckNullifiersByPrefixRequest, + >, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::new( + tonic::Code::Unknown, + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/rpc.Api/CheckNullifiersByPrefix", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("rpc.Api", "CheckNullifiersByPrefix")); + self.inner.unary(req, path, codec).await + } pub async fn get_account_details( &mut self, request: impl tonic::IntoRequest< @@ -134,6 +161,33 @@ pub mod api_client { req.extensions_mut().insert(GrpcMethod::new("rpc.Api", "GetAccountDetails")); self.inner.unary(req, path, codec).await } + pub async fn get_account_state_delta( + &mut self, + request: impl tonic::IntoRequest< + super::super::requests::GetAccountStateDeltaRequest, + >, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::new( + tonic::Code::Unknown, + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/rpc.Api/GetAccountStateDelta", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("rpc.Api", "GetAccountStateDelta")); + self.inner.unary(req, path, codec).await + } pub async fn get_block_by_number( &mut self, request: impl tonic::IntoRequest< @@ -234,6 +288,28 @@ pub mod api_client { .insert(GrpcMethod::new("rpc.Api", "SubmitProvenTransaction")); self.inner.unary(req, path, codec).await } + pub async fn sync_notes( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::new( + tonic::Code::Unknown, + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static("/rpc.Api/SyncNotes"); + let mut req = request.into_request(); + req.extensions_mut().insert(GrpcMethod::new("rpc.Api", "SyncNotes")); + self.inner.unary(req, path, codec).await + } pub async fn sync_state( &mut self, request: impl tonic::IntoRequest, diff --git a/crates/rust-client/src/rpc/web_tonic_client/generated/requests.rs b/crates/rust-client/src/rpc/web_tonic_client/generated/requests.rs index 7ce1e69ac..a75da6f50 100644 --- a/crates/rust-client/src/rpc/web_tonic_client/generated/requests.rs +++ b/crates/rust-client/src/rpc/web_tonic_client/generated/requests.rs @@ -5,6 +5,18 @@ pub struct ApplyBlockRequest { #[prost(bytes = "vec", tag = "1")] pub block: ::prost::alloc::vec::Vec, } +/// Returns a list of nullifiers that match the specified prefixes and are recorded in the node. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CheckNullifiersByPrefixRequest { + /// Number of bits used for nullifier prefix. Currently the only supported value is 16. + #[prost(uint32, tag = "1")] + pub prefix_len: u32, + /// List of nullifiers to check. Each nullifier is specified by its prefix with length equal + /// to prefix_len + #[prost(uint32, repeated, tag = "2")] + pub nullifiers: ::prost::alloc::vec::Vec, +} #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct CheckNullifiersRequest { @@ -47,20 +59,29 @@ pub struct SyncStateRequest { /// it won't be included in the response. #[prost(message, repeated, tag = "2")] pub account_ids: ::prost::alloc::vec::Vec, - /// Determines the tags which the client is interested in. These are only the 16high bits of the - /// note's complete tag. - /// - /// The above means it is not possible to request an specific note, but only a "note family", - /// this is done to increase the privacy of the client, by hiding the note's the client is - /// intereted on. - #[prost(uint32, repeated, tag = "3")] + /// Specifies the tags which the client is interested in. + #[prost(fixed32, repeated, tag = "3")] pub note_tags: ::prost::alloc::vec::Vec, - /// Determines the nullifiers the client is interested in. - /// - /// Similarly to the note_tags, this determins only the 16high bits of the target nullifier. + /// Determines the nullifiers the client is interested in by specifying the 16high bits of the + /// target nullifier. #[prost(uint32, repeated, tag = "4")] pub nullifiers: ::prost::alloc::vec::Vec, } +/// Note synchronization request. +/// +/// Specifies note tags that client is intersted in. The server will return the first block which +/// contains a note matching `note_tags` or the chain tip. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SyncNoteRequest { + /// Last block known by the client. The response will contain data starting from the next block, + /// until the first block which contains a note of matching the requested tag. + #[prost(fixed32, tag = "1")] + pub block_num: u32, + /// Specifies the tags which the client is interested in. + #[prost(fixed32, repeated, tag = "2")] + pub note_tags: ::prost::alloc::vec::Vec, +} #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct GetBlockInputsRequest { @@ -122,3 +143,18 @@ pub struct GetBlockByNumberRequest { #[prost(fixed32, tag = "1")] pub block_num: u32, } +/// Returns delta of the account states in the range from `from_block_num` (exclusive) to +/// `to_block_num` (inclusive). +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetAccountStateDeltaRequest { + /// ID of the account for which the delta is requested. + #[prost(message, optional, tag = "1")] + pub account_id: ::core::option::Option, + /// Block number from which the delta is requested (exclusive). + #[prost(fixed32, tag = "2")] + pub from_block_num: u32, + /// Block number up to which the delta is requested (inclusive). + #[prost(fixed32, tag = "3")] + pub to_block_num: u32, +} diff --git a/crates/rust-client/src/rpc/web_tonic_client/generated/responses.rs b/crates/rust-client/src/rpc/web_tonic_client/generated/responses.rs index fce40c797..e5cc8d2f9 100644 --- a/crates/rust-client/src/rpc/web_tonic_client/generated/responses.rs +++ b/crates/rust-client/src/rpc/web_tonic_client/generated/responses.rs @@ -11,6 +11,13 @@ pub struct CheckNullifiersResponse { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct CheckNullifiersByPrefixResponse { + /// List of nullifiers matching the prefixes specified in the request. + #[prost(message, repeated, tag = "1")] + pub nullifiers: ::prost::alloc::vec::Vec, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct GetBlockHeaderByNumberResponse { /// The requested block header #[prost(message, optional, tag = "1")] @@ -56,6 +63,27 @@ pub struct SyncStateResponse { #[prost(message, repeated, tag = "8")] pub nullifiers: ::prost::alloc::vec::Vec, } +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SyncNoteResponse { + /// Number of the latest block in the chain + #[prost(fixed32, tag = "1")] + pub chain_tip: u32, + /// Block header of the block with the first note matching the specified criteria + #[prost(message, optional, tag = "2")] + pub block_header: ::core::option::Option, + /// Proof for block header's MMR with respect to the chain tip. + /// + /// More specifically, the full proof consists of `forest`, `position` and `path` components. This + /// value constitutes the `path`. The other two components can be obtained as follows: + /// - `position` is simply `resopnse.block_header.block_num` + /// - `forest` is the same as `response.chain_tip + 1` + #[prost(message, optional, tag = "3")] + pub mmr_path: ::core::option::Option, + /// List of all notes together with the Merkle paths from `response.block_header.note_root` + #[prost(message, repeated, tag = "4")] + pub notes: ::prost::alloc::vec::Vec, +} /// An account returned as a response to the GetBlockInputs #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] @@ -170,3 +198,10 @@ pub struct GetBlockByNumberResponse { #[prost(bytes = "vec", optional, tag = "1")] pub block: ::core::option::Option<::prost::alloc::vec::Vec>, } +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetAccountStateDeltaResponse { + /// The calculated `AccountStateDelta` encoded using miden native format + #[prost(bytes = "vec", optional, tag = "1")] + pub delta: ::core::option::Option<::prost::alloc::vec::Vec>, +} diff --git a/crates/rust-client/src/rpc/web_tonic_client/generated/rpc.rs b/crates/rust-client/src/rpc/web_tonic_client/generated/rpc.rs index 46bd6cfdd..a38717e63 100644 --- a/crates/rust-client/src/rpc/web_tonic_client/generated/rpc.rs +++ b/crates/rust-client/src/rpc/web_tonic_client/generated/rpc.rs @@ -97,6 +97,33 @@ pub mod api_client { req.extensions_mut().insert(GrpcMethod::new("rpc.Api", "CheckNullifiers")); self.inner.unary(req, path, codec).await } + pub async fn check_nullifiers_by_prefix( + &mut self, + request: impl tonic::IntoRequest< + super::super::requests::CheckNullifiersByPrefixRequest, + >, + ) -> core::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::new( + tonic::Code::Unknown, + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/rpc.Api/CheckNullifiersByPrefix", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("rpc.Api", "CheckNullifiersByPrefix")); + self.inner.unary(req, path, codec).await + } pub async fn get_account_details( &mut self, request: impl tonic::IntoRequest< @@ -123,6 +150,33 @@ pub mod api_client { req.extensions_mut().insert(GrpcMethod::new("rpc.Api", "GetAccountDetails")); self.inner.unary(req, path, codec).await } + pub async fn get_account_state_delta( + &mut self, + request: impl tonic::IntoRequest< + super::super::requests::GetAccountStateDeltaRequest, + >, + ) -> core::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::new( + tonic::Code::Unknown, + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/rpc.Api/GetAccountStateDelta", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("rpc.Api", "GetAccountStateDelta")); + self.inner.unary(req, path, codec).await + } pub async fn get_block_by_number( &mut self, request: impl tonic::IntoRequest< @@ -223,6 +277,28 @@ pub mod api_client { .insert(GrpcMethod::new("rpc.Api", "SubmitProvenTransaction")); self.inner.unary(req, path, codec).await } + pub async fn sync_notes( + &mut self, + request: impl tonic::IntoRequest, + ) -> core::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::new( + tonic::Code::Unknown, + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static("/rpc.Api/SyncNotes"); + let mut req = request.into_request(); + req.extensions_mut().insert(GrpcMethod::new("rpc.Api", "SyncNotes")); + self.inner.unary(req, path, codec).await + } pub async fn sync_state( &mut self, request: impl tonic::IntoRequest, diff --git a/crates/rust-client/src/transactions/mod.rs b/crates/rust-client/src/transactions/mod.rs index d85a879b2..7d117cf67 100644 --- a/crates/rust-client/src/transactions/mod.rs +++ b/crates/rust-client/src/transactions/mod.rs @@ -1,5 +1,5 @@ use alloc::{ - collections::BTreeSet, + collections::{BTreeMap, BTreeSet}, string::{String, ToString}, vec::Vec, }; @@ -7,12 +7,12 @@ use core::fmt; use miden_lib::notes::{create_p2id_note, create_p2idr_note, create_swap_note}; use miden_objects::{ - accounts::{AccountDelta, AccountId, AccountType}, + accounts::{Account, AccountDelta, AccountId, AccountType}, assembly::ProgramAst, - assets::FungibleAsset, + assets::{Asset, FungibleAsset, NonFungibleAsset}, notes::{Note, NoteDetails, NoteExecutionMode, NoteId, NoteTag, NoteType}, transaction::{InputNotes, TransactionArgs}, - Digest, Felt, FieldElement, NoteError, Word, + AssetError, Digest, Felt, FieldElement, NoteError, Word, }; use miden_tx::{auth::TransactionAuthenticator, ProvingOptions, TransactionProver}; use request::{TransactionRequestError, TransactionScriptTemplate}; @@ -237,6 +237,9 @@ impl Client &mut self, transaction_request: TransactionRequest, ) -> Result { + // Validates the transaction request before executing + maybe_await!(self.validate_request(&transaction_request))?; + let account_id = transaction_request.account_id(); maybe_await!(self.tx_executor.load_account(account_id)) .map_err(ClientError::TransactionExecutorError)?; @@ -371,6 +374,141 @@ impl Client // HELPERS // -------------------------------------------------------------------------------------------- + /// Helper to get the account outgoing assets. + /// + /// Any outgoing assets resulting from executing note scripts but not present in expected output + /// notes would not be included. + fn get_outgoing_assets( + &self, + transaction_request: &TransactionRequest, + ) -> (BTreeMap, BTreeSet) { + // Get own notes assets + let mut own_notes_assets = match transaction_request.script_template() { + Some(TransactionScriptTemplate::SendNotes(notes)) => { + notes.iter().map(|note| (note.id(), note.assets())).collect::>() + }, + _ => Default::default(), + }; + // Get transaction output notes assets + let mut output_notes_assets = transaction_request + .expected_output_notes() + .map(|note| (note.id(), note.assets())) + .collect::>(); + + // Merge with own notes assets and delete duplicates + output_notes_assets.append(&mut own_notes_assets); + + // Create a map of the fungible and non-fungible assets in the output notes + let outgoing_assets = + output_notes_assets.values().flat_map(|note_assets| note_assets.iter()); + + collect_assets(outgoing_assets) + } + + /// Helper to get the account incoming assets. + #[maybe_async] + fn get_incoming_assets( + &self, + transaction_request: &TransactionRequest, + ) -> Result<(BTreeMap, BTreeSet), TransactionRequestError> + { + // Get incoming asset notes excluding unauthenticated ones + let incoming_notes_ids: Vec<_> = transaction_request + .input_notes() + .iter() + .filter_map(|(note_id, _)| { + if transaction_request + .unauthenticated_input_notes() + .iter() + .any(|note| note.id() == *note_id) + { + None + } else { + Some(*note_id) + } + }) + .collect(); + + let store_input_notes = + maybe_await!(self.get_input_notes(NoteFilter::List(&incoming_notes_ids))) + .map_err(|err| TransactionRequestError::NoteNotFound(err.to_string()))?; + + let all_incoming_assets = + store_input_notes.iter().flat_map(|note| note.assets().iter()).chain( + transaction_request + .unauthenticated_input_notes() + .iter() + .flat_map(|note| note.assets().iter()), + ); + + Ok(collect_assets(all_incoming_assets)) + } + + #[maybe_async] + fn validate_basic_account_request( + &self, + transaction_request: &TransactionRequest, + account: &Account, + ) -> Result<(), ClientError> { + // Get outgoing assets + let (fungible_balance_map, non_fungible_set) = + self.get_outgoing_assets(transaction_request); + + // Get incoming assets + let (incoming_fungible_balance_map, incoming_non_fungible_balance_set) = + maybe_await!(self.get_incoming_assets(transaction_request))?; + + // Check if the account balance plus incoming assets is greater than or equal to the outgoing + // fungible assets + for (faucet_id, amount) in fungible_balance_map { + let account_asset_amount = account.vault().get_balance(faucet_id).unwrap_or(0); + let incoming_balance = incoming_fungible_balance_map.get(&faucet_id).unwrap_or(&0); + if account_asset_amount + incoming_balance < amount { + return Err(ClientError::AssetError(AssetError::AssetAmountNotSufficient( + account_asset_amount, + amount, + ))); + } + } + + // Check if the account balance plus incoming assets is greater than or equal to the outgoing + // non fungible assets + for non_fungible in non_fungible_set { + match account.vault().has_non_fungible_asset(non_fungible.into()) { + Ok(true) => (), + Ok(false) => { + // Check if the non fungible asset is in the incoming assets + if !incoming_non_fungible_balance_set.contains(&non_fungible) { + return Err(ClientError::AssetError(AssetError::AssetAmountNotSufficient( + 0, 1, + ))); + } + }, + _ => { + return Err(ClientError::AssetError(AssetError::AssetAmountNotSufficient( + 0, 1, + ))); + }, + } + } + + Ok(()) + } + + #[maybe_async] + fn validate_request( + &self, + transaction_request: &TransactionRequest, + ) -> Result<(), ClientError> { + let (account, _) = maybe_await!(self.get_account(transaction_request.account_id()))?; + if account.is_faucet() { + // TODO(SantiagoPittella): Add faucet validations. + Ok(()) + } else { + maybe_await!(self.validate_basic_account_request(transaction_request, &account)) + } + } + /// Helper to build a [TransactionRequest] for P2ID-type transactions easily. /// /// - auth_info has to be from the executor account @@ -481,6 +619,27 @@ impl Client // HELPERS // ================================================================================================ +fn collect_assets<'a>( + assets: impl Iterator, +) -> (BTreeMap, BTreeSet) { + let mut fungible_balance_map = BTreeMap::new(); + let mut non_fungible_set = BTreeSet::new(); + + assets.for_each(|asset| match asset { + Asset::Fungible(fungible) => { + fungible_balance_map + .entry(fungible.faucet_id()) + .and_modify(|balance| *balance += fungible.amount()) + .or_insert(fungible.amount()); + }, + Asset::NonFungible(non_fungible) => { + non_fungible_set.insert(*non_fungible); + }, + }); + + (fungible_balance_map, non_fungible_set) +} + pub(crate) fn prepare_word(word: &Word) -> String { word.iter().map(|x| x.as_int().to_string()).collect::>().join(".") } diff --git a/crates/rust-client/src/transactions/request.rs b/crates/rust-client/src/transactions/request.rs index 5fa43c36b..cc3e5b5cd 100644 --- a/crates/rust-client/src/transactions/request.rs +++ b/crates/rust-client/src/transactions/request.rs @@ -268,6 +268,7 @@ pub enum TransactionRequestError { InvalidTransactionScript(AssemblyError), NoInputNotes, ScriptTemplateError(String), + NoteNotFound(String), } impl fmt::Display for TransactionRequestError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -279,6 +280,7 @@ impl fmt::Display for TransactionRequestError { Self::InvalidTransactionScript(err) => write!(f, "Invalid transaction script: {}", err), Self::NoInputNotes => write!(f, "A transaction without output notes must have at least one input note"), Self::ScriptTemplateError(err) => write!(f, "Transaction script template error: {}", err), + Self::NoteNotFound(err) => write!(f, "Note not found: {}", err), } } } diff --git a/tests/integration/common.rs b/tests/integration/common.rs index 5d23fc90b..7c281fe7a 100644 --- a/tests/integration/common.rs +++ b/tests/integration/common.rs @@ -90,6 +90,19 @@ pub fn create_test_store_path() -> std::path::PathBuf { temp_file } +pub async fn execute_failing_tx( + client: &mut TestClient, + tx_request: TransactionRequest, + expected_error: ClientError, +) { + println!("Executing transaction..."); + // We compare string since we can't compare the error directly + assert_eq!( + client.new_transaction(tx_request).unwrap_err().to_string(), + expected_error.to_string() + ); +} + pub async fn execute_tx(client: &mut TestClient, tx_request: TransactionRequest) -> TransactionId { println!("Executing transaction..."); let transaction_execution_result = client.new_transaction(tx_request).unwrap(); diff --git a/tests/integration/main.rs b/tests/integration/main.rs index d8b7b4fad..609edcdc4 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -116,6 +116,42 @@ async fn test_p2id_transfer() { assert_note_cannot_be_consumed_twice(&mut client, to_account_id, notes[0].id()).await; } +#[tokio::test] +async fn test_p2id_transfer_failing_not_enough_balance() { + let mut client = create_test_client(); + wait_for_node(&mut client).await; + + let (first_regular_account, second_regular_account, faucet_account_stub) = + setup(&mut client, AccountStorageType::OffChain).await; + + let from_account_id = first_regular_account.id(); + let to_account_id = second_regular_account.id(); + let faucet_account_id = faucet_account_stub.id(); + + // First Mint necesary token + let note = mint_note(&mut client, from_account_id, faucet_account_id, NoteType::Private).await; + consume_notes(&mut client, from_account_id, &[note]).await; + assert_account_has_single_asset(&client, from_account_id, faucet_account_id, MINT_AMOUNT).await; + + // Do a transfer from first account to second account + let asset = FungibleAsset::new(faucet_account_id, MINT_AMOUNT + 1).unwrap(); + let tx_template = TransactionTemplate::PayToId( + PaymentTransactionData::new(Asset::Fungible(asset), from_account_id, to_account_id), + NoteType::Private, + ); + println!("Running P2ID tx..."); + let tx_request = client.build_transaction_request(tx_template).unwrap(); + execute_failing_tx( + &mut client, + tx_request, + ClientError::AssetError(miden_objects::AssetError::AssetAmountNotSufficient( + MINT_AMOUNT, + MINT_AMOUNT + 1, + )), + ) + .await; +} + #[tokio::test] async fn test_p2idr_transfer_consumed_by_target() { let mut client = create_test_client();