diff --git a/src/encoding.rs b/src/encoding.rs index e27dd94..8b35888 100644 --- a/src/encoding.rs +++ b/src/encoding.rs @@ -53,10 +53,6 @@ pub trait Encodable { fn encode(&self, encoder: &mut Encoder); } -impl Encodable for Hash256 { - fn encode(&self, encoder: &mut Encoder) { encoder.write_slice(&self.0); } -} - #[cfg(test)] mod test { use super::*; diff --git a/src/tests/transaction.rs b/src/tests/transaction.rs index 3de3c25..4974208 100644 --- a/src/tests/transaction.rs +++ b/src/tests/transaction.rs @@ -180,7 +180,7 @@ mod test { }; let hash = Encoder::encode_and_hash(&satisfied_policy); - let expected = Hash256::from_str("51832be911c7382502a2011cbddf1a9f689c4ca08c6a83ae3d021fb0dc781822").unwrap(); + let expected = Hash256::from_str("92d9097978387a5da9d17435b796984dae6bd4342c88684d0949e406755c289c").unwrap(); assert_eq!(hash, expected); } @@ -194,11 +194,10 @@ mod test { }; let hash = Encoder::encode_and_hash(&satisfied_policy); - let expected = Hash256::from_str("1e612d1ee36338b93a36bac0c52007a2d678cde0bd9b95c36a1f61166cf02b87").unwrap(); + let expected = Hash256::from_str("abac830016d15871dfefad87ddfce263a6936b77e8ec18e7712870d6bf771376").unwrap(); assert_eq!(hash, expected); } - // Adding a signature to SatisfiedPolicy of PolicyHash should have no effect fn test_satisfied_policy_encode_hash_frivulous_signature() { let policy = SpendPolicy::Hash(Hash256::default()); @@ -213,7 +212,7 @@ mod test { }; let hash = Encoder::encode_and_hash(&satisfied_policy); - let expected = Hash256::from_str("80f3caa4507615945bc839c8505546decd91e9642120f26938b2fc370fa61992").unwrap(); + let expected = Hash256::from_str("f6885827fb8a6d1a5751ce3f5a8580dc590f262f42e2dd9944052ec43ffc8d97").unwrap(); assert_eq!(hash, expected); } @@ -229,7 +228,7 @@ mod test { }; let hash = Encoder::encode_and_hash(&satisfied_policy); - let expected = Hash256::from_str("80f3caa4507615945bc839c8505546decd91e9642120f26938b2fc370fa61992").unwrap(); + let expected = Hash256::from_str("e3bbd67ade36322f3de8458b1daa80fd21bb74af88c779b768908e007611f36e").unwrap(); assert_eq!(hash, expected); } @@ -253,7 +252,7 @@ mod test { }; let hash = Encoder::encode_and_hash(&satisfied_policy); - let expected = Hash256::from_str("c749f9ac53395ec557aed7e21d202f76a58e0de79222e5756b27077e9295931f").unwrap(); + let expected = Hash256::from_str("0411ac20ae5472822bdc6c24c9ba2afdd828300ed3706cb1c07a8578276fd72d").unwrap(); assert_eq!(hash, expected); } @@ -289,7 +288,7 @@ mod test { }; let hash = Encoder::encode_and_hash(&satisfied_policy); - let expected = Hash256::from_str("13806b6c13a97478e476e0e5a0469c9d0ad8bf286bec0ada992e363e9fc60901").unwrap(); + let expected = Hash256::from_str("b4d658dbc32b3e147d2736f75b14ca881d5c04963663993b6448c86f4f1a2815").unwrap(); assert_eq!(hash, expected); } @@ -308,8 +307,7 @@ mod test { }; let hash = Encoder::encode_and_hash(&satisfied_policy); - // FIXME update this in go equivalent. Preimage was changed from Vec to [u8; 32] - let expected = Hash256::from_str("2200a1464864cfaea8d312c1f16b5e00b816110896bea32ef7e1ccd43042d312").unwrap(); + let expected = Hash256::from_str("5cd34ed67f2b2a55d016b4c485dfd1ca2eca75f6831cec9eed9494d6fa735315").unwrap(); assert_eq!(hash, expected); } @@ -338,8 +336,7 @@ mod test { }; let hash = Encoder::encode_and_hash(&satisfied_policy); - // FIXME update this in go equivalent. Preimage was changed from Vec to [u8; 32] - let expected = Hash256::from_str("08852e4ad99f726120028ecd82925b5f55fa441952cfc034a5cf4f09159b9372").unwrap(); + let expected = Hash256::from_str("30abac67d0017556ae69416f54663edbe2fb14c7bcef028f2d228aef500e8f51").unwrap(); assert_eq!(hash, expected); } @@ -368,7 +365,7 @@ mod test { }; let hash = Encoder::encode_and_hash(&satisfied_policy); - let expected = Hash256::from_str("8975e8cf990d5a20d9ec3dae18ed3b3a0c92edf967a8d93fcdef6a1eb73bb348").unwrap(); + let expected = Hash256::from_str("69b26bdb1114af01e4626d2a31184706e1dc83d83063c9019f9ee66381bd6923").unwrap(); assert_eq!(hash, expected); } @@ -404,8 +401,7 @@ mod test { }; let hash = Encoder::encode_and_hash(&vin); - // FIXME update this in go equivalent. Preimage was changed from Vec to [u8; 32] - let expected = Hash256::from_str("d31a05b155113a5244f14ae833887fd8b30f555129be126ca4b90592290db24a").unwrap(); + let expected = Hash256::from_str("102a2924e7427ee3654bfeea8fc055fd82c2a403598484dbb704da9cdaada3ba").unwrap(); assert_eq!(hash, expected); } diff --git a/src/transport/client.rs b/src/transport/client.rs index 85d5640..4b311c1 100644 --- a/src/transport/client.rs +++ b/src/transport/client.rs @@ -9,14 +9,8 @@ use url::Url; #[cfg(not(target_arch = "wasm32"))] pub mod native; #[cfg(target_arch = "wasm32")] pub mod wasm; -mod helpers; -pub use helpers::{ApiClientHelpers, HelperError}; - -// FIXME remove these client specific error types -#[cfg(not(target_arch = "wasm32"))] -use reqwest::Error as ReqwestError; - -#[cfg(target_arch = "wasm32")] use wasm::wasm_fetch::FetchError; +pub(crate) mod helpers; +pub use helpers::ApiClientHelpers; // Client implementation is generalized // This allows for different client implementations (e.g., WebSocket, libp2p, etc.) @@ -26,45 +20,16 @@ pub trait ApiClient: Clone { type Request; type Response; type Conf; + type Error: std::error::Error + std::fmt::Debug; - async fn new(conf: Self::Conf) -> Result + async fn new(conf: Self::Conf) -> Result where Self: Sized; - fn process_schema(&self, schema: EndpointSchema) -> Result; - - fn to_data_request(&self, request: R) -> Result { - self.process_schema(request.to_endpoint_schema()?) - } - - // TODO this can have a default implementation if an associated type can provide .execute() - // eg self.client().execute(request).await.map_err(Self::ClientError) - async fn execute_request(&self, request: Self::Request) -> Result; + fn process_schema(&self, schema: EndpointSchema) -> Result; // TODO default implementation should be possible if Execute::Response is a serde deserializable type - async fn dispatcher(&self, request: R) -> Result; -} - -#[derive(Debug, Error)] -pub enum ApiClientError { - #[error("BuildError error: {0}")] - BuildError(String), - #[error("FixmePlaceholder error: {0}")] - FixmePlaceholder(String), // FIXME this entire enum needs refactoring to not use client-specific error types - #[error("UrlParse error: {0}")] - UrlParse(#[from] url::ParseError), - #[error("UnexpectedHttpStatus error: status:{status} body:{body}")] - UnexpectedHttpStatus { status: http::StatusCode, body: String }, - #[error("Serde error: {0}")] - Serde(#[from] serde_json::Error), - #[error("UnexpectedEmptyResponse error: {expected_type}")] - UnexpectedEmptyResponse { expected_type: String }, - #[error("WasmFetchError error: {0}")] - #[cfg(target_arch = "wasm32")] - WasmFetchError(#[from] FetchError), - #[error("ReqwestError error: {0}")] - #[cfg(not(target_arch = "wasm32"))] - ReqwestError(#[from] ReqwestError), // FIXME remove this; it should be generalized enough to not need arch-specific error types + async fn dispatcher(&self, request: R) -> Result; } // Not all client implementations will have an exact equivalent of HTTP methods @@ -150,9 +115,15 @@ pub enum Body { None, } +#[derive(Debug, Error)] +pub enum EndpointSchemaError { + #[error("EndpointSchema::build_url: failed to parse Url from constructed path: {0}")] + ParseUrl(#[from] url::ParseError), +} + impl EndpointSchema { // Safely build the URL using percent-encoding for path params - pub fn build_url(&self, base_url: &Url) -> Result { + pub fn build_url(&self, base_url: &Url) -> Result { let mut path = self.path_schema.to_string(); // Replace placeholders in the path with encoded values if path_params are provided @@ -164,7 +135,7 @@ impl EndpointSchema { } // Combine base_url with the constructed path - let mut url = base_url.join(&path).map_err(ApiClientError::UrlParse)?; + let mut url = base_url.join(&path)?; // Add query parameters if any if let Some(query_params) = &self.query_params { diff --git a/src/transport/client/helpers.rs b/src/transport/client/helpers.rs index dd3db78..39930a3 100644 --- a/src/transport/client/helpers.rs +++ b/src/transport/client/helpers.rs @@ -1,95 +1,155 @@ -use super::{ApiClient, ApiClientError}; +use super::ApiClient; use crate::transport::endpoints::{AddressBalanceRequest, AddressBalanceResponse, AddressesEventsRequest, ConsensusIndexRequest, ConsensusTipRequest, ConsensusTipstateRequest, ConsensusTipstateResponse, ConsensusUpdatesRequest, ConsensusUpdatesResponse, - GetAddressUtxosRequest, GetEventRequest, TxpoolBroadcastRequest, - TxpoolTransactionsRequest}; + DebugMineRequest, GetAddressUtxosRequest, GetEventRequest, TxpoolBroadcastRequest, + TxpoolTransactionsRequest, UtxosWithBasis}; use crate::types::{Address, Currency, Event, EventDataWrapper, Hash256, PublicKey, SiacoinElement, SiacoinOutputId, - SpendPolicy, TransactionId, V2Transaction, V2TransactionBuilder}; + SpendPolicy, TransactionId, UtxoWithBasis, V2Transaction, V2TransactionBuilder}; use async_trait::async_trait; use thiserror::Error; -#[derive(Debug, Error)] -pub enum HelperError { - #[error("ApiClientHelpers::utxo_from_txid failed: {0}")] - UtxoFromTxid(#[from] UtxoFromTxidError), - #[error("ApiClientHelpers::get_transaction failed: {0}")] - GetTx(#[from] GetTransactionError), - #[error("ApiClientHelpers::get_unconfirmed_transaction: failed to fetch mempool {0}")] - GetUnconfirmedTx(#[from] ApiClientError), - #[error("ApiClientHelpers::select_unspent_outputs failed: {0}")] - SelectUtxos(#[from] SelectUtxosError), - #[error("ApiClientHelpers::get_event failed to fetch event: {0}")] - GetEvent(ApiClientError), - #[error("ApiClientHelpers::get_address_events failed: {0}")] - GetAddressEvents(ApiClientError), - #[error("ApiClientHelpers::broadcast_transaction failed to broadcast transaction: {0}")] - BroadcastTx(ApiClientError), - #[error("ApiClientHelpers::get_median_timestamp failed: {0}")] - GetMedianTimestamp(#[from] GetMedianTimestampError), - #[error("ApiClientHelpers::get_consensus_updates_since_height failed: {0}")] - UpdatesSinceHeight(#[from] UpdatesSinceHeightError), - #[error("ApiClientHelpers::find_where_utxo_spent failed: {0}")] - FindWhereUtxoSpent(#[from] FindWhereUtxoSpentError), -} +/** Generic errors for the ApiClientHelpers trait +These errors are agnostic towards the ClientError generic type allowing each client implementation +to define its own error handling for transport errors. +These types are not intended for consumer use unless a custom client implementation is required. +Do not import these types directly. Use the corresponding type aliases defined in native.rs or wasm.rs. **/ +pub(crate) mod generic_errors { + use super::*; + + #[derive(Debug, Error)] + pub enum UtxoFromTxidErrorGeneric { + #[error("ApiClientHelpers::utxo_from_txid: failed to fetch event {0}")] + FetchEvent(#[from] ClientError), + #[error("ApiClientHelpers::utxo_from_txid: invalid event variant {0:?}")] + EventVariant(Event), + #[error("ApiClientHelpers::utxo_from_txid: output index out of bounds txid: {txid} index: {index}")] + OutputIndexOutOfBounds { txid: TransactionId, index: u32 }, + #[error("ApiClientHelpers::utxo_from_txid: get_unspent_outputs helper failed {0}")] + FetchUtxos(#[from] GetUnspentOutputsErrorGeneric), + #[error("ApiClientHelpers::utxo_from_txid: output not found txid: {txid} index: {index}")] + NotFound { txid: TransactionId, index: u32 }, + #[error("ApiClientHelpers::utxo_from_txid: found duplicate utxo txid: {txid} index: {index}")] + DuplicateUtxoFound { txid: TransactionId, index: u32 }, + } -#[derive(Debug, Error)] -pub enum UtxoFromTxidError { - #[error("ApiClientHelpers::utxo_from_txid: failed to fetch event {0}")] - FetchEvent(ApiClientError), - #[error("ApiClientHelpers::utxo_from_txid: invalid event variant {0:?}")] - EventVariant(Event), - #[error("ApiClientHelpers::utxo_from_txid: output index out of bounds txid: {txid} index: {index}")] - OutputIndexOutOfBounds { txid: TransactionId, index: u32 }, - #[error("ApiClientHelpers::utxo_from_txid: get_unspent_outputs helper failed {0}")] - FetchUtxos(ApiClientError), - #[error("ApiClientHelpers::utxo_from_txid: output not found txid: {txid} index: {index}")] - NotFound { txid: TransactionId, index: u32 }, - #[error("ApiClientHelpers::utxo_from_txid: found duplicate utxo txid: {txid} index: {index}")] - DuplicateUtxoFound { txid: TransactionId, index: u32 }, -} + #[derive(Debug, Error)] + pub enum GetTransactionErrorGeneric { + #[error("ApiClientHelpers::get_transaction: failed to fetch event {0}")] + FetchEvent(#[from] ClientError), + #[error("ApiClientHelpers::get_transaction: unexpected variant error {0:?}")] + EventVariant(EventDataWrapper), + } -#[derive(Debug, Error)] -pub enum GetTransactionError { - #[error("ApiClientHelpers::get_transaction: failed to fetch event {0}")] - FetchEvent(#[from] ApiClientError), - #[error("ApiClientHelpers::get_transaction: unexpected variant error {0:?}")] - EventVariant(EventDataWrapper), -} + #[derive(Debug, Error)] + pub enum GetUnconfirmedTransactionErrorGeneric { + #[error("ApiClientHelpers::get_unconfirmed_transaction: failed to fetch mempool {0}")] + FetchMempool(#[from] ClientError), + } + + #[derive(Debug, Error)] + pub enum FundTxSingleSourceErrorGeneric { + #[error("ApiClientHelpers::fund_tx_single_source: failed to select Utxos: {0}")] + SelectUtxos(#[from] SelectUtxosErrorGeneric), + } -#[derive(Debug, Error)] -pub enum SelectUtxosError { - #[error( + #[derive(Debug, Error)] + pub enum GetEventErrorGeneric { + #[error("ApiClientHelpers::get_event failed to fetch event: {0}")] + FetchEvent(#[from] ClientError), + } + + #[derive(Debug, Error)] + pub enum GetAddressEventsErrorGeneric { + #[error("ApiClientHelpers::get_address_events failed: {0}")] + FetchAddressEvents(#[from] ClientError), + } + + #[derive(Debug, Error)] + pub enum BroadcastTransactionErrorGeneric { + #[error("ApiClientHelpers::broadcast_transaction: broadcast failed: {0}")] + BroadcastTx(#[from] ClientError), + } + + #[derive(Debug, Error)] + pub enum SelectUtxosErrorGeneric { + #[error( "ApiClientHelpers::select_unspent_outputs: insufficent funds, available: {available:?} required: {required:?}" )] - Funding { available: Currency, required: Currency }, - #[error("ApiClientHelpers::select_unspent_outputs: failed to fetch UTXOs {0}")] - FetchUtxos(#[from] ApiClientError), -} + Funding { available: Currency, required: Currency }, + #[error("ApiClientHelpers::select_unspent_outputs: failed to fetch UTXOs: {0}")] + GetUnspentOutputs(#[from] GetUnspentOutputsErrorGeneric), + } -#[derive(Debug, Error)] -pub enum GetMedianTimestampError { - #[error("ApiClientHelpers::get_median_timestamp: failed to fetch consensus tipstate: {0}")] - FetchTipstate(#[from] ApiClientError), - #[error( - r#"ApiClientHelpers::get_median_timestamp: expected 11 timestamps in response: {0:?}. + #[derive(Debug, Error)] + pub enum GetMedianTimestampErrorGeneric { + #[error("ApiClientHelpers::get_median_timestamp: failed to fetch consensus tipstate: {0}")] + FetchTipstate(#[from] ClientError), + #[error( + r#"ApiClientHelpers::get_median_timestamp: expected 11 timestamps in response: {0:?}. The walletd state is likely corrupt as it is evidently reporting a chain height of less than 11 blocks."# - )] - TimestampVecLen(ConsensusTipstateResponse), + )] + TimestampVecLen(ConsensusTipstateResponse), + } + + #[derive(Debug, Error)] + pub enum GetUnspentOutputsErrorGeneric { + #[error("ApiClientHelpers::get_unspent_outputs: failed to fetch UTXOs: {0}")] + FetchUtxos(#[from] ClientError), + } + + #[derive(Debug, Error)] + pub enum CurrentHeightErrorGeneric { + #[error("ApiClientHelpers::current_height: failed to fetch current height: {0}")] + FetchConsensusTip(#[from] ClientError), + } + + #[derive(Debug, Error)] + pub enum AddressBalanceErrorGeneric { + #[error("ApiClientHelpers::address_balance: failed to fetch address balance: {0}")] + FetchAddressBalance(#[from] ClientError), + } + + #[derive(Debug, Error)] + pub enum FindWhereUtxoSpentErrorGeneric { + #[error("ApiClientHelpers::find_where_utxo_spent: failed to fetch consensus updates {0}")] + FetchUpdates(#[from] GetConsensusUpdatesErrorGeneric), + #[error("ApiClientHelpers::find_where_utxo_spent: SiacoinOutputId:{id} was not spent in the expected block")] + SpendNotInBlock { id: SiacoinOutputId }, + } + + #[derive(Debug, Error)] + pub enum GetConsensusUpdatesErrorGeneric { + #[error("ApiClientHelpers::get_consensus_updates_since_height: failed to fetch ChainIndex {0}")] + FetchIndex(ClientError), + #[error("ApiClientHelpers::get_consensus_updates_since_height: failed to fetch updates {0}")] + FetchUpdates(ClientError), + } + + #[derive(Debug, Error)] + pub enum DebugMineErrorGeneric { + #[error("ApiClientDebugHelpers::mine_blocks: failed to mine blocks: {0}")] + Mine(#[from] ClientError), + } } -/// Helper methods for the ApiClient trait -/// These generally provide higher level functionality than the base ApiClient trait -/// This crate is focused on catering to the Komodo Defi Framework integration +use generic_errors::*; + +/// ApiClientHelpers implements client agnostic helper methods catering to the Komodo Defi Framework +/// integration. These methods provide higher level functionality than the base ApiClient trait. +/// Clients can generally implement this as simply as `impl ApiClientHelpers for Client {}`. #[async_trait] pub trait ApiClientHelpers: ApiClient { - async fn current_height(&self) -> Result { + async fn current_height(&self) -> Result> { Ok(self.dispatcher(ConsensusTipRequest).await?.height) } - async fn address_balance(&self, address: Address) -> Result { - self.dispatcher(AddressBalanceRequest { address }).await + async fn address_balance( + &self, + address: Address, + ) -> Result> { + Ok(self.dispatcher(AddressBalanceRequest { address }).await?) } async fn get_unspent_outputs( @@ -97,13 +157,14 @@ pub trait ApiClientHelpers: ApiClient { address: &Address, limit: Option, offset: Option, - ) -> Result, ApiClientError> { - self.dispatcher(GetAddressUtxosRequest { - address: address.clone(), - limit, - offset, - }) - .await + ) -> Result> { + Ok(self + .dispatcher(GetAddressUtxosRequest { + address: address.clone(), + limit, + offset, + }) + .await?) } /// Fetches unspent outputs for the given address and attempts to select a subset of outputs @@ -124,20 +185,19 @@ pub trait ApiClientHelpers: ApiClient { &self, address: &Address, total_amount: Currency, - ) -> Result<(Vec, Currency), HelperError> { - let mut unspent_outputs = self - .get_unspent_outputs(address, None, None) - .await - .map_err(SelectUtxosError::FetchUtxos)?; + ) -> Result<(UtxosWithBasis, Currency), SelectUtxosErrorGeneric> { + let mut unspent_outputs = self.get_unspent_outputs(address, None, None).await?; // Sort outputs from largest to smallest - unspent_outputs.sort_by(|a, b| b.siacoin_output.value.0.cmp(&a.siacoin_output.value.0)); + unspent_outputs + .outputs + .sort_by(|a, b| b.siacoin_output.value.0.cmp(&a.siacoin_output.value.0)); let mut selected = Vec::new(); let mut selected_amount = Currency::ZERO; // Select outputs until the total amount is reached - for output in unspent_outputs { + for output in unspent_outputs.outputs { selected_amount += output.siacoin_output.value; selected.push(output); @@ -147,14 +207,20 @@ pub trait ApiClientHelpers: ApiClient { } if selected_amount < total_amount { - return Err(SelectUtxosError::Funding { + return Err(SelectUtxosErrorGeneric::Funding { available: selected_amount, required: total_amount, })?; } let change = selected_amount - total_amount; - Ok((selected, change)) + Ok(( + UtxosWithBasis { + outputs: selected, + basis: unspent_outputs.basis, + }, + change, + )) } /// Fund a transaction with utxos from the given address. @@ -178,7 +244,7 @@ pub trait ApiClientHelpers: ApiClient { &self, tx_builder: &mut V2TransactionBuilder, public_key: &PublicKey, - ) -> Result<(), HelperError> { + ) -> Result<(), FundTxSingleSourceErrorGeneric> { let address = public_key.address(); let outputs_total: Currency = tx_builder.siacoin_outputs.iter().map(|output| output.value).sum(); @@ -188,10 +254,13 @@ pub trait ApiClientHelpers: ApiClient { .await?; // add selected utxos as inputs to the transaction - for utxo in &selected_utxos { + for utxo in &selected_utxos.outputs { tx_builder.add_siacoin_input(utxo.clone(), SpendPolicy::PublicKey(public_key.clone())); } + // update the transaction's basis + tx_builder.update_basis(selected_utxos.basis); + if change > Currency::DUST { // add change as an output tx_builder.add_siacoin_output((address, change).into()); @@ -203,94 +272,99 @@ pub trait ApiClientHelpers: ApiClient { /// Fetches a SiacoinElement(a UTXO) from a TransactionId and Index /// Walletd doesn't currently offer an easy way to fetch the SiacoinElement type needed to build /// SiacoinInputs. - async fn utxo_from_txid(&self, txid: &TransactionId, vout_index: u32) -> Result { + async fn utxo_from_txid( + &self, + txid: &TransactionId, + vout_index: u32, + ) -> Result> { let output_id = SiacoinOutputId::new(txid.clone(), vout_index); // fetch the Event via /api/events/{txid} - let event = self - .dispatcher(GetEventRequest { txid: txid.clone() }) - .await - .map_err(UtxoFromTxidError::FetchEvent)?; + let event = self.dispatcher(GetEventRequest { txid: txid.clone() }).await?; // check that the fetched event is V2Transaction let tx = match event.data { EventDataWrapper::V2Transaction(tx) => tx, - _ => return Err(UtxoFromTxidError::EventVariant(event))?, + _ => return Err(UtxoFromTxidErrorGeneric::::EventVariant(event)), }; // check that the output index is within bounds if tx.siacoin_outputs.len() <= (vout_index as usize) { - return Err(UtxoFromTxidError::OutputIndexOutOfBounds { + return Err(UtxoFromTxidErrorGeneric::::OutputIndexOutOfBounds { txid: txid.clone(), index: vout_index, - })?; + }); } let output_address = tx.siacoin_outputs[vout_index as usize].address.clone(); // fetch unspent outputs of the address - let address_utxos = self - .get_unspent_outputs(&output_address, None, None) - .await - .map_err(UtxoFromTxidError::FetchUtxos)?; + let address_utxos = self.get_unspent_outputs(&output_address, None, None).await?; // filter the utxos to find any matching the expected SiacoinOutputId let filtered_utxos: Vec = address_utxos + .outputs .into_iter() .filter(|element| element.id == output_id) .collect(); // ensure only one utxo was found match filtered_utxos.len() { - 1 => Ok(filtered_utxos[0].clone()), - 0 => Err(UtxoFromTxidError::NotFound { + 1 => Ok(UtxoWithBasis { + output: filtered_utxos[0].clone(), + basis: address_utxos.basis, + }), + 0 => Err(UtxoFromTxidErrorGeneric::::NotFound { txid: txid.clone(), index: vout_index, - })?, - _ => Err(UtxoFromTxidError::DuplicateUtxoFound { + }), + _ => Err(UtxoFromTxidErrorGeneric::::DuplicateUtxoFound { txid: txid.clone(), index: vout_index, - })?, + }), } } - async fn get_event(&self, event_id: &Hash256) -> Result { - self.dispatcher(GetEventRequest { txid: event_id.clone() }) - .await - .map_err(HelperError::GetEvent) + async fn get_event(&self, event_id: &Hash256) -> Result> { + Ok(self.dispatcher(GetEventRequest { txid: event_id.clone() }).await?) } - async fn get_address_events(&self, address: Address) -> Result, HelperError> { + async fn get_address_events( + &self, + address: Address, + ) -> Result, GetAddressEventsErrorGeneric> { let request = AddressesEventsRequest { address, limit: None, offset: None, }; - self.dispatcher(request).await.map_err(HelperError::GetAddressEvents) + Ok(self.dispatcher(request).await?) } /// Fetch a v2 transaction from the blockchain - // FIXME Alright - this should return a Result, HelperError> to allow for + // FIXME Alright - this should return a Result, SomeError> to allow for // logic to handle the case where the transaction is not found in the blockchain // ApiClientError must be refactored to allow this - async fn get_transaction(&self, txid: &TransactionId) -> Result { - let event = self - .dispatcher(GetEventRequest { txid: txid.clone() }) - .await - .map_err(GetTransactionError::FetchEvent)?; + async fn get_transaction( + &self, + txid: &TransactionId, + ) -> Result> { + let event = self.dispatcher(GetEventRequest { txid: txid.clone() }).await?; match event.data { EventDataWrapper::V2Transaction(tx) => Ok(tx), - wrong_variant => Err(GetTransactionError::EventVariant(wrong_variant))?, + wrong_variant => Err(GetTransactionErrorGeneric::::EventVariant(wrong_variant)), } } /// Fetch a v2 transaction from the transaction pool / mempool /// Returns Ok(None) if the transaction is not found in the mempool - async fn get_unconfirmed_transaction(&self, txid: &TransactionId) -> Result, HelperError> { + async fn get_unconfirmed_transaction( + &self, + txid: &TransactionId, + ) -> Result, GetUnconfirmedTransactionErrorGeneric> { let found_in_mempool = self .dispatcher(TxpoolTransactionsRequest) - .await - .map_err(HelperError::GetUnconfirmedTx)? + .await? .v2transactions .into_iter() .find(|tx| tx.txid() == *txid); @@ -299,41 +373,48 @@ pub trait ApiClientHelpers: ApiClient { /// Get the median timestamp of the chain's last 11 blocks /// This is used in the evaluation of SpendPolicy::After - async fn get_median_timestamp(&self) -> Result { - let tipstate = self - .dispatcher(ConsensusTipstateRequest) - .await - .map_err(GetMedianTimestampError::FetchTipstate)?; + async fn get_median_timestamp(&self) -> Result> { + let tipstate = self.dispatcher(ConsensusTipstateRequest).await?; // This can happen if the chain has less than 11 blocks // We assume the chain is at least 11 blocks long for this helper. if tipstate.prev_timestamps.len() != 11 { - return Err(GetMedianTimestampError::TimestampVecLen(tipstate))?; + return Err(GetMedianTimestampErrorGeneric::::TimestampVecLen(tipstate)); } let median_timestamp = tipstate.prev_timestamps[5]; Ok(median_timestamp.timestamp() as u64) } - async fn broadcast_transaction(&self, tx: &V2Transaction) -> Result<(), HelperError> { + async fn broadcast_transaction( + &self, + tx: &V2Transaction, + ) -> Result<(), BroadcastTransactionErrorGeneric> { + // FIXME Alright possible this may fail if basis was not provided + let basis = match &tx.basis { + Some(basis) => basis.clone(), + None => self.dispatcher(ConsensusTipRequest).await?, + }; + let request = TxpoolBroadcastRequest { + basis, transactions: vec![], v2transactions: vec![tx.clone()], }; - self.dispatcher(request).await.map_err(HelperError::BroadcastTx)?; + self.dispatcher(request).await?; Ok(()) } - async fn get_consensus_updates_since_height( + async fn get_consensus_updates( &self, begin_height: u64, - ) -> Result { + ) -> Result> { let index_request = ConsensusIndexRequest { height: begin_height }; let chain_index = self .dispatcher(index_request) .await - .map_err(UpdatesSinceHeightError::FetchIndex)?; + .map_err(|e| GetConsensusUpdatesErrorGeneric::::FetchIndex(e))?; let updates_request = ConsensusUpdatesRequest { height: chain_index.height, @@ -343,8 +424,7 @@ pub trait ApiClientHelpers: ApiClient { self.dispatcher(updates_request) .await - .map_err(UpdatesSinceHeightError::FetchUpdates) - .map_err(HelperError::UpdatesSinceHeight) + .map_err(|e| GetConsensusUpdatesErrorGeneric::::FetchUpdates(e)) } /// Find the transaction that spent the given utxo @@ -352,18 +432,12 @@ pub trait ApiClientHelpers: ApiClient { /// Returns Ok(None) if the utxo has not been spent async fn find_where_utxo_spent( &self, - siacoin_output_id: &SiacoinOutputId, + output_id: &SiacoinOutputId, begin_height: u64, - ) -> Result, HelperError> { - // the SiacoinOutputId is displayed with h: prefix in the endpoint response so use we Hash256 - let output_id = siacoin_output_id; - - let updates = self - .get_consensus_updates_since_height(begin_height) - .await - .map_err(|e| FindWhereUtxoSpentError::FetchUpdates(Box::new(e)))?; + ) -> Result, FindWhereUtxoSpentErrorGeneric> { + let updates = self.get_consensus_updates(begin_height).await?; - // find the update that has the provided `siacoin_output_id`` in its "spent" field + // find the update that has the provided `output_id`` in its "spent" field let update_option = updates .applied .into_iter() @@ -383,25 +457,22 @@ pub trait ApiClientHelpers: ApiClient { .transactions .into_iter() .find(|tx| tx.siacoin_inputs.iter().any(|input| input.parent.id == *output_id)) - .ok_or(FindWhereUtxoSpentError::SpendNotInBlock { id: output_id.clone() })?; + .ok_or(FindWhereUtxoSpentErrorGeneric::SpendNotInBlock { id: output_id.clone() })?; Ok(Some(tx)) } -} - -#[derive(Debug, Error)] -pub enum FindWhereUtxoSpentError { - // Boxed to allow HelperError to be held within itself - #[error("ApiClientHelpers::find_where_utxo_spent: failed to fetch consensus updates {0}")] - FetchUpdates(#[from] Box), - #[error("ApiClientHelpers::find_where_utxo_spent: scoid:{id} was not spent in the expected block")] - SpendNotInBlock { id: SiacoinOutputId }, -} -#[derive(Debug, Error)] -pub enum UpdatesSinceHeightError { - #[error("ApiClientHelpers::get_consensus_updates_since_height: failed to fetch ChainIndex {0}")] - FetchIndex(ApiClientError), - #[error("ApiClientHelpers::get_consensus_updates_since_height: failed to fetch updates {0}")] - FetchUpdates(ApiClientError), + /** + Mine `n` blocks to the given Sia Address, `addr`. + Does not wait for the blocks to be mined. Returns immediately after receiving a response from the walletd node. + This endpoint is only available on Walletd nodes that have been started with `-debug`. + **/ + async fn mine_blocks(&self, n: i64, addr: &Address) -> Result<(), DebugMineErrorGeneric> { + self.dispatcher(DebugMineRequest { + address: addr.clone(), + blocks: n, + }) + .await?; + Ok(()) + } } diff --git a/src/transport/client/native.rs b/src/transport/client/native.rs index 78da295..dd375bf 100644 --- a/src/transport/client/native.rs +++ b/src/transport/client/native.rs @@ -1,17 +1,67 @@ -use crate::transport::endpoints::{ConsensusTipRequest, SiaApiRequest}; +use crate::transport::endpoints::{ConsensusTipRequest, SiaApiRequest, SiaApiRequestError}; use async_trait::async_trait; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; -use http::header::{HeaderMap, HeaderValue, AUTHORIZATION}; +use http::header::{HeaderMap, HeaderValue, InvalidHeaderValue, AUTHORIZATION}; use reqwest::Client as ReqwestClient; use serde::Deserialize; +use thiserror::Error; use url::Url; -use crate::transport::client::{ApiClient, ApiClientError, ApiClientHelpers, Body as ClientBody, EndpointSchema}; +use crate::transport::client::{ApiClient, ApiClientHelpers, Body as ClientBody, EndpointSchema, EndpointSchemaError}; use core::time::Duration; +pub mod error { + use super::*; + use crate::transport::client::helpers::generic_errors::*; + + pub type BroadcastTransactionError = BroadcastTransactionErrorGeneric; + pub type UtxoFromTxidError = UtxoFromTxidErrorGeneric; + pub type GetUnconfirmedTransactionError = GetUnconfirmedTransactionErrorGeneric; + pub type GetMedianTimestampError = GetMedianTimestampErrorGeneric; + pub type FindWhereUtxoSpentError = FindWhereUtxoSpentErrorGeneric; + pub type FundTxSingleSourceError = FundTxSingleSourceErrorGeneric; + pub type GetConsensusUpdatesError = GetConsensusUpdatesErrorGeneric; + pub type GetUnspentOutputsError = GetUnspentOutputsErrorGeneric; + pub type CurrentHeightError = CurrentHeightErrorGeneric; + pub type SelectUtxosError = SelectUtxosErrorGeneric; + pub type GetTransactionError = GetTransactionErrorGeneric; + + /// An error that may occur when using the `NativeClient`. + /// Each variant is used exactly once and represents a unique logical path in the code. + // TODO this can be broken into enum per method; Reqwest error handling also has significant updates + // in newer versions that provide unique error types instead of a single "reqwest::Error" + #[derive(Debug, Error)] + pub enum ClientError { + #[error("NativeClient::new: Failed to construct HTTP auth header: {0}")] + InvalidHeader(#[from] InvalidHeaderValue), + #[error("NativeClient::new: Failed to build reqwest::Client: {0}")] + BuildClient(reqwest::Error), + #[error("NativeClient::new: Failed to ping server with ConsensusTipRequest: {0}")] + PingServer(Box), + #[error("NativeClient::dispatcher: failed to convert request into schema: {0}")] + RequestToSchema(#[from] SiaApiRequestError), + #[error("NativeClient::process_schema: failed to build url: {0}")] + SchemaBuildUrl(#[from] EndpointSchemaError), + #[error("NativeClient::process_schema: Failed to build request: {0}")] + SchemaBuildRequest(reqwest::Error), + #[error("NativeClient::dispatcher: Failed to convert SiaApiRequest to reqwest::Request: {0}")] + DispatcherBuildRequest(Box), + #[error("NativeClient::dispatcher: Failed to execute reqwest::Request: {0}")] + DispatcherExecuteRequest(reqwest::Error), + #[error("NativeClient::dispatcher: Failed to deserialize response body: {0}")] + DispatcherDeserializeBody(reqwest::Error), + #[error("NativeClient::dispatcher: Expected:{expected_type} found 204 No Content")] + DispatcherUnexpectedEmptyResponse { expected_type: String }, + #[error("NativeClient::dispatcher: unexpected HTTP status:{status} body:{body}")] + DispatcherUnexpectedStatus { status: http::StatusCode, body: String }, + } +} + +use error::*; + #[derive(Clone)] -pub struct NativeClient { +pub struct Client { pub client: ReqwestClient, pub base_url: Url, } @@ -26,37 +76,38 @@ pub struct Conf { } #[async_trait] -impl ApiClient for NativeClient { +impl ApiClient for Client { type Request = reqwest::Request; type Response = reqwest::Response; type Conf = Conf; - async fn new(conf: Self::Conf) -> Result { + type Error = ClientError; + + async fn new(conf: Self::Conf) -> Result { let mut headers = HeaderMap::new(); if let Some(password) = &conf.password { let auth_value = format!("Basic {}", BASE64.encode(format!(":{}", password))); - headers.insert( - AUTHORIZATION, - HeaderValue::from_str(&auth_value).map_err(|e| ApiClientError::BuildError(e.to_string()))?, - ); + headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value)?); } let timeout = conf.timeout.unwrap_or(30); let client = ReqwestClient::builder() .default_headers(headers) .timeout(Duration::from_secs(timeout)) .build() - .map_err(ApiClientError::ReqwestError)?; + .map_err(ClientError::BuildClient)?; - let ret = NativeClient { + let ret = Client { client, base_url: conf.server_url, }; // Ping the server with ConsensusTipRequest to check if the client is working - ret.dispatcher(ConsensusTipRequest).await?; + ret.dispatcher(ConsensusTipRequest) + .await + .map_err(|e| ClientError::PingServer(Box::new(e)))?; Ok(ret) } - fn process_schema(&self, schema: EndpointSchema) -> Result { + fn process_schema(&self, schema: EndpointSchema) -> Result { let url = schema.build_url(&self.base_url)?; let req = match schema.body { ClientBody::None => self.client.request(schema.method.into(), url).build(), @@ -64,35 +115,40 @@ impl ApiClient for NativeClient { ClientBody::Json(body) => self.client.request(schema.method.into(), url).json(&body).build(), ClientBody::Bytes(body) => self.client.request(schema.method.into(), url).body(body).build(), } - .map_err(ApiClientError::ReqwestError)?; + .map_err(ClientError::SchemaBuildRequest)?; Ok(req) } - async fn execute_request(&self, request: Self::Request) -> Result { - self.client.execute(request).await.map_err(ApiClientError::ReqwestError) - } - - async fn dispatcher(&self, request: R) -> Result { - let request = self.to_data_request(request)?; - - // Execute the request using reqwest client - let response = self - .client - .execute(request) - .await - .map_err(ApiClientError::ReqwestError)?; + async fn dispatcher(&self, request: R) -> Result { + let request = self + .process_schema(request.to_endpoint_schema()?) + .map_err(|e| ClientError::DispatcherBuildRequest(Box::new(e)))?; + + let mut retries = 3; + let response = loop { + match self.client.execute(request.try_clone().unwrap()).await { + Ok(resp) => break Ok(resp), + Err(_) if retries > 0 => { + retries -= 1; + continue; + }, + Err(e) => break Err(ClientError::DispatcherExecuteRequest(e)), + } + }?; // Check the response status and return the appropriate result match response.status() { + // Attempt to deserialize the response body to the expected type if the status is OK reqwest::StatusCode::OK => Ok(response .json::() .await - .map_err(ApiClientError::ReqwestError)?), + .map_err(ClientError::DispatcherDeserializeBody)?), + // Handle empty responses; throw an error if the response is not expected to be empty reqwest::StatusCode::NO_CONTENT => { if let Some(resp_type) = R::is_empty_response() { Ok(resp_type) } else { - Err(ApiClientError::UnexpectedEmptyResponse { + Err(ClientError::DispatcherUnexpectedEmptyResponse { expected_type: std::any::type_name::().to_string(), }) } @@ -106,14 +162,14 @@ impl ApiClient for NativeClient { .map_err(|e| format!("Failed to retrieve body: {}", e)) .unwrap_or_else(|e| e); - Err(ApiClientError::UnexpectedHttpStatus { status, body }) + Err(ClientError::DispatcherUnexpectedStatus { status, body }) }, } } } #[async_trait] -impl ApiClientHelpers for NativeClient {} +impl ApiClientHelpers for Client {} // TODO these tests should not rely on the actual server - mock the server or use docker #[cfg(test)] @@ -125,13 +181,13 @@ mod tests { use std::str::FromStr; use tokio; - async fn init_client() -> NativeClient { + async fn init_client() -> Client { let conf = Conf { server_url: Url::parse("https://sia-walletd.komodo.earth/").unwrap(), password: None, timeout: Some(10), }; - NativeClient::new(conf).await.unwrap() + Client::new(conf).await.unwrap() } /// Helper function to setup the client and send a request diff --git a/src/transport/client/wasm.rs b/src/transport/client/wasm.rs index 66e495d..c2ba1c4 100644 --- a/src/transport/client/wasm.rs +++ b/src/transport/client/wasm.rs @@ -1,14 +1,67 @@ -use crate::transport::client::{ApiClient, ApiClientError, ApiClientHelpers, Body, EndpointSchema, SchemaMethod}; -use crate::transport::endpoints::{ConsensusTipRequest, SiaApiRequest}; +use crate::transport::client::{ApiClient, ApiClientHelpers, Body, EndpointSchema, EndpointSchemaError, SchemaMethod}; +use crate::transport::endpoints::{ConsensusTipRequest, SiaApiRequest, SiaApiRequestError}; use async_trait::async_trait; use http::StatusCode; use serde::Deserialize; use std::collections::HashMap; +use thiserror::Error; use url::Url; pub mod wasm_fetch; -use wasm_fetch::{Body as FetchBody, FetchMethod, FetchRequest, FetchResponse}; +use wasm_fetch::{Body as FetchBody, FetchError, FetchMethod, FetchRequest, FetchResponse}; + +pub mod error { + use super::*; + use crate::transport::client::helpers::generic_errors::*; + + pub type BroadcastTransactionError = BroadcastTransactionErrorGeneric; + pub type UtxoFromTxidError = UtxoFromTxidErrorGeneric; + pub type GetUnconfirmedTransactionError = GetUnconfirmedTransactionErrorGeneric; + pub type GetMedianTimestampError = GetMedianTimestampErrorGeneric; + pub type FindWhereUtxoSpentError = FindWhereUtxoSpentErrorGeneric; + pub type FundTxSingleSourceError = FundTxSingleSourceErrorGeneric; + pub type GetConsensusUpdatesError = GetConsensusUpdatesErrorGeneric; + pub type GetUnspentOutputsError = GetUnspentOutputsErrorGeneric; + pub type CurrentHeightError = CurrentHeightErrorGeneric; + pub type SelectUtxosError = SelectUtxosErrorGeneric; + pub type GetTransactionError = GetTransactionErrorGeneric; + + /// An error that may occur when using the `WasmClient`. + /// Each variant is used exactly once and represents a unique logical path in the code. + #[derive(Debug, Error)] + pub enum ClientError { + #[error("WasmClient::new: Failed to ping server with ConsensusTipRequest: {0}")] + PingServer(Box), + #[error("WasmClient::process_schema: failed to build url: {0}")] + SchemaBuildUrl(#[from] EndpointSchemaError), + #[error("WasmClient::process_schema: unsupported EndpointSchema.method: {0:?}")] + SchemaUnsupportedMethod(EndpointSchema), + #[error("WasmClient::dispatcher: Failed to generate EndpointSchema from SiaApiRequest: {0}")] + DispatcherGenerateSchema(#[from] SiaApiRequestError), + #[error("WasmClient::dispatcher: process_schema failed: {0}")] + DispatcherProcessSchema(Box), + #[error("WasmClient::dispatcher: Failed to execute request: {0}")] + DispatcherExecuteRequest(#[from] FetchError), + #[error("WasmClient::dispatcher: expected utf-8 or JSON in response body, found octet-stream: {0:?}")] + DispatcherUnexpectedBodyBytes(Vec), + #[error("WasmClient::dispatcher: expected utf-8 or JSON in response body, found empty body")] + DispatcherUnexpectedBodyEmpty, + #[error("WasmClient::dispatcher: failed to deserialize response body from JSON: {0}")] + DispatcherDeserializeBodyJson(serde_json::Error), + #[error("WasmClient::dispatcher: failed to deserialize response body from string: {0}")] + DispatcherDeserializeBodyUtf8(serde_json::Error), + #[error("WasmClient::dispatcher: unexpected HTTP status:{status} body:{body:?}")] + DispatcherUnexpectedHttpStatus { + status: StatusCode, + body: Option, + }, + #[error("WasmClient::dispatcher: Expected:{expected_type} found 204 No Content")] + DispatcherUnexpectedEmptyResponse { expected_type: String }, + } +} + +use error::*; #[derive(Clone)] pub struct Client { @@ -28,23 +81,27 @@ impl ApiClient for Client { type Request = FetchRequest; type Response = FetchResponse; type Conf = Conf; + type Error = ClientError; - async fn new(conf: Self::Conf) -> Result { + async fn new(conf: Self::Conf) -> Result { let client = Client { base_url: conf.server_url, headers: conf.headers, }; // Ping the server with ConsensusTipRequest to check if the client is working - client.dispatcher(ConsensusTipRequest).await?; + client + .dispatcher(ConsensusTipRequest) + .await + .map_err(|e| ClientError::PingServer(Box::new(e)))?; Ok(client) } - fn process_schema(&self, schema: EndpointSchema) -> Result { + fn process_schema(&self, schema: EndpointSchema) -> Result { let url = schema.build_url(&self.base_url)?; let method = match schema.method { SchemaMethod::Get => FetchMethod::Get, SchemaMethod::Post => FetchMethod::Post, - _ => return Err(ApiClientError::FixmePlaceholder("Unsupported method".to_string())), + _ => return Err(ClientError::SchemaUnsupportedMethod(schema.clone())), }; let body = match schema.body { Body::Utf8(body) => Some(FetchBody::Utf8(body)), @@ -60,56 +117,53 @@ impl ApiClient for Client { }) } - async fn execute_request(&self, request: Self::Request) -> Result { - request - .execute() - .await - .map_err(|e| ApiClientError::FixmePlaceholder(format!("FIXME {}", e))) - } - // Dispatcher function that converts the request and handles execution - async fn dispatcher(&self, request: R) -> Result { - let request = self.to_data_request(request)?; // Convert request to data request + async fn dispatcher(&self, request: R) -> Result { + // Generate EndpointSchema from the SiaApiRequest + let schema = request.to_endpoint_schema()?; - // Execute the request - let response = self.execute_request(request).await?; + // Convert the SiaApiRequest to FetchRequest + let request = self + .process_schema(schema) + .map_err(|e| ClientError::DispatcherProcessSchema(Box::new(e)))?; + + // Execute the FetchRequest + let response = request.execute().await?; match response.status { + // Deserialize the response body if 200 OK StatusCode::OK => { let response_body = match response.body { - Some(FetchBody::Json(body)) => serde_json::from_value(body).map_err(ApiClientError::Serde)?, - Some(FetchBody::Utf8(body)) => serde_json::from_str(&body).map_err(ApiClientError::Serde)?, - _ => { - return Err(ApiClientError::FixmePlaceholder( - "Unsupported body type in response".to_string(), - )) + Some(FetchBody::Json(body)) => { + serde_json::from_value(body).map_err(ClientError::DispatcherDeserializeBodyJson)? + }, + Some(FetchBody::Utf8(body)) => { + serde_json::from_str(&body).map_err(ClientError::DispatcherDeserializeBodyUtf8)? }, + Some(FetchBody::Bytes(bytes)) => return Err(ClientError::DispatcherUnexpectedBodyBytes(bytes)), + None => return Err(ClientError::DispatcherUnexpectedBodyEmpty), }; Ok(response_body) }, + // Return an EmptyResponse if 204 NO CONTENT StatusCode::NO_CONTENT => { if let Some(resp_type) = R::is_empty_response() { Ok(resp_type) } else { - Err(ApiClientError::UnexpectedEmptyResponse { + Err(ClientError::DispatcherUnexpectedEmptyResponse { expected_type: std::any::type_name::().to_string(), }) } }, - status => { - // Extract the body, using the Display implementation of Body or an empty string - let body = response - .body - .map(|b| format!("{}", b)) // Use Display trait to format Body - .unwrap_or_else(|| "".to_string()); // If body is None, use an empty string - - Err(ApiClientError::UnexpectedHttpStatus { status, body }) - }, + // Handle unexpected HTTP statuses eg, 400, 404, 500 + status => Err(ClientError::DispatcherUnexpectedHttpStatus { + status, + body: response.body, + }), } } } -// Implement the optional helper methods for ExampleClient // Just this is needed to implement the `ApiClientHelpers` trait // unless custom implementations for the traits methods are needed #[async_trait] @@ -187,7 +241,7 @@ mod wasm_tests { v2transactions: vec![tx], }; match client.dispatcher(req).await.expect_err("Expected HTTP 400 error") { - ApiClientError::UnexpectedHttpStatus { + ClientError::DispatcherUnexpectedHttpStatus { status: StatusCode::BAD_REQUEST, body: _, } => (), diff --git a/src/transport/client/wasm/wasm_fetch.rs b/src/transport/client/wasm/wasm_fetch.rs index 54a5460..2a3fc93 100644 --- a/src/transport/client/wasm/wasm_fetch.rs +++ b/src/transport/client/wasm/wasm_fetch.rs @@ -60,7 +60,7 @@ impl FetchMethod { } } -#[derive(Clone)] +#[derive(Clone, Debug)] pub enum Body { Utf8(String), Json(JsonValue), diff --git a/src/transport/endpoints.rs b/src/transport/endpoints.rs index 2695495..8992bc2 100644 --- a/src/transport/endpoints.rs +++ b/src/transport/endpoints.rs @@ -1,4 +1,4 @@ -use crate::transport::client::{ApiClientError, Body, EndpointSchema, EndpointSchemaBuilder, SchemaMethod}; +use crate::transport::client::{Body, EndpointSchema, EndpointSchemaBuilder, SchemaMethod}; use crate::types::{Address, ApiApplyUpdate, BlockId, ChainIndex, Currency, Event, Hash256, SiacoinElement, V1Transaction, V2Transaction}; use crate::utils::deserialize_null_as_empty_vec; @@ -6,6 +6,7 @@ use chrono::{DateTime, Utc}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use thiserror::Error; const ENDPOINT_ADDRESSES_BALANCE: &str = "api/addresses/{address}/balance"; const ENDPOINT_ADDRESSES_EVENTS: &str = "api/addresses/{address}/events"; @@ -26,7 +27,17 @@ pub trait SiaApiRequest: Send { // Applicable for requests that return HTTP 204 No Content fn is_empty_response() -> Option { None } - fn to_endpoint_schema(&self) -> Result; + fn to_endpoint_schema(&self) -> Result; +} + +// TODO it's not ideal that all endpoints share this error type because we may have "endpoints" in the +// future that aren't a 1:1 mapping of walletd endpoints and may require different error handling per +// endpoint or per data source. That is why this holds a String, request, that does not follow the typical +// error handling pattern of the rest of the library. +#[derive(Debug, Error)] +pub enum SiaApiRequestError { + #[error("SiaApiRequest::to_endpoint_schema: failed to serialize request:{request} to JSON: {error}")] + Serde { error: serde_json::Error, request: String }, } /// Represents the request-response pair for fetching the current consensus tip of the Sia network. @@ -53,7 +64,7 @@ pub struct ConsensusTipRequest; impl SiaApiRequest for ConsensusTipRequest { type Response = ChainIndex; - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { Ok(EndpointSchemaBuilder::new(ENDPOINT_CONSENSUS_TIP.to_owned(), SchemaMethod::Get).build()) } } @@ -84,7 +95,7 @@ pub struct ConsensusIndexRequest { impl SiaApiRequest for ConsensusIndexRequest { type Response = ChainIndex; - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { // Create the path_params HashMap to substitute {height} in the path schema let mut path_params = HashMap::new(); path_params.insert("height".to_owned(), self.height.to_string()); @@ -122,7 +133,7 @@ pub struct ConsensusTipstateRequest; impl SiaApiRequest for ConsensusTipstateRequest { type Response = ConsensusTipstateResponse; - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { Ok(EndpointSchemaBuilder::new(ENDPOINT_CONSENSUS_TIPSTATE.to_owned(), SchemaMethod::Get).build()) } } @@ -162,7 +173,7 @@ pub struct ConsensusUpdatesRequest { impl SiaApiRequest for ConsensusUpdatesRequest { type Response = ConsensusUpdatesResponse; - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { // Create the path_params HashMap to substitute {height} and {hash} in the path schema let mut path_params = HashMap::new(); path_params.insert("height".to_owned(), self.height.to_string()); @@ -222,7 +233,7 @@ pub struct AddressBalanceRequest { impl SiaApiRequest for AddressBalanceRequest { type Response = AddressBalanceResponse; - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { let mut path_params = HashMap::new(); path_params.insert("address".to_owned(), self.address.to_string()); @@ -269,7 +280,7 @@ pub struct GetEventRequest { impl SiaApiRequest for GetEventRequest { type Response = Event; - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { // Create the path_params HashMap to substitute {txid} in the path schema let mut path_params = HashMap::new(); path_params.insert("txid".to_owned(), self.txid.to_string()); @@ -313,7 +324,7 @@ pub struct AddressesEventsRequest { impl SiaApiRequest for AddressesEventsRequest { type Response = Vec; - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { let mut path_params = HashMap::new(); path_params.insert("address".to_owned(), self.address.to_string()); @@ -353,11 +364,11 @@ pub type AddressesEventsResponse = Vec; /// - `offset`: An optional offset for paginated results. Corresponds to `int64` in Go. /// /// # Response -/// - The response is a `Vec` in Rust, corresponding to `[]types.SiacoinElement` in Go. -/// - [Go Source for SiacoinElement Type](https://github.com/SiaFoundation/core/blob/300042fd2129381468356dcd87c5e9a6ad94c0ef/types/types.go#L614) +/// - The response is a `GetAddressUtxosResponse` in Rust, corresponding to `SiacoinElementsResponse` in Go. +/// - [Go Source for SiacoinElementsResponse Type](https://github.com/SiaFoundation/walletd/blob/94ac6b0543c7495752554ae543d4ad28b4a620a5/api/api.go#L177C1-L182C2) /// /// # References -/// - [Go Source for the HTTP Endpoint](https://github.com/SiaFoundation/walletd/blob/6ff23fe34f6fa45a19bfb6e4bacc8a16d2c48144/api/server.go#L795) +/// - [Go Source for the HTTP Endpoint](https://github.com/SiaFoundation/walletd/blob/94ac6b0543c7495752554ae543d4ad28b4a620a5/api/server.go#L1127) /// /// This type is ported from the Go codebase, representing the equivalent request-response pair in Rust. #[derive(Clone, Deserialize, Serialize, Debug)] @@ -367,10 +378,19 @@ pub struct GetAddressUtxosRequest { pub offset: Option, } +#[derive(Clone, Deserialize, Serialize, Debug)] +/// equivalent of SiacoinElementsResponse in Go +/// The ChainIndex is required to be provided while broadcasting any transaction that spends any of +/// these UTXOs +pub struct UtxosWithBasis { + pub basis: ChainIndex, + pub outputs: Vec, +} + impl SiaApiRequest for GetAddressUtxosRequest { - type Response = Vec; + type Response = UtxosWithBasis; - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { let mut path_params = HashMap::new(); path_params.insert("address".to_owned(), self.address.to_string()); @@ -425,6 +445,7 @@ impl SiaApiRequest for GetAddressUtxosRequest { /// This type is ported from the Go codebase, representing the equivalent request-response pair in Rust. #[derive(Clone, Deserialize, Serialize, Debug)] pub struct TxpoolBroadcastRequest { + pub basis: ChainIndex, pub transactions: Vec, pub v2transactions: Vec, } @@ -439,9 +460,12 @@ impl SiaApiRequest for TxpoolBroadcastRequest { fn is_empty_response() -> Option { Some(EmptyResponse) } - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { // Serialize the transactions into a JSON body - let body = serde_json::to_value(self).map_err(ApiClientError::Serde)?; + let body = serde_json::to_value(self).map_err(|e| SiaApiRequestError::Serde { + error: e, + request: format!("{:?}", self), + })?; let body = body.to_string(); Ok( EndpointSchemaBuilder::new(ENDPOINT_TXPOOL_BROADCAST.to_owned(), SchemaMethod::Post) @@ -483,7 +507,7 @@ pub struct TxpoolFeeResponse(pub Currency); impl SiaApiRequest for TxpoolFeeRequest { type Response = TxpoolFeeResponse; - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { Ok( EndpointSchemaBuilder::new(ENDPOINT_TXPOOL_FEE.to_owned(), SchemaMethod::Get).build(), // No path_params, query_params, or body needed for this request ) @@ -521,7 +545,7 @@ pub struct TxpoolTransactionsResponse { impl SiaApiRequest for TxpoolTransactionsRequest { type Response = TxpoolTransactionsResponse; - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { Ok( EndpointSchemaBuilder::new(ENDPOINT_TXPOOL_TRANSACTIONS.to_owned(), SchemaMethod::Get).build(), // No path_params, query_params, or body needed for this request ) @@ -561,9 +585,12 @@ impl SiaApiRequest for DebugMineRequest { fn is_empty_response() -> Option { Some(EmptyResponse) } - fn to_endpoint_schema(&self) -> Result { + fn to_endpoint_schema(&self) -> Result { // Serialize the request into a JSON string - let body = serde_json::to_string(self).map_err(ApiClientError::Serde)?; + let body = serde_json::to_string(self).map_err(|e| SiaApiRequestError::Serde { + error: e, + request: format!("{:?}", self), + })?; Ok( EndpointSchemaBuilder::new(ENDPOINT_DEBUG_MINE.to_owned(), SchemaMethod::Post) .body(Body::Utf8(body)) // Set the JSON body for the POST request diff --git a/src/types/hash.rs b/src/types/hash.rs index 9989a99..b0a0a77 100644 --- a/src/types/hash.rs +++ b/src/types/hash.rs @@ -1,3 +1,4 @@ +use crate::encoding::{Encodable, Encoder}; use hex; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::convert::TryFrom; @@ -5,6 +6,13 @@ use std::fmt::{self, Display}; use std::str::FromStr; use thiserror::Error; +/* +TODO: +Hash256 once required custom serde and encoding due to handling various prefixes based on the context. +These prefixes are now removed, so helpers like serde_as and derive_more could be used to reduce +boilerplate. + */ + #[derive(Debug, Error)] pub enum Hash256Error { #[error("Hash256::from_str invalid hex: expected 32 byte hex string, found {0}")] @@ -14,9 +22,15 @@ pub enum Hash256Error { #[error("Hash256::TryFrom<&[u8]> invalid slice length: expected 32 byte slice, found {0:?}")] InvalidSliceLength(Vec), } + +/// A 256 bit number representing a blake2b or sha256 hash in Sia's consensus protocol and APIs. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct Hash256(pub [u8; 32]); +impl Encodable for Hash256 { + fn encode(&self, encoder: &mut Encoder) { encoder.write_slice(&self.0); } +} + impl Serialize for Hash256 { fn serialize(&self, serializer: S) -> Result where diff --git a/src/types/transaction.rs b/src/types/transaction.rs index f620546..b43b98e 100644 --- a/src/types/transaction.rs +++ b/src/types/transaction.rs @@ -130,9 +130,16 @@ impl<'a> Encodable for CurrencyVersion<'a> { /// Preimage is a 32-byte array representing the preimage of a hash used in Sia's SpendPolicy::Hash /// Used to allow HLTC-style hashlock contracts in Sia +// TODO - this type is now effectively identical to Hash256. It only exists because Preimage once +// supported variable length preimages. Using Preimage(Hash256) would reduce code duplication, but +// we should consider changing Hash256's name as Preimage does not represent a "hash". #[derive(Clone, Debug, Default, PartialEq, From, Into)] pub struct Preimage(pub [u8; 32]); +impl Encodable for Preimage { + fn encode(&self, encoder: &mut Encoder) { encoder.write_slice(&self.0); } +} + impl Serialize for Preimage { fn serialize(&self, serializer: S) -> Result where @@ -182,7 +189,7 @@ impl<'de> Deserialize<'de> for Preimage { #[derive(Debug, Error)] pub enum PreimageError { - #[error("PreimageError: failed to convert from slice invalid length: {0}")] + #[error("Preimage:TryFrom<&[u8]>: invalid length, expected 32 bytes found: {0}")] InvalidSliceLength(usize), } @@ -222,49 +229,8 @@ impl Encodable for Signature { impl Encodable for SatisfiedPolicy { fn encode(&self, encoder: &mut Encoder) { self.policy.encode(encoder); - let mut sigi: usize = 0; - let mut prei: usize = 0; - - fn rec(policy: &SpendPolicy, encoder: &mut Encoder, sigi: &mut usize, prei: &mut usize, sp: &SatisfiedPolicy) { - match policy { - SpendPolicy::PublicKey(_) => { - if *sigi < sp.signatures.len() { - sp.signatures[*sigi].encode(encoder); - *sigi += 1; - } else { - // Sia Go code panics here but our code assumes encoding will always be successful - // TODO: check if Sia Go will fix this - encoder.write_string("Broken PublicKey encoding, see SatisfiedPolicy::encode") - } - }, - SpendPolicy::Hash(_) => { - if *prei < sp.preimages.len() { - encoder.write_slice(&sp.preimages[*prei].0); - *prei += 1; - } else { - // Sia Go code panics here but our code assumes encoding will always be successful - // consider changing the signature of encode() to return a Result - encoder.write_string("Broken Hash encoding, see SatisfiedPolicy::encode") - } - }, - SpendPolicy::Threshold { n: _, of } => { - for p in of { - rec(p, encoder, sigi, prei, sp); - } - }, - SpendPolicy::UnlockConditions(uc) => { - for unlock_key in &uc.unlock_keys { - if let UnlockKey::Ed25519(public_key) = unlock_key { - rec(&SpendPolicy::PublicKey(public_key.clone()), encoder, sigi, prei, sp); - } - // else FIXME consider when this is possible, is it always developer error or could it be forced maliciously? - } - }, - _ => {}, - } - } - - rec(&self.policy, encoder, &mut sigi, &mut prei, self); + encoder.write_len_prefixed_vec(&self.signatures); + encoder.write_len_prefixed_vec(&self.preimages); } } @@ -332,6 +298,13 @@ impl Encodable for SiacoinElement { } } +/// A UTXO with its corresponding ChainIndex. This is not a type in Sia core, but is helpful because +/// the ChainIndex is always needed when broadcasting a UTXO. +pub struct UtxoWithBasis { + pub output: SiacoinElement, + pub basis: ChainIndex, +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SiafundInputV2 { @@ -1091,6 +1064,10 @@ pub struct V2Transaction { #[serde(skip_serializing_if = "Option::is_none")] pub new_foundation_address: Option
, pub miner_fee: Currency, + // Basis is not part of the transaction structure. This field is unique to the Rust implementation. + // This field is provided to keep track of the correct ChainIndex needed to broadcast the transaction. + #[serde(skip)] + pub basis: Option, } impl V2Transaction { @@ -1250,6 +1227,10 @@ pub struct V2TransactionBuilder { // fee_policy is not part Sia consensus and it not encoded into any resulting transaction. // fee_policy has no effect unless a helper like `ApiClientHelpers::fund_tx_single_source` utilizes it. pub fee_policy: Option, + // basis is not part Sia consensus and it not encoded into any resulting transaction. + // It is the ChainIndex required to broadcast the transaction. This is provided by the + // /api/addresses/:addr/siacoin/outputs Walletd API endpoint. + pub basis: Option, } impl Encodable for V2TransactionBuilder { @@ -1331,6 +1312,7 @@ impl V2TransactionBuilder { new_foundation_address: None, miner_fee: Currency::ZERO, fee_policy: None, + basis: None, } } @@ -1418,6 +1400,23 @@ impl V2TransactionBuilder { self } + /// Update the basis of the transaction. The basis is the ChainIndex required to broadcast the + /// transaction. + pub fn update_basis(&mut self, basis: ChainIndex) -> &mut Self { + // Only update the basis if the new basis is higher than the existing basis. + match &self.basis { + Some(existing_basis) if existing_basis.height >= basis.height => {}, + _ => self.basis = Some(basis), + } + self + } + + pub fn add_siacoin_input_with_basis(&mut self, parent: UtxoWithBasis, policy: SpendPolicy) -> &mut Self { + self.add_siacoin_input(parent.output, policy); + self.update_basis(parent.basis); + self + } + pub fn add_siacoin_output(&mut self, output: SiacoinOutput) -> &mut Self { self.siacoin_outputs.push(output); self @@ -1517,6 +1516,7 @@ impl V2TransactionBuilder { arbitrary_data: cloned.arbitrary_data, new_foundation_address: cloned.new_foundation_address, miner_fee: cloned.miner_fee, + basis: cloned.basis, } } }