diff --git a/Cargo.lock b/Cargo.lock index 36f45a6..68ff0aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1561,6 +1561,8 @@ dependencies = [ "serde", "serde_json", "sqlx", + "strum", + "strum_macros", "thiserror", "tokio", "tower 0.5.1", @@ -2769,6 +2771,25 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.72", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index e1e969c..262058d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,8 @@ reqwest = { version = "0.12.5", features = ["json", "blocking"] } serde = { version = "1.0.204", features = ["derive"] } serde_json = { version = "1.0.121" } sqlx = { version = "0.8.0", features = ["runtime-tokio", "postgres", "json"] } +strum = "0.26.3" +strum_macros = "0.26.4" thiserror = "1.0.63" tokio = { version = "1.39.2", features = ["full"] } tower = "0.5.1" diff --git a/src/api/mempool_transaction.rs b/src/api/mempool_transaction.rs index 8ba7936..b5cc64f 100644 --- a/src/api/mempool_transaction.rs +++ b/src/api/mempool_transaction.rs @@ -22,7 +22,14 @@ impl MinaMesh { hashes: Some(vec![request.transaction_identifier.hash.as_str()]), })) .await?; + + // Check if the transaction is absent + if pooled_user_commands.is_empty() { + return Err(MinaMeshError::TransactionNotFound(request.transaction_identifier.hash)); + } + let operations = pooled_user_commands.into_iter().map(Into::into).collect(); + Ok(MempoolTransactionResponse { metadata: None, transaction: Box::new(Transaction { diff --git a/src/api/network_options.rs b/src/api/network_options.rs index cda0ac9..b709d00 100644 --- a/src/api/network_options.rs +++ b/src/api/network_options.rs @@ -3,7 +3,7 @@ use coinbase_mesh::models::{Allow, Case, Error, NetworkOptionsResponse, NetworkRequest, OperationStatus, Version}; -use crate::{MinaMesh, MinaMeshError}; +use crate::{operation_types, MinaMesh, MinaMeshError}; /// https://github.com/MinaProtocol/mina/blob/985eda49bdfabc046ef9001d3c406e688bc7ec45/src/app/rosetta/lib/network.ml#L444 impl MinaMesh { @@ -16,25 +16,7 @@ impl MinaMesh { OperationStatus::new("Success".to_string(), true), OperationStatus::new("Failed".to_string(), false), ], - operation_types: vec![ - "fee_payer_dec", - "fee_receiver_inc", - "coinbase_inc", - "account_creation_fee_via_payment", - "account_creation_fee_via_fee_payer", - "account_creation_fee_via_fee_receiver", - "payment_source_dec", - "payment_receiver_inc", - "fee_payment", - "delegate_change", - "create_token", - "mint_tokens", - "zkapp_fee_payer_dec", - "zkapp_balance_update", - ] - .into_iter() - .map(|s| s.to_string()) - .collect(), + operation_types: operation_types(), errors, historical_balance_lookup: true, timestamp_start_index: None, diff --git a/src/error.rs b/src/error.rs index b928f28..3f86bf2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -35,7 +35,7 @@ pub enum MinaMeshError { #[error("Internal invariant violation (you found a bug)")] InvariantViolation, - #[error("Transaction not found")] + #[error("Transaction not found: {0}")] TransactionNotFound(String), #[error("Block not found")] @@ -209,6 +209,15 @@ impl MinaMeshError { MinaMeshError::NetworkDne(expected, actual) => json!({ "error": format!("You are requesting the status for the network {}, but you are connected to the network {}", expected, actual), }), + MinaMeshError::TransactionNotFound(tx) => json!({ + "error": format!( + "You attempted to lookup transaction {}, but it is missing from the mempool. {} {}", + tx, + "This may be due to its inclusion in a block -- try looking for this transaction in a recent block.", + "It also could be due to the transaction being evicted from the mempool." + ), + "transaction": tx, + }), _ => json!(""), } } @@ -227,7 +236,7 @@ impl MinaMeshError { /// Returns a human-readable description of the error. pub fn description(&self) -> String { match self { - MinaMeshError::Sql(_) => "An SQL error occurred.".to_string(), + MinaMeshError::Sql(_) => "We encountered a SQL failure.".to_string(), MinaMeshError::JsonParse(_) => "We encountered an error while parsing JSON.".to_string(), MinaMeshError::GraphqlMinaQuery(_) => "The GraphQL query failed.".to_string(), MinaMeshError::NetworkDne(_, _) => "The specified network does not exist.".to_string(), @@ -237,24 +246,35 @@ impl MinaMeshError { MinaMeshError::TransactionNotFound(_) => "The specified transaction could not be found.".to_string(), MinaMeshError::BlockMissing(_) => "The specified block could not be found.".to_string(), MinaMeshError::MalformedPublicKey => "The provided public key is malformed.".to_string(), - MinaMeshError::OperationsNotValid(_) => "The provided operations are not valid.".to_string(), + MinaMeshError::OperationsNotValid(_) => { + "We could not convert those operations to a valid transaction.".to_string() + } MinaMeshError::UnsupportedOperationForConstruction => { "The operation is not supported for transaction construction.".to_string() } - MinaMeshError::SignatureMissing => "A signature is missing.".to_string(), - MinaMeshError::PublicKeyFormatNotValid => "The public key format is not valid.".to_string(), - MinaMeshError::NoOptionsProvided => "No options were provided.".to_string(), + MinaMeshError::SignatureMissing => "Your request is missing a signature.".to_string(), + MinaMeshError::PublicKeyFormatNotValid => "The public key you provided had an invalid format.".to_string(), + MinaMeshError::NoOptionsProvided => "Your request is missing options.".to_string(), MinaMeshError::Exception(_) => "An internal exception occurred.".to_string(), - MinaMeshError::SignatureInvalid => "The signature is invalid.".to_string(), - MinaMeshError::MemoInvalid => "The memo is invalid.".to_string(), + MinaMeshError::SignatureInvalid => "Your request has an invalid signature.".to_string(), + MinaMeshError::MemoInvalid => "Your request has an invalid memo.".to_string(), MinaMeshError::GraphqlUriNotSet => "No GraphQL URI has been set.".to_string(), - MinaMeshError::TransactionSubmitNoSender => "No sender was found in the ledger.".to_string(), + MinaMeshError::TransactionSubmitNoSender => { + "This could occur because the node isn't fully synced or the account doesn't actually exist in the ledger yet." + .to_string() + } MinaMeshError::TransactionSubmitDuplicate => "A duplicate transaction was detected.".to_string(), MinaMeshError::TransactionSubmitBadNonce => "The nonce is invalid.".to_string(), MinaMeshError::TransactionSubmitFeeSmall => "The transaction fee is too small.".to_string(), - MinaMeshError::TransactionSubmitInvalidSignature => "The transaction signature is invalid.".to_string(), - MinaMeshError::TransactionSubmitInsufficientBalance => "The account has insufficient balance.".to_string(), - MinaMeshError::TransactionSubmitExpired => "The transaction has expired.".to_string(), + MinaMeshError::TransactionSubmitInvalidSignature => { + "An invalid signature is attached to this transaction.".to_string() + } + MinaMeshError::TransactionSubmitInsufficientBalance => { + "This account do not have sufficient balance perform the requested transaction.".to_string() + } + MinaMeshError::TransactionSubmitExpired => { + "This transaction is expired. Please try again with a larger valid_until.".to_string() + } } } } diff --git a/src/test.rs b/src/test.rs index 162aa56..a45bc37 100644 --- a/src/test.rs +++ b/src/test.rs @@ -35,6 +35,31 @@ impl ResponseComparisonContext { Ok(()) } + pub async fn assert_responses_contain( + &self, + subpath: &str, + maybe_body_bytes: Option>, + expected_fragment: &str, + ) -> Result<()> { + let body_bytes = maybe_body_bytes.clone().unwrap_or_default(); + let (a, b) = + tokio::try_join!(self.mina_mesh_req(subpath, body_bytes.clone()), self.legacy_req(subpath, body_bytes))?; + + // Check if the expected fragment is present in both responses + let a_contains = a.contains(expected_fragment); + let b_contains = b.contains(expected_fragment); + + assert!( + a_contains && b_contains, + "Mismatch for {subpath}; expected fragment `{}` not found in one or both responses; mina_mesh: {}, rosetta: {}", + expected_fragment, + a, + b + ); + + Ok(()) + } + async fn mina_mesh_req(&self, subpath: &str, body_bytes: Vec) -> Result { let oneshot_req = Request::builder() .method("POST") diff --git a/src/types.rs b/src/types.rs index 120f67d..a1edb9b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,6 +1,9 @@ +use convert_case::{Case, Casing}; use derive_more::derive::Display; use serde::Serialize; use sqlx::{FromRow, Type}; +use strum::IntoEnumIterator; +use strum_macros::EnumIter; #[derive(Type, Debug, PartialEq, Eq, Serialize)] #[sqlx(type_name = "chain_status_type", rename_all = "lowercase")] @@ -47,24 +50,25 @@ impl From for OperationStatus { } } -#[derive(Debug, Display)] +#[derive(Debug, Display, EnumIter)] pub enum OperationType { FeePayerDec, FeeReceiverInc, CoinbaseInc, AccountCreationFeeViaPayment, - AccountCreationFeeViaFeePayer, AccountCreationFeeViaFeeReceiver, PaymentSourceDec, PaymentReceiverInc, FeePayment, DelegateChange, - CreateToken, - MintTokens, ZkappFeePayerDec, ZkappBalanceUpdate, } +pub fn operation_types() -> Vec { + OperationType::iter().map(|variant| format!("{:?}", variant).to_case(Case::Snake)).collect() +} + #[derive(Type, Debug, PartialEq, Eq, Serialize, Display)] #[sqlx(type_name = "may_use_token")] pub enum MayUseToken { diff --git a/tests/compare_to_ocaml.rs b/tests/compare_to_ocaml.rs index 2a52c47..98d7b8a 100644 --- a/tests/compare_to_ocaml.rs +++ b/tests/compare_to_ocaml.rs @@ -7,13 +7,28 @@ use serde::Serialize; const LEGACY_ENDPOINT: &str = "https://rosetta-devnet.minaprotocol.network"; -async fn compare_responses(subpath: &str, reqs: &[T]) -> Result<()> { +async fn assert_responses_eq(subpath: &str, reqs: &[T]) -> Result<()> { let mina_mesh = MinaMeshConfig::from_env().to_mina_mesh().await?; let comparison_ctx = ResponseComparisonContext::new(mina_mesh, LEGACY_ENDPOINT.to_string()); let assertion_futures: Vec<_> = reqs .iter() .map(|r| serde_json::to_vec(r).map(|body| comparison_ctx.assert_responses_eq(subpath, Some(body))).unwrap()) .collect(); + + join_all(assertion_futures).await; + Ok(()) +} + +async fn assert_responses_contain(subpath: &str, reqs: &[T], fragment: &str) -> Result<()> { + let mina_mesh = MinaMeshConfig::from_env().to_mina_mesh().await?; + let comparison_ctx = ResponseComparisonContext::new(mina_mesh, LEGACY_ENDPOINT.to_string()); + let assertion_futures: Vec<_> = reqs + .iter() + .map(|r| { + serde_json::to_vec(r).map(|body| comparison_ctx.assert_responses_contain(subpath, Some(body), fragment)).unwrap() + }) + .collect(); + join_all(assertion_futures).await; Ok(()) } @@ -21,5 +36,35 @@ async fn compare_responses(subpath: &str, reqs: &[T]) -> Result<() #[tokio::test] async fn search_transactions() -> Result<()> { let (subpath, reqs) = fixtures::search_transactions(); - compare_responses(subpath, &reqs).await + assert_responses_eq(subpath, &reqs).await +} + +#[tokio::test] +async fn network_list() -> Result<()> { + let (subpath, reqs) = fixtures::network_list(); + assert_responses_eq(subpath, &reqs).await +} + +#[tokio::test] +async fn network_options() -> Result<()> { + let (subpath, reqs) = fixtures::network_options(); + assert_responses_contain(subpath, &reqs, "node_version").await +} + +#[tokio::test] +async fn network_status() -> Result<()> { + let (subpath, reqs) = fixtures::network_status(); + assert_responses_contain(subpath, &reqs, "\"stage\": \"Synced\"").await +} + +#[tokio::test] +async fn mempool() -> Result<()> { + let (subpath, reqs) = fixtures::mempool(); + assert_responses_eq(subpath, &reqs).await +} + +#[tokio::test] +async fn mempool_transaction() -> Result<()> { + let (subpath, reqs) = fixtures::mempool_transaction(); + assert_responses_contain(subpath, &reqs, "\"message\": \"Transaction not found").await } diff --git a/tests/error.rs b/tests/error.rs index 72aca41..676fc4b 100644 --- a/tests/error.rs +++ b/tests/error.rs @@ -32,7 +32,7 @@ async fn test_error_properties() { use MinaMeshError::*; let cases = vec![ - (Sql("SQL syntax error".to_string()), 1, "An SQL error occurred.", false, StatusCode::INTERNAL_SERVER_ERROR), + (Sql("SQL syntax error".to_string()), 1, "We encountered a SQL failure.", false, StatusCode::INTERNAL_SERVER_ERROR), ( JsonParse(Some("Missing field".to_string())), 2, @@ -66,7 +66,13 @@ async fn test_error_properties() { ), (BlockMissing("Block ID".to_string()), 9, "The specified block could not be found.", true, StatusCode::NOT_FOUND), (MalformedPublicKey, 10, "The provided public key is malformed.", false, StatusCode::BAD_REQUEST), - (OperationsNotValid(vec![]), 11, "The provided operations are not valid.", false, StatusCode::BAD_REQUEST), + ( + OperationsNotValid(vec![]), + 11, + "We could not convert those operations to a valid transaction.", + false, + StatusCode::BAD_REQUEST, + ), ( UnsupportedOperationForConstruction, 12, @@ -74,9 +80,9 @@ async fn test_error_properties() { false, StatusCode::BAD_REQUEST, ), - (SignatureMissing, 13, "A signature is missing.", false, StatusCode::BAD_REQUEST), - (PublicKeyFormatNotValid, 14, "The public key format is not valid.", false, StatusCode::BAD_REQUEST), - (NoOptionsProvided, 15, "No options were provided.", false, StatusCode::BAD_REQUEST), + (SignatureMissing, 13, "Your request is missing a signature.", false, StatusCode::BAD_REQUEST), + (PublicKeyFormatNotValid, 14, "The public key you provided had an invalid format.", false, StatusCode::BAD_REQUEST), + (NoOptionsProvided, 15, "Your request is missing options.", false, StatusCode::BAD_REQUEST), ( Exception("Unexpected error".to_string()), 16, @@ -84,16 +90,40 @@ async fn test_error_properties() { false, StatusCode::INTERNAL_SERVER_ERROR, ), - (SignatureInvalid, 17, "The signature is invalid.", false, StatusCode::BAD_REQUEST), - (MemoInvalid, 18, "The memo is invalid.", false, StatusCode::BAD_REQUEST), + (SignatureInvalid, 17, "Your request has an invalid signature.", false, StatusCode::BAD_REQUEST), + (MemoInvalid, 18, "Your request has an invalid memo.", false, StatusCode::BAD_REQUEST), (GraphqlUriNotSet, 19, "No GraphQL URI has been set.", false, StatusCode::INTERNAL_SERVER_ERROR), - (TransactionSubmitNoSender, 20, "No sender was found in the ledger.", true, StatusCode::BAD_REQUEST), + ( + TransactionSubmitNoSender, + 20, + "This could occur because the node isn't fully synced or the account doesn't actually exist in the ledger yet.", + true, + StatusCode::BAD_REQUEST, + ), (TransactionSubmitDuplicate, 21, "A duplicate transaction was detected.", false, StatusCode::CONFLICT), (TransactionSubmitBadNonce, 22, "The nonce is invalid.", false, StatusCode::BAD_REQUEST), (TransactionSubmitFeeSmall, 23, "The transaction fee is too small.", false, StatusCode::BAD_REQUEST), - (TransactionSubmitInvalidSignature, 24, "The transaction signature is invalid.", false, StatusCode::BAD_REQUEST), - (TransactionSubmitInsufficientBalance, 25, "The account has insufficient balance.", false, StatusCode::BAD_REQUEST), - (TransactionSubmitExpired, 26, "The transaction has expired.", false, StatusCode::BAD_REQUEST), + ( + TransactionSubmitInvalidSignature, + 24, + "An invalid signature is attached to this transaction.", + false, + StatusCode::BAD_REQUEST, + ), + ( + TransactionSubmitInsufficientBalance, + 25, + "This account do not have sufficient balance perform the requested transaction.", + false, + StatusCode::BAD_REQUEST, + ), + ( + TransactionSubmitExpired, + 26, + "This transaction is expired. Please try again with a larger valid_until.", + false, + StatusCode::BAD_REQUEST, + ), ]; for (error, code, description, retriable, status) in cases { diff --git a/tests/fixtures/mempool.rs b/tests/fixtures/mempool.rs new file mode 100644 index 0000000..dac66cf --- /dev/null +++ b/tests/fixtures/mempool.rs @@ -0,0 +1,18 @@ +use mina_mesh::models::{MempoolTransactionRequest, NetworkIdentifier, NetworkRequest, TransactionIdentifier}; + +use super::CompareGroup; + +pub fn mempool<'a>() -> CompareGroup<'a> { + ("/mempool", vec![Box::new(NetworkRequest::new(network_id()))]) +} + +pub fn mempool_transaction<'a>() -> CompareGroup<'a> { + ("/mempool/transaction", vec![Box::new(MempoolTransactionRequest::new( + network_id(), + TransactionIdentifier::new("hash_not_exists".to_string()), + ))]) +} + +fn network_id() -> NetworkIdentifier { + NetworkIdentifier::new("mina".to_string(), "devnet".to_string()) +} diff --git a/tests/fixtures/mod.rs b/tests/fixtures/mod.rs index 53e61be..a71c9c7 100644 --- a/tests/fixtures/mod.rs +++ b/tests/fixtures/mod.rs @@ -2,12 +2,16 @@ use erased_serde::Serialize as ErasedSerialize; mod account_balance; mod block; +mod mempool; +mod network; mod search_transactions; #[allow(unused_imports)] pub use account_balance::*; #[allow(unused_imports)] pub use block::*; +pub use mempool::*; +pub use network::*; pub use search_transactions::*; pub type CompareGroup<'a> = (&'a str, Vec>); diff --git a/tests/fixtures/network.rs b/tests/fixtures/network.rs new file mode 100644 index 0000000..5acd123 --- /dev/null +++ b/tests/fixtures/network.rs @@ -0,0 +1,45 @@ +use mina_mesh::models::{NetworkIdentifier, NetworkRequest}; +use serde::{ser::SerializeStruct, Serialize, Serializer}; + +use super::CompareGroup; + +struct EmptyPayload; + +impl Serialize for EmptyPayload { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + // Serialize the empty struct as an empty JSON object + serializer.serialize_struct("EmptyPayload", 0)?.end() + } +} + +pub fn network_list<'a>() -> CompareGroup<'a> { + ("/network/list", vec![Box::new(EmptyPayload)]) +} + +pub fn network_options<'a>() -> CompareGroup<'a> { + ("/network/options", vec![Box::new(network_request())]) +} + +pub fn network_status<'a>() -> CompareGroup<'a> { + ("/network/status", vec![Box::new(network_request())]) +} + +fn network_request() -> NetworkRequest { + NetworkRequest::new(NetworkIdentifier::new("mina".to_string(), "devnet".to_string())) +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_empty_payload_serialization() { + let payload = EmptyPayload; + let serialized = serde_json::to_string(&payload).expect("Serialization failed"); + assert_eq!(serialized, "{}", "EmptyPayload did not serialize into an empty JSON object"); + } +} diff --git a/tests/snapshots/network_options__network_options.snap b/tests/snapshots/network_options__network_options.snap index 456bf40..9a9e835 100644 --- a/tests/snapshots/network_options__network_options.snap +++ b/tests/snapshots/network_options__network_options.snap @@ -18,14 +18,11 @@ Allow { "fee_receiver_inc", "coinbase_inc", "account_creation_fee_via_payment", - "account_creation_fee_via_fee_payer", "account_creation_fee_via_fee_receiver", "payment_source_dec", "payment_receiver_inc", "fee_payment", "delegate_change", - "create_token", - "mint_tokens", "zkapp_fee_payer_dec", "zkapp_balance_update", ], @@ -34,7 +31,7 @@ Allow { code: 1, message: "SQL failure: SQL syntax error", description: Some( - "An SQL error occurred.", + "We encountered a SQL failure.", ), retriable: false, details: Some( @@ -123,13 +120,16 @@ Allow { }, Error { code: 8, - message: "Transaction not found", + message: "Transaction not found: Transaction ID", description: Some( "The specified transaction could not be found.", ), retriable: true, details: Some( - String(""), + Object { + "error": String("You attempted to lookup transaction Transaction ID, but it is missing from the mempool. This may be due to its inclusion in a block -- try looking for this transaction in a recent block. It also could be due to the transaction being evicted from the mempool."), + "transaction": String("Transaction ID"), + }, ), }, Error { @@ -158,7 +158,7 @@ Allow { code: 11, message: "Cannot convert operations to valid transaction", description: Some( - "The provided operations are not valid.", + "We could not convert those operations to a valid transaction.", ), retriable: false, details: Some( @@ -180,7 +180,7 @@ Allow { code: 13, message: "Signature missing", description: Some( - "A signature is missing.", + "Your request is missing a signature.", ), retriable: false, details: Some( @@ -191,7 +191,7 @@ Allow { code: 14, message: "Invalid public key format", description: Some( - "The public key format is not valid.", + "The public key you provided had an invalid format.", ), retriable: false, details: Some( @@ -202,7 +202,7 @@ Allow { code: 15, message: "No options provided", description: Some( - "No options were provided.", + "Your request is missing options.", ), retriable: false, details: Some( @@ -226,7 +226,7 @@ Allow { code: 17, message: "Invalid signature", description: Some( - "The signature is invalid.", + "Your request has an invalid signature.", ), retriable: false, details: Some( @@ -237,7 +237,7 @@ Allow { code: 18, message: "Invalid memo", description: Some( - "The memo is invalid.", + "Your request has an invalid memo.", ), retriable: false, details: Some( @@ -259,7 +259,7 @@ Allow { code: 20, message: "Can't send transaction: No sender found in ledger", description: Some( - "No sender was found in the ledger.", + "This could occur because the node isn't fully synced or the account doesn't actually exist in the ledger yet.", ), retriable: true, details: Some( @@ -303,7 +303,7 @@ Allow { code: 24, message: "Can't send transaction: Invalid signature", description: Some( - "The transaction signature is invalid.", + "An invalid signature is attached to this transaction.", ), retriable: false, details: Some( @@ -314,7 +314,7 @@ Allow { code: 25, message: "Can't send transaction: Insufficient balance", description: Some( - "The account has insufficient balance.", + "This account do not have sufficient balance perform the requested transaction.", ), retriable: false, details: Some( @@ -325,7 +325,7 @@ Allow { code: 26, message: "Can't send transaction: Expired", description: Some( - "The transaction has expired.", + "This transaction is expired. Please try again with a larger valid_until.", ), retriable: false, details: Some(