diff --git a/Cargo.lock b/Cargo.lock index e05c7193c..c57fa8abd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,6 +229,119 @@ dependencies = [ "serde_json 1.0.128", ] +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.3.1", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "444b0228950ee6501b3568d3c93bf1176a1fdbc3b758dcd9475046d30f4dc7e8" +dependencies = [ + "async-lock", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener 5.3.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-std" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c634475f29802fde2b8f0b505b1bd00dfe4df7d4a000f0b36f7671197d5c3615" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.79" @@ -239,6 +352,12 @@ dependencies = [ "syn 2.0.77", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.3.0" @@ -369,6 +488,19 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "blocking" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +dependencies = [ + "async-channel 2.3.1", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "blst" version = "0.3.13" @@ -630,6 +762,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1039,6 +1180,33 @@ dependencies = [ "version_check", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +dependencies = [ + "event-listener 5.3.1", + "pin-project-lite", +] + [[package]] name = "extend" version = "1.2.0" @@ -1132,6 +1300,25 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-task" version = "0.3.30" @@ -1216,6 +1403,18 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "goblin" version = "0.8.2" @@ -1623,6 +1822,15 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1668,6 +1876,9 @@ name = "log" version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +dependencies = [ + "value-bag", +] [[package]] name = "memchr" @@ -1886,6 +2097,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.3" @@ -1958,6 +2175,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -1980,6 +2208,21 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polling" +version = "3.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2790cd301dec6cd3b7a025e4815cf825724a51c98dccfe6a3e55f05ffb6511" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.4.0", + "pin-project-lite", + "rustix", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "polyval" version = "0.6.2" @@ -2617,11 +2860,12 @@ dependencies = [ [[package]] name = "sargon" -version = "1.1.27" +version = "1.1.28" dependencies = [ "actix-rt", "aes-gcm", "assert-json-diff", + "async-std", "async-trait", "base64 0.22.1 (git+https://github.com/marshallpierce/rust-base64.git?rev=e14400697453bcc85997119b874bc03d9601d0af)", "bip39", @@ -3119,6 +3363,15 @@ dependencies = [ "walkdir", ] +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + [[package]] name = "smallvec" version = "1.13.2" @@ -3742,6 +3995,12 @@ dependencies = [ "getrandom 0.2.15", ] +[[package]] +name = "value-bag" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a84c137d37ab0142f0f2ddfe332651fdbf252e7b7dbb4e67b6c1f1b2e925101" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/crates/sargon/Cargo.toml b/crates/sargon/Cargo.toml index 74242531b..6dd9c3d11 100644 --- a/crates/sargon/Cargo.toml +++ b/crates/sargon/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sargon" -version = "1.1.27" +version = "1.1.28" edition = "2021" build = "build.rs" @@ -175,6 +175,10 @@ base64 = { git = "https://github.com/marshallpierce/rust-base64.git", rev = "e14 reqwest = { git = "https://github.com/seanmonstar/reqwest", rev = "0720159f6369f54e045a1fd315e0f24b7a0b4a39", default-features = false, features = [ "native-tls-vendored", ] } + +# async-std = "1.13.0" +async-std = "1.13.0" + # Fixes nasty iOS bug "_kSecMatchSubjectWholeString", see https://github.com/kornelski/rust-security-framework/issues/203 # This is a workaround to fix a bug with version 2.11.0 that added some symbols that are not available on iOS # The bug is fixed already but the fix is not released yet. https://github.com/kornelski/rust-security-framework/pull/204 diff --git a/crates/sargon/src/core/types/epoch.rs b/crates/sargon/src/core/types/epoch.rs index 44f01d586..49a910c2c 100644 --- a/crates/sargon/src/core/types/epoch.rs +++ b/crates/sargon/src/core/types/epoch.rs @@ -10,6 +10,8 @@ uniffi::custom_newtype!(Epoch, u64); PartialEq, Eq, Hash, + Serialize, + Deserialize, Ord, PartialOrd, derive_more::Display, diff --git a/crates/sargon/src/gateway_api/endpoints/transaction_endpoints.rs b/crates/sargon/src/gateway_api/endpoints/transaction_endpoints.rs index 28b7a7b16..f41b560e5 100644 --- a/crates/sargon/src/gateway_api/endpoints/transaction_endpoints.rs +++ b/crates/sargon/src/gateway_api/endpoints/transaction_endpoints.rs @@ -44,4 +44,16 @@ impl GatewayClient { ) -> Result { self.post("transaction/submit", request, res_id).await } + + /// Observes the status of a transaction. + /// + /// See [the Gateway API docs for details][doc]. + /// + /// [doc]: https://radix-babylon-gateway-api.redoc.ly/#operation/TransactionStatus + pub(crate) async fn transaction_status( + &self, + request: TransactionStatusRequest, + ) -> Result { + self.post("transaction/status", request, res_id).await + } } diff --git a/crates/sargon/src/gateway_api/methods/transaction_methods.rs b/crates/sargon/src/gateway_api/methods/transaction_methods.rs index 4e1b7c63d..205574f2f 100644 --- a/crates/sargon/src/gateway_api/methods/transaction_methods.rs +++ b/crates/sargon/src/gateway_api/methods/transaction_methods.rs @@ -50,3 +50,14 @@ impl GatewayClient { }) } } + +impl GatewayClient { + /// Returns the status of a transaction by its `IntentHash`. + pub async fn get_transaction_status( + &self, + intent_hash: IntentHash, + ) -> Result { + let request = TransactionStatusRequest::new(intent_hash.to_string()); + self.transaction_status(request).await + } +} diff --git a/crates/sargon/src/gateway_api/models/logic/response/ledger_state.rs b/crates/sargon/src/gateway_api/models/logic/response/ledger_state.rs index 146120ebc..001941f03 100644 --- a/crates/sargon/src/gateway_api/models/logic/response/ledger_state.rs +++ b/crates/sargon/src/gateway_api/models/logic/response/ledger_state.rs @@ -1,3 +1,15 @@ use crate::prelude::*; -impl LedgerState {} +#[cfg(test)] +impl LedgerState { + /// A sample used to facilitate unit tests. + pub fn sample_stokenet() -> Self { + Self { + network: NetworkID::Stokenet.logical_name(), + state_version: 80577579, + proposer_round_timestamp: "2024-10-07T15:41:07.259Z".to_string(), + epoch: 41965, + round: 894, + } + } +} diff --git a/crates/sargon/src/gateway_api/models/logic/response/transaction/mod.rs b/crates/sargon/src/gateway_api/models/logic/response/transaction/mod.rs index 5aecf5d43..6677bb3cc 100644 --- a/crates/sargon/src/gateway_api/models/logic/response/transaction/mod.rs +++ b/crates/sargon/src/gateway_api/models/logic/response/transaction/mod.rs @@ -1,7 +1,9 @@ mod construction; mod preview; +mod status; mod submit; pub use construction::*; pub use preview::*; +pub use status::*; pub use submit::*; diff --git a/crates/sargon/src/gateway_api/models/logic/response/transaction/status/mod.rs b/crates/sargon/src/gateway_api/models/logic/response/transaction/status/mod.rs new file mode 100644 index 000000000..5429fb306 --- /dev/null +++ b/crates/sargon/src/gateway_api/models/logic/response/transaction/status/mod.rs @@ -0,0 +1,2 @@ +mod payload_item; +mod transaction_status; diff --git a/crates/sargon/src/gateway_api/models/logic/response/transaction/status/payload_item.rs b/crates/sargon/src/gateway_api/models/logic/response/transaction/status/payload_item.rs new file mode 100644 index 000000000..71bb953ca --- /dev/null +++ b/crates/sargon/src/gateway_api/models/logic/response/transaction/status/payload_item.rs @@ -0,0 +1,88 @@ +use crate::prelude::*; + +impl HasSampleValues for TransactionStatusResponsePayloadItem { + fn sample() -> Self { + Self::sample_pending() + } + + fn sample_other() -> Self { + Self::sample_committed_success() + } +} + +impl TransactionStatusResponsePayloadItem { + pub fn sample_unknown() -> Self { + Self { + payload_status: Some( + TransactionStatusResponsePayloadStatus::Unknown, + ), + } + } + + pub fn sample_commit_pending_outcome_unknown() -> Self { + Self { + payload_status: Some( + TransactionStatusResponsePayloadStatus::CommitPendingOutcomeUnknown, + ), + } + } + + pub fn sample_pending() -> Self { + Self { + payload_status: Some( + TransactionStatusResponsePayloadStatus::Pending, + ), + } + } + + pub fn sample_committed_success() -> Self { + Self { + payload_status: Some( + TransactionStatusResponsePayloadStatus::CommittedSuccess, + ), + } + } + + pub fn sample_committed_failure() -> Self { + Self { + payload_status: Some( + TransactionStatusResponsePayloadStatus::CommittedFailure, + ), + } + } + + pub fn sample_committed_permanently_rejected() -> Self { + Self { + payload_status: Some( + TransactionStatusResponsePayloadStatus::PermanentlyRejected, + ), + } + } + + pub fn sample_temporarily_rejected() -> Self { + Self { + payload_status: Some( + TransactionStatusResponsePayloadStatus::TemporarilyRejected, + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[allow(clippy::upper_case_acronyms)] + type SUT = TransactionStatusResponsePayloadItem; + + #[test] + fn equality() { + assert_eq!(SUT::sample(), SUT::sample()); + assert_eq!(SUT::sample_other(), SUT::sample_other()); + } + + #[test] + fn inequality() { + assert_ne!(SUT::sample(), SUT::sample_other()); + } +} diff --git a/crates/sargon/src/gateway_api/models/logic/response/transaction/status/transaction_status.rs b/crates/sargon/src/gateway_api/models/logic/response/transaction/status/transaction_status.rs new file mode 100644 index 000000000..bd649adda --- /dev/null +++ b/crates/sargon/src/gateway_api/models/logic/response/transaction/status/transaction_status.rs @@ -0,0 +1,36 @@ +use crate::prelude::*; + +#[cfg(test)] +mod tests { + use super::*; + + #[allow(clippy::upper_case_acronyms)] + type SUT = TransactionStatusResponse; + + #[test] + fn json_test() { + let pending = fixture::(include_str!(concat!( + env!("FIXTURES_MODELS_GW"), + "transaction/response_status__pending.json" + ))) + .unwrap(); + assert_eq!( + pending.known_payloads.get(0).unwrap().payload_status, + Some(TransactionStatusResponsePayloadStatus::Pending) + ); + + let committed_success = fixture::(include_str!(concat!( + env!("FIXTURES_MODELS_GW"), + "transaction/response_status__committed_success.json" + ))) + .unwrap(); + assert_eq!( + committed_success + .known_payloads + .get(0) + .unwrap() + .payload_status, + Some(TransactionStatusResponsePayloadStatus::CommittedSuccess) + ); + } +} diff --git a/crates/sargon/src/gateway_api/models/types/request/transaction/mod.rs b/crates/sargon/src/gateway_api/models/types/request/transaction/mod.rs index 8a3a19527..8f15aadd6 100644 --- a/crates/sargon/src/gateway_api/models/types/request/transaction/mod.rs +++ b/crates/sargon/src/gateway_api/models/types/request/transaction/mod.rs @@ -1,5 +1,7 @@ mod preview; +mod status; mod submit; pub use preview::*; +pub use status::*; pub use submit::*; diff --git a/crates/sargon/src/gateway_api/models/types/request/transaction/status/mod.rs b/crates/sargon/src/gateway_api/models/types/request/transaction/status/mod.rs new file mode 100644 index 000000000..ebf3585bf --- /dev/null +++ b/crates/sargon/src/gateway_api/models/types/request/transaction/status/mod.rs @@ -0,0 +1,3 @@ +mod transaction_status; + +pub use transaction_status::*; diff --git a/crates/sargon/src/gateway_api/models/types/request/transaction/status/transaction_status.rs b/crates/sargon/src/gateway_api/models/types/request/transaction/status/transaction_status.rs new file mode 100644 index 000000000..200cbbac0 --- /dev/null +++ b/crates/sargon/src/gateway_api/models/types/request/transaction/status/transaction_status.rs @@ -0,0 +1,13 @@ +use crate::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransactionStatusRequest { + /** Bech32m-encoded hash. */ + pub(crate) intent_hash: String, +} + +impl TransactionStatusRequest { + pub fn new(intent_hash: String) -> Self { + Self { intent_hash } + } +} diff --git a/crates/sargon/src/gateway_api/models/types/response/transaction/mod.rs b/crates/sargon/src/gateway_api/models/types/response/transaction/mod.rs index 5aecf5d43..6677bb3cc 100644 --- a/crates/sargon/src/gateway_api/models/types/response/transaction/mod.rs +++ b/crates/sargon/src/gateway_api/models/types/response/transaction/mod.rs @@ -1,7 +1,9 @@ mod construction; mod preview; +mod status; mod submit; pub use construction::*; pub use preview::*; +pub use status::*; pub use submit::*; diff --git a/crates/sargon/src/gateway_api/models/types/response/transaction/status/mod.rs b/crates/sargon/src/gateway_api/models/types/response/transaction/status/mod.rs new file mode 100644 index 000000000..fcb904b0a --- /dev/null +++ b/crates/sargon/src/gateway_api/models/types/response/transaction/status/mod.rs @@ -0,0 +1,7 @@ +mod payload_item; +mod payload_status; +mod transaction_status; + +pub use payload_item::*; +pub use payload_status::*; +pub use transaction_status::*; diff --git a/crates/sargon/src/gateway_api/models/types/response/transaction/status/payload_item.rs b/crates/sargon/src/gateway_api/models/types/response/transaction/status/payload_item.rs new file mode 100644 index 000000000..7c493be9c --- /dev/null +++ b/crates/sargon/src/gateway_api/models/types/response/transaction/status/payload_item.rs @@ -0,0 +1,6 @@ +use crate::prelude::*; + +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, Debug)] +pub struct TransactionStatusResponsePayloadItem { + pub payload_status: Option, +} diff --git a/crates/sargon/src/gateway_api/models/types/response/transaction/status/payload_status.rs b/crates/sargon/src/gateway_api/models/types/response/transaction/status/payload_status.rs new file mode 100644 index 000000000..a052ef805 --- /dev/null +++ b/crates/sargon/src/gateway_api/models/types/response/transaction/status/payload_status.rs @@ -0,0 +1,12 @@ +use crate::prelude::*; + +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, Debug)] +pub enum TransactionStatusResponsePayloadStatus { + Unknown, + CommittedSuccess, + CommittedFailure, + CommitPendingOutcomeUnknown, + PermanentlyRejected, + TemporarilyRejected, + Pending, +} diff --git a/crates/sargon/src/gateway_api/models/types/response/transaction/status/transaction_status.rs b/crates/sargon/src/gateway_api/models/types/response/transaction/status/transaction_status.rs new file mode 100644 index 000000000..3f775d694 --- /dev/null +++ b/crates/sargon/src/gateway_api/models/types/response/transaction/status/transaction_status.rs @@ -0,0 +1,8 @@ +use crate::prelude::*; + +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, Debug)] +pub struct TransactionStatusResponse { + pub ledger_state: LedgerState, + pub known_payloads: Vec, + pub error_message: Option, +} diff --git a/crates/sargon/src/system/drivers/networking_driver/support/test/mock_networking_driver.rs b/crates/sargon/src/system/drivers/networking_driver/support/test/mock_networking_driver.rs index 65e038d2c..6130fb640 100644 --- a/crates/sargon/src/system/drivers/networking_driver/support/test/mock_networking_driver.rs +++ b/crates/sargon/src/system/drivers/networking_driver/support/test/mock_networking_driver.rs @@ -1,12 +1,13 @@ #![cfg(test)] use crate::prelude::*; +use std::sync::Mutex; /// A mocked network antenna, useful for testing. #[derive(Debug)] pub struct MockNetworkingDriver { hard_coded_status: u16, - hard_coded_body: BagOfBytes, + hard_coded_bodies: Mutex>, spy: fn(NetworkRequest) -> (), } @@ -19,7 +20,7 @@ impl MockNetworkingDriver { ) -> Self { Self { hard_coded_status: status, - hard_coded_body: body.into(), + hard_coded_bodies: Mutex::new(vec![body.into()]), spy, } } @@ -39,6 +40,23 @@ impl MockNetworkingDriver { let body = serde_json::to_vec(&response).unwrap(); Self::new(200, body) } + + pub fn with_responses(responses: Vec) -> Self + where + T: Serialize, + { + let bodies = responses + .into_iter() + .map(|r| serde_json::to_vec(&r).unwrap()) + .map(BagOfBytes::from) + .collect(); + + Self { + hard_coded_status: 200, + hard_coded_bodies: Mutex::new(bodies), + spy: |_| {}, + } + } } #[async_trait::async_trait] @@ -48,9 +66,14 @@ impl NetworkingDriver for MockNetworkingDriver { request: NetworkRequest, ) -> Result { (self.spy)(request); - Ok(NetworkResponse { - status_code: self.hard_coded_status, - body: self.hard_coded_body.clone(), - }) + let mut bodies = self.hard_coded_bodies.lock().unwrap(); + if bodies.is_empty() { + Err(CommonError::Unknown) + } else { + Ok(NetworkResponse { + status_code: self.hard_coded_status, + body: bodies.remove(0), + }) + } } } diff --git a/crates/sargon/src/system/sargon_os/mod.rs b/crates/sargon/src/system/sargon_os/mod.rs index f9a1354bb..03162aa0b 100644 --- a/crates/sargon/src/system/sargon_os/mod.rs +++ b/crates/sargon/src/system/sargon_os/mod.rs @@ -5,6 +5,7 @@ mod sargon_os_factors; mod sargon_os_gateway; mod sargon_os_profile; mod sargon_os_security_structures; +mod sargon_os_transactions; pub use profile_state_holder::*; pub use sargon_os::*; @@ -13,3 +14,4 @@ pub use sargon_os_factors::*; pub use sargon_os_gateway::*; pub use sargon_os_profile::*; pub use sargon_os_security_structures::*; +pub use sargon_os_transactions::*; diff --git a/crates/sargon/src/system/sargon_os/sargon_os.rs b/crates/sargon/src/system/sargon_os/sargon_os.rs index d20a6aac0..1edabc0dd 100644 --- a/crates/sargon/src/system/sargon_os/sargon_os.rs +++ b/crates/sargon/src/system/sargon_os/sargon_os.rs @@ -362,6 +362,33 @@ impl SargonOS { .unwrap() .unwrap() } + + /// Boot the SargonOS with a mocked networking driver. + /// This is useful for testing the SargonOS without needing to connect to the internet. + pub async fn boot_test_with_networking_driver( + networking: Arc, + ) -> Result> { + let drivers = Drivers::with_networking(networking); + let bios = Bios::new(drivers); + let os = Self::boot(bios).await; + + let (mut profile, bdfs) = os.create_new_profile_with_bdfs(None).await?; + + // Append Stokenet network since initial profile has no network + profile + .networks + .append(ProfileNetwork::new_empty_on(NetworkID::Stokenet)); + + os.secure_storage + .save_private_hd_factor_source(&bdfs) + .await?; + os.secure_storage.save_profile(&profile).await?; + os.profile_state_holder.replace_profile_state_with( + ProfileState::Loaded(profile.clone()), + )?; + + Ok(os) + } } #[cfg(test)] diff --git a/crates/sargon/src/system/sargon_os/sargon_os_transactions.rs b/crates/sargon/src/system/sargon_os/sargon_os_transactions.rs new file mode 100644 index 000000000..136a8c5ed --- /dev/null +++ b/crates/sargon/src/system/sargon_os/sargon_os_transactions.rs @@ -0,0 +1,388 @@ +use crate::prelude::*; +use std::time::Duration; + +// ================== +// Submit Transaction +// ================== +#[uniffi::export] +impl SargonOS { + /// Submits a notarized transaction payload to the network. + pub async fn submit_transaction( + &self, + notarized_transaction: NotarizedTransaction, + ) -> Result { + let network_id = self.current_network_id()?; + let gateway_client = GatewayClient::new( + self.clients.http_client.driver.clone(), + network_id, + ); + gateway_client + .submit_notarized_transaction(notarized_transaction) + .await + } +} + +// ================== +// Poll Transaction Status (Public) +// ================== +#[uniffi::export] +impl SargonOS { + /// Polls the state of a Transaction until we can determine its `TransactionStatus`. + pub async fn poll_transaction_status( + &self, + intent_hash: IntentHash, + ) -> Result { + let (status, _) = self + .poll_transaction_status_with_delays(intent_hash) + .await?; + Ok(status) + } +} + +// ================== +// Poll Transaction Status (Internal) +// ================== +impl SargonOS { + /// Polls the state of a Transaction until we can determine its `TransactionStatus`. + /// + /// This is the internal implementation of `poll_transaction_status`, which is the public API. + /// It returns the `TransactionStatus`, but also the list of delays between each poll. + async fn poll_transaction_status_with_delays( + &self, + intent_hash: IntentHash, + ) -> Result<(TransactionStatus, Vec)> { + let network_id = self.current_network_id()?; + let gateway_client = GatewayClient::new( + self.clients.http_client.driver.clone(), + network_id, + ); + let mut delays: Vec = vec![]; + + // The delay increment is set to 1 second in production, but 1 millisecond in tests. + // This will make the tests run with almost no delay, while the production code will have a 2s delay after first call, + // a 3s delay after second call, 4s after third and so on. + #[cfg(test)] + const DELAY_INCREMENT: u64 = 1; + #[cfg(not(test))] + const DELAY_INCREMENT: u64 = 1000; + + let mut delay_duration = DELAY_INCREMENT; + + loop { + // Increase delay by 1 second on subsequent calls + delay_duration += DELAY_INCREMENT; + let sleep_duration = Duration::from_millis(delay_duration); + + let response = gateway_client + .get_transaction_status(intent_hash.clone()) + .await?; + + match response + .known_payloads + .first() + .and_then(|payload| payload.payload_status.clone()) + { + Some(status) => { + match status { + TransactionStatusResponsePayloadStatus::Unknown | + TransactionStatusResponsePayloadStatus::Pending | + TransactionStatusResponsePayloadStatus::CommitPendingOutcomeUnknown => { + delays.push(delay_duration); + async_std::task::sleep(sleep_duration).await; + } + TransactionStatusResponsePayloadStatus::CommittedSuccess => { + return Ok((TransactionStatus::Success, delays)); + } + TransactionStatusResponsePayloadStatus::CommittedFailure => { + return Ok((TransactionStatus::Failed { reason: TransactionStatusReason::from_raw_error(response.error_message) }, delays)); + } + TransactionStatusResponsePayloadStatus::PermanentlyRejected => { + return Ok((TransactionStatus::PermanentlyRejected { reason: TransactionStatusReason::from_raw_error(response.error_message) }, delays)); + } + TransactionStatusResponsePayloadStatus::TemporarilyRejected => { + return Ok((TransactionStatus::TemporarilyRejected { current_epoch: Epoch::from(response.ledger_state.epoch) }, delays)); + } + } + } + None => { + delays.push(delay_duration); + async_std::task::sleep(sleep_duration).await; + } + } + } + } +} + +#[cfg(test)] +mod submit_transaction_tests { + use super::*; + use actix_rt::time::timeout; + use std::{future::Future, time::Duration}; + + #[allow(clippy::upper_case_acronyms)] + type SUT = SargonOS; + + #[actix_rt::test] + async fn submit_transaction_success() { + let notarized_transaction = NotarizedTransaction::sample(); + let response = TransactionSubmitResponse { duplicate: false }; + let body = serde_json::to_vec(&response).unwrap(); + + let mock_driver = + MockNetworkingDriver::with_spy(200, body, |request| { + // Verify the body sent matches the expected one + let sent_request = TransactionSubmitRequest::new( + NotarizedTransaction::sample(), + ); + let sent_body = serde_json::to_vec(&sent_request).unwrap(); + + assert_eq!(request.body.bytes, sent_body); + }); + + let req = SUT::boot_test_with_networking_driver(Arc::new(mock_driver)); + + let os = + actix_rt::time::timeout(SARGON_OS_TEST_MAX_ASYNC_DURATION, req) + .await + .unwrap() + .unwrap(); + + let result = os + .submit_transaction(notarized_transaction.clone()) + .await + .unwrap(); + + let expected_result = + notarized_transaction.signed_intent().intent().intent_hash(); + + assert_eq!(result, expected_result); + } + + #[actix_rt::test] + async fn submit_transaction_failure() { + let notarized_transaction = NotarizedTransaction::sample(); + let mock_driver = MockNetworkingDriver::new_always_failing(); + + let req = SUT::boot_test_with_networking_driver(Arc::new(mock_driver)); + + let os = + actix_rt::time::timeout(SARGON_OS_TEST_MAX_ASYNC_DURATION, req) + .await + .unwrap() + .unwrap(); + + let result = os + .submit_transaction(notarized_transaction) + .await + .expect_err("Expected an error"); + + assert_eq!(result, CommonError::NetworkResponseBadCode); + } +} + +#[cfg(test)] +mod poll_status_tests { + use super::*; + use actix_rt::time::timeout; + use std::{future::Future, time::Duration}; + + #[allow(clippy::upper_case_acronyms)] + type SUT = SargonOS; + + #[actix_rt::test] + async fn poll_status_success() { + // This test will simulate the case where the first response is a `CommittedSuccess` + let result = + simulate_poll_status(vec![sample_committed_success()]).await; + + // Result should be `Success` + assert_eq!(result.0, TransactionStatus::Success); + // and there shouldn't be any delays + assert!(result.1.is_empty()); + } + + #[actix_rt::test] + async fn poll_status_pending_then_failure() { + // This test will simulate the case where the first response is empty (no payload status), + // the second is `Pending` and the third is a `CommittedFailure` + let result = simulate_poll_status(vec![ + sample_empty(), + sample_pending(), + sample_committed_failure(None), + ]) + .await; + + // Result should be `Failed` + assert_eq!( + result.0, + TransactionStatus::Failed { + reason: TransactionStatusReason::Unknown + } + ); + // and there should have been a delay of 2s after first call, and 3s after the second call + assert_eq!(result.1, vec![2, 3]); + } + + #[actix_rt::test] + async fn poll_status_unknown_then_permanently_rejected() { + // This test will simulate the case where the first response is `Unknown`, + // while the second response is a `PermanentlyRejected` + let result = simulate_poll_status(vec![ + sample_unknown(), + sample_permanently_rejected(Some("AssertionFailed".to_owned())), + ]) + .await; + + // Result should be `PermanentlyRejected` + assert_eq!( + result.0, + TransactionStatus::PermanentlyRejected { + reason: TransactionStatusReason::WorktopError + } + ); + // and there should have been a delay of 2s after first call + assert_eq!(result.1, vec![2]); + } + + #[actix_rt::test] + async fn poll_status_commit_pending_outcome_unknown_then_temporarily_rejected( + ) { + // This test will simulate the case where the first response is `Unknown`, + // while the second response is a `PermanentlyRejected` + let result = simulate_poll_status(vec![ + sample_commit_pending_outcome_unknown(), + sample_temporarily_rejected(), + ]) + .await; + + let current_epoch = Epoch::from(LedgerState::sample_stokenet().epoch); + // Result should be `TemporarilyRejected` + assert_eq!( + result.0, + TransactionStatus::TemporarilyRejected { current_epoch } + ); + // and there should have been a delay of 2s after first call + assert_eq!(result.1, vec![2]); + } + + #[actix_rt::test] + async fn poll_status_error() { + // This test will simulate the case where we fail to get a `TransactionStatusResponse` from gateway + let mock_driver = MockNetworkingDriver::new_always_failing(); + + let req = SUT::boot_test_with_networking_driver(Arc::new(mock_driver)); + + let os = + actix_rt::time::timeout(SARGON_OS_TEST_MAX_ASYNC_DURATION, req) + .await + .unwrap() + .unwrap(); + + let result = os + .poll_transaction_status(IntentHash::sample()) + .await + .expect_err("Expected an error"); + + assert_eq!(result, CommonError::NetworkResponseBadCode); + } + + // Creates a `MockNetworkingDriver` that returns the given list of responses sequentially, + // and then call `poll_transaction_status` to get the result. + async fn simulate_poll_status( + responses: Vec, + ) -> (TransactionStatus, Vec) { + let mock_driver = MockNetworkingDriver::with_responses(responses); + + let req = SUT::boot_test_with_networking_driver(Arc::new(mock_driver)); + + let os = + actix_rt::time::timeout(SARGON_OS_TEST_MAX_ASYNC_DURATION, req) + .await + .unwrap() + .unwrap(); + + os.poll_transaction_status_with_delays(IntentHash::sample()) + .await + .unwrap() + } + + // Helper functions to create sample responses + + fn sample_empty() -> TransactionStatusResponse { + TransactionStatusResponse { + known_payloads: vec![], + ledger_state: LedgerState::sample_stokenet(), + error_message: None, + } + } + + fn sample_unknown() -> TransactionStatusResponse { + TransactionStatusResponse { + known_payloads: vec![ + TransactionStatusResponsePayloadItem::sample_unknown(), + ], + ledger_state: LedgerState::sample_stokenet(), + error_message: None, + } + } + + fn sample_pending() -> TransactionStatusResponse { + TransactionStatusResponse { + known_payloads: vec![ + TransactionStatusResponsePayloadItem::sample_pending(), + ], + ledger_state: LedgerState::sample_stokenet(), + error_message: None, + } + } + + fn sample_commit_pending_outcome_unknown() -> TransactionStatusResponse { + TransactionStatusResponse { + known_payloads: vec![TransactionStatusResponsePayloadItem::sample_commit_pending_outcome_unknown()], + ledger_state: LedgerState::sample_stokenet(), + error_message: None, + } + } + + fn sample_committed_success() -> TransactionStatusResponse { + TransactionStatusResponse { + known_payloads: vec![ + TransactionStatusResponsePayloadItem::sample_committed_success( + ), + ], + ledger_state: LedgerState::sample_stokenet(), + error_message: None, + } + } + + fn sample_committed_failure( + error_message: Option, + ) -> TransactionStatusResponse { + TransactionStatusResponse { + known_payloads: vec![ + TransactionStatusResponsePayloadItem::sample_committed_failure( + ), + ], + ledger_state: LedgerState::sample_stokenet(), + error_message, + } + } + + fn sample_permanently_rejected( + error_message: Option, + ) -> TransactionStatusResponse { + TransactionStatusResponse { + known_payloads: vec![TransactionStatusResponsePayloadItem::sample_committed_permanently_rejected()], + ledger_state: LedgerState::sample_stokenet(), + error_message, + } + } + + fn sample_temporarily_rejected() -> TransactionStatusResponse { + TransactionStatusResponse { + known_payloads: vec![TransactionStatusResponsePayloadItem::sample_temporarily_rejected()], + ledger_state: LedgerState::sample_stokenet(), + error_message: None, + } + } +} diff --git a/crates/sargon/src/wrapped_radix_engine_toolkit/high_level/sargon_specific_types/mod.rs b/crates/sargon/src/wrapped_radix_engine_toolkit/high_level/sargon_specific_types/mod.rs index 65fb7fe9d..72693874a 100644 --- a/crates/sargon/src/wrapped_radix_engine_toolkit/high_level/sargon_specific_types/mod.rs +++ b/crates/sargon/src/wrapped_radix_engine_toolkit/high_level/sargon_specific_types/mod.rs @@ -8,6 +8,7 @@ mod build_information; mod manifest_encountered_component_address; mod stake_claim; mod stake_claim_uniffi_fn; +mod transaction; mod transaction_guarantee; pub use address_of_account_or_persona::*; @@ -22,3 +23,4 @@ pub use transaction_guarantee::*; pub use account_locker::*; pub use manifest_encountered_component_address::*; +pub use transaction::*; diff --git a/crates/sargon/src/wrapped_radix_engine_toolkit/high_level/sargon_specific_types/transaction/mod.rs b/crates/sargon/src/wrapped_radix_engine_toolkit/high_level/sargon_specific_types/transaction/mod.rs new file mode 100644 index 000000000..b566a8a6b --- /dev/null +++ b/crates/sargon/src/wrapped_radix_engine_toolkit/high_level/sargon_specific_types/transaction/mod.rs @@ -0,0 +1,5 @@ +mod transaction_status; +mod transaction_status_reason; + +pub use transaction_status::*; +pub use transaction_status_reason::*; diff --git a/crates/sargon/src/wrapped_radix_engine_toolkit/high_level/sargon_specific_types/transaction/transaction_status.rs b/crates/sargon/src/wrapped_radix_engine_toolkit/high_level/sargon_specific_types/transaction/transaction_status.rs new file mode 100644 index 000000000..99da70d10 --- /dev/null +++ b/crates/sargon/src/wrapped_radix_engine_toolkit/high_level/sargon_specific_types/transaction/transaction_status.rs @@ -0,0 +1,58 @@ +use crate::prelude::*; + +#[derive( + Clone, + Debug, + Serialize, + Deserialize, + PartialEq, + Eq, + Hash, + derive_more::Display, + uniffi::Enum, +)] +#[serde(rename_all = "camelCase")] +pub enum TransactionStatus { + /// The transaction has been successfully processed and is now final. + Success, + + /// The transaction has been permanently rejected with the given `reason`. + PermanentlyRejected { reason: TransactionStatusReason }, + + /// The transaction has been temporarily rejected and may be processed in the future. + TemporarilyRejected { current_epoch: Epoch }, + + /// The transaction has failed with the given `reason`. + Failed { reason: TransactionStatusReason }, +} + +impl HasSampleValues for TransactionStatus { + fn sample() -> Self { + Self::Success + } + + fn sample_other() -> Self { + Self::PermanentlyRejected { + reason: TransactionStatusReason::sample(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[allow(clippy::upper_case_acronyms)] + type SUT = TransactionStatus; + + #[test] + fn equality() { + assert_eq!(SUT::sample(), SUT::sample()); + assert_eq!(SUT::sample_other(), SUT::sample_other()); + } + + #[test] + fn inequality() { + assert_ne!(SUT::sample(), SUT::sample_other()); + } +} diff --git a/crates/sargon/src/wrapped_radix_engine_toolkit/high_level/sargon_specific_types/transaction/transaction_status_reason.rs b/crates/sargon/src/wrapped_radix_engine_toolkit/high_level/sargon_specific_types/transaction/transaction_status_reason.rs new file mode 100644 index 000000000..d1de1899c --- /dev/null +++ b/crates/sargon/src/wrapped_radix_engine_toolkit/high_level/sargon_specific_types/transaction/transaction_status_reason.rs @@ -0,0 +1,77 @@ +use crate::prelude::*; + +#[derive( + Clone, + Debug, + Serialize, + Deserialize, + PartialEq, + Eq, + Hash, + derive_more::Display, + uniffi::Enum, +)] +#[serde(rename_all = "camelCase")] +pub enum TransactionStatusReason { + /// The transaction was rejected for an unknown reason. + Unknown, + + /// The transaction was rejected because there was an application error in the worktop. + WorktopError, +} + +impl HasSampleValues for TransactionStatusReason { + fn sample() -> Self { + Self::Unknown + } + + fn sample_other() -> Self { + Self::WorktopError + } +} + +impl TransactionStatusReason { + pub fn from_raw_error(raw_error: impl Into>) -> Self { + match raw_error.into() { + Some(raw_error) => { + if raw_error.contains("AssertionFailed") { + Self::WorktopError + } else { + Self::Unknown + } + } + None => Self::Unknown, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[allow(clippy::upper_case_acronyms)] + type SUT = TransactionStatusReason; + + #[test] + fn equality() { + assert_eq!(SUT::sample(), SUT::sample()); + assert_eq!(SUT::sample_other(), SUT::sample_other()); + } + + #[test] + fn inequality() { + assert_ne!(SUT::sample(), SUT::sample_other()); + } + + #[test] + fn from_error() { + let mut sut = SUT::from_raw_error(None); + assert_eq!(sut, SUT::Unknown); + + sut = SUT::from_raw_error("whatever".to_string()); + assert_eq!(sut, SUT::Unknown); + + sut = SUT::from_raw_error("AssertionFailed".to_string()); + assert_eq!(sut, SUT::WorktopError); + } +} diff --git a/crates/sargon/tests/integration/main.rs b/crates/sargon/tests/integration/main.rs index 5162261f0..39c6f3c92 100644 --- a/crates/sargon/tests/integration/main.rs +++ b/crates/sargon/tests/integration/main.rs @@ -235,4 +235,27 @@ mod integration_tests { ) ); } + + #[actix_rt::test] + async fn get_transaction_status() { + let network_id = NetworkID::Stokenet; + let gateway_client = new_gateway_client(network_id); + let private_key = Ed25519PrivateKey::generate(); + let (_, tx_id) = + submit_tx_use_faucet(private_key, network_id).await.unwrap(); + + let status_response = + timeout(MAX, gateway_client.get_transaction_status(tx_id)) + .await + .unwrap() + .unwrap(); + + assert_eq!(status_response.error_message, None); + let status = status_response + .known_payloads + .first() + .and_then(|payload| payload.payload_status.clone()) + .unwrap(); + assert_eq!(status, TransactionStatusResponsePayloadStatus::Pending); + } }