diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 1d5ced4b7..116d81337 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -468,7 +468,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.10.0", ] [[package]] @@ -597,6 +597,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83b2eb4d90d12bdda5ed17de686c2acb4c57914f8f921b8da7e112b5a36f3fe1" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622687fe0bac72a04e5599029151f5796111b90f1baaa9b544d807a5e31cd120" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.48", +] + +[[package]] +name = "darling_macro" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "733cabb43482b1a1b53eee8583c2b9e8684d592215ea83efd305dd31bc2f0178" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.48", +] + [[package]] name = "debugid" version = "0.8.0" @@ -615,6 +650,37 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_builder" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0350b5cb0331628a5916d6c5c0b72e97393b8b6b03b47a9284f4e7f5a405ffd7" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d48cda787f839151732d396ac69e3473923d54312c070ee21e9effcaa8ca0b1d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206868b8242f27cecce124c19fd88157fbd0dd334df2587f36417bafbc85097b" +dependencies = [ + "derive_builder_core", + "syn 2.0.48", +] + [[package]] name = "diesel" version = "2.1.4" @@ -1116,7 +1182,7 @@ dependencies = [ "futures-sink", "futures-util", "http", - "indexmap 2.0.0", + "indexmap 2.2.6", "slab", "tokio", "tokio-util", @@ -1131,9 +1197,9 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" -version = "0.14.0" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "headers" @@ -1302,6 +1368,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.2.3" @@ -1364,12 +1436,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.0.0" +version = "2.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" dependencies = [ "equivalent", - "hashbrown 0.14.0", + "hashbrown 0.14.5", ] [[package]] @@ -1379,7 +1451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "321f0f839cd44a4686e9504b0a62b4d69a50b62072144c71c68f5873c167b8d9" dependencies = [ "ahash", - "indexmap 2.0.0", + "indexmap 2.2.6", "is-terminal", "itoa", "log", @@ -1540,12 +1612,6 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linux-raw-sys" version = "0.3.8" @@ -1574,6 +1640,26 @@ version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" +[[package]] +name = "lz4" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6eab492fe7f8651add23237ea56dbf11b3c4ff762ab83d40a47f11433421f91" +dependencies = [ + "libc", + "lz4-sys", +] + +[[package]] +name = "lz4-sys" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9764018d143cc854c9f17f0b907de70f14393b1f502da6375dce70f00514eb3" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "matchers" version = "0.1.0" @@ -2647,18 +2733,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.193" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.193" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", @@ -2671,7 +2757,7 @@ version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46266871c240a00b8f503b877622fe33430b3c7d963bdc0f2adc511e54a1eae3" dependencies = [ - "indexmap 2.0.0", + "indexmap 2.2.6", "itoa", "ryu", "serde", @@ -2700,14 +2786,15 @@ dependencies = [ [[package]] name = "serde_yaml" -version = "0.8.26" +version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578a7433b776b56a35785ed5ce9a7e777ac0598aac5a6dd1b4b18a307c7fc71b" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 1.9.3", + "indexmap 2.2.6", + "itoa", "ryu", "serde", - "yaml-rust", + "unsafe-libyaml", ] [[package]] @@ -2889,6 +2976,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.24.1" @@ -2984,18 +3077,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.56" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" +checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.56" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" +checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" dependencies = [ "proc-macro2", "quote", @@ -3250,7 +3343,7 @@ version = "0.19.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f8751d9c1b03c6500c387e96f81f815a4f8e72d142d2d4a9ffa6fedd51ddee7" dependencies = [ - "indexmap 2.0.0", + "indexmap 2.2.6", "serde", "serde_spanned", "toml_datetime", @@ -3431,6 +3524,22 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "transaction-filter" +version = "0.1.0" +dependencies = [ + "anyhow", + "aptos-protos", + "derive_builder", + "lz4", + "memchr", + "prost 0.12.3", + "serde", + "serde_json", + "serde_yaml", + "thiserror", +] + [[package]] name = "try-lock" version = "0.2.4" @@ -3498,6 +3607,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.7.1" @@ -3918,15 +4033,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "yaml-rust" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" -dependencies = [ - "linked-hash-map", -] - [[package]] name = "zerocopy" version = "0.7.32" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index fc4270a3f..c038f2271 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" -members = ["indexer-metrics", "moving-average", "processor", "server-framework"] +members = ["indexer-metrics", "moving-average", "processor", "server-framework", "transaction-filter"] [workspace.package] authors = ["Aptos Labs "] @@ -58,6 +58,7 @@ gcloud-sdk = { version = "0.20.4", features = [ "google-cloud-bigquery-storage-v1", ] } cloud-storage = { version = "0.11.1", features = ["global-client"] } +derive_builder = "0.20.0" google-cloud-googleapis = "0.10.0" google-cloud-pubsub = "0.18.0" hex = "0.4.3" @@ -69,6 +70,8 @@ jemallocator = { version = "0.5.0", features = [ ] } kanal = { version = "0.1.0-pre8", features = ["async"] } once_cell = "1.10.0" +# SIMD for string search +memchr = "2.7.2" num_cpus = "1.16.0" pbjson = "0.5.1" prometheus = { version = "0.13.0", default-features = false } @@ -83,11 +86,12 @@ reqwest = { version = "0.11.20", features = [ ] } serde = { version = "1.0.193", features = ["derive", "rc"] } serde_json = { version = "1.0.81", features = ["preserve_order"] } -serde_yaml = "0.8.24" +serde_yaml = "0.9.34" sha2 = "0.9.3" sha3 = "0.9.1" strum = { version = "0.24.1", features = ["derive"] } tempfile = "3.3.0" +thiserror = "1.0.61" toml = "0.7.4" tracing-subscriber = { version = "0.3.17", features = ["json", "env-filter"] } tiny-keccak = { version = "2.0.2", features = ["keccak", "sha3"] } diff --git a/rust/processor/src/grpc_stream.rs b/rust/processor/src/grpc_stream.rs index e1674dcd0..12a8c0bf4 100644 --- a/rust/processor/src/grpc_stream.rs +++ b/rust/processor/src/grpc_stream.rs @@ -150,7 +150,6 @@ pub async fn get_stream( let mut rpc_client = match connect_res { Ok(client) => client - .accept_compressed(tonic::codec::CompressionEncoding::Gzip) .accept_compressed(tonic::codec::CompressionEncoding::Zstd) .send_compressed(tonic::codec::CompressionEncoding::Zstd) .max_decoding_message_size(MAX_RESPONSE_SIZE) diff --git a/rust/processor/src/utils/util.rs b/rust/processor/src/utils/util.rs index 4b19fa675..3d9b2728c 100644 --- a/rust/processor/src/utils/util.rs +++ b/rust/processor/src/utils/util.rs @@ -66,8 +66,11 @@ pub struct MultisigPayloadClean { } /// Standardizes all addresses and table handles to be length 66 (0x-64 length hash) +#[inline] pub fn standardize_address(handle: &str) -> String { - if let Some(handle) = handle.strip_prefix("0x") { + if handle.len() == 66 { + handle.to_string() + } else if let Some(handle) = handle.strip_prefix("0x") { format!("0x{:0>64}", handle) } else { format!("0x{:0>64}", handle) diff --git a/rust/transaction-filter/Cargo.toml b/rust/transaction-filter/Cargo.toml new file mode 100644 index 000000000..52b51ba50 --- /dev/null +++ b/rust/transaction-filter/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "transaction-filter" +version = "0.1.0" + +# Workspace inherited keys +authors = { workspace = true } +edition = { workspace = true } +homepage = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +repository = { workspace = true } +rust-version = { workspace = true } +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = { workspace = true } +aptos-protos = { workspace = true } + +derive_builder = { workspace = true } + +# SIMD for string search. TODO: benchmark this on various real inputs to see if it's worth it +memchr = { workspace = true } + +prost = { workspace = true } + +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } + +thiserror = { workspace = true } + +[dev-dependencies] +# we only decompress the fixture protos in test +lz4 = "1.24.0" + diff --git a/rust/transaction-filter/README.md b/rust/transaction-filter/README.md new file mode 100644 index 000000000..25f3d9277 --- /dev/null +++ b/rust/transaction-filter/README.md @@ -0,0 +1,149 @@ +# Transaction Filter + +## Overview + +The goal of **transaction filtering** is to be able to save resources downstream of wherever filtering is used. +For this to be true, the filtering itself must be **fast and use minimal resources**, and so we do a few things: + +1. We avoid clones, copies, etc as much as possible +2. We do a single pass over the transaction data + +## Transaction Filtering + +There are four different parts of a transaction that are queryable: + +1. The "root" level. This includes: + - Transaction type + - Success +2. User Transactions. Each user transaction has: + - Sender + - Payload: we only support the entry function payload + - Entry function (address, module, name) + - Entry function ID string +3. Events. Each event has: + - Key + - Type + +### Usage & Examples + +There are two different patterns for building a filter- you can either use the `TransactionFilterBuilder` or +the `TransactionFilter` struct directly. + +The `TransactionFilterBuilder` is a more ergonomic way to build a filter, and is not significantly worse construction +performance, assuming this is being done infrequently. + +``` + use transaction_filter::filters::EventFilterBuilder; + + let ef = EventFilterBuilder::default() + .data("spins") + .struct_type( + MoveStructTagFilterBuilder::default() + .address("0x0077") + .module("roulette") + .name("spin") + .build() + .unwrap(), + ) + .build() + .unwrap(); +``` + +The `TransactionFilter` struct is also available, but requires direct construction of the structs. + +``` + use transaction_filter::filters::EventFilter; + + let ef = EventFilter { + data: Some("spins".into()), + struct_type: Some(MoveStructTagFilter { + address: Some("0x0077".into()), + module: Some("roulette".into()), + name: Some("spin".into()), + }), + }; +``` + +Once you have some filters built, you can combine them with the boolean operators `and`, `or`, and `not`. + +``` + let trf = TransactionRootFilterBuilder::default() + .success(true).build().unwrap(); + + let utrf = UserTransactionFilterBuilder::default() + .sender("0x0011".into()).build().unwrap(); + + let ef = EventFilterBuilder::default() + .struct_type( + MoveStructTagFilterBuilder::default() + .address("0x0077") + .module("roulette") + .name("spin") + .build() + .unwrap(), + ) + .build() + .unwrap(); + + // Combine filters using logical operators! + // (trf OR utrf) + let trf_or_utrf = BooleanTransactionFilter::from(trf).or(utrf); + // ((trf OR utrf) AND ef) + let query = trf_or_utrf.and(ef); + + let transactions: Vec = transaction_stream.next().await; + let filtered_transactions = query.filter_vec(transactions); +``` + +## API & Serialization + +`BooleanTransactionFilter` is the top level filter struct, and it uses `serde` for serialization and deserialization. + +This means we can use it across all of our projects, whether they be GRPC services, REST services, or CLI tools. + +The above example can be serialized to JSON like so: + +```json +{ + "and": [ + { + "or": [ + { + "type": "TransactionRootFilter", + "success": true + }, + { + "type": "UserTransactionFilter", + "sender": "0x0011" + } + ] + }, + { + "type": "EventFilter", + "struct_type": { + "address": "0x0077", + "module": "roulette", + "name": "spin" + } + } + ] +} +``` + +Or, if you prefer, as yaml: + +```yaml +--- +and: + - or: + - type: TransactionRootFilter + success: true + - type: UserTransactionFilter + sender: '0x0011' + - type: EventFilter + struct_type: + address: '0x0077' + module: roulette + name: spin +``` + diff --git a/rust/transaction-filter/fixtures/compressed_files_lz4_00008bc1d5adcf862d3967c1410001fb_705101000.pb.lz4 b/rust/transaction-filter/fixtures/compressed_files_lz4_00008bc1d5adcf862d3967c1410001fb_705101000.pb.lz4 new file mode 100644 index 000000000..cee326c26 Binary files /dev/null and b/rust/transaction-filter/fixtures/compressed_files_lz4_00008bc1d5adcf862d3967c1410001fb_705101000.pb.lz4 differ diff --git a/rust/transaction-filter/fixtures/compressed_files_lz4_0013c194ec4fdbfb8db7306170aac083_445907000.pb.lz4 b/rust/transaction-filter/fixtures/compressed_files_lz4_0013c194ec4fdbfb8db7306170aac083_445907000.pb.lz4 new file mode 100644 index 000000000..ab76ef837 Binary files /dev/null and b/rust/transaction-filter/fixtures/compressed_files_lz4_0013c194ec4fdbfb8db7306170aac083_445907000.pb.lz4 differ diff --git a/rust/transaction-filter/fixtures/compressed_files_lz4_f3d880d9700c70d71fefe71aa9218aa9_301616000.pb.lz4 b/rust/transaction-filter/fixtures/compressed_files_lz4_f3d880d9700c70d71fefe71aa9218aa9_301616000.pb.lz4 new file mode 100644 index 000000000..512e84e77 Binary files /dev/null and b/rust/transaction-filter/fixtures/compressed_files_lz4_f3d880d9700c70d71fefe71aa9218aa9_301616000.pb.lz4 differ diff --git a/rust/transaction-filter/src/boolean_transaction_filter.rs b/rust/transaction-filter/src/boolean_transaction_filter.rs new file mode 100644 index 000000000..c7c72ca69 --- /dev/null +++ b/rust/transaction-filter/src/boolean_transaction_filter.rs @@ -0,0 +1,344 @@ +use crate::{ + errors::FilterError, + filters::{EventFilter, TransactionRootFilter, UserTransactionFilter}, + traits::Filterable, +}; +use aptos_protos::transaction::v1::{transaction::TxnData, Transaction}; +use serde::{Deserialize, Serialize}; +use std::fmt::Debug; + +/// BooleanTransactionFilter is the top level filter + +#[derive(Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum BooleanTransactionFilter { + And(LogicalAnd), + Or(LogicalOr), + Not(LogicalNot), + Filter(APIFilter), +} + +impl From for BooleanTransactionFilter { + fn from(filter: APIFilter) -> Self { + BooleanTransactionFilter::Filter(filter) + } +} + +impl From for BooleanTransactionFilter { + fn from(filter: TransactionRootFilter) -> Self { + BooleanTransactionFilter::Filter(APIFilter::TransactionRootFilter(filter)) + } +} + +impl From for BooleanTransactionFilter { + fn from(filter: UserTransactionFilter) -> Self { + BooleanTransactionFilter::Filter(APIFilter::UserTransactionFilter(filter)) + } +} + +impl From for BooleanTransactionFilter { + fn from(filter: EventFilter) -> Self { + BooleanTransactionFilter::Filter(APIFilter::EventFilter(filter)) + } +} + +impl BooleanTransactionFilter { + pub fn and>(self, other: Other) -> Self { + BooleanTransactionFilter::And(LogicalAnd { + and: vec![self, other.into()], + }) + } + + pub fn or>(self, other: Other) -> Self { + BooleanTransactionFilter::Or(LogicalOr { + or: vec![self, other.into()], + }) + } + + #[allow(clippy::should_implement_trait)] + pub fn not(self) -> Self { + BooleanTransactionFilter::Not(LogicalNot { + not: Box::new(self), + }) + } + + pub fn new_or(or: Vec) -> Self { + BooleanTransactionFilter::Or(LogicalOr { or }) + } + + pub fn new_not(not: BooleanTransactionFilter) -> Self { + BooleanTransactionFilter::Not(LogicalNot { not: Box::new(not) }) + } + + pub fn new_filter(filter: APIFilter) -> Self { + BooleanTransactionFilter::Filter(filter) + } +} + +impl Filterable for BooleanTransactionFilter { + fn validate_state(&self) -> Result<(), FilterError> { + match self { + BooleanTransactionFilter::And(and) => and.is_valid(), + BooleanTransactionFilter::Or(or) => or.is_valid(), + BooleanTransactionFilter::Not(not) => not.is_valid(), + BooleanTransactionFilter::Filter(filter) => filter.is_valid(), + } + } + + fn is_allowed(&self, item: &Transaction) -> bool { + match self { + BooleanTransactionFilter::And(and) => and.is_allowed(item), + BooleanTransactionFilter::Or(or) => or.is_allowed(item), + BooleanTransactionFilter::Not(not) => not.is_allowed(item), + BooleanTransactionFilter::Filter(filter) => filter.is_allowed(item), + } + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct LogicalAnd { + and: Vec, +} + +impl Filterable for LogicalAnd { + fn validate_state(&self) -> Result<(), FilterError> { + for filter in &self.and { + filter.is_valid()?; + } + Ok(()) + } + + fn is_allowed(&self, item: &Transaction) -> bool { + self.and.iter().all(|filter| filter.is_allowed(item)) + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct LogicalOr { + or: Vec, +} + +impl Filterable for LogicalOr { + fn validate_state(&self) -> Result<(), FilterError> { + for filter in &self.or { + filter.is_valid()?; + } + Ok(()) + } + + fn is_allowed(&self, item: &Transaction) -> bool { + self.or.iter().any(|filter| filter.is_allowed(item)) + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct LogicalNot { + not: Box, +} + +impl Filterable for LogicalNot { + fn validate_state(&self) -> Result<(), FilterError> { + self.not.is_valid() + } + + fn is_allowed(&self, item: &Transaction) -> bool { + !self.not.is_allowed(item) + } +} + +/// These are filters we would expect to be exposed via API +#[derive(Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +#[serde(tag = "type")] +pub enum APIFilter { + TransactionRootFilter(TransactionRootFilter), + UserTransactionFilter(UserTransactionFilter), + EventFilter(EventFilter), +} + +impl From for APIFilter { + fn from(filter: TransactionRootFilter) -> Self { + APIFilter::TransactionRootFilter(filter) + } +} + +impl From for APIFilter { + fn from(filter: UserTransactionFilter) -> Self { + APIFilter::UserTransactionFilter(filter) + } +} + +impl From for APIFilter { + fn from(filter: EventFilter) -> Self { + APIFilter::EventFilter(filter) + } +} + +impl Filterable for APIFilter { + fn validate_state(&self) -> Result<(), FilterError> { + match self { + APIFilter::TransactionRootFilter(filter) => filter.is_valid(), + APIFilter::UserTransactionFilter(filter) => filter.is_valid(), + APIFilter::EventFilter(filter) => filter.is_valid(), + } + } + + fn is_allowed(&self, txn: &Transaction) -> bool { + match self { + APIFilter::TransactionRootFilter(filter) => filter.is_allowed(txn), + APIFilter::UserTransactionFilter(ut_filter) => ut_filter.is_allowed(txn), + APIFilter::EventFilter(events_filter) => { + if let Some(txn_data) = &txn.txn_data { + let events = match txn_data { + TxnData::BlockMetadata(bm) => &bm.events, + TxnData::Genesis(g) => &g.events, + TxnData::BlockEpilogue(_) => return false, + TxnData::StateCheckpoint(_) => return false, + TxnData::User(u) => &u.events, + TxnData::Validator(_) => return false, + }; + events_filter.is_allowed_vec(events) + } else { + false + } + }, + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::{ + filters::{ + event::EventFilterBuilder, move_module::MoveStructTagFilterBuilder, + user_transaction::EntryFunctionFilter, TransactionRootFilterBuilder, + UserTransactionFilterBuilder, UserTransactionPayloadFilterBuilder, + }, + test_lib::load_graffio_fixture, + }; + + #[test] + pub fn test_query_parsing() { + let trf = TransactionRootFilter { + success: Some(true), + txn_type: Some(aptos_protos::transaction::v1::transaction::TransactionType::User), + }; + + let utrf = UserTransactionFilterBuilder::default() + .sender("0x0011") + .payload( + UserTransactionPayloadFilterBuilder::default() + .function(EntryFunctionFilter { + address: Some("0x007".into()), + module: Some("roulette".into()), + function: None, + }) + .build() + .unwrap(), + ) + .build() + .unwrap(); + + let ef = EventFilterBuilder::default() + .struct_type( + MoveStructTagFilterBuilder::default() + .address("0x0077") + .module("roulette") + .name("spin") + .build() + .unwrap(), + ) + .build() + .unwrap(); + + // (trf OR utrf) + let trf_or_utrf = BooleanTransactionFilter::from(trf).or(utrf); + // ((trf OR utrf) AND ef) + let query = trf_or_utrf.and(ef); + + println!( + "JSON RESULT: \n {}", + serde_json::to_string_pretty(&query).unwrap() + ); + + let txns = load_graffio_fixture(); + + // Benchmark how long it takes to do this 100 times + let start = std::time::Instant::now(); + const LOOPS: i32 = 1000; + for _ in 0..LOOPS { + for txn in &txns.transactions { + query.is_allowed(txn); + } + } + let elapsed = start.elapsed(); + + let total_txn = LOOPS * txns.transactions.len() as i32; + println!( + "BENCH: Took {:?} for {} transactions ({:?} each)", + elapsed, + total_txn, + elapsed / total_txn as u32 + ); + + let ef_econia = EventFilterBuilder::default() + .struct_type( + MoveStructTagFilterBuilder::default() + .address("0x00ECONIA") + .build() + .unwrap(), + ) + .build() + .unwrap(); + let ef_aries = EventFilterBuilder::default() + .struct_type( + MoveStructTagFilterBuilder::default() + .address("0x00ARIES") + .build() + .unwrap(), + ) + .build() + .unwrap(); + + let query = BooleanTransactionFilter::from(ef_econia).or(ef_aries); + println!( + "JSON RESULT: \n {}", + serde_json::to_string_pretty(&query).unwrap() + ); + } + + #[test] + fn test_serialization() { + let trf = TransactionRootFilterBuilder::default() + .success(true) + .build() + .unwrap(); + + let utrf = UserTransactionFilterBuilder::default() + .sender("0x0011") + .build() + .unwrap(); + + let ef = EventFilterBuilder::default() + .struct_type( + MoveStructTagFilterBuilder::default() + .address("0x0077") + .module("roulette") + .name("spin") + .build() + .unwrap(), + ) + .build() + .unwrap(); + + // Combine filters using logical operators! + // (trf OR utrf) + let trf_or_utrf = BooleanTransactionFilter::from(trf).or(utrf); + // ((trf OR utrf) AND ef) + let query = trf_or_utrf.and(ef); + + let yaml = serde_yaml::to_string(&query).unwrap(); + println!("YAML: \n{}", yaml); + } +} diff --git a/rust/transaction-filter/src/errors.rs b/rust/transaction-filter/src/errors.rs new file mode 100644 index 000000000..a6289cf40 --- /dev/null +++ b/rust/transaction-filter/src/errors.rs @@ -0,0 +1,81 @@ +use serde::{Serialize, Serializer}; +use std::fmt::Display; +use thiserror::Error as ThisError; + +#[derive(Debug, Serialize)] +pub struct FilterStepTrace { + pub serialized_filter: String, + pub filter_type: String, +} + +impl Display for FilterStepTrace { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.filter_type, self.serialized_filter) + } +} +#[derive(Debug)] +pub struct SerializableError { + pub inner: Box, +} + +/// Custom error that allows for keeping track of the filter type/path that caused the error +#[derive(Debug, Serialize, ThisError)] +pub struct FilterError { + pub filter_path: Vec, + pub error: SerializableError, +} + +impl FilterError { + pub fn new(error: Box) -> Self { + Self { + filter_path: Vec::new(), + error: SerializableError::new(error), + } + } + + pub fn add_trace(&mut self, serialized_filter: String, filter_type: String) { + self.filter_path.push(FilterStepTrace { + serialized_filter, + filter_type, + }); + } +} + +impl From for FilterError { + fn from(error: anyhow::Error) -> Self { + Self::new(error.into()) + } +} + +impl Display for FilterError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let trace_path = self + .filter_path + .iter() + .map(|trace| format!("{}", trace)) + .collect::>() + .join("\n"); + write!( + f, + "Filter Error: {:?}\nTrace Path:\n{}", + self.error.inner, trace_path + ) + } +} + +impl SerializableError { + fn new(error: Box) -> Self { + SerializableError { inner: error } + } +} + +// Implement Serialize for the wrapper +impl Serialize for SerializableError { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + // Serialize the error as its string representation + serializer.serialize_str(&self.inner.to_string()) + } +} diff --git a/rust/transaction-filter/src/filters/event.rs b/rust/transaction-filter/src/filters/event.rs new file mode 100644 index 000000000..5fa650125 --- /dev/null +++ b/rust/transaction-filter/src/filters/event.rs @@ -0,0 +1,51 @@ +use crate::{errors::FilterError, filters::MoveStructTagFilter, traits::Filterable}; +use anyhow::Error; +use aptos_protos::transaction::v1::{move_type::Content, Event}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +#[derive(derive_builder::Builder)] +#[builder(setter(strip_option), default)] +pub struct EventFilter { + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(setter(into, strip_option), default)] + pub data: Option, + // Only for events that have a struct as their generic + #[serde(skip_serializing_if = "Option::is_none")] + pub struct_type: Option, +} + +impl Filterable for EventFilter { + #[inline] + fn validate_state(&self) -> Result<(), FilterError> { + if self.data.is_none() && self.struct_type.is_none() { + return Err(Error::msg("At least one of data or struct_type must be set").into()); + }; + + self.data.is_valid()?; + self.struct_type.is_valid()?; + Ok(()) + } + + #[inline] + fn is_allowed(&self, item: &Event) -> bool { + if let Some(struct_type_filter) = &self.struct_type { + if let Some(Content::Struct(struct_tag)) = + &item.r#type.as_ref().and_then(|t| t.content.as_ref()) + { + if !struct_type_filter.is_allowed(struct_tag) { + return false; + } + } else { + return false; + } + } + + if !self.data.is_allowed(&item.data) { + return false; + } + + true + } +} diff --git a/rust/transaction-filter/src/filters/mod.rs b/rust/transaction-filter/src/filters/mod.rs new file mode 100644 index 000000000..b81513dcc --- /dev/null +++ b/rust/transaction-filter/src/filters/mod.rs @@ -0,0 +1,15 @@ +pub mod event; +pub mod move_module; +pub mod transaction_root; +pub mod user_transaction; + +// Re-export for easier use +pub use event::EventFilter; +// Re-export the builders +pub use event::EventFilterBuilder; +pub use move_module::{MoveStructTagFilter, MoveStructTagFilterBuilder}; +pub use transaction_root::{TransactionRootFilter, TransactionRootFilterBuilder}; +pub use user_transaction::{ + UserTransactionFilter, UserTransactionFilterBuilder, UserTransactionPayloadFilter, + UserTransactionPayloadFilterBuilder, +}; diff --git a/rust/transaction-filter/src/filters/move_module.rs b/rust/transaction-filter/src/filters/move_module.rs new file mode 100644 index 000000000..5ad45a0fc --- /dev/null +++ b/rust/transaction-filter/src/filters/move_module.rs @@ -0,0 +1,34 @@ +use crate::{errors::FilterError, traits::Filterable}; +use anyhow::anyhow; +use aptos_protos::transaction::v1::MoveStructTag; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +#[derive(derive_builder::Builder)] +#[builder(setter(into, strip_option), default)] +pub struct MoveStructTagFilter { + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub module: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl Filterable for MoveStructTagFilter { + #[inline] + fn validate_state(&self) -> Result<(), FilterError> { + if self.address.is_none() && self.module.is_none() && self.name.is_none() { + return Err(anyhow!("At least one of address, module or name must be set").into()); + }; + Ok(()) + } + + #[inline] + fn is_allowed(&self, struct_tag: &MoveStructTag) -> bool { + self.address.is_allowed(&struct_tag.address) + && self.module.is_allowed(&struct_tag.module) + && self.name.is_allowed(&struct_tag.name) + } +} diff --git a/rust/transaction-filter/src/filters/transaction_root.rs b/rust/transaction-filter/src/filters/transaction_root.rs new file mode 100644 index 000000000..79a293925 --- /dev/null +++ b/rust/transaction-filter/src/filters/transaction_root.rs @@ -0,0 +1,45 @@ +use crate::{errors::FilterError, traits::Filterable}; +use anyhow::Error; +use aptos_protos::transaction::v1::{transaction::TransactionType, Transaction}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +#[derive(derive_builder::Builder)] +#[builder(setter(strip_option), default)] +pub struct TransactionRootFilter { + #[serde(skip_serializing_if = "Option::is_none")] + pub success: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub txn_type: Option, +} + +impl Filterable for TransactionRootFilter { + #[inline] + fn validate_state(&self) -> Result<(), FilterError> { + if self.success.is_none() && self.txn_type.is_none() { + return Err(Error::msg("At least one of success or txn_types must be set").into()); + }; + Ok(()) + } + + #[inline] + fn is_allowed(&self, item: &Transaction) -> bool { + if !self + .success + .is_allowed_opt(&item.info.as_ref().map(|i| i.success)) + { + return false; + } + + if let Some(txn_type) = &self.txn_type { + if txn_type + != &TransactionType::try_from(item.r#type).expect("Invalid transaction type") + { + return false; + } + } + + true + } +} diff --git a/rust/transaction-filter/src/filters/user_transaction.rs b/rust/transaction-filter/src/filters/user_transaction.rs new file mode 100644 index 000000000..2148c6814 --- /dev/null +++ b/rust/transaction-filter/src/filters/user_transaction.rs @@ -0,0 +1,160 @@ +use crate::{errors::FilterError, traits::Filterable}; +use anyhow::{anyhow, Error}; +use aptos_protos::transaction::v1::{ + multisig_transaction_payload, transaction::TxnData, transaction_payload, EntryFunctionId, + EntryFunctionPayload, Transaction, TransactionPayload, +}; +use serde::{Deserialize, Serialize}; + +/// We use this for UserTransactions. +/// We support UserPayload and MultisigPayload +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +#[derive(derive_builder::Builder)] +#[builder(setter(strip_option), default)] +pub struct UserTransactionFilter { + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(setter(into, strip_option), default)] + pub sender: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub payload: Option, +} + +impl Filterable for UserTransactionFilter { + #[inline] + fn validate_state(&self) -> Result<(), FilterError> { + if self.sender.is_none() && self.payload.is_none() { + return Err(Error::msg("At least one of sender or payload must be set").into()); + }; + self.payload.is_valid()?; + Ok(()) + } + + #[inline] + fn is_allowed(&self, txn: &Transaction) -> bool { + let user_request = if let Some(TxnData::User(u)) = txn.txn_data.as_ref() { + if let Some(user_request) = u.request.as_ref() { + user_request + } else { + return false; + } + } else { + return false; + }; + + if let Some(sender_filter) = &self.sender { + if &user_request.sender != sender_filter { + return false; + } + } + + if let Some(payload_filter) = &self.payload { + // Get the entry_function_payload from both UserPayload and MultisigPayload + let entry_function_payload = user_request + .payload + .as_ref() + .and_then(get_entry_function_payload_from_transaction_payload); + if let Some(payload) = entry_function_payload { + // Here we have an actual EntryFunctionPayload + if !payload_filter.is_allowed(payload) { + return false; + } + } + } + + true + } +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +#[derive(derive_builder::Builder)] +#[builder(setter(into, strip_option), default)] +pub struct EntryFunctionFilter { + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub module: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub function: Option, +} + +impl Filterable for EntryFunctionFilter { + #[inline] + fn validate_state(&self) -> Result<(), FilterError> { + if self.address.is_none() && self.module.is_none() && self.function.is_none() { + return Err(anyhow!("At least one of address, name or function must be set").into()); + }; + Ok(()) + } + + #[inline] + fn is_allowed(&self, module_id: &EntryFunctionId) -> bool { + if !self.module.is_allowed(&module_id.name) { + return false; + } + + if self.address.is_some() || self.function.is_some() { + if let Some(module) = &module_id.module.as_ref() { + if !(self.address.is_allowed(&module.address) + && self.function.is_allowed(&module.name)) + { + return false; + } + } else { + return false; + } + } + + true + } +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +#[derive(derive_builder::Builder)] +#[builder(setter(strip_option), default)] +pub struct UserTransactionPayloadFilter { + #[serde(skip_serializing_if = "Option::is_none")] + pub function: Option, +} + +impl Filterable for UserTransactionPayloadFilter { + #[inline] + fn validate_state(&self) -> Result<(), FilterError> { + if self.function.is_none() { + return Err(Error::msg("At least function must be set").into()); + }; + self.function.is_valid()?; + Ok(()) + } + + #[inline] + fn is_allowed(&self, payload: &EntryFunctionPayload) -> bool { + self.function.is_allowed_opt(&payload.function) + } +} + +/// Get the entry_function_payload from both UserPayload and MultisigPayload +fn get_entry_function_payload_from_transaction_payload( + payload: &TransactionPayload, +) -> Option<&EntryFunctionPayload> { + let z = if let Some(payload) = &payload.payload { + match payload { + transaction_payload::Payload::EntryFunctionPayload(ef_payload) => Some(ef_payload), + transaction_payload::Payload::MultisigPayload(ms_payload) => ms_payload + .transaction_payload + .as_ref() + .and_then(|tp| tp.payload.as_ref()) + .map(|payload| match payload { + multisig_transaction_payload::Payload::EntryFunctionPayload(ef_payload) => { + ef_payload + }, + }), + _ => None, + } + } else { + None + }; + z +} diff --git a/rust/transaction-filter/src/lib.rs b/rust/transaction-filter/src/lib.rs new file mode 100644 index 000000000..6eb5ab927 --- /dev/null +++ b/rust/transaction-filter/src/lib.rs @@ -0,0 +1,18 @@ +pub mod boolean_transaction_filter; +pub mod errors; +pub mod filters; +pub mod traits; + +// re-export for convenience +pub use boolean_transaction_filter::BooleanTransactionFilter; +pub use traits::Filterable; + +#[cfg(test)] +pub mod test_lib; + +#[cfg(test)] +mod tests { + + #[test] + fn it_works() {} +} diff --git a/rust/transaction-filter/src/test_lib/mod.rs b/rust/transaction-filter/src/test_lib/mod.rs new file mode 100644 index 000000000..d7672730e --- /dev/null +++ b/rust/transaction-filter/src/test_lib/mod.rs @@ -0,0 +1,36 @@ +use aptos_protos::indexer::v1::TransactionsInStorage; +use prost::Message; +use std::io::Read; + +pub fn decompress_fixture(bytes: &[u8]) -> TransactionsInStorage { + let mut decompressor = lz4::Decoder::new(bytes).expect("Lz4 decompression failed."); + let mut decompressed = Vec::new(); + decompressor + .read_to_end(&mut decompressed) + .expect("Lz4 decompression failed."); + TransactionsInStorage::decode(decompressed.as_slice()).expect("Failed to parse transaction") +} + +#[allow(dead_code)] +pub fn load_taptos_fixture() -> TransactionsInStorage { + let data = include_bytes!( + "../../fixtures/compressed_files_lz4_00008bc1d5adcf862d3967c1410001fb_705101000.pb.lz4" + ); + decompress_fixture(data) +} + +#[allow(dead_code)] +pub fn load_random_april_3mb_fixture() -> TransactionsInStorage { + let data = include_bytes!( + "../../fixtures/compressed_files_lz4_0013c194ec4fdbfb8db7306170aac083_445907000.pb.lz4" + ); + decompress_fixture(data) +} + +#[allow(dead_code)] +pub fn load_graffio_fixture() -> TransactionsInStorage { + let data = include_bytes!( + "../../fixtures/compressed_files_lz4_f3d880d9700c70d71fefe71aa9218aa9_301616000.pb.lz4" + ); + decompress_fixture(data) +} diff --git a/rust/transaction-filter/src/traits.rs b/rust/transaction-filter/src/traits.rs new file mode 100644 index 000000000..b8e1baf08 --- /dev/null +++ b/rust/transaction-filter/src/traits.rs @@ -0,0 +1,199 @@ +use crate::errors::FilterError; +use serde::Serialize; +use std::fmt::Debug; + +/// Simple trait to allow for filtering of items of type T +pub trait Filterable +where + Self: Debug + Serialize, +{ + /// Whether this filter is correctly configured/initialized + /// Any call to `validate_state` is responsible for recursively checking the validity of any nested filters *by calling `is_valid`* + /// The actual public API is via `is_valid` which will call `validate_state` and return an error if it fails, but annotated with the filter type/path + fn validate_state(&self) -> Result<(), FilterError>; + + /** + * This is a convenience method to allow for the error to be annotated with the filter type/path at each level + * This is the public API for checking the validity of a filter! + * Example output looks like: + * ```text + * FilterError: This is a test error!. + * Trace Path: + * transaction_filter::traits::test::InnerStruct: {"a":"test"} + * core::option::Option: {"a":"test"} + * transaction_filter::traits::test::OuterStruct: {"inner":{"a":"test"}} + * ``` + **/ + #[inline] + fn is_valid(&self) -> Result<(), FilterError> { + // T + self.validate_state().map_err(|mut e| { + e.add_trace( + serde_json::to_string(self).unwrap(), + std::any::type_name::().to_string(), + ); + e + }) + } + + /// Whether the item is allowed by this filter + /// This is the core method that should be implemented by any filter + /// This is the method that should be called by any parent filter to determine if an item is allowed + /// *If a filter doesn't explicitly prevent an item, then it should be allowed* + /// This forces the logic of `if !child_filter.is_allowed(item) { return false; }` for any parent filter + fn is_allowed(&self, item: &T) -> bool; + + #[inline] + fn is_allowed_vec(&self, items: &[T]) -> bool { + items.iter().all(|item| self.is_allowed(item)) + } + + #[inline] + fn is_allowed_opt(&self, item: &Option) -> bool { + match item { + Some(item) => self.is_allowed(item), + None => false, + } + } + + #[inline] + fn is_allowed_opt_vec(&self, items: &Option<&Vec>) -> bool { + match items { + Some(items) => self.is_allowed_vec(items), + None => false, + } + } + + #[inline] + fn filter_vec(&self, items: Vec) -> Vec { + items + .into_iter() + .filter(|item| self.is_allowed(item)) + .collect() + } +} + +/// This allows for Option to always return true: i.e if the filter is None, then all items are allowed. +impl Filterable for Option +where + F: Filterable, +{ + #[inline] + fn validate_state(&self) -> Result<(), FilterError> { + match self { + Some(filter) => filter.is_valid(), + None => Ok(()), + } + } + + #[inline] + fn is_allowed(&self, item: &T) -> bool { + match self { + Some(filter) => filter.is_allowed(item), + None => true, + } + } + + #[inline] + fn is_allowed_opt(&self, item: &Option) -> bool { + match self { + Some(filter) => filter.is_allowed_opt(item), + None => true, + } + } +} + +impl Filterable for Option { + #[inline] + fn validate_state(&self) -> Result<(), FilterError> { + Ok(()) + } + + #[inline] + fn is_allowed(&self, item: &String) -> bool { + match self { + Some(filter) => filter == item, + None => true, + } + } +} + +impl Filterable for Option { + #[inline] + fn validate_state(&self) -> Result<(), FilterError> { + Ok(()) + } + + #[inline] + fn is_allowed(&self, item: &i32) -> bool { + match self { + Some(filter) => filter == item, + None => true, + } + } +} + +impl Filterable for Option { + #[inline] + fn validate_state(&self) -> Result<(), FilterError> { + Ok(()) + } + + #[inline] + fn is_allowed(&self, item: &bool) -> bool { + match self { + Some(filter) => filter == item, + None => true, + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use anyhow::anyhow; + + #[derive(Debug, Serialize, PartialEq)] + pub struct InnerStruct { + pub a: Option, + } + + impl Filterable for InnerStruct { + fn validate_state(&self) -> Result<(), FilterError> { + Err(anyhow!("This is a test error!").into()) + } + + fn is_allowed(&self, _item: &InnerStruct) -> bool { + true + } + } + + #[derive(Debug, PartialEq, Serialize)] + pub struct OuterStruct { + pub inner: Option, + } + + impl Filterable for OuterStruct { + fn validate_state(&self) -> Result<(), FilterError> { + self.inner.is_valid()?; + Ok(()) + } + + fn is_allowed(&self, item: &InnerStruct) -> bool { + self.inner.is_allowed(item) + } + } + + #[test] + fn test_error_prop() { + let inner = InnerStruct { + a: Some("test".to_string()), + }; + let outer = OuterStruct { inner: Some(inner) }; + + let res = outer.is_valid(); + assert!(res.is_err()); + let error = res.unwrap_err(); + assert_eq!(error.to_string(), "Filter Error: This is a test error!\nTrace Path:\ntransaction_filter::traits::test::InnerStruct: {\"a\":\"test\"}\ncore::option::Option: {\"a\":\"test\"}\ntransaction_filter::traits::test::OuterStruct: {\"inner\":{\"a\":\"test\"}}"); + } +}