diff --git a/.cursor/rules/rust_standards.mdc b/.cursor/rules/rust_standards.mdc new file mode 100644 index 000000000..a7c942990 --- /dev/null +++ b/.cursor/rules/rust_standards.mdc @@ -0,0 +1,51 @@ +--- +description: General Rust coding standards for the Relayer project +globs: + - "**/*.rs" +alwaysApply: true +--- + +# Code Style and Formatting +- Follow official Rust style guidelines using rustfmt (edition = 2021, max_width = 100). +- Follow Rust naming conventions: snake_case for functions/variables, PascalCase for types, SCREAMING_SNAKE_CASE for constants and statics. +- Order imports alphabetically and group by: std, external crates, local crates. +- Include relevant doc comments (///) on public functions, structs, and modules. Use comments for "why", not "what". Avoid redundant doc comments. +- Document lifetime parameters when they're not obvious. + +# Safety and Error Handling +- Avoid unsafe code unless absolutely necessary; justify its use in comments. +- Write idiomatic Rust: Prefer Result over panic, use ? operator for error propagation. +- Avoid unwrap; handle errors explicitly with Result and custom Error types for all async operations. +- Prefer header imports over function-level imports of dependencies. + +# Performance and Memory +- Prefer borrowing over cloning when possible. +- Use &str instead of &String for function parameters. +- Use &[T] instead of &Vec for function parameters. +- Avoid unnecessary .clone() calls. +- Use Vec::with_capacity() when size is known in advance. +- Use explicit lifetimes only when necessary; infer where possible. + +# Async and Concurrency +- Always use Tokio runtime for async code. Await futures eagerly; avoid blocking calls in async contexts. +- Streams: Use futures::StreamExt for processing streams efficiently. + +# Serialization and Data +- Always use serde for JSON serialization/deserialization with #[derive(Serialize, Deserialize)]. +- Use type aliases for complex types to improve readability. +- Implement common traits (Debug, Clone, PartialEq) where appropriate, using derive macros when possible. +- Implement Display for user-facing types. + +# Testing +- Prefer defining traits when implementing services to make mocking and testing easier. +- For tests, prefer existing utils for creating mocks instead of duplicating code. +- Use assert_eq! and assert! macros appropriately. +- Keep tests minimal, deterministic, and fast. + +# Logging and Observability +- Use tracing for structured logging, e.g., tracing::info! for request and error logs. + +# Code Organization and Clarity +- When optimizing, prefer clarity first, then performance. +- Modularize code to keep functions concise and focused. +- Use derive macros to reduce boilerplate where possible. diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 000000000..46f1d7924 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,3 @@ +target/ +Cargo.lock +.git/ diff --git a/Cargo.lock b/Cargo.lock index e18cd0cac..829c97918 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -265,15 +265,15 @@ dependencies = [ [[package]] name = "agave-feature-set" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52a2c365c0245cbb8959de725fc2b44c754b673fdf34c9a7f9d4a25c35a7bf1" +checksum = "716de4309d921e2d0908d6bc601e82b2b15f3e77423aebd7f92f54c1ce93dffe" dependencies = [ "ahash", - "solana-epoch-schedule", - "solana-hash", - "solana-pubkey", - "solana-sha256-hasher", + "solana-epoch-schedule 3.0.0", + "solana-hash 3.0.0", + "solana-pubkey 3.0.0", + "solana-sha256-hasher 3.0.0", "solana-svm-feature-set", ] @@ -1201,34 +1201,6 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" -[[package]] -name = "ark-bn254" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a22f4561524cd949590d78d7d4c5df8f592430d221f7f3c9497bbafd8972120f" -dependencies = [ - "ark-ec", - "ark-ff 0.4.2", - "ark-std 0.4.0", -] - -[[package]] -name = "ark-ec" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" -dependencies = [ - "ark-ff 0.4.2", - "ark-poly", - "ark-serialize 0.4.2", - "ark-std 0.4.0", - "derivative", - "hashbrown 0.13.2", - "itertools 0.10.5", - "num-traits", - "zeroize", -] - [[package]] name = "ark-ff" version = "0.3.0" @@ -1355,19 +1327,6 @@ dependencies = [ "syn 2.0.108", ] -[[package]] -name = "ark-poly" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" -dependencies = [ - "ark-ff 0.4.2", - "ark-serialize 0.4.2", - "ark-std 0.4.0", - "derivative", - "hashbrown 0.13.2", -] - [[package]] name = "ark-serialize" version = "0.3.0" @@ -1384,7 +1343,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" dependencies = [ - "ark-serialize-derive", "ark-std 0.4.0", "digest 0.10.7", "num-bigint 0.4.6", @@ -1402,17 +1360,6 @@ dependencies = [ "num-bigint 0.4.6", ] -[[package]] -name = "ark-serialize-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "ark-std" version = "0.3.0" @@ -1458,6 +1405,12 @@ dependencies = [ "serde", ] +[[package]] +name = "ascii" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e" + [[package]] name = "asn1-rs" version = "0.5.2" @@ -1592,17 +1545,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi 0.1.19", - "libc", - "winapi", -] - [[package]] name = "auto_impl" version = "1.3.0" @@ -1811,7 +1753,7 @@ dependencies = [ "bytes", "form_urlencoded", "hex", - "hmac 0.12.1", + "hmac", "http 0.2.12", "http 1.3.1", "percent-encoding", @@ -2695,6 +2637,19 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "combine" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3da6baa321ec19e1cc41d31bf599f00c783d0517095cdaf0332e3fe8d20680" +dependencies = [ + "ascii", + "byteorder", + "either", + "memchr", + "unreachable", +] + [[package]] name = "combine" version = "4.6.7" @@ -2738,15 +2693,15 @@ dependencies = [ [[package]] name = "console" -version = "0.15.11" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "b430743a6eb14e9764d4260d4c0d8123087d504eeb9c48f2b2a5e810dd369df4" dependencies = [ "encode_unicode", "libc", "once_cell", "unicode-width", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2968,16 +2923,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-mac" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab" -dependencies = [ - "generic-array", - "subtle", -] - [[package]] name = "ctr" version = "0.9.2" @@ -2987,19 +2932,6 @@ dependencies = [ "cipher", ] -[[package]] -name = "curve25519-dalek" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61" -dependencies = [ - "byteorder", - "digest 0.9.0", - "rand_core 0.5.1", - "subtle", - "zeroize", -] - [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -3386,19 +3318,10 @@ dependencies = [ "elliptic-curve", "rfc6979", "serdect", - "signature 2.2.0", + "signature", "spki", ] -[[package]] -name = "ed25519" -version = "1.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7" -dependencies = [ - "signature 1.6.4", -] - [[package]] name = "ed25519" version = "2.2.3" @@ -3406,21 +3329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ "pkcs8", - "signature 2.2.0", -] - -[[package]] -name = "ed25519-dalek" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c762bae6dcaf24c4c84667b8579785430908723d5c889f469d76a41d59cc7a9d" -dependencies = [ - "curve25519-dalek 3.2.0", - "ed25519 1.5.3", - "rand 0.7.3", - "serde", - "sha2 0.9.9", - "zeroize", + "signature", ] [[package]] @@ -3429,8 +3338,9 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek 4.1.3", - "ed25519 2.2.3", + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", "serde", "sha2 0.10.9", "subtle", @@ -3439,13 +3349,13 @@ dependencies = [ [[package]] name = "ed25519-dalek-bip32" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d2be62a4061b872c8c0873ee4fc6f101ce7b889d039f019c5fa2af471a59908" +checksum = "6b49a684b133c4980d7ee783936af771516011c8cd15f429dbda77245e282f03" dependencies = [ "derivation-path", - "ed25519-dalek 1.0.1", - "hmac 0.12.1", + "ed25519-dalek", + "hmac", "sha2 0.10.9", ] @@ -3526,19 +3436,6 @@ dependencies = [ "syn 2.0.108", ] -[[package]] -name = "env_logger" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7" -dependencies = [ - "atty", - "humantime", - "log", - "regex", - "termcolor", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -3571,7 +3468,7 @@ dependencies = [ "ctr", "digest 0.10.7", "hex", - "hmac 0.12.1", + "hmac", "pbkdf2", "rand 0.8.5", "scrypt", @@ -3950,10 +3847,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi 0.9.0+wasi-snapshot-preview1", - "wasm-bindgen", ] [[package]] @@ -4168,6 +4063,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -4216,15 +4120,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - [[package]] name = "hermit-abi" version = "0.5.2" @@ -4255,16 +4150,6 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12cb882ccb290b8646e554b157ab0b71e64e8d5bef775cd66b6531e52d302669" -[[package]] -name = "hmac" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "126888268dcc288495a26bf004b38c5fdbb31682f992c84ceb046a1f0fe38840" -dependencies = [ - "crypto-mac", - "digest 0.9.0", -] - [[package]] name = "hmac" version = "0.12.1" @@ -4274,17 +4159,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "hmac-drbg" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17ea0a1394df5b6574da6e0c1ade9e78868c9fb0a4e5ef4428e32da4676b85b1" -dependencies = [ - "digest 0.9.0", - "generic-array", - "hmac 0.8.1", -] - [[package]] name = "http" version = "0.2.12" @@ -4353,12 +4227,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "humantime" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" - [[package]] name = "hyper" version = "0.14.32" @@ -4671,14 +4539,14 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.17.11" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +checksum = "70a646d946d06bedbbc4cac4c218acf4bbf2d87757a784857025f4d447e4e1cd" dependencies = [ "console", - "number_prefix", "portable-atomic", "unicode-width", + "unit-prefix", "web-time", ] @@ -4778,7 +4646,7 @@ checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" dependencies = [ "cesu8", "cfg-if", - "combine", + "combine 4.6.7", "jni-sys", "log", "thiserror 1.0.69", @@ -4929,7 +4797,7 @@ dependencies = [ "once_cell", "serdect", "sha2 0.10.9", - "signature 2.2.0", + "signature", ] [[package]] @@ -5023,14 +4891,12 @@ dependencies = [ "arrayref", "base64 0.12.3", "digest 0.9.0", - "hmac-drbg", "libsecp256k1-core", "libsecp256k1-gen-ecmult", "libsecp256k1-gen-genmult", "rand 0.7.3", "serde", "sha2 0.9.9", - "typenum", ] [[package]] @@ -5159,15 +5025,6 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" -[[package]] -name = "memmap2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327" -dependencies = [ - "libc", -] - [[package]] name = "memoffset" version = "0.9.1" @@ -5282,7 +5139,7 @@ dependencies = [ "borsh 0.10.4", "num-derive 0.3.3", "num-traits", - "solana-program", + "solana-program 2.3.0", "thiserror 1.0.69", ] @@ -5492,7 +5349,7 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "hermit-abi 0.5.2", + "hermit-abi", "libc", ] @@ -5518,12 +5375,6 @@ dependencies = [ "syn 2.0.108", ] -[[package]] -name = "number_prefix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" - [[package]] name = "nybbles" version = "0.4.6" @@ -5685,12 +5536,12 @@ dependencies = [ "color-eyre", "dashmap 6.1.0", "dotenvy", - "ed25519-dalek 2.2.0", + "ed25519-dalek", "eyre", "futures", "google-cloud-auth", "hex", - "hmac 0.12.1", + "hmac", "http 1.3.1", "itertools 0.14.0", "json-patch", @@ -5722,12 +5573,14 @@ dependencies = [ "sha3", "simple_asn1", "solana-client", + "solana-commitment-config", + "solana-program 2.3.0", "solana-sdk", - "solana-system-interface", + "solana-system-interface 2.0.0", "soroban-rs", - "spl-associated-token-account", - "spl-token 8.0.0", - "spl-token-2022 8.0.1", + "spl-associated-token-account-interface", + "spl-token-2022-interface", + "spl-token-interface", "stellar-strkey 0.0.13", "strum", "strum_macros", @@ -6536,7 +6389,7 @@ dependencies = [ "backon", "bytes", "cfg-if", - "combine", + "combine 4.6.7", "futures-channel", "futures-util", "itoa", @@ -6695,7 +6548,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac 0.12.1", + "hmac", "subtle", ] @@ -7106,7 +6959,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f9e24d2b632954ded8ab2ef9fea0a0c769ea56ea98bddbafbad22caeeadf45d" dependencies = [ - "hmac 0.12.1", + "hmac", "pbkdf2", "salsa20", "sha2 0.10.9", @@ -7551,16 +7404,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - [[package]] name = "signal-hook-registry" version = "1.4.6" @@ -7570,12 +7413,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "1.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" - [[package]] name = "signature" version = "2.2.0" @@ -7662,32 +7499,45 @@ name = "solana-account" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f949fe4edaeaea78c844023bfc1c898e0b1f5a100f8a8d2d0f85d0a7b090258" +dependencies = [ + "solana-account-info 2.3.0", + "solana-clock 2.2.2", + "solana-instruction 2.3.0", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", +] + +[[package]] +name = "solana-account" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e5a5c395c41a30f0e36fa487b8cda3280f0d9e4c7b461c0881fa23564f4c28" dependencies = [ "bincode", "serde", "serde_bytes", "serde_derive", - "solana-account-info", - "solana-clock", - "solana-instruction", - "solana-pubkey", - "solana-sdk-ids", - "solana-sysvar", + "solana-account-info 3.0.0", + "solana-clock 3.0.0", + "solana-instruction-error", + "solana-pubkey 3.0.0", + "solana-sdk-ids 3.0.0", + "solana-sysvar 3.0.0", ] [[package]] name = "solana-account-decoder-client-types" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5519e8343325b707f17fbed54fcefb325131b692506d0af9e08a539d15e4f8cf" +checksum = "e2ad080c23d4a6ab04f27092172c7182cce3b395edde2c2a833cf7bc7b6a9070" dependencies = [ "base64 0.22.1", "bs58", "serde", "serde_derive", "serde_json", - "solana-account", - "solana-pubkey", + "solana-account 3.1.0", + "solana-pubkey 3.0.0", "zstd", ] @@ -7699,9 +7549,44 @@ checksum = "c8f5152a288ef1912300fc6efa6c2d1f9bb55d9398eb6c72326360b8063987da" dependencies = [ "bincode", "serde", - "solana-program-error", - "solana-program-memory", - "solana-pubkey", + "solana-program-error 2.2.2", + "solana-program-memory 2.3.1", + "solana-pubkey 2.4.0", +] + +[[package]] +name = "solana-account-info" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f4691b69b172c687d218dd2f1f23fc7ea5e9aa79df9ac26dab3d8dd829ce48" +dependencies = [ + "bincode", + "serde", + "solana-program-error 3.0.0", + "solana-program-memory 3.0.0", + "solana-pubkey 3.0.0", +] + +[[package]] +name = "solana-address" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7a457086457ea9db9a5199d719dc8734dc2d0342fad0d8f77633c31eb62f19" +dependencies = [ + "borsh 1.5.7", + "bytemuck", + "bytemuck_derive", + "curve25519-dalek", + "five8", + "five8_const", + "rand 0.8.5", + "serde", + "serde_derive", + "solana-atomic-u64 3.0.0", + "solana-define-syscall 3.0.0", + "solana-program-error 3.0.0", + "solana-sanitize 3.0.1", + "solana-sha256-hasher 3.0.0", ] [[package]] @@ -7714,11 +7599,23 @@ dependencies = [ "bytemuck", "serde", "serde_derive", - "solana-clock", - "solana-instruction", - "solana-pubkey", - "solana-sdk-ids", - "solana-slot-hashes", + "solana-clock 2.2.2", + "solana-instruction 2.3.0", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", + "solana-slot-hashes 2.2.1", +] + +[[package]] +name = "solana-address-lookup-table-interface" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2f56cac5e70517a2f27d05e5100b20de7182473ffd0035b23ea273307905987" +dependencies = [ + "solana-clock 3.0.0", + "solana-pubkey 3.0.0", + "solana-sdk-ids 3.0.0", + "solana-slot-hashes 3.0.0", ] [[package]] @@ -7730,6 +7627,15 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "solana-atomic-u64" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a933ff1e50aff72d02173cfcd7511bd8540b027ee720b75f353f594f834216d0" +dependencies = [ + "parking_lot", +] + [[package]] name = "solana-big-mod-exp" version = "2.2.1" @@ -7738,7 +7644,18 @@ checksum = "75db7f2bbac3e62cfd139065d15bcda9e2428883ba61fc8d27ccb251081e7567" dependencies = [ "num-bigint 0.4.6", "num-traits", - "solana-define-syscall", + "solana-define-syscall 2.3.0", +] + +[[package]] +name = "solana-big-mod-exp" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30c80fb6d791b3925d5ec4bf23a7c169ef5090c013059ec3ed7d0b2c04efa085" +dependencies = [ + "num-bigint 0.4.6", + "num-traits", + "solana-define-syscall 3.0.0", ] [[package]] @@ -7749,7 +7666,7 @@ checksum = "19a3787b8cf9c9fe3dd360800e8b70982b9e5a8af9e11c354b6665dd4a003adc" dependencies = [ "bincode", "serde", - "solana-instruction", + "solana-instruction 2.3.0", ] [[package]] @@ -7759,24 +7676,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a0801e25a1b31a14494fc80882a036be0ffd290efc4c2d640bfcca120a4672" dependencies = [ "blake3", - "solana-define-syscall", - "solana-hash", - "solana-sanitize", + "solana-define-syscall 2.3.0", + "solana-hash 2.3.0", + "solana-sanitize 2.2.1", ] [[package]] -name = "solana-bn254" -version = "2.2.2" +name = "solana-blake3-hasher" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4420f125118732833f36facf96a27e7b78314b2d642ba07fa9ffdacd8d79e243" +checksum = "ffa2e3bdac3339c6d0423275e45dafc5ac25f4d43bf344d026a3cc9a85e244a6" dependencies = [ - "ark-bn254", - "ark-ec", - "ark-ff 0.4.2", - "ark-serialize 0.4.2", - "bytemuck", - "solana-define-syscall", - "thiserror 2.0.17", + "blake3", + "solana-define-syscall 3.0.0", + "solana-hash 3.0.0", ] [[package]] @@ -7789,11 +7702,20 @@ dependencies = [ "borsh 1.5.7", ] +[[package]] +name = "solana-borsh" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc402b16657abbfa9991cd5cbfac5a11d809f7e7d28d3bb291baeb088b39060e" +dependencies = [ + "borsh 1.5.7", +] + [[package]] name = "solana-client" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc55d1f263e0be4127daf33378d313ea0977f9ffd3fba50fa544ca26722fc695" +checksum = "b17c4e94b0902099c0153268a854067e1fe4e459dc16f04f8f5f478e1677ad00" dependencies = [ "async-trait", "bincode", @@ -7805,17 +7727,17 @@ dependencies = [ "log", "quinn", "rayon", - "solana-account", + "solana-account 3.1.0", "solana-client-traits", "solana-commitment-config", "solana-connection-cache", "solana-epoch-info", - "solana-hash", - "solana-instruction", + "solana-hash 3.0.0", + "solana-instruction 3.0.0", "solana-keypair", "solana-measure", - "solana-message", - "solana-pubkey", + "solana-message 3.0.1", + "solana-pubkey 3.0.0", "solana-pubsub-client", "solana-quic-client", "solana-quic-definitions", @@ -7825,11 +7747,11 @@ dependencies = [ "solana-signature", "solana-signer", "solana-streamer", - "solana-thin-client", "solana-time-utils", "solana-tpu-client", "solana-transaction", - "solana-transaction-error", + "solana-transaction-error 3.0.0", + "solana-transaction-status-client-types", "solana-udp-client", "thiserror 2.0.17", "tokio", @@ -7837,23 +7759,23 @@ dependencies = [ [[package]] name = "solana-client-traits" -version = "2.2.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83f0071874e629f29e0eb3dab8a863e98502ac7aba55b7e0df1803fc5cac72a7" +checksum = "08618ed587e128105510c54ae3e456b9a06d674d8640db75afe66dad65cb4e02" dependencies = [ - "solana-account", + "solana-account 3.1.0", "solana-commitment-config", "solana-epoch-info", - "solana-hash", - "solana-instruction", + "solana-hash 3.0.0", + "solana-instruction 3.0.0", "solana-keypair", - "solana-message", - "solana-pubkey", + "solana-message 3.0.1", + "solana-pubkey 3.0.0", "solana-signature", "solana-signer", - "solana-system-interface", + "solana-system-interface 2.0.0", "solana-transaction", - "solana-transaction-error", + "solana-transaction-error 3.0.0", ] [[package]] @@ -7864,50 +7786,48 @@ checksum = "1bb482ab70fced82ad3d7d3d87be33d466a3498eb8aa856434ff3c0dfc2e2e31" dependencies = [ "serde", "serde_derive", - "solana-sdk-ids", - "solana-sdk-macro", - "solana-sysvar-id", + "solana-sdk-ids 2.2.1", + "solana-sdk-macro 2.2.1", + "solana-sysvar-id 2.2.1", ] [[package]] -name = "solana-cluster-type" -version = "2.2.1" +name = "solana-clock" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ace9fea2daa28354d107ea879cff107181d85cd4e0f78a2bedb10e1a428c97e" +checksum = "fb62e9381182459a4520b5fe7fb22d423cae736239a6427fc398a88743d0ed59" dependencies = [ "serde", "serde_derive", - "solana-hash", + "solana-sdk-ids 3.0.0", + "solana-sdk-macro 3.0.0", + "solana-sysvar-id 3.0.0", ] [[package]] -name = "solana-commitment-config" -version = "2.2.1" +name = "solana-cluster-type" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac49c4dde3edfa832de1697e9bcdb7c3b3f7cb7a1981b7c62526c8bb6700fb73" +checksum = "eb7692fa6bf10a1a86b450c4775526f56d7e0e2116a53313f2533b5694abea64" dependencies = [ - "serde", - "serde_derive", + "solana-hash 3.0.0", ] [[package]] -name = "solana-compute-budget-interface" -version = "2.2.2" +name = "solana-commitment-config" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8432d2c4c22d0499aa06d62e4f7e333f81777b3d7c96050ae9e5cb71a8c3aee4" +checksum = "5fa5933a62dadb7d3ed35e6329de5cebb0678acc8f9cfdf413269084eeccc63f" dependencies = [ - "borsh 1.5.7", "serde", "serde_derive", - "solana-instruction", - "solana-sdk-ids", ] [[package]] name = "solana-connection-cache" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c1cff5ebb26aefff52f1a8e476de70ec1683f8cc6e4a8c86b615842d91f436" +checksum = "096024d743cc53f256790333a02f85d8083b9d63738e77c9b79302109e8d2035" dependencies = [ "async-trait", "bincode", @@ -7921,7 +7841,7 @@ dependencies = [ "solana-measure", "solana-metrics", "solana-time-utils", - "solana-transaction-error", + "solana-transaction-error 3.0.0", "thiserror 2.0.17", "tokio", ] @@ -7932,12 +7852,26 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8dc71126edddc2ba014622fc32d0f5e2e78ec6c5a1e0eb511b85618c09e9ea11" dependencies = [ - "solana-account-info", - "solana-define-syscall", - "solana-instruction", - "solana-program-error", - "solana-pubkey", - "solana-stable-layout", + "solana-account-info 2.3.0", + "solana-define-syscall 2.3.0", + "solana-instruction 2.3.0", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "solana-stable-layout 2.2.1", +] + +[[package]] +name = "solana-cpi" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16238feb63d1cbdf915fb287f29ef7a7ebf81469bd6214f8b72a53866b593f8f" +dependencies = [ + "solana-account-info 3.0.0", + "solana-define-syscall 3.0.0", + "solana-instruction 3.0.0", + "solana-program-error 3.0.0", + "solana-pubkey 3.0.0", + "solana-stable-layout 3.0.0", ] [[package]] @@ -7948,8 +7882,8 @@ checksum = "eae4261b9a8613d10e77ac831a8fa60b6fa52b9b103df46d641deff9f9812a23" dependencies = [ "bytemuck", "bytemuck_derive", - "curve25519-dalek 4.1.3", - "solana-define-syscall", + "curve25519-dalek", + "solana-define-syscall 2.3.0", "subtle", "thiserror 2.0.17", ] @@ -7969,11 +7903,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ae3e2abcf541c8122eafe9a625d4d194b4023c20adde1e251f94e056bb1aee2" +[[package]] +name = "solana-define-syscall" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9697086a4e102d28a156b8d6b521730335d6951bd39a5e766512bbe09007cee" + [[package]] name = "solana-derivation-path" -version = "2.2.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "939756d798b25c5ec3cca10e06212bdca3b1443cb9bb740a38124f58b258737b" +checksum = "ff71743072690fdbdfcdc37700ae1cb77485aaad49019473a81aee099b1e0b8c" dependencies = [ "derivation-path", "qstring", @@ -7981,53 +7921,52 @@ dependencies = [ ] [[package]] -name = "solana-ed25519-program" -version = "2.2.3" +name = "solana-epoch-info" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1feafa1691ea3ae588f99056f4bdd1293212c7ece28243d7da257c443e84753" +checksum = "f8a6b69bd71386f61344f2bcf0f527f5fd6dd3b22add5880e2e1bf1dd1fa8059" dependencies = [ - "bytemuck", - "bytemuck_derive", - "ed25519-dalek 1.0.1", - "solana-feature-set", - "solana-instruction", - "solana-precompile-error", - "solana-sdk-ids", + "serde", + "serde_derive", ] [[package]] -name = "solana-epoch-info" +name = "solana-epoch-rewards" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ef6f0b449290b0b9f32973eefd95af35b01c5c0c34c569f936c34c5b20d77b" +checksum = "86b575d3dd323b9ea10bb6fe89bf6bf93e249b215ba8ed7f68f1a3633f384db7" dependencies = [ "serde", "serde_derive", + "solana-hash 2.3.0", + "solana-sdk-ids 2.2.1", + "solana-sdk-macro 2.2.1", + "solana-sysvar-id 2.2.1", ] [[package]] name = "solana-epoch-rewards" -version = "2.2.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b575d3dd323b9ea10bb6fe89bf6bf93e249b215ba8ed7f68f1a3633f384db7" +checksum = "b319a4ed70390af911090c020571f0ff1f4ec432522d05ab89f5c08080381995" dependencies = [ "serde", "serde_derive", - "solana-hash", - "solana-sdk-ids", - "solana-sdk-macro", - "solana-sysvar-id", + "solana-hash 3.0.0", + "solana-sdk-ids 3.0.0", + "solana-sdk-macro 3.0.0", + "solana-sysvar-id 3.0.0", ] [[package]] name = "solana-epoch-rewards-hasher" -version = "2.2.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c5fd2662ae7574810904585fd443545ed2b568dbd304b25a31e79ccc76e81b" +checksum = "e507099d0c2c5d7870c9b1848281ea67bbeee80d171ca85003ee5767994c9c38" dependencies = [ "siphasher 0.3.11", - "solana-hash", - "solana-pubkey", + "solana-hash 3.0.0", + "solana-pubkey 3.0.0", ] [[package]] @@ -8038,9 +7977,32 @@ checksum = "3fce071fbddecc55d727b1d7ed16a629afe4f6e4c217bc8d00af3b785f6f67ed" dependencies = [ "serde", "serde_derive", - "solana-sdk-ids", - "solana-sdk-macro", - "solana-sysvar-id", + "solana-sdk-ids 2.2.1", + "solana-sdk-macro 2.2.1", + "solana-sysvar-id 2.2.1", +] + +[[package]] +name = "solana-epoch-schedule" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e5481e72cc4d52c169db73e4c0cd16de8bc943078aac587ec4817a75cc6388f" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids 3.0.0", + "solana-sdk-macro 3.0.0", + "solana-sysvar-id 3.0.0", +] + +[[package]] +name = "solana-epoch-stake" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc6693d0ea833b880514b9b88d95afb80b42762dca98b0712465d1fcbbcb89e" +dependencies = [ + "solana-define-syscall 3.0.0", + "solana-pubkey 3.0.0", ] [[package]] @@ -8051,16 +8013,37 @@ checksum = "84461d56cbb8bb8d539347151e0525b53910102e4bced875d49d5139708e39d3" dependencies = [ "serde", "serde_derive", - "solana-address-lookup-table-interface", - "solana-clock", - "solana-hash", - "solana-instruction", - "solana-keccak-hasher", - "solana-message", - "solana-nonce", - "solana-pubkey", - "solana-sdk-ids", - "solana-system-interface", + "solana-address-lookup-table-interface 2.2.2", + "solana-clock 2.2.2", + "solana-hash 2.3.0", + "solana-instruction 2.3.0", + "solana-keccak-hasher 2.2.1", + "solana-message 2.4.0", + "solana-nonce 2.2.1", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", + "solana-system-interface 1.0.0", + "thiserror 2.0.17", +] + +[[package]] +name = "solana-example-mocks" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978855d164845c1b0235d4b4d101cadc55373fffaf0b5b6cfa2194d25b2ed658" +dependencies = [ + "serde", + "serde_derive", + "solana-address-lookup-table-interface 3.0.0", + "solana-clock 3.0.0", + "solana-hash 3.0.0", + "solana-instruction 3.0.0", + "solana-keccak-hasher 3.0.0", + "solana-message 3.0.1", + "solana-nonce 3.0.0", + "solana-pubkey 3.0.0", + "solana-sdk-ids 3.0.0", + "solana-system-interface 2.0.0", "thiserror 2.0.17", ] @@ -8073,28 +8056,27 @@ dependencies = [ "bincode", "serde", "serde_derive", - "solana-account", - "solana-account-info", - "solana-instruction", - "solana-program-error", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", - "solana-system-interface", + "solana-account 2.2.1", + "solana-account-info 2.3.0", + "solana-instruction 2.3.0", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "solana-rent 2.2.1", + "solana-sdk-ids 2.2.1", + "solana-system-interface 1.0.0", ] [[package]] -name = "solana-feature-set" -version = "2.2.5" +name = "solana-feature-gate-interface" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93b93971e289d6425f88e6e3cb6668c4b05df78b3c518c249be55ced8efd6b6d" +checksum = "7347ab62e6d47a82e340c865133795b394feea7c2b2771d293f57691c6544c3f" dependencies = [ - "ahash", - "lazy_static", - "solana-epoch-schedule", - "solana-hash", - "solana-pubkey", - "solana-sha256-hasher", + "serde", + "serde_derive", + "solana-program-error 3.0.0", + "solana-pubkey 3.0.0", + "solana-sdk-ids 3.0.0", ] [[package]] @@ -8109,56 +8091,31 @@ dependencies = [ ] [[package]] -name = "solana-fee-structure" -version = "2.3.0" +name = "solana-fee-calculator" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33adf673581c38e810bf618f745bf31b683a0a4a4377682e6aaac5d9a058dd4e" +checksum = "2a73cc03ca4bed871ca174558108835f8323e85917bb38b9c81c7af2ab853efe" dependencies = [ + "log", "serde", "serde_derive", - "solana-message", - "solana-native-token", ] [[package]] -name = "solana-genesis-config" -version = "2.3.0" +name = "solana-fee-structure" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3725085d47b96d37fef07a29d78d2787fc89a0b9004c66eed7753d1e554989f" +checksum = "5e2abdb1223eea8ec64136f39cb1ffcf257e00f915c957c35c0dd9e3f4e700b0" dependencies = [ - "bincode", - "chrono", - "memmap2", "serde", "serde_derive", - "solana-account", - "solana-clock", - "solana-cluster-type", - "solana-epoch-schedule", - "solana-fee-calculator", - "solana-hash", - "solana-inflation", - "solana-keypair", - "solana-logger", - "solana-poh-config", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", - "solana-sha256-hasher", - "solana-shred-version", - "solana-signer", - "solana-time-utils", ] [[package]] name = "solana-hard-forks" -version = "2.2.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c28371f878e2ead55611d8ba1b5fb879847156d04edea13693700ad1a28baf" -dependencies = [ - "serde", - "serde_derive", -] +checksum = "0abacc4b66ce471f135f48f22facf75cbbb0f8a252fbe2c1e0aa59d5b203f519" [[package]] name = "solana-hash" @@ -8173,16 +8130,32 @@ dependencies = [ "js-sys", "serde", "serde_derive", - "solana-atomic-u64", - "solana-sanitize", + "solana-atomic-u64 2.2.1", + "solana-sanitize 2.2.1", "wasm-bindgen", ] +[[package]] +name = "solana-hash" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a063723b9e84c14d8c0d2cdf0268207dc7adecf546e31251f9e07c7b00b566c" +dependencies = [ + "borsh 1.5.7", + "bytemuck", + "bytemuck_derive", + "five8", + "serde", + "serde_derive", + "solana-atomic-u64 3.0.0", + "solana-sanitize 3.0.1", +] + [[package]] name = "solana-inflation" -version = "2.2.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23eef6a09eb8e568ce6839573e4966850e85e9ce71e6ae1a6c930c1c43947de3" +checksum = "e92f37a14e7c660628752833250dd3dcd8e95309876aee751d7f8769a27947c6" dependencies = [ "serde", "serde_derive", @@ -8201,11 +8174,38 @@ dependencies = [ "num-traits", "serde", "serde_derive", - "solana-define-syscall", - "solana-pubkey", + "solana-define-syscall 2.3.0", + "solana-pubkey 2.4.0", "wasm-bindgen", ] +[[package]] +name = "solana-instruction" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df4e8fcba01d7efa647ed20a081c234475df5e11a93acb4393cc2c9a7b99bab" +dependencies = [ + "bincode", + "borsh 1.5.7", + "serde", + "serde_derive", + "solana-define-syscall 3.0.0", + "solana-instruction-error", + "solana-pubkey 3.0.0", +] + +[[package]] +name = "solana-instruction-error" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f0d483b8ae387178d9210e0575b666b05cdd4bd0f2f188128249f6e454d39d" +dependencies = [ + "num-traits", + "serde", + "serde_derive", + "solana-program-error 3.0.0", +] + [[package]] name = "solana-instructions-sysvar" version = "2.2.2" @@ -8213,14 +8213,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0e85a6fad5c2d0c4f5b91d34b8ca47118fc593af706e523cdbedf846a954f57" dependencies = [ "bitflags", - "solana-account-info", - "solana-instruction", - "solana-program-error", - "solana-pubkey", - "solana-sanitize", - "solana-sdk-ids", - "solana-serialize-utils", - "solana-sysvar-id", + "solana-account-info 2.3.0", + "solana-instruction 2.3.0", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "solana-sanitize 2.2.1", + "solana-sdk-ids 2.2.1", + "solana-serialize-utils 2.2.1", + "solana-sysvar-id 2.2.1", +] + +[[package]] +name = "solana-instructions-sysvar" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddf67876c541aa1e21ee1acae35c95c6fbc61119814bfef70579317a5e26955" +dependencies = [ + "bitflags", + "solana-account-info 3.0.0", + "solana-instruction 3.0.0", + "solana-instruction-error", + "solana-program-error 3.0.0", + "solana-pubkey 3.0.0", + "solana-sanitize 3.0.1", + "solana-sdk-ids 3.0.0", + "solana-serialize-utils 3.1.0", + "solana-sysvar-id 3.0.0", ] [[package]] @@ -8230,28 +8248,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7aeb957fbd42a451b99235df4942d96db7ef678e8d5061ef34c9b34cae12f79" dependencies = [ "sha3", - "solana-define-syscall", - "solana-hash", - "solana-sanitize", + "solana-define-syscall 2.3.0", + "solana-hash 2.3.0", + "solana-sanitize 2.2.1", +] + +[[package]] +name = "solana-keccak-hasher" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57eebd3012946913c8c1b8b43cdf8a6249edb09c0b6be3604ae910332a3acd97" +dependencies = [ + "sha3", + "solana-define-syscall 3.0.0", + "solana-hash 3.0.0", ] [[package]] name = "solana-keypair" -version = "2.2.3" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd3f04aa1a05c535e93e121a95f66e7dcccf57e007282e8255535d24bf1e98bb" +checksum = "952ed9074c12edd2060cb09c2a8c664303f4ab7f7056a407ac37dd1da7bdaa3e" dependencies = [ - "ed25519-dalek 1.0.1", + "ed25519-dalek", "ed25519-dalek-bip32", "five8", - "rand 0.7.3", + "rand 0.8.5", "solana-derivation-path", - "solana-pubkey", + "solana-pubkey 3.0.0", "solana-seed-derivable", "solana-seed-phrase", "solana-signature", "solana-signer", - "wasm-bindgen", ] [[package]] @@ -8262,9 +8290,22 @@ checksum = "4a6360ac2fdc72e7463565cd256eedcf10d7ef0c28a1249d261ec168c1b55cdd" dependencies = [ "serde", "serde_derive", - "solana-sdk-ids", - "solana-sdk-macro", - "solana-sysvar-id", + "solana-sdk-ids 2.2.1", + "solana-sdk-macro 2.2.1", + "solana-sysvar-id 2.2.1", +] + +[[package]] +name = "solana-last-restart-slot" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcda154ec827f5fc1e4da0af3417951b7e9b8157540f81f936c4a8b1156134d0" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids 3.0.0", + "solana-sdk-macro 3.0.0", + "solana-sysvar-id 3.0.0", ] [[package]] @@ -8276,9 +8317,9 @@ dependencies = [ "serde", "serde_bytes", "serde_derive", - "solana-instruction", - "solana-pubkey", - "solana-sdk-ids", + "solana-instruction 2.3.0", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", ] [[package]] @@ -8290,10 +8331,10 @@ dependencies = [ "serde", "serde_bytes", "serde_derive", - "solana-instruction", - "solana-pubkey", - "solana-sdk-ids", - "solana-system-interface", + "solana-instruction 2.3.0", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", + "solana-system-interface 1.0.0", ] [[package]] @@ -8305,30 +8346,17 @@ dependencies = [ "serde", "serde_bytes", "serde_derive", - "solana-instruction", - "solana-pubkey", - "solana-sdk-ids", - "solana-system-interface", -] - -[[package]] -name = "solana-logger" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8e777ec1afd733939b532a42492d888ec7c88d8b4127a5d867eb45c6eb5cd5" -dependencies = [ - "env_logger", - "lazy_static", - "libc", - "log", - "signal-hook", + "solana-instruction 2.3.0", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", + "solana-system-interface 1.0.0", ] [[package]] name = "solana-measure" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11dcd67cd2ae6065e494b64e861e0498d046d95a61cbbf1ae3d58be1ea0f42ed" +checksum = "b27e8b7a245e3baafbd36795debadeb650b166c5b3833670b063d8eb62df503f" [[package]] name = "solana-message" @@ -8342,29 +8370,49 @@ dependencies = [ "serde", "serde_derive", "solana-bincode", - "solana-hash", - "solana-instruction", - "solana-pubkey", - "solana-sanitize", - "solana-sdk-ids", - "solana-short-vec", - "solana-system-interface", - "solana-transaction-error", + "solana-hash 2.3.0", + "solana-instruction 2.3.0", + "solana-pubkey 2.4.0", + "solana-sanitize 2.2.1", + "solana-sdk-ids 2.2.1", + "solana-short-vec 2.2.1", + "solana-system-interface 1.0.0", + "solana-transaction-error 2.2.1", "wasm-bindgen", ] +[[package]] +name = "solana-message" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85666605c9fd727f865ed381665db0a8fc29f984a030ecc1e40f43bfb2541623" +dependencies = [ + "bincode", + "blake3", + "lazy_static", + "serde", + "serde_derive", + "solana-address", + "solana-hash 3.0.0", + "solana-instruction 3.0.0", + "solana-sanitize 3.0.1", + "solana-sdk-ids 3.0.0", + "solana-short-vec 3.0.0", + "solana-transaction-error 3.0.0", +] + [[package]] name = "solana-metrics" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0375159d8460f423d39e5103dcff6e07796a5ec1850ee1fcfacfd2482a8f34b5" +checksum = "6715976cc61be1629a762cc9d5771b439e027b1276678554587104aa4a41afc3" dependencies = [ "crossbeam-channel", "gethostname", "log", "reqwest", "solana-cluster-type", - "solana-sha256-hasher", + "solana-sha256-hasher 3.0.0", "solana-time-utils", "thiserror 2.0.17", ] @@ -8375,7 +8423,16 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f36a1a14399afaabc2781a1db09cb14ee4cc4ee5c7a5a3cfcc601811379a8092" dependencies = [ - "solana-define-syscall", + "solana-define-syscall 2.3.0", +] + +[[package]] +name = "solana-msg" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "264275c556ea7e22b9d3f87d56305546a38d4eee8ec884f3b126236cb7dcbbb4" +dependencies = [ + "solana-define-syscall 3.0.0", ] [[package]] @@ -8384,11 +8441,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61515b880c36974053dd499c0510066783f0cc6ac17def0c7ef2a244874cf4a9" +[[package]] +name = "solana-native-token" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8dd4c280dca9d046139eb5b7a5ac9ad10403fbd64964c7d7571214950d758f" + [[package]] name = "solana-net-utils" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a9e831d0f09bd92135d48c5bc79071bb59c0537b9459f1b4dec17ecc0558fa" +checksum = "1216dd3d15b873d224cba1a736161582c2c30ddc152ccd16d271fc85bf9db46d" dependencies = [ "anyhow", "bincode", @@ -8399,7 +8462,7 @@ dependencies = [ "rand 0.8.5", "serde", "serde_derive", - "socket2 0.5.10", + "socket2 0.6.1", "solana-serde", "tokio", "url", @@ -8413,45 +8476,47 @@ checksum = "703e22eb185537e06204a5bd9d509b948f0066f2d1d814a6f475dafb3ddf1325" dependencies = [ "serde", "serde_derive", - "solana-fee-calculator", - "solana-hash", - "solana-pubkey", - "solana-sha256-hasher", + "solana-fee-calculator 2.2.1", + "solana-hash 2.3.0", + "solana-pubkey 2.4.0", + "solana-sha256-hasher 2.3.0", ] [[package]] -name = "solana-nonce-account" -version = "2.2.1" +name = "solana-nonce" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cde971a20b8dbf60144d6a84439dda86b5466e00e2843091fe731083cda614da" +checksum = "abbdc6c8caf1c08db9f36a50967539d0f72b9f1d4aea04fec5430f532e5afadc" dependencies = [ - "solana-account", - "solana-hash", - "solana-nonce", - "solana-sdk-ids", + "serde", + "serde_derive", + "solana-fee-calculator 3.0.0", + "solana-hash 3.0.0", + "solana-pubkey 3.0.0", + "solana-sha256-hasher 3.0.0", ] [[package]] name = "solana-offchain-message" -version = "2.2.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b526398ade5dea37f1f147ce55dae49aa017a5d7326606359b0445ca8d946581" +checksum = "f6e2a1141a673f72a05cf406b99e4b2b8a457792b7c01afa07b3f00d4e2de393" dependencies = [ "num_enum", - "solana-hash", + "solana-hash 3.0.0", "solana-packet", - "solana-pubkey", - "solana-sanitize", - "solana-sha256-hasher", + "solana-pubkey 3.0.0", + "solana-sanitize 3.0.1", + "solana-sha256-hasher 3.0.0", "solana-signature", "solana-signer", ] [[package]] name = "solana-packet" -version = "2.2.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "004f2d2daf407b3ec1a1ca5ec34b3ccdfd6866dd2d3c7d0715004a96e4b6d127" +checksum = "6edf2f25743c95229ac0fdc32f8f5893ef738dbf332c669e9861d33ddb0f469d" dependencies = [ "bincode", "bitflags", @@ -8463,16 +8528,16 @@ dependencies = [ [[package]] name = "solana-perf" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37192c0be5c222ca49dbc5667288c5a8bb14837051dd98e541ee4dad160a5da9" +checksum = "6775cb16f353c05785e1ef05fd1fbeb47db4a6b0f3273a79a0fc2dad78f85f88" dependencies = [ "ahash", "bincode", "bv", "bytes", "caps", - "curve25519-dalek 4.1.3", + "curve25519-dalek", "dlopen2", "fnv", "libc", @@ -8481,62 +8546,25 @@ dependencies = [ "rand 0.8.5", "rayon", "serde", - "solana-hash", - "solana-message", + "solana-hash 3.0.0", + "solana-message 3.0.1", "solana-metrics", "solana-packet", - "solana-pubkey", + "solana-pubkey 3.0.0", "solana-rayon-threadlimit", - "solana-sdk-ids", - "solana-short-vec", + "solana-sdk-ids 3.0.0", + "solana-short-vec 3.0.0", "solana-signature", "solana-time-utils", ] -[[package]] -name = "solana-poh-config" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d650c3b4b9060082ac6b0efbbb66865089c58405bfb45de449f3f2b91eccee75" -dependencies = [ - "serde", - "serde_derive", -] - -[[package]] -name = "solana-precompile-error" -version = "2.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d87b2c1f5de77dfe2b175ee8dd318d196aaca4d0f66f02842f80c852811f9f8" -dependencies = [ - "num-traits", - "solana-decode-error", -] - -[[package]] -name = "solana-precompiles" -version = "2.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36e92768a57c652edb0f5d1b30a7d0bc64192139c517967c18600debe9ae3832" -dependencies = [ - "lazy_static", - "solana-ed25519-program", - "solana-feature-set", - "solana-message", - "solana-precompile-error", - "solana-pubkey", - "solana-sdk-ids", - "solana-secp256k1-program", - "solana-secp256r1-program", -] - [[package]] name = "solana-presigner" -version = "2.2.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81a57a24e6a4125fc69510b6774cd93402b943191b6cddad05de7281491c90fe" +checksum = "0f704eaf825be3180832445b9e4983b875340696e8e7239bf2d535b0f86c14a2" dependencies = [ - "solana-pubkey", + "solana-pubkey 3.0.0", "solana-signature", "solana-signer", ] @@ -8566,71 +8594,131 @@ dependencies = [ "serde", "serde_bytes", "serde_derive", - "solana-account-info", - "solana-address-lookup-table-interface", - "solana-atomic-u64", - "solana-big-mod-exp", + "solana-account-info 2.3.0", + "solana-address-lookup-table-interface 2.2.2", + "solana-atomic-u64 2.2.1", + "solana-big-mod-exp 2.2.1", "solana-bincode", - "solana-blake3-hasher", - "solana-borsh", - "solana-clock", - "solana-cpi", + "solana-blake3-hasher 2.2.1", + "solana-borsh 2.2.1", + "solana-clock 2.2.2", + "solana-cpi 2.2.1", "solana-decode-error", - "solana-define-syscall", - "solana-epoch-rewards", - "solana-epoch-schedule", - "solana-example-mocks", - "solana-feature-gate-interface", - "solana-fee-calculator", - "solana-hash", - "solana-instruction", - "solana-instructions-sysvar", - "solana-keccak-hasher", - "solana-last-restart-slot", + "solana-define-syscall 2.3.0", + "solana-epoch-rewards 2.2.1", + "solana-epoch-schedule 2.2.1", + "solana-example-mocks 2.2.1", + "solana-feature-gate-interface 2.2.2", + "solana-fee-calculator 2.2.1", + "solana-hash 2.3.0", + "solana-instruction 2.3.0", + "solana-instructions-sysvar 2.2.2", + "solana-keccak-hasher 2.2.1", + "solana-last-restart-slot 2.2.1", "solana-loader-v2-interface", "solana-loader-v3-interface", "solana-loader-v4-interface", - "solana-message", - "solana-msg", - "solana-native-token", - "solana-nonce", - "solana-program-entrypoint", - "solana-program-error", - "solana-program-memory", - "solana-program-option", - "solana-program-pack", - "solana-pubkey", - "solana-rent", - "solana-sanitize", - "solana-sdk-ids", - "solana-sdk-macro", - "solana-secp256k1-recover", - "solana-serde-varint", - "solana-serialize-utils", - "solana-sha256-hasher", - "solana-short-vec", - "solana-slot-hashes", - "solana-slot-history", - "solana-stable-layout", + "solana-message 2.4.0", + "solana-msg 2.2.1", + "solana-native-token 2.3.0", + "solana-nonce 2.2.1", + "solana-program-entrypoint 2.3.0", + "solana-program-error 2.2.2", + "solana-program-memory 2.3.1", + "solana-program-option 2.2.1", + "solana-program-pack 2.2.1", + "solana-pubkey 2.4.0", + "solana-rent 2.2.1", + "solana-sanitize 2.2.1", + "solana-sdk-ids 2.2.1", + "solana-sdk-macro 2.2.1", + "solana-secp256k1-recover 2.2.1", + "solana-serde-varint 2.2.2", + "solana-serialize-utils 2.2.1", + "solana-sha256-hasher 2.3.0", + "solana-short-vec 2.2.1", + "solana-slot-hashes 2.2.1", + "solana-slot-history 2.2.1", + "solana-stable-layout 2.2.1", "solana-stake-interface", - "solana-system-interface", - "solana-sysvar", - "solana-sysvar-id", - "solana-vote-interface", + "solana-system-interface 1.0.0", + "solana-sysvar 2.3.0", + "solana-sysvar-id 2.2.1", + "solana-vote-interface 2.2.6", "thiserror 2.0.17", "wasm-bindgen", ] +[[package]] +name = "solana-program" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91b12305dd81045d705f427acd0435a2e46444b65367d7179d7bdcfc3bc5f5eb" +dependencies = [ + "memoffset", + "solana-account-info 3.0.0", + "solana-big-mod-exp 3.0.0", + "solana-blake3-hasher 3.0.0", + "solana-borsh 3.0.0", + "solana-clock 3.0.0", + "solana-cpi 3.0.0", + "solana-define-syscall 3.0.0", + "solana-epoch-rewards 3.0.0", + "solana-epoch-schedule 3.0.0", + "solana-epoch-stake", + "solana-example-mocks 3.0.0", + "solana-fee-calculator 3.0.0", + "solana-hash 3.0.0", + "solana-instruction 3.0.0", + "solana-instruction-error", + "solana-instructions-sysvar 3.0.0", + "solana-keccak-hasher 3.0.0", + "solana-last-restart-slot 3.0.0", + "solana-msg 3.0.0", + "solana-native-token 3.0.0", + "solana-program-entrypoint 3.1.0", + "solana-program-error 3.0.0", + "solana-program-memory 3.0.0", + "solana-program-option 3.0.0", + "solana-program-pack 3.0.0", + "solana-pubkey 3.0.0", + "solana-rent 3.0.0", + "solana-sdk-ids 3.0.0", + "solana-secp256k1-recover 3.0.0", + "solana-serde-varint 3.0.0", + "solana-serialize-utils 3.1.0", + "solana-sha256-hasher 3.0.0", + "solana-short-vec 3.0.0", + "solana-slot-hashes 3.0.0", + "solana-slot-history 3.0.0", + "solana-stable-layout 3.0.0", + "solana-sysvar 3.0.0", + "solana-sysvar-id 3.0.0", +] + [[package]] name = "solana-program-entrypoint" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32ce041b1a0ed275290a5008ee1a4a6c48f5054c8a3d78d313c08958a06aedbd" dependencies = [ - "solana-account-info", - "solana-msg", - "solana-program-error", - "solana-pubkey", + "solana-account-info 2.3.0", + "solana-msg 2.2.1", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", +] + +[[package]] +name = "solana-program-entrypoint" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6557cf5b5e91745d1667447438a1baa7823c6086e4ece67f8e6ebfa7a8f72660" +dependencies = [ + "solana-account-info 3.0.0", + "solana-define-syscall 3.0.0", + "solana-msg 3.0.0", + "solana-program-error 3.0.0", + "solana-pubkey 3.0.0", ] [[package]] @@ -8644,9 +8732,20 @@ dependencies = [ "serde", "serde_derive", "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-pubkey", + "solana-instruction 2.3.0", + "solana-msg 2.2.1", + "solana-pubkey 2.4.0", +] + +[[package]] +name = "solana-program-error" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1af32c995a7b692a915bb7414d5f8e838450cf7c70414e763d8abcae7b51f28" +dependencies = [ + "borsh 1.5.7", + "serde", + "serde_derive", ] [[package]] @@ -8655,22 +8754,46 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a5426090c6f3fd6cfdc10685322fede9ca8e5af43cd6a59e98bfe4e91671712" dependencies = [ - "solana-define-syscall", + "solana-define-syscall 2.3.0", ] [[package]] -name = "solana-program-option" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" +name = "solana-program-memory" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10e5660c60749c7bfb30b447542529758e4dbcecd31b1e8af1fdc92e2bdde90a" +dependencies = [ + "solana-define-syscall 3.0.0", +] + +[[package]] +name = "solana-program-option" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc677a2e9bc616eda6dbdab834d463372b92848b2bfe4a1ed4e4b4adba3397d0" +[[package]] +name = "solana-program-option" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e7b4ddb464f274deb4a497712664c3b612e3f5f82471d4e47710fc4ab1c3095" + [[package]] name = "solana-program-pack" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "319f0ef15e6e12dc37c597faccb7d62525a509fec5f6975ecb9419efddeb277b" dependencies = [ - "solana-program-error", + "solana-program-error 2.2.2", +] + +[[package]] +name = "solana-program-pack" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c169359de21f6034a63ebf96d6b380980307df17a8d371344ff04a883ec4e9d0" +dependencies = [ + "solana-program-error 3.0.0", ] [[package]] @@ -8683,28 +8806,37 @@ dependencies = [ "borsh 1.5.7", "bytemuck", "bytemuck_derive", - "curve25519-dalek 4.1.3", + "curve25519-dalek", "five8", "five8_const", "getrandom 0.2.16", "js-sys", "num-traits", - "rand 0.8.5", "serde", "serde_derive", - "solana-atomic-u64", + "solana-atomic-u64 2.2.1", "solana-decode-error", - "solana-define-syscall", - "solana-sanitize", - "solana-sha256-hasher", + "solana-define-syscall 2.3.0", + "solana-sanitize 2.2.1", + "solana-sha256-hasher 2.3.0", "wasm-bindgen", ] +[[package]] +name = "solana-pubkey" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8909d399deb0851aa524420beeb5646b115fd253ef446e35fe4504c904da3941" +dependencies = [ + "rand 0.8.5", + "solana-address", +] + [[package]] name = "solana-pubsub-client" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d18a7476e1d2e8df5093816afd8fffee94fbb6e442d9be8e6bd3e85f88ce8d5c" +checksum = "c7f9773e441e93aaf1c77d609fe24e6b4d86a2ba8fac21dc9d49a02ef5bdf05c" dependencies = [ "crossbeam-channel", "futures-util", @@ -8715,8 +8847,8 @@ dependencies = [ "serde_derive", "serde_json", "solana-account-decoder-client-types", - "solana-clock", - "solana-pubkey", + "solana-clock 3.0.0", + "solana-pubkey 3.0.0", "solana-rpc-client-types", "solana-signature", "thiserror 2.0.17", @@ -8729,9 +8861,9 @@ dependencies = [ [[package]] name = "solana-quic-client" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44feb5f4a97494459c435aa56de810500cc24e22d0afc632990a8e54a07c05a4" +checksum = "8373fb6bf9ed15ea677a914cb114e6ed81244d39316f3dce9d45da351abe8fb8" dependencies = [ "async-lock", "async-trait", @@ -8746,32 +8878,33 @@ dependencies = [ "solana-measure", "solana-metrics", "solana-net-utils", - "solana-pubkey", + "solana-pubkey 3.0.0", "solana-quic-definitions", "solana-rpc-client-api", "solana-signer", "solana-streamer", "solana-tls-utils", - "solana-transaction-error", + "solana-transaction-error 3.0.0", "thiserror 2.0.17", "tokio", ] [[package]] name = "solana-quic-definitions" -version = "2.3.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbf0d4d5b049eb1d0c35f7b18f305a27c8986fc5c0c9b383e97adaa35334379e" +checksum = "15319accf7d3afd845817aeffa6edd8cc185f135cefbc6b985df29cfd8c09609" dependencies = [ "solana-keypair", ] [[package]] name = "solana-rayon-threadlimit" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cc2a4cae3ef7bb6346b35a60756d2622c297d5fa204f96731db9194c0dc75b" +checksum = "6b10c48c6b068d11d2c93271b7534d9bdabcf1ca3d39225d29f3bc4906b352a4" dependencies = [ + "log", "num_cpus", ] @@ -8783,55 +8916,29 @@ checksum = "d1aea8fdea9de98ca6e8c2da5827707fb3842833521b528a713810ca685d2480" dependencies = [ "serde", "serde_derive", - "solana-sdk-ids", - "solana-sdk-macro", - "solana-sysvar-id", + "solana-sdk-ids 2.2.1", + "solana-sdk-macro 2.2.1", + "solana-sysvar-id 2.2.1", ] [[package]] -name = "solana-rent-collector" -version = "2.3.0" +name = "solana-rent" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "127e6dfa51e8c8ae3aa646d8b2672bc4ac901972a338a9e1cd249e030564fb9d" +checksum = "b702d8c43711e3c8a9284a4f1bbc6a3de2553deb25b0c8142f9a44ef0ce5ddc1" dependencies = [ "serde", "serde_derive", - "solana-account", - "solana-clock", - "solana-epoch-schedule", - "solana-genesis-config", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", -] - -[[package]] -name = "solana-rent-debits" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f6f9113c6003492e74438d1288e30cffa8ccfdc2ef7b49b9e816d8034da18cd" -dependencies = [ - "solana-pubkey", - "solana-reward-info", -] - -[[package]] -name = "solana-reserved-account-keys" -version = "2.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4b22ea19ca2a3f28af7cd047c914abf833486bf7a7c4a10fc652fff09b385b1" -dependencies = [ - "lazy_static", - "solana-feature-set", - "solana-pubkey", - "solana-sdk-ids", + "solana-sdk-ids 3.0.0", + "solana-sdk-macro 3.0.0", + "solana-sysvar-id 3.0.0", ] [[package]] name = "solana-reward-info" -version = "2.2.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18205b69139b1ae0ab8f6e11cdcb627328c0814422ad2482000fa2ca54ae4a2f" +checksum = "82be7946105c2ee6be9f9ee7bd18a068b558389221d29efa92b906476102bfcc" dependencies = [ "serde", "serde_derive", @@ -8839,9 +8946,9 @@ dependencies = [ [[package]] name = "solana-rpc-client" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8d3161ac0918178e674c1f7f1bfac40de3e7ed0383bd65747d63113c156eaeb" +checksum = "cb85200cc4ca3f96fb6378966111e651cd026dde442b09d83ae0824e5793d59f" dependencies = [ "async-trait", "base64 0.22.1", @@ -8856,32 +8963,32 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "solana-account", + "solana-account 3.1.0", "solana-account-decoder-client-types", - "solana-clock", + "solana-clock 3.0.0", "solana-commitment-config", "solana-epoch-info", - "solana-epoch-schedule", - "solana-feature-gate-interface", - "solana-hash", - "solana-instruction", - "solana-message", - "solana-pubkey", + "solana-epoch-schedule 3.0.0", + "solana-feature-gate-interface 3.0.0", + "solana-hash 3.0.0", + "solana-instruction 3.0.0", + "solana-message 3.0.1", + "solana-pubkey 3.0.0", "solana-rpc-client-api", "solana-signature", "solana-transaction", - "solana-transaction-error", + "solana-transaction-error 3.0.0", "solana-transaction-status-client-types", "solana-version", - "solana-vote-interface", + "solana-vote-interface 3.0.0", "tokio", ] [[package]] name = "solana-rpc-client-api" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dbc138685c79d88a766a8fd825057a74ea7a21e1dd7f8de275ada899540fff7" +checksum = "7166554f3c6c807cb1c99514c1026a40bf63ce9b21cfe0059c6621df03a3c4d6" dependencies = [ "anyhow", "jsonrpc-core", @@ -8891,36 +8998,36 @@ dependencies = [ "serde_derive", "serde_json", "solana-account-decoder-client-types", - "solana-clock", + "solana-clock 3.0.0", "solana-rpc-client-types", "solana-signer", - "solana-transaction-error", + "solana-transaction-error 3.0.0", "solana-transaction-status-client-types", "thiserror 2.0.17", ] [[package]] name = "solana-rpc-client-nonce-utils" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f0ee41b9894ff36adebe546a110b899b0d0294b07845d8acdc73822e6af4b0" +checksum = "8e8422aba0c1acefd79b49e8467a8351a2967f4457a0beaeb5c535bceb180d05" dependencies = [ - "solana-account", + "solana-account 3.1.0", "solana-commitment-config", - "solana-hash", - "solana-message", - "solana-nonce", - "solana-pubkey", + "solana-hash 3.0.0", + "solana-message 3.0.1", + "solana-nonce 3.0.0", + "solana-pubkey 3.0.0", "solana-rpc-client", - "solana-sdk-ids", + "solana-sdk-ids 3.0.0", "thiserror 2.0.17", ] [[package]] name = "solana-rpc-client-types" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea428a81729255d895ea47fba9b30fd4dacbfe571a080448121bd0592751676" +checksum = "665af0ff5e14b13433407b2e706ae5c7f22b4ebd94631fc8d0eef39783174eaa" dependencies = [ "base64 0.22.1", "bs58", @@ -8928,14 +9035,14 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "solana-account", + "solana-account 3.1.0", "solana-account-decoder-client-types", - "solana-clock", + "solana-clock 3.0.0", "solana-commitment-config", - "solana-fee-calculator", + "solana-fee-calculator 3.0.0", "solana-inflation", - "solana-pubkey", - "solana-transaction-error", + "solana-pubkey 3.0.0", + "solana-transaction-error 3.0.0", "solana-transaction-status-client-types", "solana-version", "spl-generic-token", @@ -8948,75 +9055,62 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61f1bc1357b8188d9c4a3af3fc55276e56987265eb7ad073ae6f8180ee54cecf" +[[package]] +name = "solana-sanitize" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf09694a0fc14e5ffb18f9b7b7c0f15ecb6eac5b5610bf76a1853459d19daf9" + +[[package]] +name = "solana-sbpf" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f224d906c14efc7ed7f42bc5fe9588f3f09db8cabe7f6023adda62a69678e1a" +dependencies = [ + "byteorder", + "combine 3.8.1", + "hash32", + "log", + "rustc-demangle", + "thiserror 2.0.17", +] + [[package]] name = "solana-sdk" -version = "2.3.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cc0e4a7635b902791c44b6581bfb82f3ada32c5bc0929a64f39fe4bb384c86a" +checksum = "3f03df7969f5e723ad31b6c9eadccc209037ac4caa34d8dc259316b05c11e82b" dependencies = [ "bincode", "bs58", - "getrandom 0.1.16", - "js-sys", "serde", - "serde_json", - "solana-account", - "solana-bn254", - "solana-client-traits", - "solana-cluster-type", - "solana-commitment-config", - "solana-compute-budget-interface", - "solana-decode-error", - "solana-derivation-path", - "solana-ed25519-program", + "solana-account 3.1.0", "solana-epoch-info", "solana-epoch-rewards-hasher", - "solana-feature-set", "solana-fee-structure", - "solana-genesis-config", - "solana-hard-forks", "solana-inflation", - "solana-instruction", "solana-keypair", - "solana-message", - "solana-native-token", - "solana-nonce-account", + "solana-message 3.0.1", "solana-offchain-message", - "solana-packet", - "solana-poh-config", - "solana-precompile-error", - "solana-precompiles", "solana-presigner", - "solana-program", - "solana-program-memory", - "solana-pubkey", - "solana-quic-definitions", - "solana-rent-collector", - "solana-rent-debits", - "solana-reserved-account-keys", - "solana-reward-info", - "solana-sanitize", - "solana-sdk-ids", - "solana-sdk-macro", - "solana-secp256k1-program", - "solana-secp256k1-recover", - "solana-secp256r1-program", + "solana-program 3.0.0", + "solana-program-memory 3.0.0", + "solana-pubkey 3.0.0", + "solana-sanitize 3.0.1", + "solana-sdk-ids 3.0.0", + "solana-sdk-macro 3.0.0", "solana-seed-derivable", "solana-seed-phrase", "solana-serde", - "solana-serde-varint", - "solana-short-vec", + "solana-serde-varint 3.0.0", + "solana-short-vec 3.0.0", "solana-shred-version", "solana-signature", "solana-signer", - "solana-system-transaction", "solana-time-utils", "solana-transaction", - "solana-transaction-context", - "solana-transaction-error", - "solana-validator-exit", + "solana-transaction-error 3.0.0", "thiserror 2.0.17", - "wasm-bindgen", ] [[package]] @@ -9025,7 +9119,16 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c5d8b9cc68d5c88b062a33e23a6466722467dde0035152d8fb1afbcdf350a5f" dependencies = [ - "solana-pubkey", + "solana-pubkey 2.4.0", +] + +[[package]] +name = "solana-sdk-ids" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1b6d6aaf60669c592838d382266b173881c65fb1cdec83b37cb8ce7cb89f9ad" +dependencies = [ + "solana-pubkey 3.0.0", ] [[package]] @@ -9041,22 +9144,15 @@ dependencies = [ ] [[package]] -name = "solana-secp256k1-program" -version = "2.2.3" +name = "solana-sdk-macro" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f19833e4bc21558fe9ec61f239553abe7d05224347b57d65c2218aeeb82d6149" +checksum = "d6430000e97083460b71d9fbadc52a2ab2f88f53b3a4c5e58c5ae3640a0e8c00" dependencies = [ - "bincode", - "digest 0.10.7", - "libsecp256k1", - "serde", - "serde_derive", - "sha3", - "solana-feature-set", - "solana-instruction", - "solana-precompile-error", - "solana-sdk-ids", - "solana-signature", + "bs58", + "proc-macro2", + "quote", + "syn 2.0.108", ] [[package]] @@ -9065,57 +9161,47 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baa3120b6cdaa270f39444f5093a90a7b03d296d362878f7a6991d6de3bbe496" dependencies = [ - "borsh 1.5.7", "libsecp256k1", - "solana-define-syscall", + "solana-define-syscall 2.3.0", "thiserror 2.0.17", ] [[package]] -name = "solana-secp256r1-program" -version = "2.2.4" +name = "solana-secp256k1-recover" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce0ae46da3071a900f02d367d99b2f3058fe2e90c5062ac50c4f20cfedad8f0f" +checksum = "394a4470477d66296af5217970a905b1c5569032a7732c367fb69e5666c8607e" dependencies = [ - "bytemuck", - "openssl", - "solana-feature-set", - "solana-instruction", - "solana-precompile-error", - "solana-sdk-ids", + "k256", + "solana-define-syscall 3.0.0", + "thiserror 2.0.17", ] -[[package]] -name = "solana-security-txt" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "468aa43b7edb1f9b7b7b686d5c3aeb6630dc1708e86e31343499dd5c4d775183" - [[package]] name = "solana-seed-derivable" -version = "2.2.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beb82b5adb266c6ea90e5cf3967235644848eac476c5a1f2f9283a143b7c97f" +checksum = "ff7bdb72758e3bec33ed0e2658a920f1f35dfb9ed576b951d20d63cb61ecd95c" dependencies = [ "solana-derivation-path", ] [[package]] name = "solana-seed-phrase" -version = "2.2.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36187af2324f079f65a675ec22b31c24919cb4ac22c79472e85d819db9bbbc15" +checksum = "dc905b200a95f2ea9146e43f2a7181e3aeb55de6bc12afb36462d00a3c7310de" dependencies = [ - "hmac 0.12.1", + "hmac", "pbkdf2", "sha2 0.10.9", ] [[package]] name = "solana-serde" -version = "2.2.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1931484a408af466e14171556a47adaa215953c7f48b24e5f6b0282763818b04" +checksum = "709a93cab694c70f40b279d497639788fc2ccbcf9b4aa32273d4b361322c02dd" dependencies = [ "serde", ] @@ -9129,15 +9215,35 @@ dependencies = [ "serde", ] +[[package]] +name = "solana-serde-varint" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e5174c57d5ff3c1995f274d17156964664566e2cde18a07bba1586d35a70d3b" +dependencies = [ + "serde", +] + [[package]] name = "solana-serialize-utils" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "817a284b63197d2b27afdba829c5ab34231da4a9b4e763466a003c40ca4f535e" dependencies = [ - "solana-instruction", - "solana-pubkey", - "solana-sanitize", + "solana-instruction 2.3.0", + "solana-pubkey 2.4.0", + "solana-sanitize 2.2.1", +] + +[[package]] +name = "solana-serialize-utils" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e41dd8feea239516c623a02f0a81c2367f4b604d7965237fed0751aeec33ed" +dependencies = [ + "solana-instruction-error", + "solana-pubkey 3.0.0", + "solana-sanitize 3.0.1", ] [[package]] @@ -9147,8 +9253,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5aa3feb32c28765f6aa1ce8f3feac30936f16c5c3f7eb73d63a5b8f6f8ecdc44" dependencies = [ "sha2 0.10.9", - "solana-define-syscall", - "solana-hash", + "solana-define-syscall 2.3.0", + "solana-hash 2.3.0", +] + +[[package]] +name = "solana-sha256-hasher" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b912ba6f71cb202c0c3773ec77bf898fa9fe0c78691a2d6859b3b5b8954719" +dependencies = [ + "sha2 0.10.9", + "solana-define-syscall 3.0.0", + "solana-hash 3.0.0", ] [[package]] @@ -9160,41 +9277,50 @@ dependencies = [ "serde", ] +[[package]] +name = "solana-short-vec" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b69d029da5428fc1c57f7d49101b2077c61f049d4112cd5fb8456567cc7d2638" +dependencies = [ + "serde", +] + [[package]] name = "solana-shred-version" -version = "2.2.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afd3db0461089d1ad1a78d9ba3f15b563899ca2386351d38428faa5350c60a98" +checksum = "94953e22ca28fe4541a3447d6baeaf519cc4ddc063253bfa673b721f34c136bb" dependencies = [ "solana-hard-forks", - "solana-hash", - "solana-sha256-hasher", + "solana-hash 3.0.0", + "solana-sha256-hasher 3.0.0", ] [[package]] name = "solana-signature" -version = "2.3.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64c8ec8e657aecfc187522fc67495142c12f35e55ddeca8698edbb738b8dbd8c" +checksum = "4bb8057cc0e9f7b5e89883d49de6f407df655bb6f3a71d0b7baf9986a2218fd9" dependencies = [ - "ed25519-dalek 1.0.1", + "ed25519-dalek", "five8", "rand 0.8.5", "serde", "serde-big-array", "serde_derive", - "solana-sanitize", + "solana-sanitize 3.0.1", ] [[package]] name = "solana-signer" -version = "2.2.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c41991508a4b02f021c1342ba00bcfa098630b213726ceadc7cb032e051975b" +checksum = "5bfea97951fee8bae0d6038f39a5efcb6230ecdfe33425ac75196d1a1e3e3235" dependencies = [ - "solana-pubkey", + "solana-pubkey 3.0.0", "solana-signature", - "solana-transaction-error", + "solana-transaction-error 3.0.0", ] [[package]] @@ -9205,9 +9331,22 @@ checksum = "0c8691982114513763e88d04094c9caa0376b867a29577939011331134c301ce" dependencies = [ "serde", "serde_derive", - "solana-hash", - "solana-sdk-ids", - "solana-sysvar-id", + "solana-hash 2.3.0", + "solana-sdk-ids 2.2.1", + "solana-sysvar-id 2.2.1", +] + +[[package]] +name = "solana-slot-hashes" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80a293f952293281443c04f4d96afd9d547721923d596e92b4377ed2360f1746" +dependencies = [ + "serde", + "serde_derive", + "solana-hash 3.0.0", + "solana-sdk-ids 3.0.0", + "solana-sysvar-id 3.0.0", ] [[package]] @@ -9219,8 +9358,21 @@ dependencies = [ "bv", "serde", "serde_derive", - "solana-sdk-ids", - "solana-sysvar-id", + "solana-sdk-ids 2.2.1", + "solana-sysvar-id 2.2.1", +] + +[[package]] +name = "solana-slot-history" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f914f6b108f5bba14a280b458d023e3621c9973f27f015a4d755b50e88d89e97" +dependencies = [ + "bv", + "serde", + "serde_derive", + "solana-sdk-ids 3.0.0", + "solana-sysvar-id 3.0.0", ] [[package]] @@ -9229,8 +9381,18 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f14f7d02af8f2bc1b5efeeae71bc1c2b7f0f65cd75bcc7d8180f2c762a57f54" dependencies = [ - "solana-instruction", - "solana-pubkey", + "solana-instruction 2.3.0", + "solana-pubkey 2.4.0", +] + +[[package]] +name = "solana-stable-layout" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1da74507795b6e8fb60b7c7306c0c36e2c315805d16eaaf479452661234685ac" +dependencies = [ + "solana-instruction 3.0.0", + "solana-pubkey 3.0.0", ] [[package]] @@ -9244,22 +9406,23 @@ dependencies = [ "num-traits", "serde", "serde_derive", - "solana-clock", - "solana-cpi", + "solana-clock 2.2.2", + "solana-cpi 2.2.1", "solana-decode-error", - "solana-instruction", - "solana-program-error", - "solana-pubkey", - "solana-system-interface", - "solana-sysvar-id", + "solana-instruction 2.3.0", + "solana-program-error 2.2.2", + "solana-pubkey 2.4.0", + "solana-system-interface 1.0.0", + "solana-sysvar-id 2.2.1", ] [[package]] name = "solana-streamer" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5643516e5206b89dd4bdf67c39815606d835a51a13260e43349abdb92d241b1d" +checksum = "b85b5bc5e6e8e96c09ee3b5a6d5cc4c811f0f4f0e96dcc86ce45bdab37bd741b" dependencies = [ + "arc-swap", "async-channel", "bytes", "crossbeam-channel", @@ -9273,6 +9436,7 @@ dependencies = [ "libc", "log", "nix", + "num_cpus", "pem 1.1.1", "percentage", "quinn", @@ -9280,20 +9444,20 @@ dependencies = [ "rand 0.8.5", "rustls 0.23.34", "smallvec", - "socket2 0.5.10", + "socket2 0.6.1", "solana-keypair", "solana-measure", "solana-metrics", "solana-net-utils", "solana-packet", "solana-perf", - "solana-pubkey", + "solana-pubkey 3.0.0", "solana-quic-definitions", "solana-signature", "solana-signer", "solana-time-utils", "solana-tls-utils", - "solana-transaction-error", + "solana-transaction-error 3.0.0", "solana-transaction-metrics-tracker", "thiserror 2.0.17", "tokio", @@ -9303,9 +9467,9 @@ dependencies = [ [[package]] name = "solana-svm-feature-set" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f24b836eb4d74ec255217bdbe0f24f64a07adeac31aca61f334f91cd4a3b1d5" +checksum = "0db171398f959c9a5b4bd1a918d2f2a096a32760c9c633b6f19e09155e124151" [[package]] name = "solana-system-interface" @@ -9318,24 +9482,24 @@ dependencies = [ "serde", "serde_derive", "solana-decode-error", - "solana-instruction", - "solana-pubkey", + "solana-instruction 2.3.0", + "solana-pubkey 2.4.0", "wasm-bindgen", ] [[package]] -name = "solana-system-transaction" -version = "2.2.1" +name = "solana-system-interface" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bd98a25e5bcba8b6be8bcbb7b84b24c2a6a8178d7fb0e3077a916855ceba91a" +checksum = "4e1790547bfc3061f1ee68ea9d8dc6c973c02a163697b24263a8e9f2e6d4afa2" dependencies = [ - "solana-hash", - "solana-keypair", - "solana-message", - "solana-pubkey", - "solana-signer", - "solana-system-interface", - "solana-transaction", + "num-traits", + "serde", + "serde_derive", + "solana-instruction 3.0.0", + "solana-msg 3.0.0", + "solana-program-error 3.0.0", + "solana-pubkey 3.0.0", ] [[package]] @@ -9351,28 +9515,62 @@ dependencies = [ "lazy_static", "serde", "serde_derive", - "solana-account-info", - "solana-clock", - "solana-define-syscall", - "solana-epoch-rewards", - "solana-epoch-schedule", - "solana-fee-calculator", - "solana-hash", - "solana-instruction", - "solana-instructions-sysvar", - "solana-last-restart-slot", - "solana-program-entrypoint", - "solana-program-error", - "solana-program-memory", - "solana-pubkey", - "solana-rent", - "solana-sanitize", - "solana-sdk-ids", - "solana-sdk-macro", - "solana-slot-hashes", - "solana-slot-history", + "solana-account-info 2.3.0", + "solana-clock 2.2.2", + "solana-define-syscall 2.3.0", + "solana-epoch-rewards 2.2.1", + "solana-epoch-schedule 2.2.1", + "solana-fee-calculator 2.2.1", + "solana-hash 2.3.0", + "solana-instruction 2.3.0", + "solana-instructions-sysvar 2.2.2", + "solana-last-restart-slot 2.2.1", + "solana-program-entrypoint 2.3.0", + "solana-program-error 2.2.2", + "solana-program-memory 2.3.1", + "solana-pubkey 2.4.0", + "solana-rent 2.2.1", + "solana-sanitize 2.2.1", + "solana-sdk-ids 2.2.1", + "solana-sdk-macro 2.2.1", + "solana-slot-hashes 2.2.1", + "solana-slot-history 2.2.1", "solana-stake-interface", - "solana-sysvar-id", + "solana-sysvar-id 2.2.1", +] + +[[package]] +name = "solana-sysvar" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63205e68d680bcc315337dec311b616ab32fea0a612db3b883ce4de02e0953f9" +dependencies = [ + "base64 0.22.1", + "bincode", + "bytemuck", + "bytemuck_derive", + "lazy_static", + "serde", + "serde_derive", + "solana-account-info 3.0.0", + "solana-clock 3.0.0", + "solana-define-syscall 3.0.0", + "solana-epoch-rewards 3.0.0", + "solana-epoch-schedule 3.0.0", + "solana-fee-calculator 3.0.0", + "solana-hash 3.0.0", + "solana-instruction 3.0.0", + "solana-last-restart-slot 3.0.0", + "solana-program-entrypoint 3.1.0", + "solana-program-error 3.0.0", + "solana-program-memory 3.0.0", + "solana-pubkey 3.0.0", + "solana-rent 3.0.0", + "solana-sdk-ids 3.0.0", + "solana-sdk-macro 3.0.0", + "solana-slot-hashes 3.0.0", + "solana-slot-history 3.0.0", + "solana-sysvar-id 3.0.0", ] [[package]] @@ -9381,63 +9579,44 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5762b273d3325b047cfda250787f8d796d781746860d5d0a746ee29f3e8812c1" dependencies = [ - "solana-pubkey", - "solana-sdk-ids", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", ] [[package]] -name = "solana-thin-client" -version = "2.3.13" +name = "solana-sysvar-id" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c1025715a113e0e2e379b30a6bfe4455770dc0759dabf93f7dbd16646d5acbe" +checksum = "5051bc1a16d5d96a96bc33b5b2ec707495c48fe978097bdaba68d3c47987eb32" dependencies = [ - "bincode", - "log", - "rayon", - "solana-account", - "solana-client-traits", - "solana-clock", - "solana-commitment-config", - "solana-connection-cache", - "solana-epoch-info", - "solana-hash", - "solana-instruction", - "solana-keypair", - "solana-message", - "solana-pubkey", - "solana-rpc-client", - "solana-rpc-client-api", - "solana-signature", - "solana-signer", - "solana-system-interface", - "solana-transaction", - "solana-transaction-error", + "solana-pubkey 3.0.0", + "solana-sdk-ids 3.0.0", ] [[package]] name = "solana-time-utils" -version = "2.2.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6af261afb0e8c39252a04d026e3ea9c405342b08c871a2ad8aa5448e068c784c" +checksum = "0ced92c60aa76ec4780a9d93f3bd64dfa916e1b998eacc6f1c110f3f444f02c9" [[package]] name = "solana-tls-utils" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14494aa87a75a883d1abcfee00f1278a28ecc594a2f030084879eb40570728f6" +checksum = "c0e7fbf21da865ffedf59e3efa96a653981e68793782482e095887c0779a16e1" dependencies = [ "rustls 0.23.34", "solana-keypair", - "solana-pubkey", + "solana-pubkey 3.0.0", "solana-signer", "x509-parser", ] [[package]] name = "solana-tpu-client" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17895ce70fd1dd93add3fbac87d599954ded93c63fa1c66f702d278d96a6da14" +checksum = "0d6681844a1d01dbea9c5b13f9b106e8e7849c9809da06f3727d748e9bb7e3f7" dependencies = [ "async-trait", "bincode", @@ -9447,14 +9626,14 @@ dependencies = [ "log", "rayon", "solana-client-traits", - "solana-clock", + "solana-clock 3.0.0", "solana-commitment-config", "solana-connection-cache", - "solana-epoch-schedule", + "solana-epoch-schedule 3.0.0", "solana-measure", - "solana-message", + "solana-message 3.0.1", "solana-net-utils", - "solana-pubkey", + "solana-pubkey 3.0.0", "solana-pubsub-client", "solana-quic-definitions", "solana-rpc-client", @@ -9462,53 +9641,49 @@ dependencies = [ "solana-signature", "solana-signer", "solana-transaction", - "solana-transaction-error", + "solana-transaction-error 3.0.0", "thiserror 2.0.17", "tokio", ] [[package]] name = "solana-transaction" -version = "2.2.3" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80657d6088f721148f5d889c828ca60c7daeedac9a8679f9ec215e0c42bcbf41" +checksum = "64928e6af3058dcddd6da6680cbe08324b4e071ad73115738235bbaa9e9f72a5" dependencies = [ "bincode", "serde", "serde_derive", - "solana-bincode", - "solana-feature-set", - "solana-hash", - "solana-instruction", - "solana-keypair", - "solana-message", - "solana-precompiles", - "solana-pubkey", - "solana-sanitize", - "solana-sdk-ids", - "solana-short-vec", + "solana-address", + "solana-hash 3.0.0", + "solana-instruction 3.0.0", + "solana-instruction-error", + "solana-message 3.0.1", + "solana-sanitize 3.0.1", + "solana-sdk-ids 3.0.0", + "solana-short-vec 3.0.0", "solana-signature", "solana-signer", - "solana-system-interface", - "solana-transaction-error", - "wasm-bindgen", + "solana-transaction-error 3.0.0", ] [[package]] name = "solana-transaction-context" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a312304361987a85b2ef2293920558e6612876a639dd1309daf6d0d59ef2fe" +checksum = "cd6e951b985f5cb926592a72f1c8d63cbda317017d20c7e225ac30c4e736424f" dependencies = [ "bincode", "serde", "serde_derive", - "solana-account", - "solana-instruction", - "solana-instructions-sysvar", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", + "solana-account 3.1.0", + "solana-instruction 3.0.0", + "solana-instructions-sysvar 3.0.0", + "solana-pubkey 3.0.0", + "solana-rent 3.0.0", + "solana-sbpf", + "solana-sdk-ids 3.0.0", ] [[package]] @@ -9516,18 +9691,28 @@ name = "solana-transaction-error" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "222a9dc8fdb61c6088baab34fc3a8b8473a03a7a5fd404ed8dd502fa79b67cb1" +dependencies = [ + "solana-instruction 2.3.0", + "solana-sanitize 2.2.1", +] + +[[package]] +name = "solana-transaction-error" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4222065402340d7e6aec9dc3e54d22992ddcf923d91edcd815443c2bfca3144a" dependencies = [ "serde", "serde_derive", - "solana-instruction", - "solana-sanitize", + "solana-instruction-error", + "solana-sanitize 3.0.1", ] [[package]] name = "solana-transaction-metrics-tracker" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03fc4e1b6252dc724f5ee69db6229feb43070b7318651580d2174da8baefb993" +checksum = "d4258281f3dc723dfe7a42377e0c4ad87edb51f97b0a9f403401fbd263a61563" dependencies = [ "base64 0.22.1", "bincode", @@ -9535,15 +9720,15 @@ dependencies = [ "rand 0.8.5", "solana-packet", "solana-perf", - "solana-short-vec", + "solana-short-vec 3.0.0", "solana-signature", ] [[package]] name = "solana-transaction-status-client-types" -version = "2.3.13" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51f1d7c2387c35850848212244d2b225847666cb52d3bd59a5c409d2c300303d" +checksum = "ee3d7557295320bf7bf591f1703df483bd9ee69c2b750072abb0657d2915046c" dependencies = [ "base64 0.22.1", "bincode", @@ -9553,754 +9738,379 @@ dependencies = [ "serde_json", "solana-account-decoder-client-types", "solana-commitment-config", - "solana-message", + "solana-instruction 3.0.0", + "solana-message 3.0.1", + "solana-pubkey 3.0.0", "solana-reward-info", "solana-signature", - "solana-transaction", - "solana-transaction-context", - "solana-transaction-error", - "thiserror 2.0.17", -] - -[[package]] -name = "solana-udp-client" -version = "2.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dd36227dd3035ac09a89d4239551d2e3d7d9b177b61ccc7c6d393c3974d0efa" -dependencies = [ - "async-trait", - "solana-connection-cache", - "solana-keypair", - "solana-net-utils", - "solana-streamer", - "solana-transaction-error", - "thiserror 2.0.17", - "tokio", -] - -[[package]] -name = "solana-validator-exit" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bbf6d7a3c0b28dd5335c52c0e9eae49d0ae489a8f324917faf0ded65a812c1d" - -[[package]] -name = "solana-version" -version = "2.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3324d46c7f7b7f5d34bf7dc71a2883bdc072c7b28ca81d0b2167ecec4cf8da9f" -dependencies = [ - "agave-feature-set", - "rand 0.8.5", - "semver 1.0.27", - "serde", - "serde_derive", - "solana-sanitize", - "solana-serde-varint", -] - -[[package]] -name = "solana-vote-interface" -version = "2.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b80d57478d6599d30acc31cc5ae7f93ec2361a06aefe8ea79bc81739a08af4c3" -dependencies = [ - "bincode", - "num-derive 0.4.2", - "num-traits", - "serde", - "serde_derive", - "solana-clock", - "solana-decode-error", - "solana-hash", - "solana-instruction", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", - "solana-serde-varint", - "solana-serialize-utils", - "solana-short-vec", - "solana-system-interface", -] - -[[package]] -name = "solana-zk-sdk" -version = "2.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b9fc6ec37d16d0dccff708ed1dd6ea9ba61796700c3bb7c3b401973f10f63b" -dependencies = [ - "aes-gcm-siv", - "base64 0.22.1", - "bincode", - "bytemuck", - "bytemuck_derive", - "curve25519-dalek 4.1.3", - "itertools 0.12.1", - "js-sys", - "merlin", - "num-derive 0.4.2", - "num-traits", - "rand 0.8.5", - "serde", - "serde_derive", - "serde_json", - "sha3", - "solana-derivation-path", - "solana-instruction", - "solana-pubkey", - "solana-sdk-ids", - "solana-seed-derivable", - "solana-seed-phrase", - "solana-signature", - "solana-signer", - "subtle", - "thiserror 2.0.17", - "wasm-bindgen", - "zeroize", -] - -[[package]] -name = "soroban-rs" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3656ea7bbff623291d2ac7280fb91bad48f607f58c176044f2b17bf3717b1cf" -dependencies = [ - "async-trait", - "ed25519-dalek 2.2.0", - "hex", - "rand 0.9.2", - "sha2 0.10.9", - "soroban-rs-macros", - "stellar-rpc-client", - "stellar-strkey 0.0.13", - "stellar-xdr", - "tokio", -] - -[[package]] -name = "soroban-rs-macros" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c807c0f5dace568082494bcb7a3bd2fbedc422991c1c86c32e40889f0bc7daf4" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.108", -] - -[[package]] -name = "spinning_top" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "spl-associated-token-account" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76fee7d65013667032d499adc3c895e286197a35a0d3a4643c80e7fd3e9969e3" -dependencies = [ - "borsh 1.5.7", - "num-derive 0.4.2", - "num-traits", - "solana-program", - "spl-associated-token-account-client", - "spl-token 7.0.0", - "spl-token-2022 6.0.0", - "thiserror 1.0.69", -] - -[[package]] -name = "spl-associated-token-account-client" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f8349dbcbe575f354f9a533a21f272f3eb3808a49e2fdc1c34393b88ba76cb" -dependencies = [ - "solana-instruction", - "solana-pubkey", -] - -[[package]] -name = "spl-discriminator" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7398da23554a31660f17718164e31d31900956054f54f52d5ec1be51cb4f4b3" -dependencies = [ - "bytemuck", - "solana-program-error", - "solana-sha256-hasher", - "spl-discriminator-derive", -] - -[[package]] -name = "spl-discriminator-derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9e8418ea6269dcfb01c712f0444d2c75542c04448b480e87de59d2865edc750" -dependencies = [ - "quote", - "spl-discriminator-syn", - "syn 2.0.108", -] - -[[package]] -name = "spl-discriminator-syn" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d1dbc82ab91422345b6df40a79e2b78c7bce1ebb366da323572dd60b7076b67" -dependencies = [ - "proc-macro2", - "quote", - "sha2 0.10.9", - "syn 2.0.108", - "thiserror 1.0.69", -] - -[[package]] -name = "spl-elgamal-registry" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce0f668975d2b0536e8a8fd60e56a05c467f06021dae037f1d0cfed0de2e231d" -dependencies = [ - "bytemuck", - "solana-program", - "solana-zk-sdk", - "spl-pod", - "spl-token-confidential-transfer-proof-extraction 0.2.1", -] - -[[package]] -name = "spl-elgamal-registry" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65edfeed09cd4231e595616aa96022214f9c9d2be02dea62c2b30d5695a6833a" -dependencies = [ - "bytemuck", - "solana-account-info", - "solana-cpi", - "solana-instruction", - "solana-msg", - "solana-program-entrypoint", - "solana-program-error", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", - "solana-system-interface", - "solana-sysvar", - "solana-zk-sdk", - "spl-pod", - "spl-token-confidential-transfer-proof-extraction 0.3.0", -] - -[[package]] -name = "spl-generic-token" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "741a62a566d97c58d33f9ed32337ceedd4e35109a686e31b1866c5dfa56abddc" -dependencies = [ - "bytemuck", - "solana-pubkey", -] - -[[package]] -name = "spl-memo" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f09647c0974e33366efeb83b8e2daebb329f0420149e74d3a4bd2c08cf9f7cb" -dependencies = [ - "solana-account-info", - "solana-instruction", - "solana-msg", - "solana-program-entrypoint", - "solana-program-error", - "solana-pubkey", -] - -[[package]] -name = "spl-pod" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d994afaf86b779104b4a95ba9ca75b8ced3fdb17ee934e38cb69e72afbe17799" -dependencies = [ - "borsh 1.5.7", - "bytemuck", - "bytemuck_derive", - "num-derive 0.4.2", - "num-traits", - "solana-decode-error", - "solana-msg", - "solana-program-error", - "solana-program-option", - "solana-pubkey", - "solana-zk-sdk", - "thiserror 2.0.17", -] - -[[package]] -name = "spl-program-error" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d39b5186f42b2b50168029d81e58e800b690877ef0b30580d107659250da1d1" -dependencies = [ - "num-derive 0.4.2", - "num-traits", - "solana-program", - "spl-program-error-derive 0.4.1", - "thiserror 1.0.69", -] - -[[package]] -name = "spl-program-error" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdebc8b42553070b75aa5106f071fef2eb798c64a7ec63375da4b1f058688c6" -dependencies = [ - "num-derive 0.4.2", - "num-traits", - "solana-decode-error", - "solana-msg", - "solana-program-error", - "spl-program-error-derive 0.5.0", - "thiserror 2.0.17", -] - -[[package]] -name = "spl-program-error-derive" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d375dd76c517836353e093c2dbb490938ff72821ab568b545fd30ab3256b3e" -dependencies = [ - "proc-macro2", - "quote", - "sha2 0.10.9", - "syn 2.0.108", + "solana-transaction", + "solana-transaction-context", + "solana-transaction-error 3.0.0", + "thiserror 2.0.17", ] [[package]] -name = "spl-program-error-derive" -version = "0.5.0" +name = "solana-udp-client" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2539e259c66910d78593475540e8072f0b10f0f61d7607bbf7593899ed52d0" +checksum = "747532f7fe9fd6b96f0c5dfce2c98365e9d2ab98788122f31f84dcc5d34eec4f" dependencies = [ - "proc-macro2", - "quote", - "sha2 0.10.9", - "syn 2.0.108", + "async-trait", + "solana-connection-cache", + "solana-keypair", + "solana-net-utils", + "solana-streamer", + "solana-transaction-error 3.0.0", + "thiserror 2.0.17", + "tokio", ] [[package]] -name = "spl-tlv-account-resolution" -version = "0.9.0" +name = "solana-version" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd99ff1e9ed2ab86e3fd582850d47a739fec1be9f4661cba1782d3a0f26805f3" +checksum = "107c352fb2f329791d3637fcb84f67e597f79553f218760742872c66abac372e" dependencies = [ - "bytemuck", - "num-derive 0.4.2", - "num-traits", - "solana-account-info", - "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-program-error", - "solana-pubkey", - "spl-discriminator", - "spl-pod", - "spl-program-error 0.6.0", - "spl-type-length-value 0.7.0", - "thiserror 1.0.69", + "agave-feature-set", + "rand 0.8.5", + "semver 1.0.27", + "serde", + "serde_derive", + "solana-sanitize 3.0.1", + "solana-serde-varint 3.0.0", ] [[package]] -name = "spl-tlv-account-resolution" -version = "0.10.0" +name = "solana-vote-interface" +version = "2.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1408e961215688715d5a1063cbdcf982de225c45f99c82b4f7d7e1dd22b998d7" +checksum = "b80d57478d6599d30acc31cc5ae7f93ec2361a06aefe8ea79bc81739a08af4c3" dependencies = [ - "bytemuck", + "bincode", "num-derive 0.4.2", "num-traits", - "solana-account-info", + "serde", + "serde_derive", + "solana-clock 2.2.2", "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-program-error", - "solana-pubkey", - "spl-discriminator", - "spl-pod", - "spl-program-error 0.7.0", - "spl-type-length-value 0.8.0", - "thiserror 2.0.17", + "solana-hash 2.3.0", + "solana-instruction 2.3.0", + "solana-pubkey 2.4.0", + "solana-rent 2.2.1", + "solana-sdk-ids 2.2.1", + "solana-serde-varint 2.2.2", + "solana-serialize-utils 2.2.1", + "solana-short-vec 2.2.1", + "solana-system-interface 1.0.0", ] [[package]] -name = "spl-token" -version = "7.0.0" +name = "solana-vote-interface" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed320a6c934128d4f7e54fe00e16b8aeaecf215799d060ae14f93378da6dc834" +checksum = "66631ddbe889dab5ec663294648cd1df395ec9df7a4476e7b3e095604cfdb539" dependencies = [ - "arrayref", - "bytemuck", "num-derive 0.4.2", "num-traits", - "num_enum", - "solana-program", - "thiserror 1.0.69", + "solana-clock 3.0.0", + "solana-hash 3.0.0", + "solana-instruction 3.0.0", + "solana-instruction-error", + "solana-pubkey 3.0.0", + "solana-rent 3.0.0", + "solana-sdk-ids 3.0.0", + "solana-serialize-utils 3.1.0", ] [[package]] -name = "spl-token" -version = "8.0.0" +name = "solana-zk-sdk" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053067c6a82c705004f91dae058b11b4780407e9ccd6799dc9e7d0fab5f242da" +checksum = "9602bcb1f7af15caef92b91132ec2347e1c51a72ecdbefdaefa3eac4b8711475" dependencies = [ - "arrayref", + "aes-gcm-siv", + "base64 0.22.1", + "bincode", "bytemuck", + "bytemuck_derive", + "curve25519-dalek", + "getrandom 0.2.16", + "itertools 0.12.1", + "js-sys", + "merlin", "num-derive 0.4.2", "num-traits", - "num_enum", - "solana-account-info", - "solana-cpi", - "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-program-entrypoint", - "solana-program-error", - "solana-program-memory", - "solana-program-option", - "solana-program-pack", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", - "solana-sysvar", + "rand 0.8.5", + "serde", + "serde_derive", + "serde_json", + "sha3", + "solana-derivation-path", + "solana-instruction 3.0.0", + "solana-pubkey 3.0.0", + "solana-sdk-ids 3.0.0", + "solana-seed-derivable", + "solana-seed-phrase", + "solana-signature", + "solana-signer", + "subtle", "thiserror 2.0.17", + "wasm-bindgen", + "zeroize", ] [[package]] -name = "spl-token-2022" -version = "6.0.0" +name = "soroban-rs" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b27f7405010ef816587c944536b0eafbcc35206ab6ba0f2ca79f1d28e488f4f" +checksum = "e3656ea7bbff623291d2ac7280fb91bad48f607f58c176044f2b17bf3717b1cf" dependencies = [ - "arrayref", - "bytemuck", - "num-derive 0.4.2", - "num-traits", - "num_enum", - "solana-program", - "solana-security-txt", - "solana-zk-sdk", - "spl-elgamal-registry 0.1.1", - "spl-memo", - "spl-pod", - "spl-token 7.0.0", - "spl-token-confidential-transfer-ciphertext-arithmetic 0.2.1", - "spl-token-confidential-transfer-proof-extraction 0.2.1", - "spl-token-confidential-transfer-proof-generation 0.2.0", - "spl-token-group-interface 0.5.0", - "spl-token-metadata-interface 0.6.0", - "spl-transfer-hook-interface 0.9.0", - "spl-type-length-value 0.7.0", - "thiserror 1.0.69", + "async-trait", + "ed25519-dalek", + "hex", + "rand 0.9.2", + "sha2 0.10.9", + "soroban-rs-macros", + "stellar-rpc-client", + "stellar-strkey 0.0.13", + "stellar-xdr", + "tokio", ] [[package]] -name = "spl-token-2022" -version = "8.0.1" +name = "soroban-rs-macros" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31f0dfbb079eebaee55e793e92ca5f433744f4b71ee04880bfd6beefba5973e5" +checksum = "c807c0f5dace568082494bcb7a3bd2fbedc422991c1c86c32e40889f0bc7daf4" dependencies = [ - "arrayref", - "bytemuck", - "num-derive 0.4.2", - "num-traits", - "num_enum", - "solana-account-info", - "solana-clock", - "solana-cpi", - "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-native-token", - "solana-program-entrypoint", - "solana-program-error", - "solana-program-memory", - "solana-program-option", - "solana-program-pack", - "solana-pubkey", - "solana-rent", - "solana-sdk-ids", - "solana-security-txt", - "solana-system-interface", - "solana-sysvar", - "solana-zk-sdk", - "spl-elgamal-registry 0.2.0", - "spl-memo", - "spl-pod", - "spl-token 8.0.0", - "spl-token-confidential-transfer-ciphertext-arithmetic 0.3.1", - "spl-token-confidential-transfer-proof-extraction 0.3.0", - "spl-token-confidential-transfer-proof-generation 0.4.1", - "spl-token-group-interface 0.6.0", - "spl-token-metadata-interface 0.7.0", - "spl-transfer-hook-interface 0.10.0", - "spl-type-length-value 0.8.0", - "thiserror 2.0.17", + "proc-macro2", + "quote", + "syn 2.0.108", ] [[package]] -name = "spl-token-confidential-transfer-ciphertext-arithmetic" -version = "0.2.1" +name = "spinning_top" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "170378693c5516090f6d37ae9bad2b9b6125069be68d9acd4865bbe9fc8499fd" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" dependencies = [ - "base64 0.22.1", - "bytemuck", - "solana-curve25519", - "solana-zk-sdk", + "lock_api", ] [[package]] -name = "spl-token-confidential-transfer-ciphertext-arithmetic" -version = "0.3.1" +name = "spki" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cddd52bfc0f1c677b41493dafa3f2dbbb4b47cf0990f08905429e19dc8289b35" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ - "base64 0.22.1", - "bytemuck", - "solana-curve25519", - "solana-zk-sdk", + "base64ct", + "der", ] [[package]] -name = "spl-token-confidential-transfer-proof-extraction" -version = "0.2.1" +name = "spl-associated-token-account-interface" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff2d6a445a147c9d6dd77b8301b1e116c8299601794b558eafa409b342faf96" +checksum = "e6433917b60441d68d99a17e121d9db0ea15a9a69c0e5afa34649cf5ba12612f" dependencies = [ - "bytemuck", - "solana-curve25519", - "solana-program", - "solana-zk-sdk", - "spl-pod", - "thiserror 2.0.17", + "solana-instruction 3.0.0", + "solana-pubkey 3.0.0", ] [[package]] -name = "spl-token-confidential-transfer-proof-extraction" -version = "0.3.0" +name = "spl-discriminator" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe2629860ff04c17bafa9ba4bed8850a404ecac81074113e1f840dbd0ebb7bd6" +checksum = "d48cc11459e265d5b501534144266620289720b4c44522a47bc6b63cd295d2f3" dependencies = [ "bytemuck", - "solana-account-info", - "solana-curve25519", - "solana-instruction", - "solana-instructions-sysvar", - "solana-msg", - "solana-program-error", - "solana-pubkey", - "solana-sdk-ids", - "solana-zk-sdk", - "spl-pod", - "thiserror 2.0.17", + "solana-program-error 3.0.0", + "solana-sha256-hasher 3.0.0", + "spl-discriminator-derive", ] [[package]] -name = "spl-token-confidential-transfer-proof-generation" +name = "spl-discriminator-derive" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8627184782eec1894de8ea26129c61303f1f0adeed65c20e0b10bc584f09356d" +checksum = "d9e8418ea6269dcfb01c712f0444d2c75542c04448b480e87de59d2865edc750" dependencies = [ - "curve25519-dalek 4.1.3", - "solana-zk-sdk", + "quote", + "spl-discriminator-syn", + "syn 2.0.108", +] + +[[package]] +name = "spl-discriminator-syn" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d1dbc82ab91422345b6df40a79e2b78c7bce1ebb366da323572dd60b7076b67" +dependencies = [ + "proc-macro2", + "quote", + "sha2 0.10.9", + "syn 2.0.108", "thiserror 1.0.69", ] [[package]] -name = "spl-token-confidential-transfer-proof-generation" -version = "0.4.1" +name = "spl-generic-token" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa27b9174bea869a7ebf31e0be6890bce90b1a4288bc2bbf24bd413f80ae3fde" +checksum = "233df81b75ab99b42f002b5cdd6e65a7505ffa930624f7096a7580a56765e9cf" dependencies = [ - "curve25519-dalek 4.1.3", - "solana-zk-sdk", - "thiserror 2.0.17", + "bytemuck", + "solana-pubkey 3.0.0", ] [[package]] -name = "spl-token-group-interface" -version = "0.5.0" +name = "spl-pod" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d595667ed72dbfed8c251708f406d7c2814a3fa6879893b323d56a10bedfc799" +checksum = "b1233fdecd7461611d69bb87bc2e95af742df47291975d21232a0be8217da9de" dependencies = [ + "borsh 1.5.7", "bytemuck", + "bytemuck_derive", "num-derive 0.4.2", "num-traits", - "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-program-error", - "solana-pubkey", - "spl-discriminator", - "spl-pod", - "thiserror 1.0.69", + "num_enum", + "solana-program-error 3.0.0", + "solana-program-option 3.0.0", + "solana-pubkey 3.0.0", + "solana-zk-sdk", + "thiserror 2.0.17", ] [[package]] -name = "spl-token-group-interface" -version = "0.6.0" +name = "spl-token-2022-interface" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5597b4cd76f85ce7cd206045b7dc22da8c25516573d42d267c8d1fd128db5129" +checksum = "0888304af6b3d839e435712e6c84025e09513017425ff62045b6b8c41feb77d9" dependencies = [ + "arrayref", "bytemuck", "num-derive 0.4.2", "num-traits", - "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-program-error", - "solana-pubkey", - "spl-discriminator", + "num_enum", + "solana-account-info 3.0.0", + "solana-instruction 3.0.0", + "solana-program-error 3.0.0", + "solana-program-option 3.0.0", + "solana-program-pack 3.0.0", + "solana-pubkey 3.0.0", + "solana-sdk-ids 3.0.0", + "solana-zk-sdk", "spl-pod", + "spl-token-confidential-transfer-proof-extraction", + "spl-token-confidential-transfer-proof-generation", + "spl-token-group-interface", + "spl-token-metadata-interface", + "spl-type-length-value", "thiserror 2.0.17", ] [[package]] -name = "spl-token-metadata-interface" -version = "0.6.0" +name = "spl-token-confidential-transfer-proof-extraction" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfb9c89dbc877abd735f05547dcf9e6e12c00c11d6d74d8817506cab4c99fdbb" +checksum = "7a22217af69b7a61ca813f47c018afb0b00b02a74a4c70ff099cd4287740bc3d" dependencies = [ - "borsh 1.5.7", - "num-derive 0.4.2", - "num-traits", - "solana-borsh", - "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-program-error", - "solana-pubkey", - "spl-discriminator", + "bytemuck", + "solana-account-info 3.0.0", + "solana-curve25519", + "solana-instruction 3.0.0", + "solana-instructions-sysvar 3.0.0", + "solana-msg 3.0.0", + "solana-program-error 3.0.0", + "solana-pubkey 3.0.0", + "solana-sdk-ids 3.0.0", + "solana-zk-sdk", "spl-pod", - "spl-type-length-value 0.7.0", - "thiserror 1.0.69", + "thiserror 2.0.17", ] [[package]] -name = "spl-token-metadata-interface" -version = "0.7.0" +name = "spl-token-confidential-transfer-proof-generation" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "304d6e06f0de0c13a621464b1fd5d4b1bebf60d15ca71a44d3839958e0da16ee" +checksum = "f63a2b41095945dc15274b924b21ccae9b3ec9dc2fdd43dbc08de8c33bbcd915" dependencies = [ - "borsh 1.5.7", - "num-derive 0.4.2", - "num-traits", - "solana-borsh", - "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-program-error", - "solana-pubkey", - "spl-discriminator", - "spl-pod", - "spl-type-length-value 0.8.0", + "curve25519-dalek", + "solana-zk-sdk", "thiserror 2.0.17", ] [[package]] -name = "spl-transfer-hook-interface" -version = "0.9.0" +name = "spl-token-group-interface" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aa7503d52107c33c88e845e1351565050362c2314036ddf19a36cd25137c043" +checksum = "452d0f758af20caaa10d9a6f7608232e000d4c74462f248540b3d2ddfa419776" dependencies = [ - "arrayref", "bytemuck", "num-derive 0.4.2", "num-traits", - "solana-account-info", - "solana-cpi", - "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-program-error", - "solana-pubkey", + "num_enum", + "solana-instruction 3.0.0", + "solana-program-error 3.0.0", + "solana-pubkey 3.0.0", "spl-discriminator", "spl-pod", - "spl-program-error 0.6.0", - "spl-tlv-account-resolution 0.9.0", - "spl-type-length-value 0.7.0", - "thiserror 1.0.69", + "thiserror 2.0.17", ] [[package]] -name = "spl-transfer-hook-interface" -version = "0.10.0" +name = "spl-token-interface" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7e905b849b6aba63bde8c4badac944ebb6c8e6e14817029cbe1bc16829133bd" +checksum = "8c564ac05a7c8d8b12e988a37d82695b5ba4db376d07ea98bc4882c81f96c7f3" dependencies = [ "arrayref", "bytemuck", "num-derive 0.4.2", "num-traits", - "solana-account-info", - "solana-cpi", - "solana-decode-error", - "solana-instruction", - "solana-msg", - "solana-program-error", - "solana-pubkey", - "spl-discriminator", - "spl-pod", - "spl-program-error 0.7.0", - "spl-tlv-account-resolution 0.10.0", - "spl-type-length-value 0.8.0", + "num_enum", + "solana-instruction 3.0.0", + "solana-program-error 3.0.0", + "solana-program-option 3.0.0", + "solana-program-pack 3.0.0", + "solana-pubkey 3.0.0", + "solana-sdk-ids 3.0.0", "thiserror 2.0.17", ] [[package]] -name = "spl-type-length-value" -version = "0.7.0" +name = "spl-token-metadata-interface" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba70ef09b13af616a4c987797870122863cba03acc4284f226a4473b043923f9" +checksum = "9c467c7c3bd056f8fe60119e7ec34ddd6f23052c2fa8f1f51999098063b72676" dependencies = [ - "bytemuck", + "borsh 1.5.7", "num-derive 0.4.2", "num-traits", - "solana-account-info", - "solana-decode-error", - "solana-msg", - "solana-program-error", + "solana-borsh 3.0.0", + "solana-instruction 3.0.0", + "solana-program-error 3.0.0", + "solana-pubkey 3.0.0", "spl-discriminator", "spl-pod", - "thiserror 1.0.69", + "spl-type-length-value", + "thiserror 2.0.17", ] [[package]] name = "spl-type-length-value" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d417eb548214fa822d93f84444024b4e57c13ed6719d4dcc68eec24fb481e9f5" +checksum = "ca20a1a19f4507a98ca4b28ff5ed54cac9b9d34ed27863e2bde50a3238f9a6ac" dependencies = [ "bytemuck", "num-derive 0.4.2", "num-traits", - "solana-account-info", - "solana-decode-error", - "solana-msg", - "solana-program-error", + "num_enum", + "solana-account-info 3.0.0", + "solana-msg 3.0.0", + "solana-program-error 3.0.0", "spl-discriminator", "spl-pod", "thiserror 2.0.17", @@ -11205,6 +11015,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unit-prefix" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817" + [[package]] name = "universal-hash" version = "0.5.1" @@ -11215,6 +11031,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "unreachable" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56" +dependencies = [ + "void", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -11387,6 +11212,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + [[package]] name = "vsimd" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 87dc89631..67733a2c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,15 +62,17 @@ sha2 = { version = "0.10" } sha3 = { version = "0.10" } dashmap = { version = "6.1" } actix-governor = "0.8" -solana-sdk = { version = "2.2" } -solana-client = { version = "2.2" } -spl-token = { version = "8" } -spl-token-2022 = { version = "8" } +solana-sdk = { version = "3" } +solana-client = { version = "3" } +solana-program = { version = "2" } +solana-commitment-config = { version = "3" } +spl-token-interface = { version = "2" } +spl-token-2022-interface = { version = "2" } mpl-token-metadata = { version = "5.1" } sysinfo = "0.36" bincode = { version = "1.3" } bs58 = "0.5" -spl-associated-token-account = "6.0.0" +spl-associated-token-account-interface = "2" itertools = "0.14.0" validator = { version = "0.20", features = ["derive"] } vaultrs = { version = "0.7.4" } @@ -90,7 +92,7 @@ http = { version = "1.3.1" } pem = { version = "3" } simple_asn1 = { version = "0.6" } k256 = { version = "0.13", features = ["ecdsa-core"]} -solana-system-interface = { version = "1.0.0", features = ["bincode"] } +solana-system-interface = { version = "2.0.0", features = ["bincode"] } cdp-sdk = "0.1.0" reqwest-middleware = { version = "0.4.2", default-features = false, features = ["json"] } diff --git a/docs/solana.mdx b/docs/solana.mdx index 0ca27f94a..32a860754 100644 --- a/docs/solana.mdx +++ b/docs/solana.mdx @@ -30,25 +30,19 @@ Example Solana network configurations: "networks": [ { "type": "solana", - "network": "solana-mainnet", + "network": "mainnet-beta", "rpc_urls": ["https://api.mainnet-beta.solana.com"], "explorer_urls": ["https://explorer.solana.com"], - "is_testnet": false, - "tags": ["mainnet", "solana"] + "average_blocktime_ms": 400, + "is_testnet": false }, { + "from": "mainnet-beta", "type": "solana", - "network": "solana-devnet", - "rpc_urls": ["https://api.devnet.solana.com"], - "explorer_urls": ["https://explorer.solana.com?cluster=devnet"], - "is_testnet": true, - "tags": ["devnet", "solana"] - }, - { - "type": "solana", - "network": "solana-custom", - "rpc_urls": ["https://your-custom-solana-rpc.example.com"], - "tags": ["custom", "solana"] + "network": "testnet", + "rpc_urls": ["https://api.testnet.solana.com"], + "explorer_urls": ["https://explorer.solana.com?cluster=testnet"], + "is_testnet": true } ] } @@ -81,11 +75,11 @@ Key prerequisites: * Redis * Docker (optional) -Example configuration for a Solana relayer: +Example configuration for a Solana relayer with **user fee payment** (default): ```json { - "id": "solana-example", - "name": "Solana Example", + "id": "solana-user-pays", + "name": "Solana User Pays Fees", "network": "devnet", "paused": false, "notification_id": "notification-example", @@ -99,7 +93,7 @@ Example configuration for a Solana relayer: "fee_payment_strategy": "user", "min_balance": 0, "allowed_tokens": [ - { "mint": "So111...", "max_allowed_fee": 100000000 } + { "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "max_allowed_fee": 100000000 } ], "swap_config": { "strategy": "jupiter-swap", @@ -115,18 +109,71 @@ Example configuration for a Solana relayer: } ``` -For more configuration examples, visit the [OpenZeppelin Relayer examples repository, window=_blank](https://github.com/OpenZeppelin/openzeppelin-relayer/tree/main/examples). +Example configuration for a Solana relayer with **relayer fee payment**: +```json +{ + "id": "solana-relayer-pays", + "name": "Solana Relayer Pays Fees", + "network": "devnet", + "paused": false, + "notification_id": "notification-example", + "signer_id": "local-signer", + "network_type": "solana", + "custom_rpc_urls": [ + { "url": "https://primary-solana-rpc.example.com", "weight": 100 }, + { "url": "https://backup-solana-rpc.example.com", "weight": 100 } + ], + "policies": { + "fee_payment_strategy": "relayer", + "min_balance": 100000000 + } +} +``` + +For more configuration examples, visit the [OpenZeppelin Relayer examples repository](https://github.com/OpenZeppelin/openzeppelin-relayer/tree/main/examples). ## Configuration -### Relayer Policies +### Fee Payment Strategies + +Solana relayers support two distinct fee payment strategies that determine who pays for transaction fees and how transactions are processed. + +| Feature | User Pays (Default) | Relayer Pays | +|---------|---------------------|--------------| +| **Who pays fees** | Users pay in SPL tokens | Relayer pays in SOL | +| **Transaction format** | Pre-signed transactions only | Encoded transactions OR instruction arrays | +| **Best endpoint** | RPC methods (Paymaster spec) | REST API `POST /transactions` | +| **Use case** | Gasless UX, user-controlled signing | Simplified UX, backend automation | + +#### User Pays Fees (Default) + +**fee_payment_strategy**: `user` (default when not specified) + +In this mode: +* Users pay transaction fees in SPL tokens +* The relayer receives fee payment from the user as part of the transaction +* Users submit **fully-formed, pre-signed transactions** via the API +* The relayer validates and relays the transaction to the network -In addition to standard relayer configuration and policies, Solana relayers support additional options: +#### Relayer Pays Fees -* `fee_payment_strategy`: `"user"` or `"relayer"` (who pays transaction fees). "user" is default value. - * `"user"`: Users pay transaction fees in tokens (relayer receives fee payment from user) - * `"relayer"`: ***Relayer pays for all transaction fees*** using SOL from the relayer’s account -* `allowed_tokens`: List of SPL tokens supported for swaps and fee payments. Restrict relayer operations to specific tokens. Optional. +**fee_payment_strategy**: `relayer` + +In this mode: +* ***The relayer pays all transaction fees in SOL*** +* Users can submit either: + * **Encoded transactions** (base64-encoded, pre-signed) + * **Array of instructions** (relayer builds and signs the full transaction) + +**Use Cases**: +* Simplified user experience (no fee token management) +* Backend-controlled transaction execution +* Automated workflows and scheduled operations +* Applications where the relayer subsidizes transaction costs + +### Additional Relayer Policies + +* `allowed_tokens`: List of SPL tokens supported for swaps and fee payments. Optional. * ***When not set or empty, all tokens are allowed*** for transactions and fee payments * When configured, only tokens in this list can be used for transfers and fee payments * `allowed_programs`, `allowed_accounts`, `disallowed_accounts`: Restrict relayer operations to specific programs/accounts @@ -150,7 +197,7 @@ You can check all options in [User Documentation - Relayers](/relayer#3_relayers ## Automated Token Swaps -The relayer can perform automated token swaps on Solana when user fee_payment_strategy is used for relayer using: +The relayer can perform automated token swaps on Solana when `user` fee_payment_strategy is set for relayer using: * ***jupiter-swap*** – via the Jupiter Swap API * ***jupiter-ultra*** – via the Jupiter Ultra API @@ -162,48 +209,90 @@ Swaps can be set to work as: ## API Reference -The Solana API conforms to the [Paymaster spec, window=_blank](https://docs.google.com/document/d/1lweO5WH12QJaSAu5RG_wUistyk_nFeT6gy1CdvyCEHg/edit?tab=t.0#heading=h.4yldgprkuvav). +The Solana relayer provides two API interfaces: -Common endpoints: -- `POST /api/v1/relayers//rpc` - Methods: +1. **RPC Endpoint** (JSON-RPC 2.0) - Conforms to the [Paymaster spec](https://docs.google.com/document/d/1lweO5WH12QJaSAu5RG_wUistyk_nFeT6gy1CdvyCEHg/edit?tab=t.0#heading=h.4yldgprkuvav) + * **Best for**: User fee payment mode + * **URL**: `POST /api/v1/relayers//rpc` + * **Works in**: Both fee payment modes -* `feeEstimate`, -* `prepareTransaction`, -* `transferTransaction`, -* `signTransaction`, -* `signAndSendTransaction`, -* `getSupportedTokens` -* `getSupportedFeatures` +2. **REST Endpoint** - Direct transaction submission + * **Best for**: Relayer fee payment mode + * **URL**: `POST /api/v1/relayers//transactions` + * **Works in**: Relayer fee payment mode only - +### RPC Endpoint (JSON-RPC 2.0) + +**Base URL**: `POST /api/v1/relayers//rpc` +This endpoint can be used in **both fee payment modes**, but provides the best experience for **user fee payment mode**. + +#### RPC Methods + +* `feeEstimate` - Estimate transaction fees +* `getSupportedTokens` - Get list of supported tokens +* `getSupportedFeatures` - Get list of available features +* `prepareTransaction` - Prepare transaction with fee payments +* `transferTransaction` - Execute token transfers with fee payments +* `signTransaction` - Sign user-provided transactions +* `signAndSendTransaction` - Sign and relay user transactions + +For complete RPC examples, see the [SDK Solana examples](https://github.com/OpenZeppelin/openzeppelin-relayer-sdk/tree/main/examples/relayers/solana). + + + ***Fee Token Parameter Behavior:*** -When using `fee_payment_strategy: "relayer"`, the `fee_token` parameter in RPC methods becomes ***informational only***. The relayer pays all transaction fees in SOL regardless of the specified fee token. In this mode, you can use either `"So11111111111111111111111111111112"` (WSOL) or `"11111111111111111111111111111111"` (native SOL) as the fee_token value. +**In `fee_payment_strategy: "relayer"` mode:** +* The `fee_token` parameter in RPC methods becomes ***informational only*** +* The relayer pays all transaction fees in SOL regardless of the specified fee token +* You can use either `"So11111111111111111111111111111112"` (WSOL) or `"11111111111111111111111111111111"` (native SOL) as the fee_token value -When using `fee_payment_strategy: "user"`, the `fee_token` parameter determines which token the user will pay fees in, and must be a supported token from the `allowed_tokens` list (if configured). +**In `fee_payment_strategy: "user"` mode:** +* The `fee_token` parameter determines which token the user will pay fees in +* Must be a supported token from the `allowed_tokens` list (if configured) +* The relayer validates and processes fee payments in the specified token -Example: Estimate fee for a transaction -```bash -curl --location --request POST 'http://localhost:8080/api/v1/relayers/solana-example/rpc' \ ---header 'Authorization: Bearer ' \ ---header 'Content-Type: application/json' \ ---data-raw '{ - "jsonrpc": "2.0", - "method": "feeEstimate", - "params": { - "transaction": "", - "fee_token": "" - }, - "id": 1 -}' -``` +### REST Endpoint (Transaction Submission) + +**Base URL**: `POST /api/v1/relayers//transactions` + +**Only available when `fee_payment_strategy: "relayer"`** + +This endpoint provides two ways to submit transactions: + +#### Option 1: Encoded Transaction (Pre-signed) + +Submit a base64-encoded, pre-signed transaction that the relayer will relay to the network. + +#### Option 2: Array of Instructions (Relayer Builds Transaction) + +Submit an array of Solana instructions. The relayer will: +1. Build a complete transaction from the provided instructions +2. Add necessary compute budget and priority fee instructions +3. Sign the transaction with the relayer's signer +4. Submit the transaction to the Solana network + +For complete REST API examples with both options, see the [SDK Solana examples](https://github.com/OpenZeppelin/openzeppelin-relayer-sdk/tree/main/examples/relayers/solana). + +### API Summary + +**For User Fee Payment Mode** (`fee_payment_strategy: "user"`): +* Use RPC endpoint: `POST /api/v1/relayers//rpc` +* Full Paymaster specification support +* Methods: `feeEstimate`, `prepareTransaction`, `signAndSendTransaction`, etc. + +**For Relayer Fee Payment Mode** (`fee_payment_strategy: "relayer"`): +* Use REST endpoint: `POST /api/v1/relayers//transactions` +* Accepts encoded transactions or instruction arrays +* Alternative: RPC endpoint also works but REST is recommended -See [API Reference](https://release-v1-0-0%2D%2Dopenzeppelin-relayer.netlify.app/api_docs.html) and [SDK examples, window=_blank](https://github.com/OpenZeppelin/openzeppelin-relayer-sdk/tree/main/examples/solana) for full details and examples. +**Additional Resources**: +* [API Reference Documentation](https://release-v1-0-0%2D%2Dopenzeppelin-relayer.netlify.app/api_docs.html) - Complete API specification +* [SDK Solana Examples](https://github.com/OpenZeppelin/openzeppelin-relayer-sdk/tree/main/examples/relayers/solana) - Working code examples for both RPC and REST endpoints ## Security diff --git a/examples/solana-cdp-signer/config/config.json b/examples/solana-cdp-signer/config/config.json index 1d7c5ff66..f9c922e4c 100644 --- a/examples/solana-cdp-signer/config/config.json +++ b/examples/solana-cdp-signer/config/config.json @@ -6,7 +6,10 @@ "network": "devnet", "paused": false, "signer_id": "cdp-signer-solana", - "network_type": "solana" + "network_type": "solana", + "policies": { + "fee_payment_strategy": "relayer" + } } ], "notifications": [ diff --git a/examples/stellar-gcp-kms-signer/config/config.json b/examples/stellar-gcp-kms-signer/config/config.json index 65461c5c0..4b3be4ff0 100644 --- a/examples/stellar-gcp-kms-signer/config/config.json +++ b/examples/stellar-gcp-kms-signer/config/config.json @@ -8,7 +8,8 @@ "signer_id": "gcp-kms-signer-stellar", "network_type": "stellar", "policies": { - "min_balance": 0 + "min_balance": 0, + "fee_payment_strategy": "relayer" } } ], diff --git a/helpers/test_tx.rs b/helpers/test_tx.rs index 34b52cc9b..873636dfe 100644 --- a/helpers/test_tx.rs +++ b/helpers/test_tx.rs @@ -8,7 +8,7 @@ use eyre::Result; use solana_client::rpc_client::RpcClient; use solana_sdk::{hash::Hash, message::Message, pubkey::Pubkey, transaction::Transaction}; use solana_system_interface::instruction; -use spl_token::instruction as token_instruction; +use spl_token_interface::instruction as token_instruction; use std::str::FromStr; #[tokio::main] @@ -97,7 +97,7 @@ fn create_token_transfer( recent_blockhash: Hash, ) -> Result { let ix = token_instruction::transfer( - &spl_token::id(), + &spl_token_interface::id(), token_account, recipient_token_account, payer, diff --git a/openapi.json b/openapi.json index 6390bfbdc..e708a395a 100644 --- a/openapi.json +++ b/openapi.json @@ -3834,12 +3834,7 @@ } }, { - "type": "array", - "items": { - "type": "integer", - "format": "int32", - "minimum": 0 - } + "$ref": "#/components/schemas/SignTransactionResponseSolana" } ] }, @@ -6352,12 +6347,7 @@ } }, { - "type": "array", - "items": { - "type": "integer", - "format": "int32", - "minimum": 0 - } + "$ref": "#/components/schemas/SignTransactionRequestSolana" } ] }, @@ -6373,6 +6363,17 @@ }, "additionalProperties": false }, + "SignTransactionRequestSolana": { + "type": "object", + "required": [ + "transaction" + ], + "properties": { + "transaction": { + "$ref": "#/components/schemas/EncodedSerializedTransaction" + } + } + }, "SignTransactionRequestStellar": { "type": "object", "required": [ @@ -6398,15 +6399,25 @@ } }, { - "type": "array", - "items": { - "type": "integer", - "format": "int32", - "minimum": 0 - } + "$ref": "#/components/schemas/SignTransactionResponseSolana" } ] }, + "SignTransactionResponseSolana": { + "type": "object", + "required": [ + "transaction", + "signature" + ], + "properties": { + "signature": { + "type": "string" + }, + "transaction": { + "$ref": "#/components/schemas/EncodedSerializedTransaction" + } + } + }, "SignTransactionResponseStellar": { "type": "object", "required": [ @@ -6690,6 +6701,29 @@ "description": "Request model for updating an existing signer\nAt the moment, we don't allow updating signers", "additionalProperties": false }, + "SolanaAccountMeta": { + "type": "object", + "description": "Account metadata for a Solana instruction", + "required": [ + "pubkey", + "is_signer", + "is_writable" + ], + "properties": { + "is_signer": { + "type": "boolean", + "description": "Whether the account is a signer" + }, + "is_writable": { + "type": "boolean", + "description": "Whether the account is writable" + }, + "pubkey": { + "type": "string", + "description": "Account public key (base58-encoded)" + } + } + }, "SolanaAllowedTokensPolicy": { "type": "object", "description": "Configuration for allowed token handling on Solana", @@ -6751,12 +6785,38 @@ }, "SolanaFeePaymentStrategy": { "type": "string", - "description": "Solana fee payment strategy", + "description": "Solana fee payment strategy\n\nDetermines who pays transaction fees:\n- `User`: User must include fee payment to relayer in transaction (for custom RPC methods)\n- `Relayer`: Relayer pays all transaction fees (recommended for send transaction endpoint)\n\nDefault is `User`.", "enum": [ "user", "relayer" ] }, + "SolanaInstructionSpec": { + "type": "object", + "description": "Specification for a Solana instruction", + "required": [ + "program_id", + "accounts", + "data" + ], + "properties": { + "accounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SolanaAccountMeta" + }, + "description": "Account metadata for the instruction" + }, + "data": { + "type": "string", + "description": "Instruction data (base64-encoded)" + }, + "program_id": { + "type": "string", + "description": "Program ID (base58-encoded pubkey)" + } + } + }, "SolanaPolicyResponse": { "type": "object", "description": "Solana policy response model for OpenAPI documentation", @@ -7160,12 +7220,34 @@ }, "SolanaTransactionRequest": { "type": "object", - "required": [ - "transaction" - ], "properties": { + "instructions": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/SolanaInstructionSpec" + }, + "description": "Instructions to build transaction from (mutually exclusive with transaction)" + }, "transaction": { - "$ref": "#/components/schemas/EncodedSerializedTransaction" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/EncodedSerializedTransaction", + "description": "Pre-built base64-encoded transaction (mutually exclusive with instructions)" + } + ] + }, + "valid_until": { + "type": [ + "string", + "null" + ], + "description": "Optional RFC3339 timestamp when transaction should expire" } } }, @@ -7187,6 +7269,12 @@ "id": { "type": "string" }, + "instructions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SolanaInstructionSpec" + } + }, "sent_at": { "type": "string" }, diff --git a/src/constants/mod.rs b/src/constants/mod.rs index 5471e11fa..51dd18e5c 100644 --- a/src/constants/mod.rs +++ b/src/constants/mod.rs @@ -17,6 +17,9 @@ pub use evm_transaction::*; mod stellar_transaction; pub use stellar_transaction::*; +mod solana_transaction; +pub use solana_transaction::*; + mod public_endpoints; pub use public_endpoints::*; diff --git a/src/constants/solana_transaction.rs b/src/constants/solana_transaction.rs new file mode 100644 index 000000000..6672556e5 --- /dev/null +++ b/src/constants/solana_transaction.rs @@ -0,0 +1,56 @@ +//! Constants for Solana transaction processing. +//! +//! This module contains default values used throughout the Solana transaction +//! handling logic, including validation limits, status check delays, and timeout thresholds. + +use chrono::Duration; + +/// Default transaction valid timespan for Solana when no explicit valid_until is provided (in milliseconds) +/// Set to 30 minutes to balance between: +/// - Preventing infinite resubmission loops +/// - Allowing reasonable time for transaction processing during network congestion +/// - Aligning with Solana's fast finality expectations +pub const SOLANA_DEFAULT_TX_VALID_TIMESPAN: i64 = 30 * 60 * 1000; // 30 minutes in milliseconds + +// API request validation limits +/// Maximum number of instructions allowed in a transaction request +pub const REQUEST_MAX_INSTRUCTIONS: usize = 64; + +/// Maximum number of accounts allowed per instruction in a request +pub const REQUEST_MAX_ACCOUNTS_PER_INSTRUCTION: usize = 64; + +/// Maximum total unique accounts allowed in a transaction request +pub const REQUEST_MAX_TOTAL_ACCOUNTS: usize = 64; + +/// Maximum instruction data size in bytes allowed in a request +pub const REQUEST_MAX_INSTRUCTION_DATA_SIZE: usize = 1232; + +// Status check scheduling +/// Initial delay before first status check (in seconds) +pub const SOLANA_STATUS_CHECK_INITIAL_DELAY_SECONDS: i64 = 5; + +/// Minimum age before checking for resubmit/expiration (in seconds) +/// If transaction is younger than this, we don't check blockhash expiration yet +pub const SOLANA_MIN_AGE_FOR_RESUBMIT_CHECK_SECONDS: i64 = 90; + +/// Minimum age before triggering Pending status recovery (in seconds) +/// Only schedule a recovery job if Pending transaction exceeds this age +pub const SOLANA_PENDING_RECOVERY_TRIGGER_SECONDS: i64 = 20; + +/// Timeout for Pending status: transaction preparation phase (in minutes) +/// If a transaction stays in Pending for longer than this, mark as Failed +pub const SOLANA_PENDING_TIMEOUT_MINUTES: i64 = 3; + +/// Timeout for Sent status: waiting for submission (in minutes) +/// If a transaction stays in Sent for longer than this, mark as Failed +pub const SOLANA_SENT_TIMEOUT_MINUTES: i64 = 3; + +/// Maximum number of transaction resubmission attempts before marking as Failed +/// Each attempt creates a new signature (when blockhash expires and tx is resubmitted) +/// Similar to EVM's MAXIMUM_TX_ATTEMPTS but tailored for Solana's resubmission behavior +pub const MAXIMUM_SOLANA_TX_ATTEMPTS: usize = 20; + +/// Get status check initial delay duration +pub fn get_solana_status_check_initial_delay() -> Duration { + Duration::seconds(SOLANA_STATUS_CHECK_INITIAL_DELAY_SECONDS) +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index b88188f1c..77d38b1f9 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -6,8 +6,8 @@ //! * Relayer management //! * Network-specific implementations -mod relayer; +pub mod relayer; pub use relayer::*; -mod transaction; +pub mod transaction; pub use transaction::*; diff --git a/src/domain/relayer/mod.rs b/src/domain/relayer/mod.rs index 260746b69..dd98d52ce 100644 --- a/src/domain/relayer/mod.rs +++ b/src/domain/relayer/mod.rs @@ -19,11 +19,12 @@ use mockall::automock; use crate::{ jobs::JobProducerTrait, models::{ - AppState, DecoratedSignature, DeletePendingTransactionsResponse, EvmNetwork, - EvmTransactionDataSignature, JsonRpcRequest, JsonRpcResponse, NetworkRepoModel, - NetworkRpcRequest, NetworkRpcResult, NetworkTransactionRequest, NetworkType, - NotificationRepoModel, RelayerError, RelayerRepoModel, RelayerStatus, SignerRepoModel, - StellarNetwork, TransactionError, TransactionRepoModel, + AppState, DecoratedSignature, DeletePendingTransactionsResponse, + EncodedSerializedTransaction, EvmNetwork, EvmTransactionDataSignature, JsonRpcRequest, + JsonRpcResponse, NetworkRepoModel, NetworkRpcRequest, NetworkRpcResult, + NetworkTransactionRequest, NetworkType, NotificationRepoModel, RelayerError, + RelayerRepoModel, RelayerStatus, SignerRepoModel, StellarNetwork, TransactionError, + TransactionRepoModel, }, repositories::{ ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, @@ -193,58 +194,6 @@ pub trait SolanaRelayerDexTrait { ) -> Result, RelayerError>; } -/// Solana Relayer Trait -/// Subset of methods for Solana relayer -#[async_trait] -#[allow(dead_code)] -#[cfg_attr(test, automock)] -pub trait SolanaRelayerTrait { - /// Retrieves the current balance of the relayer. - /// - /// # Returns - /// - /// A `Result` containing a `BalanceResponse` on success, or a - /// `RelayerError` on failure. - async fn get_balance(&self) -> Result; - - /// Executes a JSON-RPC request. - /// - /// # Arguments - /// - /// * `request` - The JSON-RPC request to be executed. - /// - /// # Returns - /// - /// A `Result` containing a `JsonRpcResponse` on success, or a - /// `RelayerError` on failure. - async fn rpc( - &self, - request: JsonRpcRequest, - ) -> Result, RelayerError>; - - /// Runs health checks on the relayer without side effects. - /// - /// # Returns - /// - /// * `Ok(())` - All health checks passed - /// * `Err(Vec)` - One or more health checks failed - async fn check_health(&self) -> Result<(), Vec>; - - /// Initializes the relayer. - /// - /// # Returns - /// - /// A `Result` indicating success, or a `RelayerError` on failure. - async fn initialize_relayer(&self) -> Result<(), RelayerError>; - - /// Validates that the relayer's balance meets the minimum required. - /// - /// # Returns - /// - /// A `Result` indicating success, or a `RelayerError` on failure. - async fn validate_min_balance(&self) -> Result<(), RelayerError>; -} - pub enum NetworkRelayer< J: JobProducerTrait + 'static, T: TransactionRepository + Repository + Send + Sync + 'static, @@ -272,7 +221,9 @@ impl< ) -> Result { match self { NetworkRelayer::Evm(relayer) => relayer.process_transaction_request(tx_request).await, - NetworkRelayer::Solana(_) => solana_not_supported_relayer(), + NetworkRelayer::Solana(relayer) => { + relayer.process_transaction_request(tx_request).await + } NetworkRelayer::Stellar(relayer) => { relayer.process_transaction_request(tx_request).await } @@ -330,7 +281,7 @@ impl< async fn get_status(&self) -> Result { match self { NetworkRelayer::Evm(relayer) => relayer.get_status().await, - NetworkRelayer::Solana(_) => solana_not_supported_relayer(), + NetworkRelayer::Solana(relayer) => relayer.get_status().await, NetworkRelayer::Stellar(relayer) => relayer.get_status().await, } } @@ -367,9 +318,7 @@ impl< NetworkRelayer::Evm(_) => Err(RelayerError::NotSupported( "sign_transaction not supported for EVM".to_string(), )), - NetworkRelayer::Solana(_) => Err(RelayerError::NotSupported( - "sign_transaction not supported for Solana".to_string(), - )), + NetworkRelayer::Solana(relayer) => relayer.sign_transaction(request).await, NetworkRelayer::Stellar(relayer) => relayer.sign_transaction(request).await, } } @@ -549,26 +498,37 @@ pub struct SignTransactionRequestStellar { pub unsigned_xdr: String, } +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct SignTransactionRequestSolana { + pub transaction: EncodedSerializedTransaction, +} + #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(untagged)] pub enum SignTransactionRequest { Stellar(SignTransactionRequestStellar), Evm(Vec), - Solana(Vec), + Solana(SignTransactionRequestSolana), } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct SignTransactionResponseEvm { pub hash: String, pub signature: EvmTransactionDataSignature, pub raw: Vec, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct SignTransactionResponseStellar { pub signature: DecoratedSignature, } +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] +pub struct SignTransactionResponseSolana { + pub transaction: EncodedSerializedTransaction, + pub signature: String, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SignXdrTransactionResponseStellar { @@ -576,10 +536,10 @@ pub struct SignXdrTransactionResponseStellar { pub signature: DecoratedSignature, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub enum SignTransactionResponse { Evm(SignTransactionResponseEvm), - Solana(Vec), + Solana(SignTransactionResponseSolana), Stellar(SignTransactionResponseStellar), } @@ -597,7 +557,7 @@ pub struct SignTransactionExternalResponseStellar { pub enum SignTransactionExternalResponse { Stellar(SignTransactionExternalResponseStellar), Evm(Vec), - Solana(Vec), + Solana(SignTransactionResponseSolana), } impl SignTransactionResponse { diff --git a/src/domain/relayer/solana/rpc/methods/fee_estimate.rs b/src/domain/relayer/solana/rpc/methods/fee_estimate.rs index b0ad89f67..435a0a912 100644 --- a/src/domain/relayer/solana/rpc/methods/fee_estimate.rs +++ b/src/domain/relayer/solana/rpc/methods/fee_estimate.rs @@ -21,10 +21,8 @@ use std::str::FromStr; use futures::try_join; -use solana_sdk::{ - commitment_config::CommitmentConfig, pubkey::Pubkey, signature::Signature, - transaction::Transaction, -}; +use solana_commitment_config::CommitmentConfig; +use solana_sdk::{pubkey::Pubkey, signature::Signature, transaction::Transaction}; use tracing::info; use crate::{ @@ -230,7 +228,7 @@ mod tests { use super::*; use mockall::predicate::{self}; use solana_sdk::{hash::Hash, program_pack::Pack, signer::Signer}; - use spl_token::state::Account; + use spl_token_interface::state::Account; #[tokio::test] async fn test_fee_estimate_with_allowed_token_relayer_fee_strategy() { @@ -348,59 +346,62 @@ mod tests { if pubkey == ctx.relayer_token_account { // Create relayer's token account - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: ctx.token_mint, owner: relayer_pubkey, amount: 0, // Current balance doesn't matter - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) } else if pubkey == ctx.user_token_account { // Create user's token account with sufficient balance - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: ctx.token_mint, owner: user_pubkey, amount: ctx.main_transfer_amount + ctx.fee_amount, // Enough for both transfers - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) } else if pubkey == ctx.payer_token_account { // Create payers's token account with sufficient balance - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: ctx.token_mint, owner: payer_pubkey, amount: ctx.main_transfer_amount + ctx.fee_amount, // Enough for both transfers - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) } else if pubkey == ctx.token_mint { - let mut mint_data = vec![0; spl_token::state::Mint::LEN]; - let mint = spl_token::state::Mint { + let mut mint_data = vec![0; spl_token_interface::state::Mint::LEN]; + let mint = spl_token_interface::state::Mint { is_initialized: true, mint_authority: solana_sdk::program_option::COption::Some( Pubkey::new_unique(), @@ -409,12 +410,12 @@ mod tests { decimals: 6, ..Default::default() }; - spl_token::state::Mint::pack(mint, &mut mint_data).unwrap(); + spl_token_interface::state::Mint::pack(mint, &mut mint_data).unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: mint_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) diff --git a/src/domain/relayer/solana/rpc/methods/mod.rs b/src/domain/relayer/solana/rpc/methods/mod.rs index f880e67c1..631b82505 100644 --- a/src/domain/relayer/solana/rpc/methods/mod.rs +++ b/src/domain/relayer/solana/rpc/methods/mod.rs @@ -12,7 +12,6 @@ mod sign_and_send_transaction; mod sign_transaction; mod transfer_transaction; mod utils; -mod validations; #[cfg(test)] mod test_setup; @@ -23,7 +22,11 @@ use std::sync::Arc; #[cfg(test)] pub use test_setup::*; -pub use validations::*; + +// Re-export validation types from transaction domain module +pub use crate::domain::transaction::solana::{ + SolanaTransactionValidationError, SolanaTransactionValidator, +}; use crate::{ jobs::{JobProducer, JobProducerTrait}, diff --git a/src/domain/relayer/solana/rpc/methods/prepare_transaction.rs b/src/domain/relayer/solana/rpc/methods/prepare_transaction.rs index 5e323ef58..5482ed079 100644 --- a/src/domain/relayer/solana/rpc/methods/prepare_transaction.rs +++ b/src/domain/relayer/solana/rpc/methods/prepare_transaction.rs @@ -23,10 +23,8 @@ //! * `valid_until_block_height` - The block height until which the transaction remains valid.use //! std::str::FromStr; use futures::try_join; -use solana_sdk::{ - commitment_config::CommitmentConfig, hash::Hash, pubkey::Pubkey, signature::Signature, - transaction::Transaction, -}; +use solana_commitment_config::CommitmentConfig; +use solana_sdk::{hash::Hash, pubkey::Pubkey, signature::Signature, transaction::Transaction}; use std::str::FromStr; use tracing::info; @@ -168,18 +166,28 @@ where .get_latest_blockhash_with_commitment(CommitmentConfig::finalized()) .await?; - // Create new transaction message with relayer as fee payer let mut message = transaction_request.message.clone(); message.recent_blockhash = recent_blockhash.0; - // Update fee payer if needed if message.account_keys[0] != *relayer_pubkey { - message.account_keys[0] = *relayer_pubkey; + // Reconstruct the message with relayer as fee payer + // This properly recalculates num_required_signatures, deduplicates accounts, + // and maintains correct account ordering (signers first, writable before readonly) + message = self + .reconstruct_transaction_with_fee_payer( + &message, + relayer_pubkey, + &recent_blockhash.0, + )? + .message; } - // Create transaction with updated message + // Create transaction with reconstructed message let transaction = Transaction { - signatures: vec![Signature::default()], + signatures: vec![ + Signature::default(); + message.header.num_required_signatures as usize + ], message, }; @@ -230,8 +238,6 @@ async fn validate_prepare_transaction( #[cfg(test)] mod tests { - use std::str::FromStr; - use super::*; use crate::{ constants::WRAPPED_SOL_MINT, @@ -245,8 +251,11 @@ mod tests { hash::Hash, message::Message, program_pack::Pack, signature::Keypair, signer::Signer, }; use solana_system_interface::instruction; - use spl_associated_token_account::get_associated_token_address; - use spl_token::state::Account; + use spl_associated_token_account_interface::address::get_associated_token_address; + use spl_token_interface::state::Account; + use std::str::FromStr; + + use super::super::test_setup::setup_signer_mocks; #[tokio::test] async fn test_prepare_transaction_success_relayer_fee_strategy() { @@ -275,11 +284,8 @@ mod tests { ..Default::default() }); - let signature = Signature::new_unique(); - - signer - .expect_sign() - .returning(move |_| Box::pin(async move { Ok(signature) })); + // Setup signer mocks + setup_signer_mocks(&mut signer, relayer.address.clone()); // Mock provider responses provider @@ -305,6 +311,12 @@ mod tests { inner_instructions: None, replacement_blockhash: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); @@ -350,59 +362,62 @@ mod tests { if pubkey == ctx.relayer_token_account { // Create relayer's token account - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: ctx.token_mint, owner: relayer_pubkey, amount: 10000000, - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) } else if pubkey == ctx.user_token_account { // Create user's token account with sufficient balance - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: ctx.token_mint, owner: user_pubkey, amount: ctx.main_transfer_amount, - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) } else if pubkey == ctx.payer_token_account { // Create payers's token account with sufficient balance - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: ctx.token_mint, owner: payer_pubkey, amount: ctx.main_transfer_amount + ctx.fee_amount, - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) } else if pubkey == ctx.token_mint { - let mut mint_data = vec![0; spl_token::state::Mint::LEN]; - let mint = spl_token::state::Mint { + let mut mint_data = vec![0; spl_token_interface::state::Mint::LEN]; + let mint = spl_token_interface::state::Mint { is_initialized: true, mint_authority: solana_sdk::program_option::COption::Some( Pubkey::new_unique(), @@ -411,12 +426,12 @@ mod tests { decimals: 6, ..Default::default() }; - spl_token::state::Mint::pack(mint, &mut mint_data).unwrap(); + spl_token_interface::state::Mint::pack(mint, &mut mint_data).unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: mint_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -484,15 +499,18 @@ mod tests { inner_instructions: None, replacement_blockhash: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); - let signature = Signature::new_unique(); - ctx.signer.expect_sign().returning(move |_| { - let signature_clone = signature; - Box::pin(async move { Ok(signature_clone) }) - }); + // Setup signer mocks + setup_signer_mocks(&mut ctx.signer, ctx.relayer.address.clone()); let rpc = SolanaRpcMethodsImpl::new_mock( ctx.relayer, @@ -569,6 +587,12 @@ mod tests { inner_instructions: None, replacement_blockhash: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); @@ -618,11 +642,9 @@ mod tests { let message = Message::new(&[ix], Some(&wrong_fee_payer.pubkey())); let transaction = Transaction::new_unsigned(message); let encoded_tx = EncodedSerializedTransaction::try_from(&transaction).unwrap(); - let signature = Signature::new_unique(); - signer - .expect_sign() - .returning(move |_| Box::pin(async move { Ok(signature) })); + // Setup signer mocks + setup_signer_mocks(&mut signer, relayer.address.clone()); provider .expect_get_latest_blockhash_with_commitment() .returning(|_| Box::pin(async { Ok((Hash::new_unique(), 100)) })); @@ -646,6 +668,12 @@ mod tests { inner_instructions: None, replacement_blockhash: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); @@ -682,7 +710,6 @@ mod tests { setup_test_context(); println!("Setting up known keypair for signature verification"); let relayer_keypair = Keypair::new(); - let expected_signature = Signature::new_unique(); relayer.address = relayer_keypair.pubkey().to_string(); relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { @@ -700,10 +727,8 @@ mod tests { ..Default::default() }); - signer.expect_sign().returning(move |_| { - let signature = expected_signature; - Box::pin(async move { Ok(signature) }) - }); + // Setup signer mocks + setup_signer_mocks(&mut signer, relayer.address.clone()); provider .expect_get_latest_blockhash_with_commitment() .returning(|_| Box::pin(async { Ok((Hash::new_unique(), 100)) })); @@ -727,13 +752,22 @@ mod tests { inner_instructions: None, replacement_blockhash: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); - // Create test transaction - let ix = instruction::transfer(&Pubkey::new_unique(), &Pubkey::new_unique(), 1000); - let message = Message::new(&[ix], Some(&relayer_keypair.pubkey())); + // Create test transaction from a user (not relayer) + // prepare_transaction will replace fee payer with relayer and sign with relayer's key + let user = Keypair::new(); + let recipient = Pubkey::new_unique(); + let ix = instruction::transfer(&user.pubkey(), &recipient, 1000); + let message = Message::new(&[ix], Some(&user.pubkey())); let transaction = Transaction::new_unsigned(message); let encoded_tx = EncodedSerializedTransaction::try_from(&transaction).unwrap(); @@ -758,21 +792,209 @@ mod tests { let prepare_result = result.unwrap(); let final_tx = Transaction::try_from(prepare_result.transaction).unwrap(); - // Verify signature presence and correctness + // Verify signature structure + // After reconstruction, the message should properly calculate that we need 2 signatures: + // 1. Relayer (fee payer) + // 2. User (source/owner of transferred funds) assert_eq!( final_tx.signatures.len(), - 1, - "Transaction should have exactly one signature" + final_tx.message.header.num_required_signatures as usize, + "Signatures array should match num_required_signatures" ); assert_eq!( - final_tx.signatures[0], expected_signature, - "Transaction should have the expected signature" + final_tx.message.header.num_required_signatures, 2, + "Transaction should require 2 signatures (relayer as fee payer + user as source)" ); + + // Fee payer (first account) should be relayer assert_eq!( final_tx.message.account_keys[0].to_string(), relayer_keypair.pubkey().to_string(), "Fee payer should match relayer address" ); + + // Relayer signature should be present and not default + assert_ne!( + final_tx.signatures[0].as_ref(), + &[0u8; 64], + "Relayer signature should not be default/empty" + ); + + // User signature slot should exist and remain default (unsigned - user signs later) + assert_eq!( + final_tx.signatures[1].as_ref(), + &[0u8; 64], + "User signature should remain default (unsigned)" + ); + } + + #[tokio::test] + async fn test_prepare_transaction_token_transfer_with_user_as_fee_payer() { + // This test verifies the fix for the critical bug where a user submits a token + // transfer with themselves as both fee payer AND token owner. Before the fix, + // prepare_transaction would incorrectly leave num_required_signatures=1 after + // replacing the fee payer, creating an invalid transaction. + let (mut relayer, mut signer, mut provider, jupiter_service, _, job_producer, network) = + setup_test_context(); + + let relayer_keypair = Keypair::new(); + relayer.address = relayer_keypair.pubkey().to_string(); + relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), + min_balance: Some(100_000_000), + allowed_tokens: Some(vec![ + SolanaAllowedTokensPolicy { + mint: WRAPPED_SOL_MINT.to_string(), + symbol: Some("SOL".to_string()), + decimals: Some(9), + max_allowed_fee: None, + swap_config: Some(SolanaAllowedTokensSwapConfig { + ..Default::default() + }), + }, + SolanaAllowedTokensPolicy { + mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), + symbol: Some("USDC".to_string()), + decimals: Some(6), + max_allowed_fee: None, + swap_config: None, + }, + ]), + ..Default::default() + }); + + // User creates a token transfer where they are BOTH fee payer AND token owner + let user = Keypair::new(); + let token_mint = Pubkey::from_str("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v").unwrap(); + let source_ata = get_associated_token_address(&user.pubkey(), &token_mint); + let dest_ata = get_associated_token_address(&Pubkey::new_unique(), &token_mint); + + let transfer_ix = spl_token_interface::instruction::transfer_checked( + &spl_token_interface::id(), + &source_ata, + &token_mint, + &dest_ata, + &user.pubkey(), // User is the token owner (must sign!) + &[], + 1_000_000, + 6, + ) + .unwrap(); + + // User sets themselves as fee payer too + let message = Message::new(&[transfer_ix], Some(&user.pubkey())); + // At this point: num_required_signatures = 1 (Solana deduplicates user) + + let transaction = Transaction::new_unsigned(message); + let encoded_tx = EncodedSerializedTransaction::try_from(&transaction).unwrap(); + + // Setup mocks + setup_signer_mocks(&mut signer, relayer.address.clone()); + provider + .expect_get_latest_blockhash_with_commitment() + .returning(|_| Box::pin(async { Ok((Hash::new_unique(), 100)) })); + provider + .expect_calculate_total_fee() + .returning(|_| Box::pin(async { Ok(5000u64) })); + provider + .expect_get_balance() + .returning(|_| Box::pin(async { Ok(1_000_000_000) })); + provider.expect_get_account_from_pubkey().returning(|_| { + Box::pin(async { + let mut account_data = vec![0; Account::LEN]; + let token_account = spl_token_interface::state::Account { + mint: Pubkey::from_str("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v").unwrap(), + owner: Pubkey::new_unique(), + amount: 10_000_000, + state: spl_token_interface::state::AccountState::Initialized, + ..Default::default() + }; + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); + + Ok(solana_sdk::account::Account { + lamports: 1_000_000, + data: account_data, + owner: spl_token_interface::id(), + executable: false, + rent_epoch: 0, + }) + }) + }); + provider.expect_simulate_transaction().returning(|_| { + Box::pin(async { + Ok(solana_client::rpc_response::RpcSimulateTransactionResult { + err: None, + logs: None, + accounts: None, + units_consumed: None, + return_data: None, + inner_instructions: None, + replacement_blockhash: None, + loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, + }) + }) + }); + + let rpc = SolanaRpcMethodsImpl::new_mock( + relayer, + network, + Arc::new(provider), + Arc::new(signer), + Arc::new(jupiter_service), + Arc::new(job_producer), + Arc::new(MockTransactionRepository::new()), + ); + + let params = PrepareTransactionRequestParams { + transaction: encoded_tx, + fee_token: WRAPPED_SOL_MINT.to_string(), + }; + + let result = rpc.prepare_transaction(params).await; + assert!(result.is_ok()); + + let prepare_result = result.unwrap(); + let final_tx = Transaction::try_from(prepare_result.transaction).unwrap(); + + // After the fix, the transaction should now have the correct structure: + // - Relayer is fee payer (first signer) + // - User is token owner (second signer) + // - num_required_signatures = 2 + assert_eq!( + final_tx.message.header.num_required_signatures, 2, + "Transaction should require 2 signatures after reconstruction" + ); + assert_eq!( + final_tx.signatures.len(), + 2, + "Signatures array should have 2 slots" + ); + + // Relayer should be fee payer and signed + assert_eq!( + final_tx.message.account_keys[0], + relayer_keypair.pubkey(), + "Relayer should be first account (fee payer)" + ); + assert_ne!( + final_tx.signatures[0].as_ref(), + &[0u8; 64], + "Relayer signature should be present" + ); + + // User signature slot should exist but be default (they sign later) + assert_eq!( + final_tx.signatures[1].as_ref(), + &[0u8; 64], + "User signature should be default (unsigned)" + ); } #[tokio::test] @@ -800,8 +1022,8 @@ mod tests { let recipient = Pubkey::new_unique(); let not_allowed_token = Pubkey::new_unique(); - let ix = spl_token::instruction::transfer( - &spl_token::id(), + let ix = spl_token_interface::instruction::transfer( + &spl_token_interface::id(), &get_associated_token_address(&payer.pubkey(), ¬_allowed_token), &get_associated_token_address(&recipient, ¬_allowed_token), &payer.pubkey(), @@ -826,6 +1048,12 @@ mod tests { inner_instructions: None, replacement_blockhash: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); diff --git a/src/domain/relayer/solana/rpc/methods/sign_and_send_transaction.rs b/src/domain/relayer/solana/rpc/methods/sign_and_send_transaction.rs index a0a723c38..0f3d7d8b6 100644 --- a/src/domain/relayer/solana/rpc/methods/sign_and_send_transaction.rs +++ b/src/domain/relayer/solana/rpc/methods/sign_and_send_transaction.rs @@ -20,7 +20,6 @@ use std::str::FromStr; use chrono::Utc; -use futures::try_join; use solana_sdk::{pubkey::Pubkey, transaction::Transaction}; use tracing::{debug, error}; @@ -93,7 +92,9 @@ where let (signed_transaction, _) = self.relayer_sign_transaction(transaction_request).await?; let network_transaction = NetworkTransactionRequest::Solana(SolanaTransactionRequest { - transaction: params.transaction.clone(), + transaction: Some(params.transaction.clone()), + instructions: None, + valid_until: None, }); let transaction = @@ -152,7 +153,8 @@ where sent_at: Some(Utc::now().to_rfc3339()), network_data: Some(NetworkTransactionData::Solana(SolanaTransactionData { signature: Some(send_signature.to_string()), - transaction: params.transaction.clone().into_inner(), + transaction: Some(params.transaction.clone().into_inner()), + ..Default::default() })), ..Default::default() }; @@ -203,16 +205,28 @@ where } } +/// Validates a fully prepared Solana transaction according to relayer policies. +/// +/// This orchestrates multiple validation checks in parallel for optimal performance. +/// +/// # Validations Performed +/// - **Policy checks**: Allowed/disallowed accounts, programs, signatures, data size, fee payer +/// - **Blockhash validation**: Ensures blockhash is still valid on-chain +/// - **Simulation**: Executes transaction simulation on-chain +/// - **Transfer validation**: Validates lamports and token transfers async fn validate_sign_and_send_transaction( tx: &Transaction, relayer: &RelayerRepoModel, provider: &P, ) -> Result<(), SolanaTransactionValidationError> { + use futures::{try_join, TryFutureExt}; + let policy = &relayer.policies.get_solana_policy(); let relayer_pubkey = Pubkey::from_str(&relayer.address).map_err(|e| { SolanaTransactionValidationError::ValidationError(format!("Invalid relayer address: {}", e)) })?; + // Group all synchronous policy validations together let sync_validations = async { SolanaTransactionValidator::validate_tx_allowed_accounts(tx, policy)?; SolanaTransactionValidator::validate_tx_disallowed_accounts(tx, policy)?; @@ -223,13 +237,13 @@ async fn validate_sign_and_send_transaction(()) }; - // Run all validations concurrently. + // Run all validations concurrently for optimal performance try_join!( sync_validations, SolanaTransactionValidator::validate_blockhash(tx, provider), - SolanaTransactionValidator::simulate_transaction(tx, provider), + SolanaTransactionValidator::simulate_transaction(tx, provider).map_ok(|_| ()), + SolanaTransactionValidator::validate_token_transfers(tx, policy, provider, &relayer_pubkey), SolanaTransactionValidator::validate_lamports_transfers(tx, &relayer_pubkey), - SolanaTransactionValidator::validate_token_transfers(tx, policy, provider, &relayer_pubkey,), )?; Ok(()) @@ -243,10 +257,11 @@ mod tests { utils::mocks::mockutils::create_mock_solana_transaction, }; + use super::super::test_setup::setup_signer_mocks; use super::*; use mockall::predicate::{self}; use solana_sdk::{program_pack::Pack, signature::Signature, signer::Signer}; - use spl_token::state::Account; + use spl_token_interface::state::Account; #[tokio::test] async fn test_sign_and_send_transaction_success_relayer_fee_strategy() { @@ -260,12 +275,8 @@ mod tests { network, ) = setup_test_context(); - let expected_signature = Signature::new_unique(); - - signer.expect_sign().returning(move |_| { - let signature = expected_signature; - Box::pin(async move { Ok(signature) }) - }); + // Setup signer mocks and capture the expected signature + let expected_signature = setup_signer_mocks(&mut signer, relayer.address.clone()); provider .expect_is_blockhash_valid() @@ -291,6 +302,12 @@ mod tests { replacement_blockhash: None, inner_instructions: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); @@ -350,36 +367,38 @@ mod tests { if pubkey == ctx.relayer_token_account { // Create relayer's token account - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: ctx.token_mint, owner: relayer_pubkey, amount: 0, // Current balance doesn't matter - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) } else if pubkey == ctx.user_token_account { // Create user's token account with sufficient balance - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: ctx.token_mint, owner: user_pubkey, amount: ctx.main_transfer_amount + ctx.fee_amount, // Enough for both transfers - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -391,11 +410,8 @@ mod tests { }) }); - let signature = Signature::new_unique(); - ctx.signer.expect_sign().returning(move |_| { - let signature_clone = signature; - Box::pin(async move { Ok(signature_clone) }) - }); + // Setup signer mocks + setup_signer_mocks(&mut ctx.signer, ctx.relayer.address.clone()); ctx.provider .expect_is_blockhash_valid() @@ -460,6 +476,12 @@ mod tests { replacement_blockhash: None, inner_instructions: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); @@ -520,36 +542,38 @@ mod tests { if pubkey == ctx.relayer_token_account { // Create relayer's token account - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: ctx.token_mint, owner: relayer_pubkey, amount: 0, // Current balance doesn't matter - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) } else if pubkey == ctx.user_token_account { // Create user's token account with sufficient balance - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: ctx.token_mint, owner: user_pubkey, amount: 1_000_000, // NOT enough for both transfers - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -561,11 +585,8 @@ mod tests { }) }); - let signature = Signature::new_unique(); - ctx.signer.expect_sign().returning(move |_| { - let signature_clone = signature; - Box::pin(async move { Ok(signature_clone) }) - }); + // Setup signer mocks + setup_signer_mocks(&mut ctx.signer, ctx.relayer.address.clone()); ctx.provider .expect_is_blockhash_valid() @@ -630,6 +651,12 @@ mod tests { replacement_blockhash: None, inner_instructions: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); @@ -693,36 +720,38 @@ mod tests { if pubkey == ctx.relayer_token_account { // Create relayer's token account - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: ctx.token_mint, owner: relayer_pubkey, amount: 0, // Current balance doesn't matter - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) } else if pubkey == ctx.user_token_account { // Create user's token account with sufficient balance - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: ctx.token_mint, owner: user_pubkey, amount: ctx.main_transfer_amount + ctx.fee_amount, // Enough for both transfers - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -734,11 +763,8 @@ mod tests { }) }); - let signature = Signature::new_unique(); - ctx.signer.expect_sign().returning(move |_| { - let signature_clone = signature; - Box::pin(async move { Ok(signature_clone) }) - }); + // Setup signer mocks + setup_signer_mocks(&mut ctx.signer, ctx.relayer.address.clone()); ctx.provider .expect_is_blockhash_valid() @@ -803,6 +829,12 @@ mod tests { replacement_blockhash: None, inner_instructions: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); @@ -847,10 +879,42 @@ mod tests { let (relayer, signer, mut provider, jupiter_service, encoded_tx, job_producer, network) = setup_test_context(); + // Blockhash validation will be triggered for multi-signer transactions + // and will fail, causing validation to error out provider .expect_is_blockhash_valid() .returning(|_, _| Box::pin(async { Ok(false) })); + // Other validations may be called concurrently even if blockhash validation fails + provider.expect_simulate_transaction().returning(|_| { + Box::pin(async { + Ok(solana_client::rpc_response::RpcSimulateTransactionResult { + err: None, + logs: None, + accounts: None, + units_consumed: None, + return_data: None, + inner_instructions: None, + replacement_blockhash: None, + loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, + }) + }) + }); + + provider + .expect_calculate_total_fee() + .returning(|_| Box::pin(async { Ok(1_000_000u64) })); + + provider + .expect_get_balance() + .returning(|_| Box::pin(async { Ok(1_000_000_000u64) })); + let rpc = SolanaRpcMethodsImpl::new_mock( relayer, network, @@ -878,12 +942,8 @@ mod tests { let (relayer, mut signer, mut provider, jupiter_service, encoded_tx, job_producer, network) = setup_test_context(); - let expected_signature = Signature::new_unique(); - - signer.expect_sign().returning(move |_| { - let signature = expected_signature; - Box::pin(async move { Ok(signature) }) - }); + // Setup signer mocks + setup_signer_mocks(&mut signer, relayer.address.clone()); provider .expect_is_blockhash_valid() @@ -936,11 +996,8 @@ mod tests { relayer.notification_id = Some("test-webhook-id".to_string()); - let signature = Signature::new_unique(); - signer.expect_sign().returning(move |_| { - let signature = signature; - Box::pin(async move { Ok(signature) }) - }); + // Setup signer mocks and capture the signature + let signature = setup_signer_mocks(&mut signer, relayer.address.clone()); provider .expect_is_blockhash_valid() @@ -965,6 +1022,12 @@ mod tests { replacement_blockhash: None, inner_instructions: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); diff --git a/src/domain/relayer/solana/rpc/methods/sign_transaction.rs b/src/domain/relayer/solana/rpc/methods/sign_transaction.rs index a8fbb030c..42801c9b4 100644 --- a/src/domain/relayer/solana/rpc/methods/sign_transaction.rs +++ b/src/domain/relayer/solana/rpc/methods/sign_transaction.rs @@ -161,28 +161,20 @@ mod tests { services::{QuoteResponse, RoutePlan, SwapInfo}, }; + use super::super::test_setup::setup_signer_mocks; use super::*; use mockall::predicate::{self}; - use solana_sdk::{ - message::Message, - program_pack::Pack, - signature::{Keypair, Signature}, - signer::Signer, - }; + use solana_sdk::{message::Message, program_pack::Pack, signature::Keypair, signer::Signer}; use solana_system_interface::instruction; - use spl_token::state::Account; + use spl_token_interface::state::Account; #[tokio::test] async fn test_sign_transaction_success_relayer_fee_strategy() { let (relayer, mut signer, mut provider, jupiter_service, encoded_tx, job_producer, network) = setup_test_context(); - let signature = Signature::new_unique(); - - signer.expect_sign().returning(move |_| { - let signature_clone = signature; - Box::pin(async move { Ok(signature_clone) }) - }); + // Setup signer mocks + setup_signer_mocks(&mut signer, relayer.address.clone()); provider .expect_is_blockhash_valid() .with(predicate::always(), predicate::always()) @@ -207,6 +199,12 @@ mod tests { replacement_blockhash: None, inner_instructions: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); @@ -252,36 +250,38 @@ mod tests { if pubkey == ctx.relayer_token_account { // Create relayer's token account - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: ctx.token_mint, owner: relayer_pubkey, amount: 0, // Current balance doesn't matter - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) } else if pubkey == ctx.user_token_account { // Create user's token account with sufficient balance - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: ctx.token_mint, owner: user_pubkey, amount: ctx.main_transfer_amount + ctx.fee_amount, // Enough for both transfers - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -293,11 +293,8 @@ mod tests { }) }); - let signature = Signature::new_unique(); - ctx.signer.expect_sign().returning(move |_| { - let signature_clone = signature; - Box::pin(async move { Ok(signature_clone) }) - }); + // Setup signer mocks + setup_signer_mocks(&mut ctx.signer, ctx.relayer.address.clone()); ctx.provider .expect_is_blockhash_valid() @@ -358,6 +355,12 @@ mod tests { replacement_blockhash: None, inner_instructions: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); @@ -396,12 +399,8 @@ mod tests { let (relayer, mut signer, mut provider, jupiter_service, encoded_tx, job_producer, network) = setup_test_context(); - let signature = Signature::new_unique(); - - signer.expect_sign().returning(move |_| { - let signature_clone = signature; - Box::pin(async move { Ok(signature_clone) }) - }); + // Setup signer mocks + setup_signer_mocks(&mut signer, relayer.address.clone()); provider .expect_is_blockhash_valid() .with(predicate::always(), predicate::always()) @@ -427,6 +426,12 @@ mod tests { replacement_blockhash: None, inner_instructions: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); @@ -477,36 +482,38 @@ mod tests { if pubkey == ctx.relayer_token_account { // Create relayer's token account - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: ctx.token_mint, owner: relayer_pubkey, amount: 0, // Current balance doesn't matter - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) } else if pubkey == ctx.user_token_account { // Create user's token account with sufficient balance - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: ctx.token_mint, owner: user_pubkey, amount: ctx.main_transfer_amount + ctx.fee_amount, // Enough for both transfers - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -518,11 +525,8 @@ mod tests { }) }); - let signature = Signature::new_unique(); - ctx.signer.expect_sign().returning(move |_| { - let signature_clone = signature; - Box::pin(async move { Ok(signature_clone) }) - }); + // Setup signer mocks + setup_signer_mocks(&mut ctx.signer, ctx.relayer.address.clone()); ctx.provider .expect_is_blockhash_valid() @@ -583,6 +587,12 @@ mod tests { replacement_blockhash: None, inner_instructions: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); @@ -678,6 +688,12 @@ mod tests { replacement_blockhash: None, inner_instructions: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); @@ -735,6 +751,12 @@ mod tests { replacement_blockhash: None, inner_instructions: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); @@ -797,6 +819,12 @@ mod tests { replacement_blockhash: None, inner_instructions: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); @@ -897,12 +925,8 @@ mod tests { ..Default::default() }); - let signature = Signature::new_unique(); - - signer.expect_sign().returning(move |_| { - let signature = signature; - Box::pin(async move { Ok(signature) }) - }); + // Setup signer mocks + setup_signer_mocks(&mut signer, relayer.address.clone()); provider .expect_is_blockhash_valid() .with(predicate::always(), predicate::always()) @@ -927,6 +951,12 @@ mod tests { replacement_blockhash: None, inner_instructions: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); @@ -986,6 +1016,12 @@ mod tests { replacement_blockhash: None, inner_instructions: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); @@ -1035,11 +1071,8 @@ mod tests { relayer.notification_id = Some("test-webhook-id".to_string()); - let signature = Signature::new_unique(); - signer.expect_sign().returning(move |_| { - let signature = signature; - Box::pin(async move { Ok(signature) }) - }); + // Setup signer mocks + setup_signer_mocks(&mut signer, relayer.address.clone()); provider .expect_is_blockhash_valid() .returning(|_, _| Box::pin(async { Ok(true) })); @@ -1063,6 +1096,12 @@ mod tests { replacement_blockhash: None, inner_instructions: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }) }) }); diff --git a/src/domain/relayer/solana/rpc/methods/test_setup.rs b/src/domain/relayer/solana/rpc/methods/test_setup.rs index 2ad88f289..198cca3df 100644 --- a/src/domain/relayer/solana/rpc/methods/test_setup.rs +++ b/src/domain/relayer/solana/rpc/methods/test_setup.rs @@ -4,13 +4,13 @@ use solana_sdk::{ transaction::Transaction, }; use solana_system_interface::instruction; -use spl_associated_token_account::get_associated_token_address; +use spl_associated_token_account_interface::address::get_associated_token_address; use std::str::FromStr; use crate::{ jobs::MockJobProducerTrait, models::{ - EncodedSerializedTransaction, NetworkRepoModel, NetworkType, RelayerNetworkPolicy, + Address, EncodedSerializedTransaction, NetworkRepoModel, NetworkType, RelayerNetworkPolicy, RelayerRepoModel, RelayerSolanaPolicy, SolanaAllowedTokensPolicy, SolanaAllowedTokensSwapConfig, SolanaFeePaymentStrategy, }, @@ -21,6 +21,29 @@ use crate::{ utils::mocks::mockutils::create_mock_solana_network, }; +/// Sets up common mock expectations for `pubkey()` and `sign()` that all SDK transaction signing needs +/// Returns the signature that will be used by the mock, so tests can assert on it +pub fn setup_signer_mocks( + signer: &mut MockSolanaSignTrait, + relayer_address: String, +) -> solana_sdk::signature::Signature { + // Generate a unique signature for this test + let signature = solana_sdk::signature::Signature::new_unique(); + + // Mock pubkey() to return the relayer's address + signer.expect_pubkey().returning(move || { + let addr = relayer_address.clone(); + Box::pin(async move { Ok(Address::Solana(addr)) }) + }); + + // Mock sign() to return the generated signature + signer + .expect_sign() + .returning(move |_| Box::pin(async move { Ok(signature) })); + + signature +} + /// Creates a test context for Solana RPC methods /// It includes a test transaction, relayer, and mock services /// Used for testing methods with relayer fee strategy @@ -123,8 +146,8 @@ pub fn setup_test_context_relayer_fee_strategy() -> RelayerFeeStrategyTestContex let main_transfer_amount = 5_000_000u64; // Main transfer amount (5 USDC) - let main_transfer_ix = spl_token::instruction::transfer_checked( - &spl_token::id(), // Token program ID + let main_transfer_ix = spl_token_interface::instruction::transfer_checked( + &spl_token_interface::id(), // Token program ID &source_token_account, // Source token account &token_mint, // Token mint &destination_token_account, // Destination token account @@ -239,8 +262,8 @@ pub fn setup_test_context_user_fee_strategy() -> UserFeeStrategyTestContext { let main_transfer_amount = 5_000_000u64; // Main transfer amount (5 USDC) let fee_amount = 1_000_000u64; // Fee amount (1 USDC) - let main_transfer_ix = spl_token::instruction::transfer_checked( - &spl_token::id(), // Token program ID + let main_transfer_ix = spl_token_interface::instruction::transfer_checked( + &spl_token_interface::id(), // Token program ID &source_token_account, // Source token account &token_mint, // Token mint &destination_token_account, // Destination token account @@ -252,15 +275,15 @@ pub fn setup_test_context_user_fee_strategy() -> UserFeeStrategyTestContext { .unwrap(); // Create fee transfer instruction using standard SPL Token method - let fee_transfer_ix = spl_token::instruction::transfer_checked( - &spl_token::id(), // Token program ID - &source_token_account, // Source token account - &token_mint, // Token mint - &relayer_token_account, // Relayer's token account - &token_owner.pubkey(), // Owner of the source token account - &[], // Additional signers (empty array) - fee_amount, // Fee amount - 6, // Decimals (6 for USDC) + let fee_transfer_ix = spl_token_interface::instruction::transfer_checked( + &spl_token_interface::id(), // Token program ID + &source_token_account, // Source token account + &token_mint, // Token mint + &relayer_token_account, // Relayer's token account + &token_owner.pubkey(), // Owner of the source token account + &[], // Additional signers (empty array) + fee_amount, // Fee amount + 6, // Decimals (6 for USDC) ) .unwrap(); @@ -379,8 +402,8 @@ pub fn setup_test_context_single_tx_user_fee_strategy() -> UserFeeStrategySingle let main_transfer_amount = 5_000_000u64; // Main transfer amount (5 USDC) let fee_amount = 1_000_000u64; // Fee amount (1 USDC) - let transfer_ix = spl_token::instruction::transfer_checked( - &spl_token::id(), // Token program ID + let transfer_ix = spl_token_interface::instruction::transfer_checked( + &spl_token_interface::id(), // Token program ID &source_token_account, // Source token account &token_mint, // Token mint &destination_token_account, // Destination token account diff --git a/src/domain/relayer/solana/rpc/methods/transfer_transaction.rs b/src/domain/relayer/solana/rpc/methods/transfer_transaction.rs index 8f7ea1fb4..f8a8886c0 100644 --- a/src/domain/relayer/solana/rpc/methods/transfer_transaction.rs +++ b/src/domain/relayer/solana/rpc/methods/transfer_transaction.rs @@ -250,15 +250,12 @@ mod tests { services::{QuoteResponse, RoutePlan, SwapInfo}, }; + use super::super::test_setup::setup_signer_mocks; use super::*; use solana_sdk::{ - hash::Hash, - program_option::COption, - program_pack::Pack, - signature::{Keypair, Signature}, - signer::Signer, + hash::Hash, program_option::COption, program_pack::Pack, signature::Keypair, signer::Signer, }; - use spl_token::state::Account; + use spl_token_interface::state::Account; #[tokio::test] async fn test_transfer_wsol_spl_token_success_relayer_fee_strategy() { @@ -267,12 +264,12 @@ mod tests { let test_token = WRAPPED_SOL_MINT; // Create valid token account data - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: Pubkey::from_str(test_token).unwrap(), owner: Pubkey::new_unique(), // Source account owner amount: 10_000_000_000, // 10 WSOL delegate: COption::None, - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, is_native: COption::None, delegated_amount: 0, close_authority: COption::None, @@ -297,12 +294,8 @@ mod tests { ..Default::default() }); - let signature = Signature::new_unique(); - - signer.expect_sign().returning(move |_| { - let signature_clone = signature; - Box::pin(async move { Ok(signature_clone) }) - }); + // Setup signer mocks + setup_signer_mocks(&mut signer, relayer.address.clone()); // Mock provider responses provider @@ -325,7 +318,7 @@ mod tests { Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -366,12 +359,12 @@ mod tests { let test_token = WRAPPED_SOL_MINT; // Create valid token account data - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: Pubkey::from_str(test_token).unwrap(), owner: Pubkey::new_unique(), // Source account owner amount: 10_000_000_000, delegate: COption::None, - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, is_native: COption::None, delegated_amount: 0, close_authority: COption::None, @@ -395,12 +388,8 @@ mod tests { ..Default::default() }); - let signature = Signature::new_unique(); - - signer.expect_sign().returning(move |_| { - let signature_clone = signature; - Box::pin(async move { Ok(signature_clone) }) - }); + // Setup signer mocks + setup_signer_mocks(&mut signer, relayer.address.clone()); provider .expect_get_latest_blockhash_with_commitment() @@ -422,7 +411,7 @@ mod tests { Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -463,12 +452,12 @@ mod tests { let test_token = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; // noboost // Create valid token account data - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: Pubkey::from_str(test_token).unwrap(), owner: Pubkey::new_unique(), // Source account owner amount: 10_000_000, // 10 USDC (assuming 6 decimals) delegate: COption::None, - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, is_native: COption::None, delegated_amount: 0, close_authority: COption::None, @@ -493,12 +482,8 @@ mod tests { ..Default::default() }); - let signature = Signature::new_unique(); - - signer.expect_sign().returning(move |_| { - let signature_clone = signature; - Box::pin(async move { Ok(signature_clone) }) - }); + // Setup signer mocks + setup_signer_mocks(&mut signer, relayer.address.clone()); // Mock provider responses provider @@ -521,7 +506,7 @@ mod tests { Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -595,19 +580,20 @@ mod tests { let source_pubkey = ctx.source_keypair.pubkey(); let destination_pubkey = ctx.destination; - let source_token_account = spl_token::state::Account { + let source_token_account = spl_token_interface::state::Account { mint: Pubkey::from_str(&ctx.token).unwrap(), owner: source_pubkey, amount: 10_000_000, delegate: COption::None, - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, is_native: COption::None, delegated_amount: 0, close_authority: COption::None, }; - let mut source_account_data = vec![0; spl_token::state::Account::LEN]; - spl_token::state::Account::pack(source_token_account, &mut source_account_data).unwrap(); + let mut source_account_data = vec![0; spl_token_interface::state::Account::LEN]; + spl_token_interface::state::Account::pack(source_token_account, &mut source_account_data) + .unwrap(); ctx.relayer.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), @@ -624,12 +610,8 @@ mod tests { ..Default::default() }); - let signature = Signature::new_unique(); - - ctx.signer.expect_sign().returning(move |_| { - let signature_clone = signature; - Box::pin(async move { Ok(signature_clone) }) - }); + // Setup signer mocks + setup_signer_mocks(&mut ctx.signer, ctx.relayer.address.clone()); ctx.provider .expect_get_latest_blockhash_with_commitment() @@ -650,8 +632,8 @@ mod tests { let account_data = source_account_data.clone(); Box::pin(async move { if pubkey == ctx.token_mint { - let mut mint_data = vec![0; spl_token::state::Mint::LEN]; - let mint = spl_token::state::Mint { + let mut mint_data = vec![0; spl_token_interface::state::Mint::LEN]; + let mint = spl_token_interface::state::Mint { is_initialized: true, mint_authority: solana_sdk::program_option::COption::Some( Pubkey::new_unique(), @@ -660,12 +642,12 @@ mod tests { decimals: 6, ..Default::default() }; - spl_token::state::Mint::pack(mint, &mut mint_data).unwrap(); + spl_token_interface::state::Mint::pack(mint, &mut mint_data).unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: mint_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -673,7 +655,7 @@ mod tests { Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -797,12 +779,12 @@ mod tests { ..Default::default() }; // Create token account with low balance - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: Pubkey::from_str(test_token).unwrap(), owner: Pubkey::new_unique(), amount: 100, delegate: COption::None, - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, is_native: COption::None, delegated_amount: 0, close_authority: COption::None, @@ -819,7 +801,7 @@ mod tests { Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) diff --git a/src/domain/relayer/solana/rpc/methods/utils.rs b/src/domain/relayer/solana/rpc/methods/utils.rs index 4701616b0..ba35c871a 100644 --- a/src/domain/relayer/solana/rpc/methods/utils.rs +++ b/src/domain/relayer/solana/rpc/methods/utils.rs @@ -26,21 +26,26 @@ use super::*; use std::str::FromStr; -use crate::utils::calculate_scheduled_timestamp; +use crate::{services::signer::sign_sdk_transaction, utils::calculate_scheduled_timestamp}; + +/// Convert raw token amount to UI amount based on decimals +fn amount_to_ui_amount(amount: u64, decimals: u8) -> f64 { + amount as f64 / 10_f64.powi(decimals as i32) +} + +use solana_commitment_config::CommitmentConfig; use solana_sdk::{ - commitment_config::CommitmentConfig, hash::Hash, - instruction::{AccountMeta, CompiledInstruction, Instruction}, - message::{Message, MessageHeader}, + instruction::{AccountMeta, Instruction}, + message::{compiled_instruction::CompiledInstruction, Message, MessageHeader}, program_pack::Pack, pubkey::Pubkey, signature::Signature, - system_instruction::SystemInstruction, transaction::Transaction, }; -use solana_system_interface::program; -use spl_token::{amount_to_ui_amount, state::Account}; -use tracing::debug; +use solana_system_interface::{instruction::SystemInstruction, program}; +use spl_token_interface::state::Account; +use tracing::{debug, error}; use crate::{ constants::{ @@ -103,7 +108,7 @@ where SolanaRpcError::Internal(format!("Failed to fetch mint account: {}", e)) })?; - let mint_info = spl_token::state::Mint::unpack(&mint_account.data) + let mint_info = spl_token_interface::state::Mint::unpack(&mint_account.data) .map_err(|e| SolanaRpcError::Internal(format!("Failed to unpack mint data: {}", e)))?; Ok(mint_info.decimals) @@ -153,43 +158,11 @@ where /// * The transaction format is invalid pub(crate) async fn relayer_sign_transaction( &self, - mut transaction: Transaction, + transaction: Transaction, ) -> Result<(Transaction, Signature), SolanaRpcError> { - // Parse relayer public key - let relayer_pubkey = Pubkey::from_str(&self.relayer.address) - .map_err(|e| SolanaRpcError::Internal(e.to_string()))?; - - // Find the position of the relayer's public key in account_keys - let signer_index = transaction - .message - .account_keys - .iter() - .position(|key| *key == relayer_pubkey) - .ok_or_else(|| { - SolanaRpcError::Internal( - "Relayer public key not found in transaction signers".to_string(), - ) - })?; - - // Check if this is a signer position (within num_required_signatures) - if signer_index >= transaction.message.header.num_required_signatures as usize { - return Err(SolanaRpcError::Internal( - "Relayer is not marked as a required signer in the transaction".to_string(), - )); - } - - // Generate signature - let signature = self.signer.sign(&transaction.message_data()).await?; - - // Ensure signatures array has enough elements - while transaction.signatures.len() <= signer_index { - transaction.signatures.push(Signature::default()); - } - - // Place signature in the correct position - transaction.signatures[signer_index] = signature; - - Ok((transaction, signature)) + sign_sdk_transaction(&*self.signer, transaction) + .await + .map_err(|e| SolanaRpcError::Internal(e.to_string())) } /// Estimates the total fee that the fee payer will incur for a given transaction. @@ -228,7 +201,7 @@ where .iter() .filter(|ix| { transaction.message.account_keys[ix.program_id_index as usize] - == spl_associated_token_account::id() + == spl_associated_token_account_interface::program::id() }) .count(); @@ -964,6 +937,72 @@ where })?; Ok(()) } + + /// Reconstruct a Transaction from an existing Transaction by replacing the fee payer + /// with `fee_payer`. This recalculates message metadata (num_required_signatures, + /// account ordering) and returns a new unsigned Transaction with signature slots initialized. + pub(crate) fn reconstruct_transaction_with_fee_payer( + &self, + message: &Message, + fee_payer: &Pubkey, + blockhash: &Hash, + ) -> Result { + fn is_writable(index: usize, header: &MessageHeader, total: usize) -> bool { + if index < header.num_required_signatures as usize { + index + < (header.num_required_signatures - header.num_readonly_signed_accounts) + as usize + } else { + let non_signer_index = index - header.num_required_signatures as usize; + non_signer_index + < (total - header.num_required_signatures as usize) + - header.num_readonly_unsigned_accounts as usize + } + } + + let account_keys = &message.account_keys; + let header = &message.header; + let mut instructions = Vec::with_capacity(message.instructions.len()); + + for compiled_ix in &message.instructions { + if compiled_ix.program_id_index as usize >= account_keys.len() { + return Err(SolanaRpcError::Internal( + "Invalid program id index".to_string(), + )); + } + + let mut accounts = Vec::with_capacity(compiled_ix.accounts.len()); + for &index in &compiled_ix.accounts { + if index as usize >= account_keys.len() { + return Err(SolanaRpcError::Internal( + "Invalid account index".to_string(), + )); + } + let pubkey = account_keys[index as usize]; + let is_signer = (index as usize) < header.num_required_signatures as usize; + let writable = is_writable(index as usize, header, account_keys.len()); + + accounts.push(match (is_signer, writable) { + (true, true) => AccountMeta::new(pubkey, true), + (true, false) => AccountMeta::new_readonly(pubkey, true), + (false, true) => AccountMeta::new(pubkey, false), + (false, false) => AccountMeta::new_readonly(pubkey, false), + }); + } + + instructions.push(Instruction { + program_id: account_keys[compiled_ix.program_id_index as usize], + accounts, + data: compiled_ix.data.clone(), + }); + } + + let message = Message::new_with_blockhash(&instructions, Some(fee_payer), blockhash); + Ok(Transaction { + signatures: vec![Signature::default(); message.header.num_required_signatures as usize], + message, + }) + } } #[cfg(test)] @@ -979,14 +1018,10 @@ mod tests { }; use super::*; - use solana_sdk::{ - instruction::AccountMeta, - signature::{Keypair, Signature}, - signer::Signer, - }; + use solana_sdk::{instruction::AccountMeta, signature::Keypair, signer::Signer}; use solana_system_interface::instruction; - use spl_associated_token_account::{ - get_associated_token_address, instruction::create_associated_token_account, + use spl_associated_token_account_interface::{ + address::get_associated_token_address, instruction::create_associated_token_account, }; #[tokio::test] @@ -998,11 +1033,9 @@ mod tests { let instruction = instruction::transfer(&relayer_pubkey, &recipient, 1000); let message = Message::new(&[instruction], Some(&relayer_pubkey)); let transaction = Transaction::new_unsigned(message); - signer.expect_sign().returning(move |_| { - let signature = Signature::new_unique(); - let signature_clone = signature; - Box::pin(async move { Ok(signature_clone) }) - }); + + // Setup signer mocks + super::test_setup::setup_signer_mocks(&mut signer, relayer.address.clone()); let rpc = SolanaRpcMethodsImpl::new_mock( relayer, @@ -1202,8 +1235,12 @@ mod tests { let owner = Pubkey::new_unique(); let mint = Pubkey::new_unique(); - let ata_ix = - create_associated_token_account(&payer.pubkey(), &owner, &mint, &spl_token::id()); + let ata_ix = create_associated_token_account( + &payer.pubkey(), + &owner, + &mint, + &spl_token_interface::id(), + ); let message = Message::new(&[ata_ix], Some(&payer.pubkey())); let transaction = Transaction::new_unsigned(message); @@ -1247,10 +1284,18 @@ mod tests { let owner2 = Pubkey::new_unique(); let mint = Pubkey::new_unique(); - let ata_ix1 = - create_associated_token_account(&payer.pubkey(), &owner1, &mint, &spl_token::id()); - let ata_ix2 = - create_associated_token_account(&payer.pubkey(), &owner2, &mint, &spl_token::id()); + let ata_ix1 = create_associated_token_account( + &payer.pubkey(), + &owner1, + &mint, + &spl_token_interface::id(), + ); + let ata_ix2 = create_associated_token_account( + &payer.pubkey(), + &owner2, + &mint, + &spl_token_interface::id(), + ); let message = Message::new(&[ata_ix1, ata_ix2], Some(&payer.pubkey())); let transaction = Transaction::new_unsigned(message); @@ -1461,11 +1506,8 @@ mod tests { let recipient = Pubkey::new_unique(); let amount = 1_000_000; - let expected_signature = Signature::new_unique(); - signer.expect_sign().returning(move |_| { - let signature_clone = expected_signature; - Box::pin(async move { Ok(signature_clone) }) - }); + // Setup signer mocks + super::test_setup::setup_signer_mocks(&mut signer, relayer.address.clone()); let expected_blockhash = Hash::new_unique(); let expected_slot = 100u64; @@ -1496,7 +1538,16 @@ mod tests { assert_eq!(signed_tx.message.recent_blockhash, expected_blockhash); assert_eq!(slot, expected_slot); - assert_eq!(signed_tx.signatures[0], expected_signature); + assert_eq!( + signed_tx.signatures.len(), + 1, + "Transaction should have exactly one signature" + ); + assert_ne!( + signed_tx.signatures[0].as_ref(), + &[0u8; 64], + "Signature should not be default/empty" + ); assert_eq!( signed_tx.message.account_keys[0], Pubkey::from_str(&relayer_keypair.pubkey().to_string()).unwrap() @@ -1511,9 +1562,8 @@ mod tests { let relayer_keypair = Keypair::new(); relayer.address = relayer_keypair.pubkey().to_string(); - signer - .expect_sign() - .returning(|_| Box::pin(async { Ok(Signature::new_unique()) })); + // Setup signer mocks + super::test_setup::setup_signer_mocks(&mut signer, relayer.address.clone()); provider .expect_get_latest_blockhash_with_commitment() @@ -1993,8 +2043,8 @@ mod tests { let destination = get_associated_token_address(&Pubkey::new_unique(), &mint); let amount = 1_000_000; - let transfer_ix = spl_token::instruction::transfer_checked( - &spl_token::id(), + let transfer_ix = spl_token_interface::instruction::transfer_checked( + &spl_token_interface::id(), &source, &mint, &destination, @@ -2018,13 +2068,13 @@ mod tests { &message.header, ); - assert_eq!(converted_ix.program_id, spl_token::id()); + assert_eq!(converted_ix.program_id, spl_token_interface::id()); let decoded_ix = - spl_token::instruction::TokenInstruction::unpack(&converted_ix.data).unwrap(); + spl_token_interface::instruction::TokenInstruction::unpack(&converted_ix.data).unwrap(); match decoded_ix { - spl_token::instruction::TokenInstruction::TransferChecked { + spl_token_interface::instruction::TokenInstruction::TransferChecked { amount: decoded_amount, decimals, .. @@ -2096,12 +2146,12 @@ mod tests { let pubkey = *pubkey; Box::pin(async move { // Create a token account with sufficient balance - let mut account_data = vec![0; spl_token::state::Account::LEN]; + let mut account_data = vec![0; spl_token_interface::state::Account::LEN]; let mut token_account = Account { mint: token_mint, owner: user_pubkey, amount: 10_000_000, - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; if pubkey == user_token_account { @@ -2110,12 +2160,13 @@ mod tests { token_account.owner = relayer_pubkey; } - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -2184,11 +2235,13 @@ mod tests { let program_idx = ix.program_id_index as usize; if program_idx < modified_tx.message.account_keys.len() - && modified_tx.message.account_keys[program_idx] == spl_token::id() + && modified_tx.message.account_keys[program_idx] == spl_token_interface::id() { - if let Ok(token_ix) = spl_token::instruction::TokenInstruction::unpack(&ix.data) { + if let Ok(token_ix) = + spl_token_interface::instruction::TokenInstruction::unpack(&ix.data) + { match token_ix { - spl_token::instruction::TokenInstruction::TransferChecked { + spl_token_interface::instruction::TokenInstruction::TransferChecked { amount, .. } => { @@ -2317,12 +2370,12 @@ mod tests { let pubkey = *pubkey; Box::pin(async move { // Create a token account with sufficient balance - let mut account_data = vec![0; spl_token::state::Account::LEN]; + let mut account_data = vec![0; spl_token_interface::state::Account::LEN]; let mut token_account = Account { mint: token_mint, owner: user_pubkey, amount: 1_000, - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; if pubkey == user_token_account { @@ -2331,12 +2384,13 @@ mod tests { token_account.owner = relayer_pubkey; } - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -2532,12 +2586,12 @@ mod tests { let pubkey = *pubkey; Box::pin(async move { // Create a token account with sufficient balance - let mut account_data = vec![0; spl_token::state::Account::LEN]; + let mut account_data = vec![0; spl_token_interface::state::Account::LEN]; let mut token_account = Account { mint: token_mint, owner: user_pubkey, amount: 10000000, - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; if pubkey == user_token_account { @@ -2546,12 +2600,13 @@ mod tests { token_account.owner = relayer_pubkey; } - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -2571,8 +2626,8 @@ mod tests { let token_payment = 1_000_000; // Amount in token units let sol_fee = 5000; // Equivalent amount in SOL - let transfer_ix = spl_token::instruction::transfer_checked( - &spl_token::id(), + let transfer_ix = spl_token_interface::instruction::transfer_checked( + &spl_token_interface::id(), &user_token_account, &token_mint, &relayer_token_account, @@ -2674,12 +2729,12 @@ mod tests { let pubkey = *pubkey; Box::pin(async move { // Create a token account with sufficient balance - let mut account_data = vec![0; spl_token::state::Account::LEN]; + let mut account_data = vec![0; spl_token_interface::state::Account::LEN]; let mut token_account = Account { mint: token_mint, owner: user_pubkey, amount: 10000000, - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; if pubkey == user_token_account { @@ -2688,12 +2743,13 @@ mod tests { token_account.owner = relayer_pubkey; } - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -2711,8 +2767,8 @@ mod tests { ); let token_payment = 1_000_000; // 1 USDC - let transfer_ix = spl_token::instruction::transfer_checked( - &spl_token::id(), + let transfer_ix = spl_token_interface::instruction::transfer_checked( + &spl_token_interface::id(), &user_token_account, &token_mint, &relayer_token_account, @@ -2757,8 +2813,9 @@ mod tests { valid_until: None, network_data: crate::models::NetworkTransactionData::Solana( crate::models::SolanaTransactionData { - transaction: "test-transaction".to_string(), + transaction: Some("test-transaction".to_string()), signature: Some("test-signature".to_string()), + ..Default::default() }, ), priced_at: None, @@ -2807,15 +2864,15 @@ mod tests { // Mock the provider to return mint account data when get_account_from_pubkey is called let mint_data = { - let mint_info = spl_token::state::Mint { + let mint_info = spl_token_interface::state::Mint { mint_authority: None.into(), supply: 1_000_000_000_000, decimals: 6, // USDC decimals is_initialized: true, freeze_authority: None.into(), }; - let mut data = vec![0u8; spl_token::state::Mint::LEN]; - spl_token::state::Mint::pack(mint_info, &mut data).unwrap(); + let mut data = vec![0u8; spl_token_interface::state::Mint::LEN]; + spl_token_interface::state::Mint::pack(mint_info, &mut data).unwrap(); data }; @@ -2827,7 +2884,7 @@ mod tests { Ok(solana_sdk::account::Account { lamports: 1000000, data: mint_data_clone, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -2900,4 +2957,548 @@ mod tests { quote.conversion_rate ); } + + #[tokio::test] + async fn test_reconstruct_transaction_with_fee_payer_basic() { + let (relayer, signer, provider, jupiter_service, _, job_producer, network) = + setup_test_context(); + + let rpc = SolanaRpcMethodsImpl::new_mock( + relayer, + network, + Arc::new(provider), + Arc::new(signer), + Arc::new(jupiter_service), + Arc::new(job_producer), + Arc::new(MockTransactionRepository::new()), + ); + + // Create original transaction with user as fee payer + let original_fee_payer = Keypair::new(); + let recipient = Pubkey::new_unique(); + let transfer_ix = instruction::transfer(&original_fee_payer.pubkey(), &recipient, 1000); + let blockhash = Hash::new_unique(); + let original_message = Message::new_with_blockhash( + &[transfer_ix], + Some(&original_fee_payer.pubkey()), + &blockhash, + ); + let original_tx = Transaction::new_unsigned(original_message); + + // New fee payer (relayer) + let new_fee_payer = Keypair::new(); + + // Reconstruct transaction with new fee payer + let result = rpc.reconstruct_transaction_with_fee_payer( + &original_tx.message, + &new_fee_payer.pubkey(), + &blockhash, + ); + + assert!(result.is_ok(), "Transaction reconstruction should succeed"); + let reconstructed_tx = result.unwrap(); + + // Verify fee payer changed + assert_eq!( + reconstructed_tx.message.account_keys[0], + new_fee_payer.pubkey(), + "Fee payer should be updated" + ); + + // Verify blockhash is preserved + assert_eq!( + reconstructed_tx.message.recent_blockhash, blockhash, + "Blockhash should be preserved" + ); + + // Verify signature slots are initialized + assert_eq!( + reconstructed_tx.signatures.len(), + reconstructed_tx.message.header.num_required_signatures as usize, + "Signature slots should be initialized" + ); + + // Verify all signatures are default (empty) + assert!( + reconstructed_tx + .signatures + .iter() + .all(|sig| sig.as_ref() == &[0u8; 64]), + "All signatures should be default" + ); + + // Verify instruction is preserved - program ID should still be at the same relative position + assert_eq!( + reconstructed_tx.message.instructions.len(), + 1, + "Should have one instruction" + ); + // The program ID index should be 3 (new_fee_payer, original_fee_payer, recipient, system_program) + assert_eq!( + reconstructed_tx.message.instructions[0].program_id_index, 3, + "Program ID index should be correct for system program" + ); + + // Verify the program ID is still the system program + assert_eq!( + reconstructed_tx.message.account_keys + [reconstructed_tx.message.instructions[0].program_id_index as usize], + program::id(), + "Program ID should be system program" + ); + } + + #[tokio::test] + async fn test_reconstruct_transaction_with_fee_payer_multiple_signers() { + let (relayer, signer, provider, jupiter_service, _, job_producer, network) = + setup_test_context(); + + let rpc = SolanaRpcMethodsImpl::new_mock( + relayer, + network, + Arc::new(provider), + Arc::new(signer), + Arc::new(jupiter_service), + Arc::new(job_producer), + Arc::new(MockTransactionRepository::new()), + ); + + // Create transaction with multiple signers + let fee_payer = Keypair::new(); + let signer1 = Keypair::new(); + let signer2 = Keypair::new(); + let recipient = Pubkey::new_unique(); + + // Create instruction that requires multiple signers + let transfer_ix = Instruction { + program_id: program::id(), + accounts: vec![ + AccountMeta::new(fee_payer.pubkey(), true), // fee payer, signer, writable + AccountMeta::new(recipient, false), // recipient, not signer, writable + AccountMeta::new(signer1.pubkey(), true), // additional signer, writable + AccountMeta::new_readonly(signer2.pubkey(), true), // additional signer, readonly + ], + data: vec![2, 0, 0, 0, 232, 3, 0, 0, 0, 0, 0, 0], // transfer instruction data + }; + + let blockhash = Hash::new_unique(); + let message = + Message::new_with_blockhash(&[transfer_ix], Some(&fee_payer.pubkey()), &blockhash); + let original_tx = Transaction::new_unsigned(message); + + // New fee payer + let new_fee_payer = Keypair::new(); + + // Reconstruct transaction + let result = rpc.reconstruct_transaction_with_fee_payer( + &original_tx.message, + &new_fee_payer.pubkey(), + &blockhash, + ); + + assert!(result.is_ok(), "Transaction reconstruction should succeed"); + let reconstructed_tx = result.unwrap(); + + // Verify fee payer is now first + assert_eq!( + reconstructed_tx.message.account_keys[0], + new_fee_payer.pubkey(), + "New fee payer should be first" + ); + + // Account ordering: fee_payer first, then other writable signers sorted by pubkey, then readonly signers, then writable non-signers, then program IDs + // Collect the expected writable signers (excluding fee_payer) and sort by pubkey + let mut other_writable_signers = vec![fee_payer.pubkey(), signer1.pubkey()]; + other_writable_signers.sort_by(|a, b| a.to_string().cmp(&b.to_string())); + + // Expected order: new_fee_payer, then sorted other writable signers, then readonly signers, then non-signers, then program IDs + assert_eq!( + reconstructed_tx.message.account_keys[0], + new_fee_payer.pubkey(), + "New fee payer should be first" + ); + assert_eq!( + reconstructed_tx.message.account_keys[1], other_writable_signers[0], + "First sorted writable signer should be second" + ); + assert_eq!( + reconstructed_tx.message.account_keys[2], other_writable_signers[1], + "Second sorted writable signer should be third" + ); + assert_eq!( + reconstructed_tx.message.account_keys[3], + signer2.pubkey(), + "Signer2 should be fourth" + ); + assert_eq!( + reconstructed_tx.message.account_keys[4], recipient, + "Recipient should be fifth" + ); + assert_eq!( + reconstructed_tx.message.account_keys[5], + program::id(), + "System program should be last" + ); + + // Verify header is correct + assert_eq!( + reconstructed_tx.message.header.num_required_signatures, 4, + "Should have 4 required signatures" + ); + assert_eq!( + reconstructed_tx.message.header.num_readonly_signed_accounts, 1, + "Should have 1 readonly signed account" + ); + assert_eq!( + reconstructed_tx + .message + .header + .num_readonly_unsigned_accounts, + 1, + "Should have 1 readonly unsigned account (system program)" + ); + } + + #[tokio::test] + async fn test_reconstruct_transaction_with_fee_payer_readonly_accounts() { + let (relayer, signer, provider, jupiter_service, _, job_producer, network) = + setup_test_context(); + + let rpc = SolanaRpcMethodsImpl::new_mock( + relayer, + network, + Arc::new(provider), + Arc::new(signer), + Arc::new(jupiter_service), + Arc::new(job_producer), + Arc::new(MockTransactionRepository::new()), + ); + + // Create transaction with readonly accounts + let fee_payer = Keypair::new(); + let writable_account = Pubkey::new_unique(); + let readonly_account = Pubkey::new_unique(); + let program_id = Pubkey::new_unique(); + + let test_ix = Instruction { + program_id, + accounts: vec![ + AccountMeta::new(fee_payer.pubkey(), true), // fee payer, signer, writable + AccountMeta::new(writable_account, false), // writable, not signer + AccountMeta::new_readonly(readonly_account, false), // readonly, not signer + ], + data: vec![0], + }; + + let blockhash = Hash::new_unique(); + let message = + Message::new_with_blockhash(&[test_ix], Some(&fee_payer.pubkey()), &blockhash); + let original_tx = Transaction::new_unsigned(message); + + // New fee payer + let new_fee_payer = Keypair::new(); + + // Reconstruct transaction + let result = rpc.reconstruct_transaction_with_fee_payer( + &original_tx.message, + &new_fee_payer.pubkey(), + &blockhash, + ); + + assert!(result.is_ok(), "Transaction reconstruction should succeed"); + let reconstructed_tx = result.unwrap(); + + // Verify account ordering: signers first, then writable non-signers, then readonly non-signers + assert_eq!( + reconstructed_tx.message.account_keys[0], + new_fee_payer.pubkey(), + "Fee payer should be first" + ); + + // Expected ordering: new_fee_payer, fee_payer, writable_account, readonly_account, program_id + assert_eq!( + reconstructed_tx.message.account_keys[1], + fee_payer.pubkey(), + "Original fee payer should be second" + ); + assert_eq!( + reconstructed_tx.message.account_keys[2], writable_account, + "Writable account should be third" + ); + assert_eq!( + reconstructed_tx.message.account_keys[3], readonly_account, + "Readonly account should be fourth" + ); + assert_eq!( + reconstructed_tx.message.account_keys[4], program_id, + "Program ID should be fifth" + ); + + // Verify header + assert_eq!( + reconstructed_tx.message.header.num_required_signatures, 2, + "Should have 2 required signatures" + ); + assert_eq!( + reconstructed_tx.message.header.num_readonly_signed_accounts, 0, + "Should have 0 readonly signed accounts" + ); + assert_eq!( + reconstructed_tx + .message + .header + .num_readonly_unsigned_accounts, + 2, + "Should have 2 readonly unsigned accounts" + ); + } + + #[tokio::test] + async fn test_reconstruct_transaction_with_fee_payer_invalid_program_id_index() { + let (relayer, signer, provider, jupiter_service, _, job_producer, network) = + setup_test_context(); + + let rpc = SolanaRpcMethodsImpl::new_mock( + relayer, + network, + Arc::new(provider), + Arc::new(signer), + Arc::new(jupiter_service), + Arc::new(job_producer), + Arc::new(MockTransactionRepository::new()), + ); + + // Create message with invalid program_id_index + let fee_payer = Keypair::new(); + let account1 = Pubkey::new_unique(); + let account2 = Pubkey::new_unique(); + + let message = Message { + account_keys: vec![fee_payer.pubkey(), account1, account2], + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 0, + }, + instructions: vec![CompiledInstruction { + program_id_index: 5, // Invalid index (only 3 accounts) + accounts: vec![0, 1], + data: vec![0], + }], + recent_blockhash: Hash::new_unique(), + }; + + let new_fee_payer = Keypair::new(); + let blockhash = Hash::new_unique(); + + // Reconstruct transaction + let result = rpc.reconstruct_transaction_with_fee_payer( + &message, + &new_fee_payer.pubkey(), + &blockhash, + ); + + assert!(result.is_err(), "Should fail with invalid program ID index"); + match result { + Err(SolanaRpcError::Internal(msg)) => { + assert!( + msg.contains("Invalid program id index"), + "Error should mention invalid program id index" + ); + } + _ => panic!("Expected Internal error"), + } + } + + #[tokio::test] + async fn test_reconstruct_transaction_with_fee_payer_invalid_account_index() { + let (relayer, signer, provider, jupiter_service, _, job_producer, network) = + setup_test_context(); + + let rpc = SolanaRpcMethodsImpl::new_mock( + relayer, + network, + Arc::new(provider), + Arc::new(signer), + Arc::new(jupiter_service), + Arc::new(job_producer), + Arc::new(MockTransactionRepository::new()), + ); + + // Create message with invalid account index in instruction + let fee_payer = Keypair::new(); + let account1 = Pubkey::new_unique(); + let account2 = Pubkey::new_unique(); + + let message = Message { + account_keys: vec![fee_payer.pubkey(), account1, account2], + header: MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 0, + }, + instructions: vec![CompiledInstruction { + program_id_index: 2, + accounts: vec![0, 5], // Invalid account index 5 (only 3 accounts) + data: vec![0], + }], + recent_blockhash: Hash::new_unique(), + }; + + let new_fee_payer = Keypair::new(); + let blockhash = Hash::new_unique(); + + // Reconstruct transaction + let result = rpc.reconstruct_transaction_with_fee_payer( + &message, + &new_fee_payer.pubkey(), + &blockhash, + ); + + assert!(result.is_err(), "Should fail with invalid account index"); + match result { + Err(SolanaRpcError::Internal(msg)) => { + assert!( + msg.contains("Invalid account index"), + "Error should mention invalid account index" + ); + } + _ => panic!("Expected Internal error"), + } + } + + #[tokio::test] + async fn test_reconstruct_transaction_with_fee_payer_multiple_instructions() { + let (relayer, signer, provider, jupiter_service, _, job_producer, network) = + setup_test_context(); + + let rpc = SolanaRpcMethodsImpl::new_mock( + relayer, + network, + Arc::new(provider), + Arc::new(signer), + Arc::new(jupiter_service), + Arc::new(job_producer), + Arc::new(MockTransactionRepository::new()), + ); + + // Create transaction with multiple instructions + let fee_payer = Keypair::new(); + let recipient1 = Pubkey::new_unique(); + let recipient2 = Pubkey::new_unique(); + + let transfer1_ix = instruction::transfer(&fee_payer.pubkey(), &recipient1, 1000); + let transfer2_ix = instruction::transfer(&fee_payer.pubkey(), &recipient2, 2000); + + let blockhash = Hash::new_unique(); + let message = Message::new_with_blockhash( + &[transfer1_ix, transfer2_ix], + Some(&fee_payer.pubkey()), + &blockhash, + ); + let original_tx = Transaction::new_unsigned(message); + + // New fee payer + let new_fee_payer = Keypair::new(); + + // Reconstruct transaction + let result = rpc.reconstruct_transaction_with_fee_payer( + &original_tx.message, + &new_fee_payer.pubkey(), + &blockhash, + ); + + assert!(result.is_ok(), "Transaction reconstruction should succeed"); + let reconstructed_tx = result.unwrap(); + + // Verify fee payer changed + assert_eq!( + reconstructed_tx.message.account_keys[0], + new_fee_payer.pubkey(), + "Fee payer should be updated" + ); + + // Verify both instructions are preserved + assert_eq!( + reconstructed_tx.message.instructions.len(), + 2, + "Should have two instructions" + ); + + // Verify program ID indices are updated correctly (system program is at index 4) + assert_eq!( + reconstructed_tx.message.instructions[0].program_id_index, 4, + "First instruction program ID index should be updated" + ); + assert_eq!( + reconstructed_tx.message.instructions[1].program_id_index, 4, + "Second instruction program ID index should be updated" + ); + + // Verify account indices in instructions are updated (fee_payer is at index 1, not 0) + assert_eq!( + reconstructed_tx.message.instructions[0].accounts[0], 1, + "Fee payer account index should be 1 in first instruction" + ); + assert_eq!( + reconstructed_tx.message.instructions[1].accounts[0], 1, + "Fee payer account index should be 1 in second instruction" + ); + } + + #[tokio::test] + async fn test_reconstruct_transaction_with_fee_payer_same_fee_payer() { + let (relayer, signer, provider, jupiter_service, _, job_producer, network) = + setup_test_context(); + + let rpc = SolanaRpcMethodsImpl::new_mock( + relayer, + network, + Arc::new(provider), + Arc::new(signer), + Arc::new(jupiter_service), + Arc::new(job_producer), + Arc::new(MockTransactionRepository::new()), + ); + + // Create transaction + let fee_payer = Keypair::new(); + let recipient = Pubkey::new_unique(); + let transfer_ix = instruction::transfer(&fee_payer.pubkey(), &recipient, 1000); + let blockhash = Hash::new_unique(); + let message = + Message::new_with_blockhash(&[transfer_ix], Some(&fee_payer.pubkey()), &blockhash); + let original_tx = Transaction::new_unsigned(message); + + // Use same fee payer + let result = rpc.reconstruct_transaction_with_fee_payer( + &original_tx.message, + &fee_payer.pubkey(), + &blockhash, + ); + + assert!( + result.is_ok(), + "Transaction reconstruction should succeed even with same fee payer" + ); + let reconstructed_tx = result.unwrap(); + + // Verify fee payer remains the same + assert_eq!( + reconstructed_tx.message.account_keys[0], + fee_payer.pubkey(), + "Fee payer should remain the same" + ); + + // Verify account ordering is still correct + assert_eq!( + reconstructed_tx.message.account_keys[1], recipient, + "Recipient should be second" + ); + assert_eq!( + reconstructed_tx.message.account_keys[2], + program::id(), + "Program ID should be third" + ); + } } diff --git a/src/domain/relayer/solana/solana_relayer.rs b/src/domain/relayer/solana/solana_relayer.rs index 4e69192f7..58aa7afdc 100644 --- a/src/domain/relayer/solana/solana_relayer.rs +++ b/src/domain/relayer/solana/solana_relayer.rs @@ -9,29 +9,37 @@ //! in-memory repositories, and the application's domain models. use std::{str::FromStr, sync::Arc}; +use crate::constants::SOLANA_STATUS_CHECK_INITIAL_DELAY_SECONDS; +use crate::domain::{ + create_error_response, Relayer, SignDataRequest, SignTransactionExternalResponse, + SignTransactionRequest, SignTransactionResponse, SignTypedDataRequest, SolanaRpcHandlerType, + SwapParams, +}; +use crate::jobs::{TransactionRequest, TransactionStatusCheck}; +use crate::models::{ + DeletePendingTransactionsResponse, JsonRpcRequest, JsonRpcResponse, NetworkRpcRequest, + NetworkRpcResult, RelayerStatus, RepositoryError, RpcErrorCodes, SolanaRpcRequest, + SolanaRpcResult, +}; use crate::utils::calculate_scheduled_timestamp; use crate::{ constants::{ DEFAULT_CONVERSION_SLIPPAGE_PERCENTAGE, DEFAULT_SOLANA_MIN_BALANCE, SOLANA_SMALLEST_UNIT_NAME, WRAPPED_SOL_MINT, }, - domain::{ - relayer::{evm::create_error_response, RelayerError}, - BalanceResponse, DexStrategy, SolanaRelayerDexTrait, SolanaRelayerTrait, - SolanaRpcHandlerType, SwapParams, - }, + domain::{relayer::RelayerError, BalanceResponse, DexStrategy, SolanaRelayerDexTrait}, jobs::{JobProducerTrait, RelayerHealthCheck, SolanaTokenSwapRequest}, models::{ produce_relayer_disabled_payload, produce_solana_dex_webhook_payload, DisabledReason, - HealthCheckFailure, JsonRpcRequest, JsonRpcResponse, NetworkRepoModel, NetworkRpcRequest, - NetworkRpcResult, NetworkType, RelayerNetworkPolicy, RelayerRepoModel, RelayerSolanaPolicy, - RpcErrorCodes, SolanaAllowedTokensPolicy, SolanaDexPayload, SolanaNetwork, - SolanaRpcRequest, SolanaRpcResult, TransactionRepoModel, + HealthCheckFailure, NetworkRepoModel, NetworkTransactionData, NetworkType, + RelayerNetworkPolicy, RelayerRepoModel, RelayerSolanaPolicy, SolanaAllowedTokensPolicy, + SolanaDexPayload, SolanaFeePaymentStrategy, SolanaNetwork, SolanaTransactionData, + TransactionRepoModel, TransactionStatus, }, repositories::{NetworkRepository, RelayerRepository, Repository, TransactionRepository}, services::{ provider::{SolanaProvider, SolanaProviderTrait}, - signer::{SolanaSignTrait, SolanaSigner}, + signer::{Signer, SolanaSignTrait, SolanaSigner}, JupiterService, JupiterServiceTrait, }, }; @@ -57,7 +65,7 @@ where RR: Repository + RelayerRepository + Send + Sync + 'static, TR: TransactionRepository + Repository + Send + Sync + 'static, J: JobProducerTrait + Send + Sync + 'static, - S: SolanaSignTrait + Send + Sync + 'static, + S: SolanaSignTrait + Signer + Send + Sync + 'static, JS: JupiterServiceTrait + Send + Sync + 'static, SP: SolanaProviderTrait + Send + Sync + 'static, NR: NetworkRepository + Repository + Send + Sync + 'static, @@ -82,7 +90,7 @@ where RR: Repository + RelayerRepository + Send + Sync + 'static, TR: TransactionRepository + Repository + Send + Sync + 'static, J: JobProducerTrait + Send + Sync + 'static, - S: SolanaSignTrait + Send + Sync + 'static, + S: SolanaSignTrait + Signer + Send + Sync + 'static, JS: JupiterServiceTrait + Send + Sync + 'static, SP: SolanaProviderTrait + Send + Sync + 'static, NR: NetworkRepository + Repository + Send + Sync + 'static, @@ -314,7 +322,7 @@ where RR: Repository + RelayerRepository + Send + Sync + 'static, TR: TransactionRepository + Repository + Send + Sync + 'static, J: JobProducerTrait + Send + Sync + 'static, - S: SolanaSignTrait + Send + Sync + 'static, + S: SolanaSignTrait + Signer + Send + Sync + 'static, JS: JupiterServiceTrait + Send + Sync + 'static, SP: SolanaProviderTrait + Send + Sync + 'static, NR: NetworkRepository + Repository + Send + Sync + 'static, @@ -332,7 +340,7 @@ where &self, relayer_id: String, ) -> Result, RelayerError> { - debug!("handling token swap request for relayer"); + debug!("handling token swap request for relayer {}", relayer_id); let relayer = self .relayer_repository .get_by_id(relayer_id.clone()) @@ -343,7 +351,7 @@ where let swap_config = match policy.get_swap_config() { Some(config) => config, None => { - info!("No swap configuration specified; Exiting."); + debug!(%relayer_id, "No swap configuration specified for relayer; Exiting."); return Ok(vec![]); } }; @@ -351,7 +359,7 @@ where match swap_config.strategy { Some(strategy) => strategy, None => { - info!("No swap strategy specified; Exiting."); + debug!(%relayer_id, "No swap strategy specified for relayer; Exiting."); return Ok(vec![]); } }; @@ -396,7 +404,7 @@ where .unwrap_or(0); if swap_amount > 0 { - debug!(token = ?token, "token swap eligible for token"); + debug!(%relayer_id, token = ?token, "token swap eligible for token"); // Add the token to the list of eligible tokens for swapping eligible_tokens.push(TokenSwapCandidate { @@ -508,16 +516,86 @@ where } #[async_trait] -impl SolanaRelayerTrait for SolanaRelayer +impl Relayer for SolanaRelayer where RR: Repository + RelayerRepository + Send + Sync + 'static, TR: TransactionRepository + Repository + Send + Sync + 'static, J: JobProducerTrait + Send + Sync + 'static, - S: SolanaSignTrait + Send + Sync + 'static, + S: SolanaSignTrait + Signer + Send + Sync + 'static, JS: JupiterServiceTrait + Send + Sync + 'static, SP: SolanaProviderTrait + Send + Sync + 'static, NR: NetworkRepository + Repository + Send + Sync + 'static, { + async fn process_transaction_request( + &self, + network_transaction: crate::models::NetworkTransactionRequest, + ) -> Result { + // Validate fee payment strategy - send transaction endpoint only supports relayer-paid fees + let policy = self.relayer.policies.get_solana_policy(); + + // Send transaction endpoint only supports Relayer fee payment mode + // Custom RPC methods (signTransaction, signAndSendTransaction) support both User and Relayer modes + // + // Note: For safety, when fee_payment_strategy is not explicitly set (None), we default to User. + // This means the send transaction endpoint will reject requests unless explicitly configured + // with fee_payment_strategy='relayer', preventing accidental use in User mode. + if matches!( + policy + .fee_payment_strategy + .as_ref() + .unwrap_or(&SolanaFeePaymentStrategy::User), + SolanaFeePaymentStrategy::User + ) { + return Err(RelayerError::ValidationError( + "Send transaction endpoint requires fee_payment_strategy to be 'relayer'. \ + For user-paid fees, use the custom RPC methods (signTransaction, signAndSendTransaction) instead." + .to_string(), + )); + } + + let network_model = self + .network_repository + .get_by_name(NetworkType::Solana, &self.relayer.network) + .await? + .ok_or_else(|| { + RelayerError::NetworkConfiguration(format!( + "Network {} not found", + self.relayer.network + )) + })?; + + let transaction = + TransactionRepoModel::try_from((&network_transaction, &self.relayer, &network_model))?; + + self.transaction_repository + .create(transaction.clone()) + .await + .map_err(|e| RepositoryError::TransactionFailure(e.to_string()))?; + + self.job_producer + .produce_transaction_request_job( + TransactionRequest::new(transaction.id.clone(), transaction.relayer_id.clone()), + None, + ) + .await?; + + // Queue status check job (with initial delay) + self.job_producer + .produce_check_transaction_status_job( + TransactionStatusCheck::new( + transaction.id.clone(), + transaction.relayer_id.clone(), + NetworkType::Solana, + ), + Some(calculate_scheduled_timestamp( + SOLANA_STATUS_CHECK_INITIAL_DELAY_SECONDS, + )), + ) + .await?; + + Ok(transaction) + } + async fn get_balance(&self) -> Result { let address = &self.relayer.address; let balance = self.provider.get_balance(address).await?; @@ -528,6 +606,100 @@ where }) } + async fn delete_pending_transactions( + &self, + ) -> Result { + Err(RelayerError::NotSupported( + "Delete pending transactions not supported for Solana relayers".to_string(), + )) + } + + async fn sign_data( + &self, + _request: SignDataRequest, + ) -> Result { + Err(RelayerError::NotSupported( + "Sign data not supported for Solana relayers".to_string(), + )) + } + + async fn sign_typed_data( + &self, + _request: SignTypedDataRequest, + ) -> Result { + Err(RelayerError::NotSupported( + "Sign typed data not supported for Solana relayers".to_string(), + )) + } + + async fn sign_transaction( + &self, + request: &SignTransactionRequest, + ) -> Result { + let policy = self.relayer.policies.get_solana_policy(); + + // For safety, default to User mode when not explicitly configured + // This ensures sign_transaction endpoint requires explicit relayer mode configuration + if matches!( + policy + .fee_payment_strategy + .as_ref() + .unwrap_or(&SolanaFeePaymentStrategy::User), + SolanaFeePaymentStrategy::User + ) { + return Err(RelayerError::ValidationError( + "Sign transaction endpoint requires fee_payment_strategy to be 'relayer'. \ + For user-paid fees, use the custom RPC methods (signTransaction, signAndSendTransaction) instead." + .to_string(), + )); + } + + let transaction_bytes = match request { + SignTransactionRequest::Solana(req) => &req.transaction, + _ => { + error!( + id = %self.relayer.id, + "Invalid request type for Solana relayer", + ); + return Err(RelayerError::NotSupported( + "Invalid request type for Solana relayer".to_string(), + )); + } + }; + + // Prepare transaction data for signing + let transaction_data = NetworkTransactionData::Solana(SolanaTransactionData { + transaction: Some(transaction_bytes.clone().into_inner()), + ..Default::default() + }); + + // Sign the transaction using the signer trait + let response = self + .signer + .sign_transaction(transaction_data) + .await + .map_err(|e| { + error!( + %e, + id = %self.relayer.id, + "Failed to sign transaction", + ); + RelayerError::SignerError(e) + })?; + + // Extract Solana-specific response + let solana_response = match response { + SignTransactionResponse::Solana(resp) => resp, + _ => { + return Err(RelayerError::ProviderError( + "Unexpected response type from Solana signer".to_string(), + )) + } + }; + + Ok(SignTransactionExternalResponse::Solana(solana_response)) + } + async fn rpc( &self, request: JsonRpcRequest, @@ -692,55 +864,38 @@ where } } - async fn validate_min_balance(&self) -> Result<(), RelayerError> { - let balance = self - .provider - .get_balance(&self.relayer.address) - .await - .map_err(|e| RelayerError::ProviderError(e.to_string()))?; - - debug!(balance = %balance, "balance for relayer"); - - let policy = self.relayer.policies.get_solana_policy(); - - if balance < policy.min_balance.unwrap_or(DEFAULT_SOLANA_MIN_BALANCE) { - return Err(RelayerError::InsufficientBalanceError( - "Insufficient balance".to_string(), - )); - } - - Ok(()) - } - - async fn check_health(&self) -> Result<(), Vec> { - debug!( - "running health checks for Solana relayer {}", - self.relayer.id - ); - - let validate_rpc_result = self.validate_rpc().await; - let validate_min_balance_result = self.validate_min_balance().await; + async fn get_status(&self) -> Result { + let address = &self.relayer.address; + let balance = self.provider.get_balance(address).await?; - // Collect all failures - let failures: Vec = vec![ - validate_rpc_result - .err() - .map(|e| HealthCheckFailure::RpcValidationFailed(e.to_string())), - validate_min_balance_result - .err() - .map(|e| HealthCheckFailure::BalanceCheckFailed(e.to_string())), - ] - .into_iter() - .flatten() - .collect(); + let pending_statuses = [TransactionStatus::Pending, TransactionStatus::Submitted]; + let pending_transactions = self + .transaction_repository + .find_by_status(&self.relayer.id, &pending_statuses[..]) + .await + .map_err(RelayerError::from)?; + let pending_transactions_count = pending_transactions.len() as u64; - if failures.is_empty() { - info!("all health checks passed"); - Ok(()) - } else { - warn!("health checks failed: {:?}", failures); - Err(failures) - } + let confirmed_statuses = [TransactionStatus::Confirmed]; + let confirmed_transactions = self + .transaction_repository + .find_by_status(&self.relayer.id, &confirmed_statuses[..]) + .await + .map_err(RelayerError::from)?; + + let last_confirmed_transaction_timestamp = confirmed_transactions + .iter() + .filter_map(|tx| tx.confirmed_at.as_ref()) + .max() + .cloned(); + + Ok(RelayerStatus::Solana { + balance: (balance as u128).to_string(), + pending_transactions_count, + last_confirmed_transaction_timestamp, + system_disabled: self.relayer.system_disabled, + paused: self.relayer.paused, + }) } async fn initialize_relayer(&self) -> Result<(), RelayerError> { @@ -813,6 +968,57 @@ where Ok(()) } + + async fn check_health(&self) -> Result<(), Vec> { + debug!( + "running health checks for Solana relayer {}", + self.relayer.id + ); + + let validate_rpc_result = self.validate_rpc().await; + let validate_min_balance_result = self.validate_min_balance().await; + + // Collect all failures + let failures: Vec = vec![ + validate_rpc_result + .err() + .map(|e| HealthCheckFailure::RpcValidationFailed(e.to_string())), + validate_min_balance_result + .err() + .map(|e| HealthCheckFailure::BalanceCheckFailed(e.to_string())), + ] + .into_iter() + .flatten() + .collect(); + + if failures.is_empty() { + info!("all health checks passed"); + Ok(()) + } else { + warn!("health checks failed: {:?}", failures); + Err(failures) + } + } + + async fn validate_min_balance(&self) -> Result<(), RelayerError> { + let balance = self + .provider + .get_balance(&self.relayer.address) + .await + .map_err(|e| RelayerError::ProviderError(e.to_string()))?; + + debug!(balance = %balance, "balance for relayer"); + + let policy = self.relayer.policies.get_solana_policy(); + + if balance < policy.min_balance.unwrap_or(DEFAULT_SOLANA_MIN_BALANCE) { + return Err(RelayerError::InsufficientBalanceError( + "Insufficient balance".to_string(), + )); + } + + Ok(()) + } } #[cfg(test)] @@ -820,7 +1026,10 @@ mod tests { use super::*; use crate::{ config::{NetworkConfigCommon, SolanaNetworkConfig}, - domain::{create_network_dex_generic, SolanaRpcHandler, SolanaRpcMethodsImpl}, + domain::{ + create_network_dex_generic, Relayer, SignTransactionRequestSolana, SolanaRpcHandler, + SolanaRpcMethodsImpl, + }, jobs::MockJobProducerTrait, models::{ EncodedSerializedTransaction, FeeEstimateRequestParams, @@ -837,9 +1046,10 @@ mod tests { }, utils::mocks::mockutils::create_mock_solana_network, }; + use chrono::Utc; use mockall::predicate::*; use solana_sdk::{hash::Hash, program_pack::Pack, signature::Signature}; - use spl_token::state::Account as SplAccount; + use spl_token_interface::state::Account as SplAccount; /// Bundles all the pieces you need to instantiate a SolanaRelayer. /// Default::default gives you fresh mocks, but you can override any of them. @@ -1148,19 +1358,20 @@ mod tests { Box::pin(async { let mut account_data = vec![0; SplAccount::LEN]; - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: Pubkey::new_unique(), owner: Pubkey::new_unique(), amount: 10000000, - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -1310,19 +1521,20 @@ mod tests { Box::pin(async { let mut account_data = vec![0; SplAccount::LEN]; - let token_account = spl_token::state::Account { + let token_account = spl_token_interface::state::Account { mint: Pubkey::new_unique(), owner: Pubkey::new_unique(), amount: 10000000, - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; - spl_token::state::Account::pack(token_account, &mut account_data).unwrap(); + spl_token_interface::state::Account::pack(token_account, &mut account_data) + .unwrap(); Ok(solana_sdk::account::Account { lamports: 1_000_000, data: account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -1363,7 +1575,7 @@ mod tests { jupiter_mock.expect_execute_ultra_order().returning(|_| { Box::pin(async { - Ok(UltraExecuteResponse { + Ok(UltraExecuteResponse { signature: Some("2jg9xbGLtZRsiJBrDWQnz33JuLjDkiKSZuxZPdjJ3qrJbMeTEerXFAKynkPW63J88nq63cvosDNRsg9VqHtGixvP".to_string()), status: "success".to_string(), slot: Some("123456789".to_string()), @@ -1404,6 +1616,7 @@ mod tests { ) .unwrap(), ); + let mut job_producer = MockJobProducerTrait::new(); job_producer .expect_produce_send_notification_job() @@ -2151,4 +2364,223 @@ mod tests { other => panic!("Expected PolicyConfigurationError, got {:?}", other), } } + + #[tokio::test] + async fn test_sign_transaction_success() { + let signer = MockSolanaSignTrait::new(); + + let relayer_model = RelayerRepoModel { + id: "test-relayer-id".to_string(), + address: "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin".to_string(), + network: "devnet".to_string(), + policies: RelayerNetworkPolicy::Solana(RelayerSolanaPolicy { + fee_payment_strategy: Some(SolanaFeePaymentStrategy::Relayer), + min_balance: Some(0), + ..Default::default() + }), + ..Default::default() + }; + + let ctx = TestCtx { + relayer_model, + signer: Arc::new(signer), + ..Default::default() + }; + + let solana_relayer = ctx.into_relayer().await; + + let sign_request = SignTransactionRequest::Solana(SignTransactionRequestSolana { + transaction: EncodedSerializedTransaction::new("raw_transaction_data".to_string()), + }); + + let result = solana_relayer.sign_transaction(&sign_request).await; + assert!(result.is_ok()); + let response = result.unwrap(); + match response { + SignTransactionExternalResponse::Solana(solana_resp) => { + assert_eq!( + solana_resp.transaction.into_inner(), + "signed_transaction_data" + ); + assert_eq!(solana_resp.signature, "signature_data"); + } + _ => panic!("Expected Solana response"), + } + } + + #[tokio::test] + async fn test_sign_transaction_fee_payment_mismatch() { + let relayer_model = create_test_relayer(); // Uses default fee_payment_strategy (User) + + let ctx = TestCtx { + relayer_model, + ..Default::default() + }; + + let solana_relayer = ctx.into_relayer().await; + + let sign_request = SignTransactionRequest::Solana(SignTransactionRequestSolana { + transaction: EncodedSerializedTransaction::new("raw_transaction_data".to_string()), + }); + + let result = solana_relayer.sign_transaction(&sign_request).await; + assert!(result.is_err()); + match result.unwrap_err() { + RelayerError::ValidationError(msg) => { + assert!(msg.contains("fee_payment_strategy")); + } + other => panic!("Expected ValidationError, got {:?}", other), + } + } + + #[tokio::test] + async fn test_get_status_success() { + let mut raw_provider = MockSolanaProviderTrait::new(); + let mut tx_repo = MockTransactionRepository::new(); + + // Mock balance retrieval + raw_provider + .expect_get_balance() + .returning(|_| Box::pin(async { Ok(1000000) })); + + // Mock transaction counts + tx_repo + .expect_find_by_status() + .with( + eq("test-id"), + eq(vec![ + TransactionStatus::Pending, + TransactionStatus::Submitted, + ]), + ) + .returning(|_, _| { + Ok(vec![ + TransactionRepoModel::default(), + TransactionRepoModel::default(), + ]) + }); + + // Mock recent confirmed transaction + let recent_tx = TransactionRepoModel { + id: "recent-tx".to_string(), + relayer_id: "test-id".to_string(), + network_data: NetworkTransactionData::Solana(SolanaTransactionData::default()), + network_type: NetworkType::Solana, + status: TransactionStatus::Confirmed, + confirmed_at: Some(Utc::now().to_string()), + ..Default::default() + }; + tx_repo + .expect_find_by_status() + .with(eq("test-id"), eq(vec![TransactionStatus::Confirmed])) + .returning(move |_, _| Ok(vec![recent_tx.clone()])); + + let ctx = TestCtx { + tx_repo: Arc::new(tx_repo), + provider: Arc::new(raw_provider), + ..Default::default() + }; + + let solana_relayer = ctx.into_relayer().await; + + let result = solana_relayer.get_status().await; + assert!(result.is_ok()); + let status = result.unwrap(); + + match status { + RelayerStatus::Solana { + balance, + pending_transactions_count, + last_confirmed_transaction_timestamp, + .. + } => { + assert_eq!(balance, "1000000"); + assert_eq!(pending_transactions_count, 2); + assert!(last_confirmed_transaction_timestamp.is_some()); + } + _ => panic!("Expected Solana status"), + } + } + + #[tokio::test] + async fn test_get_status_balance_error() { + let mut raw_provider = MockSolanaProviderTrait::new(); + let tx_repo = MockTransactionRepository::new(); + + // Mock balance error + raw_provider.expect_get_balance().returning(|_| { + Box::pin(async { Err(SolanaProviderError::RpcError("RPC error".to_string())) }) + }); + + let ctx = TestCtx { + tx_repo: Arc::new(tx_repo), + provider: Arc::new(raw_provider), + ..Default::default() + }; + + let solana_relayer = ctx.into_relayer().await; + + let result = solana_relayer.get_status().await; + assert!(result.is_err()); + match result.unwrap_err() { + RelayerError::UnderlyingSolanaProvider(err) => { + assert!(err.to_string().contains("RPC error")); + } + other => panic!("Expected UnderlyingSolanaProvider, got {:?}", other), + } + } + + #[tokio::test] + async fn test_get_status_no_recent_transactions() { + let mut raw_provider = MockSolanaProviderTrait::new(); + let mut tx_repo = MockTransactionRepository::new(); + + // Mock balance retrieval + raw_provider + .expect_get_balance() + .returning(|_| Box::pin(async { Ok(500000) })); + + // Mock transaction counts + tx_repo + .expect_find_by_status() + .with( + eq("test-id"), + eq(vec![ + TransactionStatus::Pending, + TransactionStatus::Submitted, + ]), + ) + .returning(|_, _| Ok(vec![])); + + tx_repo + .expect_find_by_status() + .with(eq("test-id"), eq(vec![TransactionStatus::Confirmed])) + .returning(|_, _| Ok(vec![])); + + let ctx = TestCtx { + tx_repo: Arc::new(tx_repo), + provider: Arc::new(raw_provider), + ..Default::default() + }; + + let solana_relayer = ctx.into_relayer().await; + + let result = solana_relayer.get_status().await; + assert!(result.is_ok()); + let status = result.unwrap(); + + match status { + RelayerStatus::Solana { + balance, + pending_transactions_count, + last_confirmed_transaction_timestamp, + .. + } => { + assert_eq!(balance, "500000"); + assert_eq!(pending_transactions_count, 0); + assert!(last_confirmed_transaction_timestamp.is_none()); + } + _ => panic!("Expected Solana status"), + } + } } diff --git a/src/domain/relayer/solana/token.rs b/src/domain/relayer/solana/token.rs index 2e3b67b99..82ba50886 100644 --- a/src/domain/relayer/solana/token.rs +++ b/src/domain/relayer/solana/token.rs @@ -7,14 +7,14 @@ //! This module abstracts away differences between token program versions, allowing //! for consistent interaction regardless of which token program (SPL Token or Token-2022) //! is being used. -use ::spl_token::state::Account as SplTokenAccount; +use ::spl_token_interface::state::Account as SplTokenAccount; use solana_sdk::{ account::Account as SolanaAccount, instruction::Instruction, program_pack::Pack, pubkey::Pubkey, }; -use spl_associated_token_account::get_associated_token_address_with_program_id; +use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; use tracing::error; -use spl_associated_token_account::instruction::create_associated_token_account; +use spl_associated_token_account_interface::instruction::create_associated_token_account; use crate::services::provider::SolanaProviderTrait; @@ -88,10 +88,10 @@ impl SolanaTokenProgram { .await .map_err(|e| TokenError::InvalidTokenMint(e.to_string()))?; - if account.owner == spl_token::id() { - Ok(spl_token::id()) - } else if account.owner == spl_token_2022::id() { - Ok(spl_token_2022::id()) + if account.owner == spl_token_interface::id() { + Ok(spl_token_interface::id()) + } else if account.owner == spl_token_2022_interface::id() { + Ok(spl_token_2022_interface::id()) } else { Err(TokenError::InvalidTokenProgram(format!( "Unknown token program: {}", @@ -110,7 +110,7 @@ impl SolanaTokenProgram { /// /// `true` if the program ID is SPL Token or Token-2022, `false` otherwise pub fn is_token_program(program_id: &Pubkey) -> bool { - program_id == &spl_token::id() || program_id == &spl_token_2022::id() + program_id == &spl_token_interface::id() || program_id == &spl_token_2022_interface::id() } /// Creates a transfer checked instruction. @@ -143,8 +143,8 @@ impl SolanaTokenProgram { program_id ))); } - if program_id == &spl_token::id() { - return spl_token::instruction::transfer_checked( + if program_id == &spl_token_interface::id() { + return spl_token_interface::instruction::transfer_checked( program_id, source, mint, @@ -155,8 +155,8 @@ impl SolanaTokenProgram { decimals, ) .map_err(|e| TokenError::Instruction(e.to_string())); - } else if program_id == &spl_token_2022::id() { - return spl_token_2022::instruction::transfer_checked( + } else if program_id == &spl_token_2022_interface::id() { + return spl_token_2022_interface::instruction::transfer_checked( program_id, source, mint, @@ -194,7 +194,7 @@ impl SolanaTokenProgram { program_id ))); } - if program_id == &spl_token::id() { + if program_id == &spl_token_interface::id() { let account = SplTokenAccount::unpack(&account.data) .map_err(|e| TokenError::AccountError(format!("Invalid token account1: {}", e)))?; @@ -204,9 +204,9 @@ impl SolanaTokenProgram { amount: account.amount, is_frozen: account.is_frozen(), }); - } else if program_id == &spl_token_2022::id() { - let state_with_extensions = spl_token_2022::extension::StateWithExtensions::< - spl_token_2022::state::Account, + } else if program_id == &spl_token_2022_interface::id() { + let state_with_extensions = spl_token_2022_interface::extension::StateWithExtensions::< + spl_token_2022_interface::state::Account, >::unpack(&account.data) .map_err(|e| TokenError::AccountError(format!("Invalid token account2: {}", e)))?; @@ -285,13 +285,13 @@ impl SolanaTokenProgram { program_id ))); } - if program_id == &spl_token::id() { - match spl_token::instruction::TokenInstruction::unpack(data) { + if program_id == &spl_token_interface::id() { + match spl_token_interface::instruction::TokenInstruction::unpack(data) { Ok(instr) => match instr { - spl_token::instruction::TokenInstruction::Transfer { amount } => { + spl_token_interface::instruction::TokenInstruction::Transfer { amount } => { Ok(TokenInstruction::Transfer { amount }) } - spl_token::instruction::TokenInstruction::TransferChecked { + spl_token_interface::instruction::TokenInstruction::TransferChecked { amount, decimals, } => Ok(TokenInstruction::TransferChecked { amount, decimals }), @@ -299,14 +299,14 @@ impl SolanaTokenProgram { }, Err(e) => Err(TokenError::InvalidTokenInstruction(e.to_string())), } - } else if program_id == &spl_token_2022::id() { - match spl_token_2022::instruction::TokenInstruction::unpack(data) { + } else if program_id == &spl_token_2022_interface::id() { + match spl_token_2022_interface::instruction::TokenInstruction::unpack(data) { Ok(instr) => match instr { #[allow(deprecated)] - spl_token_2022::instruction::TokenInstruction::Transfer { amount } => { - Ok(TokenInstruction::Transfer { amount }) - } - spl_token_2022::instruction::TokenInstruction::TransferChecked { + spl_token_2022_interface::instruction::TokenInstruction::Transfer { + amount, + } => Ok(TokenInstruction::Transfer { amount }), + spl_token_2022_interface::instruction::TokenInstruction::TransferChecked { amount, decimals, } => Ok(TokenInstruction::TransferChecked { amount, decimals }), @@ -370,9 +370,9 @@ impl SolanaTokenProgram { mod tests { use mockall::predicate::eq; use solana_sdk::{program_pack::Pack, pubkey::Pubkey}; - use spl_associated_token_account::get_associated_token_address_with_program_id; - use spl_associated_token_account::instruction::create_associated_token_account; - use spl_token::state::Account; + use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; + use spl_associated_token_account_interface::instruction::create_associated_token_account; + use spl_token_interface::state::Account; use crate::{ domain::{SolanaTokenProgram, TokenError, TokenInstruction}, @@ -393,7 +393,7 @@ mod tests { Ok(solana_sdk::account::Account { lamports: 1000000, data: vec![], - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -403,7 +403,7 @@ mod tests { let result = SolanaTokenProgram::get_token_program_for_mint(&mock_provider, &mint).await; assert!(result.is_ok()); - assert_eq!(result.unwrap(), spl_token::id()); + assert_eq!(result.unwrap(), spl_token_interface::id()); } #[tokio::test] @@ -420,7 +420,7 @@ mod tests { Ok(solana_sdk::account::Account { lamports: 1000000, data: vec![], - owner: spl_token_2022::id(), + owner: spl_token_2022_interface::id(), executable: false, rent_epoch: 0, }) @@ -429,7 +429,7 @@ mod tests { let result = SolanaTokenProgram::get_token_program_for_mint(&mock_provider, &mint).await; assert!(result.is_ok()); - assert_eq!(result.unwrap(), spl_token_2022::id()); + assert_eq!(result.unwrap(), spl_token_2022_interface::id()); } #[tokio::test] @@ -463,14 +463,18 @@ mod tests { #[test] fn test_is_token_program() { - assert!(SolanaTokenProgram::is_token_program(&spl_token::id())); - assert!(SolanaTokenProgram::is_token_program(&spl_token_2022::id())); + assert!(SolanaTokenProgram::is_token_program( + &spl_token_interface::id() + )); + assert!(SolanaTokenProgram::is_token_program( + &spl_token_2022_interface::id() + )); assert!(!SolanaTokenProgram::is_token_program(&Pubkey::new_unique())); } #[test] fn test_create_transfer_checked_instruction_spl_token() { - let program_id = spl_token::id(); + let program_id = spl_token_interface::id(); let source = Pubkey::new_unique(); let mint = Pubkey::new_unique(); let destination = Pubkey::new_unique(); @@ -500,7 +504,7 @@ mod tests { #[test] fn test_create_transfer_checked_instruction_token_2022() { - let program_id = spl_token_2022::id(); + let program_id = spl_token_2022_interface::id(); let source = Pubkey::new_unique(); let mint = Pubkey::new_unique(); let destination = Pubkey::new_unique(); @@ -557,7 +561,7 @@ mod tests { #[test] fn test_unpack_account_spl_token() { - let program_id = spl_token::id(); + let program_id = spl_token_interface::id(); let mint = Pubkey::new_unique(); let owner = Pubkey::new_unique(); let amount = 1000; @@ -566,7 +570,7 @@ mod tests { mint, owner, amount, - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; @@ -593,7 +597,7 @@ mod tests { #[test] fn test_unpack_account_token_2022() { - let program_id = spl_token_2022::id(); + let program_id = spl_token_2022_interface::id(); let mint = Pubkey::new_unique(); let owner = Pubkey::new_unique(); let amount = 1000; @@ -602,7 +606,7 @@ mod tests { mint, owner, amount, - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; @@ -638,7 +642,7 @@ mod tests { mint, owner, amount, - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; @@ -663,7 +667,7 @@ mod tests { #[test] fn test_get_associated_token_address_spl_token() { - let program_id = spl_token::id(); + let program_id = spl_token_interface::id(); let wallet = Pubkey::new_unique(); let mint = Pubkey::new_unique(); @@ -675,7 +679,7 @@ mod tests { #[test] fn test_get_associated_token_address_token_2022() { - let program_id = spl_token_2022::id(); + let program_id = spl_token_2022_interface::id(); let wallet = Pubkey::new_unique(); let mint = Pubkey::new_unique(); @@ -687,7 +691,7 @@ mod tests { #[test] fn test_create_associated_token_account() { - let program_id = spl_token::id(); + let program_id = spl_token_interface::id(); let payer = Pubkey::new_unique(); let wallet = Pubkey::new_unique(); let mint = Pubkey::new_unique(); @@ -713,10 +717,10 @@ mod tests { #[test] fn test_unpack_instruction_spl_token_transfer() { - let program_id = spl_token::id(); + let program_id = spl_token_interface::id(); let amount = 1000u64; - let instruction = spl_token::instruction::transfer( + let instruction = spl_token_interface::instruction::transfer( &program_id, &Pubkey::new_unique(), &Pubkey::new_unique(), @@ -741,11 +745,11 @@ mod tests { #[test] fn test_unpack_instruction_spl_token_transfer_checked() { - let program_id = spl_token::id(); + let program_id = spl_token_interface::id(); let amount = 1000u64; let decimals = 9u8; - let instruction = spl_token::instruction::transfer_checked( + let instruction = spl_token_interface::instruction::transfer_checked( &program_id, &Pubkey::new_unique(), &Pubkey::new_unique(), @@ -774,11 +778,11 @@ mod tests { #[test] fn test_unpack_instruction_token_2022_transfer() { - let program_id = spl_token_2022::id(); + let program_id = spl_token_2022_interface::id(); let amount = 1000u64; #[allow(deprecated)] - let instruction = spl_token_2022::instruction::transfer( + let instruction = spl_token_2022_interface::instruction::transfer( &program_id, &Pubkey::new_unique(), &Pubkey::new_unique(), @@ -803,11 +807,11 @@ mod tests { #[test] fn test_unpack_instruction_token_2022_transfer_checked() { - let program_id = spl_token_2022::id(); + let program_id = spl_token_2022_interface::id(); let amount = 1000u64; let decimals = 9u8; - let instruction = spl_token_2022::instruction::transfer_checked( + let instruction = spl_token_2022_interface::instruction::transfer_checked( &program_id, &Pubkey::new_unique(), &Pubkey::new_unique(), diff --git a/src/domain/transaction/common.rs b/src/domain/transaction/common.rs index 5d7d587f7..af3e390ed 100644 --- a/src/domain/transaction/common.rs +++ b/src/domain/transaction/common.rs @@ -4,8 +4,10 @@ //! across multiple blockchain domains (EVM, Solana, Stellar) to avoid //! cross-domain dependencies. +use chrono::{DateTime, Duration, Utc}; + use crate::constants::FINAL_TRANSACTION_STATUSES; -use crate::models::TransactionStatus; +use crate::models::{TransactionError, TransactionRepoModel, TransactionStatus}; /// Checks if a transaction is in a final state (confirmed, failed, canceled, or expired). /// @@ -24,8 +26,29 @@ pub fn is_final_state(tx_status: &TransactionStatus) -> bool { FINAL_TRANSACTION_STATUSES.contains(tx_status) } +pub fn is_pending_transaction(tx_status: &TransactionStatus) -> bool { + matches!( + tx_status, + TransactionStatus::Pending | TransactionStatus::Sent | TransactionStatus::Submitted + ) +} + +/// Gets the age of a transaction since it was sent. +pub fn get_age_of_sent_at(tx: &TransactionRepoModel) -> Result { + let now = Utc::now(); + let sent_at_str = tx.sent_at.as_ref().ok_or_else(|| { + TransactionError::UnexpectedError("Transaction sent_at time is missing".to_string()) + })?; + let sent_time = DateTime::parse_from_rfc3339(sent_at_str) + .map_err(|_| TransactionError::UnexpectedError("Error parsing sent_at time".to_string()))? + .with_timezone(&Utc); + Ok(now.signed_duration_since(sent_time)) +} + #[cfg(test)] mod tests { + use crate::utils::mocks::mockutils::create_mock_transaction; + use super::*; #[test] @@ -42,4 +65,69 @@ mod tests { assert!(!is_final_state(&TransactionStatus::Submitted)); assert!(!is_final_state(&TransactionStatus::Mined)); } + + #[test] + fn test_is_pending_transaction() { + // Test pending status + assert!(is_pending_transaction(&TransactionStatus::Pending)); + + // Test sent status + assert!(is_pending_transaction(&TransactionStatus::Sent)); + + // Test submitted status + assert!(is_pending_transaction(&TransactionStatus::Submitted)); + + // Test non-pending statuses + assert!(!is_pending_transaction(&TransactionStatus::Confirmed)); + assert!(!is_pending_transaction(&TransactionStatus::Failed)); + assert!(!is_pending_transaction(&TransactionStatus::Canceled)); + assert!(!is_pending_transaction(&TransactionStatus::Mined)); + assert!(!is_pending_transaction(&TransactionStatus::Expired)); + } + + #[test] + fn test_get_age_of_sent_at() { + let now = Utc::now(); + + // Test with valid sent_at timestamp (1 hour ago) + let sent_at_time = now - Duration::hours(1); + let mut tx = create_mock_transaction(); + tx.sent_at = Some(sent_at_time.to_rfc3339()); + + let age_result = get_age_of_sent_at(&tx); + assert!(age_result.is_ok()); + let age = age_result.unwrap(); + // Age should be approximately 1 hour (with some tolerance for test execution time) + assert!(age.num_minutes() >= 59 && age.num_minutes() <= 61); + } + + #[test] + fn test_get_age_of_sent_at_missing_sent_at() { + let mut tx = create_mock_transaction(); + tx.sent_at = None; // Missing sent_at + + let result = get_age_of_sent_at(&tx); + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::UnexpectedError(msg) => { + assert!(msg.contains("sent_at time is missing")); + } + _ => panic!("Expected UnexpectedError for missing sent_at"), + } + } + + #[test] + fn test_get_age_of_sent_at_invalid_timestamp() { + let mut tx = create_mock_transaction(); + tx.sent_at = Some("invalid-timestamp".to_string()); // Invalid timestamp format + + let result = get_age_of_sent_at(&tx); + assert!(result.is_err()); + match result.unwrap_err() { + TransactionError::UnexpectedError(msg) => { + assert!(msg.contains("Error parsing sent_at time")); + } + _ => panic!("Expected UnexpectedError for invalid timestamp"), + } + } } diff --git a/src/domain/transaction/evm/status.rs b/src/domain/transaction/evm/status.rs index 15e26da49..1b48927a3 100644 --- a/src/domain/transaction/evm/status.rs +++ b/src/domain/transaction/evm/status.rs @@ -9,16 +9,18 @@ use tracing::{debug, error, info, warn}; use super::EvmRelayerTransaction; use super::{ - ensure_status, get_age_of_sent_at, get_age_since_status_change, has_enough_confirmations, - is_noop, is_pending_transaction, is_too_early_to_resubmit, is_transaction_valid, make_noop, - too_many_attempts, too_many_noop_attempts, + ensure_status, get_age_since_status_change, has_enough_confirmations, is_noop, + is_too_early_to_resubmit, is_transaction_valid, make_noop, too_many_attempts, + too_many_noop_attempts, }; use crate::constants::{ get_evm_min_age_for_hash_recovery, get_evm_pending_recovery_trigger_timeout, get_evm_prepare_timeout, get_evm_resend_timeout, ARBITRUM_TIME_TO_RESUBMIT, EVM_MIN_HASHES_FOR_RECOVERY, EVM_PREPARE_TIMEOUT_MINUTES, }; -use crate::domain::transaction::common::is_final_state; +use crate::domain::transaction::common::{ + get_age_of_sent_at, is_final_state, is_pending_transaction, +}; use crate::domain::transaction::util::get_age_since_created; use crate::models::{EvmNetwork, NetworkRepoModel, NetworkType}; use crate::repositories::{NetworkRepository, RelayerRepository}; diff --git a/src/domain/transaction/evm/utils.rs b/src/domain/transaction/evm/utils.rs index 60cc32bcb..b6947b71e 100644 --- a/src/domain/transaction/evm/utils.rs +++ b/src/domain/transaction/evm/utils.rs @@ -72,12 +72,6 @@ pub fn too_many_noop_attempts(tx: &TransactionRepoModel) -> bool { tx.noop_count.unwrap_or(0) > MAXIMUM_NOOP_RETRY_ATTEMPTS } -pub fn is_pending_transaction(tx_status: &TransactionStatus) -> bool { - tx_status == &TransactionStatus::Pending - || tx_status == &TransactionStatus::Sent - || tx_status == &TransactionStatus::Submitted -} - /// Validates that a transaction is in the expected state. /// /// This enforces state machine invariants and prevents invalid state transitions. @@ -183,18 +177,6 @@ pub fn is_transaction_valid(created_at: &str, valid_until: &Option) -> b } } -/// Gets the age of a transaction since it was sent. -pub fn get_age_of_sent_at(tx: &TransactionRepoModel) -> Result { - let now = Utc::now(); - let sent_at_str = tx.sent_at.as_ref().ok_or_else(|| { - TransactionError::UnexpectedError("Transaction sent_at time is missing".to_string()) - })?; - let sent_time = DateTime::parse_from_rfc3339(sent_at_str) - .map_err(|_| TransactionError::UnexpectedError("Error parsing sent_at time".to_string()))? - .with_timezone(&Utc); - Ok(now.signed_duration_since(sent_time)) -} - /// Get age since status last changed /// Uses sent_at, otherwise falls back to created_at pub fn get_age_since_status_change( @@ -224,6 +206,13 @@ pub fn is_too_early_to_resubmit(tx: &TransactionRepoModel) -> Result Result { + is_too_early_to_resubmit(tx) +} + #[cfg(test)] mod tests { use super::*; @@ -696,25 +685,6 @@ mod tests { assert!(!is_transaction_valid("", &valid_until)); } - #[test] - fn test_is_pending_transaction() { - // Test pending status - assert!(is_pending_transaction(&TransactionStatus::Pending)); - - // Test sent status - assert!(is_pending_transaction(&TransactionStatus::Sent)); - - // Test submitted status - assert!(is_pending_transaction(&TransactionStatus::Submitted)); - - // Test non-pending statuses - assert!(!is_pending_transaction(&TransactionStatus::Confirmed)); - assert!(!is_pending_transaction(&TransactionStatus::Failed)); - assert!(!is_pending_transaction(&TransactionStatus::Canceled)); - assert!(!is_pending_transaction(&TransactionStatus::Mined)); - assert!(!is_pending_transaction(&TransactionStatus::Expired)); - } - #[test] fn test_ensure_status_success() { let tx = make_test_transaction(TransactionStatus::Pending); @@ -951,142 +921,6 @@ mod tests { } } - #[test] - fn test_get_age_of_sent_at() { - let now = Utc::now(); - - // Test with valid sent_at timestamp (1 hour ago) - let sent_at_time = now - Duration::hours(1); - let tx = TransactionRepoModel { - id: "test-tx".to_string(), - relayer_id: "test-relayer".to_string(), - status: TransactionStatus::Sent, - status_reason: None, - created_at: "2024-01-01T00:00:00Z".to_string(), - sent_at: Some(sent_at_time.to_rfc3339()), - confirmed_at: None, - valid_until: None, - network_type: crate::models::NetworkType::Evm, - network_data: NetworkTransactionData::Evm(EvmTransactionData { - from: "0x1234".to_string(), - to: Some("0x5678".to_string()), - value: U256::from(0u64), - data: Some("0x".to_string()), - gas_limit: Some(21000), - gas_price: Some(10_000_000_000), - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - nonce: Some(42), - signature: None, - hash: None, - speed: Some(Speed::Fast), - chain_id: 1, - raw: None, - }), - priced_at: None, - hashes: vec![], - noop_count: None, - is_canceled: Some(false), - delete_at: None, - }; - - let age_result = get_age_of_sent_at(&tx); - assert!(age_result.is_ok()); - let age = age_result.unwrap(); - // Age should be approximately 1 hour (with some tolerance for test execution time) - assert!(age.num_minutes() >= 59 && age.num_minutes() <= 61); - } - - #[test] - fn test_get_age_of_sent_at_missing_sent_at() { - let tx = TransactionRepoModel { - id: "test-tx".to_string(), - relayer_id: "test-relayer".to_string(), - status: TransactionStatus::Pending, - status_reason: None, - created_at: "2024-01-01T00:00:00Z".to_string(), - sent_at: None, // Missing sent_at - confirmed_at: None, - valid_until: None, - network_type: crate::models::NetworkType::Evm, - network_data: NetworkTransactionData::Evm(EvmTransactionData { - from: "0x1234".to_string(), - to: Some("0x5678".to_string()), - value: U256::from(0u64), - data: Some("0x".to_string()), - gas_limit: Some(21000), - gas_price: Some(10_000_000_000), - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - nonce: Some(42), - signature: None, - hash: None, - speed: Some(Speed::Fast), - chain_id: 1, - raw: None, - }), - priced_at: None, - hashes: vec![], - noop_count: None, - is_canceled: Some(false), - delete_at: None, - }; - - let result = get_age_of_sent_at(&tx); - assert!(result.is_err()); - match result.unwrap_err() { - TransactionError::UnexpectedError(msg) => { - assert!(msg.contains("sent_at time is missing")); - } - _ => panic!("Expected UnexpectedError for missing sent_at"), - } - } - - #[test] - fn test_get_age_of_sent_at_invalid_timestamp() { - let tx = TransactionRepoModel { - id: "test-tx".to_string(), - relayer_id: "test-relayer".to_string(), - status: TransactionStatus::Sent, - status_reason: None, - created_at: "2024-01-01T00:00:00Z".to_string(), - sent_at: Some("invalid-timestamp".to_string()), // Invalid timestamp format - confirmed_at: None, - valid_until: None, - network_type: crate::models::NetworkType::Evm, - network_data: NetworkTransactionData::Evm(EvmTransactionData { - from: "0x1234".to_string(), - to: Some("0x5678".to_string()), - value: U256::from(0u64), - data: Some("0x".to_string()), - gas_limit: Some(21000), - gas_price: Some(10_000_000_000), - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - nonce: Some(42), - signature: None, - hash: None, - speed: Some(Speed::Fast), - chain_id: 1, - raw: None, - }), - priced_at: None, - hashes: vec![], - noop_count: None, - is_canceled: Some(false), - delete_at: None, - }; - - let result = get_age_of_sent_at(&tx); - assert!(result.is_err()); - match result.unwrap_err() { - TransactionError::UnexpectedError(msg) => { - assert!(msg.contains("Error parsing sent_at time")); - } - _ => panic!("Expected UnexpectedError for invalid timestamp"), - } - } - #[test] fn test_get_age_since_created() { let now = Utc::now(); diff --git a/src/domain/transaction/mod.rs b/src/domain/transaction/mod.rs index 1aa378fcf..66500c6b1 100644 --- a/src/domain/transaction/mod.rs +++ b/src/domain/transaction/mod.rs @@ -27,7 +27,7 @@ use crate::{ price_params_handler::PriceParamsHandler, }, provider::get_network_provider, - signer::{EvmSignerFactory, StellarSignerFactory}, + signer::{EvmSignerFactory, SolanaSignerFactory, StellarSignerFactory}, }, }; use async_trait::async_trait; @@ -44,6 +44,8 @@ pub mod stellar; mod util; pub use util::*; +// Explicit re-exports to avoid ambiguous glob re-exports +pub use common::is_final_state; pub use common::*; pub use evm::{ensure_status, ensure_status_one_of, DefaultEvmTransaction, EvmRelayerTransaction}; pub use solana::{DefaultSolanaTransaction, SolanaRelayerTransaction}; @@ -474,12 +476,16 @@ impl RelayerTransactionFactory { relayer.custom_rpc_urls.clone(), )?); + let signer_service = + Arc::new(SolanaSignerFactory::create_solana_signer(&signer.into())?); + Ok(NetworkTransaction::Solana(SolanaRelayerTransaction::new( relayer, relayer_repository, solana_provider, transaction_repository, job_producer, + signer_service, )?)) } NetworkType::Stellar => { diff --git a/src/domain/transaction/solana/mod.rs b/src/domain/transaction/solana/mod.rs index 499bacfbe..7f03d1539 100644 --- a/src/domain/transaction/solana/mod.rs +++ b/src/domain/transaction/solana/mod.rs @@ -2,3 +2,7 @@ mod solana_transaction; pub use solana_transaction::*; pub mod status; +pub mod utils; +pub mod validation; + +pub use validation::*; diff --git a/src/domain/transaction/solana/solana_transaction.rs b/src/domain/transaction/solana/solana_transaction.rs index 0c0f3598e..25d737106 100644 --- a/src/domain/transaction/solana/solana_transaction.rs +++ b/src/domain/transaction/solana/solana_transaction.rs @@ -4,34 +4,55 @@ //! implements the Transaction trait for Solana transactions. use async_trait::async_trait; +use chrono::Utc; use eyre::Result; +use solana_sdk::{pubkey::Pubkey, transaction::Transaction as SolanaTransaction}; +use std::str::FromStr; use std::sync::Arc; -use tracing::info; +use tracing::{debug, error, info, warn}; use crate::{ - domain::transaction::Transaction, - jobs::{JobProducer, JobProducerTrait}, - models::{NetworkTransactionRequest, RelayerRepoModel, TransactionError, TransactionRepoModel}, + domain::transaction::{ + solana::{ + utils::{ + build_transaction_from_instructions, decode_solana_transaction, + decode_solana_transaction_from_string, is_resubmitable, + }, + validation::SolanaTransactionValidator, + }, + Transaction, + }, + jobs::{JobProducer, JobProducerTrait, TransactionSend}, + models::{ + produce_transaction_update_notification_payload, EncodedSerializedTransaction, + NetworkTransactionData, NetworkTransactionRequest, RelayerRepoModel, SolanaTransactionData, + TransactionError, TransactionRepoModel, TransactionStatus, TransactionUpdateRequest, + }, repositories::{ RelayerRepository, RelayerRepositoryStorage, Repository, TransactionRepository, TransactionRepositoryStorage, }, - services::provider::{SolanaProvider, SolanaProviderTrait}, + services::{ + provider::{SolanaProvider, SolanaProviderError, SolanaProviderTrait}, + signer::{SolanaSignTrait, SolanaSigner}, + }, }; #[allow(dead_code)] -pub struct SolanaRelayerTransaction +pub struct SolanaRelayerTransaction where - P: SolanaProviderTrait, + P: SolanaProviderTrait + Send + Sync + 'static, RR: RelayerRepository + Repository + Send + Sync + 'static, TR: TransactionRepository + Repository + Send + Sync + 'static, J: JobProducerTrait + Send + Sync + 'static, + S: SolanaSignTrait + Send + Sync + 'static, { relayer: RelayerRepoModel, relayer_repository: Arc, provider: Arc

, job_producer: Arc, transaction_repository: Arc, + signer: Arc, } pub type DefaultSolanaTransaction = SolanaRelayerTransaction< @@ -39,15 +60,17 @@ pub type DefaultSolanaTransaction = SolanaRelayerTransaction< RelayerRepositoryStorage, TransactionRepositoryStorage, JobProducer, + SolanaSigner, >; #[allow(dead_code)] -impl SolanaRelayerTransaction +impl SolanaRelayerTransaction where - P: SolanaProviderTrait, + P: SolanaProviderTrait + Send + Sync + 'static, RR: RelayerRepository + Repository + Send + Sync + 'static, TR: TransactionRepository + Repository + Send + Sync + 'static, J: JobProducerTrait + Send + Sync + 'static, + S: SolanaSignTrait + Send + Sync + 'static, { pub fn new( relayer: RelayerRepoModel, @@ -55,6 +78,7 @@ where provider: Arc

, transaction_repository: Arc, job_producer: Arc, + signer: Arc, ) -> Result { Ok(Self { relayer, @@ -62,10 +86,10 @@ where provider, transaction_repository, job_producer, + signer, }) } - // Getter methods for status module access pub(super) fn provider(&self) -> &P { &self.provider } @@ -81,38 +105,595 @@ where pub(super) fn job_producer(&self) -> &J { &self.job_producer } + + pub(super) fn signer(&self) -> &S { + &self.signer + } + + /// Prepare transaction - validate and sign + async fn prepare_transaction_impl( + &self, + tx: TransactionRepoModel, + ) -> Result { + debug!(tx_id = %tx.id, status = ?tx.status, "preparing Solana transaction"); + + // If transaction is not in Pending status, return Ok to avoid wasteful retries + // (e.g., if it's already Sent, Failed, or in another state) + if tx.status != TransactionStatus::Pending { + debug!( + tx_id = %tx.id, + status = ?tx.status, + expected_status = ?TransactionStatus::Pending, + "transaction not in Pending status, skipping preparation" + ); + return Ok(tx); + } + + let solana_data = tx.network_data.get_solana_transaction_data()?; + + // Build or decode transaction based on input mode + let mut transaction = if let Some(transaction_str) = &solana_data.transaction { + // Transaction mode: decode pre-built transaction + // Use the provided blockhash from user - resubmit logic will handle expiration if needed + debug!( + tx_id = %tx.id, + "transaction mode: using pre-built transaction with provided blockhash" + ); + decode_solana_transaction_from_string(transaction_str)? + } else if let Some(instructions) = &solana_data.instructions { + // Instructions mode: build transaction from instructions with fresh blockhash + debug!( + tx_id = %tx.id, + "instructions mode: building transaction with fresh blockhash" + ); + + let payer = Pubkey::from_str(&self.relayer.address).map_err(|e| { + TransactionError::ValidationError(format!("Invalid relayer address: {}", e)) + })?; + + // Fetch fresh blockhash for instructions mode + let latest_blockhash = self.provider.get_latest_blockhash().await?; + + build_transaction_from_instructions(instructions, &payer, latest_blockhash)? + } else { + // Neither transaction nor instructions provided - permanent validation error + let validation_error = TransactionError::ValidationError( + "Must provide either transaction or instructions".to_string(), + ); + + let updated_tx = self + .fail_transaction_with_notification(&tx, &validation_error) + .await?; + + // Return Ok since transaction is in final Failed state - no retry needed + return Ok(updated_tx); + }; + + // Validate transaction before signing + // Distinguish between transient errors (RPC issues) and permanent errors (policy violations) + if let Err(validation_error) = self.validate_transaction_impl(&transaction).await { + // Determine if the error is transient + let is_transient = validation_error.is_transient(); + + if is_transient { + warn!( + tx_id = %tx.id, + error = %validation_error, + "transient validation error (likely RPC/network issue), will retry" + ); + return Err(validation_error); + } else { + // Permanent validation error (policy violation, insufficient balance, etc.) - mark as failed + warn!( + tx_id = %tx.id, + error = %validation_error, + "permanent validation error, marking transaction as failed" + ); + + let updated_tx = self + .fail_transaction_with_notification(&tx, &validation_error) + .await?; + + // Return Ok since transaction is in final Failed state - no retry needed + return Ok(updated_tx); + } + } + + // Sign transaction + let signature = self + .signer + .sign(&transaction.message_data()) + .await + .map_err(|e| TransactionError::SignerError(e.to_string()))?; + + transaction.signatures[0] = signature; + + // Update transaction with signature + let update = TransactionUpdateRequest { + status: Some(TransactionStatus::Sent), + network_data: Some(NetworkTransactionData::Solana(SolanaTransactionData { + signature: Some(signature.to_string()), + transaction: Some( + EncodedSerializedTransaction::try_from(&transaction) + .map_err(|e| { + TransactionError::ValidationError(format!( + "Failed to encode transaction: {}", + e + )) + })? + .into_inner(), + ), + instructions: solana_data.instructions, + })), + ..Default::default() + }; + + let updated_tx = self + .transaction_repository + .partial_update(tx.id.clone(), update) + .await?; + + // After preparing the transaction, produce a submit job to send it to the blockchain + self.job_producer + .produce_submit_transaction_job( + TransactionSend::submit(updated_tx.id.clone(), updated_tx.relayer_id.clone()), + None, + ) + .await?; + + // Send notification as best-effort (errors logged but not propagated) + if let Err(e) = self.send_transaction_update_notification(&updated_tx).await { + error!( + tx_id = %updated_tx.id, + status = ?TransactionStatus::Sent, + "sending transaction update notification failed after prepare: {:?}", + e + ); + } + + Ok(updated_tx) + } + + /// Submit transaction to blockchain + async fn submit_transaction_impl( + &self, + tx: TransactionRepoModel, + ) -> Result { + debug!(tx_id = %tx.id, status = ?tx.status, "submitting Solana transaction to blockchain"); + + if tx.status != TransactionStatus::Sent && tx.status != TransactionStatus::Submitted { + debug!( + tx_id = %tx.id, + status = ?tx.status, + "transaction not in expected status for submission, skipping" + ); + return Ok(tx); + } + + // Extract Solana transaction data and decode + let solana_data = tx.network_data.get_solana_transaction_data()?; + let transaction = decode_solana_transaction(&tx)?; + + // Send to blockchain + match self.provider.send_transaction(&transaction).await { + Ok(sig) => sig, + Err(provider_error) => { + // Special case: AlreadyProcessed means transaction is already on-chain + if matches!(provider_error, SolanaProviderError::AlreadyProcessed(_)) { + debug!( + tx_id = %tx.id, + signature = ?solana_data.signature, + "transaction already processed on-chain" + ); + + // Transaction is already on-chain with existing signature. + // Return as-is - the status check job will query and update to the actual on-chain status. + return Ok(tx); + } + + // Special case: BlockhashNotFound handling depends on signature requirements + if matches!(provider_error, SolanaProviderError::BlockhashNotFound(_)) + && is_resubmitable(&transaction) + { + // Single-signer: Can update blockhash via resubmit + // Return Ok to allow status check to detect expiration and trigger resubmit + // The resubmit logic will fetch fresh blockhash, re-sign, and resubmit + debug!( + tx_id = %tx.id, + error = %provider_error, + "blockhash expired for single-signer transaction, status check will trigger resubmit" + ); + return Ok(tx); + } + + error!( + tx_id = %tx.id, + error = %provider_error, + "failed to send transaction to blockchain" + ); + + // Check if error is transient or permanent + if provider_error.is_transient() { + // Transient error - propagate so job can retry + return Err(TransactionError::UnderlyingSolanaProvider(provider_error)); + } else { + // Non-transient error - mark as failed and send notification + let error = TransactionError::UnderlyingSolanaProvider(provider_error); + let updated_tx = self.fail_transaction_with_notification(&tx, &error).await?; + + // Return Ok with failed transaction since it's in final state + return Ok(updated_tx); + } + } + }; + + debug!(tx_id = %tx.id, "transaction submitted successfully to blockchain"); + + // Transaction is now on-chain - update status and timestamp + // Append signature to hashes array to track attempts + let signature_str = transaction.signatures[0].to_string(); + let mut updated_hashes = tx.hashes.clone(); + updated_hashes.push(signature_str.clone()); + + let update = TransactionUpdateRequest { + status: Some(TransactionStatus::Submitted), + sent_at: Some(Utc::now().to_rfc3339()), + hashes: Some(updated_hashes), + ..Default::default() + }; + + let updated_tx = match self + .transaction_repository + .partial_update(tx.id.clone(), update) + .await + { + Ok(tx) => tx, + Err(e) => { + error!( + error = %e, + tx_id = %tx.id, + "CRITICAL: transaction sent to blockchain but failed to update database - transaction may not be tracked correctly" + ); + // Transaction is on-chain - don't propagate error to avoid wasteful retries + // Return the original transaction data + tx + } + }; + + // Send notification as best-effort (errors logged but not propagated) + if let Err(e) = self.send_transaction_update_notification(&updated_tx).await { + error!( + tx_id = %updated_tx.id, + status = ?TransactionStatus::Submitted, + "sending transaction update notification failed after submit: {:?}", + e + ); + } + + Ok(updated_tx) + } + + /// Resubmit transaction + async fn resubmit_transaction_impl( + &self, + tx: TransactionRepoModel, + ) -> Result { + debug!(tx_id = %tx.id, "resubmitting Solana transaction"); + + // Validate transaction is in correct status for resubmission + if !matches!( + tx.status, + TransactionStatus::Sent | TransactionStatus::Submitted + ) { + warn!( + tx_id = %tx.id, + status = ?tx.status, + "transaction not in expected status for resubmission, skipping" + ); + return Ok(tx); + } + + // Decode current transaction + let mut transaction = decode_solana_transaction(&tx)?; + + info!( + tx_id = %tx.id, + old_blockhash = %transaction.message.recent_blockhash, + "fetching fresh blockhash for resubmission" + ); + + // Fetch fresh blockhash + // SolanaProviderError automatically converts to TransactionError::UnderlyingSolanaProvider + let fresh_blockhash = self.provider.get_latest_blockhash().await?; + + // Update transaction with fresh blockhash + transaction.message.recent_blockhash = fresh_blockhash; + + // Re-sign the transaction with the updated message + // SignerError automatically converts to TransactionError::SignerError + let signature = self.signer.sign(&transaction.message_data()).await?; + + // Update transaction signature + transaction.signatures[0] = signature; + + // Append new signature to hashes array to track resubmission attempts + let mut updated_hashes = tx.hashes.clone(); + updated_hashes.push(signature.to_string()); + + // Update in repository with Submitted status and new sent_at + let update_request = TransactionUpdateRequest { + status: Some(TransactionStatus::Submitted), + network_data: Some(NetworkTransactionData::Solana(SolanaTransactionData { + signature: Some(signature.to_string()), + transaction: Some( + EncodedSerializedTransaction::try_from(&transaction) + .map_err(|e| { + TransactionError::ValidationError(format!( + "Failed to encode transaction: {}", + e + )) + })? + .into_inner(), + ), + ..Default::default() + })), + sent_at: Some(Utc::now().to_rfc3339()), + hashes: Some(updated_hashes), + ..Default::default() + }; + + // Send resubmitted transaction to blockchain directly - this is the critical operation + let was_already_processed = match self.provider.send_transaction(&transaction).await { + Ok(sig) => { + info!( + tx_id = %tx.id, + signature = %sig, + new_blockhash = %fresh_blockhash, + "transaction resubmitted successfully with fresh blockhash" + ); + false + } + Err(e) => { + // Special case: AlreadyProcessed means transaction is already on-chain + if matches!(e, SolanaProviderError::AlreadyProcessed(_)) { + warn!( + tx_id = %tx.id, + error = %e, + "resubmission indicates transaction already on-chain - keeping original signature" + ); + // Don't update with new signature - the original transaction is what's on-chain + true + } else if e.is_transient() { + // Transient error (network, RPC) - return for retry + warn!( + tx_id = %tx.id, + error = %e, + "transient error during resubmission, will retry" + ); + return Err(TransactionError::UnderlyingSolanaProvider(e)); + } else { + // Permanent error (invalid tx, insufficient funds) - mark as failed + warn!( + tx_id = %tx.id, + error = %e, + "permanent error during resubmission, marking transaction as failed" + ); + let updated_tx = self + .fail_transaction_with_notification( + &tx, + &TransactionError::UnderlyingSolanaProvider(e), + ) + .await?; + return Ok(updated_tx); + } + } + }; + + // If transaction was already processed, don't update anything - status check will handle it + let updated_tx = if was_already_processed { + // Transaction already on-chain - return as-is, status check job will update to Confirmed/Mined + info!( + tx_id = %tx.id, + "transaction already on-chain, no update needed - status check will handle confirmation" + ); + tx + } else { + // Transaction resubmitted successfully - update with new signature and blockhash + let tx = match self + .transaction_repository + .partial_update(tx.id.clone(), update_request) + .await + { + Ok(tx) => tx, + Err(e) => { + error!( + error = %e, + tx_id = %tx.id, + "CRITICAL: resubmitted transaction sent to blockchain but failed to update database" + ); + // Transaction is on-chain - return original tx data to avoid wasteful retries + tx + } + }; + + info!( + tx_id = %tx.id, + new_signature = %signature, + new_blockhash = %fresh_blockhash, + "transaction resubmitted with fresh blockhash" + ); + + tx + }; + + Ok(updated_tx) + } + + /// Helper method to send transaction update notification. + /// + /// This is a best-effort operation that logs errors but does not propagate them, + /// as notification failures should not affect the transaction lifecycle. + pub(super) async fn send_transaction_update_notification( + &self, + tx: &TransactionRepoModel, + ) -> Result<(), eyre::Report> { + if let Some(notification_id) = &self.relayer.notification_id { + self.job_producer + .produce_send_notification_job( + produce_transaction_update_notification_payload(notification_id, tx), + None, + ) + .await?; + } + Ok(()) + } + + /// Marks a transaction as failed, updates the database, and sends notification. + /// + /// This is a convenience method that combines: + /// 1. Marking transaction as Failed + /// 2. Sending notification (best-effort, errors logged but not propagated) + async fn fail_transaction_with_notification( + &self, + tx: &TransactionRepoModel, + error: &TransactionError, + ) -> Result { + let updated_tx = self.mark_transaction_as_failed(tx, error).await?; + + // Send notification as best-effort (errors logged but not propagated) + if let Err(e) = self.send_transaction_update_notification(&updated_tx).await { + error!( + tx_id = %updated_tx.id, + status = ?TransactionStatus::Failed, + error = %error, + notification_error = %e, + "failed to send notification for failed transaction" + ); + } + + Ok(updated_tx) + } + + /// Marks a transaction as failed and updates the database. + async fn mark_transaction_as_failed( + &self, + tx: &TransactionRepoModel, + error: &TransactionError, + ) -> Result { + warn!( + tx_id = %tx.id, + error = %error, + "marking transaction as Failed" + ); + + let update = TransactionUpdateRequest { + status: Some(TransactionStatus::Failed), + status_reason: Some(error.to_string()), + ..Default::default() + }; + + let updated_tx = self + .transaction_repository + .partial_update(tx.id.clone(), update) + .await?; + + Ok(updated_tx) + } + + async fn validate_transaction_impl( + &self, + tx: &SolanaTransaction, + ) -> Result<(), TransactionError> { + use futures::{try_join, TryFutureExt}; + + let policy = self.relayer.policies.get_solana_policy(); + let relayer_pubkey = Pubkey::from_str(&self.relayer.address).map_err(|e| { + TransactionError::ValidationError(format!("Invalid relayer address: {}", e)) + })?; + + // Group all synchronous policy validations together + let sync_validations = async { + SolanaTransactionValidator::validate_tx_allowed_accounts(tx, &policy)?; + SolanaTransactionValidator::validate_tx_disallowed_accounts(tx, &policy)?; + SolanaTransactionValidator::validate_allowed_programs(tx, &policy)?; + SolanaTransactionValidator::validate_max_signatures(tx, &policy)?; + SolanaTransactionValidator::validate_fee_payer(tx, &relayer_pubkey)?; + SolanaTransactionValidator::validate_data_size(tx, &policy)?; + Ok::<(), TransactionError>(()) + }; + + // Fee calculation and validation (async - needs RPC calls) + let fee_validations = async { + let fee = self + .provider + .calculate_total_fee(&tx.message) + .await + .map_err(TransactionError::from)?; + + SolanaTransactionValidator::validate_max_fee(fee, &policy)?; + + SolanaTransactionValidator::validate_sufficient_relayer_balance( + fee, + &self.relayer.address, + &policy, + self.provider.as_ref(), + ) + .await?; + + Ok::<(), TransactionError>(()) + }; + + // Run all validations in parallel for optimal performance + // Use map_err to convert SolanaTransactionValidationError to TransactionError + try_join!( + sync_validations, + SolanaTransactionValidator::validate_blockhash(tx, self.provider.as_ref()) + .map_err(TransactionError::from), + SolanaTransactionValidator::simulate_transaction(tx, self.provider.as_ref()) + .map_ok(|_| ()) // Discard simulation result, we only care about errors + .map_err(TransactionError::from), + SolanaTransactionValidator::validate_token_transfers( + tx, + &policy, + self.provider.as_ref(), + &relayer_pubkey, + ) + .map_err(TransactionError::from), + fee_validations, + )?; + + Ok(()) + } } #[async_trait] -impl Transaction for SolanaRelayerTransaction +impl Transaction for SolanaRelayerTransaction where P: SolanaProviderTrait, RR: RelayerRepository + Repository + Send + Sync + 'static, TR: TransactionRepository + Repository + Send + Sync + 'static, J: JobProducerTrait + Send + Sync + 'static, + S: SolanaSignTrait + Send + Sync + 'static, { async fn prepare_transaction( &self, tx: TransactionRepoModel, ) -> Result { - info!("preparing transaction"); - Ok(tx) + self.prepare_transaction_impl(tx).await } async fn submit_transaction( &self, tx: TransactionRepoModel, ) -> Result { - info!("submitting transaction"); - Ok(tx) + self.submit_transaction_impl(tx).await } async fn resubmit_transaction( &self, tx: TransactionRepoModel, ) -> Result { - info!("resubmitting transaction"); - Ok(tx) + self.resubmit_transaction_impl(tx).await } /// Main entry point for transaction status handling @@ -125,9 +706,11 @@ where async fn cancel_transaction( &self, - tx: TransactionRepoModel, + _tx: TransactionRepoModel, ) -> Result { - Ok(tx) + Err(TransactionError::NotSupported( + "Transaction cancellation is not supported for Solana".to_string(), + )) } async fn replace_transaction( @@ -135,20 +718,32 @@ where _old_tx: TransactionRepoModel, _new_tx_request: NetworkTransactionRequest, ) -> Result { - Ok(_old_tx) + Err(TransactionError::NotSupported( + "Transaction replacement is not supported for Solana".to_string(), + )) } async fn sign_transaction( &self, - tx: TransactionRepoModel, + _tx: TransactionRepoModel, ) -> Result { - Ok(tx) + Err(TransactionError::NotSupported( + "Standalone transaction signing is not supported for Solana - signing happens during prepare_transaction".to_string(), + )) } async fn validate_transaction( &self, - _tx: TransactionRepoModel, + tx: TransactionRepoModel, ) -> Result { + debug!(tx_id = %tx.id, "validating Solana transaction"); + + // Decode transaction + let transaction = decode_solana_transaction(&tx)?; + + // Run validation logic + self.validate_transaction_impl(&transaction).await?; + Ok(true) } } @@ -158,10 +753,18 @@ mod tests { use super::*; use crate::{ jobs::MockJobProducerTrait, + models::{ + Address, NetworkTransactionData, SignerError, SolanaTransactionData, TransactionStatus, + }, repositories::{MockRelayerRepository, MockTransactionRepository}, - services::provider::MockSolanaProviderTrait, + services::{ + provider::{MockSolanaProviderTrait, SolanaProviderError}, + signer::MockSolanaSignTrait, + }, utils::mocks::mockutils::{create_mock_solana_relayer, create_mock_solana_transaction}, }; + use solana_sdk::{hash::Hash, message::Message, pubkey::Pubkey, signature::Signature}; + use std::sync::Arc; #[tokio::test] async fn test_solana_transaction_creation() { @@ -170,6 +773,7 @@ mod tests { let provider = Arc::new(MockSolanaProviderTrait::new()); let transaction_repository = Arc::new(MockTransactionRepository::new()); let job_producer = Arc::new(MockJobProducerTrait::new()); + let signer = Arc::new(MockSolanaSignTrait::new()); let transaction = SolanaRelayerTransaction::new( relayer, @@ -177,44 +781,933 @@ mod tests { provider, transaction_repository, job_producer, + signer, ); assert!(transaction.is_ok()); } + #[tokio::test] + async fn test_prepare_transaction_transaction_mode_success() { + let mut provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let mut tx_repo = MockTransactionRepository::new(); + let mut job_producer = MockJobProducerTrait::new(); + let mut signer = MockSolanaSignTrait::new(); + + let relayer = create_mock_solana_relayer("test-relayer".to_string(), false); + let mut tx = create_mock_solana_transaction(); + tx.status = TransactionStatus::Pending; + + // Create a valid base64-encoded transaction + let signer_pubkey = Pubkey::from_str("11111111111111111111111111111112").unwrap(); + let recipient = Pubkey::new_unique(); + let message = Message::new( + &[solana_system_interface::instruction::transfer( + &signer_pubkey, + &recipient, + 1000, + )], + Some(&signer_pubkey), + ); + let transaction = solana_sdk::transaction::Transaction::new_unsigned(message); + let encoded_tx = EncodedSerializedTransaction::try_from(&transaction).unwrap(); + + // Set up transaction with pre-built transaction data + tx.network_data = NetworkTransactionData::Solana(SolanaTransactionData { + transaction: Some(encoded_tx.into_inner()), + ..Default::default() + }); + + let tx_id = tx.id.clone(); + let tx_id_clone = tx_id.clone(); + let tx_clone = tx.clone(); + + // Mock validation calls + provider + .expect_calculate_total_fee() + .returning(|_| Box::pin(async { Ok(5000) })); + provider + .expect_get_balance() + .returning(|_| Box::pin(async { Ok(1000000) })); + provider + .expect_is_blockhash_valid() + .returning(|_, _| Box::pin(async { Ok(true) })); + provider.expect_simulate_transaction().returning(|_| { + Box::pin(async { + Ok(solana_client::rpc_response::RpcSimulateTransactionResult { + err: None, + logs: Some(vec![]), + accounts: None, + units_consumed: Some(0), + return_data: None, + fee: Some(0), + inner_instructions: None, + loaded_accounts_data_size: Some(0), + replacement_blockhash: None, + pre_balances: Some(vec![]), + post_balances: Some(vec![]), + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, + }) + }) + }); + + // Mock signer + let signer_pubkey_str = signer_pubkey.to_string(); + signer.expect_pubkey().returning(move || { + let value = signer_pubkey_str.clone(); + Box::pin(async move { Ok(Address::Solana(value)) }) + }); + signer + .expect_sign() + .returning(|_| Box::pin(async { Ok(Signature::new_unique()) })); + + // Mock repository update + tx_repo + .expect_partial_update() + .withf(move |id, update| { + id == &tx_id_clone && matches!(update.status, Some(TransactionStatus::Sent)) + }) + .times(1) + .returning(move |_, _| { + let mut updated_tx = tx_clone.clone(); + updated_tx.status = TransactionStatus::Sent; + Ok(updated_tx) + }); + + // Mock job producer + job_producer + .expect_produce_submit_transaction_job() + .times(1) + .returning(|_, _| Box::pin(async { Ok(()) })); + + let handler = SolanaRelayerTransaction { + relayer, + relayer_repository: relayer_repo, + provider: Arc::new(provider), + transaction_repository: Arc::new(tx_repo), + job_producer: Arc::new(job_producer), + signer: Arc::new(signer), + }; + + let tx_for_test = tx.clone(); + let result = handler.prepare_transaction_impl(tx_for_test).await; + assert!(result.is_ok()); + let updated_tx = result.unwrap(); + assert_eq!(updated_tx.status, TransactionStatus::Sent); + } + + #[tokio::test] + async fn test_prepare_transaction_instructions_mode_success() { + let mut provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let mut tx_repo = MockTransactionRepository::new(); + let mut job_producer = MockJobProducerTrait::new(); + let mut signer = MockSolanaSignTrait::new(); + + let relayer = create_mock_solana_relayer("test-relayer".to_string(), false); + let mut tx = create_mock_solana_transaction(); + tx.status = TransactionStatus::Pending; + + // Set up transaction with instructions data + let instructions = vec![crate::models::SolanaInstructionSpec { + program_id: "11111111111111111111111111111112".to_string(), + accounts: vec![crate::models::SolanaAccountMeta { + pubkey: "11111111111111111111111111111112".to_string(), + is_signer: false, + is_writable: true, + }], + data: "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(), + }]; + + tx.network_data = NetworkTransactionData::Solana(SolanaTransactionData { + instructions: Some(instructions), + ..Default::default() + }); + + let tx_id = tx.id.clone(); + let tx_id_clone = tx_id.clone(); + let tx_clone = tx.clone(); + + // Mock blockhash fetch + provider + .expect_get_latest_blockhash() + .returning(|| Box::pin(async { Ok(Hash::new_unique()) })); + + // Mock validation calls + provider + .expect_calculate_total_fee() + .returning(|_| Box::pin(async { Ok(5000) })); + provider + .expect_get_balance() + .returning(|_| Box::pin(async { Ok(1000000) })); + provider + .expect_is_blockhash_valid() + .returning(|_, _| Box::pin(async { Ok(true) })); + provider.expect_simulate_transaction().returning(|_| { + Box::pin(async { + Ok(solana_client::rpc_response::RpcSimulateTransactionResult { + err: None, + logs: Some(vec![]), + accounts: None, + units_consumed: Some(0), + return_data: None, + fee: Some(0), + inner_instructions: None, + loaded_accounts_data_size: Some(0), + replacement_blockhash: None, + pre_balances: Some(vec![]), + post_balances: Some(vec![]), + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, + }) + }) + }); + + // Mock signer + let signer_pubkey = Pubkey::from_str("11111111111111111111111111111112").unwrap(); + signer.expect_pubkey().returning(move || { + Box::pin(async move { Ok(Address::Solana(signer_pubkey.to_string())) }) + }); + signer + .expect_sign() + .returning(|_| Box::pin(async { Ok(Signature::new_unique()) })); + + // Mock repository update + tx_repo + .expect_partial_update() + .withf(move |id, update| { + id == &tx_id_clone && matches!(update.status, Some(TransactionStatus::Sent)) + }) + .times(1) + .returning(move |_, _| { + let mut updated_tx = tx_clone.clone(); + updated_tx.status = TransactionStatus::Sent; + Ok(updated_tx) + }); + + // Mock job producer + job_producer + .expect_produce_submit_transaction_job() + .times(1) + .returning(|_, _| Box::pin(async { Ok(()) })); + + let handler = SolanaRelayerTransaction { + relayer, + relayer_repository: relayer_repo, + provider: Arc::new(provider), + transaction_repository: Arc::new(tx_repo), + job_producer: Arc::new(job_producer), + signer: Arc::new(signer), + }; + + let tx_for_test = tx.clone(); + let result = handler.prepare_transaction_impl(tx_for_test).await; + assert!(result.is_ok()); + let updated_tx = result.unwrap(); + assert_eq!(updated_tx.status, TransactionStatus::Sent); + } + + #[tokio::test] + async fn test_prepare_transaction_validation_failure() { + let provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let mut tx_repo = MockTransactionRepository::new(); + let job_producer = Arc::new(MockJobProducerTrait::new()); + let signer = MockSolanaSignTrait::new(); + + let relayer = create_mock_solana_relayer("test-relayer".to_string(), false); + let mut tx = create_mock_solana_transaction(); + tx.status = TransactionStatus::Pending; + + // Create transaction with invalid data (missing both transaction and instructions) + tx.network_data = NetworkTransactionData::Solana(SolanaTransactionData::default()); + + let tx_id = tx.id.clone(); + + // Mock repository update + let tx_for_closure = tx.clone(); + tx_repo + .expect_partial_update() + .withf(move |id, update| { + id == &tx_id && matches!(update.status, Some(TransactionStatus::Failed)) + }) + .times(1) + .returning(move |_, _| { + let mut updated_tx = tx_for_closure.clone(); + updated_tx.status = TransactionStatus::Failed; + Ok(updated_tx) + }); + + let handler = SolanaRelayerTransaction { + relayer, + relayer_repository: relayer_repo, + provider: Arc::new(provider), + transaction_repository: Arc::new(tx_repo), + job_producer, + signer: Arc::new(signer), + }; + + let tx_for_test = tx.clone(); + let result = handler.prepare_transaction_impl(tx_for_test).await; + assert!(result.is_ok()); // Returns Ok with failed transaction + let updated_tx = result.unwrap(); + assert_eq!(updated_tx.status, TransactionStatus::Failed); + } + + #[tokio::test] + async fn test_prepare_transaction_signer_error() { + let mut provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + let mut signer = MockSolanaSignTrait::new(); + + let relayer = create_mock_solana_relayer("test-relayer".to_string(), false); + let mut tx = create_mock_solana_transaction(); + tx.status = TransactionStatus::Pending; + + // Create a valid transaction + let signer_pubkey = Pubkey::from_str("11111111111111111111111111111112").unwrap(); + let recipient = Pubkey::new_unique(); + let message = Message::new( + &[solana_system_interface::instruction::transfer( + &signer_pubkey, + &recipient, + 1000, + )], + Some(&signer_pubkey), + ); + let transaction = solana_sdk::transaction::Transaction::new_unsigned(message); + let encoded_tx = EncodedSerializedTransaction::try_from(&transaction).unwrap(); + + tx.network_data = NetworkTransactionData::Solana(SolanaTransactionData { + transaction: Some(encoded_tx.into_inner()), + ..Default::default() + }); + + // Mock validation calls (needed before signer is called) + provider + .expect_calculate_total_fee() + .returning(|_| Box::pin(async { Ok(5000) })); + provider + .expect_get_balance() + .returning(|_| Box::pin(async { Ok(1000000) })); + provider + .expect_is_blockhash_valid() + .returning(|_, _| Box::pin(async { Ok(true) })); + provider.expect_simulate_transaction().returning(|_| { + Box::pin(async { + Ok(solana_client::rpc_response::RpcSimulateTransactionResult { + err: None, + logs: Some(vec![]), + accounts: None, + units_consumed: Some(0), + return_data: None, + fee: Some(0), + inner_instructions: None, + loaded_accounts_data_size: Some(0), + replacement_blockhash: None, + pre_balances: Some(vec![]), + post_balances: Some(vec![]), + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, + }) + }) + }); + + // Mock signer to return error + let signer_pubkey_str = signer_pubkey.to_string(); + signer.expect_pubkey().returning(move || { + let value = signer_pubkey_str.clone(); + Box::pin(async move { Ok(Address::Solana(value)) }) + }); + signer.expect_sign().returning(|_| { + Box::pin(async { Err(SignerError::SigningError("Signer failed".to_string())) }) + }); + + let handler = SolanaRelayerTransaction { + relayer, + relayer_repository: relayer_repo, + provider: Arc::new(provider), + transaction_repository: tx_repo, + job_producer, + signer: Arc::new(signer), + }; + + let tx_for_test = tx.clone(); + let result = handler.prepare_transaction_impl(tx_for_test).await; + assert!(result.is_err()); + let error = result.unwrap_err(); + match error { + TransactionError::SignerError(msg) => assert!(msg.contains("Signer failed")), + _ => panic!("Expected SignerError"), + } + } + + #[tokio::test] + async fn test_submit_transaction_success() { + let mut provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let mut tx_repo = MockTransactionRepository::new(); + let job_producer = Arc::new(MockJobProducerTrait::new()); + let signer = MockSolanaSignTrait::new(); + + let relayer = create_mock_solana_relayer("test-relayer".to_string(), false); + let mut tx = create_mock_solana_transaction(); + tx.status = TransactionStatus::Sent; + + // Create a valid transaction with signature + let signer_pubkey = Pubkey::from_str("11111111111111111111111111111112").unwrap(); + let recipient = Pubkey::new_unique(); + let message = Message::new( + &[solana_system_interface::instruction::transfer( + &signer_pubkey, + &recipient, + 1000, + )], + Some(&signer_pubkey), + ); + let mut transaction = solana_sdk::transaction::Transaction::new_unsigned(message); + let signature = Signature::new_unique(); + transaction.signatures = vec![signature]; + + let encoded_tx = EncodedSerializedTransaction::try_from(&transaction).unwrap(); + + tx.network_data = NetworkTransactionData::Solana(SolanaTransactionData { + transaction: Some(encoded_tx.into_inner()), + signature: Some(signature.to_string()), + ..Default::default() + }); + + let tx_id = tx.id.clone(); + let tx_id_clone = tx_id.clone(); + let tx_clone = tx.clone(); + + // Mock successful send + provider + .expect_send_transaction() + .returning(|_| Box::pin(async { Ok(Signature::new_unique()) })); + + // Mock repository update + tx_repo + .expect_partial_update() + .withf(move |id, update| { + id == &tx_id_clone && matches!(update.status, Some(TransactionStatus::Submitted)) + }) + .times(1) + .returning(move |_, _| { + let mut updated_tx = tx_clone.clone(); + updated_tx.status = TransactionStatus::Submitted; + Ok(updated_tx) + }); + + let handler = SolanaRelayerTransaction { + relayer, + relayer_repository: relayer_repo, + provider: Arc::new(provider), + transaction_repository: Arc::new(tx_repo), + job_producer, + signer: Arc::new(signer), + }; + + let tx_for_test = tx.clone(); + let result = handler.submit_transaction_impl(tx_for_test).await; + assert!(result.is_ok()); + let updated_tx = result.unwrap(); + assert_eq!(updated_tx.status, TransactionStatus::Submitted); + } + + #[tokio::test] + async fn test_submit_transaction_already_processed() { + let mut provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + let signer = MockSolanaSignTrait::new(); + + let relayer = create_mock_solana_relayer("test-relayer".to_string(), false); + let mut tx = create_mock_solana_transaction(); + tx.status = TransactionStatus::Sent; + + // Create a valid transaction + let signer_pubkey = Pubkey::from_str("11111111111111111111111111111112").unwrap(); + let recipient = Pubkey::new_unique(); + let message = Message::new( + &[solana_system_interface::instruction::transfer( + &signer_pubkey, + &recipient, + 1000, + )], + Some(&signer_pubkey), + ); + let mut transaction = solana_sdk::transaction::Transaction::new_unsigned(message); + let signature = Signature::new_unique(); + transaction.signatures = vec![signature]; + + let encoded_tx = EncodedSerializedTransaction::try_from(&transaction).unwrap(); + + tx.network_data = NetworkTransactionData::Solana(SolanaTransactionData { + transaction: Some(encoded_tx.into_inner()), + signature: Some(signature.to_string()), + ..Default::default() + }); + + // Mock provider to return AlreadyProcessed + provider.expect_send_transaction().returning(|_| { + Box::pin(async { + Err(SolanaProviderError::AlreadyProcessed( + "Already processed".to_string(), + )) + }) + }); + + let handler = SolanaRelayerTransaction { + relayer, + relayer_repository: relayer_repo, + provider: Arc::new(provider), + transaction_repository: tx_repo, + job_producer, + signer: Arc::new(signer), + }; + + let result = handler.submit_transaction_impl(tx.clone()).await; + assert!(result.is_ok()); + let updated_tx = result.unwrap(); + assert_eq!(updated_tx.status, tx.status); // Status unchanged + } + + #[tokio::test] + async fn test_submit_transaction_blockhash_expired_resubmitable() { + let mut provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + let signer = MockSolanaSignTrait::new(); + + let relayer = create_mock_solana_relayer("test-relayer".to_string(), false); + let mut tx = create_mock_solana_transaction(); + tx.status = TransactionStatus::Sent; + + // Create a single-signer transaction (resubmitable) + let signer_pubkey = Pubkey::from_str("11111111111111111111111111111112").unwrap(); + let recipient = Pubkey::new_unique(); + let message = Message::new( + &[solana_system_interface::instruction::transfer( + &signer_pubkey, + &recipient, + 1000, + )], + Some(&signer_pubkey), + ); + let mut transaction = solana_sdk::transaction::Transaction::new_unsigned(message); + let signature = Signature::new_unique(); + transaction.signatures = vec![signature]; + + let encoded_tx = EncodedSerializedTransaction::try_from(&transaction).unwrap(); + + tx.network_data = NetworkTransactionData::Solana(SolanaTransactionData { + transaction: Some(encoded_tx.into_inner()), + signature: Some(signature.to_string()), + ..Default::default() + }); + + // Mock provider to return BlockhashNotFound + provider.expect_send_transaction().returning(|_| { + Box::pin(async { + Err(SolanaProviderError::BlockhashNotFound( + "Blockhash not found".to_string(), + )) + }) + }); + + let handler = SolanaRelayerTransaction { + relayer, + relayer_repository: relayer_repo, + provider: Arc::new(provider), + transaction_repository: tx_repo, + job_producer, + signer: Arc::new(signer), + }; + + let result = handler.submit_transaction_impl(tx.clone()).await; + assert!(result.is_ok()); + let updated_tx = result.unwrap(); + assert_eq!(updated_tx.status, tx.status); // Status unchanged, resubmit scheduled + } + + #[tokio::test] + async fn test_submit_transaction_permanent_error() { + let mut provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let mut tx_repo = MockTransactionRepository::new(); + let job_producer = Arc::new(MockJobProducerTrait::new()); + let signer = MockSolanaSignTrait::new(); + + let relayer = create_mock_solana_relayer("test-relayer".to_string(), false); + let mut tx = create_mock_solana_transaction(); + tx.status = TransactionStatus::Sent; + + // Create a valid transaction + let signer_pubkey = Pubkey::from_str("11111111111111111111111111111112").unwrap(); + let recipient = Pubkey::new_unique(); + let message = Message::new( + &[solana_system_interface::instruction::transfer( + &signer_pubkey, + &recipient, + 1000, + )], + Some(&signer_pubkey), + ); + let mut transaction = solana_sdk::transaction::Transaction::new_unsigned(message); + let signature = Signature::new_unique(); + transaction.signatures = vec![signature]; + + let encoded_tx = EncodedSerializedTransaction::try_from(&transaction).unwrap(); + + tx.network_data = NetworkTransactionData::Solana(SolanaTransactionData { + transaction: Some(encoded_tx.into_inner()), + signature: Some(signature.to_string()), + ..Default::default() + }); + + let tx_id = tx.id.clone(); + let tx_clone = tx.clone(); + + // Mock provider to return permanent error + provider.expect_send_transaction().returning(|_| { + Box::pin(async { + Err(SolanaProviderError::InsufficientFunds( + "Insufficient balance".to_string(), + )) + }) + }); + + // Mock repository update to failed + tx_repo + .expect_partial_update() + .withf(move |id, update| { + id == &tx_id && matches!(update.status, Some(TransactionStatus::Failed)) + }) + .times(1) + .returning(move |_, _| { + let mut updated_tx = tx_clone.clone(); + updated_tx.status = TransactionStatus::Failed; + Ok(updated_tx) + }); + + let handler = SolanaRelayerTransaction { + relayer, + relayer_repository: relayer_repo, + provider: Arc::new(provider), + transaction_repository: Arc::new(tx_repo), + job_producer, + signer: Arc::new(signer), + }; + + let tx_for_test = tx.clone(); + let result = handler.submit_transaction_impl(tx_for_test).await; + assert!(result.is_ok()); // Returns Ok with failed transaction + let updated_tx = result.unwrap(); + assert_eq!(updated_tx.status, TransactionStatus::Failed); + } + + #[tokio::test] + async fn test_resubmit_transaction_success() { + let mut provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let mut tx_repo = MockTransactionRepository::new(); + let job_producer = Arc::new(MockJobProducerTrait::new()); + let mut signer = MockSolanaSignTrait::new(); + + let relayer = create_mock_solana_relayer("test-relayer".to_string(), false); + let mut tx = create_mock_solana_transaction(); + tx.status = TransactionStatus::Submitted; + + // Create a valid transaction + let signer_pubkey = Pubkey::from_str("11111111111111111111111111111112").unwrap(); + let recipient = Pubkey::new_unique(); + let message = Message::new( + &[solana_system_interface::instruction::transfer( + &signer_pubkey, + &recipient, + 1000, + )], + Some(&signer_pubkey), + ); + let mut transaction = solana_sdk::transaction::Transaction::new_unsigned(message); + let signature = Signature::new_unique(); + transaction.signatures = vec![signature]; + + let encoded_tx = EncodedSerializedTransaction::try_from(&transaction).unwrap(); + + tx.network_data = NetworkTransactionData::Solana(SolanaTransactionData { + transaction: Some(encoded_tx.into_inner()), + signature: Some(signature.to_string()), + ..Default::default() + }); + + let tx_id = tx.id.clone(); + let tx_id_clone = tx_id.clone(); + let tx_clone = tx.clone(); + let tx_for_test = tx.clone(); + + // Mock fresh blockhash + provider + .expect_get_latest_blockhash() + .returning(|| Box::pin(async { Ok(Hash::new_unique()) })); + + // Mock signer + let signer_pubkey_str = signer_pubkey.to_string(); + signer.expect_pubkey().returning(move || { + let value = signer_pubkey_str.clone(); + Box::pin(async move { Ok(Address::Solana(value)) }) + }); + signer + .expect_sign() + .returning(|_| Box::pin(async { Ok(Signature::new_unique()) })); + + // Mock successful resubmit + provider + .expect_send_transaction() + .returning(|_| Box::pin(async { Ok(Signature::new_unique()) })); + + // Mock repository update + tx_repo + .expect_partial_update() + .withf(move |id, update| { + id == &tx_id_clone && matches!(update.status, Some(TransactionStatus::Submitted)) + }) + .times(1) + .returning(move |_, _| { + let mut updated_tx = tx_clone.clone(); + updated_tx.status = TransactionStatus::Submitted; + Ok(updated_tx) + }); + + let handler = SolanaRelayerTransaction { + relayer, + relayer_repository: relayer_repo, + provider: Arc::new(provider), + transaction_repository: Arc::new(tx_repo), + job_producer, + signer: Arc::new(signer), + }; + + let result = handler.resubmit_transaction_impl(tx_for_test).await; + assert!(result.is_ok()); + let updated_tx = result.unwrap(); + assert_eq!(updated_tx.status, TransactionStatus::Submitted); + } + + #[tokio::test] + async fn test_validate_transaction_success() { + let mut provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + let signer = MockSolanaSignTrait::new(); + + let relayer = create_mock_solana_relayer("test-relayer".to_string(), false); + let _tx = create_mock_solana_transaction(); + + // Create a valid transaction + let signer_pubkey = Pubkey::from_str("11111111111111111111111111111112").unwrap(); + let recipient = Pubkey::new_unique(); + let message = Message::new( + &[solana_system_interface::instruction::transfer( + &signer_pubkey, + &recipient, + 1000, + )], + Some(&signer_pubkey), + ); + let transaction = solana_sdk::transaction::Transaction::new_unsigned(message); + + // Mock all validation calls + provider + .expect_calculate_total_fee() + .returning(|_| Box::pin(async { Ok(5000) })); + provider + .expect_get_balance() + .returning(|_| Box::pin(async { Ok(1000000) })); + provider.expect_get_transaction_status().returning(|_| { + Box::pin(async { Ok(crate::models::SolanaTransactionStatus::Processed) }) + }); + provider + .expect_is_blockhash_valid() + .returning(|_, _| Box::pin(async { Ok(true) })); + provider.expect_simulate_transaction().returning(|_| { + Box::pin(async { + Ok(solana_client::rpc_response::RpcSimulateTransactionResult { + err: None, + logs: Some(vec![]), + accounts: None, + units_consumed: Some(0), + return_data: None, + fee: Some(0), + inner_instructions: None, + loaded_accounts_data_size: Some(0), + replacement_blockhash: None, + pre_balances: Some(vec![]), + post_balances: Some(vec![]), + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, + }) + }) + }); + + let handler = SolanaRelayerTransaction { + relayer, + relayer_repository: relayer_repo, + provider: Arc::new(provider), + transaction_repository: tx_repo, + job_producer, + signer: Arc::new(signer), + }; + + let result = handler.validate_transaction_impl(&transaction).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_cancel_transaction_not_supported() { + let provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + let signer = MockSolanaSignTrait::new(); + + let relayer = create_mock_solana_relayer("test-relayer".to_string(), false); + let tx = create_mock_solana_transaction(); + + let handler = SolanaRelayerTransaction { + relayer, + relayer_repository: relayer_repo, + provider: Arc::new(provider), + transaction_repository: tx_repo, + job_producer, + signer: Arc::new(signer), + }; + + let result = handler.cancel_transaction(tx).await; + assert!(result.is_err()); + let error = result.unwrap_err(); + match error { + TransactionError::NotSupported(msg) => { + assert!(msg.contains("Transaction cancellation is not supported for Solana")); + } + _ => panic!("Expected NotSupported error"), + } + } + + #[tokio::test] + async fn test_replace_transaction_not_supported() { + let provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + let signer = MockSolanaSignTrait::new(); + + let relayer = create_mock_solana_relayer("test-relayer".to_string(), false); + let old_tx = create_mock_solana_transaction(); + let new_request = crate::models::NetworkTransactionRequest::Evm( + crate::models::EvmTransactionRequest::default(), + ); + + let handler = SolanaRelayerTransaction { + relayer, + relayer_repository: relayer_repo, + provider: Arc::new(provider), + transaction_repository: tx_repo, + job_producer, + signer: Arc::new(signer), + }; + + let result = handler.replace_transaction(old_tx, new_request).await; + assert!(result.is_err()); + let error = result.unwrap_err(); + match error { + TransactionError::NotSupported(msg) => { + assert!(msg.contains("Transaction replacement is not supported for Solana")); + } + _ => panic!("Expected NotSupported error"), + } + } + + #[tokio::test] + async fn test_sign_transaction_not_supported() { + let provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + let signer = MockSolanaSignTrait::new(); + + let relayer = create_mock_solana_relayer("test-relayer".to_string(), false); + let tx = create_mock_solana_transaction(); + + let handler = SolanaRelayerTransaction { + relayer, + relayer_repository: relayer_repo, + provider: Arc::new(provider), + transaction_repository: tx_repo, + job_producer, + signer: Arc::new(signer), + }; + + let result = handler.sign_transaction(tx).await; + assert!(result.is_err()); + let error = result.unwrap_err(); + match error { + TransactionError::NotSupported(msg) => { + assert!(msg.contains("Standalone transaction signing is not supported for Solana")); + } + _ => panic!("Expected NotSupported error"), + } + } + #[tokio::test] async fn test_handle_transaction_status_calls_impl() { // Create test data let relayer = create_mock_solana_relayer("test-solana-relayer".to_string(), false); let relayer_repository = Arc::new(MockRelayerRepository::new()); - let provider = Arc::new(MockSolanaProviderTrait::new()); + let provider = MockSolanaProviderTrait::new(); let transaction_repository = Arc::new(MockTransactionRepository::new()); - let job_producer = Arc::new(MockJobProducerTrait::new()); + let mut job_producer = MockJobProducerTrait::new(); + let signer = MockSolanaSignTrait::new(); - // Create test transaction + // Create test transaction (will be in Pending status by default) let test_tx = create_mock_solana_transaction(); + job_producer + .expect_produce_transaction_request_job() + .returning(|_, _| Box::pin(async { Ok(()) })); + // Create transaction handler - let transaction_handler = SolanaRelayerTransaction::new( + let transaction_handler = SolanaRelayerTransaction { relayer, relayer_repository, - provider, + provider: Arc::new(provider), transaction_repository, - job_producer, - ) - .unwrap(); + job_producer: Arc::new(job_producer), + signer: Arc::new(signer), + }; - // Mock handle_transaction_status_impl to return Ok(test_tx.clone()) + // Call handle_transaction_status - with new implementation, + // Pending transactions just return Ok without querying provider let result = transaction_handler .handle_transaction_status(test_tx.clone()) .await; - // Verify the result matches what we expect from handle_transaction_status_impl - assert!(result.is_err()); - let error = result.unwrap_err(); - assert_eq!( - error.to_string(), - "Transaction validation error: Transaction signature is missing".to_string() - ); + // Verify the result is Ok and transaction is unchanged + assert!(result.is_ok()); + let returned_tx = result.unwrap(); + assert_eq!(returned_tx.id, test_tx.id); + assert_eq!(returned_tx.status, test_tx.status); } } diff --git a/src/domain/transaction/solana/status.rs b/src/domain/transaction/solana/status.rs index 9e1ecba85..e67765477 100644 --- a/src/domain/transaction/solana/status.rs +++ b/src/domain/transaction/solana/status.rs @@ -3,55 +3,112 @@ //! This module provides transaction status checking for Solana transactions, //! including status updates, repository management, and webhook notifications. -use chrono::Utc; -use solana_sdk::signature::Signature; +use crate::constants::{ + MAXIMUM_SOLANA_TX_ATTEMPTS, SOLANA_DEFAULT_TX_VALID_TIMESPAN, + SOLANA_MIN_AGE_FOR_RESUBMIT_CHECK_SECONDS, SOLANA_PENDING_RECOVERY_TRIGGER_SECONDS, + SOLANA_PENDING_TIMEOUT_MINUTES, SOLANA_SENT_TIMEOUT_MINUTES, +}; +use crate::models::{NetworkTransactionData, SolanaTransactionData}; +use crate::services::provider::SolanaProviderError; +use chrono::{DateTime, Duration, Utc}; +use solana_commitment_config::CommitmentConfig; +use solana_sdk::{signature::Signature, transaction::Transaction as SolanaTransaction}; use std::str::FromStr; -use tracing::{debug, error, warn}; +use tracing::{debug, error, info, warn}; -use super::SolanaRelayerTransaction; +use super::{utils::decode_solana_transaction, SolanaRelayerTransaction}; use crate::domain::transaction::common::is_final_state; +use crate::domain::transaction::solana::utils::{ + is_resubmitable, map_solana_status_to_transaction_status, too_many_solana_attempts, +}; use crate::{ - jobs::JobProducerTrait, + jobs::{JobProducerTrait, TransactionRequest, TransactionSend}, models::{ - produce_transaction_update_notification_payload, RelayerRepoModel, SolanaTransactionStatus, - TransactionError, TransactionRepoModel, TransactionStatus, TransactionUpdateRequest, + RelayerRepoModel, SolanaTransactionStatus, TransactionError, TransactionRepoModel, + TransactionStatus, TransactionUpdateRequest, }, repositories::{transaction::TransactionRepository, RelayerRepository, Repository}, - services::provider::SolanaProviderTrait, + services::{provider::SolanaProviderTrait, signer::SolanaSignTrait}, }; -impl SolanaRelayerTransaction +impl SolanaRelayerTransaction where - P: SolanaProviderTrait, + P: SolanaProviderTrait + Send + Sync + 'static, RR: RelayerRepository + Repository + Send + Sync + 'static, TR: TransactionRepository + Repository + Send + Sync + 'static, J: JobProducerTrait + Send + Sync + 'static, + S: SolanaSignTrait + Send + Sync + 'static, { /// Main status handling method with error handling + /// + /// 1. Check transaction status (query chain or return current for Pending/Sent) + /// 2. Reload transaction from DB if status changed (ensures fresh data) + /// 3. Check if too early for resubmit checks (young transactions just update status) + /// 4. Handle based on detected status (handlers update DB if needed) pub async fn handle_transaction_status_impl( &self, - tx: TransactionRepoModel, + mut tx: TransactionRepoModel, ) -> Result { - debug!(tx_id = %tx.id, "handling solana transaction status"); + debug!(tx_id = %tx.id, status = ?tx.status, "handling solana transaction status"); - // Skip if already in final state // Early return if transaction is already in a final state if is_final_state(&tx.status) { debug!(status = ?tx.status, "transaction already in final state"); return Ok(tx); } - // Call core status checking logic - // Errors are propagated to trigger job system retry - self.check_and_update_status(tx).await + // Step 1: Check transaction status (query chain or return current) + let detected_status = self.check_onchain_transaction_status(&tx).await?; + + // Reload transaction from DB if status changed + // This ensures we have fresh data if check_transaction_status triggered a recovery + // or any other update that modified the transaction in the database. + if tx.status != detected_status { + tx = self + .transaction_repository() + .get_by_id(tx.id.clone()) + .await?; + } + + // Step 2: Handle based on detected status (handlers will update if needed) + match detected_status { + TransactionStatus::Pending => { + // Pending transactions haven't been submitted yet - schedule request job if not expired + self.handle_pending_status(tx).await + } + TransactionStatus::Sent | TransactionStatus::Submitted => { + // Sent/Submitted transactions may need resubmission if blockhash expired + self.handle_resubmit_or_expiration(tx).await + } + TransactionStatus::Mined + | TransactionStatus::Confirmed + | TransactionStatus::Failed + | TransactionStatus::Canceled + | TransactionStatus::Expired => { + self.update_transaction_status_if_needed(tx, detected_status) + .await + } + } } - /// Core status checking logic - async fn check_and_update_status( + /// Check transaction status from chain (or return current for Pending/Sent) + /// + /// Similar to EVM's check_transaction_status, this method: + /// - Returns current status for Pending/Sent (no on-chain query needed) + /// - Queries chain for Submitted/Mined and returns appropriate status + async fn check_onchain_transaction_status( &self, - tx: TransactionRepoModel, - ) -> Result { - // Extract signature from Solana transaction data + tx: &TransactionRepoModel, + ) -> Result { + // Early return for Pending/Sent - these are DB-only states + match tx.status { + TransactionStatus::Pending | TransactionStatus::Sent => { + return Ok(tx.status.clone()); + } + _ => {} + } + + // For Submitted/Mined, query the chain let solana_data = tx.network_data.get_solana_transaction_data()?; let signature_str = solana_data.signature.as_ref().ok_or_else(|| { TransactionError::ValidationError("Transaction signature is missing".to_string()) @@ -61,141 +118,551 @@ where TransactionError::ValidationError(format!("Invalid signature format: {}", e)) })?; - // Get transaction status from provider - let solana_status = self - .provider() - .get_transaction_status(&signature) + // Query on-chain status + match self.provider().get_transaction_status(&signature).await { + Ok(solana_status) => { + // Map Solana on-chain status to repository status + Ok(map_solana_status_to_transaction_status(solana_status)) + } + Err(e) => { + // Transaction not found or error querying + warn!( + tx_id = %tx.id, + signature = %signature_str, + error = %e, + "error getting transaction status from chain" + ); + // Return current status (will be handled later for potential resubmit) + Ok(tx.status.clone()) + } + } + } + + /// Update transaction status in DB and send notification (unconditionally) + /// + /// Optionally updates network_data along with status. This is useful when + /// updating the signature field after finding a transaction on-chain. + /// + /// Used internally by update_transaction_status_if_needed and + /// handle_resubmit_or_expiration + async fn update_transaction_status_and_send_notification( + &self, + tx: TransactionRepoModel, + new_status: TransactionStatus, + network_data: Option, + ) -> Result { + let update_request = TransactionUpdateRequest { + status: Some(new_status.clone()), + network_data, + confirmed_at: if matches!(new_status, TransactionStatus::Confirmed) { + Some(Utc::now().to_rfc3339()) + } else { + None + }, + ..Default::default() + }; + + // Update transaction in repository + let updated_tx = self + .transaction_repository() + .partial_update(tx.id.clone(), update_request) .await - .map_err(|e| { - TransactionError::UnexpectedError(format!( - "Failed to get Solana transaction status for tx {} (signature {}): {}", - tx.id, signature_str, e - )) - })?; - - // Map Solana status to repository status and handle accordingly - match solana_status { - SolanaTransactionStatus::Processed => self.handle_processed_status(tx).await, - SolanaTransactionStatus::Confirmed => self.handle_confirmed_status(tx).await, - SolanaTransactionStatus::Finalized => self.handle_finalized_status(tx).await, - SolanaTransactionStatus::Failed => self.handle_failed_status(tx).await, + .map_err(|e| TransactionError::UnexpectedError(e.to_string()))?; + + // Send webhook notification if relayer has notification configured + // Best-effort operation - errors logged but not propagated + if let Err(e) = self.send_transaction_update_notification(&updated_tx).await { + error!( + tx_id = %updated_tx.id, + status = ?new_status, + "sending transaction update notification failed: {:?}", + e + ); } + + Ok(updated_tx) } - /// Helper method that updates transaction status only if it's different from the current status + /// Update transaction status in DB if status has changed + /// + /// Similar to EVM's update_transaction_status_if_needed pattern async fn update_transaction_status_if_needed( &self, tx: TransactionRepoModel, new_status: TransactionStatus, ) -> Result { if tx.status != new_status { - let update_request = TransactionUpdateRequest { - status: Some(new_status.clone()), - confirmed_at: if matches!(new_status, TransactionStatus::Confirmed) { - Some(Utc::now().to_rfc3339()) - } else { - None - }, - ..Default::default() - }; return self - .finalize_transaction_state(tx.id.clone(), update_request) + .update_transaction_status_and_send_notification(tx, new_status, None) .await; } Ok(tx) } - /// Handle processed status (transaction processed by leader but not yet confirmed) - async fn handle_processed_status( + /// Handle Pending status - check for expiration/timeout or schedule transaction request job + /// + /// Pending transactions haven't been submitted yet, so we should schedule a transaction + /// request job to prepare and submit them, not a resubmit job. + async fn handle_pending_status( &self, tx: TransactionRepoModel, ) -> Result { - debug!(tx_id = %tx.id, "transaction is processed but waiting for supermajority confirmation"); + // Step 1: Check if valid_until has expired + if self.is_valid_until_expired(&tx) { + info!( + tx_id = %tx.id, + valid_until = ?tx.valid_until, + "pending transaction valid_until has expired" + ); + return self + .mark_as_expired( + tx, + "Transaction valid_until timestamp has expired".to_string(), + ) + .await; + } + + // Step 2: Check if transaction has exceeded pending timeout + // Only schedule recovery job if transaction is stuck (similar to EVM pattern) + if self.has_exceeded_timeout(&tx)? { + warn!( + tx_id = %tx.id, + timeout_minutes = SOLANA_PENDING_TIMEOUT_MINUTES, + "pending transaction has exceeded timeout, marking as failed" + ); + return self + .mark_as_failed( + tx, + format!( + "Transaction stuck in Pending status for more than {} minutes", + SOLANA_PENDING_TIMEOUT_MINUTES + ), + ) + .await; + } + + // Step 3: Check if transaction is stuck (prepare job may have failed) + // Only re-queue job if transaction age indicates it might be stuck + let age = self.get_time_since_sent_or_created_at(&tx).ok_or_else(|| { + TransactionError::UnexpectedError( + "Both sent_at and created_at are missing or invalid".to_string(), + ) + })?; + + // Use a recovery trigger timeout (e.g., 30 seconds) + // This prevents scheduling a job on every 5-second status check + if age.num_seconds() >= SOLANA_PENDING_RECOVERY_TRIGGER_SECONDS { + info!( + tx_id = %tx.id, + age_seconds = age.num_seconds(), + "pending transaction may be stuck, scheduling recovery job" + ); + + let transaction_request = TransactionRequest::new(tx.id.clone(), tx.relayer_id.clone()); + + self.job_producer() + .produce_transaction_request_job(transaction_request, None) + .await + .map_err(|e| { + TransactionError::UnexpectedError(format!( + "Failed to enqueue transaction request job: {}", + e + )) + })?; + } else { + debug!( + tx_id = %tx.id, + age_seconds = age.num_seconds(), + "pending transaction too young for recovery check" + ); + } - // Keep current status - will check again later for confirmation/finalization Ok(tx) } - /// Handle confirmed status (transaction confirmed by supermajority) - /// We are mapping this to mined status because we don't have a separate finalized status - /// and we want to keep the status consistent with the other networks - async fn handle_confirmed_status( + /// Check if enough time has passed since sent_at (or created_at) to check for resubmit/expiration + /// + /// Falls back to created_at for Pending transactions where sent_at is not yet set. + /// Returns None if both timestamps are missing or invalid. + fn get_time_since_sent_or_created_at(&self, tx: &TransactionRepoModel) -> Option { + // Try sent_at first, fallback to created_at for Pending transactions + let timestamp = tx.sent_at.as_ref().or(Some(&tx.created_at))?; + match DateTime::parse_from_rfc3339(timestamp) { + Ok(dt) => Some(Utc::now().signed_duration_since(dt.with_timezone(&Utc))), + Err(e) => { + warn!(tx_id = %tx.id, ts = %timestamp, error = %e, "failed to parse timestamp"); + None + } + } + } + + /// Check if any previous signature from the transaction is already on-chain. + /// + /// This prevents double-execution by verifying that none of the previous + /// submission attempts are already processed before resubmitting with a new blockhash. + /// + /// Returns: + /// - `Ok(Some((signature, status)))` if a signature was found on-chain + /// - `Ok(None)` if no signature was found on-chain + /// + /// Critical for handling race conditions where: + /// - Transaction was sent but DB update failed + /// - Transaction is in mempool when resubmit logic runs + /// - RPC indexing lag causes signature lookup to fail temporarily + async fn check_any_signature_on_chain( &self, - tx: TransactionRepoModel, - ) -> Result { - debug!(tx_id = %tx.id, "transaction is confirmed by supermajority"); + tx: &TransactionRepoModel, + ) -> Result, TransactionError> { + // Check all previous signatures stored in hashes + for (idx, sig_str) in tx.hashes.iter().enumerate() { + let signature = match Signature::from_str(sig_str) { + Ok(sig) => sig, + Err(e) => { + warn!( + tx_id = %tx.id, + signature = %sig_str, + error = %e, + "invalid signature format in hashes, skipping" + ); + continue; + } + }; - // Update status to mined only if not already mined - let updated_tx = self - .update_transaction_status_if_needed(tx, TransactionStatus::Mined) - .await?; + match self.provider().get_transaction_status(&signature).await { + Ok(solana_status) => { + // Found on-chain! This signature was processed + info!( + tx_id = %tx.id, + signature = %sig_str, + signature_idx = idx, + on_chain_status = ?solana_status, + "found transaction on-chain with previous signature" + ); + return Ok(Some((sig_str.clone(), solana_status))); + } + Err(e) => { + // Signature not found or RPC error - continue checking others + debug!( + tx_id = %tx.id, + signature = %sig_str, + signature_idx = idx, + error = %e, + "signature not found on-chain or RPC error" + ); + continue; + } + } + } - Ok(updated_tx) + // No signatures found on-chain + Ok(None) } - /// Handle finalized status (transaction is finalized and irreversible) - /// We are mapping this to confirmed status because we don't have a separate finalized status - /// and we want to keep the status consistent with the other networks - async fn handle_finalized_status( + /// Check if the blockhash in the transaction is still valid + /// + /// Queries the chain to see if the blockhash is still recognized + async fn is_blockhash_valid( &self, - tx: TransactionRepoModel, - ) -> Result { - debug!(tx_id=%tx.id, "transaction is finalized and irreversible"); + transaction: &SolanaTransaction, + ) -> Result { + let blockhash = transaction.message.recent_blockhash; - // Update status to confirmed only if not already confirmed (final success state) - self.update_transaction_status_if_needed(tx, TransactionStatus::Confirmed) + match self + .provider() + .is_blockhash_valid(&blockhash, CommitmentConfig::confirmed()) .await + { + Ok(is_valid) => Ok(is_valid), + Err(e) => { + // Check if blockhash not found + if matches!(e, SolanaProviderError::BlockhashNotFound(_)) { + info!("blockhash not found on chain, treating as expired"); + return Ok(false); + } + + // Propagate the error so the job system can retry the status check later + warn!( + error = %e, + "error checking blockhash validity, propagating error for retry" + ); + Err(TransactionError::UnderlyingSolanaProvider(e)) + } + } } - /// Handle failed status (transaction failed on-chain) - async fn handle_failed_status( + /// Mark transaction as expired with appropriate reason + async fn mark_as_expired( &self, tx: TransactionRepoModel, + reason: String, ) -> Result { - warn!(tx_id=%tx.id, "transaction failed on-chain"); + warn!(tx_id = %tx.id, reason = %reason, "marking transaction as expired"); - // Update status to failed only if not already failed (final failure state) - self.update_transaction_status_if_needed(tx, TransactionStatus::Failed) + let update_request = TransactionUpdateRequest { + status: Some(TransactionStatus::Expired), + status_reason: Some(reason), + ..Default::default() + }; + + self.transaction_repository() + .partial_update(tx.id.clone(), update_request) .await + .map_err(|e| TransactionError::UnexpectedError(e.to_string())) } - /// Helper function to update transaction status, save it, and send notification - async fn finalize_transaction_state( + /// Mark transaction as failed with appropriate reason + async fn mark_as_failed( &self, - tx_id: String, - update_req: TransactionUpdateRequest, + tx: TransactionRepoModel, + reason: String, ) -> Result { - // Update transaction in repository - let updated_tx = self - .transaction_repository() - .partial_update(tx_id, update_req) + warn!(tx_id = %tx.id, reason = %reason, "marking transaction as failed"); + + let update_request = TransactionUpdateRequest { + status: Some(TransactionStatus::Failed), + status_reason: Some(reason), + ..Default::default() + }; + + self.transaction_repository() + .partial_update(tx.id.clone(), update_request) .await - .map_err(|e| TransactionError::UnexpectedError(e.to_string()))?; + .map_err(|e| TransactionError::UnexpectedError(e.to_string())) + } - // Send webhook notification if relayer has notification configured - self.send_transaction_update_notification(&updated_tx).await; + /// Check if valid_until has expired + /// + /// This checks both: + /// 1. User-provided valid_until (if present) + /// 2. Default valid_until based on created_at + DEFAULT_TX_VALID_TIMESPAN + fn is_valid_until_expired(&self, tx: &TransactionRepoModel) -> bool { + // Check user-provided valid_until first + if let Some(valid_until_str) = &tx.valid_until { + if let Ok(valid_until) = DateTime::parse_from_rfc3339(valid_until_str) { + return Utc::now() > valid_until.with_timezone(&Utc); + } + } - Ok(updated_tx) + // Fall back to default valid_until based on created_at + if let Ok(created_at) = DateTime::parse_from_rfc3339(&tx.created_at) { + let default_valid_until = created_at.with_timezone(&Utc) + + Duration::milliseconds(SOLANA_DEFAULT_TX_VALID_TIMESPAN); + return Utc::now() > default_valid_until; + } + + // If we can't parse created_at, consider it not expired + // (will be caught by other safety mechanisms) + false + } + + /// Check if transaction has exceeded timeout for its status + fn has_exceeded_timeout(&self, tx: &TransactionRepoModel) -> Result { + let age = self.get_time_since_sent_or_created_at(tx).ok_or_else(|| { + TransactionError::UnexpectedError( + "Both sent_at and created_at are missing or invalid".to_string(), + ) + })?; + + let timeout = match tx.status { + TransactionStatus::Pending => Duration::minutes(SOLANA_PENDING_TIMEOUT_MINUTES), + TransactionStatus::Sent => Duration::minutes(SOLANA_SENT_TIMEOUT_MINUTES), + // Submitted status uses attempt-based limiting, not time-based timeout + _ => return Ok(false), // No timeout for other statuses + }; + + Ok(age >= timeout) } - /// Send webhook notification for transaction updates. + /// Handle resubmit or expiration logic based on blockhash validity /// - /// This is a best-effort operation that logs errors but does not propagate them, - /// as notification failures should not affect the transaction lifecycle. - async fn send_transaction_update_notification(&self, tx: &TransactionRepoModel) { - if let Some(notification_id) = &self.relayer().notification_id { - debug!(tx_id = %tx.id, "sending webhook notification for transaction"); - - let notification_payload = - produce_transaction_update_notification_payload(notification_id, tx); - - if let Err(e) = self - .job_producer() - .produce_send_notification_job(notification_payload, None) - .await - { - error!(error = %e, "failed to produce notification job"); + /// This method performs the following steps: + /// 1. Checks if the transaction's `valid_until` timestamp has expired. + /// 2. Verifies if the transaction has exceeded status-based timeouts or attempt limits. + /// 3. Ensures enough time has passed since `sent_at` or `created_at` for resubmission checks. + /// 4. Checks if any previous signatures are already on-chain to prevent double-execution. + /// 5. Validates the blockhash and schedules a resubmit job if expired and resubmitable. + /// 6. Marks the transaction as expired or failed if resubmission is not possible. + /// + /// Returns the updated transaction or an error if the operation fails. + async fn handle_resubmit_or_expiration( + &self, + tx: TransactionRepoModel, + ) -> Result { + // Step 1: Check if valid_until has expired + if self.is_valid_until_expired(&tx) { + info!( + tx_id = %tx.id, + valid_until = ?tx.valid_until, + "transaction valid_until has expired" + ); + return self + .mark_as_expired( + tx, + "Transaction valid_until timestamp has expired".to_string(), + ) + .await; + } + + // Step 2: Check if transaction has exceeded timeout or attempt limit + if tx.status == TransactionStatus::Submitted { + // For Submitted status, use attempt-based limiting instead of timeout + if too_many_solana_attempts(&tx) { + let attempt_count = tx.hashes.len(); + warn!( + tx_id = %tx.id, + attempt_count = attempt_count, + max_attempts = MAXIMUM_SOLANA_TX_ATTEMPTS, + "transaction has exceeded maximum resubmission attempts" + ); + return self + .mark_as_failed( + tx, + format!( + "Transaction exceeded maximum resubmission attempts ({} > {})", + attempt_count, MAXIMUM_SOLANA_TX_ATTEMPTS + ), + ) + .await; + } + } else if self.has_exceeded_timeout(&tx)? { + // For other statuses (Pending, Sent), use time-based timeout + let timeout_minutes = match tx.status { + TransactionStatus::Pending => SOLANA_PENDING_TIMEOUT_MINUTES, + TransactionStatus::Sent => SOLANA_SENT_TIMEOUT_MINUTES, + _ => 0, + }; + let status = tx.status.clone(); + warn!( + tx_id = %tx.id, + status = ?status, + timeout_minutes = timeout_minutes, + "transaction has exceeded timeout for status" + ); + return self + .mark_as_failed( + tx, + format!( + "Transaction stuck in {:?} status for more than {} minutes", + status, timeout_minutes + ), + ) + .await; + } + + // Step 3: Check if enough time has passed for blockhash check + let time_since_sent = match self.get_time_since_sent_or_created_at(&tx) { + Some(duration) => duration, + None => { + debug!(tx_id = %tx.id, "both sent_at and created_at are missing or invalid, skipping resubmit check"); + return Ok(tx); } + }; + + if time_since_sent.num_seconds() < SOLANA_MIN_AGE_FOR_RESUBMIT_CHECK_SECONDS { + debug!( + tx_id = %tx.id, + time_since_sent_secs = time_since_sent.num_seconds(), + min_age = SOLANA_MIN_AGE_FOR_RESUBMIT_CHECK_SECONDS, + "transaction too young for blockhash expiration check" + ); + return Ok(tx); + } + + // Step 4: Check if any previous signature is already on-chain + // This prevents double-execution if: + // - Transaction was sent but DB update failed + // - Transaction is still in mempool/processing + // - RPC had temporary indexing lag + // - Jobs timeouts causing double-execution + if let Some((found_signature, solana_status)) = + self.check_any_signature_on_chain(&tx).await? + { + info!( + tx_id = %tx.id, + signature = %found_signature, + on_chain_status = ?solana_status, + "transaction found on-chain with previous signature, updating to final state" + ); + + // Map Solana on-chain status to repository status + let new_status = map_solana_status_to_transaction_status(solana_status); + + // Update transaction with correct signature and status + let solana_data = tx.network_data.get_solana_transaction_data()?; + let updated_solana_data = SolanaTransactionData { + signature: Some(found_signature), + ..solana_data + }; + let updated_network_data = NetworkTransactionData::Solana(updated_solana_data); + + // Update status, signature, and send notification using shared method + return self + .update_transaction_status_and_send_notification( + tx, + new_status, + Some(updated_network_data), + ) + .await; + } + + // Step 5: Decode transaction to extract blockhash + let transaction = decode_solana_transaction(&tx)?; + + // Step 6: Check if blockhash is expired + let blockhash_valid = self.is_blockhash_valid(&transaction).await?; + + if blockhash_valid { + debug!( + tx_id = %tx.id, + "blockhash still valid, no action needed" + ); + return Ok(tx); + } + + info!( + tx_id = %tx.id, + "blockhash has expired, checking if transaction can be resubmitted" + ); + + // Step 7: Check if transaction can be resubmitted + if is_resubmitable(&transaction) { + info!( + tx_id = %tx.id, + "transaction is resubmitable, enqueuing resubmit job" + ); + + // Schedule resubmit job + self.job_producer() + .produce_submit_transaction_job( + TransactionSend::resubmit(tx.id.clone(), tx.relayer_id.clone()), + None, + ) + .await + .map_err(|e| { + TransactionError::UnexpectedError(format!( + "Failed to enqueue resubmit job: {}", + e + )) + })?; + + info!(tx_id = %tx.id, "resubmit job enqueued successfully"); + Ok(tx) + } else { + // Multi-signature transaction cannot be resubmitted by relayer alone + warn!( + tx_id = %tx.id, + num_signatures = transaction.message.header.num_required_signatures, + "transaction has expired blockhash but cannot be resubmitted (multi-sig)" + ); + + self.mark_as_expired( + tx, + format!( + "Blockhash expired and transaction requires {} signatures (cannot resubmit)", + transaction.message.header.num_required_signatures + ), + ) + .await } } } @@ -204,14 +671,22 @@ where mod tests { use super::*; use crate::{ - jobs::MockJobProducerTrait, + jobs::{MockJobProducerTrait, TransactionCommand}, models::{NetworkTransactionData, SolanaTransactionData}, repositories::{MockRelayerRepository, MockTransactionRepository}, - services::provider::{MockSolanaProviderTrait, SolanaProviderError}, - utils::mocks::mockutils::{create_mock_solana_relayer, create_mock_solana_transaction}, + services::{ + provider::{MockSolanaProviderTrait, SolanaProviderError}, + signer::MockSolanaSignTrait, + }, + utils::{ + base64_encode, + mocks::mockutils::{create_mock_solana_relayer, create_mock_solana_transaction}, + }, }; use eyre::Result; use mockall::predicate::*; + use solana_sdk::{hash::Hash, message::Message, pubkey::Pubkey}; + use solana_system_interface::instruction as system_instruction; use std::sync::Arc; // Helper to create a transaction with a specific status and optional signature @@ -223,7 +698,8 @@ mod tests { tx.status = status; if let Some(sig) = signature { tx.network_data = NetworkTransactionData::Solana(SolanaTransactionData { - transaction: "test".to_string(), + transaction: Some("test".to_string()), + instructions: None, signature: Some(sig.to_string()), }); } @@ -232,15 +708,21 @@ mod tests { #[tokio::test] async fn test_handle_status_already_final() { - let provider = Arc::new(MockSolanaProviderTrait::new()); + let provider = MockSolanaProviderTrait::new(); let relayer_repo = Arc::new(MockRelayerRepository::new()); let tx_repo = Arc::new(MockTransactionRepository::new()); let job_producer = Arc::new(MockJobProducerTrait::new()); let relayer = create_mock_solana_relayer("test-relayer".to_string(), false); - let handler = - SolanaRelayerTransaction::new(relayer, relayer_repo, provider, tx_repo, job_producer) - .unwrap(); + let handler = SolanaRelayerTransaction::new( + relayer, + relayer_repo, + Arc::new(provider), + tx_repo, + job_producer, + Arc::new(MockSolanaSignTrait::new()), + ) + .unwrap(); // Test with Confirmed status let tx_confirmed = create_tx_with_signature(TransactionStatus::Confirmed, None); @@ -271,25 +753,57 @@ mod tests { async fn test_handle_status_processed() -> Result<()> { let mut provider = MockSolanaProviderTrait::new(); let relayer_repo = Arc::new(MockRelayerRepository::new()); - let tx_repo = Arc::new(MockTransactionRepository::new()); + let mut tx_repo = MockTransactionRepository::new(); let job_producer = MockJobProducerTrait::new(); let signature_str = "4XFPmbPT4TRchFWNmQD2N8BhjxJQKqYdXWQG7kJJtxCBZ8Y9WtNDoPAwQaHFYnVynCjMVyF9TCMrpPFkEpG7LpZr"; - let tx = create_tx_with_signature(TransactionStatus::Pending, Some(signature_str)); + // Start with Submitted status + let tx = create_tx_with_signature(TransactionStatus::Submitted, Some(signature_str)); + // check_transaction_status will query the chain provider .expect_get_transaction_status() .with(eq(Signature::from_str(signature_str)?)) .times(1) .returning(|_| Box::pin(async { Ok(SolanaTransactionStatus::Processed) })); + let tx_id = tx.id.clone(); + let tx_id_clone = tx_id.clone(); + + // Expect get_by_id call when status changes (to reload fresh data) + tx_repo + .expect_get_by_id() + .with(eq(tx_id.clone())) + .times(1) + .returning(move |_| { + Ok(create_tx_with_signature( + TransactionStatus::Submitted, // Return with original status before update + Some(signature_str), + )) + }); + + // Expect status update from Submitted to Mined (Processed maps to Mined) + tx_repo + .expect_partial_update() + .withf(move |tx_id_param, update_req| { + tx_id_param == &tx_id_clone && update_req.status == Some(TransactionStatus::Mined) + }) + .times(1) + .returning(move |_, _| { + Ok(create_tx_with_signature( + TransactionStatus::Mined, + Some(signature_str), + )) + }); + let handler = SolanaRelayerTransaction::new( create_mock_solana_relayer("test-relayer".to_string(), false), relayer_repo, Arc::new(provider), - tx_repo, + Arc::new(tx_repo), Arc::new(job_producer), + Arc::new(MockSolanaSignTrait::new()), )?; let result = handler.handle_transaction_status_impl(tx.clone()).await; @@ -297,7 +811,8 @@ mod tests { assert!(result.is_ok()); let updated_tx = result.unwrap(); assert_eq!(updated_tx.id, tx.id); - assert_eq!(updated_tx.status, TransactionStatus::Pending); // Status should not change + // Status should be upgraded to Mined + assert_eq!(updated_tx.status, TransactionStatus::Mined); Ok(()) } @@ -319,11 +834,24 @@ mod tests { .returning(|_| Box::pin(async { Ok(SolanaTransactionStatus::Confirmed) })); let tx_id = tx.id.clone(); + let tx_id_clone = tx_id.clone(); + + // Expect get_by_id call when status changes + tx_repo + .expect_get_by_id() + .with(eq(tx_id.clone())) + .times(1) + .returning(move |_| { + Ok(create_tx_with_signature( + TransactionStatus::Submitted, + Some(signature_str), + )) + }); tx_repo .expect_partial_update() .withf(move |tx_id_param, update_req| { - tx_id_param == &tx_id && update_req.status == Some(TransactionStatus::Mined) + tx_id_param == &tx_id_clone && update_req.status == Some(TransactionStatus::Mined) }) .times(1) .returning(move |_, _| { @@ -339,6 +867,7 @@ mod tests { Arc::new(provider), Arc::new(tx_repo), Arc::new(job_producer), + Arc::new(MockSolanaSignTrait::new()), )?; let result = handler.handle_transaction_status_impl(tx.clone()).await; @@ -368,11 +897,25 @@ mod tests { .returning(|_| Box::pin(async { Ok(SolanaTransactionStatus::Finalized) })); let tx_id = tx.id.clone(); + let tx_id_clone = tx_id.clone(); + + // Expect get_by_id call when status changes + tx_repo + .expect_get_by_id() + .with(eq(tx_id.clone())) + .times(1) + .returning(move |_| { + Ok(create_tx_with_signature( + TransactionStatus::Mined, + Some(signature_str), + )) + }); tx_repo .expect_partial_update() .withf(move |tx_id_param, update_req| { - tx_id_param == &tx_id && update_req.status == Some(TransactionStatus::Confirmed) + tx_id_param == &tx_id_clone + && update_req.status == Some(TransactionStatus::Confirmed) }) .times(1) .returning(move |_, _| { @@ -388,6 +931,7 @@ mod tests { Arc::new(provider), Arc::new(tx_repo), Arc::new(job_producer), + Arc::new(MockSolanaSignTrait::new()), )?; let result = handler.handle_transaction_status_impl(tx.clone()).await; @@ -407,9 +951,12 @@ mod tests { let job_producer = MockJobProducerTrait::new(); let signature_str = "4XFPmbPT4TRchFWNmQD2N8BhjxJQKqYdXWQG7kJJtxCBZ8Y9WtNDoPAwQaHFYnVynCjMVyF9TCMrpPFkEpG7LpZr"; - let tx = create_tx_with_signature(TransactionStatus::Pending, Some(signature_str)); + // Use Submitted status so check_transaction_status() queries provider + let tx = create_tx_with_signature(TransactionStatus::Submitted, Some(signature_str)); let error_message = "Provider is down"; + // check_transaction_status will query the provider and get an error + // It will return the current status (Submitted) provider .expect_get_transaction_status() .with(eq(Signature::from_str(signature_str)?)) @@ -418,8 +965,8 @@ mod tests { Box::pin(async { Err(SolanaProviderError::RpcError(error_message.to_string())) }) }); + // No DB update expected since status doesn't change // No need to expect manual rescheduling - the job system handles retries - // when an error is returned let handler = SolanaRelayerTransaction::new( create_mock_solana_relayer("test-relayer".to_string(), false), @@ -427,14 +974,16 @@ mod tests { Arc::new(provider), tx_repo, Arc::new(job_producer), + Arc::new(MockSolanaSignTrait::new()), )?; let result = handler.handle_transaction_status_impl(tx.clone()).await; - // Verify that an error is returned, which triggers job system retry - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, TransactionError::UnexpectedError(_))); + // Provider error in check_transaction_status returns current status + // Status unchanged, so no DB update, handler just returns Ok(tx) + assert!(result.is_ok()); + let updated_tx = result.unwrap(); + assert_eq!(updated_tx.status, TransactionStatus::Submitted); // Status unchanged Ok(()) } @@ -456,11 +1005,24 @@ mod tests { .returning(|_| Box::pin(async { Ok(SolanaTransactionStatus::Failed) })); let tx_id = tx.id.clone(); + let tx_id_clone = tx_id.clone(); + + // Expect get_by_id call when status changes + tx_repo + .expect_get_by_id() + .with(eq(tx_id.clone())) + .times(1) + .returning(move |_| { + Ok(create_tx_with_signature( + TransactionStatus::Submitted, + Some(signature_str), + )) + }); tx_repo .expect_partial_update() .withf(move |tx_id_param, update_req| { - tx_id_param == &tx_id && update_req.status == Some(TransactionStatus::Failed) + tx_id_param == &tx_id_clone && update_req.status == Some(TransactionStatus::Failed) }) .times(1) .returning(move |_, _| { @@ -476,6 +1038,7 @@ mod tests { Arc::new(provider), Arc::new(tx_repo), Arc::new(job_producer), + Arc::new(MockSolanaSignTrait::new()), )?; let result = handler.handle_transaction_status_impl(tx.clone()).await; @@ -486,4 +1049,942 @@ mod tests { assert_eq!(updated_tx.status, TransactionStatus::Failed); Ok(()) } + + #[tokio::test] + async fn test_default_valid_until_expired() -> Result<()> { + let provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let mut tx_repo = MockTransactionRepository::new(); + let job_producer = MockJobProducerTrait::new(); + + // Create PENDING transaction with created_at older than SOLANA_DEFAULT_TX_VALID_TIMESPAN + let old_created_at = (Utc::now() + - Duration::milliseconds(SOLANA_DEFAULT_TX_VALID_TIMESPAN + 60000)) + .to_rfc3339(); + let mut tx = create_tx_with_signature(TransactionStatus::Pending, None); + tx.created_at = old_created_at; + tx.valid_until = None; // No user-provided valid_until + + let tx_id = tx.id.clone(); + + // Should mark as expired + tx_repo + .expect_partial_update() + .withf(move |tx_id_param, update_req| { + tx_id_param == &tx_id && update_req.status == Some(TransactionStatus::Expired) + }) + .times(1) + .returning(move |_, _| { + let mut expired_tx = create_tx_with_signature(TransactionStatus::Expired, None); + expired_tx.status = TransactionStatus::Expired; + Ok(expired_tx) + }); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + Arc::new(tx_repo), + Arc::new(job_producer), + Arc::new(MockSolanaSignTrait::new()), + )?; + + let result = handler.handle_transaction_status_impl(tx).await; + + assert!(result.is_ok()); + let updated_tx = result.unwrap(); + assert_eq!(updated_tx.status, TransactionStatus::Expired); + Ok(()) + } + + #[tokio::test] + async fn test_default_valid_until_not_expired() -> Result<()> { + let mut provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let mut tx_repo = MockTransactionRepository::new(); + let job_producer = MockJobProducerTrait::new(); + + // Create transaction with created_at within SOLANA_DEFAULT_TX_VALID_TIMESPAN + let recent_created_at = (Utc::now() + - Duration::milliseconds(SOLANA_DEFAULT_TX_VALID_TIMESPAN - 60000)) + .to_rfc3339(); + let signature_str = + "4XFPmbPT4TRchFWNmQD2N8BhjxJQKqYdXWQG7kJJtxCBZ8Y9WtNDoPAwQaHFYnVynCjMVyF9TCMrpPFkEpG7LpZr"; + let mut tx = create_tx_with_signature(TransactionStatus::Submitted, Some(signature_str)); + tx.created_at = recent_created_at.clone(); + tx.valid_until = None; // No user-provided valid_until + + let tx_id = tx.id.clone(); + let tx_id_clone = tx_id.clone(); + let recent_created_at_clone = recent_created_at.clone(); + + // Mock provider to return processed status + provider + .expect_get_transaction_status() + .with(eq(Signature::from_str(signature_str)?)) + .times(1) + .returning(|_| Box::pin(async { Ok(SolanaTransactionStatus::Processed) })); + + // Expect get_by_id call when status changes + tx_repo + .expect_get_by_id() + .with(eq(tx_id.clone())) + .times(1) + .returning(move |_| { + let mut tx = + create_tx_with_signature(TransactionStatus::Submitted, Some(signature_str)); + tx.created_at = recent_created_at_clone.clone(); + tx.valid_until = None; + Ok(tx) + }); + + // Expect status update from Submitted to Mined (Processed maps to Mined) + tx_repo + .expect_partial_update() + .withf(move |tx_id_param, update_req| { + tx_id_param == &tx_id_clone && update_req.status == Some(TransactionStatus::Mined) + }) + .times(1) + .returning(move |_, _| { + Ok(create_tx_with_signature( + TransactionStatus::Mined, + Some(signature_str), + )) + }); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + Arc::new(tx_repo), + Arc::new(job_producer), + Arc::new(MockSolanaSignTrait::new()), + )?; + + let result = handler.handle_transaction_status_impl(tx.clone()).await; + + assert!(result.is_ok()); + let updated_tx = result.unwrap(); + // Should not be expired since within default timespan, status changes to Mined + assert_eq!(updated_tx.status, TransactionStatus::Mined); + Ok(()) + } + + #[tokio::test] + async fn test_too_many_resubmission_attempts() -> Result<()> { + let mut provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let mut tx_repo = MockTransactionRepository::new(); + let job_producer = MockJobProducerTrait::new(); + + // Create transaction with too many signatures (attempts exceeded) + let signature_str = + "4XFPmbPT4TRchFWNmQD2N8BhjxJQKqYdXWQG7kJJtxCBZ8Y9WtNDoPAwQaHFYnVynCjMVyF9TCMrpPFkEpG7LpZr"; + let mut tx = create_tx_with_signature(TransactionStatus::Submitted, Some(signature_str)); + tx.hashes = vec!["sig".to_string(); MAXIMUM_SOLANA_TX_ATTEMPTS + 1]; + tx.sent_at = Some(Utc::now().to_rfc3339()); // Ensure sent_at is set + + let tx_id = tx.id.clone(); + + // Mock provider call - return error to skip status update, go straight to resubmit check + provider + .expect_get_transaction_status() + .with(eq(Signature::from_str(signature_str)?)) + .times(1) + .returning(|_| { + Box::pin(async { + Err(crate::services::provider::SolanaProviderError::RpcError( + "test error".to_string(), + )) + }) + }); + + // Should mark as failed due to too many attempts (happens after status check) + tx_repo + .expect_partial_update() + .withf(move |tx_id_param, update_req| { + tx_id_param == &tx_id && update_req.status == Some(TransactionStatus::Failed) + }) + .times(1) + .returning(move |_, _| { + let mut failed_tx = create_tx_with_signature(TransactionStatus::Failed, None); + failed_tx.status = TransactionStatus::Failed; + Ok(failed_tx) + }); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + Arc::new(tx_repo), + Arc::new(job_producer), + Arc::new(MockSolanaSignTrait::new()), + )?; + + let result = handler.handle_transaction_status_impl(tx).await; + + assert!(result.is_ok()); + let updated_tx = result.unwrap(); + assert_eq!(updated_tx.status, TransactionStatus::Failed); + Ok(()) + } + + #[tokio::test] + async fn test_handle_pending_status_schedules_recovery_job() -> Result<()> { + let provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let mut job_producer = MockJobProducerTrait::new(); + + // Create transaction that's been pending long enough to trigger recovery + let mut tx = create_tx_with_signature(TransactionStatus::Pending, None); + tx.created_at = (Utc::now() + - Duration::seconds(SOLANA_PENDING_RECOVERY_TRIGGER_SECONDS + 10)) + .to_rfc3339(); + + let tx_id = tx.id.clone(); + + // Expect transaction request job to be produced + job_producer + .expect_produce_transaction_request_job() + .withf(move |job, _delay| job.transaction_id == tx_id) + .times(1) + .returning(|_, _| Box::pin(async { Ok(()) })); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + tx_repo, + Arc::new(job_producer), + Arc::new(MockSolanaSignTrait::new()), + )?; + + let result = handler.handle_pending_status(tx.clone()).await; + + assert!(result.is_ok()); + let returned_tx = result.unwrap(); + assert_eq!(returned_tx.status, TransactionStatus::Pending); // Status unchanged + Ok(()) + } + + #[tokio::test] + async fn test_handle_pending_status_too_young() -> Result<()> { + let provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + + // Create transaction that's too young for recovery + let mut tx = create_tx_with_signature(TransactionStatus::Pending, None); + tx.created_at = (Utc::now() + - Duration::seconds(SOLANA_PENDING_RECOVERY_TRIGGER_SECONDS - 10)) + .to_rfc3339(); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + tx_repo, + job_producer, + Arc::new(MockSolanaSignTrait::new()), + )?; + + let result = handler.handle_pending_status(tx.clone()).await; + + assert!(result.is_ok()); + let returned_tx = result.unwrap(); + assert_eq!(returned_tx.status, TransactionStatus::Pending); // Status unchanged, no job scheduled + Ok(()) + } + + #[tokio::test] + async fn test_handle_pending_status_timeout() -> Result<()> { + let provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let mut tx_repo = MockTransactionRepository::new(); + let job_producer = Arc::new(MockJobProducerTrait::new()); + + // Create transaction that's exceeded pending timeout + let mut tx = create_tx_with_signature(TransactionStatus::Pending, None); + tx.created_at = + (Utc::now() - Duration::minutes(SOLANA_PENDING_TIMEOUT_MINUTES + 1)).to_rfc3339(); + + let tx_id = tx.id.clone(); + + // Should mark as failed due to timeout + tx_repo + .expect_partial_update() + .withf(move |tx_id_param, update_req| { + tx_id_param == &tx_id && update_req.status == Some(TransactionStatus::Failed) + }) + .times(1) + .returning(move |_, _| { + let mut failed_tx = create_tx_with_signature(TransactionStatus::Failed, None); + failed_tx.status = TransactionStatus::Failed; + Ok(failed_tx) + }); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + Arc::new(tx_repo), + job_producer, + Arc::new(MockSolanaSignTrait::new()), + )?; + + let result = handler.handle_pending_status(tx).await; + + assert!(result.is_ok()); + let updated_tx = result.unwrap(); + assert_eq!(updated_tx.status, TransactionStatus::Failed); + Ok(()) + } + + #[tokio::test] + async fn test_handle_resubmit_blockhash_expired_resubmitable() -> Result<()> { + let mut provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let mut job_producer = MockJobProducerTrait::new(); + + // Create a simple transaction for testing + let payer = Pubkey::new_unique(); + let instruction = + solana_system_interface::instruction::transfer(&payer, &Pubkey::new_unique(), 1000); + let mut transaction = SolanaTransaction::new_with_payer(&[instruction], Some(&payer)); + transaction.message.recent_blockhash = Hash::from_str("11111111111111111111111111111112")?; + let transaction_bytes = bincode::serialize(&transaction)?; + let transaction_b64 = base64_encode(&transaction_bytes); + + // Create transaction with expired blockhash that's resubmitable + let signature_str = "4XFPmbPT4TRchFWNmQD2N8BhjxJQKqYdXWQG7kJJtxCBZ8Y9WtNDoPAwQaHFYnVynCjMVyF9TCMrpPFkEpG7LpZr"; + let mut tx = create_tx_with_signature(TransactionStatus::Submitted, Some(signature_str)); + tx.sent_at = Some( + (Utc::now() - Duration::seconds(SOLANA_MIN_AGE_FOR_RESUBMIT_CHECK_SECONDS + 10)) + .to_rfc3339(), + ); + tx.network_data = NetworkTransactionData::Solana(SolanaTransactionData { + transaction: Some(transaction_b64), + instructions: None, + signature: Some(signature_str.to_string()), + }); + + let tx_id = tx.id.clone(); + + // Mock provider calls + provider + .expect_is_blockhash_valid() + .with( + eq(Hash::from_str("11111111111111111111111111111112")?), + eq(CommitmentConfig::confirmed()), + ) + .times(1) + .returning(|_, _| Box::pin(async { Ok(false) })); // Blockhash expired + + // Expect resubmit job to be produced + job_producer + .expect_produce_submit_transaction_job() + .withf(move |job, _delay| { + matches!(job.command, TransactionCommand::Resubmit) && job.transaction_id == tx_id + }) + .times(1) + .returning(|_, _| Box::pin(async { Ok(()) })); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + tx_repo, + Arc::new(job_producer), + Arc::new(MockSolanaSignTrait::new()), + )?; + + let result = handler.handle_resubmit_or_expiration(tx.clone()).await; + + assert!(result.is_ok()); + let returned_tx = result.unwrap(); + assert_eq!(returned_tx.status, TransactionStatus::Submitted); // Status unchanged + Ok(()) + } + + #[tokio::test] + async fn test_handle_resubmit_blockhash_expired_not_resubmitable() -> Result<()> { + let mut provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let mut tx_repo = MockTransactionRepository::new(); + let job_producer = Arc::new(MockJobProducerTrait::new()); + + // Create multi-signature transaction (not resubmitable) + let payer = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + let additional_signer = Pubkey::new_unique(); + let instruction = system_instruction::transfer(&payer, &recipient, 1000); + + // Create message with multiple signers + let mut message = Message::new(&[instruction], Some(&payer)); + message.account_keys.push(additional_signer); + message.header.num_required_signatures = 2; // Multi-sig + message.recent_blockhash = Hash::from_str("11111111111111111111111111111112")?; + + let transaction = SolanaTransaction::new_unsigned(message); + let transaction_bytes = bincode::serialize(&transaction)?; + let transaction_b64 = base64_encode(&transaction_bytes); + + let signature_str = "4XFPmbPT4TRchFWNmQD2N8BhjxJQKqYdXWQG7kJJtxCBZ8Y9WtNDoPAwQaHFYnVynCjMVyF9TCMrpPFkEpG7LpZr"; + let mut tx = create_tx_with_signature(TransactionStatus::Submitted, Some(signature_str)); + tx.sent_at = Some( + (Utc::now() - Duration::seconds(SOLANA_MIN_AGE_FOR_RESUBMIT_CHECK_SECONDS + 10)) + .to_rfc3339(), + ); + tx.network_data = NetworkTransactionData::Solana(SolanaTransactionData { + transaction: Some(transaction_b64), + instructions: None, + signature: Some(signature_str.to_string()), + }); + + let tx_id = tx.id.clone(); + + // Mock provider calls + provider + .expect_is_blockhash_valid() + .with( + eq(Hash::from_str("11111111111111111111111111111112")?), + eq(CommitmentConfig::confirmed()), + ) + .times(1) + .returning(|_, _| Box::pin(async { Ok(false) })); // Blockhash expired + + // Should mark as expired + tx_repo + .expect_partial_update() + .withf(move |tx_id_param, update_req| { + tx_id_param == &tx_id && update_req.status == Some(TransactionStatus::Expired) + }) + .times(1) + .returning(move |_, _| { + let mut expired_tx = create_tx_with_signature(TransactionStatus::Expired, None); + expired_tx.status = TransactionStatus::Expired; + Ok(expired_tx) + }); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + Arc::new(tx_repo), + job_producer, + Arc::new(MockSolanaSignTrait::new()), + )?; + + let result = handler.handle_resubmit_or_expiration(tx).await; + + assert!(result.is_ok()); + let updated_tx = result.unwrap(); + assert_eq!(updated_tx.status, TransactionStatus::Expired); + Ok(()) + } + + #[tokio::test] + async fn test_check_any_signature_on_chain_found() -> Result<()> { + let mut provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + + let signature1 = "4XFPmbPT4TRchFWNmQD2N8BhjxJQKqYdXWQG7kJJtxCBZ8Y9WtNDoPAwQaHFYnVynCjMVyF9TCMrpPFkEpG7LpZr"; + let signature2 = "3XFPmbPT4TRchFWNmQD2N8BhjxJQKqYdXWQG7kJJtxCBZ8Y9WtNDoPAwQaHFYnVynCjMVyF9TCMrpPFkEpG7LpZr"; + + let mut tx = create_tx_with_signature(TransactionStatus::Submitted, Some(signature1)); + tx.hashes = vec![signature1.to_string(), signature2.to_string()]; + + // Mock provider to return error for first signature, success for second + provider + .expect_get_transaction_status() + .with(eq(Signature::from_str(signature1)?)) + .times(1) + .returning(|_| { + Box::pin(async { Err(SolanaProviderError::RpcError("not found".to_string())) }) + }); + + provider + .expect_get_transaction_status() + .with(eq(Signature::from_str(signature2)?)) + .times(1) + .returning(|_| Box::pin(async { Ok(SolanaTransactionStatus::Processed) })); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + tx_repo, + job_producer, + Arc::new(MockSolanaSignTrait::new()), + )?; + + let result = handler.check_any_signature_on_chain(&tx).await; + + assert!(result.is_ok()); + let found = result.unwrap(); + assert!(found.is_some()); + let (found_sig, status) = found.unwrap(); + assert_eq!(found_sig, signature2); + assert_eq!(status, SolanaTransactionStatus::Processed); + Ok(()) + } + + #[tokio::test] + async fn test_check_any_signature_on_chain_not_found() -> Result<()> { + let mut provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + + let signature1 = "4XFPmbPT4TRchFWNmQD2N8BhjxJQKqYdXWQG7kJJtxCBZ8Y9WtNDoPAwQaHFYnVynCjMVyF9TCMrpPFkEpG7LpZr"; + let signature2 = "3XFPmbPT4TRchFWNmQD2N8BhjxJQKqYdXWQG7kJJtxCBZ8Y9WtNDoPAwQaHFYnVynCjMVyF9TCMrpPFkEpG7LpZr"; + + let mut tx = create_tx_with_signature(TransactionStatus::Submitted, Some(signature1)); + tx.hashes = vec![signature1.to_string(), signature2.to_string()]; + + // Mock provider to return error for both signatures + provider + .expect_get_transaction_status() + .with(eq(Signature::from_str(signature1)?)) + .times(1) + .returning(|_| { + Box::pin(async { Err(SolanaProviderError::RpcError("not found".to_string())) }) + }); + + provider + .expect_get_transaction_status() + .with(eq(Signature::from_str(signature2)?)) + .times(1) + .returning(|_| { + Box::pin(async { Err(SolanaProviderError::RpcError("not found".to_string())) }) + }); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + tx_repo, + job_producer, + Arc::new(MockSolanaSignTrait::new()), + )?; + + let result = handler.check_any_signature_on_chain(&tx).await; + + assert!(result.is_ok()); + let found = result.unwrap(); + assert!(found.is_none()); + Ok(()) + } + + #[tokio::test] + async fn test_is_blockhash_valid_true() -> Result<()> { + let mut provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + + let blockhash = Hash::from_str("11111111111111111111111111111112")?; + + provider + .expect_is_blockhash_valid() + .with(eq(blockhash), eq(CommitmentConfig::confirmed())) + .times(1) + .returning(|_, _| Box::pin(async { Ok(true) })); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + tx_repo, + job_producer, + Arc::new(MockSolanaSignTrait::new()), + )?; + + let mut transaction = + SolanaTransaction::new_unsigned(Message::new(&[], Some(&Pubkey::new_unique()))); + transaction.message.recent_blockhash = blockhash; + + let result = handler.is_blockhash_valid(&transaction).await; + + assert!(result.is_ok()); + assert!(result.unwrap()); + Ok(()) + } + + #[tokio::test] + async fn test_is_blockhash_valid_false() -> Result<()> { + let mut provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + + let blockhash = Hash::from_str("11111111111111111111111111111112")?; + + provider + .expect_is_blockhash_valid() + .with(eq(blockhash), eq(CommitmentConfig::confirmed())) + .times(1) + .returning(|_, _| Box::pin(async { Ok(false) })); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + tx_repo, + job_producer, + Arc::new(MockSolanaSignTrait::new()), + )?; + + let mut transaction = + SolanaTransaction::new_unsigned(Message::new(&[], Some(&Pubkey::new_unique()))); + transaction.message.recent_blockhash = blockhash; + + let result = handler.is_blockhash_valid(&transaction).await; + + assert!(result.is_ok()); + assert!(!result.unwrap()); + Ok(()) + } + + #[tokio::test] + async fn test_is_blockhash_valid_error() -> Result<()> { + let mut provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + + let blockhash = Hash::from_str("11111111111111111111111111111112")?; + + provider + .expect_is_blockhash_valid() + .with(eq(blockhash), eq(CommitmentConfig::confirmed())) + .times(1) + .returning(|_, _| { + Box::pin(async { Err(SolanaProviderError::RpcError("test error".to_string())) }) + }); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + tx_repo, + job_producer, + Arc::new(MockSolanaSignTrait::new()), + )?; + + let mut transaction = + SolanaTransaction::new_unsigned(Message::new(&[], Some(&Pubkey::new_unique()))); + transaction.message.recent_blockhash = blockhash; + + let result = handler.is_blockhash_valid(&transaction).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + match error { + TransactionError::UnderlyingSolanaProvider(_) => {} // Expected + _ => panic!("Expected UnderlyingSolanaProvider error"), + } + Ok(()) + } + + #[tokio::test] + async fn test_get_time_since_sent_or_created_at_with_sent_at() { + let provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + tx_repo, + job_producer, + Arc::new(MockSolanaSignTrait::new()), + ) + .unwrap(); + + let mut tx = create_tx_with_signature(TransactionStatus::Pending, None); + let past_time = Utc::now() - Duration::minutes(5); + tx.sent_at = Some(past_time.to_rfc3339()); + + let result = handler.get_time_since_sent_or_created_at(&tx); + + assert!(result.is_some()); + let duration = result.unwrap(); + assert!(duration.num_minutes() >= 5); + } + + #[tokio::test] + async fn test_get_time_since_sent_or_created_at_with_created_at() { + let provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + tx_repo, + job_producer, + Arc::new(MockSolanaSignTrait::new()), + ) + .unwrap(); + + let mut tx = create_tx_with_signature(TransactionStatus::Pending, None); + let past_time = Utc::now() - Duration::minutes(10); + tx.created_at = past_time.to_rfc3339(); + tx.sent_at = None; // No sent_at + + let result = handler.get_time_since_sent_or_created_at(&tx); + + assert!(result.is_some()); + let duration = result.unwrap(); + assert!(duration.num_minutes() >= 10); + } + + #[tokio::test] + async fn test_has_exceeded_timeout_pending() { + let provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + tx_repo, + job_producer, + Arc::new(MockSolanaSignTrait::new()), + ) + .unwrap(); + + let mut tx = create_tx_with_signature(TransactionStatus::Pending, None); + tx.created_at = + (Utc::now() - Duration::minutes(SOLANA_PENDING_TIMEOUT_MINUTES + 1)).to_rfc3339(); + + let result = handler.has_exceeded_timeout(&tx); + + assert!(result.is_ok()); + assert!(result.unwrap()); + } + + #[tokio::test] + async fn test_has_exceeded_timeout_sent() { + let provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + tx_repo, + job_producer, + Arc::new(MockSolanaSignTrait::new()), + ) + .unwrap(); + + let mut tx = create_tx_with_signature(TransactionStatus::Sent, None); + tx.sent_at = + Some((Utc::now() - Duration::minutes(SOLANA_SENT_TIMEOUT_MINUTES + 1)).to_rfc3339()); + + let result = handler.has_exceeded_timeout(&tx); + + assert!(result.is_ok()); + assert!(result.unwrap()); + } + + #[tokio::test] + async fn test_is_valid_until_expired_user_provided() { + let provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + tx_repo, + job_producer, + Arc::new(MockSolanaSignTrait::new()), + ) + .unwrap(); + + let mut tx = create_tx_with_signature(TransactionStatus::Pending, None); + let past_time = Utc::now() - Duration::minutes(1); + tx.valid_until = Some(past_time.to_rfc3339()); + + assert!(handler.is_valid_until_expired(&tx)); + } + + #[tokio::test] + async fn test_is_valid_until_expired_default() { + let provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let tx_repo = Arc::new(MockTransactionRepository::new()); + let job_producer = Arc::new(MockJobProducerTrait::new()); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + tx_repo, + job_producer, + Arc::new(MockSolanaSignTrait::new()), + ) + .unwrap(); + + let mut tx = create_tx_with_signature(TransactionStatus::Pending, None); + let past_time = + Utc::now() - Duration::milliseconds(SOLANA_DEFAULT_TX_VALID_TIMESPAN + 1000); + tx.created_at = past_time.to_rfc3339(); + tx.valid_until = None; // Use default + + assert!(handler.is_valid_until_expired(&tx)); + } + + #[tokio::test] + async fn test_mark_as_expired() -> Result<()> { + let provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let mut tx_repo = MockTransactionRepository::new(); + let job_producer = Arc::new(MockJobProducerTrait::new()); + + let tx = create_tx_with_signature(TransactionStatus::Pending, None); + let tx_id = tx.id.clone(); + let reason = "Test expiration"; + + tx_repo + .expect_partial_update() + .withf(move |tx_id_param, update_req| { + tx_id_param == &tx_id + && update_req.status == Some(TransactionStatus::Expired) + && update_req.status_reason == Some(reason.to_string()) + }) + .times(1) + .returning(move |_, _| { + let mut expired_tx = create_tx_with_signature(TransactionStatus::Expired, None); + expired_tx.status = TransactionStatus::Expired; + Ok(expired_tx) + }); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + Arc::new(tx_repo), + job_producer, + Arc::new(MockSolanaSignTrait::new()), + )?; + + let result = handler.mark_as_expired(tx, reason.to_string()).await; + + assert!(result.is_ok()); + let updated_tx = result.unwrap(); + assert_eq!(updated_tx.status, TransactionStatus::Expired); + Ok(()) + } + + #[tokio::test] + async fn test_mark_as_failed() -> Result<()> { + let provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let mut tx_repo = MockTransactionRepository::new(); + let job_producer = Arc::new(MockJobProducerTrait::new()); + + let tx = create_tx_with_signature(TransactionStatus::Pending, None); + let tx_id = tx.id.clone(); + let reason = "Test failure"; + + tx_repo + .expect_partial_update() + .withf(move |tx_id_param, update_req| { + tx_id_param == &tx_id + && update_req.status == Some(TransactionStatus::Failed) + && update_req.status_reason == Some(reason.to_string()) + }) + .times(1) + .returning(move |_, _| { + let mut failed_tx = create_tx_with_signature(TransactionStatus::Failed, None); + failed_tx.status = TransactionStatus::Failed; + Ok(failed_tx) + }); + + let handler = SolanaRelayerTransaction::new( + create_mock_solana_relayer("test-relayer".to_string(), false), + relayer_repo, + Arc::new(provider), + Arc::new(tx_repo), + job_producer, + Arc::new(MockSolanaSignTrait::new()), + )?; + + let result = handler.mark_as_failed(tx, reason.to_string()).await; + + assert!(result.is_ok()); + let updated_tx = result.unwrap(); + assert_eq!(updated_tx.status, TransactionStatus::Failed); + Ok(()) + } + + #[tokio::test] + async fn test_update_transaction_status_and_send_notification() -> Result<()> { + let provider = MockSolanaProviderTrait::new(); + let relayer_repo = Arc::new(MockRelayerRepository::new()); + let mut tx_repo = MockTransactionRepository::new(); + let mut job_producer = MockJobProducerTrait::new(); + + // Create relayer with notification configured + let mut relayer = create_mock_solana_relayer("test-relayer".to_string(), false); + relayer.notification_id = Some("test-notification".to_string()); + + let tx = create_tx_with_signature(TransactionStatus::Submitted, None); + let tx_id = tx.id.clone(); + let new_status = TransactionStatus::Confirmed; + + tx_repo + .expect_partial_update() + .withf(move |tx_id_param, update_req| { + tx_id_param == &tx_id && update_req.status == Some(TransactionStatus::Confirmed) + }) + .times(1) + .returning(move |_, _| { + let mut confirmed_tx = create_tx_with_signature(TransactionStatus::Confirmed, None); + confirmed_tx.status = TransactionStatus::Confirmed; + Ok(confirmed_tx) + }); + + job_producer + .expect_produce_send_notification_job() + .times(1) + .returning(|_, _| Box::pin(async { Ok(()) })); + + let handler = SolanaRelayerTransaction::new( + relayer, + relayer_repo, + Arc::new(provider), + Arc::new(tx_repo), + Arc::new(job_producer), + Arc::new(MockSolanaSignTrait::new()), + )?; + + let result = handler + .update_transaction_status_and_send_notification(tx, new_status, None) + .await; + + assert!(result.is_ok()); + let updated_tx = result.unwrap(); + assert_eq!(updated_tx.status, TransactionStatus::Confirmed); + Ok(()) + } } diff --git a/src/domain/transaction/solana/utils.rs b/src/domain/transaction/solana/utils.rs new file mode 100644 index 000000000..db38cbfcf --- /dev/null +++ b/src/domain/transaction/solana/utils.rs @@ -0,0 +1,488 @@ +//! Utility functions for Solana transaction domain logic. + +use solana_sdk::{ + hash::Hash, + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, + transaction::Transaction as SolanaTransaction, +}; +use std::str::FromStr; + +use crate::{ + constants::MAXIMUM_SOLANA_TX_ATTEMPTS, + models::{ + EncodedSerializedTransaction, SolanaInstructionSpec, SolanaTransactionStatus, + TransactionError, TransactionRepoModel, TransactionStatus, + }, + utils::base64_decode, +}; + +/// Checks if a Solana transaction has exceeded the maximum number of resubmission attempts. +/// +/// Each time a transaction is resubmitted with a fresh blockhash, a new signature is generated +/// and appended to tx.hashes. This function checks if that limit has been exceeded. +/// +/// Similar to EVM's `too_many_attempts` but tailored for Solana's resubmission behavior. +pub fn too_many_solana_attempts(tx: &TransactionRepoModel) -> bool { + tx.hashes.len() >= MAXIMUM_SOLANA_TX_ATTEMPTS +} + +/// Determines if a transaction's blockhash can be safely updated. +/// +/// A blockhash can only be updated if the transaction requires a single signature (the relayer). +/// Multi-signer transactions cannot have their blockhash updated because it would invalidate +/// the existing signatures from other parties. +/// +/// # Returns +/// - `true` if the transaction has only one required signer (relayer can update blockhash) +/// - `false` if the transaction has multiple required signers (blockhash is locked) +/// +/// # Use Cases +/// - **Prepare phase**: Decide whether to fetch a fresh blockhash +/// - **Submit phase**: Decide whether BlockhashNotFound error is retriable +pub fn is_resubmitable(tx: &SolanaTransaction) -> bool { + tx.message.header.num_required_signatures <= 1 +} + +/// Maps Solana on-chain transaction status to repository transaction status. +/// +/// This mapping is used consistently across status checks to ensure uniform +/// status transitions: +/// - `Processed` → `Mined`: Transaction included in a block +/// - `Confirmed` → `Mined`: Transaction confirmed by supermajority +/// - `Finalized` → `Confirmed`: Transaction finalized (irreversible) +/// - `Failed` → `Failed`: Transaction failed on-chain +pub fn map_solana_status_to_transaction_status( + solana_status: SolanaTransactionStatus, +) -> TransactionStatus { + match solana_status { + SolanaTransactionStatus::Processed => TransactionStatus::Mined, + SolanaTransactionStatus::Confirmed => TransactionStatus::Mined, + SolanaTransactionStatus::Finalized => TransactionStatus::Confirmed, + SolanaTransactionStatus::Failed => TransactionStatus::Failed, + } +} + +/// Decodes a Solana transaction from the transaction repository model. +/// +/// Extracts the Solana transaction data and deserializes it into a SolanaTransaction. +/// This is a pure helper function that can be used anywhere in the Solana transaction domain. +/// +/// Note: This only works for transactions that have already been built (transaction field is Some). +/// For instructions-based transactions that haven't been prepared yet, this will return an error. +pub fn decode_solana_transaction( + tx: &TransactionRepoModel, +) -> Result { + let solana_data = tx.network_data.get_solana_transaction_data()?; + + if let Some(transaction_str) = &solana_data.transaction { + decode_solana_transaction_from_string(transaction_str) + } else { + Err(TransactionError::ValidationError( + "Transaction not yet built - only available after preparation".to_string(), + )) + } +} + +/// Decodes a Solana transaction from a base64-encoded string. +pub fn decode_solana_transaction_from_string( + encoded: &str, +) -> Result { + let encoded_tx = EncodedSerializedTransaction::new(encoded.to_string()); + SolanaTransaction::try_from(encoded_tx) + .map_err(|e| TransactionError::ValidationError(format!("Invalid transaction: {}", e))) +} + +/// Converts instruction specifications to Solana SDK instructions. +/// +/// Validates and converts each instruction specification by: +/// - Parsing program IDs and account pubkeys from base58 strings +/// - Decoding base64 instruction data +/// +/// # Arguments +/// * `instructions` - Array of instruction specifications from the request +/// +/// # Returns +/// Vector of Solana SDK `Instruction` objects ready to be included in a transaction +pub fn convert_instruction_specs_to_instructions( + instructions: &[SolanaInstructionSpec], +) -> Result, TransactionError> { + let mut solana_instructions = Vec::new(); + + for (idx, spec) in instructions.iter().enumerate() { + let program_id = Pubkey::from_str(&spec.program_id).map_err(|e| { + TransactionError::ValidationError(format!( + "Instruction {}: Invalid program_id: {}", + idx, e + )) + })?; + + let accounts = spec + .accounts + .iter() + .enumerate() + .map(|(acc_idx, a)| { + let pubkey = Pubkey::from_str(&a.pubkey).map_err(|e| { + TransactionError::ValidationError(format!( + "Instruction {} account {}: Invalid pubkey: {}", + idx, acc_idx, e + )) + })?; + Ok(AccountMeta { + pubkey, + is_signer: a.is_signer, + is_writable: a.is_writable, + }) + }) + .collect::, TransactionError>>()?; + + let data = base64_decode(&spec.data).map_err(|e| { + TransactionError::ValidationError(format!( + "Instruction {}: Invalid base64 data: {}", + idx, e + )) + })?; + + solana_instructions.push(Instruction { + program_id, + accounts, + data, + }); + } + + Ok(solana_instructions) +} + +/// Builds a Solana transaction from instruction specifications. +/// +/// # Arguments +/// * `instructions` - Array of instruction specifications +/// * `payer` - Public key of the fee payer (must be the first signer) +/// * `recent_blockhash` - Recent blockhash from the network +/// +/// # Returns +/// A fully formed transaction ready to be signed +pub fn build_transaction_from_instructions( + instructions: &[SolanaInstructionSpec], + payer: &Pubkey, + recent_blockhash: Hash, +) -> Result { + let solana_instructions = convert_instruction_specs_to_instructions(instructions)?; + + let mut tx = SolanaTransaction::new_with_payer(&solana_instructions, Some(payer)); + tx.message.recent_blockhash = recent_blockhash; + Ok(tx) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + models::{ + NetworkTransactionData, NetworkType, SolanaAccountMeta, SolanaTransactionData, + TransactionStatus, + }, + utils::base64_encode, + }; + use chrono::Utc; + use solana_sdk::message::Message; + use solana_system_interface::instruction as system_instruction; + + #[test] + fn test_decode_solana_transaction_invalid_data() { + // Create a transaction with invalid base64 data + let tx = TransactionRepoModel { + id: "test-tx".to_string(), + relayer_id: "test-relayer".to_string(), + status: TransactionStatus::Pending, + status_reason: None, + created_at: Utc::now().to_rfc3339(), + sent_at: None, + confirmed_at: None, + valid_until: None, + delete_at: None, + network_type: NetworkType::Solana, + network_data: NetworkTransactionData::Solana(SolanaTransactionData { + transaction: Some("invalid-base64!!!".to_string()), + ..Default::default() + }), + priced_at: None, + hashes: Vec::new(), + noop_count: None, + is_canceled: Some(false), + }; + + let result = decode_solana_transaction(&tx); + assert!(result.is_err()); + + if let Err(TransactionError::ValidationError(msg)) = result { + assert!(msg.contains("Invalid transaction")); + } else { + panic!("Expected ValidationError"); + } + } + + #[test] + fn test_decode_solana_transaction_not_built() { + // Create a transaction that hasn't been built yet (transaction field is None) + let tx = TransactionRepoModel { + id: "test-tx".to_string(), + relayer_id: "test-relayer".to_string(), + status: TransactionStatus::Pending, + status_reason: None, + created_at: Utc::now().to_rfc3339(), + sent_at: None, + confirmed_at: None, + valid_until: None, + delete_at: None, + network_type: NetworkType::Solana, + network_data: NetworkTransactionData::Solana(SolanaTransactionData { + transaction: None, // Not built yet + ..Default::default() + }), + priced_at: None, + hashes: Vec::new(), + noop_count: None, + is_canceled: Some(false), + }; + + let result = decode_solana_transaction(&tx); + assert!(result.is_err()); + + if let Err(TransactionError::ValidationError(msg)) = result { + assert!(msg.contains("not yet built")); + } else { + panic!("Expected ValidationError"); + } + } + + #[test] + fn test_convert_instruction_specs_to_instructions_success() { + let program_id = Pubkey::new_unique(); + let account = Pubkey::new_unique(); + + let specs = vec![SolanaInstructionSpec { + program_id: program_id.to_string(), + accounts: vec![SolanaAccountMeta { + pubkey: account.to_string(), + is_signer: false, + is_writable: true, + }], + data: base64_encode(b"test data"), + }]; + + let result = convert_instruction_specs_to_instructions(&specs); + assert!(result.is_ok()); + + let instructions = result.unwrap(); + assert_eq!(instructions.len(), 1); + assert_eq!(instructions[0].program_id, program_id); + assert_eq!(instructions[0].accounts.len(), 1); + assert_eq!(instructions[0].accounts[0].pubkey, account); + assert!(!instructions[0].accounts[0].is_signer); + assert!(instructions[0].accounts[0].is_writable); + } + + #[test] + fn test_build_transaction_from_instructions_success() { + let payer = Pubkey::new_unique(); + let program_id = Pubkey::new_unique(); + let account = Pubkey::new_unique(); + let blockhash = Hash::new_unique(); + + let instructions = vec![SolanaInstructionSpec { + program_id: program_id.to_string(), + accounts: vec![SolanaAccountMeta { + pubkey: account.to_string(), + is_signer: false, + is_writable: true, + }], + data: base64_encode(b"test data"), + }]; + + let result = build_transaction_from_instructions(&instructions, &payer, blockhash); + assert!(result.is_ok()); + + let tx = result.unwrap(); + assert_eq!(tx.message.account_keys[0], payer); + assert_eq!(tx.message.recent_blockhash, blockhash); + } + + #[test] + fn test_build_transaction_invalid_program_id() { + let payer = Pubkey::new_unique(); + let blockhash = Hash::new_unique(); + + let instructions = vec![SolanaInstructionSpec { + program_id: "invalid".to_string(), + accounts: vec![], + data: base64_encode(b"test"), + }]; + + let result = build_transaction_from_instructions(&instructions, &payer, blockhash); + assert!(result.is_err()); + } + + #[test] + fn test_build_transaction_invalid_base64_data() { + let payer = Pubkey::new_unique(); + let program_id = Pubkey::new_unique(); + let blockhash = Hash::new_unique(); + + let instructions = vec![SolanaInstructionSpec { + program_id: program_id.to_string(), + accounts: vec![], + data: "not-valid-base64!!!".to_string(), + }]; + + let result = build_transaction_from_instructions(&instructions, &payer, blockhash); + assert!(result.is_err()); + } + + #[test] + fn test_is_resubmitable_single_signer() { + let payer = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + let instruction = system_instruction::transfer(&payer, &recipient, 1000); + + // Create transaction with single signer + let tx = SolanaTransaction::new_with_payer(&[instruction], Some(&payer)); + + // Single signer - should be able to update blockhash + assert!(is_resubmitable(&tx)); + assert_eq!(tx.message.header.num_required_signatures, 1); + } + + #[test] + fn test_is_resubmitable_multi_signer() { + let payer = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + let additional_signer = Pubkey::new_unique(); + let instruction = system_instruction::transfer(&payer, &recipient, 1000); + + // Create transaction with multiple signers + let mut message = Message::new(&[instruction], Some(&payer)); + // Add additional signer + message.account_keys.push(additional_signer); + message.header.num_required_signatures = 2; + + let tx = SolanaTransaction::new_unsigned(message); + + // Multi-signer - cannot update blockhash + assert!(!is_resubmitable(&tx)); + assert_eq!(tx.message.header.num_required_signatures, 2); + } + + #[test] + fn test_is_resubmitable_no_signers() { + let payer = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + let instruction = system_instruction::transfer(&payer, &recipient, 1000); + + // Create transaction with no required signatures (edge case) + let mut message = Message::new(&[instruction], Some(&payer)); + message.header.num_required_signatures = 0; + + let tx = SolanaTransaction::new_unsigned(message); + + // No signers (edge case) - should be able to update + assert!(is_resubmitable(&tx)); + assert_eq!(tx.message.header.num_required_signatures, 0); + } + + #[test] + fn test_too_many_solana_attempts_under_limit() { + let tx = TransactionRepoModel { + id: "test-tx".to_string(), + relayer_id: "test-relayer".to_string(), + status: TransactionStatus::Pending, + status_reason: None, + created_at: Utc::now().to_rfc3339(), + sent_at: None, + confirmed_at: None, + valid_until: None, + delete_at: None, + network_type: NetworkType::Solana, + network_data: NetworkTransactionData::Solana(SolanaTransactionData::default()), + priced_at: None, + hashes: vec!["hash1".to_string(), "hash2".to_string()], // Less than limit + noop_count: None, + is_canceled: Some(false), + }; + + // Should not be too many attempts when under limit + assert!(!too_many_solana_attempts(&tx)); + } + + #[test] + fn test_too_many_solana_attempts_at_limit() { + let tx = TransactionRepoModel { + id: "test-tx".to_string(), + relayer_id: "test-relayer".to_string(), + status: TransactionStatus::Pending, + status_reason: None, + created_at: Utc::now().to_rfc3339(), + sent_at: None, + confirmed_at: None, + valid_until: None, + delete_at: None, + network_type: NetworkType::Solana, + network_data: NetworkTransactionData::Solana(SolanaTransactionData::default()), + priced_at: None, + hashes: vec!["hash".to_string(); MAXIMUM_SOLANA_TX_ATTEMPTS], // Exactly at limit + noop_count: None, + is_canceled: Some(false), + }; + + // Should be too many attempts when at limit + assert!(too_many_solana_attempts(&tx)); + } + + #[test] + fn test_too_many_solana_attempts_over_limit() { + let tx = TransactionRepoModel { + id: "test-tx".to_string(), + relayer_id: "test-relayer".to_string(), + status: TransactionStatus::Pending, + status_reason: None, + created_at: Utc::now().to_rfc3339(), + sent_at: None, + confirmed_at: None, + valid_until: None, + delete_at: None, + network_type: NetworkType::Solana, + network_data: NetworkTransactionData::Solana(SolanaTransactionData::default()), + priced_at: None, + hashes: vec!["hash".to_string(); MAXIMUM_SOLANA_TX_ATTEMPTS + 1], // Over limit + noop_count: None, + is_canceled: Some(false), + }; + + // Should be too many attempts when over limit + assert!(too_many_solana_attempts(&tx)); + } + + #[test] + fn test_map_solana_status_to_transaction_status_processed() { + let result = map_solana_status_to_transaction_status(SolanaTransactionStatus::Processed); + assert_eq!(result, TransactionStatus::Mined); + } + + #[test] + fn test_map_solana_status_to_transaction_status_confirmed() { + let result = map_solana_status_to_transaction_status(SolanaTransactionStatus::Confirmed); + assert_eq!(result, TransactionStatus::Mined); + } + + #[test] + fn test_map_solana_status_to_transaction_status_finalized() { + let result = map_solana_status_to_transaction_status(SolanaTransactionStatus::Finalized); + assert_eq!(result, TransactionStatus::Confirmed); + } + + #[test] + fn test_map_solana_status_to_transaction_status_failed() { + let result = map_solana_status_to_transaction_status(SolanaTransactionStatus::Failed); + assert_eq!(result, TransactionStatus::Failed); + } +} diff --git a/src/domain/relayer/solana/rpc/methods/validations.rs b/src/domain/transaction/solana/validation.rs similarity index 88% rename from src/domain/relayer/solana/rpc/methods/validations.rs rename to src/domain/transaction/solana/validation.rs index 12019c292..e7979dcf5 100644 --- a/src/domain/relayer/solana/rpc/methods/validations.rs +++ b/src/domain/transaction/solana/validation.rs @@ -13,18 +13,17 @@ use crate::{ constants::{DEFAULT_SOLANA_MAX_TX_DATA_SIZE, DEFAULT_SOLANA_MIN_BALANCE}, domain::{SolanaTokenProgram, TokenInstruction as SolanaTokenInstruction}, models::RelayerSolanaPolicy, - services::provider::SolanaProviderTrait, + services::provider::{SolanaProviderError, SolanaProviderTrait}, }; +use serde::Serialize; use solana_client::rpc_response::RpcSimulateTransactionResult; -use solana_sdk::{ - commitment_config::CommitmentConfig, pubkey::Pubkey, system_instruction::SystemInstruction, - transaction::Transaction, -}; -use solana_system_interface::program; +use solana_commitment_config::CommitmentConfig; +use solana_sdk::{pubkey::Pubkey, transaction::Transaction}; +use solana_system_interface::{instruction::SystemInstruction, program}; use thiserror::Error; use tracing::info; -#[derive(Debug, Error)] +#[derive(Debug, Error, Serialize)] #[allow(dead_code)] pub enum SolanaTransactionValidationError { #[error("Failed to decode transaction: {0}")] @@ -33,8 +32,6 @@ pub enum SolanaTransactionValidationError { DeserializeError(String), #[error("Validation error: {0}")] SigningError(String), - #[error("Simulation error: {0}")] - SimulationError(String), #[error("Policy violation: {0}")] PolicyViolation(String), #[error("Blockhash {0} is expired")] @@ -47,6 +44,65 @@ pub enum SolanaTransactionValidationError { InsufficientFunds(String), #[error("Insufficient balance: {0}")] InsufficientBalance(String), + #[error("Underlying Solana provider error: {0}")] + UnderlyingSolanaProvider(#[from] SolanaProviderError), +} + +impl SolanaTransactionValidationError { + /// Determines if this validation error is transient (retriable) or permanent. + /// + /// Transient errors are typically RPC/network issues that may succeed on retry: + /// - RPC connection errors + /// - Network timeouts + /// - Node behind errors + /// + /// Permanent errors are validation failures that won't change on retry: + /// - Policy violations + /// - Invalid transaction structure + /// - Insufficient funds (actual balance issue, not RPC error) + pub fn is_transient(&self) -> bool { + match self { + // Policy violations are always permanent + Self::PolicyViolation(_) => false, + + // Fee payer mismatch is permanent + Self::FeePayer(_) => false, + + // Decode/deserialize errors are permanent (invalid transaction) + Self::DecodeError(_) | Self::DeserializeError(_) => false, + + // Expired blockhash is permanent (cannot be fixed by retry) + Self::ExpiredBlockhash(_) => false, + + // Signing errors are permanent + Self::SigningError(_) => false, + + Self::UnderlyingSolanaProvider(err) => err.is_transient(), + + // Generic validation errors - check message for transient patterns + Self::ValidationError(msg) => { + // Check for known transient error patterns in the message + msg.contains("RPC") + || msg.contains("timeout") + || msg.contains("timed out") + || msg.contains("connection") + || msg.contains("network") + || msg.contains("Failed to check") + || msg.contains("Failed to get") + || msg.contains("node behind") + || msg.contains("rate limit") + } + + // Balance errors - check if it's an RPC error or actual insufficient balance + Self::InsufficientBalance(msg) | Self::InsufficientFunds(msg) => { + // If the message indicates an RPC failure, it's transient + msg.contains("Failed to get balance") + || msg.contains("RPC") + || msg.contains("timeout") + || msg.contains("network") + } + } + } } #[allow(dead_code)] @@ -110,6 +166,14 @@ impl SolanaTransactionValidator { } /// Validates that the transaction's blockhash is still valid. + /// + /// Checks if the provided blockhash is still valid on-chain. If the blockhash has expired, + /// the transaction will fail when submitted. + /// + /// **Note**: For single-signer transactions, expired blockhashes can be refreshed during + /// resubmission. However, validation still occurs to provide early feedback. + /// For multi-signer transactions, expired blockhashes cannot be refreshed without + /// invalidating existing signatures, so validation is critical. pub async fn validate_blockhash( tx: &Transaction, provider: &T, @@ -119,13 +183,7 @@ impl SolanaTransactionValidator { // Check if blockhash is still valid let is_valid = provider .is_blockhash_valid(&blockhash, CommitmentConfig::confirmed()) - .await - .map_err(|e| { - SolanaTransactionValidationError::ValidationError(format!( - "Failed to check blockhash validity: {}", - e - )) - })?; + .await?; if !is_valid { return Err(SolanaTransactionValidationError::ExpiredBlockhash(format!( @@ -339,11 +397,7 @@ impl SolanaTransactionValidator { policy: &RelayerSolanaPolicy, provider: &impl SolanaProviderTrait, ) -> Result<(), SolanaTransactionValidationError> { - let balance = provider - .get_balance(relayer_address) - .await - .map_err(|e| SolanaTransactionValidationError::ValidationError(e.to_string()))?; - + let balance = provider.get_balance(relayer_address).await?; // Ensure minimum balance policy is maintained let min_balance = policy.min_balance.unwrap_or(DEFAULT_SOLANA_MIN_BALANCE); let required_balance = fee + min_balance; @@ -563,10 +617,9 @@ impl SolanaTransactionValidator { ) -> Result { let new_tx = Transaction::new_unsigned(tx.message.clone()); - provider - .simulate_transaction(&new_tx) - .await - .map_err(|e| SolanaTransactionValidationError::SimulationError(e.to_string())) + let result = provider.simulate_transaction(&new_tx).await?; + + Ok(result) } } @@ -586,7 +639,7 @@ mod tests { signature::{Keypair, Signer}, }; use solana_system_interface::{instruction, program}; - use spl_token::{instruction as token_instruction, state::Account}; + use spl_token_interface::{instruction as token_instruction, state::Account}; fn setup_token_transfer_test( transfer_amount: Option, @@ -606,7 +659,7 @@ mod tests { // Create token transfer instruction let transfer_ix = token_instruction::transfer( - &spl_token::id(), + &spl_token_interface::id(), &source, &destination, &owner.pubkey(), @@ -649,7 +702,7 @@ mod tests { mint, owner: owner.pubkey(), amount: 999, - state: spl_token::state::AccountState::Initialized, + state: spl_token_interface::state::AccountState::Initialized, ..Default::default() }; let mut account_data = vec![0; Account::LEN]; @@ -663,7 +716,7 @@ mod tests { Ok(solana_sdk::account::Account { lamports: 1000000, data: local_account_data, - owner: spl_token::id(), + owner: spl_token_interface::id(), executable: false, rent_epoch: 0, }) @@ -688,6 +741,23 @@ mod tests { Transaction::new_unsigned(message) } + fn create_multi_signer_test_transaction( + fee_payer: &Pubkey, + additional_signer: &Pubkey, + ) -> Transaction { + let recipient = Pubkey::new_unique(); + let instruction = instruction::transfer(fee_payer, &recipient, 1000); + // Create message with 2 required signatures + let mut message = Message::new(&[instruction], Some(fee_payer)); + // Add second signer to account keys + if !message.account_keys.contains(additional_signer) { + message.account_keys.push(*additional_signer); + } + // Set num_required_signatures to 2 + message.header.num_required_signatures = 2; + Transaction::new_unsigned(message) + } + #[test] fn test_validate_fee_payer_success() { let relayer_keypair = Keypair::new(); @@ -715,7 +785,10 @@ mod tests { #[tokio::test] async fn test_validate_blockhash_valid() { - let transaction = create_test_transaction(&Keypair::new().pubkey()); + // Use multi-signer transaction so blockhash validation actually runs + let fee_payer = Keypair::new().pubkey(); + let additional_signer = Keypair::new().pubkey(); + let transaction = create_multi_signer_test_transaction(&fee_payer, &additional_signer); let mut mock_provider = MockSolanaProviderTrait::new(); mock_provider @@ -734,7 +807,10 @@ mod tests { #[tokio::test] async fn test_validate_blockhash_expired() { - let transaction = create_test_transaction(&Keypair::new().pubkey()); + // Use multi-signer transaction so blockhash validation actually runs + let fee_payer = Keypair::new().pubkey(); + let additional_signer = Keypair::new().pubkey(); + let transaction = create_multi_signer_test_transaction(&fee_payer, &additional_signer); let mut mock_provider = MockSolanaProviderTrait::new(); mock_provider @@ -752,7 +828,10 @@ mod tests { #[tokio::test] async fn test_validate_blockhash_provider_error() { - let transaction = create_test_transaction(&Keypair::new().pubkey()); + // Use multi-signer transaction so blockhash validation actually runs + let fee_payer = Keypair::new().pubkey(); + let additional_signer = Keypair::new().pubkey(); + let transaction = create_multi_signer_test_transaction(&fee_payer, &additional_signer); let mut mock_provider = MockSolanaProviderTrait::new(); mock_provider.expect_is_blockhash_valid().returning(|_, _| { @@ -764,10 +843,29 @@ mod tests { assert!(matches!( result.unwrap_err(), - SolanaTransactionValidationError::ValidationError(_) + SolanaTransactionValidationError::UnderlyingSolanaProvider(_) )); } + #[tokio::test] + async fn test_validate_blockhash_validates_single_signer() { + // Single-signer transactions are now validated (no longer skipped) + // This provides early feedback even though blockhash can be refreshed during resubmit + let transaction = create_test_transaction(&Keypair::new().pubkey()); + let mut mock_provider = MockSolanaProviderTrait::new(); + + // Expect provider call for blockhash validation + mock_provider + .expect_is_blockhash_valid() + .returning(|_, _| Box::pin(async { Ok(true) })); + + let result = + SolanaTransactionValidator::validate_blockhash(&transaction, &mock_provider).await; + + // Should succeed after validation + assert!(result.is_ok()); + } + #[test] fn test_validate_max_signatures_within_limit() { let transaction = create_test_transaction(&Keypair::new().pubkey()); @@ -1116,6 +1214,12 @@ mod tests { inner_instructions: None, replacement_blockhash: None, loaded_accounts_data_size: None, + fee: None, + pre_balances: None, + post_balances: None, + pre_token_balances: None, + post_token_balances: None, + loaded_addresses: None, }; Box::pin(async { Ok(simulation_result) }) }); @@ -1147,7 +1251,7 @@ mod tests { assert!(matches!( result.unwrap_err(), - SolanaTransactionValidationError::SimulationError(_) + SolanaTransactionValidationError::UnderlyingSolanaProvider(_) )); } diff --git a/src/domain/transaction/stellar/mod.rs b/src/domain/transaction/stellar/mod.rs index 0160d46e5..c17e046bc 100644 --- a/src/domain/transaction/stellar/mod.rs +++ b/src/domain/transaction/stellar/mod.rs @@ -13,6 +13,9 @@ pub use utils::*; mod lane_gate; pub use lane_gate::*; +// Re-export common transaction utilities +pub use crate::domain::transaction::common::is_final_state; + pub mod validation; #[cfg(test)] diff --git a/src/domain/transaction/stellar/prepare/mod.rs b/src/domain/transaction/stellar/prepare/mod.rs index 1b434484b..473229f1f 100644 --- a/src/domain/transaction/stellar/prepare/mod.rs +++ b/src/domain/transaction/stellar/prepare/mod.rs @@ -11,8 +11,7 @@ pub mod unsigned_xdr; use eyre::Result; use tracing::{debug, info, warn}; -use super::{lane_gate, StellarRelayerTransaction}; -use crate::domain::transaction::common::is_final_state; +use super::{is_final_state, lane_gate, StellarRelayerTransaction}; use crate::models::RelayerRepoModel; use crate::{ jobs::JobProducerTrait, diff --git a/src/domain/transaction/stellar/status.rs b/src/domain/transaction/stellar/status.rs index 786053dd8..5392b65df 100644 --- a/src/domain/transaction/stellar/status.rs +++ b/src/domain/transaction/stellar/status.rs @@ -6,8 +6,7 @@ use chrono::Utc; use soroban_rs::xdr::{Error, Hash}; use tracing::{debug, info, warn}; -use super::StellarRelayerTransaction; -use crate::domain::transaction::common::is_final_state; +use super::{is_final_state, StellarRelayerTransaction}; use crate::{ jobs::JobProducerTrait, models::{ diff --git a/src/domain/transaction/stellar/submit.rs b/src/domain/transaction/stellar/submit.rs index 8fa08e4c9..a2f2c9267 100644 --- a/src/domain/transaction/stellar/submit.rs +++ b/src/domain/transaction/stellar/submit.rs @@ -5,8 +5,7 @@ use chrono::Utc; use tracing::{info, warn}; -use super::{utils::is_bad_sequence_error, StellarRelayerTransaction}; -use crate::domain::transaction::common::is_final_state; +use super::{is_final_state, utils::is_bad_sequence_error, StellarRelayerTransaction}; use crate::{ constants::STELLAR_BAD_SEQUENCE_RETRY_DELAY_SECONDS, jobs::JobProducerTrait, diff --git a/src/models/error/transaction.rs b/src/models/error/transaction.rs index c552efdcf..98d4d9ca7 100644 --- a/src/models/error/transaction.rs +++ b/src/models/error/transaction.rs @@ -1,4 +1,5 @@ use crate::{ + domain::solana::SolanaTransactionValidationError, jobs::JobProducerError, models::{SignerError, SignerFactoryError}, services::provider::{ProviderError, SolanaProviderError}, @@ -15,6 +16,9 @@ pub enum TransactionError { #[error("Transaction validation error: {0}")] ValidationError(String), + #[error("Solana transaction validation error: {0}")] + SolanaValidation(#[from] SolanaTransactionValidationError), + #[error("Network configuration error: {0}")] NetworkConfiguration(String), @@ -46,10 +50,52 @@ pub enum TransactionError { SimulationFailed(String), } +impl TransactionError { + /// Determines if this error is transient (can retry) or permanent (should fail). + /// + /// **Transient (can retry):** + /// - `SolanaValidation`: Delegates to underlying error's is_transient() + /// - `UnderlyingSolanaProvider`: Delegates to underlying error's is_transient() + /// - `UnderlyingProvider`: Delegates to underlying error's is_transient() + /// - `UnexpectedError`: Unexpected errors may resolve on retry + /// - `JobProducerError`: Job queue issues are typically transient + /// + /// **Permanent (fail immediately):** + /// - `ValidationError`: Malformed data, missing fields, invalid state transitions + /// - `InsufficientBalance`: Balance issues won't resolve without funding + /// - `NetworkConfiguration`: Configuration errors are permanent + /// - `InvalidType`: Type mismatches are permanent + /// - `NotSupported`: Unsupported operations won't change + /// - `SignerError`: Signer issues are typically permanent + /// - `SimulationFailed`: Transaction simulation failures are permanent + pub fn is_transient(&self) -> bool { + match self { + // Delegate to underlying error's is_transient() method + TransactionError::SolanaValidation(err) => err.is_transient(), + TransactionError::UnderlyingSolanaProvider(err) => err.is_transient(), + TransactionError::UnderlyingProvider(err) => err.is_transient(), + + // Transient errors - may resolve on retry + TransactionError::UnexpectedError(_) => true, + TransactionError::JobProducerError(_) => true, + + // Permanent errors - fail immediately + TransactionError::ValidationError(_) => false, + TransactionError::InsufficientBalance(_) => false, + TransactionError::NetworkConfiguration(_) => false, + TransactionError::InvalidType(_) => false, + TransactionError::NotSupported(_) => false, + TransactionError::SignerError(_) => false, + TransactionError::SimulationFailed(_) => false, + } + } +} + impl From for ApiError { fn from(error: TransactionError) -> Self { match error { TransactionError::ValidationError(msg) => ApiError::BadRequest(msg), + TransactionError::SolanaValidation(err) => ApiError::BadRequest(err.to_string()), TransactionError::NetworkConfiguration(msg) => ApiError::InternalError(msg), TransactionError::JobProducerError(msg) => ApiError::InternalError(msg.to_string()), TransactionError::InvalidType(msg) => ApiError::InternalError(msg), @@ -320,4 +366,124 @@ mod tests { _ => panic!("Expected TransactionError::ValidationError"), } } + + #[test] + fn test_is_transient_permanent_errors() { + // Test permanent errors that should return false + let permanent_errors = vec![ + TransactionError::ValidationError("invalid input".to_string()), + TransactionError::InsufficientBalance("not enough funds".to_string()), + TransactionError::NetworkConfiguration("wrong network".to_string()), + TransactionError::InvalidType("unknown type".to_string()), + TransactionError::NotSupported("feature unavailable".to_string()), + TransactionError::SignerError("key error".to_string()), + TransactionError::SimulationFailed("sim failed".to_string()), + ]; + + for error in permanent_errors { + assert!( + !error.is_transient(), + "Error {:?} should be permanent", + error + ); + } + } + + #[test] + fn test_is_transient_transient_errors() { + // Test transient errors that should return true + let transient_errors = vec![ + TransactionError::UnexpectedError("something went wrong".to_string()), + TransactionError::JobProducerError(JobProducerError::QueueError( + "queue full".to_string(), + )), + ]; + + for error in transient_errors { + assert!( + error.is_transient(), + "Error {:?} should be transient", + error + ); + } + } + + #[test] + fn test_stellar_provider_error_conversion() { + // Test SimulationFailed + let sim_error = StellarProviderError::SimulationFailed("sim failed".to_string()); + let tx_error = TransactionError::from(sim_error); + match tx_error { + TransactionError::SimulationFailed(msg) => { + assert_eq!(msg, "sim failed"); + } + _ => panic!("Expected TransactionError::SimulationFailed"), + } + + // Test InsufficientBalance + let balance_error = + StellarProviderError::InsufficientBalance("not enough funds".to_string()); + let tx_error = TransactionError::from(balance_error); + match tx_error { + TransactionError::InsufficientBalance(msg) => { + assert_eq!(msg, "not enough funds"); + } + _ => panic!("Expected TransactionError::InsufficientBalance"), + } + + // Test BadSeq + let seq_error = StellarProviderError::BadSeq("bad sequence".to_string()); + let tx_error = TransactionError::from(seq_error); + match tx_error { + TransactionError::ValidationError(msg) => { + assert_eq!(msg, "bad sequence"); + } + _ => panic!("Expected TransactionError::ValidationError"), + } + + // Test RpcError + let rpc_error = StellarProviderError::RpcError("rpc failed".to_string()); + let tx_error = TransactionError::from(rpc_error); + match tx_error { + TransactionError::UnderlyingProvider(ProviderError::NetworkConfiguration(msg)) => { + assert_eq!(msg, "rpc failed"); + } + _ => panic!("Expected TransactionError::UnderlyingProvider"), + } + + // Test Unknown + let unknown_error = StellarProviderError::Unknown("unknown error".to_string()); + let tx_error = TransactionError::from(unknown_error); + match tx_error { + TransactionError::UnderlyingProvider(ProviderError::NetworkConfiguration(msg)) => { + assert_eq!(msg, "unknown error"); + } + _ => panic!("Expected TransactionError::UnderlyingProvider"), + } + } + + #[test] + fn test_is_transient_delegated_errors() { + // Test errors that delegate to underlying error's is_transient() method + // We need to create mock errors that have is_transient() methods + + // For SolanaValidation - create a mock error + use crate::domain::solana::SolanaTransactionValidationError; + let solana_validation_error = + SolanaTransactionValidationError::ValidationError("bad validation".to_string()); + let tx_error = TransactionError::SolanaValidation(solana_validation_error); + // This will delegate to the underlying error's is_transient method + // We can't easily test the delegation without mocking, so we'll just ensure it doesn't panic + let _ = tx_error.is_transient(); + + // For UnderlyingSolanaProvider + let solana_provider_error = SolanaProviderError::RpcError("rpc failed".to_string()); + let tx_error = TransactionError::UnderlyingSolanaProvider(solana_provider_error); + let _ = tx_error.is_transient(); + + // For UnderlyingProvider + let provider_error = ProviderError::NetworkConfiguration("network issue".to_string()); + let tx_error = TransactionError::UnderlyingProvider(provider_error); + let _ = tx_error.is_transient(); + } } diff --git a/src/models/relayer/mod.rs b/src/models/relayer/mod.rs index f008ce613..99c4c7f1d 100644 --- a/src/models/relayer/mod.rs +++ b/src/models/relayer/mod.rs @@ -379,6 +379,12 @@ impl SolanaAllowedTokensPolicy { } /// Solana fee payment strategy +/// +/// Determines who pays transaction fees: +/// - `User`: User must include fee payment to relayer in transaction (for custom RPC methods) +/// - `Relayer`: Relayer pays all transaction fees (recommended for send transaction endpoint) +/// +/// Default is `User`. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, Default)] #[serde(rename_all = "lowercase")] pub enum SolanaFeePaymentStrategy { diff --git a/src/models/transaction/repository.rs b/src/models/transaction/repository.rs index d74e3e526..994b6849b 100644 --- a/src/models/transaction/repository.rs +++ b/src/models/transaction/repository.rs @@ -14,6 +14,7 @@ use crate::{ models::{ transaction::{ request::{evm::EvmTransactionRequest, stellar::StellarTransactionRequest}, + solana::SolanaInstructionSpec, stellar::{DecoratedSignature, MemoSpec, OperationSpec}, }, AddressError, EvmNetwork, NetworkRepoModel, NetworkTransactionRequest, NetworkType, @@ -436,12 +437,25 @@ impl EvmTransactionDataTrait for EvmTransactionData { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct SolanaTransactionData { - pub transaction: String, + /// Pre-built serialized transaction (base64) - mutually exclusive with instructions + pub transaction: Option, + /// Instructions to build transaction from - mutually exclusive with transaction + pub instructions: Option>, + /// Transaction signature after submission pub signature: Option, } +impl SolanaTransactionData { + /// Creates a new `SolanaTransactionData` with an updated signature. + /// Moves the data to avoid unnecessary cloning. + pub fn with_signature(mut self, signature: String) -> Self { + self.signature = Some(signature); + self + } +} + /// Represents different input types for Stellar transactions #[derive(Debug, Clone, Serialize, Deserialize)] pub enum TransactionInput { @@ -839,11 +853,12 @@ impl created_at: now, sent_at: None, confirmed_at: None, - valid_until: None, + valid_until: solana_request.valid_until.clone(), delete_at: None, network_type: NetworkType::Solana, network_data: NetworkTransactionData::Solana(SolanaTransactionData { - transaction: solana_request.transaction.clone().into_inner(), + transaction: solana_request.transaction.clone().map(|t| t.into_inner()), + instructions: solana_request.instructions.clone(), signature: None, }), priced_at: None, @@ -1389,8 +1404,8 @@ mod tests { // Should fail for non-EVM data let solana_data = NetworkTransactionData::Solana(SolanaTransactionData { - transaction: "transaction_123".to_string(), - signature: None, + transaction: Some("transaction_123".to_string()), + ..Default::default() }); assert!(solana_data.get_evm_transaction_data().is_err()); } @@ -1398,8 +1413,8 @@ mod tests { #[test] fn test_network_tx_data_get_solana_transaction_data() { let solana_tx_data = SolanaTransactionData { - transaction: "transaction_123".to_string(), - signature: None, + transaction: Some("transaction_123".to_string()), + ..Default::default() }; let network_data = NetworkTransactionData::Solana(solana_tx_data.clone()); @@ -1470,8 +1485,8 @@ mod tests { // Should fail for non-EVM data let solana_data = NetworkTransactionData::Solana(SolanaTransactionData { - transaction: "transaction_123".to_string(), - signature: None, + transaction: Some("transaction_123".to_string()), + ..Default::default() }); assert!(TxLegacy::try_from(solana_data).is_err()); } @@ -1718,7 +1733,11 @@ mod tests { let solana_request = NetworkTransactionRequest::Solana( crate::models::transaction::request::solana::SolanaTransactionRequest { - transaction: EncodedSerializedTransaction::new("transaction_123".to_string()), + transaction: Some(EncodedSerializedTransaction::new( + "transaction_123".to_string(), + )), + instructions: None, + valid_until: None, }, ); @@ -1765,7 +1784,7 @@ mod tests { assert_eq!(transaction.valid_until, None); if let NetworkTransactionData::Solana(solana_data) = transaction.network_data { - assert_eq!(solana_data.transaction, "transaction_123".to_string()); + assert_eq!(solana_data.transaction, Some("transaction_123".to_string())); assert_eq!(solana_data.signature, None); } else { panic!("Expected Solana transaction data"); @@ -1916,8 +1935,8 @@ mod tests { // Should fail for non-EVM data let solana_data = NetworkTransactionData::Solana(SolanaTransactionData { - transaction: "transaction_123".to_string(), - signature: None, + transaction: Some("transaction_123".to_string()), + ..Default::default() }); assert!(TxEip1559::try_from(solana_data).is_err()); } diff --git a/src/models/transaction/request/mod.rs b/src/models/transaction/request/mod.rs index c2206c517..f4fecaa88 100644 --- a/src/models/transaction/request/mod.rs +++ b/src/models/transaction/request/mod.rs @@ -40,7 +40,7 @@ impl NetworkTransactionRequest { match self { NetworkTransactionRequest::Evm(request) => request.validate(relayer), NetworkTransactionRequest::Stellar(request) => request.validate(), - _ => Ok(()), + NetworkTransactionRequest::Solana(request) => request.validate(relayer), } } } diff --git a/src/models/transaction/request/solana.rs b/src/models/transaction/request/solana.rs index 4629dcc03..94b1657f2 100644 --- a/src/models/transaction/request/solana.rs +++ b/src/models/transaction/request/solana.rs @@ -1,8 +1,689 @@ -use crate::models::EncodedSerializedTransaction; +use crate::{ + constants::{ + REQUEST_MAX_ACCOUNTS_PER_INSTRUCTION, REQUEST_MAX_INSTRUCTIONS, + REQUEST_MAX_INSTRUCTION_DATA_SIZE, REQUEST_MAX_TOTAL_ACCOUNTS, + }, + models::{ApiError, EncodedSerializedTransaction, RelayerRepoModel, SolanaInstructionSpec}, + utils::base64_decode, +}; use serde::{Deserialize, Serialize}; +use solana_sdk::{pubkey::Pubkey, transaction::Transaction}; +use std::{collections::HashSet, str::FromStr}; use utoipa::ToSchema; #[derive(Deserialize, Serialize, ToSchema)] pub struct SolanaTransactionRequest { - pub transaction: EncodedSerializedTransaction, + /// Pre-built base64-encoded transaction (mutually exclusive with instructions) + #[schema(nullable = true)] + pub transaction: Option, + + /// Instructions to build transaction from (mutually exclusive with transaction) + #[schema(nullable = true)] + pub instructions: Option>, + + /// Optional RFC3339 timestamp when transaction should expire + #[schema(nullable = true)] + pub valid_until: Option, +} + +impl SolanaTransactionRequest { + pub fn validate(&self, relayer: &RelayerRepoModel) -> Result<(), ApiError> { + let has_transaction = self.transaction.is_some(); + let has_instructions = self + .instructions + .as_ref() + .map(|i| !i.is_empty()) + .unwrap_or(false); + + match (has_transaction, has_instructions) { + (true, true) => { + return Err(ApiError::BadRequest( + "Cannot provide both transaction and instructions".to_string(), + )); + } + (false, false) => { + return Err(ApiError::BadRequest( + "Must provide either transaction or instructions".to_string(), + )); + } + _ => {} + } + + // Validate pre-built transaction if provided + if let Some(ref transaction) = self.transaction { + Self::validate_transaction(transaction, relayer)?; + } + + // Validate instructions if provided + if let Some(ref instructions) = self.instructions { + Self::validate_instructions(instructions, relayer)?; + } + + // Validate valid_until if provided + if let Some(valid_until) = &self.valid_until { + match chrono::DateTime::parse_from_rfc3339(valid_until) { + Ok(valid_until_dt) => { + if valid_until_dt <= chrono::Utc::now() { + return Err(ApiError::BadRequest( + "valid_until cannot be in the past".to_string(), + )); + } + } + Err(_) => { + return Err(ApiError::BadRequest( + "valid_until must be a valid RFC3339 timestamp".to_string(), + )); + } + } + } + + Ok(()) + } + + /// Validates a pre-built Solana transaction + /// + /// Ensures the transaction meets requirements: + /// - Transaction can be decoded from base64 + /// - Fee payer (source) matches the relayer address + fn validate_transaction( + transaction: &EncodedSerializedTransaction, + relayer: &RelayerRepoModel, + ) -> Result<(), ApiError> { + // Parse the transaction from encoded bytes + let tx = Transaction::try_from(transaction.clone()) + .map_err(|e| ApiError::BadRequest(format!("Failed to decode transaction: {}", e)))?; + + // Get the fee payer (first account in account_keys) + let fee_payer = tx.message.account_keys.first().ok_or_else(|| { + ApiError::BadRequest("Transaction has no fee payer account".to_string()) + })?; + + // Parse relayer address + let relayer_pubkey = Pubkey::from_str(&relayer.address) + .map_err(|e| ApiError::BadRequest(format!("Invalid relayer address: {}", e)))?; + + // Validate fee payer matches relayer address + if fee_payer != &relayer_pubkey { + return Err(ApiError::BadRequest(format!( + "Transaction fee payer {} does not match relayer address {}", + fee_payer, relayer_pubkey + ))); + } + + Ok(()) + } + + /// Validates Solana instruction specifications + /// + /// Ensures all instruction fields are valid: + /// - Number of instructions within reasonable limits (max 64) + /// - Program IDs are valid Solana public keys (not empty, not default pubkey) + /// - Account public keys are valid (not empty) + /// - Accounts per instruction within Solana's limit (max 64) + /// - Total unique accounts don't exceed Solana's limit (max 64) + /// - Instruction data is valid base64 and within size limits (max 1000 bytes) + /// - Only the relayer can be marked as a signer (relayer auto-signs as fee payer) + fn validate_instructions( + instructions: &[SolanaInstructionSpec], + relayer: &RelayerRepoModel, + ) -> Result<(), ApiError> { + // Parse relayer address once for validation + let relayer_pubkey = Pubkey::from_str(&relayer.address) + .map_err(|e| ApiError::BadRequest(format!("Invalid relayer address: {}", e)))?; + if instructions.is_empty() { + return Err(ApiError::BadRequest( + "Instructions cannot be empty".to_string(), + )); + } + + if instructions.len() > REQUEST_MAX_INSTRUCTIONS { + return Err(ApiError::BadRequest(format!( + "Too many instructions: {} exceeds maximum of {}", + instructions.len(), + REQUEST_MAX_INSTRUCTIONS + ))); + } + + let mut unique_accounts = HashSet::new(); + + for (idx, instruction) in instructions.iter().enumerate() { + // Validate program_id is not empty/whitespace + let trimmed_program_id = instruction.program_id.trim(); + if trimmed_program_id.is_empty() { + return Err(ApiError::BadRequest(format!( + "Instruction {}: program_id cannot be empty", + idx + ))); + } + + // Validate program_id is valid pubkey + let program_pubkey = Pubkey::from_str(trimmed_program_id).map_err(|e| { + ApiError::BadRequest(format!( + "Instruction {}: Invalid program_id '{}' - {}", + idx, trimmed_program_id, e + )) + })?; + + // Reject default/zero pubkey as program_id + if program_pubkey == Pubkey::default() { + return Err(ApiError::BadRequest(format!( + "Instruction {}: program_id cannot be default pubkey", + idx + ))); + } + + unique_accounts.insert(program_pubkey); + + // Validate number of accounts per instruction + if instruction.accounts.len() > REQUEST_MAX_ACCOUNTS_PER_INSTRUCTION { + return Err(ApiError::BadRequest(format!( + "Instruction {}: Too many accounts {} exceeds maximum of {}", + idx, + instruction.accounts.len(), + REQUEST_MAX_ACCOUNTS_PER_INSTRUCTION + ))); + } + + // Validate account pubkeys + for (acc_idx, account) in instruction.accounts.iter().enumerate() { + let trimmed_pubkey = account.pubkey.trim(); + if trimmed_pubkey.is_empty() { + return Err(ApiError::BadRequest(format!( + "Instruction {} account {}: pubkey cannot be empty", + idx, acc_idx + ))); + } + + let pubkey = Pubkey::from_str(trimmed_pubkey).map_err(|e| { + ApiError::BadRequest(format!( + "Instruction {} account {}: Invalid pubkey '{}' - {}", + idx, acc_idx, trimmed_pubkey, e + )) + })?; + + // Validate that only the relayer can be marked as a signer + if account.is_signer && pubkey != relayer_pubkey { + return Err(ApiError::BadRequest(format!( + "Instruction {} account {}: Only the relayer address {} can be marked as \ + a signer, but '{}' is marked as a signer. The relayer can only provide \ + its own signature.", + idx, acc_idx, relayer_pubkey, pubkey + ))); + } + + unique_accounts.insert(pubkey); + } + + // Validate data is valid base64 and decode it + let decoded_data = base64_decode(&instruction.data).map_err(|e| { + ApiError::BadRequest(format!("Instruction {}: Invalid base64 data - {}", idx, e)) + })?; + + // Validate decoded data size + if decoded_data.len() > REQUEST_MAX_INSTRUCTION_DATA_SIZE { + return Err(ApiError::BadRequest(format!( + "Instruction {}: Data too large ({} bytes, max: {} bytes)", + idx, + decoded_data.len(), + REQUEST_MAX_INSTRUCTION_DATA_SIZE + ))); + } + } + + // Validate total unique accounts across all instructions + if unique_accounts.len() > REQUEST_MAX_TOTAL_ACCOUNTS { + return Err(ApiError::BadRequest(format!( + "Too many unique accounts: {} exceeds Solana's limit of {}", + unique_accounts.len(), + REQUEST_MAX_TOTAL_ACCOUNTS + ))); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::RelayerRepoModel; + use base64::Engine; + use solana_sdk::{message::Message, pubkey::Pubkey}; + use solana_system_interface::instruction as system_instruction; + + fn create_test_relayer() -> RelayerRepoModel { + RelayerRepoModel { + id: "test-relayer".to_string(), + name: "Test Relayer".to_string(), + network: "solana".to_string(), + paused: false, + network_type: crate::models::NetworkType::Solana, + signer_id: "test-signer".to_string(), + policies: crate::models::RelayerNetworkPolicy::Solana( + crate::models::RelayerSolanaPolicy::default(), + ), + address: "6eoxMcGNaSRKcd8s84ukZjRZBJ27C5DrSXGH6nz73W8h".to_string(), + notification_id: None, + system_disabled: false, + disabled_reason: None, + custom_rpc_urls: None, + } + } + + fn create_valid_instruction_spec() -> SolanaInstructionSpec { + SolanaInstructionSpec { + program_id: "11111111111111111111111111111112".to_string(), // System program + accounts: vec![ + crate::models::SolanaAccountMeta { + pubkey: "6eoxMcGNaSRKcd8s84ukZjRZBJ27C5DrSXGH6nz73W8h".to_string(), + is_signer: true, + is_writable: true, + }, + crate::models::SolanaAccountMeta { + pubkey: "HmZhRVuT8UuMrUJr1JsWFXTQU4EzwGVmQ29Q6QmzLbNs".to_string(), + is_signer: false, + is_writable: true, + }, + ], + data: base64::prelude::BASE64_STANDARD.encode( + [2, 0, 0, 0] + .iter() + .chain(&1000000u64.to_le_bytes()) + .chain(&[0, 0, 0, 0, 0, 0, 0]) + .cloned() + .collect::>(), + ), // Transfer 1 SOL + } + } + + fn create_valid_transaction(relayer_pubkey: &Pubkey) -> EncodedSerializedTransaction { + let recipient = Pubkey::new_unique(); + let instruction = system_instruction::transfer(relayer_pubkey, &recipient, 1000000); + let message = Message::new(&[instruction], Some(relayer_pubkey)); + let tx = solana_sdk::transaction::Transaction::new_unsigned(message); + let serialized = bincode::serialize(&tx).unwrap(); + EncodedSerializedTransaction::new(base64::prelude::BASE64_STANDARD.encode(serialized)) + } + + fn create_transaction_with_wrong_fee_payer() -> EncodedSerializedTransaction { + let wrong_fee_payer = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + let instruction = system_instruction::transfer(&wrong_fee_payer, &recipient, 1000000); + let message = Message::new(&[instruction], Some(&wrong_fee_payer)); + let tx = solana_sdk::transaction::Transaction::new_unsigned(message); + let serialized = bincode::serialize(&tx).unwrap(); + EncodedSerializedTransaction::new(base64::prelude::BASE64_STANDARD.encode(serialized)) + } + + #[test] + fn test_validate_valid_request_with_transaction() { + let relayer = create_test_relayer(); + let relayer_pubkey = Pubkey::from_str(&relayer.address).unwrap(); + let transaction = create_valid_transaction(&relayer_pubkey); + + let request = SolanaTransactionRequest { + transaction: Some(transaction), + instructions: None, + valid_until: None, + }; + + assert!(request.validate(&relayer).is_ok()); + } + + #[test] + fn test_validate_valid_request_with_instructions() { + let relayer = create_test_relayer(); + let instruction = create_valid_instruction_spec(); + + let request = SolanaTransactionRequest { + transaction: None, + instructions: Some(vec![instruction]), + valid_until: None, + }; + + assert!(request.validate(&relayer).is_ok()); + } + + #[test] + fn test_validate_invalid_both_transaction_and_instructions() { + let relayer = create_test_relayer(); + let relayer_pubkey = Pubkey::from_str(&relayer.address).unwrap(); + let transaction = create_valid_transaction(&relayer_pubkey); + let instruction = create_valid_instruction_spec(); + + let request = SolanaTransactionRequest { + transaction: Some(transaction), + instructions: Some(vec![instruction]), + valid_until: None, + }; + + let result = request.validate(&relayer); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Cannot provide both transaction and instructions")); + } + + #[test] + fn test_validate_invalid_neither_transaction_nor_instructions() { + let relayer = create_test_relayer(); + + let request = SolanaTransactionRequest { + transaction: None, + instructions: None, + valid_until: None, + }; + + let result = request.validate(&relayer); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Must provide either transaction or instructions")); + } + + #[test] + fn test_validate_valid_request_with_future_valid_until() { + let relayer = create_test_relayer(); + let future_time = chrono::Utc::now() + chrono::Duration::hours(1); + + let request = SolanaTransactionRequest { + transaction: None, + instructions: Some(vec![create_valid_instruction_spec()]), + valid_until: Some(future_time.to_rfc3339()), + }; + + assert!(request.validate(&relayer).is_ok()); + } + + #[test] + fn test_validate_invalid_past_valid_until() { + let relayer = create_test_relayer(); + let past_time = chrono::Utc::now() - chrono::Duration::hours(1); + + let request = SolanaTransactionRequest { + transaction: None, + instructions: Some(vec![create_valid_instruction_spec()]), + valid_until: Some(past_time.to_rfc3339()), + }; + + let result = request.validate(&relayer); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("valid_until cannot be in the past")); + } + + #[test] + fn test_validate_invalid_malformed_valid_until() { + let relayer = create_test_relayer(); + + let request = SolanaTransactionRequest { + transaction: None, + instructions: Some(vec![create_valid_instruction_spec()]), + valid_until: Some("invalid-timestamp".to_string()), + }; + + let result = request.validate(&relayer); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("valid_until must be a valid RFC3339 timestamp")); + } + + #[test] + fn test_validate_transaction_invalid_base64() { + let relayer = create_test_relayer(); + let transaction = EncodedSerializedTransaction::new("invalid-base64!".to_string()); + + let result = SolanaTransactionRequest::validate_transaction(&transaction, &relayer); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Failed to decode transaction")); + } + + #[test] + fn test_validate_transaction_wrong_fee_payer() { + let relayer = create_test_relayer(); + let transaction = create_transaction_with_wrong_fee_payer(); + + let result = SolanaTransactionRequest::validate_transaction(&transaction, &relayer); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("does not match relayer address")); + } + + #[test] + fn test_validate_instructions_empty_instructions() { + let relayer = create_test_relayer(); + + let result = SolanaTransactionRequest::validate_instructions(&[], &relayer); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Instructions cannot be empty")); + } + + #[test] + fn test_validate_instructions_too_many_instructions() { + let relayer = create_test_relayer(); + let instructions = vec![create_valid_instruction_spec(); REQUEST_MAX_INSTRUCTIONS + 1]; + + let result = SolanaTransactionRequest::validate_instructions(&instructions, &relayer); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Too many instructions")); + } + + #[test] + fn test_validate_instructions_empty_program_id() { + let relayer = create_test_relayer(); + let mut instruction = create_valid_instruction_spec(); + instruction.program_id = "".to_string(); + + let result = SolanaTransactionRequest::validate_instructions(&[instruction], &relayer); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("program_id cannot be empty")); + } + + #[test] + fn test_validate_instructions_invalid_program_id() { + let relayer = create_test_relayer(); + let mut instruction = create_valid_instruction_spec(); + instruction.program_id = "invalid-pubkey".to_string(); + + let result = SolanaTransactionRequest::validate_instructions(&[instruction], &relayer); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid program_id")); + } + + #[test] + fn test_validate_instructions_default_program_id() { + let relayer = create_test_relayer(); + let mut instruction = create_valid_instruction_spec(); + instruction.program_id = "11111111111111111111111111111111".to_string(); // Default pubkey + + let result = SolanaTransactionRequest::validate_instructions(&[instruction], &relayer); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("program_id cannot be default pubkey")); + } + + #[test] + fn test_validate_instructions_too_many_accounts_per_instruction() { + let relayer = create_test_relayer(); + let mut instruction = create_valid_instruction_spec(); + instruction.accounts = + vec![instruction.accounts[0].clone(); REQUEST_MAX_ACCOUNTS_PER_INSTRUCTION + 1]; + + let result = SolanaTransactionRequest::validate_instructions(&[instruction], &relayer); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Too many accounts")); + } + + #[test] + fn test_validate_instructions_empty_account_pubkey() { + let relayer = create_test_relayer(); + let mut instruction = create_valid_instruction_spec(); + instruction.accounts[0].pubkey = "".to_string(); + + let result = SolanaTransactionRequest::validate_instructions(&[instruction], &relayer); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("pubkey cannot be empty")); + } + + #[test] + fn test_validate_instructions_invalid_account_pubkey() { + let relayer = create_test_relayer(); + let mut instruction = create_valid_instruction_spec(); + instruction.accounts[0].pubkey = "invalid-pubkey".to_string(); + + let result = SolanaTransactionRequest::validate_instructions(&[instruction], &relayer); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Invalid pubkey")); + } + + #[test] + fn test_validate_instructions_non_relayer_signer() { + let relayer = create_test_relayer(); + let mut instruction = create_valid_instruction_spec(); + // Make the second account (which is not the relayer) a signer + instruction.accounts[1].is_signer = true; + + let result = SolanaTransactionRequest::validate_instructions(&[instruction], &relayer); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Only the relayer address")); + } + + #[test] + fn test_validate_instructions_invalid_base64_data() { + let relayer = create_test_relayer(); + let mut instruction = create_valid_instruction_spec(); + instruction.data = "invalid-base64!".to_string(); + + let result = SolanaTransactionRequest::validate_instructions(&[instruction], &relayer); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid base64 data")); + } + + #[test] + fn test_validate_instructions_data_too_large() { + let relayer = create_test_relayer(); + let mut instruction = create_valid_instruction_spec(); + // Create data larger than REQUEST_MAX_INSTRUCTION_DATA_SIZE + let large_data = vec![0u8; REQUEST_MAX_INSTRUCTION_DATA_SIZE + 1]; + instruction.data = base64::prelude::BASE64_STANDARD.encode(large_data); + + let result = SolanaTransactionRequest::validate_instructions(&[instruction], &relayer); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Data too large")); + } + + #[test] + fn test_validate_instructions_too_many_unique_accounts() { + let relayer = create_test_relayer(); + let mut instructions = Vec::new(); + + // Create instructions that will exceed REQUEST_MAX_TOTAL_ACCOUNTS unique accounts + for i in 0..(REQUEST_MAX_TOTAL_ACCOUNTS + 1) { + let mut instruction = create_valid_instruction_spec(); + // Change program_id to create unique accounts + instruction.program_id = format!("{:0>44}", i); // Create unique but invalid pubkeys + // Add a unique account + instruction.accounts.push(crate::models::SolanaAccountMeta { + pubkey: format!("{:0>44}", i + 1000), // Another unique account + is_signer: false, + is_writable: false, + }); + instructions.push(instruction); + } + + // Create multiple instructions with different valid pubkeys + let mut instructions = Vec::new(); + for _ in 0..10 { + instructions.push(create_valid_instruction_spec()); + } + + // This should pass since we're using the same accounts repeatedly + assert!(SolanaTransactionRequest::validate_instructions(&instructions, &relayer).is_ok()); + } + + #[test] + fn test_validate_instructions_too_many_unique_accounts_failure() { + let relayer = create_test_relayer(); + let relayer_pubkey = Pubkey::from_str(&relayer.address).unwrap(); + let mut instructions = Vec::new(); + // We will generate REQUEST_MAX_TOTAL_ACCOUNTS + 1 unique accounts total. + for _i in 0..(REQUEST_MAX_TOTAL_ACCOUNTS) { + // We need to go up to the limit + 1 + // Create a unique non-relayer pubkey for the instruction + let unique_account = Pubkey::new_unique(); + + // This program ID is guaranteed to be unique and valid + let unique_program_id = Pubkey::new_unique(); + + instructions.push(SolanaInstructionSpec { + // Unique program ID consumes a unique account slot + program_id: unique_program_id.to_string(), + accounts: vec![ + crate::models::SolanaAccountMeta { + // The relayer's key is always included (1 unique key) + pubkey: relayer_pubkey.to_string(), + is_signer: true, + is_writable: true, + }, + // Unique account key consumes another unique account slot + crate::models::SolanaAccountMeta { + pubkey: unique_account.to_string(), + is_signer: false, + is_writable: true, + }, + ], + data: base64::prelude::BASE64_STANDARD.encode(vec![0u8]), + }); + } + + // Final instruction to push it over the limit of REQUEST_MAX_TOTAL_ACCOUNTS (e.g., 64) + // If REQUEST_MAX_TOTAL_ACCOUNTS=64, the loop created 64 instructions, + // with 1 relayer key + 63 other unique keys, for a total of 1 + 63*2 unique keys, + // which vastly exceeds the limit. + // We only need to check that the limit is hit and failed. + + let result = SolanaTransactionRequest::validate_instructions(&instructions, &relayer); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Too many unique accounts")); + } } diff --git a/src/models/transaction/response.rs b/src/models/transaction/response.rs index dfb1a4308..32551a521 100644 --- a/src/models/transaction/response.rs +++ b/src/models/transaction/response.rs @@ -1,7 +1,7 @@ use crate::{ models::{ - evm::Speed, EvmTransactionDataSignature, NetworkTransactionData, TransactionRepoModel, - TransactionStatus, U256, + evm::Speed, EvmTransactionDataSignature, NetworkTransactionData, SolanaInstructionSpec, + TransactionRepoModel, TransactionStatus, U256, }, utils::{deserialize_optional_u128, deserialize_optional_u64, serialize_optional_u128}, }; @@ -83,8 +83,10 @@ pub struct SolanaTransactionResponse { #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = false)] pub confirmed_at: Option, - #[schema(nullable = false)] pub transaction: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + pub instructions: Option>, } #[derive(Debug, Serialize, Clone, PartialEq, Deserialize, ToSchema)] @@ -134,13 +136,14 @@ impl From for TransactionResponse { NetworkTransactionData::Solana(solana_data) => { TransactionResponse::Solana(Box::new(SolanaTransactionResponse { id: model.id, - transaction: solana_data.transaction, + transaction: solana_data.transaction.unwrap_or_default(), status: model.status, status_reason: model.status_reason, created_at: model.created_at, sent_at: model.sent_at, confirmed_at: model.confirmed_at, signature: solana_data.signature, + instructions: solana_data.instructions, })) } NetworkTransactionData::Stellar(stellar_data) => { @@ -243,7 +246,8 @@ mod tests { priced_at: None, hashes: vec![], network_data: NetworkTransactionData::Solana(SolanaTransactionData { - transaction: "transaction_123".to_string(), + transaction: Some("transaction_123".to_string()), + instructions: None, signature: Some("signature_123".to_string()), }), valid_until: None, @@ -390,7 +394,8 @@ mod tests { priced_at: None, hashes: vec![], network_data: NetworkTransactionData::Solana(SolanaTransactionData { - transaction: "transaction_123".to_string(), + transaction: Some("transaction_123".to_string()), + instructions: None, signature: None, }), valid_until: None, diff --git a/src/models/transaction/solana/instruction.rs b/src/models/transaction/solana/instruction.rs new file mode 100644 index 000000000..b4e097048 --- /dev/null +++ b/src/models/transaction/solana/instruction.rs @@ -0,0 +1,26 @@ +//! Solana instruction specifications for transaction building + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Specification for a Solana instruction +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] +pub struct SolanaInstructionSpec { + /// Program ID (base58-encoded pubkey) + pub program_id: String, + /// Account metadata for the instruction + pub accounts: Vec, + /// Instruction data (base64-encoded) + pub data: String, +} + +/// Account metadata for a Solana instruction +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] +pub struct SolanaAccountMeta { + /// Account public key (base58-encoded) + pub pubkey: String, + /// Whether the account is a signer + pub is_signer: bool, + /// Whether the account is writable + pub is_writable: bool, +} diff --git a/src/models/transaction/solana/mod.rs b/src/models/transaction/solana/mod.rs index 38af51707..67b26a8c2 100644 --- a/src/models/transaction/solana/mod.rs +++ b/src/models/transaction/solana/mod.rs @@ -1,2 +1,5 @@ +mod instruction; +pub use instruction::*; + mod solana_transaction_status; pub use solana_transaction_status::*; diff --git a/src/services/cdp/mod.rs b/src/services/cdp/mod.rs index e24b30bb3..474018c1a 100644 --- a/src/services/cdp/mod.rs +++ b/src/services/cdp/mod.rs @@ -77,7 +77,7 @@ pub trait CdpServiceTrait: Send + Sync { async fn sign_solana_transaction(&self, message: String) -> Result, CdpError>; } -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct CdpService { pub config: CdpSignerConfig, pub client: Client, diff --git a/src/services/google_cloud_kms/mod.rs b/src/services/google_cloud_kms/mod.rs index 7bbcbd77f..74155b903 100644 --- a/src/services/google_cloud_kms/mod.rs +++ b/src/services/google_cloud_kms/mod.rs @@ -120,7 +120,7 @@ pub trait GoogleCloudKmsK256: Send + Sync { async fn sign_digest(&self, digest: [u8; 32]) -> GoogleCloudKmsResult>; } -#[derive(Clone)] +#[derive(Clone, Debug)] #[allow(dead_code)] pub struct GoogleCloudKmsService { pub config: GoogleCloudKmsSignerConfig, diff --git a/src/services/provider/mod.rs b/src/services/provider/mod.rs index 31a78587d..9443f115a 100644 --- a/src/services/provider/mod.rs +++ b/src/services/provider/mod.rs @@ -45,6 +45,13 @@ pub enum ProviderError { Other(String), } +impl ProviderError { + /// Determines if this error is transient (can retry) or permanent (should fail). + pub fn is_transient(&self) -> bool { + is_retriable_error(self) + } +} + impl From for ProviderError { fn from(err: hex::FromHexError) -> Self { ProviderError::InvalidAddress(err.to_string()) @@ -274,21 +281,36 @@ pub fn get_network_provider( N::new_provider(rpc_urls, timeout_seconds) } +/// Determines if an HTTP status code indicates the provider should be marked as failed. +/// +/// This is a low-level function that can be reused across different error types. +/// +/// Returns `true` for: +/// - 5xx Server Errors (500-599) - RPC node is having issues +/// - Specific 4xx Client Errors that indicate provider issues: +/// - 401 (Unauthorized) - auth required but not provided +/// - 403 (Forbidden) - node is blocking requests or auth issues +/// - 404 (Not Found) - endpoint doesn't exist or misconfigured +/// - 410 (Gone) - endpoint permanently removed +pub fn should_mark_provider_failed_by_status_code(status_code: u16) -> bool { + match status_code { + // 5xx Server Errors - RPC node is having issues + 500..=599 => true, + + // 4xx Client Errors that indicate we can't use this provider + 401 => true, // Unauthorized - auth required but not provided + 403 => true, // Forbidden - node is blocking requests or auth issues + 404 => true, // Not Found - endpoint doesn't exist or misconfigured + 410 => true, // Gone - endpoint permanently removed + + _ => false, + } +} + pub fn should_mark_provider_failed(error: &ProviderError) -> bool { match error { ProviderError::RequestError { status_code, .. } => { - match *status_code { - // 5xx Server Errors - RPC node is having issues - 500..=599 => true, - - // 4xx Client Errors that indicate we can't use this provider - 401 => true, // Unauthorized - auth required but not provided - 403 => true, // Forbidden - node is blocking requests or auth issues - 404 => true, // Not Found - endpoint doesn't exist or misconfigured - 410 => true, // Gone - endpoint permanently removed - - _ => false, - } + should_mark_provider_failed_by_status_code(*status_code) } _ => false, } @@ -352,6 +374,8 @@ pub fn is_retriable_error(error: &ProviderError) -> bool { } } + ProviderError::SolanaRpcError(err) => err.is_transient(), + // Any other errors: check message for network-related issues _ => { let err_msg = format!("{}", error); diff --git a/src/services/provider/solana/mod.rs b/src/services/provider/solana/mod.rs index 00c1e0e26..d27e61c47 100644 --- a/src/services/provider/solana/mod.rs +++ b/src/services/provider/solana/mod.rs @@ -16,13 +16,14 @@ use mpl_token_metadata::accounts::Metadata; use reqwest::Url; use serde::Serialize; use solana_client::{ + client_error::{ClientError, ClientErrorKind}, nonblocking::rpc_client::RpcClient, rpc_request::RpcRequest, rpc_response::{RpcPrioritizationFee, RpcSimulateTransactionResult}, }; +use solana_commitment_config::CommitmentConfig; use solana_sdk::{ account::Account, - commitment_config::CommitmentConfig, hash::Hash, message::Message, program_pack::Pack, @@ -30,13 +31,13 @@ use solana_sdk::{ signature::Signature, transaction::{Transaction, VersionedTransaction}, }; -use spl_token::state::Mint; +use spl_token_interface::state::Mint; use std::{str::FromStr, sync::Arc, time::Duration}; use thiserror::Error; use crate::{ models::{RpcConfig, SolanaTransactionStatus}, - services::provider::retry_rpc_call, + services::provider::{retry_rpc_call, should_mark_provider_failed_by_status_code}, }; use super::ProviderError; @@ -45,16 +46,308 @@ use super::{ RetryConfig, }; +/// Utility function to match error patterns by normalizing both strings. +/// Removes spaces and converts to lowercase for flexible matching. +/// +/// This allows matching patterns like "invalid instruction data" against errors +/// containing "invalidinstructiondata", "invalid instruction data", etc. +fn matches_error_pattern(error_msg: &str, pattern: &str) -> bool { + let normalized_msg = error_msg.to_lowercase().replace(' ', ""); + let normalized_pattern = pattern.to_lowercase().replace(' ', ""); + normalized_msg.contains(&normalized_pattern) +} + +/// Errors that can occur when interacting with the Solana provider. +/// +/// Use `is_transient()` to determine if an error should be retried. #[derive(Error, Debug, Serialize)] pub enum SolanaProviderError { - #[error("RPC client error: {0}")] + /// Network/IO error (transient - connection issues, timeouts) + #[error("Network error: {0}")] + NetworkError(String), + + /// RPC protocol error (transient - RPC-level issues like node lag, sync pending) + #[error("RPC error: {0}")] RpcError(String), + + /// HTTP request error with status code (transient/permanent based on status code) + #[error("Request error (HTTP {status_code}): {error}")] + RequestError { error: String, status_code: u16 }, + + /// Invalid address format (permanent) #[error("Invalid address: {0}")] InvalidAddress(String), + + /// RPC selector error (transient - can retry with different node) #[error("RPC selector error: {0}")] SelectorError(RpcSelectorError), + + /// Network configuration error (permanent - missing data, unsupported operations) #[error("Network configuration error: {0}")] NetworkConfiguration(String), + + /// Insufficient funds for transaction (permanent) + #[error("Insufficient funds for transaction: {0}")] + InsufficientFunds(String), + + /// Blockhash not found or expired (transient - can rebuild with fresh blockhash) + #[error("Blockhash not found or expired: {0}")] + BlockhashNotFound(String), + + /// Invalid transaction structure or execution (permanent) + #[error("Invalid transaction: {0}")] + InvalidTransaction(String), + + /// Transaction already processed (permanent - duplicate) + #[error("Transaction already processed: {0}")] + AlreadyProcessed(String), +} + +impl SolanaProviderError { + /// Determines if this error is transient (can retry) or permanent (should fail). + /// + /// With comprehensive error code classification in `from_rpc_response_error()`, + /// errors are properly categorized at the source, so we can simply match on variants. + /// + /// **Transient (can retry):** + /// - `NetworkError`: IO/connection errors, timeouts, network unavailable + /// - `RpcError`: RPC protocol issues, node lag, sync pending (-32004, -32005, -32014, -32016) + /// - `BlockhashNotFound`: Can rebuild transaction with fresh blockhash (-32008) + /// - `SelectorError`: Can retry with different RPC node + /// - `RequestError`: HTTP errors with retriable status codes (5xx, 408, 425, 429) + /// + /// **Permanent (fail immediately):** + /// - `InsufficientFunds`: Not enough balance for transaction + /// - `InvalidTransaction`: Malformed transaction, invalid signatures, version mismatch (-32002, -32003, -32013, -32015, -32602) + /// - `AlreadyProcessed`: Duplicate transaction already on-chain (-32009) + /// - `InvalidAddress`: Invalid public key format + /// - `NetworkConfiguration`: Missing data, unsupported operations (-32007, -32010) + /// - `RequestError`: HTTP errors with non-retriable status codes (4xx except 408, 425, 429) + pub fn is_transient(&self) -> bool { + match self { + // Transient errors - safe to retry + SolanaProviderError::NetworkError(_) => true, + SolanaProviderError::RpcError(_) => true, + SolanaProviderError::BlockhashNotFound(_) => true, + SolanaProviderError::SelectorError(_) => true, + + // RequestError - check status code to determine if retriable + SolanaProviderError::RequestError { status_code, .. } => match *status_code { + // Non-retriable 5xx: persistent server-side issues + 501 | 505 => false, // Not Implemented, HTTP Version Not Supported + + // Retriable 5xx: temporary server-side issues + 500 | 502..=504 | 506..=599 => true, + + // Retriable 4xx: timeout or rate-limit related + 408 | 425 | 429 => true, + + // Non-retriable 4xx: client errors + 400..=499 => false, + + // Other status codes: not retriable + _ => false, + }, + + // Permanent errors - fail immediately + SolanaProviderError::InsufficientFunds(_) => false, + SolanaProviderError::InvalidTransaction(_) => false, + SolanaProviderError::AlreadyProcessed(_) => false, + SolanaProviderError::InvalidAddress(_) => false, + SolanaProviderError::NetworkConfiguration(_) => false, + } + } + + /// Classifies a Solana RPC client error into the appropriate error variant. + /// + /// Uses structured error types from the Solana SDK for precise classification, + /// including JSON-RPC error codes for enhanced accuracy. + pub fn from_rpc_error(error: ClientError) -> Self { + match error.kind() { + // Network/IO errors - connection issues, timeouts (transient) + ClientErrorKind::Io(_) => SolanaProviderError::NetworkError(error.to_string()), + + // Reqwest errors - extract status code if available + ClientErrorKind::Reqwest(reqwest_err) => { + if let Some(status) = reqwest_err.status() { + SolanaProviderError::RequestError { + error: error.to_string(), + status_code: status.as_u16(), + } + } else { + // No status code available (e.g., connection error, timeout) + SolanaProviderError::NetworkError(error.to_string()) + } + } + + // RPC errors - classify based on error code and message + ClientErrorKind::RpcError(rpc_err) => { + let rpc_err_str = format!("{}", rpc_err); + Self::from_rpc_response_error(&rpc_err_str, &error) + } + + // Transaction errors - classify based on specific error type + ClientErrorKind::TransactionError(tx_error) => { + Self::from_transaction_error(tx_error, &error) + } + + // Custom errors from Solana client - reuse pattern matching logic + ClientErrorKind::Custom(msg) => { + // Delegate to from_rpc_response_error for consistent classification + Self::from_rpc_response_error(msg, &error) + } + + // All other error types + _ => SolanaProviderError::RpcError(error.to_string()), + } + } + + /// Classifies RPC response errors using error codes and messages. + /// + /// Solana JSON-RPC 2.0 error codes (see https://www.quicknode.com/docs/solana/error-references): + /// + /// **Transient errors (can retry):** + /// - `-32004`: Block not available for slot - temporary, retry recommended + /// - `-32005`: Node is unhealthy/behind - temporary node lag + /// - `-32008`: Blockhash not found - can rebuild transaction with fresh blockhash + /// - `-32014`: Block status not yet available - pending sync, retry later + /// - `-32016`: Minimum context slot not reached - future slot, retry later + /// + /// **Permanent errors (fail immediately):** + /// - `-32002`: Transaction simulation failed - check message for specific cause + /// - `-32003`: Signature verification failure - invalid signatures + /// - `-32007`: Slot skipped/missing (snapshot jump) - data unavailable + /// - `-32009`: Already processed - duplicate transaction + /// - `-32010`: Key excluded from secondary indexes - RPC method unavailable + /// - `-32013`: Transaction signature length mismatch - malformed transaction + /// - `-32015`: Transaction version not supported - client version mismatch + /// - `-32602`: Invalid params - malformed request parameters + fn from_rpc_response_error(rpc_err: &str, full_error: &ClientError) -> Self { + let error_str = rpc_err; + + // Check for specific error codes in the error string + if error_str.contains("-32002") { + // Transaction simulation failed - check message for specific issues + if matches_error_pattern(error_str, "blockhash not found") { + SolanaProviderError::BlockhashNotFound(full_error.to_string()) + } else if matches_error_pattern(error_str, "insufficient funds") { + SolanaProviderError::InsufficientFunds(full_error.to_string()) + } else { + // Most simulation failures are permanent (invalid instruction data, etc.) + SolanaProviderError::InvalidTransaction(full_error.to_string()) + } + } else if error_str.contains("-32003") { + // Signature verification failure - permanent + SolanaProviderError::InvalidTransaction(full_error.to_string()) + } else if error_str.contains("-32004") { + // Block not available - transient, retry recommended + SolanaProviderError::RpcError(full_error.to_string()) + } else if error_str.contains("-32005") { + // Node is behind - transient + SolanaProviderError::RpcError(full_error.to_string()) + } else if error_str.contains("-32007") { + // Slot skipped/missing due to snapshot jump - permanent + SolanaProviderError::NetworkConfiguration(full_error.to_string()) + } else if error_str.contains("-32008") { + // Blockhash not found - transient (can rebuild transaction) + SolanaProviderError::BlockhashNotFound(full_error.to_string()) + } else if error_str.contains("-32009") { + // Already processed - permanent + SolanaProviderError::AlreadyProcessed(full_error.to_string()) + } else if error_str.contains("-32010") { + // Key excluded from secondary indexes - permanent + SolanaProviderError::NetworkConfiguration(full_error.to_string()) + } else if error_str.contains("-32013") { + // Transaction signature length mismatch - permanent + SolanaProviderError::InvalidTransaction(full_error.to_string()) + } else if error_str.contains("-32014") { + // Block status not yet available - transient, retry later + SolanaProviderError::RpcError(full_error.to_string()) + } else if error_str.contains("-32015") { + // Transaction version not supported - permanent + SolanaProviderError::InvalidTransaction(full_error.to_string()) + } else if error_str.contains("-32016") { + // Minimum context slot not reached - transient, retry later + SolanaProviderError::RpcError(full_error.to_string()) + } else if error_str.contains("-32602") { + // Invalid params - permanent + SolanaProviderError::InvalidTransaction(full_error.to_string()) + } else { + // For other codes, fall back to string matching + if matches_error_pattern(error_str, "insufficient funds") { + SolanaProviderError::InsufficientFunds(full_error.to_string()) + } else if matches_error_pattern(error_str, "blockhash not found") { + SolanaProviderError::BlockhashNotFound(full_error.to_string()) + } else if matches_error_pattern(error_str, "already processed") { + SolanaProviderError::AlreadyProcessed(full_error.to_string()) + } else { + // Default to transient RPC error for unknown codes + SolanaProviderError::RpcError(full_error.to_string()) + } + } + } + + /// Classifies a Solana TransactionError into the appropriate error variant. + fn from_transaction_error( + tx_error: &solana_sdk::transaction::TransactionError, + full_error: &ClientError, + ) -> Self { + use solana_sdk::transaction::TransactionError as TxErr; + + match tx_error { + // Insufficient funds - permanent + TxErr::InsufficientFundsForFee | TxErr::InsufficientFundsForRent { .. } => { + SolanaProviderError::InsufficientFunds(full_error.to_string()) + } + + // Blockhash not found - transient (can rebuild transaction with fresh blockhash) + TxErr::BlockhashNotFound => { + SolanaProviderError::BlockhashNotFound(full_error.to_string()) + } + + // Already processed - permanent + TxErr::AlreadyProcessed => { + SolanaProviderError::AlreadyProcessed(full_error.to_string()) + } + + // Invalid transaction structure/signatures - permanent + TxErr::SignatureFailure + | TxErr::MissingSignatureForFee + | TxErr::InvalidAccountForFee + | TxErr::AccountNotFound + | TxErr::InvalidAccountIndex + | TxErr::InvalidProgramForExecution + | TxErr::ProgramAccountNotFound + | TxErr::InstructionError(_, _) + | TxErr::CallChainTooDeep + | TxErr::InvalidWritableAccount + | TxErr::InvalidRentPayingAccount + | TxErr::WouldExceedMaxBlockCostLimit + | TxErr::WouldExceedMaxAccountCostLimit + | TxErr::WouldExceedMaxVoteCostLimit + | TxErr::WouldExceedAccountDataBlockLimit + | TxErr::TooManyAccountLocks + | TxErr::AddressLookupTableNotFound + | TxErr::InvalidAddressLookupTableOwner + | TxErr::InvalidAddressLookupTableData + | TxErr::InvalidAddressLookupTableIndex + | TxErr::MaxLoadedAccountsDataSizeExceeded + | TxErr::InvalidLoadedAccountsDataSizeLimit + | TxErr::ResanitizationNeeded + | TxErr::ProgramExecutionTemporarilyRestricted { .. } + | TxErr::AccountBorrowOutstanding => { + SolanaProviderError::InvalidTransaction(full_error.to_string()) + } + + // Transient errors that might succeed on retry + TxErr::AccountInUse | TxErr::AccountLoadedTwice | TxErr::ClusterMaintenance => { + SolanaProviderError::RpcError(full_error.to_string()) + } + + // Treat unknown errors as generic RPC errors (transient by default) + _ => SolanaProviderError::RpcError(full_error.to_string()), + } + } } /// A trait that abstracts common Solana provider operations. @@ -168,25 +461,19 @@ impl From for SolanaProviderError { } } -const RETRIABLE_ERROR_SUBSTRINGS: &[&str] = &[ - "timeout", - "connection", - "reset", - "temporarily unavailable", - "rate limit", - "too many requests", - "503", - "502", - "504", - "blockhash not found", - "node is behind", - "unhealthy", -]; - -fn is_retriable_error(msg: &str) -> bool { - RETRIABLE_ERROR_SUBSTRINGS - .iter() - .any(|substr| msg.contains(substr)) +/// Determines if a Solana provider error should mark the provider as failed. +/// +/// This function identifies errors that indicate the RPC provider itself is having issues +/// and should be marked as failed to trigger failover to another provider. +/// +/// Uses the shared `should_mark_provider_failed_by_status_code` function for HTTP status code logic. +fn should_mark_solana_provider_failed(error: &SolanaProviderError) -> bool { + match error { + SolanaProviderError::RequestError { status_code, .. } => { + should_mark_provider_failed_by_status_code(*status_code) + } + _ => false, + } } #[derive(Error, Debug, PartialEq)] @@ -297,10 +584,7 @@ impl SolanaProvider { F: Fn(Arc) -> Fut, Fut: std::future::Future>, { - let is_retriable = |e: &SolanaProviderError| match e { - SolanaProviderError::RpcError(msg) => is_retriable_error(msg), - _ => false, - }; + let is_retriable = |e: &SolanaProviderError| e.is_transient(); tracing::debug!( "Starting RPC operation '{}' with timeout: {}s", @@ -312,7 +596,7 @@ impl SolanaProvider { &self.selector, operation_name, is_retriable, - |_| false, // TODO: implement fn to mark provider failed based on error + should_mark_solana_provider_failed, |url| match self.initialize_provider(url) { Ok(provider) => Ok(provider), Err(e) => Err(e), @@ -340,7 +624,7 @@ impl SolanaProviderTrait for SolanaProvider { client .get_balance(&pubkey) .await - .map_err(|e| SolanaProviderError::RpcError(e.to_string())) + .map_err(SolanaProviderError::from_rpc_error) }) .await } @@ -355,7 +639,7 @@ impl SolanaProviderTrait for SolanaProvider { client .is_blockhash_valid(hash, commitment) .await - .map_err(|e| SolanaProviderError::RpcError(e.to_string())) + .map_err(SolanaProviderError::from_rpc_error) }) .await } @@ -366,7 +650,7 @@ impl SolanaProviderTrait for SolanaProvider { client .get_latest_blockhash() .await - .map_err(|e| SolanaProviderError::RpcError(e.to_string())) + .map_err(SolanaProviderError::from_rpc_error) }) .await } @@ -381,7 +665,7 @@ impl SolanaProviderTrait for SolanaProvider { client .get_latest_blockhash_with_commitment(commitment) .await - .map_err(|e| SolanaProviderError::RpcError(e.to_string())) + .map_err(SolanaProviderError::from_rpc_error) }, ) .await @@ -396,7 +680,7 @@ impl SolanaProviderTrait for SolanaProvider { client .send_transaction(transaction) .await - .map_err(|e| SolanaProviderError::RpcError(e.to_string())) + .map_err(SolanaProviderError::from_rpc_error) }) .await } @@ -410,7 +694,7 @@ impl SolanaProviderTrait for SolanaProvider { client .send_transaction(transaction) .await - .map_err(|e| SolanaProviderError::RpcError(e.to_string())) + .map_err(SolanaProviderError::from_rpc_error) }) .await } @@ -424,7 +708,7 @@ impl SolanaProviderTrait for SolanaProvider { client .confirm_transaction(signature) .await - .map_err(|e| SolanaProviderError::RpcError(e.to_string())) + .map_err(SolanaProviderError::from_rpc_error) }) .await } @@ -440,7 +724,7 @@ impl SolanaProviderTrait for SolanaProvider { client .get_minimum_balance_for_rent_exemption(data_size) .await - .map_err(|e| SolanaProviderError::RpcError(e.to_string())) + .map_err(SolanaProviderError::from_rpc_error) }, ) .await @@ -455,7 +739,7 @@ impl SolanaProviderTrait for SolanaProvider { client .simulate_transaction(transaction) .await - .map_err(|e| SolanaProviderError::RpcError(e.to_string())) + .map_err(SolanaProviderError::from_rpc_error) .map(|response| response.value) }) .await @@ -470,7 +754,7 @@ impl SolanaProviderTrait for SolanaProvider { client .get_account(&address) .await - .map_err(|e| SolanaProviderError::RpcError(e.to_string())) + .map_err(SolanaProviderError::from_rpc_error) }) .await } @@ -484,7 +768,7 @@ impl SolanaProviderTrait for SolanaProvider { client .get_account(pubkey) .await - .map_err(|e| SolanaProviderError::RpcError(e.to_string())) + .map_err(SolanaProviderError::from_rpc_error) }) .await } @@ -494,24 +778,32 @@ impl SolanaProviderTrait for SolanaProvider { &self, pubkey: &str, ) -> Result { - // Retrieve account associated with the given pubkey - let account = self.get_account_from_str(pubkey).await.map_err(|e| { - SolanaProviderError::RpcError(format!("Failed to fetch account for {}: {}", pubkey, e)) + // Parse and validate pubkey once + let mint_pubkey = Pubkey::from_str(pubkey).map_err(|e| { + SolanaProviderError::InvalidAddress(format!("Invalid pubkey {}: {}", pubkey, e)) })?; - // Unpack the mint info from the account's data - let mint_info = Mint::unpack(&account.data).map_err(|e| { - SolanaProviderError::RpcError(format!("Failed to unpack mint info: {}", e)) - })?; - let decimals = mint_info.decimals; + // Retrieve account using already-parsed pubkey (avoids re-parsing) + let account = self.get_account_from_pubkey(&mint_pubkey).await?; - // Convert provided string into a Pubkey - let mint_pubkey = Pubkey::try_from(pubkey).map_err(|e| { - SolanaProviderError::RpcError(format!("Invalid pubkey {}: {}", pubkey, e)) - })?; + // Unpack the mint info from the account's data + let decimals = Mint::unpack(&account.data) + .map_err(|e| { + SolanaProviderError::InvalidTransaction(format!( + "Failed to unpack mint info for {}: {}", + pubkey, e + )) + })? + .decimals; // Derive the PDA for the token metadata - let metadata_pda = Metadata::find_pda(&mint_pubkey).0; + // Convert bytes directly between Pubkey types (no string conversion needed) + let mint_pubkey_program = + solana_program::pubkey::Pubkey::new_from_array(mint_pubkey.to_bytes()); + let metadata_pda_program = Metadata::find_pda(&mint_pubkey_program).0; + + // Convert bytes directly (no string conversion) + let metadata_pda = Pubkey::new_from_array(metadata_pda_program.to_bytes()); let symbol = match self.get_account_from_pubkey(&metadata_pda).await { Ok(metadata_account) => match Metadata::from_bytes(&metadata_account.data) { @@ -534,7 +826,7 @@ impl SolanaProviderTrait for SolanaProvider { client .get_fee_for_message(message) .await - .map_err(|e| SolanaProviderError::RpcError(e.to_string())) + .map_err(SolanaProviderError::from_rpc_error) }) .await } @@ -547,7 +839,7 @@ impl SolanaProviderTrait for SolanaProvider { client .get_recent_prioritization_fees(addresses) .await - .map_err(|e| SolanaProviderError::RpcError(e.to_string())) + .map_err(SolanaProviderError::from_rpc_error) }) .await } @@ -574,10 +866,9 @@ impl SolanaProviderTrait for SolanaProvider { client .get_signature_statuses_with_history(&[*signature]) .await - .map_err(|e| SolanaProviderError::RpcError(e.to_string())) + .map_err(SolanaProviderError::from_rpc_error) }) - .await - .map_err(|e| SolanaProviderError::RpcError(e.to_string()))?; + .await?; let status = result.value.first(); @@ -1039,23 +1330,148 @@ mod tests { } #[test] - fn test_is_retriable_error_true() { - for msg in RETRIABLE_ERROR_SUBSTRINGS { - assert!(is_retriable_error(msg), "Should be retriable: {}", msg); - } - } + fn test_matches_error_pattern() { + // Test exact matches + assert!(matches_error_pattern( + "blockhash not found", + "blockhash not found" + )); + assert!(matches_error_pattern( + "insufficient funds", + "insufficient funds" + )); - #[test] - fn test_is_retriable_error_false() { - let non_retriable_cases = [ + // Test case insensitive matching + assert!(matches_error_pattern( + "BLOCKHASH NOT FOUND", + "blockhash not found" + )); + assert!(matches_error_pattern( + "blockhash not found", + "BLOCKHASH NOT FOUND" + )); + assert!(matches_error_pattern( + "BlockHash Not Found", + "blockhash not found" + )); + + // Test space insensitive matching + assert!(matches_error_pattern( + "blockhashnotfound", + "blockhash not found" + )); + assert!(matches_error_pattern( + "blockhash not found", + "blockhashnotfound" + )); + assert!(matches_error_pattern( + "insufficientfunds", + "insufficient funds" + )); + + // Test mixed case and space insensitive + assert!(matches_error_pattern( + "BLOCKHASHNOTFOUND", + "blockhash not found" + )); + assert!(matches_error_pattern( + "blockhash not found", + "BLOCKHASHNOTFOUND" + )); + assert!(matches_error_pattern( + "BlockHashNotFound", + "blockhash not found" + )); + assert!(matches_error_pattern( + "INSUFFICIENTFUNDS", + "insufficient funds" + )); + + // Test partial matches within longer strings + assert!(matches_error_pattern( + "transaction failed: blockhash not found", + "blockhash not found" + )); + assert!(matches_error_pattern( + "error: insufficient funds for transaction", + "insufficient funds" + )); + assert!(matches_error_pattern( + "BLOCKHASHNOTFOUND in simulation", + "blockhash not found" + )); + + // Test multiple spaces handling + assert!(matches_error_pattern( + "blockhash not found", + "blockhash not found" + )); + assert!(matches_error_pattern( + "insufficient funds", + "insufficient funds" + )); + + // Test no matches + assert!(!matches_error_pattern( "account not found", + "blockhash not found" + )); + assert!(!matches_error_pattern( "invalid signature", - "insufficient funds", - "unknown error", - ]; - for msg in non_retriable_cases { - assert!(!is_retriable_error(msg), "Should NOT be retriable: {}", msg); - } + "insufficient funds" + )); + assert!(!matches_error_pattern( + "timeout error", + "blockhash not found" + )); + + // Test empty strings + assert!(matches_error_pattern("", "")); + assert!(matches_error_pattern("blockhash not found", "")); // Empty pattern matches everything + assert!(!matches_error_pattern("", "blockhash not found")); + + // Test special characters and numbers + assert!(matches_error_pattern( + "error code -32008: blockhash not found", + "-32008" + )); + assert!(matches_error_pattern("slot 123456 skipped", "slot")); + assert!(matches_error_pattern("RPC_ERROR_503", "rpc_error_503")); + } + + #[test] + fn test_solana_provider_error_is_transient() { + // Test transient errors (should return true) + assert!(SolanaProviderError::NetworkError("connection timeout".to_string()).is_transient()); + assert!(SolanaProviderError::RpcError("node is behind".to_string()).is_transient()); + assert!( + SolanaProviderError::BlockhashNotFound("blockhash expired".to_string()).is_transient() + ); + assert!( + SolanaProviderError::SelectorError(RpcSelectorError::AllProvidersFailed).is_transient() + ); + + // Test permanent errors (should return false) + assert!( + !SolanaProviderError::InsufficientFunds("not enough balance".to_string()) + .is_transient() + ); + assert!( + !SolanaProviderError::InvalidTransaction("invalid signature".to_string()) + .is_transient() + ); + assert!( + !SolanaProviderError::AlreadyProcessed("duplicate transaction".to_string()) + .is_transient() + ); + assert!( + !SolanaProviderError::InvalidAddress("invalid pubkey format".to_string()) + .is_transient() + ); + assert!( + !SolanaProviderError::NetworkConfiguration("unsupported operation".to_string()) + .is_transient() + ); } #[tokio::test] @@ -1116,4 +1532,265 @@ mod tests { assert_ne!(blockhash, solana_sdk::hash::Hash::new_from_array([0u8; 32])); assert!(last_valid_block_height > 0); } + + #[test] + fn test_from_rpc_response_error_transaction_simulation_failed() { + // Create a simple mock ClientError for testing + let mock_error = create_mock_client_error(); + + // -32002 with "blockhash not found" should be BlockhashNotFound + let error_str = + r#"{"code": -32002, "message": "Transaction simulation failed: Blockhash not found"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::BlockhashNotFound(_))); + + // -32002 with "insufficient funds" should be InsufficientFunds + let error_str = + r#"{"code": -32002, "message": "Transaction simulation failed: Insufficient funds"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::InsufficientFunds(_))); + + // -32002 with other message should be InvalidTransaction + let error_str = r#"{"code": -32002, "message": "Transaction simulation failed: Invalid instruction data"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::InvalidTransaction(_))); + } + + #[test] + fn test_from_rpc_response_error_signature_verification() { + let mock_error = create_mock_client_error(); + + // -32003 should be InvalidTransaction + let error_str = r#"{"code": -32003, "message": "Signature verification failure"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::InvalidTransaction(_))); + } + + #[test] + fn test_from_rpc_response_error_transient_errors() { + let mock_error = create_mock_client_error(); + + // -32004: Block not available - should be RpcError (transient) + let error_str = r#"{"code": -32004, "message": "Block not available for slot"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::RpcError(_))); + + // -32005: Node is behind - should be RpcError (transient) + let error_str = r#"{"code": -32005, "message": "Node is behind"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::RpcError(_))); + + // -32008: Blockhash not found - should be BlockhashNotFound (transient) + let error_str = r#"{"code": -32008, "message": "Blockhash not found"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::BlockhashNotFound(_))); + + // -32014: Block status not available - should be RpcError (transient) + let error_str = r#"{"code": -32014, "message": "Block status not yet available"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::RpcError(_))); + + // -32016: Minimum context slot not reached - should be RpcError (transient) + let error_str = r#"{"code": -32016, "message": "Minimum context slot not reached"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::RpcError(_))); + } + + #[test] + fn test_from_rpc_response_error_permanent_errors() { + let mock_error = create_mock_client_error(); + + // -32007: Slot skipped - should be NetworkConfiguration (permanent) + let error_str = r#"{"code": -32007, "message": "Slot skipped"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!( + result, + SolanaProviderError::NetworkConfiguration(_) + )); + + // -32009: Already processed - should be AlreadyProcessed (permanent) + let error_str = r#"{"code": -32009, "message": "Already processed"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::AlreadyProcessed(_))); + + // -32010: Key excluded from secondary indexes - should be NetworkConfiguration (permanent) + let error_str = r#"{"code": -32010, "message": "Key excluded from secondary indexes"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!( + result, + SolanaProviderError::NetworkConfiguration(_) + )); + + // -32013: Transaction signature length mismatch - should be InvalidTransaction (permanent) + let error_str = r#"{"code": -32013, "message": "Transaction signature length mismatch"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::InvalidTransaction(_))); + + // -32015: Transaction version not supported - should be InvalidTransaction (permanent) + let error_str = r#"{"code": -32015, "message": "Transaction version not supported"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::InvalidTransaction(_))); + + // -32602: Invalid params - should be InvalidTransaction (permanent) + let error_str = r#"{"code": -32602, "message": "Invalid params"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::InvalidTransaction(_))); + } + + #[test] + fn test_from_rpc_response_error_string_pattern_matching() { + let mock_error = create_mock_client_error(); + + // Test case-insensitive and space-insensitive pattern matching + let error_str = r#"{"code": -32000, "message": "INSUFFICIENTFUNDS for transaction"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::InsufficientFunds(_))); + + let error_str = r#"{"code": -32000, "message": "BlockhashNotFound"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::BlockhashNotFound(_))); + + let error_str = r#"{"code": -32000, "message": "AlreadyProcessed"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::AlreadyProcessed(_))); + } + + #[test] + fn test_from_rpc_response_error_unknown_code() { + let mock_error = create_mock_client_error(); + + // Unknown error code should default to RpcError (transient) + let error_str = r#"{"code": -99999, "message": "Unknown error"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::RpcError(_))); + } + + // Helper function to create a mock ClientError for testing + fn create_mock_client_error() -> ClientError { + use solana_client::rpc_request::RpcRequest; + // Create a simple ClientError using available constructors + ClientError::new_with_request( + ClientErrorKind::RpcError(solana_client::rpc_request::RpcError::RpcRequestError( + "test".to_string(), + )), + RpcRequest::GetHealth, + ) + } + + #[test] + fn test_from_rpc_error_integration() { + // Test that a typical RPC error string gets classified correctly + let mock_error = create_mock_client_error(); + + // Test the fallback string matching for "insufficient funds" + let error_str = r#"{"code": -32000, "message": "Account has insufficient funds"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::InsufficientFunds(_))); + + // Test the fallback string matching for "blockhash not found" + let error_str = r#"{"code": -32000, "message": "Blockhash not found"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::BlockhashNotFound(_))); + + // Test the fallback string matching for "already processed" + let error_str = r#"{"code": -32000, "message": "Transaction was already processed"}"#; + let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error); + assert!(matches!(result, SolanaProviderError::AlreadyProcessed(_))); + } + + #[test] + fn test_request_error_is_transient() { + // Test retriable 5xx errors + let error = SolanaProviderError::RequestError { + error: "Server error".to_string(), + status_code: 500, + }; + assert!(error.is_transient()); + + let error = SolanaProviderError::RequestError { + error: "Bad gateway".to_string(), + status_code: 502, + }; + assert!(error.is_transient()); + + let error = SolanaProviderError::RequestError { + error: "Service unavailable".to_string(), + status_code: 503, + }; + assert!(error.is_transient()); + + let error = SolanaProviderError::RequestError { + error: "Gateway timeout".to_string(), + status_code: 504, + }; + assert!(error.is_transient()); + + // Test retriable 4xx errors + let error = SolanaProviderError::RequestError { + error: "Request timeout".to_string(), + status_code: 408, + }; + assert!(error.is_transient()); + + let error = SolanaProviderError::RequestError { + error: "Too early".to_string(), + status_code: 425, + }; + assert!(error.is_transient()); + + let error = SolanaProviderError::RequestError { + error: "Too many requests".to_string(), + status_code: 429, + }; + assert!(error.is_transient()); + + // Test non-retriable 5xx errors + let error = SolanaProviderError::RequestError { + error: "Not implemented".to_string(), + status_code: 501, + }; + assert!(!error.is_transient()); + + let error = SolanaProviderError::RequestError { + error: "HTTP version not supported".to_string(), + status_code: 505, + }; + assert!(!error.is_transient()); + + // Test non-retriable 4xx errors + let error = SolanaProviderError::RequestError { + error: "Bad request".to_string(), + status_code: 400, + }; + assert!(!error.is_transient()); + + let error = SolanaProviderError::RequestError { + error: "Unauthorized".to_string(), + status_code: 401, + }; + assert!(!error.is_transient()); + + let error = SolanaProviderError::RequestError { + error: "Forbidden".to_string(), + status_code: 403, + }; + assert!(!error.is_transient()); + + let error = SolanaProviderError::RequestError { + error: "Not found".to_string(), + status_code: 404, + }; + assert!(!error.is_transient()); + } + + #[test] + fn test_request_error_display() { + let error = SolanaProviderError::RequestError { + error: "Server error".to_string(), + status_code: 500, + }; + let error_str = format!("{}", error); + assert!(error_str.contains("HTTP 500")); + assert!(error_str.contains("Server error")); + } } diff --git a/src/services/signer/solana/cdp_signer.rs b/src/services/signer/solana/cdp_signer.rs index ce8f9ccdb..c98bcf5e8 100644 --- a/src/services/signer/solana/cdp_signer.rs +++ b/src/services/signer/solana/cdp_signer.rs @@ -14,9 +14,13 @@ //! Private keys never leave the CDP service, providing enhanced security //! compared to local key storage solutions. use crate::{ - domain::SignTransactionResponse, - models::{Address, CdpSignerConfig, NetworkTransactionData, SignerError}, + domain::{SignTransactionResponse, SignTransactionResponseSolana}, + models::{ + Address, CdpSignerConfig, EncodedSerializedTransaction, NetworkTransactionData, + SignerError, TransactionError, + }, services::{signer::Signer, CdpService, CdpServiceTrait}, + utils::base64_encode, }; use async_trait::async_trait; use base64::{engine::general_purpose, Engine as _}; @@ -28,6 +32,7 @@ use super::SolanaSignTrait; pub type DefaultCdpService = CdpService; +#[derive(Debug)] pub struct CdpSigner where T: CdpServiceTrait, @@ -141,34 +146,6 @@ impl SolanaSignTrait for CdpSigner { } } -#[async_trait] -impl Signer for CdpSigner { - async fn address(&self) -> Result { - let address = self - .cdp_service - .account_address() - .await - .map_err(SignerError::CdpError)?; - - Ok(address) - } - - async fn sign_transaction( - &self, - transaction: NetworkTransactionData, - ) -> Result { - let solana_data = transaction.get_solana_transaction_data()?; - - let signed_transaction = self - .cdp_service - .sign_solana_transaction(solana_data.transaction) - .await - .map_err(SignerError::CdpError)?; - - Ok(SignTransactionResponse::Solana(signed_transaction)) - } -} - #[cfg(test)] mod tests { use super::*; @@ -195,7 +172,7 @@ mod tests { }); let signer = CdpSigner::new_for_testing(mock_service); - let result = signer.address().await.unwrap(); + let result = signer.pubkey().await.unwrap(); match result { Address::Solana(addr) => { @@ -355,76 +332,6 @@ mod tests { } } - #[tokio::test] - async fn test_sign_transaction_success() { - let mut mock_service = MockCdpServiceTrait::new(); - - let test_transaction = "transaction_123".to_string(); - let mock_signed_transaction = vec![1u8; 64]; // Mock signed transaction bytes - - mock_service - .expect_sign_solana_transaction() - .times(1) - .with(eq(test_transaction.clone())) - .returning(move |_| { - let signed_tx = mock_signed_transaction.clone(); - Box::pin(async { Ok(signed_tx) }) - }); - - let signer = CdpSigner::new_for_testing(mock_service); - - let tx_data = SolanaTransactionData { - transaction: test_transaction, - signature: None, - }; - - let result = signer - .sign_transaction(NetworkTransactionData::Solana(tx_data)) - .await; - - assert!(result.is_ok()); - match result.unwrap() { - SignTransactionResponse::Solana(signed_tx) => { - assert_eq!(signed_tx, vec![1u8; 64]); - } - _ => panic!("Expected Solana SignTransactionResponse"), - } - } - - #[tokio::test] - async fn test_sign_transaction_error() { - let mut mock_service = MockCdpServiceTrait::new(); - - let test_transaction = "transaction_123".to_string(); - - mock_service - .expect_sign_solana_transaction() - .times(1) - .with(eq(test_transaction.clone())) - .returning(move |_| { - Box::pin(async { Err(CdpError::SigningError("Mock signing error".into())) }) - }); - - let signer = CdpSigner::new_for_testing(mock_service); - - let tx_data = SolanaTransactionData { - transaction: test_transaction, - signature: None, - }; - - let result = signer - .sign_transaction(NetworkTransactionData::Solana(tx_data)) - .await; - - assert!(result.is_err()); - match result { - Err(SignerError::CdpError(err)) => { - assert_eq!(err.to_string(), "Signing error: Mock signing error"); - } - _ => panic!("Expected CdpError error variant"), - } - } - #[tokio::test] async fn test_address_error_handling() { let mut mock_service = MockCdpServiceTrait::new(); @@ -437,7 +344,7 @@ mod tests { }); let signer = CdpSigner::new_for_testing(mock_service); - let result = signer.address().await; + let result = signer.pubkey().await; assert!(result.is_err()); } diff --git a/src/services/signer/solana/google_cloud_kms_signer.rs b/src/services/signer/solana/google_cloud_kms_signer.rs index aa7f6690f..1a82dd1aa 100644 --- a/src/services/signer/solana/google_cloud_kms_signer.rs +++ b/src/services/signer/solana/google_cloud_kms_signer.rs @@ -32,6 +32,7 @@ use crate::{ use super::SolanaSignTrait; pub type DefaultGoogleCloudKmsService = GoogleCloudKmsService; +#[derive(Debug)] pub struct GoogleCloudKmsSigner where T: GoogleCloudKmsServiceTrait, @@ -92,30 +93,6 @@ impl SolanaSignTrait for GoogleCloudKmsSigner } } -#[async_trait] -impl Signer for GoogleCloudKmsSigner { - async fn address(&self) -> Result { - let pubkey = self - .google_cloud_kms_service - .get_solana_address() - .await - .map_err(|e| SignerError::SigningError(e.to_string())); - - let address = pubkey.map(|pubkey| Address::Solana(pubkey.to_string()))?; - - Ok(address) - } - - async fn sign_transaction( - &self, - _transaction: NetworkTransactionData, - ) -> Result { - Err(SignerError::NotImplemented( - "sign_transaction is not implemented".to_string(), - )) - } -} - #[cfg(test)] mod tests { use super::*; @@ -137,7 +114,7 @@ mod tests { }); let signer = GoogleCloudKmsSigner::new_for_testing(mock_service); - let result = signer.address().await.unwrap(); + let result = signer.pubkey().await.unwrap(); match result { Address::Solana(addr) => { @@ -257,7 +234,7 @@ mod tests { }); let signer = GoogleCloudKmsSigner::new_for_testing(mock_service); - let result = signer.address().await; + let result = signer.pubkey().await; assert!(result.is_err()); match result { diff --git a/src/services/signer/solana/local_signer.rs b/src/services/signer/solana/local_signer.rs index 3c2b6d01a..336fb98fd 100644 --- a/src/services/signer/solana/local_signer.rs +++ b/src/services/signer/solana/local_signer.rs @@ -32,6 +32,7 @@ use crate::{ use super::SolanaSignTrait; +#[derive(Debug)] pub struct LocalSigner { local_signer_client: Keypair, } @@ -69,23 +70,6 @@ impl SolanaSignTrait for LocalSigner { } } -#[async_trait] -impl Signer for LocalSigner { - async fn address(&self) -> Result { - let address: Address = Address::Solana(self.local_signer_client.pubkey().to_string()); - Ok(address) - } - - async fn sign_transaction( - &self, - _transaction: NetworkTransactionData, - ) -> Result { - Err(SignerError::NotImplemented( - "sign_transaction is not implemented".to_string(), - )) - } -} - #[cfg(test)] mod tests { use crate::{ @@ -208,22 +192,4 @@ mod tests { _ => panic!("Expected Address::Solana variant"), } } - - #[tokio::test] - async fn test_sign_transaction_not_implemented() { - let local_signer = create_testing_signer(); - let transaction_data = NetworkTransactionData::Solana(SolanaTransactionData { - transaction: "transaction_123".to_string(), - signature: None, - }); - - let result = local_signer.sign_transaction(transaction_data).await; - - match result { - Err(SignerError::NotImplemented(msg)) => { - assert_eq!(msg, "sign_transaction is not implemented".to_string()); - } - _ => panic!("Expected SignerError::NotImplemented"), - } - } } diff --git a/src/services/signer/solana/mod.rs b/src/services/signer/solana/mod.rs index 0bdb4316b..e15405d6b 100644 --- a/src/services/signer/solana/mod.rs +++ b/src/services/signer/solana/mod.rs @@ -33,16 +33,22 @@ use cdp_signer::*; mod google_cloud_kms_signer; use google_cloud_kms_signer::*; +use solana_program::message::compiled_instruction::CompiledInstruction; +use solana_sdk::pubkey::Pubkey; use solana_sdk::signature::Signature; +use solana_sdk::transaction::Transaction as SolanaTransaction; +use std::str::FromStr; + +use solana_system_interface::instruction as system_instruction; use crate::{ domain::{ SignDataRequest, SignDataResponse, SignDataResponseEvm, SignTransactionResponse, - SignTypedDataRequest, + SignTransactionResponseSolana, SignTypedDataRequest, }, models::{ - Address, NetworkTransactionData, Signer as SignerDomainModel, SignerConfig, - SignerRepoModel, SignerType, TransactionRepoModel, VaultSignerConfig, + Address, EncodedSerializedTransaction, NetworkTransactionData, Signer as SignerDomainModel, + SignerConfig, SignerRepoModel, SignerType, TransactionRepoModel, VaultSignerConfig, }, services::{CdpService, GoogleCloudKmsService, TurnkeyService, VaultConfig, VaultService}, }; @@ -52,6 +58,7 @@ use super::{Signer, SignerError, SignerFactoryError}; #[cfg(test)] use mockall::automock; +#[derive(Debug)] pub enum SolanaSigner { Local(LocalSigner), Vault(VaultSigner), @@ -64,28 +71,48 @@ pub enum SolanaSigner { #[async_trait] impl Signer for SolanaSigner { async fn address(&self) -> Result { - match self { - Self::Local(signer) => signer.address().await, - Self::Vault(signer) => signer.address().await, - Self::VaultTransit(signer) => signer.address().await, - Self::Turnkey(signer) => signer.address().await, - Self::Cdp(signer) => signer.address().await, - Self::GoogleCloudKms(signer) => signer.address().await, - } + // Delegate to SolanaSignTrait::pubkey() which all inner types implement + self.pubkey().await } async fn sign_transaction( &self, transaction: NetworkTransactionData, ) -> Result { - match self { - Self::Local(signer) => signer.sign_transaction(transaction).await, - Self::Vault(signer) => signer.sign_transaction(transaction).await, - Self::VaultTransit(signer) => signer.sign_transaction(transaction).await, - Self::Turnkey(signer) => signer.sign_transaction(transaction).await, - Self::Cdp(signer) => signer.sign_transaction(transaction).await, - Self::GoogleCloudKms(signer) => signer.sign_transaction(transaction).await, - } + // Extract Solana transaction data + let solana_data = transaction.get_solana_transaction_data().map_err(|e| { + SignerError::SigningError(format!("Invalid transaction type for Solana signer: {}", e)) + })?; + + // Get the pre-built transaction string + let transaction_str = solana_data.transaction.ok_or_else(|| { + SignerError::SigningError( + "Transaction not yet built - only available after preparation".to_string(), + ) + })?; + + // Decode transaction from base64 + let encoded_tx = EncodedSerializedTransaction::new(transaction_str); + let sdk_transaction = SolanaTransaction::try_from(encoded_tx).map_err(|e| { + SignerError::SigningError(format!("Failed to decode transaction: {}", e)) + })?; + + // Sign using the SDK transaction signing helper function + let (signed_tx, signature) = sign_sdk_transaction(self, sdk_transaction).await?; + + // Encode back to base64 + let encoded_signed_tx = + EncodedSerializedTransaction::try_from(&signed_tx).map_err(|e| { + SignerError::SigningError(format!("Failed to encode signed transaction: {}", e)) + })?; + + // Return Solana-specific response + Ok(SignTransactionResponse::Solana( + SignTransactionResponseSolana { + transaction: encoded_signed_tx, + signature: signature.to_string(), + }, + )) } } @@ -111,6 +138,73 @@ pub trait SolanaSignTrait: Sync + Send { async fn sign(&self, message: &[u8]) -> Result; } +/// Signs a raw Solana SDK transaction by finding the signer's position and adding the signature +/// +/// This helper function: +/// 1. Retrieves the signer's public key +/// 2. Finds its position in the transaction's account_keys +/// 3. Validates it's marked as a required signer +/// 4. Signs the transaction message +/// 5. Inserts the signature at the correct position +/// +/// # Arguments +/// +/// * `signer` - A type implementing SolanaSignTrait +/// * `transaction` - The Solana SDK transaction to sign +/// +/// # Returns +/// +/// A Result containing either a tuple of (signed Transaction, Signature) or a SignerError +/// +/// # Note +/// +/// This is distinct from the `Signer::sign_transaction` method which operates on domain models. +/// This function works directly with `solana_sdk::transaction::Transaction`. +pub async fn sign_sdk_transaction( + signer: &T, + mut transaction: solana_sdk::transaction::Transaction, +) -> Result<(solana_sdk::transaction::Transaction, Signature), SignerError> { + // Get signer's public key + let signer_address = signer.pubkey().await?; + let signer_pubkey = Pubkey::from_str(&signer_address.to_string()) + .map_err(|e| SignerError::KeyError(format!("Invalid signer address: {}", e)))?; + + // Find the position of the signer's public key in account_keys + let signer_index = transaction + .message + .account_keys + .iter() + .position(|key| *key == signer_pubkey) + .ok_or_else(|| { + SignerError::SigningError( + "Signer public key not found in transaction signers".to_string(), + ) + })?; + + // Check if this is a signer position (within num_required_signatures) + if signer_index >= transaction.message.header.num_required_signatures as usize { + return Err(SignerError::SigningError(format!( + "Signer is not marked as a required signer in the transaction (position {} >= {})", + signer_index, transaction.message.header.num_required_signatures + ))); + } + + // Generate signature + let signature = signer.sign(&transaction.message_data()).await?; + + // Ensure signatures array has exactly num_required_signatures slots + // This preserves any existing signatures and doesn't shrink the array + let num_required = transaction.message.header.num_required_signatures as usize; + transaction + .signatures + .resize(num_required, Signature::default()); + + // Set our signature at the correct index + transaction.signatures[signer_index] = signature; + + Ok((transaction, signature)) +} + #[async_trait] impl SolanaSignTrait for SolanaSigner { async fn pubkey(&self) -> Result { @@ -228,6 +322,7 @@ mod solana_signer_factory_tests { }; use mockall::predicate::*; use secrets::SecretVec; + use std::str::FromStr; use std::sync::Arc; fn test_key_bytes() -> SecretVec { @@ -259,23 +354,6 @@ mod solana_signer_factory_tests { } } - #[test] - fn test_create_solana_signer_test() { - let signer_model = SignerDomainModel { - id: "test".to_string(), - config: SignerConfig::Local(LocalSignerConfig { - raw_key: test_key_bytes(), - }), - }; - - let signer = SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); - - match signer { - SolanaSigner::Local(_) => {} - _ => panic!("Expected Local signer"), - } - } - #[test] fn test_create_solana_signer_vault() { let signer_model = SignerDomainModel { @@ -550,4 +628,231 @@ mod solana_signer_factory_tests { assert!(signature.is_ok()); } + + #[tokio::test] + async fn test_sign_sdk_transaction_success() { + use solana_sdk::message::Message; + use solana_sdk::pubkey::Pubkey; + use solana_sdk::signature::Signature; + use solana_sdk::transaction::Transaction; + + // Create a mock signer + let signer_model = SignerDomainModel { + id: "test".to_string(), + config: SignerConfig::Local(LocalSignerConfig { + raw_key: test_key_bytes(), + }), + }; + let signer = SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); + + // Create a simple transaction with our signer as the first account + let signer_pubkey = Pubkey::from_str(&test_key_bytes_pubkey().to_string()).unwrap(); + let recipient = Pubkey::new_unique(); + + let message = Message::new( + &[solana_system_interface::instruction::transfer( + &signer_pubkey, + &recipient, + 1000, + )], + Some(&signer_pubkey), + ); + let transaction = Transaction::new_unsigned(message); + + // Sign the transaction + let result = sign_sdk_transaction(&signer, transaction).await; + assert!(result.is_ok()); + + let (signed_tx, signature) = result.unwrap(); + assert!(!signature.to_string().is_empty()); + assert_eq!(signed_tx.signatures.len(), 1); + assert_eq!(signed_tx.signatures[0], signature); + } + + #[tokio::test] + async fn test_sign_sdk_transaction_signer_not_in_accounts() { + use solana_sdk::message::Message; + use solana_sdk::pubkey::Pubkey; + use solana_sdk::transaction::Transaction; + + // Create a mock signer + let signer_model = SignerDomainModel { + id: "test".to_string(), + config: SignerConfig::Local(LocalSignerConfig { + raw_key: test_key_bytes(), + }), + }; + let signer = SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); + + // Create a transaction where our signer is NOT in the account keys + let other_pubkey = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + + let message = Message::new( + &[solana_system_interface::instruction::transfer( + &other_pubkey, + &recipient, + 1000, + )], + Some(&other_pubkey), + ); + let transaction = Transaction::new_unsigned(message); + + // Try to sign - should fail because signer is not in account_keys + let result = sign_sdk_transaction(&signer, transaction).await; + assert!(result.is_err()); + let error = result.unwrap_err(); + match error { + SignerError::SigningError(msg) => { + assert!(msg.contains("Signer public key not found in transaction signers")); + } + _ => panic!("Expected SigningError, got {:?}", error), + } + } + + #[tokio::test] + async fn test_sign_sdk_transaction_signer_not_required() { + use solana_sdk::message::Message; + use solana_sdk::pubkey::Pubkey; + use solana_sdk::transaction::Transaction; + + // Create a mock signer + let signer_model = SignerDomainModel { + id: "test".to_string(), + config: SignerConfig::Local(LocalSignerConfig { + raw_key: test_key_bytes(), + }), + }; + let signer = SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); + + // Create a transaction where our signer is in account_keys but NOT marked as required + let signer_pubkey = Pubkey::from_str(&test_key_bytes_pubkey().to_string()).unwrap(); + let fee_payer = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + + // Create message with signer as a readonly account (not required signer) + // Use a different approach - create a message where signer is not the fee payer + let message = Message::new( + &[solana_system_interface::instruction::transfer( + &fee_payer, &recipient, 1000, + )], + Some(&fee_payer), + ); + let transaction = Transaction::new_unsigned(message); + + // Manually modify the message to include our signer as a readonly account + // This simulates a transaction where our signer is present but not required + let mut modified_message = transaction.message.clone(); + modified_message.account_keys.push(signer_pubkey); // Add signer as additional account + modified_message.header.num_readonly_unsigned_accounts += 1; // Make it readonly unsigned + + let modified_transaction = Transaction::new_unsigned(modified_message); + + // Try to sign - should fail because signer is not a required signer + let result = sign_sdk_transaction(&signer, modified_transaction).await; + assert!(result.is_err()); + let error = result.unwrap_err(); + match error { + SignerError::SigningError(msg) => { + assert!(msg.contains("Signer is not marked as a required signer")); + } + _ => panic!("Expected SigningError, got {:?}", error), + } + } + + #[tokio::test] + async fn test_sign_transaction_with_domain_model() { + use crate::models::{NetworkTransactionData, SolanaTransactionData}; + use solana_sdk::message::Message; + use solana_sdk::pubkey::Pubkey; + + // Create a mock signer + let signer_model = SignerDomainModel { + id: "test".to_string(), + config: SignerConfig::Local(LocalSignerConfig { + raw_key: test_key_bytes(), + }), + }; + let signer = SolanaSignerFactory::create_solana_signer(&signer_model).unwrap(); + + // Create a domain transaction data + let signer_pubkey = Pubkey::from_str(&test_key_bytes_pubkey().to_string()).unwrap(); + let recipient = Pubkey::new_unique(); + + let message = Message::new( + &[solana_system_interface::instruction::transfer( + &signer_pubkey, + &recipient, + 1000, + )], + Some(&signer_pubkey), + ); + let transaction = solana_sdk::transaction::Transaction::new_unsigned(message); + let encoded_tx = + crate::models::EncodedSerializedTransaction::try_from(&transaction).unwrap(); + + let solana_data = SolanaTransactionData { + transaction: Some(encoded_tx.into_inner()), + ..Default::default() + }; + + let network_data = NetworkTransactionData::Solana(solana_data); + + // Sign using the domain model method + let result = signer.sign_transaction(network_data).await; + assert!(result.is_ok()); + + let response = result.unwrap(); + match response { + crate::domain::SignTransactionResponse::Solana(solana_response) => { + assert!(!solana_response.transaction.into_inner().is_empty()); + assert!(!solana_response.signature.is_empty()); + } + _ => panic!("Expected Solana response"), + } + } + + #[test] + fn test_create_solana_signer_aws_kms_unsupported() { + let signer_model = SignerDomainModel { + id: "test".to_string(), + config: SignerConfig::AwsKms(AwsKmsSignerConfig { + region: Some("us-east-1".to_string()), + key_id: "test-key-id".to_string(), + }), + }; + + let result = SolanaSignerFactory::create_solana_signer(&signer_model); + assert!(result.is_err()); + let error = result.unwrap_err(); + match error { + SignerFactoryError::UnsupportedType(msg) => { + assert_eq!(msg, "AWS KMS"); + } + _ => panic!("Expected UnsupportedType error, got {:?}", error), + } + } + + #[cfg(test)] + #[async_trait] + impl Signer for MockSolanaSignTrait { + async fn address(&self) -> Result { + self.pubkey().await + } + + async fn sign_transaction( + &self, + _transaction: NetworkTransactionData, + ) -> Result { + // For testing, return a mock response + Ok(SignTransactionResponse::Solana( + crate::domain::SignTransactionResponseSolana { + transaction: crate::models::EncodedSerializedTransaction::new( + "signed_transaction_data".to_string(), + ), + signature: "signature_data".to_string(), + }, + )) + } + } } diff --git a/src/services/signer/solana/turnkey_signer.rs b/src/services/signer/solana/turnkey_signer.rs index 63475b37a..69fb89279 100644 --- a/src/services/signer/solana/turnkey_signer.rs +++ b/src/services/signer/solana/turnkey_signer.rs @@ -32,6 +32,7 @@ use crate::{ use super::SolanaSignTrait; pub type DefaultTurnkeyService = TurnkeyService; +#[derive(Debug)] pub struct TurnkeySigner where T: TurnkeyServiceTrait, @@ -76,24 +77,6 @@ impl SolanaSignTrait for TurnkeySigner { } } -#[async_trait] -impl Signer for TurnkeySigner { - async fn address(&self) -> Result { - let address = self.turnkey_service.address_solana()?; - - Ok(address) - } - - async fn sign_transaction( - &self, - _transaction: NetworkTransactionData, - ) -> Result { - Err(SignerError::NotImplemented( - "sign_transaction is not implemented".to_string(), - )) - } -} - #[cfg(test)] mod tests { use super::*; @@ -114,7 +97,7 @@ mod tests { }); let signer = TurnkeySigner::new_for_testing(mock_service); - let result = signer.address().await.unwrap(); + let result = signer.pubkey().await.unwrap(); match result { Address::Solana(addr) => { @@ -220,26 +203,6 @@ mod tests { } } - #[tokio::test] - async fn test_sign_transaction_not_implemented() { - let mock_service = MockTurnkeyServiceTrait::new(); - let signer = TurnkeySigner::new_for_testing(mock_service); - - let tx_data = SolanaTransactionData { - transaction: "transaction_123".to_string(), - signature: None, - }; - - let result = signer - .sign_transaction(NetworkTransactionData::Solana(tx_data)) - .await; - assert!(result.is_err()); - match result { - Err(SignerError::NotImplemented(_)) => {} - _ => panic!("Expected NotImplemented error variant"), - } - } - #[tokio::test] async fn test_address_error_handling() { let mut mock_service = MockTurnkeyServiceTrait::new(); @@ -250,7 +213,7 @@ mod tests { .returning(|| Err(TurnkeyError::ConfigError("Invalid public key".to_string()))); let signer = TurnkeySigner::new_for_testing(mock_service); - let result = signer.address().await; + let result = signer.pubkey().await; assert!(result.is_err()); } diff --git a/src/services/signer/solana/vault_signer.rs b/src/services/signer/solana/vault_signer.rs index 2cb61f4b1..787edede9 100644 --- a/src/services/signer/solana/vault_signer.rs +++ b/src/services/signer/solana/vault_signer.rs @@ -62,7 +62,7 @@ static VAULT_SIGNER_CACHE: Lazy>> Lazy::new(|| RwLock::new(HashMap::new())); /// Solana signer that fetches private keys from HashiCorp Vault KV2 engine -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct VaultSigner where T: VaultServiceTrait + Clone, @@ -202,22 +202,6 @@ impl VaultSigner { } } -#[async_trait] -impl Signer for VaultSigner { - async fn address(&self) -> Result { - let signer = self.get_local_signer().await?; - signer.address().await - } - - async fn sign_transaction( - &self, - transaction: NetworkTransactionData, - ) -> Result { - let signer = self.get_local_signer().await?; - signer.sign_transaction(transaction).await - } -} - #[async_trait] impl SolanaSignTrait for VaultSigner { async fn sign(&self, message: &[u8]) -> Result { @@ -281,7 +265,7 @@ mod tests { let mock_service = MockVaultService::new(mock_private_key.to_string()); let signer_id = uuid::Uuid::new_v4().to_string(); let signer = VaultSigner::new(signer_id, config, mock_service); - let address_result = signer.address().await; + let address_result = signer.pubkey().await; assert!( address_result.is_ok(), @@ -296,7 +280,7 @@ mod tests { let mock_service = MockVaultService::new(mock_private_key.to_string()); let signer_id = uuid::Uuid::new_v4().to_string(); let signer = VaultSigner::new(signer_id, config, mock_service); - let address_result = signer.address().await; + let address_result = signer.pubkey().await; assert!(address_result.is_ok(), "Signer should handle 0x prefix"); } @@ -308,7 +292,7 @@ mod tests { let mock_service = MockVaultService::new(invalid_hex.to_string()); let signer_id = uuid::Uuid::new_v4().to_string(); let signer = VaultSigner::new(signer_id, config, mock_service); - let result = signer.address().await; + let result = signer.pubkey().await; assert!(result.is_err(), "Should fail with invalid hex characters"); if let Err(SignerError::KeyError(msg)) = result { @@ -328,7 +312,7 @@ mod tests { let mock_service = MockVaultService::new(short_key.to_string()); let signer_id = uuid::Uuid::new_v4().to_string(); let signer = VaultSigner::new(signer_id, config, mock_service); - let result = signer.address().await; + let result = signer.pubkey().await; assert!(result.is_err(), "Should fail with invalid key length"); if let Err(SignerError::KeyError(msg)) = result { @@ -348,7 +332,7 @@ mod tests { let mock_service = MockVaultService::new(empty_key.to_string()); let signer_id = uuid::Uuid::new_v4().to_string(); let signer = VaultSigner::new(signer_id, config, mock_service); - let result = signer.address().await; + let result = signer.pubkey().await; assert!(result.is_err(), "Should fail with empty key"); if let Err(SignerError::KeyError(msg)) = result { @@ -367,11 +351,11 @@ mod tests { let signer = VaultSigner::new(signer_id, config, mock_service); // First call should load from vault - let address1 = signer.address().await; + let address1 = signer.pubkey().await; assert!(address1.is_ok()); // Second call should use cached version - let address2 = signer.address().await; + let address2 = signer.pubkey().await; assert!(address2.is_ok()); assert_eq!(address1.unwrap(), address2.unwrap()); } diff --git a/src/services/signer/solana/vault_transit_signer.rs b/src/services/signer/solana/vault_transit_signer.rs index cab9b3c14..1ac17679c 100644 --- a/src/services/signer/solana/vault_transit_signer.rs +++ b/src/services/signer/solana/vault_transit_signer.rs @@ -37,6 +37,7 @@ use super::SolanaSignTrait; pub type DefaultVaultService = VaultService; +#[derive(Debug)] pub struct VaultTransitSigner where T: VaultServiceTrait, @@ -114,27 +115,6 @@ impl SolanaSignTrait for VaultTransitSigner { } } -#[async_trait] -impl Signer for VaultTransitSigner { - async fn address(&self) -> Result { - let raw_pubkey = - base64_decode(&self.pubkey).map_err(|e| SignerError::KeyError(e.to_string()))?; - let pubkey = bs58::encode(&raw_pubkey).into_string(); - let address: Address = Address::Solana(pubkey); - - Ok(address) - } - - async fn sign_transaction( - &self, - _transaction: NetworkTransactionData, - ) -> Result { - Err(SignerError::NotImplemented( - "sign_transaction is not implemented".to_string(), - )) - } -} - #[cfg(test)] mod tests { use super::*; @@ -221,31 +201,6 @@ mod tests { assert_eq!(signature.as_ref(), &mock_sig_bytes); } - #[tokio::test] - async fn test_sign_transaction_with_mock() { - let mock_vault_service = MockVaultServiceTrait::new(); - let key_name = "test-key"; - - let signer = VaultTransitSigner::new_for_testing( - key_name.to_string(), - "9zzYYGQM9prm/xXgn6Vwas/TVgteDaACCm1zW1ouKQs=".to_string(), - mock_vault_service, - ); - let transaction_data = NetworkTransactionData::Solana(SolanaTransactionData { - transaction: "transaction_123".to_string(), - signature: None, - }); - - let result = signer.sign_transaction(transaction_data).await; - - match result { - Err(SignerError::NotImplemented(msg)) => { - assert_eq!(msg, "sign_transaction is not implemented".to_string()); - } - _ => panic!("Expected SignerError::NotImplemented"), - } - } - #[tokio::test] async fn test_pubkey_returns_correct_address() { let mock_vault_service = MockVaultServiceTrait::new(); @@ -258,11 +213,9 @@ mod tests { ); let result = signer.pubkey().await; - let result_address = signer.address().await; // Assert assert!(result.is_ok()); - assert!(result_address.is_ok()); match result.unwrap() { Address::Solana(pubkey) => { // The expected base58 encoded representation of the public key @@ -270,12 +223,5 @@ mod tests { } _ => panic!("Expected Address::Solana variant"), } - match result_address.unwrap() { - Address::Solana(pubkey) => { - // The expected base58 encoded representation of the public key - assert_eq!(pubkey, "He7WmJPCHfaJYHhMqK7QePfRT1JC5JC4UXxf3gnQhN3L"); - } - _ => panic!("Expected Address::Solana variant"), - } } } diff --git a/src/services/turnkey/mod.rs b/src/services/turnkey/mod.rs index 3edcf38c5..cf4eb08f0 100644 --- a/src/services/turnkey/mod.rs +++ b/src/services/turnkey/mod.rs @@ -206,7 +206,7 @@ pub trait TurnkeyServiceTrait: Send + Sync { ) -> TurnkeyResult<(Transaction, Signature)>; } -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct TurnkeyService { pub api_public_key: String, pub api_private_key: SecretString, diff --git a/src/services/vault/mod.rs b/src/services/vault/mod.rs index 0888176b1..315f6d88d 100644 --- a/src/services/vault/mod.rs +++ b/src/services/vault/mod.rs @@ -91,7 +91,7 @@ use mockall::automock; use crate::models::SecretString; use crate::utils::base64_encode; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct VaultConfig { pub address: String, pub namespace: Option, @@ -137,7 +137,7 @@ pub trait VaultServiceTrait: Send + Sync { async fn sign(&self, key_name: &str, message: &[u8]) -> Result; } -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct VaultService { pub config: VaultConfig, } diff --git a/src/utils/mocks.rs b/src/utils/mocks.rs index 972257017..44ce7c0a5 100644 --- a/src/utils/mocks.rs +++ b/src/utils/mocks.rs @@ -40,7 +40,7 @@ pub mod mockutils { ..Default::default() }), signer_id: "test".to_string(), - address: "0x".to_string(), + address: "11111111111111111111111111111112".to_string(), notification_id: None, system_disabled: false, custom_rpc_urls: None, @@ -159,13 +159,13 @@ pub mod mockutils { relayer_id: "test".to_string(), status: TransactionStatus::Pending, status_reason: None, - created_at: Utc::now().to_string(), + created_at: Utc::now().to_rfc3339(), sent_at: None, confirmed_at: None, valid_until: None, network_data: NetworkTransactionData::Solana(SolanaTransactionData { - transaction: "test".to_string(), - signature: None, + transaction: Some("test".to_string()), + ..Default::default() }), priced_at: None, hashes: vec![],