diff --git a/package-lock.json b/package-lock.json index 74c6db403d..a4ab2f9864 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,11 +31,11 @@ "react-hook-form": "^7.66.0", "react-i18next": "^15.7.4", "react-icons": "^5.5.0", - "react-intersection-observer": "^10.0.0", "react-markdown": "^10.1.0", "styled-components": "^6.1.19", "use-sync-external-store": "^1.6.0", "uuid": "^13.0.0", + "virtua": "^0.47.0", "vite-tsconfig-paths": "^5.1.4", "zustand": "^5.0.8" }, @@ -6938,21 +6938,6 @@ "react": "*" } }, - "node_modules/react-intersection-observer": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-10.0.0.tgz", - "integrity": "sha512-JJRgcnFQoVXmbE5+GXr1OS1NDD1gHk0HyfpLcRf0575IbJz+io8yzs4mWVlfaqOQq1FiVjLvuYAdEEcrrCfveg==", - "license": "MIT", - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -8179,6 +8164,36 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/virtua": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/virtua/-/virtua-0.47.0.tgz", + "integrity": "sha512-ihUpsZKbw+lqG9ET1zXHOHVV9nSLszLSE8Z2m3kWJKey9HCd7OG3QKK3quJZs1/jRJgMpYBffi3pYP0X0jxAzA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0", + "solid-js": ">=1.0", + "svelte": ">=5.0", + "vue": ">=3.2" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/vite": { "version": "7.2.4", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", diff --git a/package.json b/package.json index 2e61d972fd..1964a11ab4 100644 --- a/package.json +++ b/package.json @@ -38,11 +38,11 @@ "react-hook-form": "^7.66.0", "react-i18next": "^15.7.4", "react-icons": "^5.5.0", - "react-intersection-observer": "^10.0.0", "react-markdown": "^10.1.0", "styled-components": "^6.1.19", "use-sync-external-store": "^1.6.0", "uuid": "^13.0.0", + "virtua": "^0.47.0", "vite-tsconfig-paths": "^5.1.4", "zustand": "^5.0.8" }, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fe4f839971..bb8c7997b3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -88,6 +88,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -271,6 +277,7 @@ dependencies = [ "futures-io", "memchr", "pin-project-lite", + "tokio", "xz2", "zstd", "zstd-safe", @@ -484,6 +491,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -548,11 +564,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" dependencies = [ "axum-core 0.5.5", + "axum-macros", "bytes 1.11.0", + "form_urlencoded", "futures-util", "http", "http-body", "http-body-util", + "hyper", + "hyper-util", "itoa", "matchit 0.8.4", "memchr", @@ -560,10 +580,15 @@ dependencies = [ "percent-encoding", "pin-project-lite", "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", + "tokio", "tower 0.5.2", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -603,6 +628,18 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", ] [[package]] @@ -1585,6 +1622,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1830,6 +1876,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] @@ -2011,6 +2058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -2257,6 +2305,9 @@ name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] [[package]] name = "elliptic-curve" @@ -2391,6 +2442,17 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + [[package]] name = "event-listener" version = "2.5.3" @@ -2559,9 +2621,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2693,6 +2767,17 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot 0.12.5", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -3196,6 +3281,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -3205,6 +3292,15 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "hdrhistogram" version = "7.5.4" @@ -3315,6 +3411,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -4091,6 +4196,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "lazycell" @@ -4186,15 +4294,57 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.25.2" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29f835d03d717946d28b1d1ed632eb6f0e24a299388ee623d0c23118d3e8a7fa" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ "cc", "pkg-config", "vcpkg", ] +[[package]] +name = "libz-rs-sys" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" +dependencies = [ + "zlib-rs", +] + +[[package]] +name = "lightweight_wallet_libs" +version = "0.2.0" +source = "git+https://github.com/tari-project/tari-wallet?rev=fefeeeb205120bd22d5d5b36dcec4a790cd9d0f7#fefeeeb205120bd22d5d5b36dcec4a790cd9d0f7" +dependencies = [ + "anyhow", + "async-trait", + "futures", + "getrandom 0.2.16", + "js-sys", + "minotari_app_grpc 5.2.0-pre.5", + "primitive-types", + "rayon", + "reqwest", + "serde", + "serde-wasm-bindgen", + "serde_json", + "tari_common 5.2.0-pre.5", + "tari_common_types 5.2.0-pre.5", + "tari_crypto", + "tari_node_components 5.2.0-pre.5", + "tari_script 5.2.0-pre.5", + "tari_transaction_components 5.2.0-pre.5", + "tari_utilities", + "thiserror 1.0.69", + "tonic 0.13.1", + "tracing", + "tracing-subscriber", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "linux-keyutils" version = "0.2.4" @@ -4434,6 +4584,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.6" @@ -4607,6 +4767,41 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "minotari" +version = "0.1.0" +source = "git+https://github.com/Misieq01/minotari-cli.git?branch=feat%2FTU-integration-adjustments#f3cc6bce3314126e201ac80ee195f58c3374af4c" +dependencies = [ + "anyhow", + "async-trait", + "axum 0.8.7", + "blake2", + "chacha20poly1305", + "chrono", + "clap 4.5.51", + "hex", + "lightweight_wallet_libs", + "reqwest", + "reqwest-middleware", + "reqwest-retry", + "serde", + "serde_json", + "sqlx", + "tari_common 5.2.0-pre.5", + "tari_common_types 5.2.0-pre.5", + "tari_crypto", + "tari_script 5.2.0-pre.5", + "tari_transaction_components 5.2.0-pre.5", + "tari_utilities", + "thiserror 2.0.17", + "tokio", + "tokio-util 0.7.17", + "url", + "utoipa", + "utoipa-swagger-ui", + "uuid", +] + [[package]] name = "minotari_app_grpc" version = "5.1.0" @@ -4620,16 +4815,16 @@ dependencies = [ "rand 0.8.5", "rcgen", "subtle", - "tari_common_types", + "tari_common_types 5.1.0", "tari_comms", "tari_core", "tari_crypto", - "tari_features", - "tari_max_size", - "tari_node_components", - "tari_script", - "tari_sidechain", - "tari_transaction_components", + "tari_features 5.1.0", + "tari_max_size 5.1.0", + "tari_node_components 5.1.0", + "tari_script 5.1.0", + "tari_sidechain 5.1.0", + "tari_transaction_components 5.1.0", "tari_utilities", "thiserror 1.0.69", "tokio", @@ -4638,6 +4833,35 @@ dependencies = [ "zeroize", ] +[[package]] +name = "minotari_app_grpc" +version = "5.2.0-pre.5" +source = "git+https://github.com/tari-project/tari/?rev=ed473c3c94c2219ac1eebb9345384b6a7245606c#ed473c3c94c2219ac1eebb9345384b6a7245606c" +dependencies = [ + "argon2", + "base64 0.13.1", + "borsh", + "log", + "prost 0.13.5", + "rand 0.8.5", + "rcgen", + "subtle", + "tari_common_types 5.2.0-pre.5", + "tari_crypto", + "tari_features 5.2.0-pre.5", + "tari_max_size 5.2.0-pre.5", + "tari_node_components 5.2.0-pre.5", + "tari_script 5.2.0-pre.5", + "tari_sidechain 5.2.0-pre.5", + "tari_transaction_components 5.2.0-pre.5", + "tari_utilities", + "thiserror 2.0.17", + "tokio", + "tonic 0.13.1", + "tonic-build", + "zeroize", +] + [[package]] name = "minotari_ledger_wallet_common" version = "5.1.0" @@ -4646,12 +4870,21 @@ dependencies = [ "bs58 0.5.1", ] +[[package]] +name = "minotari_ledger_wallet_common" +version = "5.2.0-pre.5" +source = "git+https://github.com/tari-project/tari/?rev=ed473c3c94c2219ac1eebb9345384b6a7245606c#ed473c3c94c2219ac1eebb9345384b6a7245606c" +dependencies = [ + "bs58 0.5.1", + "serde", +] + [[package]] name = "minotari_node_grpc_client" version = "5.1.0" source = "git+https://github.com/tari-project/tari.git?tag=v5.1.0#633e5ae62d19b7880e8822dc3c4451da2c34cecc" dependencies = [ - "minotari_app_grpc", + "minotari_app_grpc 5.1.0", ] [[package]] @@ -4659,9 +4892,9 @@ name = "minotari_wallet_grpc_client" version = "0.1.0" source = "git+https://github.com/tari-project/tari.git?tag=v5.1.0#633e5ae62d19b7880e8822dc3c4451da2c34cecc" dependencies = [ - "minotari_app_grpc", - "tari_common", - "tari_common_types", + "minotari_app_grpc 5.1.0", + "tari_common 5.1.0", + "tari_common_types 5.1.0", "thiserror 1.0.69", "tonic 0.13.1", ] @@ -4996,6 +5229,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -5006,6 +5248,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82c79c15c05d4bf82b6f5ef163104cc81a760d8e874d38ac50ab67c8877b647b" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -5053,6 +5311,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -5060,6 +5329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -5771,6 +6041,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -6019,6 +6298,17 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -6855,6 +7145,7 @@ version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ + "async-compression", "base64 0.22.1", "bytes 1.11.0", "cookie", @@ -7001,30 +7292,84 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" [[package]] -name = "rustc-demangle" -version = "0.1.26" +name = "rsa" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] [[package]] -name = "rustc-hash" -version = "2.1.1" +name = "rust-embed" +version = "8.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "947d7f3fad52b283d261c4c99a084937e2fe492248cb9a68a8435a861b8798ca" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] [[package]] -name = "rustc-hex" -version = "2.1.0" +name = "rust-embed-impl" +version = "8.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" +checksum = "5fa2c8c9e8711e10f9c4fd2d64317ef13feaab820a4c51541f1a8c8e2e851ab2" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.110", + "walkdir", +] [[package]] -name = "rustc_version" -version = "0.4.1" +name = "rust-embed-utils" +version = "8.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "60b161f275cb337fe0a44d924a5f4df0ed69c2c39519858f931ce61c779d3475" dependencies = [ - "semver", + "sha2", + "walkdir", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", ] [[package]] @@ -7508,6 +7853,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_cbor" version = "0.11.2" @@ -7844,6 +8200,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest", "rand_core 0.6.4", ] @@ -7888,6 +8245,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smart-default" @@ -8016,6 +8376,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -8041,6 +8410,198 @@ dependencies = [ "web-sys", ] +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes 1.11.0", + "chrono", + "crc 3.3.0", + "crossbeam-queue", + "either", + "event-listener 5.4.1", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.12.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.110", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.110", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.10.0", + "byteorder", + "bytes 1.11.0", + "chrono", + "crc 3.3.0", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1 0.10.6", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.17", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.10.0", + "byteorder", + "chrono", + "crc 3.3.0", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.17", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.17", + "tracing", + "url", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -8102,6 +8663,17 @@ dependencies = [ "quote", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strip-ansi-escapes" version = "0.2.1" @@ -8438,6 +9010,7 @@ dependencies = [ "libsqlite3-sys", "log", "log4rs", + "minotari", "minotari_node_grpc_client", "minotari_wallet_grpc_client", "monero-address-creator", @@ -8462,15 +9035,16 @@ dependencies = [ "serde_json", "serde_yaml", "sha2", + "sqlx", "starship-battery", "sys-locale", "sysinfo", "tar", - "tari_common", - "tari_common_types", + "tari_common 5.2.0-pre.5", + "tari_common_types 5.2.0-pre.5", "tari_crypto", "tari_shutdown", - "tari_transaction_components", + "tari_transaction_components 5.2.0-pre.5", "tari_utilities", "tauri", "tauri-build", @@ -8539,12 +9113,35 @@ dependencies = [ "serde_yaml", "sha2", "structopt", - "tari_features", + "tari_features 5.1.0", "tempfile", "thiserror 1.0.69", "toml 0.5.11", ] +[[package]] +name = "tari_common" +version = "5.2.0-pre.5" +source = "git+https://github.com/tari-project/tari/?rev=ed473c3c94c2219ac1eebb9345384b6a7245606c#ed473c3c94c2219ac1eebb9345384b6a7245606c" +dependencies = [ + "anyhow", + "cargo_toml 0.20.5", + "config", + "dirs-next", + "log", + "log4rs", + "multiaddr", + "path-clean", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "structopt", + "tari_features 5.2.0-pre.5", + "tempfile", + "thiserror 2.0.17", +] + [[package]] name = "tari_common_sqlite" version = "5.1.0" @@ -8579,7 +9176,7 @@ dependencies = [ "digest", "getrandom 0.2.16", "js-sys", - "minotari_ledger_wallet_common", + "minotari_ledger_wallet_common 5.1.0", "newtype-ops", "once_cell", "primitive-types", @@ -8589,16 +9186,52 @@ dependencies = [ "strum 0.22.0", "strum_macros 0.22.0", "subtle", - "tari_common", + "tari_common 5.1.0", "tari_crypto", - "tari_hashing", - "tari_max_size", + "tari_hashing 5.1.0", + "tari_max_size 5.1.0", "tari_utilities", "thiserror 1.0.69", "utoipa", "zeroize", ] +[[package]] +name = "tari_common_types" +version = "5.2.0-pre.5" +source = "git+https://github.com/tari-project/tari/?rev=ed473c3c94c2219ac1eebb9345384b6a7245606c#ed473c3c94c2219ac1eebb9345384b6a7245606c" +dependencies = [ + "argon2", + "base64 0.21.7", + "bitflags 2.10.0", + "blake2", + "borsh", + "bs58 0.5.1", + "chacha20 0.7.3", + "chacha20poly1305", + "crc32fast", + "digest", + "getrandom 0.2.16", + "js-sys", + "newtype-ops", + "once_cell", + "primitive-types", + "rand 0.8.5", + "serde", + "serde_json", + "strum 0.22.0", + "strum_macros 0.22.0", + "subtle", + "tari_common 5.2.0-pre.5", + "tari_crypto", + "tari_hashing 5.2.0-pre.5", + "tari_max_size 5.2.0-pre.5", + "tari_utilities", + "thiserror 2.0.17", + "utoipa", + "zeroize", +] + [[package]] name = "tari_comms" version = "5.1.0" @@ -8630,7 +9263,7 @@ dependencies = [ "serde_json", "sha3", "snow", - "tari_common", + "tari_common 5.1.0", "tari_common_sqlite", "tari_crypto", "tari_shutdown", @@ -8667,7 +9300,7 @@ dependencies = [ "prost 0.13.5", "rand 0.8.5", "serde", - "tari_common", + "tari_common 5.1.0", "tari_common_sqlite", "tari_comms", "tari_comms_rpc_macros", @@ -8725,26 +9358,26 @@ dependencies = [ "sha3", "strum_macros 0.22.0", "tari-tiny-keccak", - "tari_common", + "tari_common 5.1.0", "tari_common_sqlite", - "tari_common_types", + "tari_common_types 5.1.0", "tari_comms", "tari_comms_dht", "tari_comms_rpc_macros", "tari_crypto", - "tari_features", - "tari_hashing", - "tari_max_size", + "tari_features 5.1.0", + "tari_hashing 5.1.0", + "tari_max_size 5.1.0", "tari_mmr", - "tari_node_components", + "tari_node_components 5.1.0", "tari_p2p", - "tari_script", + "tari_script 5.1.0", "tari_service_framework", "tari_shutdown", - "tari_sidechain", + "tari_sidechain 5.1.0", "tari_storage", "tari_test_utils", - "tari_transaction_components", + "tari_transaction_components 5.1.0", "tari_transaction_key_manager", "tari_utilities", "thiserror 1.0.69", @@ -8781,6 +9414,11 @@ name = "tari_features" version = "5.1.0" source = "git+https://github.com/tari-project/tari.git?tag=v5.1.0#633e5ae62d19b7880e8822dc3c4451da2c34cecc" +[[package]] +name = "tari_features" +version = "5.2.0-pre.5" +source = "git+https://github.com/tari-project/tari/?rev=ed473c3c94c2219ac1eebb9345384b6a7245606c#ed473c3c94c2219ac1eebb9345384b6a7245606c" + [[package]] name = "tari_hashing" version = "5.1.0" @@ -8792,6 +9430,17 @@ dependencies = [ "tari_crypto", ] +[[package]] +name = "tari_hashing" +version = "5.2.0-pre.5" +source = "git+https://github.com/tari-project/tari/?rev=ed473c3c94c2219ac1eebb9345384b6a7245606c#ed473c3c94c2219ac1eebb9345384b6a7245606c" +dependencies = [ + "blake2", + "borsh", + "digest", + "tari_crypto", +] + [[package]] name = "tari_jellyfish" version = "5.1.0" @@ -8802,7 +9451,21 @@ dependencies = [ "indexmap 2.12.0", "serde", "tari_crypto", - "tari_hashing", + "tari_hashing 5.1.0", + "thiserror 2.0.17", +] + +[[package]] +name = "tari_jellyfish" +version = "5.2.0-pre.5" +source = "git+https://github.com/tari-project/tari/?rev=ed473c3c94c2219ac1eebb9345384b6a7245606c#ed473c3c94c2219ac1eebb9345384b6a7245606c" +dependencies = [ + "borsh", + "digest", + "indexmap 2.12.0", + "serde", + "tari_crypto", + "tari_hashing 5.2.0-pre.5", "thiserror 2.0.17", ] @@ -8817,6 +9480,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "tari_max_size" +version = "5.2.0-pre.5" +source = "git+https://github.com/tari-project/tari/?rev=ed473c3c94c2219ac1eebb9345384b6a7245606c#ed473c3c94c2219ac1eebb9345384b6a7245606c" +dependencies = [ + "borsh", + "serde", + "tari_utilities", + "thiserror 2.0.17", +] + [[package]] name = "tari_mmr" version = "5.1.0" @@ -8844,13 +9518,33 @@ dependencies = [ "log", "primitive-types", "serde", - "tari_common_types", - "tari_hashing", - "tari_transaction_components", + "tari_common_types 5.1.0", + "tari_hashing 5.1.0", + "tari_transaction_components 5.1.0", "tari_utilities", "thiserror 1.0.69", ] +[[package]] +name = "tari_node_components" +version = "5.2.0-pre.5" +source = "git+https://github.com/tari-project/tari/?rev=ed473c3c94c2219ac1eebb9345384b6a7245606c#ed473c3c94c2219ac1eebb9345384b6a7245606c" +dependencies = [ + "blake2", + "borsh", + "chrono", + "digest", + "js-sys", + "log", + "primitive-types", + "serde", + "tari_common_types 5.2.0-pre.5", + "tari_hashing 5.2.0-pre.5", + "tari_transaction_components 5.2.0-pre.5", + "tari_utilities", + "thiserror 2.0.17", +] + [[package]] name = "tari_p2p" version = "5.1.0" @@ -8864,7 +9558,7 @@ dependencies = [ "prost 0.13.5", "rand 0.8.5", "serde", - "tari_common", + "tari_common 5.1.0", "tari_common_sqlite", "tari_comms", "tari_comms_dht", @@ -8890,11 +9584,29 @@ dependencies = [ "sha2", "sha3", "tari_crypto", - "tari_max_size", + "tari_max_size 5.1.0", "tari_utilities", "thiserror 1.0.69", ] +[[package]] +name = "tari_script" +version = "5.2.0-pre.5" +source = "git+https://github.com/tari-project/tari/?rev=ed473c3c94c2219ac1eebb9345384b6a7245606c#ed473c3c94c2219ac1eebb9345384b6a7245606c" +dependencies = [ + "blake2", + "borsh", + "digest", + "integer-encoding", + "serde", + "sha2", + "sha3", + "tari_crypto", + "tari_max_size 5.2.0-pre.5", + "tari_utilities", + "thiserror 2.0.17", +] + [[package]] name = "tari_service_framework" version = "5.1.0" @@ -8927,10 +9639,27 @@ dependencies = [ "hex", "log", "serde", - "tari_common_types", + "tari_common_types 5.1.0", + "tari_crypto", + "tari_hashing 5.1.0", + "tari_jellyfish 5.1.0", + "tari_utilities", + "thiserror 2.0.17", +] + +[[package]] +name = "tari_sidechain" +version = "5.2.0-pre.5" +source = "git+https://github.com/tari-project/tari/?rev=ed473c3c94c2219ac1eebb9345384b6a7245606c#ed473c3c94c2219ac1eebb9345384b6a7245606c" +dependencies = [ + "borsh", + "hex", + "log", + "serde", + "tari_common_types 5.2.0-pre.5", "tari_crypto", - "tari_hashing", - "tari_jellyfish", + "tari_hashing 5.2.0-pre.5", + "tari_jellyfish 5.2.0-pre.5", "tari_utilities", "thiserror 2.0.17", ] @@ -8991,14 +9720,14 @@ dependencies = [ "serde_valid", "strum 0.22.0", "strum_macros 0.22.0", - "tari_common", - "tari_common_types", + "tari_common 5.1.0", + "tari_common_types 5.1.0", "tari_crypto", - "tari_hashing", - "tari_max_size", - "tari_script", + "tari_hashing 5.1.0", + "tari_max_size 5.1.0", + "tari_script 5.1.0", "tari_service_framework", - "tari_sidechain", + "tari_sidechain 5.1.0", "tari_utilities", "thiserror 1.0.69", "tokio", @@ -9007,6 +9736,51 @@ dependencies = [ "zeroize", ] +[[package]] +name = "tari_transaction_components" +version = "5.2.0-pre.5" +source = "git+https://github.com/tari-project/tari/?rev=ed473c3c94c2219ac1eebb9345384b6a7245606c#ed473c3c94c2219ac1eebb9345384b6a7245606c" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "blake2", + "borsh", + "bytes 0.5.6", + "chacha20poly1305", + "chrono", + "decimal-rs", + "derivative", + "digest", + "integer-encoding", + "log", + "minotari_ledger_wallet_common 5.2.0-pre.5", + "newtype-ops", + "num-derive 0.4.2", + "num-format", + "num-traits", + "primitive-types", + "rand 0.8.5", + "semver", + "serde", + "serde_json", + "serde_repr", + "serde_valid", + "strum_macros 0.22.0", + "tari_common 5.2.0-pre.5", + "tari_common_types 5.2.0-pre.5", + "tari_crypto", + "tari_hashing 5.2.0-pre.5", + "tari_max_size 5.2.0-pre.5", + "tari_script 5.2.0-pre.5", + "tari_sidechain 5.2.0-pre.5", + "tari_utilities", + "thiserror 2.0.17", + "tokio", + "utoipa", + "uuid", + "zeroize", +] + [[package]] name = "tari_transaction_key_manager" version = "5.1.0" @@ -9020,8 +9794,8 @@ dependencies = [ "log", "rand 0.8.5", "tari_common_sqlite", - "tari_common_types", - "tari_transaction_components", + "tari_common_types 5.1.0", + "tari_transaction_components 5.1.0", "tari_utilities", "tokio", "zeroize", @@ -10069,6 +10843,17 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.20" @@ -10076,12 +10861,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", + "nu-ansi-term", "once_cell", "regex-automata", "sharded-slab", + "smallvec", "thread_local", "tracing", "tracing-core", + "tracing-log", ] [[package]] @@ -10250,6 +11038,12 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -10265,6 +11059,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -10414,6 +11214,24 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" +dependencies = [ + "axum 0.8.7", + "base64 0.22.1", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "zip 3.0.0", +] + [[package]] name = "uuid" version = "1.18.1" @@ -11938,6 +12756,20 @@ dependencies = [ "zstd", ] +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap 2.12.0", + "memchr", + "zopfli", +] + [[package]] name = "zip" version = "4.6.1" @@ -11950,6 +12782,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "zlib-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" + [[package]] name = "zopfli" version = "0.8.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7da46ddb8a..078b2707b4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,7 +42,7 @@ keyring = { version = "3.0.5", features = [ "linux-native-sync-persistent", "windows-native", ] } -libsqlite3-sys = { version = "0.25.1", features = [ +libsqlite3-sys = { version = "0.30.1", features = [ "bundled", ] } # Required for tari_wallet log = "0.4.22" @@ -72,15 +72,24 @@ serde_cbor = "0.11.2" serde_json = "1" serde_yaml = "0.9.10" sha2 = "0.10.8" +sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite"] } sys-locale = "0.3.1" sysinfo = "0.31.2" tar = "0.4.26" -tari_common = { git = "https://github.com/tari-project/tari.git", tag = "v5.1.0" } -tari_common_types = { git = "https://github.com/tari-project/tari.git", tag = "v5.1.0" } -tari_transaction_components = { git = "https://github.com/tari-project/tari.git", tag = "v5.1.0" } +# TODO: Switch back to main repository when the changes are merged +tari_common = { git = "https://github.com/tari-project/tari/", rev = "ed473c3c94c2219ac1eebb9345384b6a7245606c" } +tari_common_types = { git = "https://github.com/tari-project/tari/", rev = "ed473c3c94c2219ac1eebb9345384b6a7245606c" } +tari_transaction_components = { git = "https://github.com/tari-project/tari/", rev = "ed473c3c94c2219ac1eebb9345384b6a7245606c" } +# tari_common = { git = "https://github.com/tari-project/tari.git", tag = "v5.1.0" } +# tari_common_types = { git = "https://github.com/tari-project/tari.git", tag = "v5.1.0" } +# tari_transaction_components = { git = "https://github.com/tari-project/tari.git", tag = "v5.1.0" } tari_crypto = "0.22.1" tari_shutdown = { git = "https://github.com/tari-project/tari.git", tag = "v5.1.0" } tari_utilities = "0.8.0" +# # TODO Update to main repository when the changes are merged +minotari-wallet = { package = "minotari", git = "https://github.com/Misieq01/minotari-cli.git", branch = "feat/TU-integration-adjustments" } +# minotari-wallet = { package = "minotari", path = "/home/bartek/Desktop/web/tari/minotari-cli" } +# minotari-wallet = { package = "minotari", git = "https://github.com/tari-project/minotari-cli.git", branch = "main" } tauri = { version = "2", features = [ "protocol-asset", "isolation", @@ -122,7 +131,7 @@ winreg = "0.52.0" # needed for keymanager. TODO: Find a way of creating a keymanager without bundling sqlite chrono = "0.4.38" device_query = "2.1.0" -libsqlite3-sys = { version = "0.25.1", features = ["bundled"] } +# libsqlite3-sys = { version = "0.25.1", features = ["bundled"] } log = "0.4.22" nvml-wrapper = "0.10.0" rand = "0.8.5" diff --git a/src-tauri/env.esmeralda b/src-tauri/env.esmeralda index d84852e98f..5fe3c4702b 100644 --- a/src-tauri/env.esmeralda +++ b/src-tauri/env.esmeralda @@ -7,4 +7,3 @@ EXCHANGE_ID=universal TARI_NETWORK=esme TARI_TARGET_NETWORK=testnet TELEMETRY_API_URL=https://ut.tari.com/push - diff --git a/src-tauri/env.mainnet b/src-tauri/env.mainnet index 1fa3e7f39c..64830b4a86 100644 --- a/src-tauri/env.mainnet +++ b/src-tauri/env.mainnet @@ -7,3 +7,4 @@ EXCHANGE_ID=universal TARI_NETWORK=mainnet TARI_TARGET_NETWORK=mainnet TELEMETRY_API_URL=https://ut.tari.com/push +SQLX_OFFLINE=true \ No newline at end of file diff --git a/src-tauri/env.nextnet b/src-tauri/env.nextnet index ac9b177ce1..1304bc58b0 100644 --- a/src-tauri/env.nextnet +++ b/src-tauri/env.nextnet @@ -6,4 +6,5 @@ BRIDGE_WALLET_CONNECT_PROJECT_ID=89085ba8291ae91cf7e35f57ad60033d EXCHANGE_ID=universal TARI_NETWORK=nextnet TARI_TARGET_NETWORK=nextnet -TELEMETRY_API_URL=https://ut.tari.com/push \ No newline at end of file +TELEMETRY_API_URL=https://ut.tari.com/push +SQLX_OFFLINE=true \ No newline at end of file diff --git a/src-tauri/src/airdrop.rs b/src-tauri/src/airdrop.rs index 0622d01832..f54ca12592 100644 --- a/src-tauri/src/airdrop.rs +++ b/src-tauri/src/airdrop.rs @@ -51,6 +51,7 @@ pub struct AirdropAccessToken { pub scope: String, } +#[allow(dead_code)] #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AirdropMinedBlockMessage { @@ -117,6 +118,7 @@ pub async fn get_wallet_view_key_hashed(app: AppHandle) -> String { hex::encode(Sha256::digest(view_private_key)) } +#[allow(dead_code)] pub async fn send_new_block_mined(app: AppHandle, block_height: u64) { TasksTrackers::current().wallet_phase.get_task_tracker().await.spawn(async move { let app_in_config_memory = app.state::().in_memory_config.clone(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a587ddcbdd..9863cf2223 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -59,8 +59,8 @@ use crate::tasks_tracker::TasksTrackers; use crate::tor_adapter::TorConfig; use crate::utils::address_utils::verify_send; use crate::utils::app_flow_utils::FrontendReadyChannel; -use crate::wallet::wallet_manager::WalletManagerError; -use crate::wallet::wallet_types::{TariAddressVariants, TransactionInfo}; +use crate::wallet::minotari_wallet::MinotariWalletManager; +use crate::wallet::wallet_types::TariAddressVariants; use crate::{airdrop, UniverseAppState, LOG_TARGET_APP_LOGIC}; use base64::prelude::*; @@ -554,32 +554,6 @@ pub async fn get_airdrop_tokens( Ok(airdrop_access_token) } -#[tauri::command] -pub async fn get_transactions( - state: tauri::State<'_, UniverseAppState>, - offset: Option, - limit: Option, - status_bitflag: Option, -) -> Result, String> { - let timer = Instant::now(); - let transactions = state - .wallet_manager - .get_transactions(offset, limit, status_bitflag) - .await - .unwrap_or_else(|e| { - if !matches!(e, WalletManagerError::WalletNotStarted) { - warn!(target: LOG_TARGET_APP_LOGIC, "Error getting transactions: {e}"); - } - vec![] - }); - - if timer.elapsed() > MAX_ACCEPTABLE_COMMAND_TIME { - warn!(target: LOG_TARGET_APP_LOGIC, "get_transactions took too long: {:?}", timer.elapsed()); - } - - Ok(transactions) -} - #[tauri::command] pub async fn forgot_pin( seed_words: Vec, @@ -1574,24 +1548,21 @@ pub async fn reconnect() -> Result<(), String> { #[tauri::command] pub async fn send_one_sided_to_stealth_address( - state: tauri::State<'_, UniverseAppState>, - app_handle: tauri::AppHandle, amount: String, destination: String, payment_id: Option, ) -> Result<(), String> { let timer = Instant::now(); info!(target: LOG_TARGET_APP_LOGIC, "[send_one_sided_to_stealth_address] called with args: (amount: {amount:?}, destination: {destination:?}, payment_id: {payment_id:?})"); - state - .wallet_manager - .send_one_sided_to_stealth_address(amount, destination, payment_id, &app_handle) - .await - .map_err(|e| e.to_string())?; - let balance = state.wallet_manager.get_balance().await; - if let Ok(balance) = balance { - EventsEmitter::emit_wallet_balance_update(balance).await; - } + let parsed_amount = Minotari::from_str(&amount).map_err(|e| e.to_string())?; + MinotariWalletManager::send_one_sided_transaction( + destination, + parsed_amount.uT().0, + payment_id, + ) + .await + .map_err(|e| e.to_string())?; if timer.elapsed() > MAX_ACCEPTABLE_COMMAND_TIME { warn!(target: LOG_TARGET_APP_LOGIC, "send_one_sided_to_stealth_address took too long: {:?}", timer.elapsed()); @@ -1992,9 +1963,9 @@ pub async fn refresh_wallet_history( .map_err(|e| e.to_string())?; // Trigger it manually to immediately update the UI - let node_status_watch_rx = state.node_status_watch_rx.clone(); - let node_status = *node_status_watch_rx.borrow(); - EventsEmitter::emit_init_wallet_scanning_progress(0, node_status.block_height, 0.0).await; + // let node_status_watch_rx = state.node_status_watch_rx.clone(); + // let node_status = *node_status_watch_rx.borrow(); + // EventsEmitter::emit_wallet_scanning_progress_update(0, node_status.block_height, 0.0).await; SetupManager::get_instance() .resume_phases(vec![SetupPhase::Wallet]) diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index aef0709601..075bde505e 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -36,7 +36,11 @@ use crate::{ #[derive(Clone, Debug, Serialize)] pub enum EventType { - WalletBalanceUpdate, + WalletBalanceUpdate, // =================== + WalletScanningProgressUpdate, // =================== + WalletTransactionsFound, + WalletTransactionsCleared, + WalletTransactionUpdated, BaseNodeUpdate, GpuDevicesUpdate, CpuPoolsStatsUpdate, @@ -62,7 +66,6 @@ pub enum EventType { ConfigMiningLoaded, ConfigPoolsLoaded, BackgroundNodeSyncUpdate, - InitWalletScanningProgress, ConnectionStatus, ExchangeIdChanged, DisabledPhases, @@ -81,7 +84,6 @@ pub enum EventType { UpdateAppModuleStatus, UpdateSelectedMiner, AvailableMiners, - WalletStatusUpdate, UpdateCpuMinerControlsState, UpdateGpuMinerControlsState, OpenSettings, @@ -173,10 +175,11 @@ pub struct NodeTypeUpdatePayload { } #[derive(Debug, Clone, Serialize)] -pub struct InitWalletScanningProgressPayload { +pub struct WalletScanningProgressUpdatePayload { pub scanned_height: u64, pub total_height: u64, pub progress: f64, + pub is_initial_scan_complete: bool, } // TODO: Bring back connection status callback, was removed with removing setup screen and related logic @@ -200,9 +203,3 @@ pub struct TariAddressUpdatePayload { pub tari_address_emoji: String, pub tari_address_type: TariAddressType, } - -#[derive(Debug, Serialize, Clone)] -pub struct WalletStatusUpdatePayload { - pub loading: bool, - pub unhealthy: Option, -} diff --git a/src-tauri/src/events_emitter.rs b/src-tauri/src/events_emitter.rs index c8e9f303d5..580f80f0cf 100644 --- a/src-tauri/src/events_emitter.rs +++ b/src-tauri/src/events_emitter.rs @@ -24,7 +24,7 @@ use crate::LOG_TARGET_APP_LOGIC; use crate::configs::config_ui::WalletUIMode; use crate::events::{ ConnectionStatusPayload, CriticalProblemPayload, DisabledPhasesPayload, - InitWalletScanningProgressPayload, UpdateAppModuleStatusPayload, WalletStatusUpdatePayload, + UpdateAppModuleStatusPayload, WalletScanningProgressUpdatePayload, }; use crate::internal_wallet::TariAddressType; use crate::mining::cpu::CpuMinerStatus; @@ -51,6 +51,7 @@ use crate::{ BaseNodeStatus, }; use log::error; +use minotari_wallet::DisplayedTransaction; use std::collections::HashMap; use std::sync::LazyLock; use tari_common_types::tari_address::TariAddress; @@ -532,18 +533,20 @@ impl EventsEmitter { } } - pub async fn emit_init_wallet_scanning_progress( + pub async fn emit_wallet_scanning_progress_update( scanned_height: u64, total_height: u64, progress: f64, + is_initial_scan_complete: bool, ) { let _unused = FrontendReadyChannel::current().wait_for_ready().await; let event = Event { - event_type: EventType::InitWalletScanningProgress, - payload: InitWalletScanningProgressPayload { + event_type: EventType::WalletScanningProgressUpdate, + payload: WalletScanningProgressUpdatePayload { scanned_height, total_height, progress, + is_initial_scan_complete, }, }; if let Err(e) = Self::get_app_handle() @@ -728,18 +731,6 @@ impl EventsEmitter { error!(target: LOG_TARGET_APP_LOGIC, "Failed to emit SeedBackedUp event: {e:?}"); } } - - pub async fn emit_wallet_status_updated(loading: bool, unhealthy: Option) { - let _ = FrontendReadyChannel::current().wait_for_ready().await; - let evt = Event { - event_type: EventType::WalletStatusUpdate, - payload: WalletStatusUpdatePayload { loading, unhealthy }, - }; - if let Err(e) = Self::get_app_handle().await.emit(BACKEND_STATE_UPDATE, evt) { - error!(target: LOG_TARGET_APP_LOGIC, "Failed to emit WalletStatusUpdate event: {e:?}"); - } - } - pub async fn emit_update_cpu_miner_state(state: MinerControlsState) { let _unused = FrontendReadyChannel::current().wait_for_ready().await; if let Err(e) = Self::get_app_handle().await.emit( @@ -842,4 +833,86 @@ impl EventsEmitter { error!(target: LOG_TARGET_APP_LOGIC, "Failed to emit ShowBatteryAlert event: {e:?}"); } } + + pub async fn emit_wallet_transactions_found(payload: Vec) { + let _ = FrontendReadyChannel::current().wait_for_ready().await; + if let Err(e) = Self::get_app_handle().await.emit( + BACKEND_STATE_UPDATE, + Event { + event_type: EventType::WalletTransactionsFound, + payload, + }, + ) { + error!(target: LOG_TARGET, "Failed to emit WalletTransactionsFound event: {e:?}"); + } + } + + pub async fn emit_wallet_transactions_cleared() { + let _ = FrontendReadyChannel::current().wait_for_ready().await; + if let Err(e) = Self::get_app_handle().await.emit( + BACKEND_STATE_UPDATE, + Event { + event_type: EventType::WalletTransactionsCleared, + payload: (), + }, + ) { + error!(target: LOG_TARGET, "Failed to emit WalletTransactionsCleared event: {e:?}"); + } + } + + /// Emit when a pending transaction has been matched with a scanned transaction + /// This allows the frontend to update the transaction status + pub async fn emit_wallet_transaction_updated(payload: DisplayedTransaction) { + let _ = FrontendReadyChannel::current().wait_for_ready().await; + if let Err(e) = Self::get_app_handle().await.emit( + BACKEND_STATE_UPDATE, + Event { + event_type: EventType::WalletTransactionUpdated, + payload, + }, + ) { + error!(target: LOG_TARGET, "Failed to emit WalletTransactionUpdated event: {e:?}"); + } + } + + pub async fn emit_wallet_transactions_found(payload: Vec) { + let _ = FrontendReadyChannel::current().wait_for_ready().await; + if let Err(e) = Self::get_app_handle().await.emit( + BACKEND_STATE_UPDATE, + Event { + event_type: EventType::WalletTransactionsFound, + payload, + }, + ) { + error!(target: LOG_TARGET, "Failed to emit WalletTransactionsFound event: {e:?}"); + } + } + + pub async fn emit_wallet_transactions_cleared() { + let _ = FrontendReadyChannel::current().wait_for_ready().await; + if let Err(e) = Self::get_app_handle().await.emit( + BACKEND_STATE_UPDATE, + Event { + event_type: EventType::WalletTransactionsCleared, + payload: (), + }, + ) { + error!(target: LOG_TARGET, "Failed to emit WalletTransactionsCleared event: {e:?}"); + } + } + + /// Emit when a pending transaction has been matched with a scanned transaction + /// This allows the frontend to update the transaction status + pub async fn emit_wallet_transaction_updated(payload: DisplayedTransaction) { + let _ = FrontendReadyChannel::current().wait_for_ready().await; + if let Err(e) = Self::get_app_handle().await.emit( + BACKEND_STATE_UPDATE, + Event { + event_type: EventType::WalletTransactionUpdated, + payload, + }, + ) { + error!(target: LOG_TARGET, "Failed to emit WalletTransactionUpdated event: {e:?}"); + } + } } diff --git a/src-tauri/src/events_manager.rs b/src-tauri/src/events_manager.rs index 134bd56170..db75a00821 100644 --- a/src-tauri/src/events_manager.rs +++ b/src-tauri/src/events_manager.rs @@ -20,15 +20,15 @@ // WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE // USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -use std::time::Duration; +// use std::time::Duration; -use log::{error, info}; -use tari_transaction_components::tari_amount::MicroMinotari; +use log::info; +// use tari_transaction_components::tari_amount::MicroMinotari; use tauri::{AppHandle, Manager}; -use crate::airdrop::send_new_block_mined; -use crate::configs::config_core::ConfigCore; -use crate::configs::trait_config::ConfigImpl; +// use crate::airdrop::send_new_block_mined; +// use crate::configs::config_core::ConfigCore; +// use crate::configs::trait_config::ConfigImpl; use crate::setup::listeners::SetupFeature; use crate::setup::setup_manager::SetupManager; use crate::LOG_TARGET_APP_LOGIC; @@ -54,53 +54,57 @@ impl EventsManager { return; } drop(in_memory_config); - let app_clone = app.clone(); - let wallet_manager = state.wallet_manager.clone(); + // let app_clone = app.clone(); + // let wallet_manager = state.wallet_manager.clone(); - TasksTrackers::current().wallet_phase.get_task_tracker().await.spawn(async move { - // Event does not need to be fired immediately since frontend uses block height from explorer - match wallet_manager.wait_for_scan_to_height(block_height, Some(Duration::from_secs(20))).await { - Ok(scanned_wallet_state) => { - if let Some(balance) = scanned_wallet_state.balance { - EventsEmitter::emit_wallet_balance_update(balance.clone()).await; - // Check for coinbase transaction if there's pending balance - let coinbase_tx = if balance.pending_incoming_balance.gt(&MicroMinotari::zero()) { - wallet_manager.find_coinbase_transaction_for_block(block_height).await.unwrap_or_else(|e| { - error!(target: LOG_TARGET_APP_LOGIC, "Failed to get coinbase transaction: {e:?}"); - None - }) - } else { - None - }; + TasksTrackers::current() + .wallet_phase + .get_task_tracker() + .await + .spawn(async move { + // Event does not need to be fired immediately since frontend uses block height from explorer + // match wallet_manager.wait_for_scan_to_height(block_height, Some(Duration::from_secs(20))).await { + // Ok(scanned_wallet_state) => { + // if let Some(balance) = scanned_wallet_state.balance { + // EventsEmitter::emit_wallet_balance_update(balance.clone()).await; + // // Check for coinbase transaction if there's pending balance + // let coinbase_tx = if balance.pending_incoming_balance.gt(&MicroMinotari::zero()) { + // wallet_manager.find_coinbase_transaction_for_block(block_height).await.unwrap_or_else(|e| { + // error!(target: LOG_TARGET_APP_LOGIC, "Failed to get coinbase transaction: {e:?}"); + // None + // }) + // } else { + // None + // }; - EventsEmitter::emit_new_block_mined( - block_height, - coinbase_tx.clone(), - ) - .await; - let allow_notifications = *ConfigCore::content().await.allow_notifications(); - if coinbase_tx.is_some() && allow_notifications { - send_new_block_mined(app_clone.clone(), block_height).await; - } - } else { - error!(target: LOG_TARGET_APP_LOGIC, "Wallet balance is None after new block height #{block_height}"); - EventsEmitter::emit_new_block_mined( - block_height, - None, - ) - .await; - } - }, - Err(e) => { - error!(target: LOG_TARGET_APP_LOGIC, "Error waiting for wallet scan: {e}"); - EventsEmitter::emit_new_block_mined( - block_height, - None, - ) - .await; - } - } - }); + // EventsEmitter::emit_new_block_mined( + // block_height, + // coinbase_tx.clone(), + // ) + // .await; + // let allow_notifications = *ConfigCore::content().await.allow_notifications(); + // if coinbase_tx.is_some() && allow_notifications { + // send_new_block_mined(app_clone.clone(), block_height).await; + // } + // } else { + // error!(target: LOG_TARGET_APP_LOGIC, "Wallet balance is None after new block height #{block_height}"); + // EventsEmitter::emit_new_block_mined( + // block_height, + // None, + // ) + // .await; + // } + // }, + // Err(e) => { + // error!(target: LOG_TARGET_APP_LOGIC, "Error waiting for wallet scan: {e}"); + // EventsEmitter::emit_new_block_mined( + // block_height, + // None, + // ) + // .await; + // } + // } + }); } pub async fn handle_node_type_update(app_handle: &AppHandle) { diff --git a/src-tauri/src/internal_wallet.rs b/src-tauri/src/internal_wallet.rs index 8fb5f58513..6b9833a86c 100644 --- a/src-tauri/src/internal_wallet.rs +++ b/src-tauri/src/internal_wallet.rs @@ -32,12 +32,10 @@ use tari_common_types::seeds::cipher_seed::CipherSeed; use tari_common_types::seeds::mnemonic::Mnemonic; use tari_common_types::seeds::seed_words::SeedWords; use tari_common_types::tari_address::{TariAddress, TariAddressFeatures}; -use tari_common_types::types::CompressedPublicKey; -use tari_transaction_components::key_manager::memory_key_manager::create_memory_key_manager_from_seed; -use tari_transaction_components::key_manager::tari_key_manager::TariKeyManager; +use tari_transaction_components::key_manager::wallet_types::{SeedWordsWallet, WalletType}; +use tari_transaction_components::key_manager::KeyManager; use tari_transaction_components::key_manager::{ - KeyDigest, KeyManagerBranch, SecretTransactionKeyManagerInterface, - TransactionKeyManagerInterface, + SecretTransactionKeyManagerInterface, TransactionKeyManagerInterface, }; use tari_utilities::encoding::MBase58; use tari_utilities::message_format::MessageFormat; @@ -62,6 +60,7 @@ use crate::mining::pools::gpu_pool_manager::GpuPoolManager; use crate::mining::pools::PoolManagerInterfaceTrait; use crate::pin::PinManager; use crate::utils::{cryptography, rand_utils}; +use crate::wallet::minotari_wallet::MinotariWalletManager; use crate::{UniverseAppState, LOG_TARGET_APP_LOGIC}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -251,7 +250,7 @@ impl InternalWallet { } } else { // Create new wallet - let tari_seed = CipherSeed::new(); + let tari_seed = CipherSeed::random(); let (tari_wallet_details, tari_seed_binary) = InternalWallet::add_tari_wallet(app_handle, tari_seed, None).await?; @@ -294,6 +293,11 @@ impl InternalWallet { // External(Seedless) } + MinotariWalletManager::handle_side_effects_after_wallet_import( + self.tari_address_type.clone(), + ) + .await?; + ConfigUI::handle_wallet_type_update(self.tari_address_type.clone()).await?; EventsEmitter::emit_selected_tari_address_changed( self.extract_tari_address(), @@ -752,32 +756,56 @@ impl InternalWallet { Ok((tari_wallet_details.id, tari_seed_binary, monero_seed_binary)) } + pub async fn get_key_manager(app_handle: &AppHandle) -> Result { + let tari_wallet_details = Self::tari_wallet_details().await; + + if let Some(details) = tari_wallet_details { + let tari_seed_binary = + match InternalWallet::get_credentials(app_handle, details.id.clone(), true).await { + Ok(cred) => cred.encrypted_seed, + Err(e) => { + panic!("Failed to get credentials: {e}") + } + }; + let tari_cipher_seed = CipherSeed::from_binary(&tari_seed_binary) + .expect("Could not convert Tari Seed to binary"); + + let seed_words_wallet = SeedWordsWallet::construct_new(tari_cipher_seed.clone()) + .map_err(|e| anyhow!(e.to_string()))?; + + let tx_key_manager = KeyManager::new(WalletType::SeedWords(seed_words_wallet)) + .map_err(|e| anyhow!(e.to_string()))?; + + Ok(tx_key_manager) + } else { + Err(anyhow!( + "Seedless Wallet does not support Key Manager extraction" + )) + } + } + pub async fn get_tari_wallet_details( wallet_id: WalletId, tari_cipher_seed: CipherSeed, ) -> Result { let wallet_birthday = tari_cipher_seed.birthday(); - let comms_key_manager = TariKeyManager::::from( - tari_cipher_seed.clone(), - KeyManagerBranch::Comms.get_branch_key(), - 0, - ); - let comms_key = comms_key_manager - .derive_key(0) - .map_err(|e| anyhow!(e.to_string()))? - .key; - - let comms_pub_key = CompressedPublicKey::from_secret_key(&comms_key); - let tx_key_manager = create_memory_key_manager_from_seed(tari_cipher_seed, 64).await?; - let view_key = tx_key_manager.get_view_key().await?; - let view_key_private = tx_key_manager.get_private_key(&view_key.key_id).await?; + let seed_words_wallet = SeedWordsWallet::construct_new(tari_cipher_seed.clone()) + .map_err(|e| anyhow!(e.to_string()))?; + + let tx_key_manager = KeyManager::new(WalletType::SeedWords(seed_words_wallet)) + .map_err(|e| anyhow!(e.to_string()))?; + let view_key = tx_key_manager.get_view_key(); + let view_key_private = tx_key_manager + .get_private_key(&view_key.key_id) + .map_err(|e| anyhow!(e.to_string()))?; let view_key_public = view_key.pub_key; + let spend_key = tx_key_manager.get_spend_key().pub_key; let network = Network::default(); let tari_address = TariAddress::new_dual_address( view_key_public.clone(), - comms_pub_key.clone(), + spend_key.clone(), network, TariAddressFeatures::create_one_sided_only(), None, @@ -788,7 +816,7 @@ impl InternalWallet { id: wallet_id, tari_address, wallet_birthday, - spend_public_key_hex: comms_pub_key.to_hex(), + spend_public_key_hex: spend_key.to_hex(), view_private_key_hex: view_key_private.to_hex(), }) } @@ -939,7 +967,7 @@ impl InternalWallet { // ** Utils ** -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[repr(u8)] pub enum TariAddressType { Internal = 0, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 30f45ba10a..605e4c5f36 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -515,7 +515,6 @@ fn main() { commands::get_paper_wallet_details, commands::get_seed_words, commands::get_tor_config, - commands::get_transactions, commands::import_seed_words, commands::revert_to_internal_wallet, commands::log_web_message, diff --git a/src-tauri/src/progress_trackers/progress_plans.rs b/src-tauri/src/progress_trackers/progress_plans.rs index db2620031e..5c0849597a 100644 --- a/src-tauri/src/progress_trackers/progress_plans.rs +++ b/src-tauri/src/progress_trackers/progress_plans.rs @@ -48,6 +48,7 @@ pub enum SetupStep { // Wallet Phase StartWallet, SetupBridge, + MinotariWallet, } impl SetupStep { @@ -81,6 +82,7 @@ impl SetupStep { Self::BinariesWallet => "binaries-wallet".to_string(), Self::StartWallet => "start-wallet".to_string(), Self::SetupBridge => "setup-bridge".to_string(), + Self::MinotariWallet => "minotari-wallet".to_string(), } } @@ -114,7 +116,8 @@ impl SetupStep { // Wallet Phase 20 Self::BinariesWallet => 10, Self::StartWallet => 5, - Self::SetupBridge => 5, + Self::SetupBridge => 1, + Self::MinotariWallet => 4, } } } diff --git a/src-tauri/src/setup/phase_node.rs b/src-tauri/src/setup/phase_node.rs index 656ea23175..ae0c00034b 100644 --- a/src-tauri/src/setup/phase_node.rs +++ b/src-tauri/src/setup/phase_node.rs @@ -34,6 +34,7 @@ use crate::{ }, setup::setup_manager::SetupPhase, tasks_tracker::TasksTrackers, + wallet::minotari_wallet::MinotariWalletManager, UniverseAppState, LOG_TARGET_APP_LOGIC, }; use anyhow::Error; @@ -287,17 +288,17 @@ impl SetupPhaseImpl for NodeSetupPhase { tokio::select! { _ = node_status_watch_rx.changed() => { let node_status = *node_status_watch_rx.borrow(); - let initial_sync_finished = app_state.wallet_manager.is_initial_scan_completed(); + let is_syncing = MinotariWalletManager::is_syncing().await; let node_synced = node_status.is_synced; - if node_status.block_height > latest_updated_block_height && initial_sync_finished && node_synced { + if node_status.block_height > latest_updated_block_height && !is_syncing && node_synced { while latest_updated_block_height < node_status.block_height { latest_updated_block_height += 1; let _ = EventsManager::handle_new_block_height(&app_handle_clone, latest_updated_block_height).await; } } EventsEmitter::emit_base_node_update(node_status).await; - if node_status.block_height > latest_updated_block_height && !initial_sync_finished { + if node_status.block_height > latest_updated_block_height && is_syncing { latest_updated_block_height = node_status.block_height; } }, diff --git a/src-tauri/src/setup/phase_wallet.rs b/src-tauri/src/setup/phase_wallet.rs index 80a5a8db33..21baef8862 100644 --- a/src-tauri/src/setup/phase_wallet.rs +++ b/src-tauri/src/setup/phase_wallet.rs @@ -25,6 +25,10 @@ use super::{ trait_setup_phase::{SetupConfiguration, SetupPhaseImpl}, utils::{setup_default_adapter::SetupDefaultAdapter, timeout_watcher::TimeoutWatcher}, }; +use crate::wallet::{ + minotari_wallet::MinotariWalletManager, + wallet_manager::{WalletManagerError, STOP_ON_ERROR_CODES}, +}; use crate::{ binaries::{Binaries, BinaryResolver}, configs::{ @@ -49,7 +53,7 @@ use crate::{ LOG_TARGET_APP_LOGIC, }; use anyhow::Error; -use log::{error, warn}; +use log::{error, info, warn}; use tari_shutdown::ShutdownSignal; use tauri::{AppHandle, Manager}; use tokio::sync::{ @@ -139,6 +143,7 @@ impl SetupPhaseImpl for WalletSetupPhase { ProgressStepperBuilder::new() .add_incremental_step(SetupStep::BinariesWallet, true) .add_step(SetupStep::StartWallet, true) + .add_step(SetupStep::MinotariWallet, true) .add_incremental_step(SetupStep::SetupBridge, false) .build( app_handle, @@ -232,6 +237,22 @@ impl SetupPhaseImpl for WalletSetupPhase { }) .await?; + let app_handle_clone = self.get_app_handle().clone(); + progress_stepper + .complete_step(SetupStep::MinotariWallet, || async { + MinotariWalletManager::load_app_handle(app_handle_clone).await; + if InternalWallet::is_internal().await { + MinotariWalletManager::initialize_wallet().await?; + info!(target: LOG_TARGET, "============================ Setting up Minotari Wallet"); + let _unused = MinotariWalletManager::import_view_key().await; + info!(target: LOG_TARGET, "============================ Scanning blocks for Minotari Wallet"); + MinotariWalletManager::initialize_blockchain_scanning().await?; + } + + Ok(()) + }) + .await?; + let bridge_binary_progress_tracker = progress_stepper.track_step_incrementally(SetupStep::SetupBridge); @@ -256,15 +277,6 @@ impl SetupPhaseImpl for WalletSetupPhase { .send(PhaseStatus::SuccessWithWarnings(setup_warnings.clone()))?; } - let app_state = self.get_app_handle().state::().clone(); - let node_status_watch_rx = (*app_state.node_status_watch_rx).clone(); - if InternalWallet::is_internal().await { - app_state - .wallet_manager - .wait_for_initial_wallet_scan(node_status_watch_rx) - .await?; - } - let config_wallet = ConfigWallet::content().await; let is_pin_locked = PinManager::pin_locked().await; EventsEmitter::emit_pin_locked(is_pin_locked).await; diff --git a/src-tauri/src/wallet/minotari_wallet/balance_tracker/balance_calculator.rs b/src-tauri/src/wallet/minotari_wallet/balance_tracker/balance_calculator.rs new file mode 100644 index 0000000000..a4326e1a60 --- /dev/null +++ b/src-tauri/src/wallet/minotari_wallet/balance_tracker/balance_calculator.rs @@ -0,0 +1,175 @@ +// Copyright 2025. The Tari Project +// +// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +// following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following +// disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the +// following disclaimer in the documentation and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote +// products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +use super::errors::BalanceCalculationError; + +pub struct BalanceCalculator; + +impl BalanceCalculator { + pub fn calculate_new_balance( + current_balance: u64, + incoming_balance: u64, + outgoing_balance: u64, + ) -> Result { + let current = current_balance; + + let new_balance = + current + .checked_add(incoming_balance) + .ok_or(BalanceCalculationError::Overflow { + current: current_balance, + credit: incoming_balance, + })?; + + let new_balance = new_balance.checked_sub(outgoing_balance).ok_or( + BalanceCalculationError::Underflow { + current: current_balance, + debit: outgoing_balance, + }, + )?; + Ok(new_balance) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calculate_new_balance_happy_path() { + // Simple addition and subtraction + let result = BalanceCalculator::calculate_new_balance(100, 50, 30); + assert_eq!(result.unwrap(), 120); + + // Zero incoming + let result = BalanceCalculator::calculate_new_balance(100, 0, 30); + assert_eq!(result.unwrap(), 70); + + // Zero outgoing + let result = BalanceCalculator::calculate_new_balance(100, 50, 0); + assert_eq!(result.unwrap(), 150); + + // Both zero + let result = BalanceCalculator::calculate_new_balance(100, 0, 0); + assert_eq!(result.unwrap(), 100); + + // Zero balance with incoming + let result = BalanceCalculator::calculate_new_balance(0, 100, 50); + assert_eq!(result.unwrap(), 50); + + // Large values + let result = BalanceCalculator::calculate_new_balance(1_000_000, 500_000, 300_000); + assert_eq!(result.unwrap(), 1_200_000); + } + + #[test] + fn test_calculate_new_balance_overflow() { + // Adding to u64::MAX causes overflow + let result = BalanceCalculator::calculate_new_balance(u64::MAX, 1, 0); + assert!(result.is_err()); + match result.unwrap_err() { + BalanceCalculationError::Overflow { current, credit } => { + assert_eq!(current, u64::MAX); + assert_eq!(credit, 1); + } + _ => panic!("Expected Overflow error"), + } + + // Large incoming causing overflow + let result = BalanceCalculator::calculate_new_balance(u64::MAX - 10, 20, 0); + assert!(result.is_err()); + match result.unwrap_err() { + BalanceCalculationError::Overflow { current, credit } => { + assert_eq!(current, u64::MAX - 10); + assert_eq!(credit, 20); + } + _ => panic!("Expected Overflow error"), + } + + // Overflow even though outgoing would bring it back down + let result = BalanceCalculator::calculate_new_balance(u64::MAX, 100, 100); + assert!(result.is_err()); + match result.unwrap_err() { + BalanceCalculationError::Overflow { .. } => {} + _ => panic!("Expected Overflow error"), + } + } + + #[test] + fn test_calculate_new_balance_underflow() { + // Subtracting more than available + let result = BalanceCalculator::calculate_new_balance(100, 0, 101); + assert!(result.is_err()); + match result.unwrap_err() { + BalanceCalculationError::Underflow { current, debit } => { + assert_eq!(current, 100); + assert_eq!(debit, 101); + } + _ => panic!("Expected Underflow error"), + } + + // Zero balance with outgoing + let result = BalanceCalculator::calculate_new_balance(0, 0, 1); + assert!(result.is_err()); + match result.unwrap_err() { + BalanceCalculationError::Underflow { current, debit } => { + assert_eq!(current, 0); + assert_eq!(debit, 1); + } + _ => panic!("Expected Underflow error"), + } + + // Incoming + current < outgoing + let result = BalanceCalculator::calculate_new_balance(100, 50, 200); + assert!(result.is_err()); + match result.unwrap_err() { + BalanceCalculationError::Underflow { current, debit } => { + assert_eq!(current, 100); + assert_eq!(debit, 200); + } + _ => panic!("Expected Underflow error"), + } + + // Large outgoing value + let result = BalanceCalculator::calculate_new_balance(1000, 500, u64::MAX); + assert!(result.is_err()); + match result.unwrap_err() { + BalanceCalculationError::Underflow { .. } => {} + _ => panic!("Expected Underflow error"), + } + } + + #[test] + fn test_calculate_new_balance_edge_cases() { + // Max balance minus max outgoing = 0 + let result = BalanceCalculator::calculate_new_balance(u64::MAX, 0, u64::MAX); + assert_eq!(result.unwrap(), 0); + + // Zero balance with equal incoming and outgoing + let result = BalanceCalculator::calculate_new_balance(0, 100, 100); + assert_eq!(result.unwrap(), 0); + + // All max values should underflow (overflow first, actually) + let result = BalanceCalculator::calculate_new_balance(u64::MAX, u64::MAX, u64::MAX); + assert!(result.is_err()); + } +} diff --git a/src-tauri/src/wallet/minotari_wallet/balance_tracker/errors.rs b/src-tauri/src/wallet/minotari_wallet/balance_tracker/errors.rs new file mode 100644 index 0000000000..fe35e3e774 --- /dev/null +++ b/src-tauri/src/wallet/minotari_wallet/balance_tracker/errors.rs @@ -0,0 +1,31 @@ +// Copyright 2025. The Tari Project +// +// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +// following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following +// disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the +// following disclaimer in the documentation and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote +// products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum BalanceCalculationError { + #[error("Balance overflow: current={current}, credit={credit}")] + Overflow { current: u64, credit: u64 }, + #[error("Balance underflow: current={current}, debit={debit}")] + Underflow { current: u64, debit: u64 }, +} diff --git a/src-tauri/src/wallet/minotari_wallet/balance_tracker/mod.rs b/src-tauri/src/wallet/minotari_wallet/balance_tracker/mod.rs new file mode 100644 index 0000000000..b0e7fb5567 --- /dev/null +++ b/src-tauri/src/wallet/minotari_wallet/balance_tracker/mod.rs @@ -0,0 +1,143 @@ +// Copyright 2025. The Tari Project +// +// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +// following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following +// disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the +// following disclaimer in the documentation and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote +// products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +mod balance_calculator; +mod errors; + +pub use balance_calculator::BalanceCalculator; +pub use errors::BalanceCalculationError; + +use log::{error, info}; +use minotari_wallet::{db::AccountBalance, DisplayedTransaction}; +use std::sync::LazyLock; +use tokio::sync::RwLock; + +use crate::events_emitter::EventsEmitter; +use crate::wallet::wallet_types::WalletBalance; +use tari_transaction_components::tari_amount::MicroMinotari; + +const LOG_TARGET: &str = "tari::universe::wallet::balance_tracker"; + +static INSTANCE: LazyLock = LazyLock::new(BalanceTracker::new); + +pub struct BalanceTracker { + current_balance: RwLock, +} + +impl BalanceTracker { + pub fn new() -> Self { + Self { + current_balance: RwLock::new(0), + } + } + + /// Get the singleton instance + pub fn current() -> &'static BalanceTracker { + &INSTANCE + } + + /// Initialize balance from database AccountBalance + pub async fn initialize_from_account_balance(&self, account_balance: AccountBalance) { + // Calculate available balance from total_credits - total_debits + let credits = account_balance.total_credits.unwrap_or(0) as u64; + let debits = account_balance.total_debits.unwrap_or(0) as u64; + let balance = credits.saturating_sub(debits); + + let mut current = self.current_balance.write().await; + *current = balance; + + info!( + target: LOG_TARGET, + "Balance initialized to {} microTari (credits: {}, debits: {})", balance, credits, debits + ); + + // Emit initial balance to frontend + Self::emit_balance(balance).await; + } + + /// Clear balance (e.g., on wallet import) + pub async fn clear(&self) { + let mut current = self.current_balance.write().await; + *current = 0; + + info!(target: LOG_TARGET, "Balance cleared"); + + Self::emit_balance(0).await; + } + + /// Get current balance + pub async fn get_balance(&self) -> u64 { + *self.current_balance.read().await + } + + /// Update balance based on a list of new transactions + /// This calculates the net change from transactions and applies it + pub async fn update_from_transactions(&self, transactions: &[DisplayedTransaction]) { + if transactions.is_empty() { + return; + } + + let mut total_credit: u64 = 0; + let mut total_debit: u64 = 0; + + for tx in transactions { + // Use details.total_credit and details.total_debit from DisplayedTransaction + total_credit = total_credit.saturating_add(tx.details.total_credit); + total_debit = total_debit.saturating_add(tx.details.total_debit); + } + + let current = self.get_balance().await; + + match BalanceCalculator::calculate_new_balance(current, total_credit, total_debit) { + Ok(new_balance) => { + let mut balance = self.current_balance.write().await; + *balance = new_balance; + + info!( + target: LOG_TARGET, + "Balance updated: {} -> {} (credit: +{}, debit: -{})", + current, new_balance, total_credit, total_debit + ); + + Self::emit_balance(new_balance).await; + } + Err(e) => { + error!( + target: LOG_TARGET, + "Failed to calculate new balance: {:?}", e + ); + } + } + } + + /// Emit balance update to frontend + async fn emit_balance(balance: u64) { + let wallet_balance = WalletBalance { + available_balance: MicroMinotari(balance), + pending_incoming_balance: MicroMinotari(0), + pending_outgoing_balance: MicroMinotari(0), + timelocked_balance: MicroMinotari(0), + }; + + EventsEmitter::emit_wallet_balance_update(wallet_balance).await; + } +} diff --git a/src-tauri/src/wallet/minotari_wallet/database_manager.rs b/src-tauri/src/wallet/minotari_wallet/database_manager.rs new file mode 100644 index 0000000000..c03b6de58c --- /dev/null +++ b/src-tauri/src/wallet/minotari_wallet/database_manager.rs @@ -0,0 +1,179 @@ +// Copyright 2025. The Tari Project +// +// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +// following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following +// disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the +// following disclaimer in the documentation and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote +// products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +use crate::{tasks_tracker::TasksTrackers, APPLICATION_FOLDER_ID}; +use log::{error, info, warn}; +use minotari_wallet::init_db; +use sqlx::{Pool, Sqlite}; +use tari_common::configuration::Network; +use tokio::sync::RwLock; + +static LOG_TARGET: &str = "tari::universe::wallet::minotari_wallet::database_manager"; + +const CONNECTION_HEALTH_CHECK_INTERVAL_SECS: u64 = 60; +const CONNECTION_RETRY_MAX_ATTEMPTS: usize = 3; +const CONNECTION_RETRY_DELAY_MS: u64 = 1000; + +pub const DEFAULT_ACCOUNT_ID: i64 = 1; + +pub struct MinotariWalletDatabaseManager { + database_pool: RwLock>>, +} + +impl MinotariWalletDatabaseManager { + pub fn new() -> Self { + Self { + database_pool: RwLock::new(None), + } + } + + pub async fn initialize(&self, database_path: &str) -> Result<(), anyhow::Error> { + let pool = init_db(database_path).await?; + let mut pool_lock = self.database_pool.write().await; + *pool_lock = Some(pool); + Ok(()) + } + + pub fn database_path() -> Result { + let data_directory_path = + dirs::data_dir().ok_or_else(|| anyhow::anyhow!("Failed to get cache directory"))?; + + let binary_folder_path = data_directory_path + .join(APPLICATION_FOLDER_ID) + .join("minotari-wallet") + .join( + Network::get_current_or_user_setting_or_default() + .to_string() + .to_lowercase(), + ) + .join("wallet.db"); + + if let Some(string_path) = binary_folder_path.to_str() { + Ok(string_path.to_string()) + } else { + Err(anyhow::anyhow!("Failed to convert database path to string")) + } + } + + pub async fn get_pool(&self) -> Result, anyhow::Error> { + let pool_lock = self.database_pool.read().await; + let pool = pool_lock + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Database pool not initialized"))?; + Ok(pool.clone()) + } + + pub async fn get_connection( + &self, + ) -> Result, anyhow::Error> { + let pool_lock = self.database_pool.read().await; + let pool = pool_lock + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Database pool not initialized"))?; + + for attempt in 1..=CONNECTION_RETRY_MAX_ATTEMPTS { + match pool.acquire().await { + Ok(conn) => { + log::debug!(target: LOG_TARGET, "Database connection acquired"); + return Ok(conn); + } + Err(e) => { + warn!( + target: LOG_TARGET, + "Failed to acquire database connection (attempt {}/{}): {}", + attempt, CONNECTION_RETRY_MAX_ATTEMPTS, e + ); + if attempt < CONNECTION_RETRY_MAX_ATTEMPTS { + tokio::time::sleep(tokio::time::Duration::from_millis( + CONNECTION_RETRY_DELAY_MS, + )) + .await; + } else { + return Err(anyhow::anyhow!( + "Failed to acquire database connection after {} attempts", + CONNECTION_RETRY_MAX_ATTEMPTS + )); + } + } + } + } + + Err(anyhow::anyhow!("Failed to acquire database connection")) + } + + pub async fn start_health_check(&self) { + let mut shutdown_signal = TasksTrackers::current().wallet_phase.get_signal().await; + + let pool_lock = self.database_pool.read().await; + let pool_clone = pool_lock.clone(); + drop(pool_lock); + + TasksTrackers::current() + .wallet_phase + .get_task_tracker() + .await + .spawn(async move { + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs( + CONNECTION_HEALTH_CHECK_INTERVAL_SECS, + )); + loop { + tokio::select! { + _ = interval.tick() => { + if let Some(pool) = &pool_clone { + match pool.acquire().await { + Ok(_) => { + log::debug!(target: LOG_TARGET, "Database connection health check passed"); + } + Err(e) => { + error!( + target: LOG_TARGET, + "Database connection health check failed: {}", e + ); + warn!(target: LOG_TARGET, "Database connection unhealthy: {}", e); + } + } + } + } + _ = shutdown_signal.wait() => { + info!(target: LOG_TARGET, "Shutdown signal received. Terminating health check."); + break; + } + } + } + }); + } + + pub async fn get_account_by_name( + &self, + friendly_name: &str, + ) -> Result, anyhow::Error> { + let mut conn = self.get_connection().await?; + let account = minotari_wallet::db::get_account_by_name(&mut conn, friendly_name).await?; + Ok(account) + } +} + +impl Default for MinotariWalletDatabaseManager { + fn default() -> Self { + Self::new() + } +} diff --git a/src-tauri/src/wallet/minotari_wallet/mod.rs b/src-tauri/src/wallet/minotari_wallet/mod.rs new file mode 100644 index 0000000000..134c827334 --- /dev/null +++ b/src-tauri/src/wallet/minotari_wallet/mod.rs @@ -0,0 +1,715 @@ +// Copyright 2025. The Tari Project +// +// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +// following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following +// disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the +// following disclaimer in the documentation and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote +// products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +pub mod balance_tracker; +pub mod database_manager; +pub mod transaction; + +use crate::{ + events_emitter::EventsEmitter, + internal_wallet::{InternalWallet, TariAddressType}, + tasks_tracker::TasksTrackers, + wallet::minotari_wallet::{ + balance_tracker::BalanceTracker, + database_manager::{MinotariWalletDatabaseManager, DEFAULT_ACCOUNT_ID}, + transaction::TransactionManager, + }, + UniverseAppState, +}; +use log::{error, info}; +use minotari_wallet::{ + db::{ + get_all_balance_changes_by_account_id, get_latest_scanned_tip_block_by_account, + AccountBalance, + }, + get_balance, + models::BalanceChange, + transactions::one_sided_transaction::Recipient, + utils::init_with_view_key, + DisplayedTransaction, ProcessingEvent, ScanMode, ScanStatusEvent, Scanner, + TransactionHistoryService, +}; +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicBool, Ordering}, + LazyLock, + }, + time::Duration, +}; +use tari_common::configuration::Network; +use tari_common_types::tari_address::TariAddress; +use tauri::{AppHandle, Manager}; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; +static LOG_TARGET: &str = "tari::universe::wallet::minotari_wallet_manager"; + +static INSTANCE: LazyLock = LazyLock::new(MinotariWalletManager::new); + +static DEFAULT_GRPC_URL: LazyLock = LazyLock::new(|| { + let network = Network::get_current_or_user_setting_or_default(); + let http_api_url = match network { + Network::MainNet => "https://rpc.tari.com", + Network::StageNet => "https://rpc.stagenet.tari.com", + Network::NextNet => "https://rpc.nextnet.tari.com", + Network::LocalNet => "https://rpc.localnet.tari.com", + Network::Igor => "https://rpc.igor.tari.com", + Network::Esmeralda => "https://rpc.esmeralda.tari.com", + }; + http_api_url.to_string() +}); +static DEFAULT_PASSWORD: &str = "test_password"; + +// Blockchain scanning constants +const SCAN_BATCH_SIZE: u64 = 50; +const SCAN_POLL_INTERVAL_SECS: u64 = 30; + +pub struct MinotariWalletManager { + database_manager: MinotariWalletDatabaseManager, + app_handle: RwLock>, + cancel_token: RwLock>, + // ============== |Unified Wallet State| ============== + last_scanned_height: RwLock, + owner_tari_address: RwLock>, + /// Indicates if initial sync is complete (first Completed event received) + initial_sync_complete: AtomicBool, + /// Stores pending transactions by their sent_output_hashes for matching with scanned transactions + /// Key: comma-separated sorted output hashes, Value: DisplayedTransaction + pending_transactions: RwLock>, +} + +impl MinotariWalletManager { + pub fn new() -> Self { + Self { + database_manager: MinotariWalletDatabaseManager::new(), + app_handle: RwLock::new(None), + cancel_token: RwLock::new(None), + owner_tari_address: RwLock::new(None), + last_scanned_height: RwLock::new(0), + initial_sync_complete: AtomicBool::new(false), + pending_transactions: RwLock::new(HashMap::new()), + } + } + + /// Returns true if wallet is currently performing initial sync (catching up to chain tip). + /// Returns false once the first sync completes and we're just polling for new blocks. + pub async fn is_syncing() -> bool { + let scan_running = INSTANCE.cancel_token.read().await.is_some(); + let initial_complete = INSTANCE.initial_sync_complete.load(Ordering::SeqCst); + + // We're syncing if scan is running AND initial sync hasn't completed yet + scan_running && !initial_complete + } + + pub async fn load_app_handle(app_handle: AppHandle) { + let mut handle_lock = INSTANCE.app_handle.write().await; + *handle_lock = Some(app_handle); + } + + /// Initialize and cache the owner Tari address + async fn init_owner_address() -> Result<(), anyhow::Error> { + let address = InternalWallet::tari_address().await.to_base58(); + let mut owner_address_lock = INSTANCE.owner_tari_address.write().await; + *owner_address_lock = Some(address); + Ok(()) + } + + pub async fn update_owner_address(new_address: &str) -> Result<(), anyhow::Error> { + let mut owner_address_lock = INSTANCE.owner_tari_address.write().await; + *owner_address_lock = Some(new_address.to_string()); + Ok(()) + } + + pub async fn send_one_sided_transaction( + address: String, + amount: u64, + payment_id: Option, + ) -> Result { + println!( + "Sending one-sided transaction to address: {}, amount: {}", + address, amount + ); + let tari_address = Self::get_owner_address().await; + let mut transaction_manager = TransactionManager::new( + INSTANCE.database_manager.get_pool().await?, + Self::get_owner_address().await, + ) + .await?; + + let recipient: Recipient = Recipient { + address: TariAddress::from_base58(&address)?, + amount: amount.into(), + payment_id, + }; + + println!( + "Creating one-sided transaction from {} to {} for amount {}", + tari_address, address, amount + ); + let unsigned_one_sided_transaction = transaction_manager + .create_one_sided_transaction(recipient) + .await?; + + println!("Signing one-sided transaction..."); + + let signed_transaction = transaction_manager + .sign_one_sided_transaction( + INSTANCE + .app_handle + .read() + .await + .as_ref() + .ok_or_else(|| anyhow::anyhow!("App handle not set"))?, + unsigned_one_sided_transaction, + ) + .await?; + + println!("Finalizing and broadcasting one-sided transaction..."); + + let displayed_transaction = transaction_manager + .finalize_one_sided_transaction(signed_transaction) + .await?; + + // Store as pending transaction for later matching with scanned transactions + Self::store_pending_transaction(&displayed_transaction).await; + + // Emit to frontend immediately so user sees the pending transaction + EventsEmitter::emit_wallet_transactions_found(vec![displayed_transaction.clone()]).await; + + println!("One-sided transaction sent successfully."); + Ok(displayed_transaction) + } + + /// Create a key from output hashes for pending transaction lookup + fn create_pending_tx_key(output_hashes: &[String]) -> String { + let mut sorted_hashes = output_hashes.to_vec(); + sorted_hashes.sort(); + sorted_hashes.join(",") + } + + /// Store a pending transaction for later matching with scanned transactions + async fn store_pending_transaction(tx: &DisplayedTransaction) { + if tx.details.sent_output_hashes.is_empty() { + return; + } + let key = Self::create_pending_tx_key(&tx.details.sent_output_hashes); + let mut pending = INSTANCE.pending_transactions.write().await; + pending.insert(key, tx.clone()); + info!( + target: LOG_TARGET, + "Stored pending transaction with id: {}", tx.id + ); + } + + /// Try to find and remove a pending transaction that matches the given output hashes + /// Returns the pending transaction if found + async fn match_and_remove_pending_transaction( + output_hashes: &[String], + ) -> Option { + if output_hashes.is_empty() { + return None; + } + let key = Self::create_pending_tx_key(output_hashes); + let mut pending = INSTANCE.pending_transactions.write().await; + let result = pending.remove(&key); + if result.is_some() { + info!( + target: LOG_TARGET, + "Matched and removed pending transaction with key: {}", key + ); + } + result + } + + /// Clear all pending transactions (e.g., on wallet import) + async fn clear_pending_transactions() { + let mut pending = INSTANCE.pending_transactions.write().await; + pending.clear(); + } + + pub async fn handle_side_effects_after_wallet_import( + tari_wallet_type: TariAddressType, + ) -> Result<(), anyhow::Error> { + info!( + target: LOG_TARGET, + "Handling side effects after wallet import for wallet type: {:?}", tari_wallet_type + ); + + if tari_wallet_type == TariAddressType::Internal { + let new_address = InternalWallet::tari_address().await.to_base58(); + Self::update_owner_address(&new_address).await?; + } + + // Clear balance tracker + BalanceTracker::current().clear().await; + + // Clear pending transactions since we're starting fresh with new wallet + Self::clear_pending_transactions().await; + + // Reset initial sync state since we're starting fresh with new wallet + INSTANCE + .initial_sync_complete + .store(false, Ordering::SeqCst); + + info!( + target: LOG_TARGET, + "Wallet import side effects handled. Transactions, balance, and pending transactions cleared." + ); + Ok(()) + } + + /// Get cached owner address or fetch if not cached + async fn get_owner_address() -> String { + let owner_address_lock = INSTANCE.owner_tari_address.read().await; + if let Some(address) = owner_address_lock.as_ref() { + return address.clone(); + } + drop(owner_address_lock); + + // Not cached, initialize it + let _unused = Self::init_owner_address().await; + INSTANCE + .owner_tari_address + .read() + .await + .as_ref() + .cloned() + .unwrap_or_else(|| { + error!(target: LOG_TARGET, "Failed to get owner Tari address"); + String::new() + }) + } + + /// Acquire database connection with retry logic + async fn get_db_connection() -> Result, anyhow::Error> + { + INSTANCE.database_manager.get_connection().await + } + + /// Get the latest scanned tip block for an account + async fn get_latest_scanned_tip_block( + account_id: i64, + ) -> Result, anyhow::Error> { + let mut conn = Self::get_db_connection().await?; + get_latest_scanned_tip_block_by_account(&mut conn, account_id) + .await + .map_err(|e| e.into()) + } + + /// Get balance for an account + async fn get_account_balance(account_id: i64) -> Result { + let mut conn = Self::get_db_connection().await?; + get_balance(&mut conn, account_id) + .await + .map_err(|e| e.into()) + } + + /// Get all balance changes for an account + async fn get_all_balance_changes(account_id: i64) -> Result, anyhow::Error> { + let mut conn = Self::get_db_connection().await?; + get_all_balance_changes_by_account_id(&mut conn, account_id) + .await + .map_err(|e| e.into()) + } + + pub async fn initialize_wallet() -> Result<(), anyhow::Error> { + let database_path = MinotariWalletDatabaseManager::database_path()?; + + // Initialize database + INSTANCE.database_manager.initialize(&database_path).await?; + + // Initialize owner address cache + Self::init_owner_address().await?; + + // Start connection health check + INSTANCE.database_manager.start_health_check().await; + + // ============= | Check latest block height | ============== + + let latest_scanned_block = Self::get_latest_scanned_tip_block(DEFAULT_ACCOUNT_ID).await?; + if let Some(block) = latest_scanned_block { + { + let mut last_scanned_height_lock = INSTANCE.last_scanned_height.write().await; + *last_scanned_height_lock = block.height; + info!( + target: LOG_TARGET, + "Latest scanned tip block height from database: {}", block.height + ); + } + } + + // ============== |Initialize Balance Data| ============== + let balance = Self::get_account_balance(DEFAULT_ACCOUNT_ID).await?; + BalanceTracker::current() + .initialize_from_account_balance(balance) + .await; + + // ============== |Fetch and Process All Balance Changes| ============== + + // Use TransactionHistoryService to load and process transaction history + let db_pool = INSTANCE.database_manager.get_pool().await?; + let history_service = TransactionHistoryService::new(db_pool); + + match history_service + .load_transactions_excluding_reorged(DEFAULT_ACCOUNT_ID) + .await + { + Ok(transactions) => { + info!( + target: LOG_TARGET, + "Loaded {} transactions from history (excluding reorged) via TransactionHistoryService", transactions.len() + ); + + // Emit transactions to frontend + EventsEmitter::emit_wallet_transactions_found(transactions).await; + } + Err(e) => { + error!( + target: LOG_TARGET, + "Failed to load transaction history: {:?}", e + ); + } + } + + Ok(()) + } + + // ───────────────────────────────────────────────────────────────────────────── + // Blockchain Scanning + // ───────────────────────────────────────────────────────────────────────────── + + pub async fn initialize_blockchain_scanning() -> Result<(), anyhow::Error> { + // Check if already running + if INSTANCE.cancel_token.read().await.is_some() { + info!( + target: LOG_TARGET, + "Blockchain scanning already running, skipping initialization." + ); + return Ok(()); + } + + let database_path = MinotariWalletDatabaseManager::database_path()?; + let tari_address = Self::get_owner_address().await; + + info!( + target: LOG_TARGET, + "Starting blockchain scan for Minotari wallet at database path: {}", database_path + ); + + // Create cancellation token + let cancel_token = CancellationToken::new(); + *INSTANCE.cancel_token.write().await = Some(cancel_token.clone()); + + // Get shutdown signal for graceful termination + let mut shutdown_signal = TasksTrackers::current().wallet_phase.get_signal().await; + let cancel_token_for_shutdown = cancel_token.clone(); + let cancel_token_for_scan = cancel_token.clone(); + + // Spawn the scan via spawn_blocking since the Scanner future is !Send + // We create the Scanner inside spawn_blocking to avoid Send issues + let database_path_clone = database_path.clone(); + let tari_address_clone = tari_address.clone(); + tokio::task::spawn_blocking(move || { + tokio::runtime::Handle::current().block_on(async move { + // Build the scanner with continuous mode inside the blocking task + let (event_rx, scan_future) = Scanner::new( + DEFAULT_PASSWORD, + DEFAULT_GRPC_URL.as_str(), + &database_path_clone, + SCAN_BATCH_SIZE, + ) + .account(&tari_address_clone) + .mode(ScanMode::Continuous { + poll_interval: Duration::from_secs(SCAN_POLL_INTERVAL_SECS), + }) + .cancel_token(cancel_token_for_scan.clone()) + .run_with_events(); + + // Process events and run scan concurrently + tokio::select! { + _ = Self::process_scan_events(event_rx) => { + info!(target: LOG_TARGET, "Scan event processing completed."); + } + result = scan_future => { + match result { + Ok(_) => { + info!(target: LOG_TARGET, "Blockchain scan completed successfully."); + } + Err(e) => { + error!(target: LOG_TARGET, "Blockchain scan failed: {:?}", e); + } + } + } + } + + // Ensure token is cancelled when done + cancel_token_for_scan.cancel(); + }); + }); + + // Spawn shutdown listener task + TasksTrackers::current() + .wallet_phase + .get_task_tracker() + .await + .spawn(async move { + shutdown_signal.wait().await; + info!(target: LOG_TARGET, "Shutdown signal received. Cancelling scan."); + cancel_token_for_shutdown.cancel(); + *INSTANCE.cancel_token.write().await = None; + }); + + Ok(()) + } + + pub async fn stop_scanning() { + if let Some(token) = INSTANCE.cancel_token.write().await.take() { + token.cancel(); + // Reset initial sync state when scanning is stopped + INSTANCE + .initial_sync_complete + .store(false, Ordering::SeqCst); + info!(target: LOG_TARGET, "Blockchain scanning stopped."); + } + } + + async fn process_scan_events(mut rx: tokio::sync::mpsc::UnboundedReceiver) { + while let Some(event) = rx.recv().await { + match event { + ProcessingEvent::ScanStatus(status) => { + Self::handle_status_event(status).await; + } + ProcessingEvent::BlockProcessed(block_event) => { + info!(target: LOG_TARGET, "Block processed event: height {}", block_event.height); + } + ProcessingEvent::TransactionsReady(transactions_event) => { + let transaction_count = transactions_event.transactions.len(); + info!( + target: LOG_TARGET, + "TransactionsReady event received with {} transactions", + transaction_count + ); + + // Process transactions - check each for pending transaction match + let mut transactions_to_emit = Vec::new(); + for tx in transactions_event.transactions { + // Check if this scanned transaction matches any pending transaction + if let Some(_pending_tx) = Self::match_and_remove_pending_transaction( + &tx.details.sent_output_hashes, + ) + .await + { + // Emit update event - the scanned transaction replaces the pending one + info!( + target: LOG_TARGET, + "Found matching pending transaction for scanned tx: {}", + tx.id + ); + EventsEmitter::emit_wallet_transaction_updated(tx.clone()).await; + } + transactions_to_emit.push(tx); + } + + // Update balance based on new transactions + if !transactions_to_emit.is_empty() { + BalanceTracker::current() + .update_from_transactions(&transactions_to_emit) + .await; + + // Emit all transactions to frontend + EventsEmitter::emit_wallet_transactions_found(transactions_to_emit).await; + } + } + ProcessingEvent::ReorgDetected(reorg_event) => { + info!( + target: LOG_TARGET, + "Chain reorganization detected at height {}, {} transactions affected", + reorg_event.reorg_from_height, + reorg_event.reorganized_displayed_transactions.len() + ); + + // Emit updates for each reorganized transaction so frontend can update/remove them + for tx in reorg_event.reorganized_displayed_transactions { + EventsEmitter::emit_wallet_transaction_updated(tx).await; + } + } + ProcessingEvent::TransactionsUpdated(update_event) => { + let update_count = update_event.updated_transactions.len(); + info!( + target: LOG_TARGET, + "TransactionsUpdated event received with {} transactions", + update_count + ); + + // Emit update event for each transaction with updated confirmations + for tx in update_event.updated_transactions { + EventsEmitter::emit_wallet_transaction_updated(tx).await; + } + } + } + } + } + + /// Get the current chain tip height from node status + fn get_chain_tip_height() -> u64 { + let app_handle = match INSTANCE.app_handle.try_read() { + Ok(guard) => match guard.as_ref() { + Some(h) => h.clone(), + None => return 0, + }, + Err(_) => return 0, + }; + + let app_state: tauri::State<'_, UniverseAppState> = app_handle.state::(); + let height = app_state.node_status_watch_rx.borrow().block_height; + height + } + + async fn handle_status_event(event: ScanStatusEvent) { + match event { + ScanStatusEvent::Started { + account_id, + from_height, + } => { + info!( + target: LOG_TARGET, + "Scan started for account {} from height {}", account_id, from_height + ); + } + ScanStatusEvent::Progress { + current_height, + blocks_scanned, + .. + } => { + info!( + target: LOG_TARGET, + "Scan progress event: current height {}, blocks scanned {}", + current_height, blocks_scanned + ); + + // Update last scanned height + { + let mut height = INSTANCE.last_scanned_height.write().await; + *height = current_height; + } + + // Get chain tip from node status for accurate progress + let tip_height = Self::get_chain_tip_height(); + let progress = if tip_height > 0 { + ((current_height as f64 / tip_height as f64) * 100.0).min(100.0) + } else { + 0.0 + }; + + EventsEmitter::emit_wallet_scanning_progress_update( + current_height, + tip_height, + progress, + false, // is_initial_scan_complete - still scanning + ) + .await; + + info!( + target: LOG_TARGET, + "Scan progress: height {}/{}, {:.1}%, {} blocks scanned", + current_height, tip_height, progress, blocks_scanned + ); + } + ScanStatusEvent::Completed { + final_height, + total_blocks_scanned, + .. + } => { + info!( + target: LOG_TARGET, + "Scan completed at height {}, total blocks scanned {}", + final_height, total_blocks_scanned + ); + { + let mut height = INSTANCE.last_scanned_height.write().await; + *height = final_height; + } + + // Mark initial sync as complete + INSTANCE.initial_sync_complete.store(true, Ordering::SeqCst); + + let tip_height = Self::get_chain_tip_height(); + EventsEmitter::emit_wallet_scanning_progress_update( + final_height, + if tip_height > 0 { + tip_height + } else { + final_height + }, + 100.0, + true, // is_initial_scan_complete + ) + .await; + + info!( + target: LOG_TARGET, + "Scan completed at height {}, {} total blocks scanned", + final_height, total_blocks_scanned + ); + } + ScanStatusEvent::Waiting { resume_in, .. } => { + info!( + target: LOG_TARGET, + "Scan waiting, will resume in {:?}", resume_in + ); + } + ScanStatusEvent::MoreBlocksAvailable { + last_scanned_height, + .. + } => { + info!( + target: LOG_TARGET, + "More blocks available after height {}", last_scanned_height + ); + } + ScanStatusEvent::Paused { reason, .. } => { + info!(target: LOG_TARGET, "Scan paused: {:?}", reason); + } + } + } + pub async fn import_view_key() -> Result<(), anyhow::Error> { + let tari_wallet_details = InternalWallet::tari_wallet_details().await; + if let Some(details) = tari_wallet_details { + let database_path = MinotariWalletDatabaseManager::database_path()?; + let tari_address = Self::get_owner_address().await; + + init_with_view_key( + &details.view_private_key_hex, + &details.spend_public_key_hex, + DEFAULT_PASSWORD, + database_path.as_str(), + details.wallet_birthday, + Some(tari_address.as_str()), + ) + .await?; + + Ok(()) + } else { + Err(anyhow::anyhow!("Tari wallet details not found")) + } + } +} diff --git a/src-tauri/src/wallet/minotari_wallet/transaction/mod.rs b/src-tauri/src/wallet/minotari_wallet/transaction/mod.rs new file mode 100644 index 0000000000..623b01e0ae --- /dev/null +++ b/src-tauri/src/wallet/minotari_wallet/transaction/mod.rs @@ -0,0 +1,114 @@ +// Copyright 2025. The Tari Project +// +// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +// following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following +// disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the +// following disclaimer in the documentation and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote +// products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +// let one_sided_tx = OneSidedTransaction::new(pool.clone(), network, password.clone()); +// let result = one_sided_tx +// .create_unsigned_transaction(account, recipients, idempotency_key, seconds_to_lock) +// .await +// .map_err(|e| anyhow!("Failed to create unsigned transaction: {}", e))?; + +use anyhow::anyhow; +use minotari_wallet::{ + db::AccountRow, + transactions::{manager::TransactionSender, one_sided_transaction::Recipient}, + DisplayedTransaction, +}; +use sqlx::SqlitePool; +use tari_common::configuration::Network; +use tari_transaction_components::{ + offline_signing::{ + models::{PrepareOneSidedTransactionForSigningResult, SignedOneSidedTransactionResult}, + sign_locked_transaction, + }, + test_helpers::{create_consensus_constants, create_consensus_manager}, +}; +use tauri::AppHandle; + +use crate::{ + internal_wallet::InternalWallet, + wallet::minotari_wallet::{DEFAULT_GRPC_URL, DEFAULT_PASSWORD}, +}; + +pub struct TransactionManager { + transaction_sender: TransactionSender, +} + +impl TransactionManager { + pub async fn new(pool: SqlitePool, sender_address: String) -> Result { + let network = Network::get_current_or_user_setting_or_default(); + let transaction_sender = + TransactionSender::new(pool, sender_address, DEFAULT_PASSWORD.to_string(), network) + .await?; + + Ok(Self { transaction_sender }) + } + + pub async fn create_one_sided_transaction( + &mut self, + recipient: Recipient, + ) -> Result { + let idempotency_key = uuid::Uuid::new_v4().to_string(); + let seconds_to_lock = 86400; // 24 hours + + let prepared_one_sided_transaction = self + .transaction_sender + .start_new_transaction(idempotency_key, recipient, seconds_to_lock) + .await + .map_err(|e| anyhow!("Failed to create unsigned transaction: {}", e))?; + + Ok(prepared_one_sided_transaction) + } + + pub async fn sign_one_sided_transaction( + &self, + app_handle: &AppHandle, + unsigned_tx: PrepareOneSidedTransactionForSigningResult, + ) -> Result { + println!("Signing one-sided transaction..."); + let key_manager = InternalWallet::get_key_manager(app_handle).await?; + let network = Network::get_current_or_user_setting_or_default(); + let rules = create_consensus_manager(); + + let signed_transaction = sign_locked_transaction( + &key_manager, + rules.consensus_constants(0).clone(), + network, + unsigned_tx, + ) + .map_err(|e| anyhow!("Failed to sign one-sided transaction: {}", e))?; + + Ok(signed_transaction) + } + + pub async fn finalize_one_sided_transaction( + &mut self, + signed_transaction: SignedOneSidedTransactionResult, + ) -> Result { + println!("Finalizing one-sided transaction..."); + let displayed_transaction = self + .transaction_sender + .finalize_transaction_and_broadcast(signed_transaction, DEFAULT_GRPC_URL.clone()) + .await?; + + Ok(displayed_transaction) + } +} diff --git a/src-tauri/src/wallet/mod.rs b/src-tauri/src/wallet/mod.rs index 8d9c0d9d45..929391385f 100644 --- a/src-tauri/src/wallet/mod.rs +++ b/src-tauri/src/wallet/mod.rs @@ -20,8 +20,7 @@ // WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE // USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -pub mod spend_wallet; -pub mod transaction_service; +pub mod minotari_wallet; pub mod wallet_adapter; pub mod wallet_manager; pub mod wallet_status_monitor; diff --git a/src-tauri/src/wallet/spend_wallet.rs b/src-tauri/src/wallet/spend_wallet.rs deleted file mode 100644 index 1a982f9778..0000000000 --- a/src-tauri/src/wallet/spend_wallet.rs +++ /dev/null @@ -1,343 +0,0 @@ -// Copyright 2025. The Tari Project -// -// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the -// following conditions are met: -// -// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following -// disclaimer. -// -// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the -// following disclaimer in the documentation and/or other materials provided with the distribution. -// -// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote -// products derived from this software without specific prior written permission. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, -// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, -// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE -// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -use std::collections::HashMap; -use std::path::PathBuf; -use std::time::Duration; - -use anyhow::{Context, Error, Result}; -use axum::async_trait; -use log::{debug, info}; -use tari_common::configuration::Network; -use tari_common_types::seeds::mnemonic::{Mnemonic, MnemonicLanguage}; -use tari_shutdown::Shutdown; -use tauri::{AppHandle, Manager}; - -use crate::binaries::{Binaries, BinaryResolver}; -use crate::internal_wallet::InternalWallet; -use crate::pin::PinManager; -use crate::process_adapter::{ - HealthStatus, ProcessAdapter, ProcessInstance, ProcessInstanceTrait, ProcessStartupSpec, - StatusMonitor, -}; -use crate::tasks_tracker::TasksTrackers; -use crate::utils::commands_builder::CommandBuilder; -use crate::utils::logging_utils::setup_logging; -use crate::LOG_TARGET_APP_LOGIC; - -/// Log target for spend wallet module -const EXIT_CODE_ZERO: i32 = 0; - -/// SpendWallet provides functionality to handle one-sided transaction signing -/// and related operations for the Tari wallet. -#[derive(Debug, Clone, Default)] -pub struct SpendWallet { - /// Optional configuration for wallet commands - config: SpendWalletConfig, -} - -/// Configuration options for the SpendWallet -#[derive(Debug, Clone, Default)] -pub struct SpendWalletConfig { - /// Custom environment variables to pass to wallet commands - pub custom_envs: HashMap, -} - -impl SpendWallet { - pub fn new() -> Self { - Self::default() - } - - /// Syncs the wallet with the network using the provided seed words(Required to execute other cli commands) - async fn sync_wallet(&self, app_handle: &AppHandle, seed_words: &str) -> Result<(), Error> { - let sync_command = CommandBuilder::new("sync") - .add_args(&["--skip-recovery", "sync"]) - .add_env("MINOTARI_WALLET_SEED_WORDS", seed_words); - - self.execute_command(app_handle, sync_command, vec![EXIT_CODE_ZERO]) - .await - .map_err(|e| anyhow::anyhow!("Failed to execute sync command: {}", e))?; - - Ok(()) - } - - /// Signs a one-sided transaction from the provided input file and writes the result to the output file - /// - /// # Arguments - /// * `input_file` - Path to the input transaction file to be signed - /// * `output_file` - Path where the signed transaction will be written - /// * `app_handle` - Tauri AppHandle for accessing application paths - /// - /// # Returns - /// * `Result<(), Error>` - Ok if the transaction was successfully signed, otherwise an error - pub async fn sign_one_sided_transaction( - &self, - input_file: PathBuf, - output_file: PathBuf, - app_handle: &AppHandle, - ) -> Result<(), Error> { - let seed_words = self - .get_seed_words(app_handle) - .await - .context("Failed to retrieve wallet seed words")?; - - // Required step - self.sync_wallet(app_handle, &seed_words).await?; - - let sign_command = CommandBuilder::new("sign-one-sided-transaction") - .add_args(&[ - "--skip-recovery", - "sign-one-sided-transaction", - "--input-file", - &input_file.to_string_lossy(), - "--output-file", - &output_file.to_string_lossy(), - ]) - .add_env("MINOTARI_WALLET_SEED_WORDS", &seed_words); - - let (exit_code, _stdout, _stderr) = self - .execute_command(app_handle, sign_command, vec![EXIT_CODE_ZERO]) - .await - .map_err(|e| anyhow::anyhow!("Failed to execute signing command: {}", e))?; - - info!( - target: LOG_TARGET_APP_LOGIC, - "Transaction signing completed with exit code: {exit_code}" - ); - - let data_dir = self.get_data_dir(app_handle)?; - let working_dir = data_dir.join("spend_wallet"); - // Clean up spend wallet working directory - std::fs::remove_dir_all(&working_dir) - .and_then(|_| std::fs::create_dir_all(&working_dir)) - .context("Failed to clean up Spend Wallet working directory")?; - - Ok(()) - } - - /// Executes a wallet command and waits for its output - /// - /// # Arguments - /// * `app_handle` - Tauri AppHandle for accessing application paths - /// * `command` - Command to execute - /// * `allow_exit_codes` - List of exit codes that are considered successful - /// - /// # Returns - /// * `Result<(i32, Vec, Vec), Error>` - Exit code, stdout lines, and stderr lines - async fn execute_command( - &self, - app_handle: &AppHandle, - command: CommandBuilder, - allow_exit_codes: Vec, - ) -> Result<(i32, Vec, Vec), Error> { - let data_dir = self.get_data_dir(app_handle)?; - let config_dir = self.get_config_dir(app_handle)?; - let log_dir = self.get_log_dir(app_handle)?; - let binary_path = self.get_binary_path().await?; - - let network = Network::get_current_or_user_setting_or_default().to_string(); - // Ensure working directory exists - let working_dir = data_dir.join("spend_wallet").join(network); - if !working_dir.exists() { - std::fs::create_dir_all(&working_dir) - .context("Failed to create Spend Wallet working directory")?; - } - - // Create process instance and monitor - let (mut instance, _monitor) = - self.spawn(data_dir.clone(), config_dir, log_dir, binary_path, false)?; - - // Add command-specific arguments and environment variables - instance.startup_spec.args.extend(command.args.clone()); - - let envs = instance.startup_spec.envs.get_or_insert_with(HashMap::new); - envs.extend(command.envs); - envs.extend(self.config.custom_envs.clone()); - - // Start process and wait for completion - let (exit_code, stdout_lines, stderr_lines) = instance - .start_and_wait_for_output( - TasksTrackers::current() - .wallet_phase - .get_task_tracker() - .await, - ) - .await - .context("Failed to start wallet process or collect output")?; - - debug!( - target: LOG_TARGET_APP_LOGIC, - "Spend Wallet command '{}' execution completed with exit code: {}", - command.name, - exit_code - ); - - if !allow_exit_codes.contains(&exit_code) { - log::error!( - target: LOG_TARGET_APP_LOGIC, - "Command '{}' failed with exit code: {}.\n* Error: {}\n* Stdout: {}\n* Args: {:?}", - command.name, - exit_code, - stdout_lines.join("\n"), - stderr_lines.join("\n"), - command.args - ); - return Err(anyhow::anyhow!( - "Command '{}' failed with exit code: {}. Error: {}", - command.name, - exit_code, - stderr_lines.join("\n") - )); - } - - Ok((exit_code, stdout_lines, stderr_lines)) - } - - /// Gets the shared command line arguments for all commands - fn get_shared_args(&self, data_dir: PathBuf, log_dir: PathBuf) -> Result, Error> { - let working_dir = data_dir.join("spend_wallet"); - let log_config_file = log_dir - .join("spend_wallet") - .join("configs") - .join("log4rs_config_spend_wallet.yml"); - - let working_dir_str = working_dir.to_string_lossy().to_string(); - let log_config_str = log_config_file.to_string_lossy().to_string(); - - Ok(vec![ - "-b".to_string(), - working_dir_str, - format!("--log-config={}", log_config_str), - "--non-interactive-mode".to_string(), - "--auto-exit".to_string(), - ]) - } - - async fn get_seed_words(&self, app_handle: &AppHandle) -> Result { - let pin_password = PinManager::get_validated_pin_if_defined(app_handle) - .await - .context("Failed to validate PIN")?; - let tari_cipher_seed = InternalWallet::get_tari_seed(pin_password) - .await - .context("Failed to get Tari seed")?; - let seed_words = tari_cipher_seed - .to_mnemonic(MnemonicLanguage::English, None) - .context("Failed to convert seed to mnemonic")?; - - Ok(seed_words.join(" ").reveal().to_string()) - } - - pub fn get_data_dir(&self, app_handle: &AppHandle) -> Result { - app_handle - .path() - .app_local_data_dir() - .context("Could not get application data directory") - } - - pub fn get_config_dir(&self, app_handle: &AppHandle) -> Result { - app_handle - .path() - .app_config_dir() - .context("Could not get application config directory") - } - - pub fn get_log_dir(&self, app_handle: &AppHandle) -> Result { - app_handle - .path() - .app_log_dir() - .context("Could not get application log directory") - } - - pub async fn get_binary_path(&self) -> Result { - BinaryResolver::current() - .get_binary_path(Binaries::Wallet) - .await - .context("Failed to resolve wallet binary path") - } -} - -/// Status monitor for the spend wallet process -#[derive(Clone, Debug)] -pub struct DummyStatusMonitor; - -#[async_trait] -impl StatusMonitor for DummyStatusMonitor { - async fn check_health(&self, _uptime: Duration, _timeout_duration: Duration) -> HealthStatus { - // Since this is a short-lived command-line process, it's always considered healthy - HealthStatus::Healthy - } -} - -impl ProcessAdapter for SpendWallet { - type StatusMonitor = DummyStatusMonitor; - type ProcessInstance = ProcessInstance; - - fn spawn_inner( - &self, - data_dir: PathBuf, - _config_folder: PathBuf, - log_dir: PathBuf, - binary_version_path: PathBuf, - _is_first_start: bool, - ) -> Result<(Self::ProcessInstance, Self::StatusMonitor), anyhow::Error> { - let log4rs_config = log_dir - .join("spend_wallet") - .join("configs") - .join("log4rs_config_spend_wallet.yml"); - setup_logging( - &log4rs_config, - &log_dir, - include_str!("../../log4rs/spend_wallet_sample.yml"), - )?; - - let shared_args = self - .get_shared_args(data_dir.clone(), log_dir) - .context("Failed to build shared arguments")?; - let envs = HashMap::from([( - "MINOTARI_WALLET_PASSWORD".to_string(), - "asjhfahjajhdfvarehnavrahuyg28397823yauifh24@@$@84y8".to_string(), - )]); - - let instance = ProcessInstance { - shutdown: Shutdown::new(), - handle: None, - startup_spec: ProcessStartupSpec { - file_path: binary_version_path, - envs: Some(envs), - args: shared_args, - pid_file_name: self.pid_file_name().to_string(), - data_dir, - name: self.name().to_string(), - }, - }; - - Ok((instance, DummyStatusMonitor)) - } - - fn name(&self) -> &str { - "spend_wallet" - } - - fn pid_file_name(&self) -> &str { - "spend_wallet.pid" - } -} diff --git a/src-tauri/src/wallet/transaction_service.rs b/src-tauri/src/wallet/transaction_service.rs deleted file mode 100644 index 8fdfad6606..0000000000 --- a/src-tauri/src/wallet/transaction_service.rs +++ /dev/null @@ -1,253 +0,0 @@ -// Copyright 2024. The Tari Project -// -// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the -// following conditions are met: -// -// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following -// disclaimer. -// -// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the -// following disclaimer in the documentation and/or other materials provided with the distribution. -// -// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote -// products derived from this software without specific prior written permission. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, -// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, -// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE -// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -use crate::wallet::spend_wallet::SpendWallet; -use crate::wallet::wallet_adapter::WalletAdapter; -use crate::wallet::wallet_status_monitor::WalletStatusMonitorError; -use crate::LOG_TARGET_APP_LOGIC; -use minotari_node_grpc_client::grpc::payment_recipient::PaymentType; -use minotari_node_grpc_client::grpc::wallet_client::WalletClient; -use minotari_node_grpc_client::grpc::{ - BroadcastSignedOneSidedTransactionRequest, CancelTransactionRequest, PaymentRecipient, - PrepareOneSidedTransactionForSigningRequest, UserPaymentId, -}; -use std::fs; -use std::path::PathBuf; -use tari_common::configuration::Network; -use tauri::Manager; - -/// This struct encapsulates all functionality related to transactions -pub struct TransactionService<'a> { - wallet_adapter: &'a WalletAdapter, - app_handle: &'a tauri::AppHandle, -} - -impl<'a> TransactionService<'a> { - pub fn new(wallet_adapter: &'a WalletAdapter, app_handle: &'a tauri::AppHandle) -> Self { - Self { - wallet_adapter, - app_handle, - } - } - - /// Prepares a one-sided transaction to be signed by the spend wallet - /// - /// # Arguments - /// * `amount` - Amount to send(MicroMinotari as u64) - /// * `address` - Recipient's stealth address - /// * `payment_id` - Optional utf8_string Payment ID for the transaction - /// - /// # Returns - /// * `Result<(PathBuf, String), anyhow::Error>` - Path to the unsigned transaction file and transaction ID - pub async fn prepare_one_sided_transaction_for_signing( - &self, - amount: u64, - address: String, - payment_id: Option, - ) -> Result<(PathBuf, String), anyhow::Error> { - let payment_recipient = PaymentRecipient { - address, - amount, - raw_payment_id: vec![], - user_payment_id: payment_id.map(|p_id| UserPaymentId { - utf8_string: p_id, - u256: vec![], - user_bytes: vec![], - }), - fee_per_gram: 1, // TODO: Implement fee calculation logic - payment_type: PaymentType::OneSidedToStealthAddress.into(), - }; - - let mut client = WalletClient::connect(self.wallet_adapter.wallet_grpc_address()) - .await - .map_err(|_e| WalletStatusMonitorError::WalletNotStarted)?; - let res = client - .prepare_one_sided_transaction_for_signing( - PrepareOneSidedTransactionForSigningRequest { - recipient: Some(payment_recipient), - }, - ) - .await?; - - let prepare_tx_res = res.into_inner(); - let unsigned_tx_json = if prepare_tx_res.is_success { - prepare_tx_res.result - } else { - return Err(anyhow::anyhow!( - "One-sided transaction preparation failed: {}", - prepare_tx_res.failure_message - )); - }; - - // Create directory for transaction files if it doesn't exist - let wallet_txs_dir = get_transactions_directory(self.app_handle)?; - if !wallet_txs_dir.exists() { - std::fs::create_dir_all(&wallet_txs_dir).unwrap_or_else(|e| { - log::error!(target: LOG_TARGET_APP_LOGIC, "Failed to create transactions directory: {e}"); - }); - } - - // Extract transaction ID from the JSON response - let parsed: serde_json::Value = serde_json::from_str(&unsigned_tx_json) - .expect("Failed to parse unsigned one-sided transaction JSON"); - let tx_id = if let Some(tx_id) = parsed.get("tx_id") { - tx_id.to_string().trim_matches('"').to_string() - } else { - return Err(anyhow::anyhow!( - "One-sided transaction ID not found in JSON file" - )); - }; - - // Save unsigned transaction to file - let unsigned_tx_file = wallet_txs_dir.join(format!("{tx_id}-unsigned.json")); - fs::write(&unsigned_tx_file, &unsigned_tx_json)?; - - Ok((unsigned_tx_file, tx_id)) - } - - /// Cancel a transaction - /// Used as cleanup after failing to sign one sided transaction by spend wallet - /// - /// # Arguments - /// * `tx_id` - The ID of the transaction to cancel - /// - /// # Returns - /// * `Result<(), anyhow::Error>` - A result indicating success or failure - pub async fn cancel_transaction(&self, tx_id: String) -> Result<(), anyhow::Error> { - let wallet_txs_dir = get_transactions_directory(self.app_handle)?; - let unsigned_tx_file = wallet_txs_dir.join(format!("{tx_id}-unsigned.json")); - let signed_tx_file = wallet_txs_dir.join(format!("{tx_id}.json")); - - let tx_id_u64 = tx_id - .parse::() - .map_err(|_| anyhow::anyhow!("Invalid transaction ID: {}", tx_id))?; - - let mut client = WalletClient::connect(self.wallet_adapter.wallet_grpc_address()) - .await - .map_err(|_e| WalletStatusMonitorError::WalletNotStarted)?; - let res = client - .cancel_transaction(CancelTransactionRequest { tx_id: tx_id_u64 }) - .await?; - - let cancel_tx_res = res.into_inner(); - if !cancel_tx_res.is_success { - return Err(anyhow::anyhow!( - "One-sided transaction preparation failed: {}", - cancel_tx_res.failure_message - )); - }; - - // Remove unsigned and signed transaction files - fs::remove_file(&unsigned_tx_file)?; - fs::remove_file(&signed_tx_file)?; - - Ok(()) - } - - /// Signs a prepared one-sided transaction using the SpendWallet - /// - /// # Arguments - /// * `unsigned_tx_file` - Path to the unsigned transaction file - /// * `tx_id` - Transaction ID - /// - /// # Returns - /// * `Result` - Path to the signed transaction file - pub async fn sign_one_sided_tx( - &self, - unsigned_tx_file: PathBuf, - tx_id: String, - ) -> Result { - // Define the output file path for the signed transaction - let wallet_txs_dir = get_transactions_directory(self.app_handle)?; - let signed_tx_destination_file = wallet_txs_dir.join(format!("{tx_id}.json")); - - // Sign the transaction using SpendWallet - let spend_wallet = SpendWallet::new(); - spend_wallet - .sign_one_sided_transaction( - unsigned_tx_file, - signed_tx_destination_file.clone(), - self.app_handle, - ) - .await?; - - Ok(signed_tx_destination_file) - } - - /// Broadcasts a signed one-sided transaction to the network - /// - /// # Arguments - /// * `signed_tx_file` - Path to the signed transaction file - /// - /// # Returns - /// * `Result<(), anyhow::Error>` - Success or failure - pub async fn broadcast_one_sided_tx( - &self, - signed_tx_file: PathBuf, - ) -> Result<(), anyhow::Error> { - let signed_tx_json = fs::read_to_string(&signed_tx_file)?; - - let mut client = WalletClient::connect(self.wallet_adapter.wallet_grpc_address()) - .await - .map_err(|_e| WalletStatusMonitorError::WalletNotStarted)?; - - let res = client - .broadcast_signed_one_sided_transaction(BroadcastSignedOneSidedTransactionRequest { - request: signed_tx_json, - }) - .await?; - - let broadcast_signed_tx_res = res.into_inner(); - if broadcast_signed_tx_res.is_success { - log::info!( - target: LOG_TARGET_APP_LOGIC, - "One-sided transaction broadcasted successfully | tx_id: {}", - broadcast_signed_tx_res.transaction_id - ); - Ok(()) - } else { - Err(anyhow::anyhow!( - "One-sided transaction broadcast failed: {}", - broadcast_signed_tx_res.failure_message - )) - } - } -} - -/// Gets the directory where transaction files(signed + unsigned) are stored -/// -/// # Returns -/// * `Result` - Path to the one-sided transactions directory -pub fn get_transactions_directory(app_handle: &tauri::AppHandle) -> Result { - let network = Network::get_current_or_user_setting_or_default() - .to_string() - .to_lowercase(); - - let dir = app_handle - .path() - .app_local_data_dir() - .expect("Couldn't get app_local_data_dir for get_transactions_directory!") - .join(network) - .join("sent_transactions"); - - Ok(dir) -} diff --git a/src-tauri/src/wallet/wallet_adapter.rs b/src-tauri/src/wallet/wallet_adapter.rs index 93acfc8ecf..05d9a6003f 100644 --- a/src-tauri/src/wallet/wallet_adapter.rs +++ b/src-tauri/src/wallet/wallet_adapter.rs @@ -20,34 +20,23 @@ // WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE // USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -use crate::events_emitter::EventsEmitter; use crate::port_allocator::PortAllocator; use crate::process_adapter::{ProcessAdapter, ProcessInstance, ProcessStartupSpec}; use crate::process_adapter_utils::setup_working_directory; -use crate::tasks_tracker::TasksTrackers; use crate::utils::file_utils::convert_to_string; use crate::utils::logging_utils::setup_logging; #[cfg(target_os = "windows")] use crate::utils::windows_setup_utils::add_firewall_rule; -use crate::wallet::transaction_service::TransactionService; -use crate::wallet::wallet_status_monitor::{WalletStatusMonitor, WalletStatusMonitorError}; -use crate::wallet::wallet_types::{ - ConnectivityStatus, TransactionInfo, TransactionStatus, WalletBalance, WalletState, -}; +use crate::wallet::wallet_status_monitor::WalletStatusMonitor; +use crate::wallet::wallet_types::WalletState; use crate::{LOG_TARGET_APP_LOGIC, LOG_TARGET_STATUSES}; use anyhow::Error; use log::{info, warn}; -use minotari_node_grpc_client::grpc::wallet_client::WalletClient; -use minotari_node_grpc_client::grpc::{GetAllCompletedTransactionsRequest, GetBalanceRequest}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; -use std::time::Duration; use tari_common::configuration::Network; -use tari_common_types::tari_address::{TariAddress, TariAddressError}; use tari_shutdown::Shutdown; -use tari_transaction_components::tari_amount::MicroMinotari; -use tari_transaction_components::transaction_components::memo_field::MemoField; use tokio::sync::watch; #[derive(Serialize, Deserialize, Default)] @@ -106,169 +95,6 @@ impl WalletAdapter { self.connect_with_local_node = connect_with_local_node; } - pub async fn get_balance(&self) -> Result { - let mut client = WalletClient::connect(self.wallet_grpc_address()) - .await - .map_err(|_e| WalletStatusMonitorError::WalletNotStarted)?; - let res = client - .get_balance(GetBalanceRequest { payment_id: None }) - .await?; - let balance = res.into_inner(); - - Ok(WalletBalance::from_response(balance)) - } - - pub async fn get_transactions( - &self, - offset: Option, - limit: Option, - status: Option, - current_block_height: u64, - ) -> Result, WalletStatusMonitorError> { - let mut client = WalletClient::connect(self.wallet_grpc_address()) - .await - .map_err(|_e| WalletStatusMonitorError::WalletNotStarted)?; - // TODO: This needs to be upgraded to the streaming API https://github.com/tari-project/tari/pull/7366/ - #[allow(deprecated)] - let res = client - .get_all_completed_transactions(GetAllCompletedTransactionsRequest { - offset: u64::from(offset.unwrap_or(0)), - limit: u64::from(limit.unwrap_or(0)), - status_bitflag: u64::from(status.unwrap_or(0)), - }) - .await - .map_err(|e| WalletStatusMonitorError::UnknownError(e.into()))?; - - let transactions = res - .into_inner() - .transactions - .into_iter() - .map(|tx| { - let confirmations = if current_block_height > 0 - && tx.mined_in_block_height <= current_block_height - { - current_block_height - tx.mined_in_block_height - } else { - 0 - }; - let payment_reference = if confirmations >= 5 { - match tx.direction { - 1 => tx.payment_references_received.last().map(hex::encode), - 2 => tx.payment_references_sent.last().map(hex::encode), - _ => None, - } - } else { - None - }; - - Ok(TransactionInfo { - tx_id: tx.tx_id.to_string(), - source_address: TariAddress::from_bytes(&tx.source_address)?.to_base58(), - dest_address: TariAddress::from_bytes(&tx.dest_address)?.to_base58(), - status: TransactionStatus::from(tx.status), - amount: MicroMinotari(tx.amount), - is_cancelled: tx.is_cancelled, - direction: tx.direction, - excess_sig: tx.excess_sig, - fee: tx.fee, - timestamp: tx.timestamp, - payment_id: MemoField::stringify_bytes(&tx.user_payment_id), - mined_in_block_height: tx.mined_in_block_height, - payment_reference, - }) - }) - .collect::, TariAddressError>>()?; - - Ok(transactions) - } - - pub async fn send_one_sided_to_stealth_address( - &self, - amount: u64, - address: String, - payment_id: Option, - app_handle: &tauri::AppHandle, - ) -> Result<(), anyhow::Error> { - let tx_service = TransactionService::new(self, app_handle); - - let (unsigned_tx_file, tx_id) = tx_service - .prepare_one_sided_transaction_for_signing(amount, address, payment_id) - .await?; - let sign_result = tx_service - .sign_one_sided_tx(unsigned_tx_file, tx_id.clone()) - .await; - match sign_result { - Ok(signed_tx_file) => tx_service.broadcast_one_sided_tx(signed_tx_file).await, - Err(e) => { - let cancel_res = tx_service.cancel_transaction(tx_id).await; - if let Err(cancel_err) = cancel_res { - log::error!(target: LOG_TARGET_APP_LOGIC, "Failed to cancel transaction after failed to sign one sided tx: {cancel_err}: {e}"); - } - Err(e) - } - } - } - - pub async fn wait_for_scan_to_height( - &self, - block_height: u64, - timeout: Option, - ) -> Result { - let mut state_receiver = self.state_broadcast.subscribe(); - let mut shutdown_signal = TasksTrackers::current().wallet_phase.get_signal().await; - let mut zero_scanned_height_count = 0; - loop { - tokio::select! { - result = state_receiver.changed() => { - if result.is_err() { - return Err(WalletStatusMonitorError::WalletNotStarted); - } - - let current_state = state_receiver.borrow().clone(); - if let Some(state) = current_state { - // Case 1: Scan has reached or exceeded target height - if state.scanned_height >= block_height { - info!(target: LOG_TARGET_STATUSES, "Wallet scan completed up to block height {block_height}"); - EventsEmitter::emit_wallet_status_updated(false, None).await; - return Ok(state); - } - // Case 2: Wallet is at height 0 but is connected - likely means scan finished already - if state.scanned_height == 0 { - if let Some(network) = &state.network { - if matches!(network.status, ConnectivityStatus::Online(3..)) { - zero_scanned_height_count += 1; - if zero_scanned_height_count >= 5 { - warn!(target: LOG_TARGET_STATUSES, "Wallet scanned before gRPC service started"); - return Ok(state); - } - } - } - } - } - }, - _ = shutdown_signal.wait() => { - log::info!(target: LOG_TARGET_STATUSES, "Shutdown signal received, stopping wait_for_scan_to_height"); - return Ok(WalletState::default()); - } - _ = async { - tokio::time::sleep(timeout.unwrap_or(Duration::MAX)).await; - } => { - warn!( - target: LOG_TARGET_STATUSES, - "Timeout reached while waiting for wallet scan to complete. Current height: {}/{}", - state_receiver.borrow().as_ref().map(|s| s.scanned_height).unwrap_or(0), - block_height - ); - // Return current state if available, otherwise error - return state_receiver - .borrow() - .clone() - .ok_or(WalletStatusMonitorError::WalletNotStarted); - } - } - } - } - pub fn wallet_grpc_address(&self) -> String { format!("http://127.0.0.1:{}", self.grpc_port) } diff --git a/src-tauri/src/wallet/wallet_manager.rs b/src-tauri/src/wallet/wallet_manager.rs index 8b80064fe3..bd1bb9855e 100644 --- a/src-tauri/src/wallet/wallet_manager.rs +++ b/src-tauri/src/wallet/wallet_manager.rs @@ -20,9 +20,6 @@ // WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE // USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -use crate::configs::config_wallet::{ConfigWallet, ConfigWalletContent}; -use crate::configs::trait_config::ConfigImpl; -use crate::events_emitter::EventsEmitter; use crate::internal_wallet::InternalWallet; use crate::node::node_manager::{NodeManager, NodeManagerError}; use crate::process_adapter::ProcessAdapter; @@ -30,8 +27,7 @@ use crate::process_stats_collector::ProcessStatsCollectorBuilder; use crate::process_watcher::ProcessWatcher; use crate::tasks_tracker::TasksTrackers; use crate::wallet::wallet_adapter::WalletAdapter; -use crate::wallet::wallet_status_monitor::WalletStatusMonitorError; -use crate::wallet::wallet_types::{TransactionInfo, TransactionStatus, WalletBalance, WalletState}; +use crate::wallet::wallet_types::WalletState; use crate::{BaseNodeStatus, LOG_TARGET_APP_LOGIC, LOG_TARGET_STATUSES}; use futures_util::future::FusedFuture; use log::{error, info}; @@ -182,11 +178,6 @@ impl WalletManager { self.watcher.read().await.adapter.grpc_port } - pub fn is_initial_scan_completed(&self) -> bool { - self.initial_scan_completed - .load(std::sync::atomic::Ordering::Relaxed) - } - pub async fn on_app_exit(&self) { match self .watcher @@ -221,303 +212,6 @@ impl WalletManager { Ok(()) } - pub async fn get_balance(&self) -> Result { - let process_watcher = self.watcher.read().await; - process_watcher.adapter.get_balance().await - } - - pub async fn get_transactions( - &self, - offset: Option, - limit: Option, - status_bitflag: Option, - ) -> Result, WalletManagerError> { - let node_status = *self.base_node_watch_rx.borrow(); - let current_block_height = node_status.block_height; - let process_watcher = self.watcher.read().await; - process_watcher - .adapter - .get_transactions(offset, limit, status_bitflag, current_block_height) - .await - .map_err(|e| match e { - WalletStatusMonitorError::WalletNotStarted => WalletManagerError::WalletNotStarted, - _ => WalletManagerError::UnknownError(e.into()), - }) - } - - pub async fn wait_for_scan_to_height( - &self, - block_height: u64, - timeout: Option, - ) -> Result { - let process_watcher = self.watcher.read().await; - - if !process_watcher.is_running() { - return Err(WalletManagerError::WalletNotStarted); - } - - process_watcher - .adapter - .wait_for_scan_to_height(block_height, timeout) - .await - .map_err(|e| match e { - WalletStatusMonitorError::WalletNotStarted => WalletManagerError::WalletNotStarted, - _ => WalletManagerError::UnknownError(e.into()), - }) - } - - pub async fn send_one_sided_to_stealth_address( - &self, - amount_str: String, - destination: String, - payment_id: Option, - app_handle: &tauri::AppHandle, - ) -> Result<(), WalletManagerError> { - let process_watcher = self.watcher.read().await; - if !process_watcher.is_running() { - return Err(WalletManagerError::WalletNotStarted); - } - - // TODO: check if node is synced? - self.node_manager.wait_ready().await?; - - let minotari_amount = Minotari::from_str(&amount_str) - .map_err(|e| WalletManagerError::UnknownError(e.into()))?; - let micro_minotari_amount = MicroMinotari::from(minotari_amount); - let amount = micro_minotari_amount.as_u64(); - - // Payment ID can't be an empty string - let payment_id = match payment_id { - Some(s) if s.is_empty() => None, - _ => payment_id, - }; - - let res = process_watcher - .adapter - .send_one_sided_to_stealth_address(amount, destination, payment_id, app_handle) - .await; - - res.map_err(WalletManagerError::UnknownError) - } - - pub async fn find_coinbase_transaction_for_block( - &self, - block_height: u64, - ) -> Result, WalletManagerError> { - const COINBASE_STATUSES_BITFLAG: u32 = (1 << TransactionStatus::CoinbaseConfirmed as u32) - | (1 << TransactionStatus::CoinbaseUnconfirmed as u32); - - // Get a small batch of recent coinbase transactions - let coinbase_txs = self - .get_transactions(Some(0), Some(10), Some(COINBASE_STATUSES_BITFLAG)) - .await?; - - // Find one matching the specified block height - let matching_tx = coinbase_txs - .into_iter() - .find(|tx| tx.mined_in_block_height == block_height); - - Ok(matching_tx) - } - - #[allow(clippy::too_many_lines)] - pub async fn wait_for_initial_wallet_scan( - &self, - node_status_watch_rx: watch::Receiver, - ) -> Result<(), WalletManagerError> { - if self.is_initial_scan_completed() { - // TODO - need to change this so we can get scan progress? - log::info!(target: LOG_TARGET_APP_LOGIC, "Initial wallet scan already completed, skipping"); - EventsEmitter::emit_wallet_status_updated(true, None).await; - return Ok(()); - } - - let process_watcher = self.watcher.read().await; - if !process_watcher.is_running() { - return Err(WalletManagerError::WalletNotStarted); - } - let wallet_state_receiver = process_watcher.adapter.state_broadcast.subscribe(); - let wallet_state_receiver_clone = wallet_state_receiver.clone(); - drop(process_watcher); - - let node_status_watch_rx_progress = node_status_watch_rx.clone(); - let initial_scan_completed = self.initial_scan_completed.clone(); - // Start a background task to monitor the wallet state and emit scan progress updates - TasksTrackers::current().wallet_phase.get_task_tracker().await.spawn(async move { - let mut wallet_state_rx = wallet_state_receiver_clone; - let mut shutdown_signal = TasksTrackers::current().wallet_phase.get_signal().await; - - loop { - tokio::select! { - _ = shutdown_signal.wait() => { - log::info!(target: LOG_TARGET_APP_LOGIC, "Shutdown signal received, stopping status forwarding thread"); - break; - } - _ = wallet_state_rx.changed() => { - let current_target_height = node_status_watch_rx_progress.borrow().block_height; - let (scanned_height, progress) = { - if let Some(wallet_state) = &*wallet_state_rx.borrow() { - let progress = if current_target_height > 0 { - (wallet_state.scanned_height as f64 / current_target_height as f64 * 100.0).min(100.0) - } else { - 0.0 - }; - (wallet_state.scanned_height, progress) - } else { - continue; - } - }; - if initial_scan_completed.load(std::sync::atomic::Ordering::Relaxed) { - break; - } - - if scanned_height > 0 && progress < 100.0 { - log::info!(target: LOG_TARGET_STATUSES, "Initial wallet scanning: {progress}% ({scanned_height}/{current_target_height})"); - EventsEmitter::emit_init_wallet_scanning_progress( - scanned_height, - current_target_height, - progress, - ).await; - } - } - } - } - }); - - let wallet_manager = self.clone(); - let mut node_status_watch_rx_scan = node_status_watch_rx.clone(); - - TasksTrackers::current().wallet_phase.get_task_tracker().await.spawn(async move { - let mut shutdown_signal = TasksTrackers::current().wallet_phase.get_signal().await; - - loop { - let mut retries = 0; - let current_target_height = loop { - let current_height = node_status_watch_rx_scan.borrow().block_height; - if current_height > 0 { - break current_height; - } - retries += 1; - if retries >= 10 { - log::warn!(target: LOG_TARGET_APP_LOGIC, "Max retries(10) reached while waiting for node status update"); - break 1; - } - tokio::select!{ - _ = node_status_watch_rx_scan.changed() => {}, - _ = shutdown_signal.wait() =>{ - break 1; - } - } - }; - tokio::select! { - _ = shutdown_signal.wait() => { - log::info!(target: LOG_TARGET_APP_LOGIC, "Shutdown signal received, stopping wallet initial scan task"); - return Ok(()); - } - result = wallet_manager.wait_for_scan_to_height(current_target_height, None) => { - match result { - Ok(scanned_wallet_state) => { - let node_status = *node_status_watch_rx_scan.borrow(); - if !node_status.is_synced { - log::info!(target: LOG_TARGET_APP_LOGIC, - "Node is not synced, continuing.."); - continue; - } - - let latest_height = node_status.block_height; - if latest_height > current_target_height { - log::info!(target: LOG_TARGET_APP_LOGIC, - "Node height increased from {current_target_height} to {latest_height} while initial scanning, continuing.."); - continue; - } - - // Scan completed to current target height - if let Some(balance) = scanned_wallet_state.balance { - log::info!( - target: LOG_TARGET_APP_LOGIC, - "Initial wallet scan complete up to {} block height. Available balance: {}", - latest_height, - balance.available_balance - ); - - ConfigWallet::update_field(ConfigWalletContent::set_last_known_balance, balance.available_balance).await?; - - EventsEmitter::emit_wallet_balance_update(balance).await; - EventsEmitter::emit_init_wallet_scanning_progress( - current_target_height, - current_target_height, - 100.0, - ).await; - - wallet_manager.initial_scan_completed - .store(true, std::sync::atomic::Ordering::Relaxed); - } else { - log::warn!(target: LOG_TARGET_APP_LOGIC, "Wallet Balance is None after initial scanning"); - } - break; - } - Err(e) => { - log::error!(target: LOG_TARGET_APP_LOGIC, "Error during initial wallet scan: {e}"); - return Err(e); - } - } - } - } - } - - Ok(()) - }); - - // Balance might be invalid right after initial scanning but it should be revalidated shortly after - let wallet_state_receiver_clone = wallet_state_receiver.clone(); - TasksTrackers::current() - .wallet_phase - .get_task_tracker() - .await - .spawn(async move { - WalletManager::validate_balance_after_scan(wallet_state_receiver_clone) - .await - .inspect_err(|e| { - log::error!(target: LOG_TARGET_APP_LOGIC, "Balance validation failed: {e}"); - }) - }); - - Ok(()) - } - - async fn validate_balance_after_scan( - wallet_state_receiver: watch::Receiver>, - ) -> Result<(), WalletManagerError> { - let mut interval = tokio::time::interval(Duration::from_secs(2)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - let end_time = tokio::time::Instant::now() + Duration::from_secs(120); - let mut shutdown_signal = TasksTrackers::current().wallet_phase.get_signal().await; - - loop { - tokio::select! { - _ = shutdown_signal.wait() => { - info!(target: LOG_TARGET_APP_LOGIC, "Shutdown signal received, stopping balance validation"); - break; - } - _ = interval.tick() => { - if tokio::time::Instant::now() >= end_time { - break; - } - - let wallet_status = wallet_state_receiver.borrow().clone(); - if let Some(wallet_state) = wallet_status { - if let Some(balance) = wallet_state.balance { - ConfigWallet::update_field(ConfigWalletContent::set_last_known_balance, balance.available_balance).await?; - EventsEmitter::emit_wallet_balance_update(balance).await; - } - } - } - } - } - - Ok(()) - } - #[allow(dead_code)] pub async fn stop(&self) -> Result { // Reset the initial scan flag diff --git a/src/App/AppEffects.tsx b/src/App/AppEffects.tsx index 2e5a642368..53179b4bdf 100644 --- a/src/App/AppEffects.tsx +++ b/src/App/AppEffects.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import setupLogger from '../utils/shared-logger'; @@ -19,7 +19,11 @@ import useMiningTime from '@app/hooks/app/useMiningTime.ts'; setupLogger(); export default function AppEffects() { + const initializationRef = useRef(false); + useEffect(() => { + if (initializationRef.current) return; + initializationRef.current = true; async function initialize() { await fetchBackendInMemoryConfig(); await getMiningNetwork(); diff --git a/src/components/transactions/components/AccordionItem/AccordionItem.tsx b/src/components/transactions/components/AccordionItem/AccordionItem.tsx new file mode 100644 index 0000000000..18a35abd7f --- /dev/null +++ b/src/components/transactions/components/AccordionItem/AccordionItem.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Wrapper, Header, TextWrapper, Title, Subtitle, Content, ChevronIcon, ContentPadding } from './styles.ts'; + +interface AccordionItemProps { + title: React.ReactNode; + subtitle?: React.ReactNode; + content: React.ReactNode; + isOpen: boolean; + onToggle: () => void; +} + +export const AccordionItem = ({ title, subtitle, content, isOpen, onToggle }: AccordionItemProps) => { + return ( + +
+ + {title} + {subtitle && {subtitle}} + + + + +
+ + {content} + +
+ ); +}; diff --git a/src/components/transactions/components/AccordionItem/styles.ts b/src/components/transactions/components/AccordionItem/styles.ts new file mode 100644 index 0000000000..eb89330f0b --- /dev/null +++ b/src/components/transactions/components/AccordionItem/styles.ts @@ -0,0 +1,81 @@ +/* eslint-disable prettier/prettier */ +import * as m from 'motion/react-m'; +import styled, { css } from 'styled-components'; + +export const Wrapper = styled.div` + border: 1px solid ${({ theme }) => theme.colorsAlpha.greyscaleAlpha[15]}; + border-radius: 8px; + background: ${({ theme }) => theme.colorsAlpha.greyscaleAlpha[5]}; + overflow: hidden; + margin-bottom: 8px; + + &:last-child { + margin-bottom: 0; + } +`; + +export const Header = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + cursor: pointer; + font-weight: 500; + gap: 12px; + user-select: none; + transition: background-color 0.2s ease; + + &:hover { + background: ${({ theme }) => theme.colorsAlpha.greyscaleAlpha[10]}; + } +`; + +export const TextWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; +`; + +export const Title = styled.div` + font-size: 13px; + font-weight: 600; + line-height: 1.3; + margin: 0; + padding: 0; + color: ${({ theme }) => theme.palette.text.primary}; +`; + +export const Subtitle = styled.div` + color: ${({ theme }) => theme.palette.text.secondary}; + font-size: 11px; + font-weight: 500; + line-height: 1.4; + opacity: 0.7; +`; + +export const Content = styled(m.div) <{ $isOpen: boolean }>` + overflow: hidden; +`; + +export const ContentPadding = styled.div` + padding: 0 16px 12px 16px; +`; + +export const ChevronIcon = styled.svg<{ $isOpen: boolean }>` + width: 18px; + height: 18px; + transform: scaleY(1); + transition: transform 0.25s ease; + flex-shrink: 0; + color: ${({ theme }) => theme.palette.text.secondary}; + opacity: 0.6; + + ${({ $isOpen }) => + $isOpen && + css` + transform: scaleY(-1); + opacity: 1; + `} +`; diff --git a/src/components/transactions/history/BridgeHoveredItem.tsx b/src/components/transactions/history/BridgeHoveredItem.tsx deleted file mode 100644 index 6aca4d6aa7..0000000000 --- a/src/components/transactions/history/BridgeHoveredItem.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React, { memo } from 'react'; - -import { ButtonWrapper, HoverWrapper } from './ListItem.styles.ts'; - -interface Props { - button?: React.ReactNode; -} - -const BridgeItemHover = memo(function BridgeItemHover({ button }: Props) { - return ( - - - {button} - - - ); -}); - -export default BridgeItemHover; diff --git a/src/components/transactions/history/List.tsx b/src/components/transactions/history/List.tsx index a54644a05e..bfa45f5656 100644 --- a/src/components/transactions/history/List.tsx +++ b/src/components/transactions/history/List.tsx @@ -1,96 +1,106 @@ -import { useCallback, useEffect, RefObject } from 'react'; -import { useOnInView } from 'react-intersection-observer'; -import { useTranslation } from 'react-i18next'; -import { invoke } from '@tauri-apps/api/core'; +import { useCallback, useEffect, useState, useRef, useMemo } from 'react'; -import { CombinedBridgeWalletTransaction, useWalletStore } from '@app/store'; +import { useTranslation } from 'react-i18next'; +import { VList } from 'virtua'; -import { useFetchTxHistory } from '@app/hooks/wallet/useFetchTxHistory.ts'; +import { useWalletStore } from '@app/store'; -import { HistoryListItem } from './ListItem.tsx'; -import { PlaceholderItem } from './ListItem.styles.ts'; import { EmptyText, ListItemWrapper, ListWrapper } from './List.styles.ts'; -import { setDetailsItem } from '@app/store/actions/walletStoreActions.ts'; -import LoadingDots from '@app/components/elements/loaders/LoadingDots.tsx'; +import { setSelectedTransactionId } from '@app/store/actions/walletStoreActions.ts'; +import { DisplayedTransaction, TransactionSource } from '@app/types/app-status.ts'; +import { HistoryListItem } from './transactionHistoryItem/HistoryItem.tsx'; +import { PlaceholderItem } from './transactionHistoryItem/HistoryItem.styles.ts'; -interface ListProps { - setIsScrolled: (isScrolled: boolean) => void; - targetRef: RefObject | null; -} - -export function List({ setIsScrolled, targetRef }: ListProps) { +export function List() { const { t } = useTranslation('wallet'); const walletScanning = useWalletStore((s) => s.wallet_scanning); - const walletImporting = useWalletStore((s) => s.is_wallet_importing); - const walletIsLoading = useWalletStore((s) => s.isLoading); - const { data, fetchNextPage, isFetchingNextPage, isFetching, hasNextPage } = useFetchTxHistory(); + const walletTransactionsAll = useWalletStore((s) => s.wallet_transactions); + const transactionsFilter = useWalletStore((s) => s.transaction_history_filter); - // TODO clean up - const walletLoading = walletImporting || walletScanning?.is_scanning || isFetching || walletIsLoading; + console.log('Rendering Transaction History List with filter:', walletTransactionsAll, transactionsFilter); - useEffect(() => { - const el = targetRef?.current; - if (!el) return; - const onScroll = () => setIsScrolled(el.scrollTop > 1); - el.addEventListener('scroll', onScroll); - return () => el.removeEventListener('scroll', onScroll); - }, [targetRef, setIsScrolled]); - - const ref = useOnInView((inView) => { - if (inView && hasNextPage && !isFetching) { - void fetchNextPage({ cancelRefetch: false }); + const walletTransactions = useMemo(() => { + if (!walletTransactionsAll) return []; + + switch (transactionsFilter) { + case 'all-activity': + return walletTransactionsAll; + case 'rewards': + return walletTransactionsAll.filter((tx) => tx.source === TransactionSource.Coinbase); + case 'transactions': + return walletTransactionsAll.filter((tx) => tx.source !== TransactionSource.Coinbase); + default: + return walletTransactionsAll; } - }); - const transactions = data?.pages.flatMap((page) => page) || []; + }, [walletTransactionsAll, transactionsFilter]); + + // Track seen transaction IDs to show "new" indicator for new transactions + const [seenTransactionIds, setSeenTransactionIds] = useState>(new Set()); + const isInitialLoad = useRef(true); - const handleDetailsChange = useCallback(async (transaction: CombinedBridgeWalletTransaction | null) => { - if (!transaction || !transaction.walletTransactionDetails) { - setDetailsItem(null); - return; + // Mark all transactions as seen on initial load (so they don't show as "new") + useEffect(() => { + if (isInitialLoad.current && walletTransactions && walletTransactions.length > 0) { + const initialIds = new Set(walletTransactions.map((tx) => tx.id)); + setSeenTransactionIds(initialIds); + isInitialLoad.current = false; } - const dest_address_emoji = await invoke('parse_tari_address', { address: transaction.destinationAddress }) - .then((result) => result?.emoji_string) - .catch(() => undefined); - - setDetailsItem({ - ...transaction, - walletTransactionDetails: { - ...transaction.walletTransactionDetails, - destAddressEmoji: dest_address_emoji, - }, - }); + }, [walletTransactions]); + + // Mark new transactions as seen after 30 seconds + useEffect(() => { + if (!walletTransactions || isInitialLoad.current) return; + + const newTransactionIds = walletTransactions.filter((tx) => !seenTransactionIds.has(tx.id)).map((tx) => tx.id); + + if (newTransactionIds.length === 0) return; + + const timer = setTimeout(() => { + setSeenTransactionIds((prev) => { + const updated = new Set(prev); + newTransactionIds.forEach((id) => updated.add(id)); + return updated; + }); + }, 30000); // 30 seconds + + return () => clearTimeout(timer); + }, [walletTransactions, seenTransactionIds]); + + const handleDetailsChange = useCallback((transaction: DisplayedTransaction) => { + setSelectedTransactionId(transaction.id); }, []); // Calculate how many placeholder items we need to add - const transactionsCount = transactions?.length || 0; + const transactionsCount = walletTransactions?.length || 0; const placeholdersNeeded = Math.max(0, 5 - transactionsCount); - const listMarkup = ( - - {transactions?.map((tx, i) => { - const txId = tx.walletTransactionDetails?.txId || tx.paymentId; - const hash = tx.bridgeTransactionDetails?.transactionHash; - const hasNoId = !txId && !hash?.length; - - const itemKey = `ListItem_${txId}-${hash}-${hasNoId ? i : ''}`; - return ; - })} - - {/* fill the list with placeholders if there are less than 4 entries */} - {Array.from({ length: placeholdersNeeded }).map((_, index) => ( - - ))} - {isFetchingNextPage || isFetching ? : null} - - ); - const isEmpty = !walletLoading && !transactions?.length; + const isEmpty = !walletTransactionsAll?.length; const emptyMarkup = isEmpty ? {t('empty-tx')} : null; + return ( {emptyMarkup} - {listMarkup} - {/*added placeholder so the scroll can trigger fetch*/} - {!walletScanning?.is_scanning ? : null} + + + {walletTransactions?.map((tx, i) => { + const isNewTransaction = !seenTransactionIds.has(tx.id); + return ( + + ); + })} + + {/* fill the list with placeholders if there are less than 4 entries */} + {Array.from({ length: placeholdersNeeded }).map((_, index) => ( + + ))} + + ); } diff --git a/src/components/transactions/history/ListItem.tsx b/src/components/transactions/history/ListItem.tsx deleted file mode 100644 index ed934532d0..0000000000 --- a/src/components/transactions/history/ListItem.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { memo, useRef, useState } from 'react'; -import { AnimatePresence } from 'motion/react'; -import { formatNumber, FormatPreset, truncateMiddle } from '@app/utils'; -import { HistoryListItemProps } from '../types.ts'; -import { formatTimeStamp } from './helpers.ts'; -import ItemHover from './HoveredItem'; -import { - BlockInfoWrapper, - Content, - ContentWrapper, - CurrencyText, - ItemWrapper, - TimeWrapper, - TitleWrapper, - ValueChangeWrapper, - ValueWrapper, -} from './ListItem.styles.ts'; -import { useUIStore } from '@app/store'; -import { useTranslation } from 'react-i18next'; -import { Button } from '@app/components/elements/buttons/Button.tsx'; - -import { getTxTitle, getTxTypeByStatus } from '@app/utils/getTxStatus.ts'; -import { TransactionDirection } from '@app/types/transactions.ts'; -import BridgeItemHover from './BridgeHoveredItem.tsx'; - -const HistoryListItem = memo(function ListItem({ item, index, setDetailsItem }: HistoryListItemProps) { - const { t } = useTranslation('wallet'); - const hideWalletBalance = useUIStore((s) => s.hideWalletBalance); - const ref = useRef(null); - const itemType = getTxTypeByStatus(item); - const isMined = itemType === 'mined'; - - const [hovering, setHovering] = useState(false); - - const itemTitle = getTxTitle(item); - const earningsFormatted = hideWalletBalance - ? `***` - : formatNumber(item.tokenAmount, FormatPreset.XTM_COMPACT).toLowerCase(); - const earningsFull = hideWalletBalance - ? `***` - : formatNumber(item.tokenAmount, FormatPreset.XTM_LONG).toLowerCase(); - const itemTime = formatTimeStamp(item.createdAt); - - // note re. isPositiveValue: - // amounts in the tx response are always positive numbers but - // if the transaction is Outbound, the value is negative - const isPositiveValue = item.walletTransactionDetails.direction === TransactionDirection.Inbound; - const displayTitle = itemTitle.length > 26 ? truncateMiddle(itemTitle, 8) : itemTitle; - - const getValueMarkup = (fullValue = false) => ( - - {!hideWalletBalance && ( - - {isPositiveValue ? `+` : `-`} - - )} - {fullValue ? earningsFull : earningsFormatted} - {`XTM`} - - ); - const baseItem = ( - - - - {displayTitle} - {itemTime} - - - {getValueMarkup()} - - ); - - const detailsButton = !isMined ? ( - <> - {getValueMarkup(true)} - - - ) : null; - - return ( - setHovering(true)} - onMouseLeave={() => setHovering(false)} - > - {item.bridgeTransactionDetails ? ( - {hovering && } - ) : ( - {hovering && } - )} - {baseItem} - - ); -}); - -export { HistoryListItem }; diff --git a/src/components/transactions/history/details/TransactionDetails.tsx b/src/components/transactions/history/details/TransactionDetails.tsx deleted file mode 100644 index 31bd83f2f8..0000000000 --- a/src/components/transactions/history/details/TransactionDetails.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import TransactionModal from '@app/components/TransactionModal/TransactionModal.tsx'; -import { Button } from '@app/components/elements/buttons/Button.tsx'; -import { IoCheckmarkOutline, IoCopyOutline } from 'react-icons/io5'; -import { useCopyToClipboard } from '@app/hooks/helpers/useCopyToClipboard.ts'; -import { Wrapper } from './styles.ts'; -import { StatusList } from '@app/components/transactions/components/StatusList/StatusList.tsx'; -import { getListEntries } from './getListEntries.tsx'; -import { CombinedBridgeWalletTransaction } from '@app/store'; - -interface TransactionDetailsProps { - item: CombinedBridgeWalletTransaction; - expanded: boolean; - handleClose: () => void; -} - -export const TransactionDetails = ({ item, expanded, handleClose }: TransactionDetailsProps) => { - const { copyToClipboard, isCopied } = useCopyToClipboard(); - const { t } = useTranslation('wallet'); - - const entries = getListEntries(item); - - const copyIcon = !isCopied ? : ; - - return ( - - - - - - - ); -}; diff --git a/src/components/transactions/history/details/getListEntries.tsx b/src/components/transactions/history/details/getListEntries.tsx deleted file mode 100644 index 898edebf55..0000000000 --- a/src/components/transactions/history/details/getListEntries.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import i18n from 'i18next'; -import { ReactNode } from 'react'; -import { formatTimeStamp } from '@app/components/transactions/history/helpers.ts'; -import { formatNumber, FormatPreset } from '@app/utils'; -import { StatusListEntry } from '@app/components/transactions/components/StatusList/StatusList.tsx'; -import { getExplorerUrl, Network } from '@app/utils/network.ts'; -import { CombinedBridgeWalletTransaction, useNodeStore, useMiningStore } from '@app/store'; -import { getTxStatusTitleKey, getTxTitle } from '@app/utils/getTxStatus.ts'; -import { EmojiAddressWrapper } from '@app/components/transactions/history/details/styles.ts'; - -const network = useMiningStore.getState().network; -const keyTranslations: Record = { - sourceAddress: 'wallet:receive.label-address', - destinationAddress: 'wallet:send.destination-address', - paymentId: 'wallet:send.transaction-description', - feeAmount: 'wallet:send.network-fee', - createdAt: 'Created at', - tokenAmount: 'wallet:send.label-amount', - mined_in_block_height: 'Block height', -}; -function capitalizeKey(key: string): string { - return key - .split('_') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); -} -function getLabel(key: string): string { - return key in keyTranslations ? i18n.t(keyTranslations[key]) : capitalizeKey(key); -} -function getPaymentReferenceValue(transaction: CombinedBridgeWalletTransaction): ReactNode { - if (transaction.walletTransactionDetails.paymentReference) { - return transaction.walletTransactionDetails.paymentReference; - } - const currentBlockHeight = useNodeStore.getState().base_node_status?.block_height; - if (!transaction.mined_in_block_height || !currentBlockHeight) { - return i18n.t('common:pending'); - } - const confirmations = currentBlockHeight - transaction.mined_in_block_height; - if (confirmations < 5) { - return i18n.t('common:waiting-for-confirmations', { confirmations, total: 5 }); - } - return i18n.t('common:not-available'); -} - -const UNIFIED_DISPLAY_ORDER = [ - 'paymentId', - 'paymentReference', - 'txId', - 'tokenAmount', - 'feeAmount', - 'createdAt', - - 'mined_in_block_height', - 'status', - - 'sourceAddress', - 'destinationAddress', - 'destAddressEmoji', // Emoji address, if available -]; - -// Helper function to get custom display order for specific transaction types -function getUnifiedDisplayOrder(transaction: CombinedBridgeWalletTransaction): string[] { - // For bridge transactions, we might want a different order - if (transaction.bridgeTransactionDetails) { - return [ - 'destinationAddress', - 'sourceAddress', - 'tokenAmount', - 'amountAfterFee', - 'feeAmount', - 'createdAt', - 'mined_in_block_height', - 'transactionHash', - 'status', - 'txId', - ]; - } - - return UNIFIED_DISPLAY_ORDER; -} - -const unifiedValueHandlers: Record< - string, - (transaction: CombinedBridgeWalletTransaction) => (Partial & { value: ReactNode }) | null -> = { - createdAt: (transaction) => ({ value: formatTimeStamp(transaction.createdAt) }), - - paymentId: (transaction) => ({ value: getTxTitle(transaction) }), - - tokenAmount: (transaction) => { - const preset = - transaction.tokenAmount.toString().length > 5 ? FormatPreset.XTM_LONG : FormatPreset.XTM_DECIMALS; - const valueMarkup = ( - <> - {formatNumber(Number(transaction.tokenAmount), preset)} - {` XTM`} - - ); - return { - value: valueMarkup, - valueRight: `${formatNumber(Number(transaction.tokenAmount), FormatPreset.DECIMAL_COMPACT)} µXTM`, - }; - }, - - feeAmount: (transaction) => { - const preset = transaction.feeAmount.toString().length > 5 ? FormatPreset.XTM_LONG : FormatPreset.XTM_DECIMALS; - const valueMarkup = ( - <> - {formatNumber(Number(transaction.feeAmount), preset)} - {` XTM`} - - ); - return { - value: valueMarkup, - valueRight: `${formatNumber(Number(transaction.feeAmount), FormatPreset.DECIMAL_COMPACT)} µXTM`, - }; - }, - - mined_in_block_height: (transaction) => { - const rest: Partial = {}; - if (transaction.mined_in_block_height) { - const explorerURL = getExplorerUrl(network === Network.MainNet); - rest['externalLink'] = `${explorerURL}/blocks/${transaction.mined_in_block_height}`; - } - return { value: transaction.mined_in_block_height || 'Pending', ...rest }; - }, - - destinationAddress: (transaction) => { - // Show ETH address if it's a bridge transaction - if (transaction.bridgeTransactionDetails) { - return { label: 'Destination address [ ETH ]', value: transaction.destinationAddress }; - } - return { value: transaction.destinationAddress }; - }, - - destAddressEmoji: (transaction) => { - // Return emoji address if available - if (transaction.walletTransactionDetails.destAddressEmoji) { - return { - label: getLabel('destAddressEmoji'), - value: ( - {transaction.walletTransactionDetails.destAddressEmoji} - ), - }; - } - return null; - }, - - sourceAddress: (transaction) => ({ value: transaction.sourceAddress }), - - status: (transaction) => { - const statusKey = getTxStatusTitleKey(transaction); - if (!statusKey) return null; - const valueRight = transaction.bridgeTransactionDetails?.status || transaction.walletTransactionDetails.status; - - return { - label: i18n.t('common:status'), - value: i18n.t(`common:${statusKey}`), - valueRight: valueRight.toString(), - }; - }, - - paymentReference: (transaction) => { - return { - label: 'Payment Reference', - value: getPaymentReferenceValue(transaction), - }; - }, - - txId: (transaction) => { - if (!transaction.walletTransactionDetails.txId) return null; - - return { - label: i18n.t('wallet:send.transaction-id'), - value: transaction.walletTransactionDetails.txId.toString(), - }; - }, - - amountAfterFee: (transaction) => { - const bridgeDetails = transaction.bridgeTransactionDetails; - if (!bridgeDetails?.amountAfterFee) return null; - - const preset = - bridgeDetails.amountAfterFee.toString().length > 5 ? FormatPreset.XTM_LONG : FormatPreset.XTM_DECIMALS; - const valueMarkup = ( - <> - {formatNumber(Number(bridgeDetails.amountAfterFee), preset)} - {` XTM`} - - ); - return { - label: 'Amount after fee', - value: valueMarkup, - valueRight: `${formatNumber(Number(bridgeDetails.amountAfterFee), FormatPreset.DECIMAL_COMPACT)} µXTM`, - }; - }, - - transactionHash: (transaction) => { - const bridgeDetails = transaction.bridgeTransactionDetails; - if (!bridgeDetails?.transactionHash) return null; - - return { - label: 'Transaction Hash', - value: bridgeDetails.transactionHash, - }; - }, - - // Skip nested objects in main processing - walletTransactionDetails: () => null, - bridgeTransactionDetails: () => null, -}; - -function parseUnifiedTransactionValue( - key: string, - transaction: CombinedBridgeWalletTransaction -): (Partial & { value: ReactNode }) | null { - // Use unified handler if available - const handler = unifiedValueHandlers[key]; - if (handler) { - return handler(transaction); - } - - // Default fallback for unhandled keys - get from main transaction object - const value = transaction[key]; - if (value === undefined) return null; - - return { value: String(value) }; -} - -export function getListEntries(item: CombinedBridgeWalletTransaction): StatusListEntry[] { - const displayOrder = getUnifiedDisplayOrder(item); - const unifiedEntries: StatusListEntry[] = []; - - for (const key of displayOrder) { - const result = parseUnifiedTransactionValue(key, item); - - // Skip null results (e.g., missing bridge details for regular transactions, or hidden fields) - if (!result) continue; - - const { value, label, ...rest } = result; - unifiedEntries.push({ - label: label || getLabel(key), - value, - ...rest, - }); - } - - return unifiedEntries; -} diff --git a/src/components/transactions/history/details/styles.ts b/src/components/transactions/history/details/styles.ts deleted file mode 100644 index f47f3ea11d..0000000000 --- a/src/components/transactions/history/details/styles.ts +++ /dev/null @@ -1,37 +0,0 @@ -import styled from 'styled-components'; - -export const Wrapper = styled.div` - display: flex; - max-height: 76vh; - overflow-y: auto; - scrollbar-width: auto; - scrollbar-color: rgba(0, 0, 0, 0.15) rgba(0, 0, 0, 0.05); - scrollbar-gutter: stable both-edges; - - &::-webkit-scrollbar { - scrollbar-width: auto; - width: 5px; - display: block !important; - } - - &::-webkit-scrollbar-track { - background: transparent; - } - - &::-webkit-scrollbar-thumb { - background: ${({ theme }) => theme.palette.divider}; - border-radius: 3px; - } - - &::-webkit-scrollbar-thumb:hover { - background: ${({ theme }) => theme.palette.text.accent}; - } -`; - -export const EmojiAddressWrapper = styled.div` - display: flex; - width: 100%; - line-height: 1.4; - letter-spacing: 2px; - font-size: 13px; -`; diff --git a/src/components/transactions/history/helpers.ts b/src/components/transactions/history/helpers.ts index b1852d1bf0..7cc9002c8d 100644 --- a/src/components/transactions/history/helpers.ts +++ b/src/components/transactions/history/helpers.ts @@ -1,13 +1,57 @@ +import i18n from 'i18next'; +import { DisplayedTransaction } from '@app/types/app-status'; +import { TransactionType } from '../types'; import { useConfigUIStore } from '@app/store'; -export function formatTimeStamp(timestamp: number): string { +/** + * Formats the blockchain timestamp from DisplayedTransaction into a readable format + * @param timestamp - ISO 8601 date string (e.g., "2025-05-13T05:25:43") + * @returns Formatted date string (e.g., "May 13, 05:25") + */ +export const formatEffectiveDate = (timestamp: string): string => { const appLanguage = useConfigUIStore.getState().application_language; const systemLang = useConfigUIStore.getState().should_always_use_system_language; - return new Date(timestamp * 1000)?.toLocaleString(systemLang ? undefined : appLanguage, { + + return new Date(timestamp)?.toLocaleString(systemLang ? undefined : appLanguage, { month: 'short', day: '2-digit', hourCycle: 'h23', hour: 'numeric', minute: 'numeric', }); -} +}; + +export const resolveTransactionType = (transaction: DisplayedTransaction): TransactionType => { + // Check if it's a coinbase (mining) transaction + if (transaction.source === 'coinbase') { + return 'mined'; + } + // Check direction for sent/received + if (transaction.direction === 'outgoing') { + return 'sent'; + } + return 'received'; +}; + +export const resolveTransactionTitle = (transaction: DisplayedTransaction): string => { + const itemType = resolveTransactionType(transaction); + + if (transaction.bridge_transaction_details) { + return 'Bridge XTM to WXTM'; + } + + const typeTitle = i18n.t(`common:${itemType}`); + + // For mined transactions, show block number + if (itemType === 'mined' && transaction.blockchain.block_height) { + return `${i18n.t('sidebar:block')} #${transaction.blockchain.block_height}`; + } + + // If there's a message/memo, return it + if (transaction.message && !transaction.message.includes('')) { + return transaction.message; + } + + // Default to transaction type + return typeTitle; +}; diff --git a/src/components/transactions/history/transactionDetails/TransactionDetails.tsx b/src/components/transactions/history/transactionDetails/TransactionDetails.tsx new file mode 100644 index 0000000000..a3eb38ff74 --- /dev/null +++ b/src/components/transactions/history/transactionDetails/TransactionDetails.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import TransactionModal from '@app/components/TransactionModal/TransactionModal.tsx'; +import { Button } from '@app/components/elements/buttons/Button.tsx'; +import { IoCheckmarkOutline, IoCopyOutline } from 'react-icons/io5'; +import { useCopyToClipboard } from '@app/hooks/helpers/useCopyToClipboard.ts'; +import { StatusList } from '@app/components/transactions/components/StatusList/StatusList.tsx'; +import { AccordionItem } from '@app/components/transactions/components/AccordionItem/AccordionItem.tsx'; +import { DisplayedTransaction, TransactionInput, TransactionOutput } from '@app/types/app-status.ts'; +import { getTransactionListEntries, getInputDetails, getOutputDetails } from './getTransactionListEntries.tsx'; +import { Wrapper, OperationsSection, OperationsTitle } from './styles.ts'; + +interface TransactionDetailsProps { + transaction: DisplayedTransaction; + expanded: boolean; + handleClose: () => void; +} + +export const TransactionDetails = ({ transaction, expanded, handleClose }: TransactionDetailsProps) => { + const { copyToClipboard, isCopied } = useCopyToClipboard(); + const { t } = useTranslation('wallet'); + + // Track which accordions are open + const [openOperations, setOpenOperations] = useState>(new Set()); + + const toggleOperation = (index: number) => { + setOpenOperations((prev) => { + const newSet = new Set(prev); + if (newSet.has(index)) { + newSet.delete(index); + } else { + newSet.add(index); + } + return newSet; + }); + }; + + const mainEntries = getTransactionListEntries(transaction); + const copyIcon = !isCopied ? : ; + + // Get inputs and outputs from details + const inputs = transaction.details.inputs || []; + const outputs = transaction.details.outputs || []; + const hasDetails = inputs.length > 0 || outputs.length > 0; + + return ( + + + {/* Main transaction details */} + + + {/* Operations section */} + {hasDetails && ( + + {`Details (${inputs.length + outputs.length})`} + + {/* Render inputs */} + {inputs.map((input: TransactionInput, index: number) => { + const operationEntries = getInputDetails(input); + const subtitle = `Amount: ${input.amount} µXTM`; + + return ( + toggleOperation(index)} + content={} + /> + ); + })} + + {/* Render outputs */} + {outputs.map((output: TransactionOutput, index: number) => { + const operationEntries = getOutputDetails(output); + const subtitle = `Amount: ${output.amount} µXTM • ${output.output_type}`; + const outputIndex = inputs.length + index; + + return ( + toggleOperation(outputIndex)} + content={} + /> + ); + })} + + )} + + + + + ); +}; diff --git a/src/components/transactions/history/transactionDetails/getTransactionListEntries.tsx b/src/components/transactions/history/transactionDetails/getTransactionListEntries.tsx new file mode 100644 index 0000000000..b37ab699d8 --- /dev/null +++ b/src/components/transactions/history/transactionDetails/getTransactionListEntries.tsx @@ -0,0 +1,330 @@ +import i18n from 'i18next'; +import { ReactNode } from 'react'; +import { formatNumber, FormatPreset } from '@app/utils'; +import { StatusListEntry } from '@app/components/transactions/components/StatusList/StatusList.tsx'; +import { + DisplayedTransaction, + TransactionInput, + TransactionOutput, + TransactionDirection, + TransactionSource, + TransactionDisplayStatus, +} from '@app/types/app-status.ts'; +import { EmojiAddressWrapper } from '@app/components/transactions/history/transactionDetails/styles.ts'; +import { formatEffectiveDate } from '../helpers'; + +enum TransactionField { + Status = 'status', + Type = 'type', + Direction = 'direction', + Amount = 'amount', + Fee = 'fee', + Date = 'date', + BlockHeight = 'blockHeight', + Confirmations = 'confirmations', + CounterpartyAddress = 'counterpartyAddress', + CounterpartyEmoji = 'counterpartyEmoji', + Message = 'message', + BridgeStatus = 'bridgeStatus', + BridgeTransactionHash = 'bridgeTransactionHash', + CoinbaseExtra = 'coinbaseExtra', +} + +const TRANSACTION_FIELD_ORDER: TransactionField[] = [ + TransactionField.Status, + TransactionField.Type, + TransactionField.Direction, + TransactionField.Amount, + TransactionField.Fee, + TransactionField.Date, + TransactionField.BlockHeight, + TransactionField.Confirmations, + TransactionField.CounterpartyAddress, + TransactionField.CounterpartyEmoji, + TransactionField.Message, + TransactionField.BridgeStatus, + TransactionField.BridgeTransactionHash, + TransactionField.CoinbaseExtra, +]; + +enum InputField { + Amount = 'amount', + OutputHash = 'outputHash', + Matched = 'matched', +} + +const INPUT_FIELD_ORDER: InputField[] = [InputField.Amount, InputField.OutputHash, InputField.Matched]; + +enum OutputField { + Amount = 'amount', + Status = 'status', + OutputType = 'outputType', + Hash = 'hash', + ConfirmedHeight = 'confirmedHeight', + IsChange = 'isChange', +} + +const OUTPUT_FIELD_ORDER: OutputField[] = [ + OutputField.Amount, + OutputField.Status, + OutputField.OutputType, + OutputField.Hash, + OutputField.ConfirmedHeight, + OutputField.IsChange, +]; + +interface OrderedEntry extends StatusListEntry { + field: TransactionField | InputField | OutputField; +} + +function sortByFieldOrder(entries: OrderedEntry[], fieldOrder: T[]): StatusListEntry[] { + return entries + .sort((a, b) => { + const indexA = fieldOrder.indexOf(a.field as T); + const indexB = fieldOrder.indexOf(b.field as T); + return indexA - indexB; + }) + .map(({ field: _, ...entry }) => entry); +} + +function formatMicroTari(value: number): ReactNode { + const preset = value.toString().length > 5 ? FormatPreset.XTM_LONG : FormatPreset.XTM_DECIMALS; + return ( + <> + {formatNumber(Number(value), preset)} + {` XTM`} + + ); +} + +function getDirectionLabel(direction: TransactionDirection): string { + switch (direction) { + case TransactionDirection.Incoming: + return 'Received'; + case TransactionDirection.Outgoing: + return 'Sent'; + default: + return direction; + } +} + +function getSourceLabel(source: TransactionSource): string { + switch (source) { + case TransactionSource.Coinbase: + return 'Mining Reward'; + case TransactionSource.OneSided: + return 'One-sided Payment'; + case TransactionSource.Transfer: + return 'Transfer'; + case TransactionSource.Unknown: + default: + return 'Transaction'; + } +} + +function getStatusLabel(status: TransactionDisplayStatus): string { + switch (status) { + case TransactionDisplayStatus.Pending: + return 'Pending'; + case TransactionDisplayStatus.Unconfirmed: + return 'Unconfirmed'; + case TransactionDisplayStatus.Confirmed: + return 'Confirmed'; + case TransactionDisplayStatus.Cancelled: + return 'Cancelled'; + case TransactionDisplayStatus.Reorganized: + return 'Reorganized'; + case TransactionDisplayStatus.Rejected: + return 'Rejected'; + default: + return status; + } +} + +export function getTransactionListEntries(transaction: DisplayedTransaction): StatusListEntry[] { + const entries: OrderedEntry[] = []; + + entries.push({ + field: TransactionField.BlockHeight, + label: 'Block Height', + value: transaction.blockchain.block_height.toString(), + }); + + entries.push({ + field: TransactionField.Date, + label: 'Date', + value: formatEffectiveDate(transaction.blockchain.timestamp), + }); + + entries.push({ + field: TransactionField.Direction, + label: 'Direction', + value: getDirectionLabel(transaction.direction), + }); + + entries.push({ + field: TransactionField.Type, + label: 'Type', + value: getSourceLabel(transaction.source), + }); + + entries.push({ + field: TransactionField.Status, + label: i18n.t('common:status'), + value: getStatusLabel(transaction.status), + }); + + const isNegative = transaction.direction === TransactionDirection.Outgoing; + const balancePreset = transaction.amount.toString().length > 5 ? FormatPreset.XTM_LONG : FormatPreset.XTM_DECIMALS; + entries.push({ + field: TransactionField.Amount, + label: 'Amount', + value: ( + <> + {isNegative ? '-' : '+'} + {formatNumber(Number(transaction.amount), balancePreset)} + {` XTM`} + + ), + valueRight: `${formatNumber(Number(transaction.amount), FormatPreset.DECIMAL_COMPACT)} µXTM`, + }); + + if (transaction.fee) { + entries.push({ + field: TransactionField.Fee, + label: 'Fee', + value: formatMicroTari(transaction.fee.amount), + valueRight: `${formatNumber(Number(transaction.fee.amount), FormatPreset.DECIMAL_COMPACT)} µXTM`, + }); + } + + if (transaction.blockchain.confirmations > 0) { + entries.push({ + field: TransactionField.Confirmations, + label: 'Confirmations', + value: transaction.blockchain.confirmations.toString(), + }); + } + + if (transaction.counterparty?.address) { + entries.push({ + field: TransactionField.CounterpartyAddress, + label: transaction.direction === TransactionDirection.Incoming ? 'From Address' : 'To Address', + value: transaction.counterparty.address, + }); + } + + if (transaction.counterparty?.address_emoji) { + entries.push({ + field: TransactionField.CounterpartyEmoji, + label: transaction.direction === TransactionDirection.Incoming ? 'From (Emoji)' : 'To (Emoji)', + value: {transaction.counterparty.address_emoji}, + }); + } + + if (transaction.message) { + entries.push({ + field: TransactionField.Message, + label: 'Message', + value: transaction.message, + }); + } + + if (transaction.bridge_transaction_details) { + entries.push({ + field: TransactionField.BridgeStatus, + label: 'Bridge Status', + value: transaction.bridge_transaction_details.status, + }); + + if (transaction.bridge_transaction_details.transactionHash) { + entries.push({ + field: TransactionField.BridgeTransactionHash, + label: 'Transaction Hash', + value: transaction.bridge_transaction_details.transactionHash, + }); + } + } + + if (transaction.details.coinbase_extra) { + entries.push({ + field: TransactionField.CoinbaseExtra, + label: 'Coinbase Extra', + value: transaction.details.coinbase_extra, + }); + } + + return sortByFieldOrder(entries, TRANSACTION_FIELD_ORDER); +} + +export function getInputDetails(input: TransactionInput): StatusListEntry[] { + const entries: OrderedEntry[] = []; + + entries.push({ + field: InputField.Amount, + label: 'Amount', + value: formatMicroTari(input.amount), + valueRight: `${formatNumber(Number(input.amount), FormatPreset.DECIMAL_COMPACT)} µXTM`, + }); + + entries.push({ + field: InputField.OutputHash, + label: 'Output Hash', + value: input.output_hash, + }); + + entries.push({ + field: InputField.Matched, + label: 'Matched', + value: input.is_matched ? 'Yes' : 'No', + }); + + return sortByFieldOrder(entries, INPUT_FIELD_ORDER); +} + +export function getOutputDetails(output: TransactionOutput): StatusListEntry[] { + const entries: OrderedEntry[] = []; + + entries.push({ + field: OutputField.Amount, + label: 'Amount', + value: formatMicroTari(output.amount), + valueRight: `${formatNumber(Number(output.amount), FormatPreset.DECIMAL_COMPACT)} µXTM`, + }); + + entries.push({ + field: OutputField.Status, + label: 'Status', + value: output.status, + }); + + entries.push({ + field: OutputField.OutputType, + label: 'Output Type', + value: output.output_type, + }); + + entries.push({ + field: OutputField.Hash, + label: 'Hash', + value: output.hash, + }); + + if (output.confirmed_height) { + entries.push({ + field: OutputField.ConfirmedHeight, + label: 'Confirmed Height', + value: output.confirmed_height.toString(), + }); + } + + if (output.is_change) { + entries.push({ + field: OutputField.IsChange, + label: 'Change Output', + value: 'Yes', + }); + } + + return sortByFieldOrder(entries, OUTPUT_FIELD_ORDER); +} diff --git a/src/components/transactions/history/transactionDetails/styles.ts b/src/components/transactions/history/transactionDetails/styles.ts new file mode 100644 index 0000000000..435c20c9ce --- /dev/null +++ b/src/components/transactions/history/transactionDetails/styles.ts @@ -0,0 +1,70 @@ +import styled from 'styled-components'; + +export const Wrapper = styled.div` + display: flex; + flex-direction: column; + max-height: 70vh; + overflow-y: auto; + overflow-x: hidden; + padding: 0; + + /* Custom scrollbar */ + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: ${({ theme }) => theme.colorsAlpha.greyscaleAlpha[20]}; + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb:hover { + background: ${({ theme }) => theme.colorsAlpha.greyscaleAlpha[30]}; + } +`; + +export const EmojiAddressWrapper = styled.div` + display: flex; + width: 100%; + line-height: 1.4; + letter-spacing: 2px; + font-size: 13px; +`; + +export const OperationsSection = styled.div` + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const OperationsTitle = styled.h3` + font-size: 14px; + font-weight: 600; + margin: 0 0 12px 0; + padding: 12px 0 8px 0; + color: ${({ theme }) => theme.palette.text.primary}; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.8; +`; + +export const OutputDetailsSection = styled.div` + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid ${({ theme }) => theme.colorsAlpha.greyscaleAlpha[10]}; +`; + +export const OutputDetailsSectionTitle = styled.div` + font-size: 12px; + font-weight: 600; + margin: 0 0 8px 0; + color: ${({ theme }) => theme.palette.text.secondary}; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.8; +`; diff --git a/src/components/transactions/history/ListItem.styles.ts b/src/components/transactions/history/transactionHistoryItem/HistoryItem.styles.ts similarity index 100% rename from src/components/transactions/history/ListItem.styles.ts rename to src/components/transactions/history/transactionHistoryItem/HistoryItem.styles.ts diff --git a/src/components/transactions/history/transactionHistoryItem/HistoryItem.tsx b/src/components/transactions/history/transactionHistoryItem/HistoryItem.tsx new file mode 100644 index 0000000000..78a522d0f0 --- /dev/null +++ b/src/components/transactions/history/transactionHistoryItem/HistoryItem.tsx @@ -0,0 +1,162 @@ +import { memo, useRef, useState } from 'react'; +import { AnimatePresence } from 'motion/react'; +import { formatNumber, FormatPreset, truncateMiddle } from '@app/utils'; +import { + BlockInfoWrapper, + Content, + ContentWrapper, + CurrencyText, + ItemWrapper, + TimeWrapper, + TitleWrapper, + ValueChangeWrapper, + ValueWrapper, +} from './HistoryItem.styles.ts'; +import { useUIStore } from '@app/store'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@app/components/elements/buttons/Button.tsx'; + +import { TransactionDirection as UITransactionDirection } from '@app/types/transactions.ts'; +import { DisplayedTransaction, TransactionDirection } from '@app/types/app-status.ts'; +import HoverItem from './HoverItem.tsx'; +import { formatEffectiveDate, resolveTransactionTitle, resolveTransactionType } from '../helpers.ts'; + +export interface HistoryListItemProps { + transaction: DisplayedTransaction; + index: number; + itemIsNew?: boolean; + setDetailsItem?: (item: DisplayedTransaction) => void; +} + +export interface BaseItemProps { + title: string; + direction: UITransactionDirection; + time: string; + value: string; + chip?: string; + onClick?: () => void; + hideWalletBalance?: boolean; +} + +const BaseItem = memo(function BaseItem({ + title, + time, + value, + direction, + chip, + onClick, + hideWalletBalance, +}: BaseItemProps) { + const isPositiveValue = direction === UITransactionDirection.Inbound; + const displayTitle = title.length > 26 ? truncateMiddle(title, 8) : title; + return ( + + + + {displayTitle} + {time} + + + + {chip ? ( + + {chip} + + ) : null} + + + {!hideWalletBalance && ( + + {isPositiveValue ? `+` : `-`} + + )} + {value} + {`XTM`} + + + + ); +}); + +const HistoryListItem = memo(function HistoryListItem({ + transaction, + index, + itemIsNew = false, + setDetailsItem, +}: HistoryListItemProps) { + const { t } = useTranslation('wallet'); + const hideWalletBalance = useUIStore((s) => s.hideWalletBalance); + const ref = useRef(null); + + const itemType = resolveTransactionType(transaction); + const isMined = itemType === 'mined'; + + const [hovering, setHovering] = useState(false); + + const itemTitle = resolveTransactionTitle(transaction); + const earningsFormatted = hideWalletBalance + ? `***` + : formatNumber(transaction.amount, FormatPreset.XTM_COMPACT).toLowerCase(); + + // note re. isPositiveValue: + // amounts in the tx response are always positive numbers but + // if the transaction is Outbound, the value is negative + const isPositiveValue = item.walletTransactionDetails.direction === TransactionDirection.Inbound; + const displayTitle = itemTitle.length > 26 ? truncateMiddle(itemTitle, 8) : itemTitle; + + const getValueMarkup = (fullValue = false) => ( + + {!hideWalletBalance && ( + + {isPositiveValue ? `+` : `-`} + + )} + {fullValue ? earningsFull : earningsFormatted} + {`XTM`} + + ); + const baseItem = ( + + ); + + const detailsButton = !isMined ? ( + + ) : null; + + return ( + setHovering(true)} + onMouseLeave={() => setHovering(false)} + > + + {hovering && } + + {baseItem} + + ); +}); + +export { HistoryListItem }; diff --git a/src/components/transactions/history/HoveredItem.tsx b/src/components/transactions/history/transactionHistoryItem/HoverItem.tsx similarity index 87% rename from src/components/transactions/history/HoveredItem.tsx rename to src/components/transactions/history/transactionHistoryItem/HoverItem.tsx index a87bca995e..3259a279e8 100644 --- a/src/components/transactions/history/HoveredItem.tsx +++ b/src/components/transactions/history/transactionHistoryItem/HoverItem.tsx @@ -5,18 +5,18 @@ import { GIFT_GEMS, useAirdropStore } from '@app/store/useAirdropStore.ts'; import { useShareRewardStore } from '@app/store/useShareRewardStore.ts'; import gemImage from '@app/assets/images/gem.png'; -import { handleWinReplay } from '@app/store/useBlockchainVisualisationStore.ts'; import { ReplaySVG } from '@app/assets/icons/replay.tsx'; -import { ButtonWrapper, FlexButton, GemImage, GemPill, HoverWrapper, ReplayButton } from './ListItem.styles.ts'; +import { ButtonWrapper, FlexButton, GemImage, GemPill, HoverWrapper, ReplayButton } from './HistoryItem.styles.ts'; import { useConfigUIStore } from '@app/store/useAppConfigStore.ts'; -import { CombinedBridgeWalletTransaction } from '@app/store/useWalletStore.ts'; +import { DisplayedTransaction } from '@app/types/app-status.ts'; +import { handleWinReplay } from '@app/store/useBlockchainVisualisationStore.ts'; interface Props { - item: CombinedBridgeWalletTransaction; + transaction: DisplayedTransaction; button?: React.ReactNode; } -const ItemHover = memo(function ItemHover({ item, button }: Props) { +const HoverItem = memo(function ItemHover({ transaction, button }: Props) { const { t } = useTranslation('sidebar', { useSuspense: false }); const sharingEnabled = useConfigUIStore((s) => s.sharing_enabled); const airdropTokens = useAirdropStore((s) => s.airdropTokens); @@ -27,7 +27,7 @@ const ItemHover = memo(function ItemHover({ item, button }: Props) { const handleShareClick = () => { setShowModal(true); - setItemData(item); + setItemData(transaction); }; const isLoggedIn = !!airdropTokens; const showShareButton = sharingEnabled && isLoggedIn; @@ -47,7 +47,7 @@ const ItemHover = memo(function ItemHover({ item, button }: Props) { )} - handleWinReplay(item)}> + handleWinReplay(transaction)}> @@ -57,4 +57,4 @@ const ItemHover = memo(function ItemHover({ item, button }: Props) { ); }); -export default ItemHover; +export default HoverItem; diff --git a/src/components/transactions/send/SendForm.tsx b/src/components/transactions/send/SendForm.tsx index 03ce807c73..995c3e39e6 100644 --- a/src/components/transactions/send/SendForm.tsx +++ b/src/components/transactions/send/SendForm.tsx @@ -29,7 +29,7 @@ export function SendForm({ isBack }: Props) { const availableBalance = useWalletStore((s) => s.balance?.available_balance); const numericAvailableBalance = Number(Math.floor((availableBalance || 0) / 1_000_000).toFixed(2)); - const isWalletScanning = useWalletStore((s) => s.wallet_scanning?.is_scanning); + const isInitialWalletScanning = useWalletStore((s) => !s.wallet_scanning?.is_initial_scan_complete); const { control, formState, setError, setValue, clearErrors, getValues } = useFormContext(); const { isSubmitting, errors } = formState; @@ -142,10 +142,12 @@ export function SendForm({ isBack }: Props) { isSecondary={true} /> } - secondaryText={!isWalletScanning ? `${t('send.max-available')} ${numericAvailableBalance} XTM` : ''} + secondaryText={ + !isInitialWalletScanning ? `${t('send.max-available')} ${numericAvailableBalance} XTM` : '' + } miniButton={ <> - {!isWalletScanning && ( + {!isInitialWalletScanning && ( void; -} +export type TransactionType = 'mined' | 'sent' | 'received' | 'unknown'; diff --git a/src/components/wallet/components/actions/WalletActions.tsx b/src/components/wallet/components/actions/WalletActions.tsx index b2dae4dc7a..e80f7ac069 100644 --- a/src/components/wallet/components/actions/WalletActions.tsx +++ b/src/components/wallet/components/actions/WalletActions.tsx @@ -9,7 +9,7 @@ interface WalletActionsProps { } export default function WalletActions({ section, setSection }: WalletActionsProps) { const { t } = useTranslation(['wallet', 'sidebar']); - const isScanning = useWalletStore((s) => s.wallet_scanning.is_scanning); + const isScanning = useWalletStore((s) => !s.wallet_scanning.is_initial_scan_complete); return ( diff --git a/src/components/wallet/components/balance/WalletBalance.tsx b/src/components/wallet/components/balance/WalletBalance.tsx index 399c2b50cf..42592778fe 100644 --- a/src/components/wallet/components/balance/WalletBalance.tsx +++ b/src/components/wallet/components/balance/WalletBalance.tsx @@ -2,7 +2,7 @@ import i18n from 'i18next'; import NumberFlow, { type Format } from '@number-flow/react'; import { Trans, useTranslation } from 'react-i18next'; import { IoEyeOffOutline, IoEyeOutline } from 'react-icons/io5'; -import { useConfigWalletStore, useNodeStore, useUIStore, useWalletStore } from '@app/store'; +import { useNodeStore, useUIStore, useWalletStore } from '@app/store'; import { roundToTwoDecimals, removeXTMCryptoDecimals, formatNumber, FormatPreset, formatValue } from '@app/utils'; import { Typography } from '@app/components/elements/Typography.tsx'; @@ -44,20 +44,18 @@ export const WalletBalance = () => { const hideBalance = useUIStore((s) => s.hideWalletBalance); const isConnected = useNodeStore((s) => s.isNodeConnected); - const cached = useConfigWalletStore((s) => s.last_known_balance); - const walletIsLoading = useWalletStore((s) => s.isLoading); const available = useWalletStore((s) => s.balance?.available_balance); const total = useWalletStore((s) => s.calculated_balance); const scanData = useWalletStore((s) => s.wallet_scanning); - const isScanning = scanData.is_scanning; + const isScanning = !scanData.is_initial_scan_complete; const scanProgress = Math.floor(scanData.progress * 10) / 10; - const balance = removeXTMCryptoDecimals(roundToTwoDecimals((isScanning ? cached : total) || 0)); + const balance = removeXTMCryptoDecimals(roundToTwoDecimals(total || 0)); const balanceMismatch = removeXTMCryptoDecimals(roundToTwoDecimals(available || 0)) != balance; const displayText = hideBalance ? '*******' : formatNumber(available || 0, FormatPreset.XTM_LONG); - const isLoading = !isConnected || isScanning || walletIsLoading; + const isLoading = !isConnected || isScanning; const balanceText = balanceMismatch ? `${t('history.available-balance')}: ${displayText} XTM` @@ -90,7 +88,15 @@ export const WalletBalance = () => { ); - const bottomMarkup = !isLoading ? {balanceText} : loadingMarkup; + // const bottomMarkup = !isLoading ? {balanceText} : loadingMarkup; + let bottomMarkup; + if (scanData.total_height === 0 && isScanning) { + bottomMarkup = <>; + } else if (isLoading) { + bottomMarkup = loadingMarkup; + } else { + bottomMarkup = {balanceText}; + } const progressMarkup = isLoading && !walletModuleFailed && ( diff --git a/src/components/wallet/sidebarWallet/SidebarWallet.tsx b/src/components/wallet/sidebarWallet/SidebarWallet.tsx index f6de390111..16752a1adb 100644 --- a/src/components/wallet/sidebarWallet/SidebarWallet.tsx +++ b/src/components/wallet/sidebarWallet/SidebarWallet.tsx @@ -26,15 +26,14 @@ import { List } from '@app/components/transactions/history/List.tsx'; import { open } from '@tauri-apps/plugin-shell'; import WalletActions from '@app/components/wallet/components/actions/WalletActions.tsx'; -import { TransactionDetails } from '@app/components/transactions/history/details/TransactionDetails.tsx'; -import { setDetailsItem, setIsSwapping, setTxHistoryFilter } from '@app/store/actions/walletStoreActions.ts'; +import { TransactionDetails } from '@app/components/transactions/history/transactionDetails/TransactionDetails.tsx'; +import { setIsSwapping, setTxHistoryFilter, setSelectedTransactionId } from '@app/store/actions/walletStoreActions.ts'; import ExchangesUrls from '@app/components/transactions/wallet/Exchanges/ExchangesUrls.tsx'; import { useFetchExchangeBranding } from '@app/hooks/exchanges/fetchExchangeContent.ts'; import { ExternalLink } from '@app/components/transactions/components/StatusList/styles.ts'; import { Typography } from '@app/components/elements/Typography.tsx'; import { ExternalLink2SVG } from '@app/assets/icons/external-link2.tsx'; -import SyncLoading from '../components/loaders/SyncLoading/SyncLoading.tsx'; import { FilterSelect, TxHistoryFilter } from '@app/components/transactions/history/FilterSelect.tsx'; import { WalletUIMode } from '@app/types/events-payloads.ts'; import SecureWalletWarning from './SecureWalletWarning/SecureWalletWarning.tsx'; @@ -49,16 +48,16 @@ interface SidebarWalletProps { export default function SidebarWallet({ section, setSection }: SidebarWalletProps) { const { t } = useTranslation('wallet'); const { data: xcData } = useFetchExchangeBranding(); - const detailsItem = useWalletStore((s) => s.detailsItem); - const filter = useWalletStore((s) => s.tx_history_filter); + const selectedTransaction = useWalletStore((s) => + s.selectedTransactionId ? s.wallet_transactions.find((tx) => tx.id === s.selectedTransactionId) : null + ); + const filter = useWalletStore((s) => s.transaction_history_filter); - // Wallet module state const walletModule = useSetupStore(setupStoreSelectors.selectWalletModule); const isWalletModuleFailed = walletModule?.status === AppModuleStatus.Failed; const isConnectedToTariNetwork = useNodeStore((s) => s.isNodeConnected); - const isWalletScanning = useWalletStore((s) => s.wallet_scanning?.is_scanning); - const walletIsLoading = useWalletStore((s) => s.isLoading); + const isInitialWalletScanning = useWalletStore((s) => !s.wallet_scanning?.is_initial_scan_complete); const targetRef = useRef(null) as RefObject; const [isScrolled, setIsScrolled] = useState(false); @@ -75,7 +74,7 @@ export default function SidebarWallet({ section, setSection }: SidebarWalletProp return () => el.removeEventListener('scroll', onScroll); }, []); - const isSyncing = !isConnectedToTariNetwork || isWalletScanning; + const isSyncing = !isConnectedToTariNetwork || isInitialWalletScanning; const isSwapping = useWalletStore((s) => s.is_swapping); const isStandardWalletUI = useConfigUIStore((s) => s.wallet_ui_mode === WalletUIMode.Standard); @@ -85,20 +84,6 @@ export default function SidebarWallet({ section, setSection }: SidebarWalletProp } }, [xcData]); - const syncMarkup = ( - <> - - - - - - {isStandardWalletUI ? : } - - - - - ); - const postLoadedMarkup = ( <> {xcData?.wallet_app_link && xcData?.wallet_app_label && ( @@ -145,7 +130,7 @@ export default function SidebarWallet({ section, setSection }: SidebarWalletProp - + )} @@ -162,7 +147,6 @@ export default function SidebarWallet({ section, setSection }: SidebarWalletProp ); } - const standardWalletLoading = isStandardWalletUI && (isSyncing || walletIsLoading); return ( <> @@ -175,8 +159,8 @@ export default function SidebarWallet({ section, setSection }: SidebarWalletProp ) : ( - - {standardWalletLoading ? {syncMarkup} : walletMarkup} + + {walletMarkup} setIsSwapping(true)}> {`${t('swap.buy-tari')} (XTM)`} @@ -184,11 +168,11 @@ export default function SidebarWallet({ section, setSection }: SidebarWalletProp )} - {detailsItem && ( + {selectedTransaction && ( setDetailsItem(null)} + transaction={selectedTransaction} + expanded={Boolean(selectedTransaction)} + handleClose={() => setSelectedTransactionId(null)} /> )} diff --git a/src/containers/floating/Settings/sections/wallet/RefreshWalletHistory.tsx b/src/containers/floating/Settings/sections/wallet/RefreshWalletHistory.tsx index 992920e46f..4d343aca58 100644 --- a/src/containers/floating/Settings/sections/wallet/RefreshWalletHistory.tsx +++ b/src/containers/floating/Settings/sections/wallet/RefreshWalletHistory.tsx @@ -12,7 +12,6 @@ import { useCallback } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { useSetupStore } from '@app/store/useSetupStore.ts'; import { setupStoreSelectors } from '@app/store/selectors/setupStoreSelectors.ts'; -import { refreshTransactions } from '@app/hooks/wallet/useFetchTxHistory.ts'; export const RefreshWalletHistory = () => { const { t } = useTranslation('settings', { useSuspense: false }); @@ -23,7 +22,7 @@ export const RefreshWalletHistory = () => { await invoke('refresh_wallet_history'); console.info('Wallet history reset successfully'); - await refreshTransactions(); + // await refreshTransactions(); } catch (error) { console.error('Failed to reset wallet history:', error); } diff --git a/src/containers/floating/ShareRewardModal/ShareRewardModal.tsx b/src/containers/floating/ShareRewardModal/ShareRewardModal.tsx index 6607c1b996..ad8d0bf78e 100644 --- a/src/containers/floating/ShareRewardModal/ShareRewardModal.tsx +++ b/src/containers/floating/ShareRewardModal/ShareRewardModal.tsx @@ -50,8 +50,8 @@ const ShareRewardModal = memo(function ShareRewardModal() { const userDetails = useAirdropStore((s) => s.userDetails); const referralCode = userDetails?.user?.referral_code || ''; const gemsValue = GIFT_GEMS.toLocaleString(); - const block = item?.mined_in_block_height || 0; - const reward = item?.tokenAmount || 0; + const block = item?.blockchain.block_height || 0; + const reward = item?.amount || 0; const earningsFormatted = useMemo(() => formatNumber(reward, FormatPreset.XTM_COMPACT).toLowerCase(), [reward]); const shareUrl = `${airdropUrl}/download/${referralCode}?bh=${block}`; diff --git a/src/containers/main/Dashboard/MiningView/components/Earnings.tsx b/src/containers/main/Dashboard/MiningView/components/Earnings.tsx index 2eb5e7aed2..d4f02814c5 100644 --- a/src/containers/main/Dashboard/MiningView/components/Earnings.tsx +++ b/src/containers/main/Dashboard/MiningView/components/Earnings.tsx @@ -35,7 +35,7 @@ export default function Earnings() { const replayItem = useBlockchainVisualisationStore((s) => s.replayItem); const earnings = useBlockchainVisualisationStore((s) => s.earnings); const recapData = useBlockchainVisualisationStore((s) => s.recapData); - const displayEarnings = replayItem?.tokenAmount || recapData?.totalEarnings || earnings; + const displayEarnings = replayItem?.amount || recapData?.totalEarnings || earnings; const [value, setValue] = useState(0); const [show, setShow] = useState(false); @@ -71,13 +71,13 @@ export default function Earnings() { ) : null; const replayText = - replayItem?.tokenAmount && replayItem.mined_in_block_height ? ( + replayItem?.amount && replayItem.blockchain.block_height ? ( }} /> diff --git a/src/containers/navigation/Sidebars/buttons/BridgeButton.tsx b/src/containers/navigation/Sidebars/buttons/BridgeButton.tsx index 3225874cdc..5b137b9c2c 100644 --- a/src/containers/navigation/Sidebars/buttons/BridgeButton.tsx +++ b/src/containers/navigation/Sidebars/buttons/BridgeButton.tsx @@ -9,7 +9,7 @@ import { useWalletStore } from '@app/store/useWalletStore.ts'; const BridgeButton = memo(function BridgeButton() { const showTapplet = useUIStore((s) => s.showTapplet); const setActiveTappById = useTappletsStore((s) => s.setActiveTappById); - const isWalletScanning = useWalletStore((s) => s.wallet_scanning?.is_scanning); + const isWalletScanning = useWalletStore((s) => !s.wallet_scanning?.is_initial_scan_complete); function handleToggleOpen() { if (isWalletScanning) return; diff --git a/src/hooks/app/useTauriEventsListener.ts b/src/hooks/app/useTauriEventsListener.ts index e67058eb9d..414b6f6d2f 100644 --- a/src/hooks/app/useTauriEventsListener.ts +++ b/src/hooks/app/useTauriEventsListener.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { listen } from '@tauri-apps/api/event'; import { BACKEND_STATE_UPDATE, BackendStateUpdateEvent } from '@app/types/backend-state.ts'; @@ -64,10 +64,12 @@ import { invoke } from '@tauri-apps/api/core'; import { setCpuPoolStats, setGpuPoolStats } from '@app/store/actions/miningPoolsStoreActions'; import { + handleWalletTransactionsFound, handlePinLocked, handleSeedBackedUp, handleSelectedTariAddressChange, - setIsWalletLoading, + handleWalletTransactionsCleared, + handleWalletTransactionUpdated, } from '@app/store/actions/walletStoreActions'; import { handleConfigCoreLoaded } from '@app/store/actions/config/core.ts'; import { handleFeedbackExitSurveyRequested } from '@app/store/stores/userFeedbackStore'; @@ -75,6 +77,7 @@ import { handleFeedbackExitSurveyRequested } from '@app/store/stores/userFeedbac const LOG_EVENT_TYPES = ['WalletAddressUpdate', 'CriticalProblem', 'MissingApplications']; const useTauriEventsListener = () => { + const initializationRef = useRef(false); const eventRef = useRef(null); function handleLogUpdate(newEvent: BackendStateUpdateEvent) { if (LOG_EVENT_TYPES.includes(newEvent.event_type)) { @@ -86,212 +89,226 @@ const useTauriEventsListener = () => { } } - useEffect(() => { - const setupListener = async () => { - // Set up the event listener - const unlisten = await listen( - BACKEND_STATE_UPDATE, - async ({ payload: event }: { payload: BackendStateUpdateEvent }) => { - handleLogUpdate(event); - switch (event.event_type) { - case 'UpdateAppModuleStatus': - await handleAppModulesUpdate(event.payload); - break; - case 'SetupProgressUpdate': - updateSetupProgress(event.payload); - break; - case 'UpdateTorEntryGuards': - setTorEntryGuards(event.payload); - break; - case 'InitialSetupFinished': - setInitialSetupFinished(true); - break; - case 'WalletBalanceUpdate': - await setWalletBalance(event.payload); - break; - case 'BaseNodeUpdate': - handleBaseNodeStatusUpdate(event.payload); - break; - case 'GpuMiningUpdate': - setGpuMiningStatus(event.payload); - break; - case 'CpuMiningUpdate': - setCpuMiningStatus(event.payload); - break; - case 'CpuPoolsStatsUpdate': - setCpuPoolStats(event.payload); - break; - case 'GpuPoolsStatsUpdate': - setGpuPoolStats(event.payload); - break; - case 'NewBlockHeight': { - const current = useBlockchainVisualisationStore.getState().latestBlockPayload?.block_height; - if (!current || current < event.payload.block_height) { - await handleNewBlockPayload(event.payload); - } - break; + const setupListener = useCallback(async () => { + // Set up the event listener + const unlisten = await listen( + BACKEND_STATE_UPDATE, + async ({ payload: event }: { payload: BackendStateUpdateEvent }) => { + handleLogUpdate(event); + switch (event.event_type) { + case 'UpdateAppModuleStatus': + await handleAppModulesUpdate(event.payload); + break; + case 'SetupProgressUpdate': + updateSetupProgress(event.payload); + break; + case 'UpdateTorEntryGuards': + setTorEntryGuards(event.payload); + break; + case 'InitialSetupFinished': + setInitialSetupFinished(true); + break; + case 'WalletBalanceUpdate': + await setWalletBalance(event.payload); + break; + case 'BaseNodeUpdate': + handleBaseNodeStatusUpdate(event.payload); + break; + case 'GpuMiningUpdate': + setGpuMiningStatus(event.payload); + break; + case 'CpuMiningUpdate': + setCpuMiningStatus(event.payload); + break; + case 'CpuPoolsStatsUpdate': + setCpuPoolStats(event.payload); + break; + case 'GpuPoolsStatsUpdate': + setGpuPoolStats(event.payload); + break; + case 'NewBlockHeight': { + const current = useBlockchainVisualisationStore.getState().latestBlockPayload?.block_height; + if (!current || current < event.payload.block_height) { + await handleNewBlockPayload(event.payload); } - case 'ConfigCoreLoaded': - await handleConfigCoreLoaded(event.payload); - break; - case 'ConfigWalletLoaded': - handleConfigWalletLoaded(event.payload); - break; - case 'ConfigMiningLoaded': - handleConfigMiningLoaded(event.payload); - break; - case 'ConfigUILoaded': - await handleConfigUILoaded(event.payload); - break; - case 'ConfigPoolsLoaded': - console.info('ConfigPoolsLoaded', event.payload); - handleConfigPoolsLoaded(event.payload); - break; - case 'CloseSplashscreen': - //TODO find better place for this - await handleAppLoaded(); - setSidebarOpen(true); - handleCloseSplashscreen(); - break; - case 'DetectedDevices': - setGpuDevices(event.payload.devices); - break; - case 'UpdateSelectedMiner': - handleSelectedMinerChanged(event.payload); - break; - case 'AvailableMiners': - handleAvailableMinersChanged(event.payload); - break; - case 'DetectedAvailableGpuEngines': - setAvailableEngines(event.payload.engines, event.payload.selected_engine); - break; - case 'CriticalProblem': { - const isMacAppFolderError = - event.payload.title === 'common:installation-problem' && - event.payload.description === 'common:not-installed-in-applications-directory'; + break; + } + case 'ConfigCoreLoaded': + await handleConfigCoreLoaded(event.payload); + break; + case 'ConfigWalletLoaded': + handleConfigWalletLoaded(event.payload); + break; + case 'ConfigMiningLoaded': + handleConfigMiningLoaded(event.payload); + break; + case 'ConfigUILoaded': + await handleConfigUILoaded(event.payload); + break; + case 'ConfigPoolsLoaded': + console.info('ConfigPoolsLoaded', event.payload); + handleConfigPoolsLoaded(event.payload); + break; + case 'CloseSplashscreen': + //TODO find better place for this + await handleAppLoaded(); + setSidebarOpen(true); + handleCloseSplashscreen(); + break; + case 'DetectedDevices': + setGpuDevices(event.payload.devices); + break; + case 'UpdateSelectedMiner': + handleSelectedMinerChanged(event.payload); + break; + case 'AvailableMiners': + handleAvailableMinersChanged(event.payload); + break; + case 'DetectedAvailableGpuEngines': + setAvailableEngines(event.payload.engines, event.payload.selected_engine); + break; + case 'CriticalProblem': { + const isMacAppFolderError = + event.payload.title === 'common:installation-problem' && + event.payload.description === 'common:not-installed-in-applications-directory'; - if (isMacAppFolderError) { - setCriticalError(event.payload); - } else { - handleCriticalProblemEvent(event.payload); - } - break; + if (isMacAppFolderError) { + setCriticalError(event.payload); + } else { + handleCriticalProblemEvent(event.payload); } - case 'SystemDependenciesLoaded': - loadSystemDependencies(event.payload); - break; - case 'StuckOnOrphanChain': - setIsStuckOnOrphanChain(event.payload); - if (event.payload) { - setConnectionStatus('disconnected'); - } - break; - case 'ShowReleaseNotes': - handleShowRelesaeNotes(event.payload); - break; - case `NetworkStatus`: - setNetworkStatus(event.payload); - break; - case `NodeTypeUpdate`: - setNodeStoreState(event.payload); - break; - case 'RestartingPhases': - await handleRestartingPhases(event.payload); - break; - case 'AskForRestart': - handleAskForRestart(); - break; - case 'BackgroundNodeSyncUpdate': - setBackgroundNodeState(event.payload); - break; - case 'InitWalletScanningProgress': - updateWalletScanningProgress(event.payload); - break; - case 'ConnectionStatus': - handleConnectionStatusChanged(event.payload); - break; - case 'ExchangeIdChanged': - await handleExchangeIdChanged(event.payload); - break; - case 'DisabledPhases': - handleUpdateDisabledPhases(event.payload); - break; - case 'SelectedTariAddressChanged': - handleSelectedTariAddressChange(event.payload); - break; - case 'WalletUIModeChanged': - handleWalletUIChanged(event.payload); - break; - case 'ShouldShowExchangeMinerModal': - setShouldShowExchangeSpecificModal(true); - break; - case 'ShowKeyringDialog': - setDialogToShow('keychain'); - break; - case 'CreatePin': - useSecurityStore.setState({ modal: 'create_pin' }); - break; - case 'EnterPin': - useSecurityStore.setState({ modal: 'enter_pin' }); - break; - case 'UpdateGpuDevicesSettings': - handleGpuDevicesSettingsUpdated(event.payload); - break; - case 'PinLocked': - handlePinLocked(event.payload); - break; - case 'SeedBackedUp': - handleSeedBackedUp(event.payload); - break; - case 'WalletStatusUpdate': - setIsWalletLoading(event.payload?.loading); - break; - case 'UpdateCpuMinerControlsState': - handleCpuMinerControlsStateChanged(event.payload); - break; - case 'UpdateGpuMinerControlsState': - handleGpuMinerControlsStateChanged(event.payload); - break; - case 'OpenSettings': - setIsSettingsOpen(true); - break; - case 'SystrayAppShutdownRequested': - handleSystrayAppShutdownRequested(); - break; - case 'ShowEcoAlert': - setShowEcoAlert(true); - break; - case 'FeedbackSurveyRequested': - await handleFeedbackExitSurveyRequested(); - break; - case 'ShutdownModeSelectionRequested': - setShowShutdownSelectionModal(true); - break; - case 'ShuttingDown': - setIsShuttingDown(true); - break; - case 'SetShowBatteryAlert': - setShowBatteryAlert(event.payload); - break; - default: - console.warn('Unknown event', JSON.stringify(event)); - break; + break; } + case 'SystemDependenciesLoaded': + loadSystemDependencies(event.payload); + break; + case 'StuckOnOrphanChain': + setIsStuckOnOrphanChain(event.payload); + if (event.payload) { + setConnectionStatus('disconnected'); + } + break; + case 'ShowReleaseNotes': + handleShowRelesaeNotes(event.payload); + break; + case `NetworkStatus`: + setNetworkStatus(event.payload); + break; + case `NodeTypeUpdate`: + setNodeStoreState(event.payload); + break; + case 'RestartingPhases': + await handleRestartingPhases(event.payload); + break; + case 'AskForRestart': + handleAskForRestart(); + break; + case 'BackgroundNodeSyncUpdate': + setBackgroundNodeState(event.payload); + break; + case 'WalletScanningProgressUpdate': + updateWalletScanningProgress(event.payload); + break; + case 'ConnectionStatus': + handleConnectionStatusChanged(event.payload); + break; + case 'ExchangeIdChanged': + await handleExchangeIdChanged(event.payload); + break; + case 'DisabledPhases': + handleUpdateDisabledPhases(event.payload); + break; + case 'SelectedTariAddressChanged': + handleSelectedTariAddressChange(event.payload); + break; + case 'WalletUIModeChanged': + handleWalletUIChanged(event.payload); + break; + case 'ShouldShowExchangeMinerModal': + setShouldShowExchangeSpecificModal(true); + break; + case 'ShowKeyringDialog': + setDialogToShow('keychain'); + break; + case 'CreatePin': + useSecurityStore.setState({ modal: 'create_pin' }); + break; + case 'EnterPin': + useSecurityStore.setState({ modal: 'enter_pin' }); + break; + case 'UpdateGpuDevicesSettings': + handleGpuDevicesSettingsUpdated(event.payload); + break; + case 'PinLocked': + handlePinLocked(event.payload); + break; + case 'SeedBackedUp': + handleSeedBackedUp(event.payload); + break; + case 'UpdateCpuMinerControlsState': + handleCpuMinerControlsStateChanged(event.payload); + break; + case 'UpdateGpuMinerControlsState': + handleGpuMinerControlsStateChanged(event.payload); + break; + case 'OpenSettings': + setIsSettingsOpen(true); + break; + case 'SystrayAppShutdownRequested': + handleSystrayAppShutdownRequested(); + break; + case 'ShowEcoAlert': + setShowEcoAlert(true); + break; + case 'FeedbackSurveyRequested': + await handleFeedbackExitSurveyRequested(); + break; + case 'ShutdownModeSelectionRequested': + setShowShutdownSelectionModal(true); + break; + case 'ShuttingDown': + setIsShuttingDown(true); + break; + case 'WalletTransactionsFound': + console.log('WalletTransactionsFound event received', event.payload); + handleWalletTransactionsFound(event.payload); + break; + case 'WalletTransactionsCleared': + handleWalletTransactionsCleared(); + break; + case 'WalletTransactionUpdated': + console.log('WalletTransactionUpdated event received', event.payload); + handleWalletTransactionUpdated(event.payload); + break; + case 'SetShowBatteryAlert': + setShowBatteryAlert(event.payload); + break; + default: + console.warn('Unknown event', JSON.stringify(event)); + break; } - ); - - try { - await invoke('frontend_ready'); - console.info('Successfully called frontend_ready'); - } catch (e) { - console.error('Failed to call frontend_ready: ', e); } + ); - return unlisten; - }; + try { + await invoke('frontend_ready'); + console.info('Successfully called frontend_ready'); + } catch (e) { + console.error('Failed to call frontend_ready: ', e); + } + + return unlisten; + }, []); + + useEffect(() => { + console.info('Setting up Tauri event listener for backend state updates'); + if (initializationRef.current) return; + initializationRef.current = true; + console.log('Initializing Tauri event listener'); let unlistenFunction: (() => void) | null = null; setupListener().then((unlisten) => { + console.log('Tauri event listener set up successfully'); unlistenFunction = unlisten; }); diff --git a/src/hooks/mining/useEarningsRecap.ts b/src/hooks/mining/useEarningsRecap.ts index 87c70e5b6f..2215743d99 100644 --- a/src/hooks/mining/useEarningsRecap.ts +++ b/src/hooks/mining/useEarningsRecap.ts @@ -1,15 +1,19 @@ import { handleWinRecap, useBlockchainVisualisationStore } from '@app/store/useBlockchainVisualisationStore.ts'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { listen } from '@tauri-apps/api/event'; import { useWalletStore } from '@app/store/useWalletStore.ts'; export default function useEarningsRecap() { const recapIds = useBlockchainVisualisationStore((s) => s.recapIds); - const coinbase_transactions = useWalletStore((s) => s.coinbase_transactions); + const transactions = useWalletStore((s) => s.wallet_transactions); + const coinbase_transactions = useMemo(() => { + // Filter by coinbase source from DisplayedTransaction + return transactions.filter((tx) => tx.source === 'coinbase'); + }, [transactions]); const getMissedEarnings = useCallback(() => { if (recapIds.length && coinbase_transactions.length) { - const missedWins = coinbase_transactions.filter((tx) => recapIds.includes(tx.tx_id)); + const missedWins = coinbase_transactions.filter((tx) => recapIds.includes(tx.id)); const count = missedWins.length; if (count > 0) { const totalEarnings = missedWins.reduce((earnings, cur) => earnings + cur.amount, 0); diff --git a/src/hooks/wallet/helpers.ts b/src/hooks/wallet/helpers.ts deleted file mode 100644 index fb838d3193..0000000000 --- a/src/hooks/wallet/helpers.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { TransactionInfo } from '@app/types/app-status.ts'; -import { BackendBridgeTransaction, CombinedBridgeWalletTransaction, useWalletStore } from '@app/store'; -import { TariAddressType } from '@app/types/events-payloads.ts'; - -interface MergeTransactionsArgs { - walletTransactions: TransactionInfo[]; - bridgeTransactions?: BackendBridgeTransaction[]; -} - -interface CheckTransactionsArgs { - walletTransactions: CombinedBridgeWalletTransaction[]; - bridgeTransactions?: BackendBridgeTransaction[]; -} -function convertWalletTransactionToCombinedTransaction(transaction: TransactionInfo): CombinedBridgeWalletTransaction { - const txDetails = { - ...transaction, - txId: transaction.tx_id, - isCancelled: transaction.is_cancelled, - excessSig: transaction.excess_sig, - paymentReference: transaction.payment_reference, - }; - return { - ...transaction, - sourceAddress: transaction.source_address, - destinationAddress: transaction.dest_address, - paymentId: transaction.payment_id, - feeAmount: transaction.fee, - createdAt: transaction.timestamp, - tokenAmount: transaction.amount, - walletTransactionDetails: txDetails, - bridgeTransactionDetails: undefined, - }; -} - -function getBridgeItemIndex(bridgeTx: BackendBridgeTransaction, walletTxs: CombinedBridgeWalletTransaction[]) { - if (bridgeTx.paymentId) { - // Currently we can find the bridge transaction by paymentId - return walletTxs.findIndex((walletTx) => walletTx.paymentId === bridgeTx.paymentId); - } - // Index found by paymentId should be always preferred, but if it is not found we use the deprecated method if exists - const coldWalletAddress = useWalletStore.getState().cold_wallet_address; - // If the bridge transaction has no paymentId, we try to find it by tokenAmount and destinationAddress which should equal the cold wallet address - // This supports older transactions that might not have a paymentId - return walletTxs.findIndex( - (walletTx) => - walletTx.tokenAmount === Number(bridgeTx.tokenAmount) && walletTx.destinationAddress === coldWalletAddress - ); -} - -export function mergeTransactionLists({ - walletTransactions, - bridgeTransactions, -}: MergeTransactionsArgs): CombinedBridgeWalletTransaction[] { - const mergedWalletTransactions: CombinedBridgeWalletTransaction[] = walletTransactions.map( - convertWalletTransactionToCombinedTransaction - ); - - bridgeTransactions?.forEach((bridgeTx) => { - const bridgeIndex = getBridgeItemIndex(bridgeTx, mergedWalletTransactions); - // Don't process if we can't find the transaction in the wallet transactions - if (bridgeIndex < 0) return; - - const baseTransactionDetails = mergedWalletTransactions[bridgeIndex]; - - const bridgeTransactionDetails = { - status: bridgeTx.status, - transactionHash: bridgeTx.transactionHash, - amountAfterFee: bridgeTx.amountAfterFee, - }; - - mergedWalletTransactions[bridgeIndex] = { - ...baseTransactionDetails, - bridgeTransactionDetails, - }; - }); - - return mergedWalletTransactions; -} - -export function shouldRefetchBridgeItems({ walletTransactions, bridgeTransactions }: CheckTransactionsArgs): boolean { - const coldWalletAddress = useWalletStore.getState().cold_wallet_address; - const tariAddressType = useWalletStore.getState().tari_address_type; - - const isThereANewBridgeTransaction = !!walletTransactions.find( - (tx) => - tx.destinationAddress === coldWalletAddress && - !bridgeTransactions?.some( - (bridgeTx) => bridgeTx.paymentId === tx.paymentId && Number(bridgeTx.tokenAmount) === tx.tokenAmount - ) - ); - - const isThereEmptyBridgeTransactionAndFoundInWallet = !!walletTransactions.find( - (tx) => tx.destinationAddress === coldWalletAddress && bridgeTransactions?.length === 0 - ); - - return ( - tariAddressType === TariAddressType.Internal && - (isThereANewBridgeTransaction || isThereEmptyBridgeTransactionAndFoundInWallet) - ); -} diff --git a/src/hooks/wallet/useFetchTxHistory.ts b/src/hooks/wallet/useFetchTxHistory.ts deleted file mode 100644 index b4a9ba3682..0000000000 --- a/src/hooks/wallet/useFetchTxHistory.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useInfiniteQuery } from '@tanstack/react-query'; -import { queryClient } from '@app/App/queryClient.ts'; -import { useConfigBEInMemoryStore, useConfigUIStore, useWalletStore } from '@app/store'; -import { fetchTransactionsHistory } from '@app/store/actions/walletStoreActions'; -import { fetchBridgeTransactionsHistory } from '@app/store/actions/bridgeApiActions.ts'; -import { mergeTransactionLists, shouldRefetchBridgeItems } from './helpers.ts'; - -export const KEY_TX = `transactions`; - -async function baseQuery({ pageParam, filter, walletAddress }) { - const limit = 20; - const offset = limit * (pageParam as number); - try { - const walletTransactions = await fetchTransactionsHistory({ offset, limit, filter }); - let bridgeTransactions = await fetchBridgeTransactionsHistory(walletAddress); - let mergedList = mergeTransactionLists({ walletTransactions, bridgeTransactions }); - const shouldRefetch = shouldRefetchBridgeItems({ walletTransactions: mergedList, bridgeTransactions }); - - if (shouldRefetch) { - bridgeTransactions = await fetchBridgeTransactionsHistory(walletAddress); - mergedList = mergeTransactionLists({ walletTransactions, bridgeTransactions }); - } - - return mergedList; - } catch (error) { - console.error(error); - throw error; - } -} - -export function useFetchTxHistory() { - const walletUiMode = useConfigUIStore((s) => s.wallet_ui_mode); - const baseUrl = useConfigBEInMemoryStore((s) => s.bridge_backend_api_url); - const walletAddress = useWalletStore((state) => state.tari_address_base58); - const filter = useWalletStore((s) => s.tx_history_filter); - - const baseQueryKeys = [KEY_TX, { walletAddress, walletUiMode }]; - const queriesEnabled = Boolean(walletAddress?.length && !!baseUrl?.length); - - return useInfiniteQuery({ - queryKey: [...baseQueryKeys, { filter }], - queryFn: async ({ pageParam }) => await baseQuery({ pageParam, filter, walletAddress }), - enabled: queriesEnabled, - initialPageParam: 0, - getNextPageParam: (lastPage, _allPages, lastPageParam) => { - if (lastPage?.length === 0) { - return undefined; - } - return lastPageParam + 1; - }, - getPreviousPageParam: (_firstPage, _allPages, firstPageParam) => { - if (firstPageParam <= 1) { - return undefined; - } - return firstPageParam - 1; - }, - refetchInterval: 1000 * 60 * 45, - refetchIntervalInBackground: true, - }); -} - -export const refreshTransactions = async () => { - await queryClient.invalidateQueries({ queryKey: [KEY_TX] }); -}; diff --git a/src/store/actions/bridgeApiActions.ts b/src/store/actions/bridgeApiActions.ts index 92dcababd5..0105f81063 100644 --- a/src/store/actions/bridgeApiActions.ts +++ b/src/store/actions/bridgeApiActions.ts @@ -17,6 +17,11 @@ export const fetchBridgeTransactionsHistory = async ( OpenAPI.BASE = baseUrl; return await WrapTokenService.getUserTransactions(tari_address_base58) .then((response) => { + useWalletStore.setState((c) => ({ + ...c, + bridge_transactions: response.transactions, + })); + console.log('Bridge transactions history fetched successfully:', response.transactions); return response.transactions; }) .catch((error) => { diff --git a/src/store/actions/index.ts b/src/store/actions/index.ts index 9be29c237d..2c6d435716 100644 --- a/src/store/actions/index.ts +++ b/src/store/actions/index.ts @@ -48,6 +48,6 @@ export { setUITheme, } from './uiStoreActions.ts'; -export { fetchTransactionsHistory, importSeedWords, setWalletBalance } from './walletStoreActions'; +export { importSeedWords, setWalletBalance } from './walletStoreActions'; export { handleBaseNodeStatusUpdate } from './nodeStoreActions.ts'; diff --git a/src/store/actions/setupStoreActions.ts b/src/store/actions/setupStoreActions.ts index 0c3ddd0c87..324485fac1 100644 --- a/src/store/actions/setupStoreActions.ts +++ b/src/store/actions/setupStoreActions.ts @@ -8,7 +8,7 @@ import { } from './miningStoreActions'; import { fetchApplicationsVersionsWithRetry, - fetchTransactionsHistory, + // fetchTransactionsHistory, useConfigMiningStore, useMiningStore, useWalletStore, @@ -107,8 +107,6 @@ export const updateAppModule = (state: AppModuleState) => { export const handleWalletModuleUpdateSideEffects = async (state: AppModuleState) => { switch (state.status) { case AppModuleStatus.Initialized: { - const tx_history_filter = useWalletStore.getState().tx_history_filter; - await fetchTransactionsHistory({ offset: 0, limit: 20, filter: tx_history_filter }); break; } case AppModuleStatus.Failed: diff --git a/src/store/actions/walletStoreActions.ts b/src/store/actions/walletStoreActions.ts index 77e8891cae..e245942ab9 100644 --- a/src/store/actions/walletStoreActions.ts +++ b/src/store/actions/walletStoreActions.ts @@ -1,6 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; -import { TransactionInfo, WalletBalance } from '@app/types/app-status.ts'; -import { CombinedBridgeWalletTransaction, useWalletStore } from '../useWalletStore'; +import { DisplayedTransaction, WalletBalance } from '@app/types/app-status.ts'; +import { useWalletStore } from '../useWalletStore'; import { setError } from './appStateStoreActions'; import { TxHistoryFilter } from '@app/components/transactions/history/FilterSelect'; @@ -9,11 +9,11 @@ import { addToast } from '@app/components/ToastStack/useToastStore'; import { t } from 'i18next'; import { startMining, stopMining } from './miningStoreActions'; import { useMiningStore } from '../useMiningStore'; -import { refreshTransactions } from '@app/hooks/wallet/useFetchTxHistory.ts'; import { deepEqual } from '@app/utils/objectDeepEqual.ts'; import { queryClient } from '@app/App/queryClient.ts'; import { KEY_EXPLORER } from '@app/hooks/mining/useFetchExplorerData.ts'; import { useMiningPoolsStore } from '@app/store/useMiningPoolsStore.ts'; +import { fetchBridgeTransactionsHistory } from './bridgeApiActions'; // NOTE: Tx status differ for core and proto(grpc) export const COINBASE_BITFLAG = 6144; @@ -25,47 +25,14 @@ export interface TxArgs { limit?: number; } -const filterToBitflag = (filter: TxHistoryFilter): number => { - switch (filter) { - case 'transactions': - return NON_COINBASE_BITFLAG; - case 'rewards': - return COINBASE_BITFLAG; - default: - return COINBASE_BITFLAG | NON_COINBASE_BITFLAG; - } -}; - -export const fetchTransactionsHistory = async ({ offset = 0, limit, filter = 'all-activity' }: TxArgs) => { - const bitflag = filterToBitflag(filter); - try { - const transactions = await invoke('get_transactions', { offset, limit, statusBitflag: bitflag }); - if (filter === 'rewards') { - setCoinbaseTransactions({ newTxs: transactions, offset }); - } - - return transactions; - } catch (error) { - console.error(`Could not get transaction history for rewards: `, error); - return [] as TransactionInfo[]; - } -}; - -export const setCoinbaseTransactions = ({ newTxs, offset = 0 }: { newTxs: TransactionInfo[]; offset?: number }) => { - const currentTxs = useWalletStore.getState().coinbase_transactions; - const coinbase_transactions = offset > 0 ? [...currentTxs, ...newTxs] : newTxs; - useWalletStore.setState((c) => ({ ...c, coinbase_transactions: coinbase_transactions })); -}; - export const importSeedWords = async (seedWords: string[]) => { useWalletStore.setState((c) => ({ ...c, is_wallet_importing: true, - coinbase_transactions: [], tx_history: [], bridge_transactions: [], wallet_scanning: { - is_scanning: true, + is_initial_scan_complete: false, scanned_height: 0, total_height: 0, progress: 0, @@ -85,7 +52,6 @@ export const importSeedWords = async (seedWords: string[]) => { }); useWalletStore.setState((c) => ({ ...c, is_wallet_importing: false })); - await refreshTransactions(); addToast({ title: t('success', { ns: 'airdrop' }), text: t('import-seed-success', { ns: 'settings' }), @@ -129,7 +95,6 @@ export const setWalletBalance = async (balance: WalletBalance) => { useWalletStore.setState({ calculated_balance }); await queryClient.invalidateQueries({ queryKey: [KEY_EXPLORER] }); - await refreshTransactions(); }; export const setIsSwapping = (isSwapping: boolean) => { @@ -140,11 +105,11 @@ export const setIsWalletLoading = (isLoading: boolean) => { }; export const setTxHistoryFilter = (filter: TxHistoryFilter) => { - useWalletStore.setState((c) => ({ ...c, tx_history_filter: filter })); + useWalletStore.setState((c) => ({ ...c, transaction_history_filter: filter })); }; -export const setDetailsItem = (detailsItem: CombinedBridgeWalletTransaction | null) => - useWalletStore.setState((c) => ({ ...c, detailsItem })); +export const setSelectedTransactionId = (transactionId: string | null) => + useWalletStore.setState((c) => ({ ...c, selectedTransactionId: transactionId })); export const handleSelectedTariAddressChange = (payload: TariAddressUpdatePayload) => { const { tari_address_base58, tari_address_emoji, tari_address_type } = payload; @@ -182,3 +147,103 @@ export const handleSeedBackedUp = (is_seed_backed_up: boolean) => { is_seed_backed_up, })); }; + +function shouldFetchBridgeItems(incomingWalletTransactions: DisplayedTransaction[]): boolean { + const bridgeWalletTransactions = useWalletStore.getState().bridge_transactions; + const coldWalletAddress = useWalletStore.getState().cold_wallet_address; + + const isThereANewBridgeTransaction = incomingWalletTransactions.some( + (tx) => + tx.counterparty?.address === coldWalletAddress && + (bridgeWalletTransactions.length === 0 || + !bridgeWalletTransactions?.some( + (bridgeTx) => bridgeTx.paymentId === tx.message && Number(bridgeTx.tokenAmount) === tx.amount + )) + ); + + return isThereANewBridgeTransaction; +} + +const solveBridgeTransactionDetails = async (walletTxs: DisplayedTransaction[]): Promise => { + if (shouldFetchBridgeItems(walletTxs)) { + const processedTransactions: DisplayedTransaction[] = [...walletTxs]; + const walletAddress = useWalletStore.getState().tari_address_base58; + const bridgeTransactions = await fetchBridgeTransactionsHistory(walletAddress); + bridgeTransactions.forEach((bridgeTx) => { + walletTxs.forEach((walletTx, index) => { + if ( + bridgeTx.paymentId === walletTx.message || + (Number(bridgeTx.tokenAmount) === walletTx.amount && + walletTx.counterparty?.address === useWalletStore.getState().cold_wallet_address) + ) { + processedTransactions[index] = { + ...walletTx, + bridge_transaction_details: { + status: bridgeTx.status, + transactionHash: bridgeTx.transactionHash, + amountAfterFee: bridgeTx.amountAfterFee, + }, + }; + } + }); + }); + return processedTransactions; + } + return walletTxs; +}; + +export const handleWalletTransactionsFound = async (payload: DisplayedTransaction[]) => { + const currentTransactions = useWalletStore.getState().wallet_transactions; + const copiedCurrentTransactions = [...currentTransactions]; + const filteredIncomingTransactions = payload.filter((newTx) => { + return !copiedCurrentTransactions.some((existingTx) => existingTx.id === newTx.id); + }); + + const processedTransactions = await solveBridgeTransactionDetails(filteredIncomingTransactions); + + // Prepend new transactions so they appear at the top of the list + const mergedTransactions = processedTransactions.concat(copiedCurrentTransactions); + useWalletStore.setState((c) => ({ + ...c, + wallet_transactions: mergedTransactions, + })); +}; + +export const handleWalletTransactionsCleared = () => { + useWalletStore.setState((c) => ({ + ...c, + wallet_transactions: [], + })); +}; + +export const handleWalletTransactionUpdated = async (payload: DisplayedTransaction) => { + // Find and replace the matching transaction (by id or output hashes) + const currentTransactions = useWalletStore.getState().wallet_transactions; + + // Find matching transaction by sent_output_hashes in details + const matchingIndex = currentTransactions.findIndex((tx) => { + if (tx.id === payload.id) return true; + // Match by output hashes if available (in details) + const txHashes = tx.details?.sent_output_hashes; + const payloadHashes = payload.details?.sent_output_hashes; + if (txHashes?.length && payloadHashes?.length) { + const txHashKey = [...txHashes].sort().join(','); + const payloadHashKey = [...payloadHashes].sort().join(','); + return txHashKey === payloadHashKey; + } + return false; + }); + + if (matchingIndex >= 0) { + const processedTransactions = await solveBridgeTransactionDetails([payload]); + const updatedTransaction = processedTransactions[0] || payload; + + const newTransactions = [...currentTransactions]; + newTransactions[matchingIndex] = updatedTransaction; + + useWalletStore.setState((c) => ({ + ...c, + wallet_transactions: newTransactions, + })); + } +}; diff --git a/src/store/useBlockchainVisualisationStore.ts b/src/store/useBlockchainVisualisationStore.ts index 45ba4218f2..3378ba1b8b 100644 --- a/src/store/useBlockchainVisualisationStore.ts +++ b/src/store/useBlockchainVisualisationStore.ts @@ -4,11 +4,10 @@ import { useMiningStore } from './useMiningStore.ts'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { setAnimationState } from '@tari-project/tari-tower'; -import { TransactionInfo } from '@app/types/app-status.ts'; +import { DisplayedTransaction, TransactionInfo } from '@app/types/app-status.ts'; import { setMiningControlsEnabled } from './actions/miningStoreActions.ts'; -import { CombinedBridgeWalletTransaction, updateWalletScanningProgress, useWalletStore } from './useWalletStore.ts'; +import { updateWalletScanningProgress, useWalletStore } from './useWalletStore.ts'; import { useConfigUIStore } from '@app/store/useAppConfigStore.ts'; -import { refreshTransactions } from '@app/hooks/wallet/useFetchTxHistory.ts'; const appWindow = getCurrentWindow(); interface LatestBlockPayload { @@ -24,8 +23,8 @@ interface State { recapData?: Recap; recapCount?: number; rewardCount?: number; - recapIds: number[]; - replayItem?: CombinedBridgeWalletTransaction; + recapIds: string[]; + replayItem?: DisplayedTransaction; latestBlockPayload?: LatestBlockPayload; } @@ -53,9 +52,9 @@ export const useBlockchainVisualisationStore = create set({ rewardCount }), })); -const handleWin = async (coinbase_transaction: TransactionInfo, canAnimate: boolean) => { - const blockHeight = Number(coinbase_transaction?.mined_in_block_height); - const earnings = coinbase_transaction.amount; +const handleWin = async (transaction: DisplayedTransaction, canAnimate: boolean) => { + const blockHeight = Number(transaction?.blockchain.block_height); + const earnings = transaction.amount; console.info(`Block #${blockHeight} mined! Earnings: ${earnings}`); @@ -69,14 +68,11 @@ const handleWin = async (coinbase_transaction: TransactionInfo, canAnimate: bool setMiningControlsEnabled(true); } useBlockchainVisualisationStore.setState((c) => ({ ...c, earnings })); - await refreshTransactions(); useBlockchainVisualisationStore.setState((c) => ({ ...c, earnings: undefined, latestBlockPayload: undefined })); } else { - await refreshTransactions(); - useBlockchainVisualisationStore.setState((curr) => ({ ...curr, - recapIds: [...curr.recapIds, coinbase_transaction.tx_id], + recapIds: [...curr.recapIds, transaction.id], displayBlockHeight: blockHeight, earnings: undefined, latestBlockPayload: undefined, @@ -88,7 +84,6 @@ const handleFail = async (canAnimate: boolean) => { if (canAnimate && visualModeEnabled) { setMiningControlsEnabled(false); setAnimationState('fail'); - await refreshTransactions(); setMiningControlsEnabled(true); } useBlockchainVisualisationStore.setState((c) => ({ ...c, latestBlockPayload: undefined })); @@ -101,8 +96,8 @@ export const handleWinRecap = (recapData: Recap) => { setMiningControlsEnabled(true); useBlockchainVisualisationStore.setState((c) => ({ ...c, recapData, recapCount: recapData.count })); }; -export const handleWinReplay = (txItem: CombinedBridgeWalletTransaction) => { - const earnings = txItem.tokenAmount; +export const handleWinReplay = (txItem: DisplayedTransaction) => { + const earnings = txItem.amount; const successTier = getSuccessTier(earnings); useBlockchainVisualisationStore.setState((c) => ({ ...c, replayItem: txItem })); setAnimationState(successTier, true); @@ -120,30 +115,28 @@ export const handleReplayComplete = () => { } }; -export async function processNewBlock(payload: { block_height: number; coinbase_transaction?: TransactionInfo }) { +export async function processNewBlock(payload: { block_height: number; transaction?: DisplayedTransaction }) { if (useMiningStore.getState().isCpuMiningInitiated || useMiningStore.getState().isGpuMiningInitiated) { const minimized = await appWindow?.isMinimized(); const documentIsVisible = document?.visibilityState === 'visible' || false; const canAnimate = !minimized && documentIsVisible; - if (payload.coinbase_transaction) { - await handleWin(payload.coinbase_transaction, canAnimate); + if (payload.transaction) { + await handleWin(payload.transaction, canAnimate); } else { await handleFail(canAnimate); } - } else { - await refreshTransactions(); } } export const handleNewBlockPayload = async (payload: LatestBlockPayload) => { useBlockchainVisualisationStore.setState((c) => ({ ...c, latestBlockPayload: payload })); - await refreshTransactions(); - const isWalletScanned = !useWalletStore.getState().wallet_scanning?.is_scanning; - if (!isWalletScanned) { + const isWalletScanned = useWalletStore.getState().wallet_scanning?.is_initial_scan_complete; + if (isWalletScanned) { updateWalletScanningProgress({ progress: 1, scanned_height: payload.block_height, total_height: payload.block_height, + is_initial_scan_complete: true, }); } }; diff --git a/src/store/useShareRewardStore.ts b/src/store/useShareRewardStore.ts index 79dfb0e79c..7c25f6046c 100644 --- a/src/store/useShareRewardStore.ts +++ b/src/store/useShareRewardStore.ts @@ -1,14 +1,14 @@ +import { DisplayedTransaction } from '@app/types/app-status'; import { create } from 'zustand'; -import { CombinedBridgeWalletTransaction } from './useWalletStore.ts'; interface State { showModal: boolean; - item: CombinedBridgeWalletTransaction | null; + item: DisplayedTransaction | null; } interface Actions { setShowModal: (showModal: boolean) => void; - setItemData: (item: CombinedBridgeWalletTransaction | null) => void; + setItemData: (item: DisplayedTransaction | null) => void; } const initialState: State = { diff --git a/src/store/useWalletStore.ts b/src/store/useWalletStore.ts index 5a2a9962f5..fa9b867f5f 100644 --- a/src/store/useWalletStore.ts +++ b/src/store/useWalletStore.ts @@ -1,9 +1,9 @@ import { create } from 'zustand'; -import { TransactionInfo, WalletBalance } from '../types/app-status.ts'; +import { DisplayedTransaction, WalletBalance } from '../types/app-status.ts'; import { TxHistoryFilter } from '@app/components/transactions/history/FilterSelect.tsx'; import { UserTransactionDTO } from '@tari-project/wxtm-bridge-backend-api'; -import { TariAddressType } from '@app/types/events-payloads.ts'; +import { TariAddressType, WalletScanningProgressUpdatePayload } from '@app/types/events-payloads.ts'; import { useExchangeStore } from './useExchangeStore.ts'; export interface BackendBridgeTransaction extends UserTransactionDTO { @@ -16,28 +16,6 @@ export interface BridgeTransactionDetails { transactionHash?: string; amountAfterFee: string; } -export interface WalletTransactionDetails extends Partial { - txId: number; - direction: number; - isCancelled: boolean; - status: number; - excessSig?: string; - message?: string; - paymentReference?: string; - destAddressEmoji?: string; -} -// combined type for transactions -export interface CombinedBridgeWalletTransaction { - destinationAddress: string; - paymentId: string; - feeAmount: number; - createdAt: number; - tokenAmount: number; - mined_in_block_height?: number; - sourceAddress?: string; - walletTransactionDetails: WalletTransactionDetails; - bridgeTransactionDetails?: BridgeTransactionDetails; -} export interface WalletStoreState { tari_address_base58: string; @@ -46,21 +24,20 @@ export interface WalletStoreState { exchange_wxtm_addresses: Record; balance?: WalletBalance; calculated_balance?: number; - coinbase_transactions: TransactionInfo[]; - tx_history_filter: TxHistoryFilter; - tx_history: TransactionInfo[]; - // TODO: decide later for the best place to store this data + transaction_history_filter: TxHistoryFilter; + wallet_transactions: DisplayedTransaction[]; + // ========= Bridge related data ========== bridge_transactions: BackendBridgeTransaction[]; cold_wallet_address?: string; + // ======================================== is_wallet_importing: boolean; - isLoading: boolean; is_swapping?: boolean; - detailsItem?: CombinedBridgeWalletTransaction | null; + selectedTransactionId?: string | null; wallet_scanning: { - is_scanning: boolean; scanned_height: number; total_height: number; progress: number; + is_initial_scan_complete: boolean; }; is_pin_locked: boolean; is_seed_backed_up: boolean; @@ -74,29 +51,22 @@ export const initialState: WalletStoreState = { tari_address_base58: '', tari_address_emoji: '', tari_address_type: TariAddressType.Internal, - coinbase_transactions: [], exchange_wxtm_addresses: {}, - tx_history_filter: 'all-activity', - tx_history: [], + transaction_history_filter: 'all-activity', + wallet_transactions: [], bridge_transactions: [], cold_wallet_address: undefined, is_wallet_importing: false, - isLoading: false, wallet_scanning: { - is_scanning: true, scanned_height: 0, total_height: 0, progress: 0, + is_initial_scan_complete: false, }, is_pin_locked: false, is_seed_backed_up: false, }; -// Configuration for memory management -const MAX_TRANSACTIONS_IN_MEMORY = 1000; // Keep only the latest 1000 transactions -const MAX_COINBASE_TRANSACTIONS_IN_MEMORY = 500; // Keep only the latest 500 coinbase transactions -// const MAX_PENDING_TRANSACTIONS = 100; // Keep only the latest 100 pending transactions - export const useWalletStore = create()((_, get) => ({ ...initialState, getETHAddressOfCurrentExchange: () => { @@ -105,45 +75,11 @@ export const useWalletStore = create()( }, })); -// Helper function to prune large arrays -const pruneTransactionArray = (array: T[], maxSize: number): T[] => { - if (array.length <= maxSize) return array; - - // Sort by timestamp (newest first) or tx_id as fallback, then take the latest - return array - .sort((a, b) => { - const aTime = a.timestamp || a.tx_id || 0; - const bTime = b.timestamp || b.tx_id || 0; - return bTime - aTime; - }) - .slice(0, maxSize); -}; - -export const updateWalletScanningProgress = (payload: { - scanned_height: number; - total_height: number; - progress: number; -}) => { - const is_scanning = payload.scanned_height < payload.total_height; +export const updateWalletScanningProgress = (payload: WalletScanningProgressUpdatePayload) => { useWalletStore.setState((c) => ({ ...c, wallet_scanning: { - is_scanning, ...payload, }, })); }; - -// New function to prune transaction arrays when they get too large -export const pruneTransactionHistory = () => { - useWalletStore.setState((state) => ({ - transactions: pruneTransactionArray(state.tx_history, MAX_TRANSACTIONS_IN_MEMORY), - coinbase_transactions: pruneTransactionArray(state.coinbase_transactions, MAX_COINBASE_TRANSACTIONS_IN_MEMORY), - })); -}; - -// Function to clear old transaction data (can be called periodically or on certain events) -const _clearOldTransactionData = () => { - console.info('Clearing old transaction data to free memory'); - pruneTransactionHistory(); -}; diff --git a/src/types/app-status.ts b/src/types/app-status.ts index 9f1754b88a..e8d486464b 100644 --- a/src/types/app-status.ts +++ b/src/types/app-status.ts @@ -1,4 +1,5 @@ import { AppModule } from '@app/store/types/setup'; +import { UserTransactionDTO } from '@tari-project/wxtm-bridge-backend-api'; export interface TorConfig { control_port: number; @@ -32,6 +33,175 @@ export interface SystemDependency { required_by_app_modules: AppModule[]; } +// ============================================================================ +// DisplayedTransaction Types (from minotari_wallet) +// ============================================================================ + +export enum TransactionDirection { + Incoming = 'incoming', + Outgoing = 'outgoing', +} + +export enum TransactionSource { + Transfer = 'transfer', + Coinbase = 'coinbase', + OneSided = 'one_sided', + Unknown = 'unknown', +} + +export enum TransactionDisplayStatus { + Pending = 'pending', + Unconfirmed = 'unconfirmed', + Confirmed = 'confirmed', + Cancelled = 'cancelled', + Reorganized = 'reorganized', + Rejected = 'rejected', +} + +export enum OutputStatus { + Unspent = 'Unspent', + Locked = 'Locked', + Spent = 'Spent', +} + +export interface CounterpartyInfo { + address: string; + address_emoji?: string; + label?: string; +} + +export interface BlockchainInfo { + block_height: number; + timestamp: string; + confirmations: number; +} + +export interface FeeInfo { + amount: number; + amount_display: string; +} + +export interface TransactionInput { + output_hash: string; + amount: number; + matched_output_id?: number; + is_matched: boolean; +} + +export interface TransactionOutput { + hash: string; + amount: number; + status: OutputStatus; + confirmed_height?: number; + output_type: string; + is_change: boolean; +} + +export interface TransactionDetails { + account_id: number; + total_credit: number; + total_debit: number; + inputs: TransactionInput[]; + outputs: TransactionOutput[]; + output_type?: string; + coinbase_extra?: string; + memo_hex?: string; + sent_output_hashes: string[]; +} + +export interface DisplayedTransaction { + id: string; + direction: TransactionDirection; + source: TransactionSource; + status: TransactionDisplayStatus; + amount: number; + amount_display: string; + message?: string; + counterparty?: CounterpartyInfo; + blockchain: BlockchainInfo; + fee?: FeeInfo; + details: TransactionDetails; + bridge_transaction_details?: { + status: UserTransactionDTO.status; + transactionHash?: string; + amountAfterFee?: string; + }; +} + +// ============================================================================ +// Legacy Types (kept for backward compatibility) +// ============================================================================ + +export enum InternalTransactionType { + Sent = 'Sent', + Received = 'Received', + Coinbase = 'Coinbase', +} + +export enum TranactionDetailsType { + Input = 'Input', + Output = 'Output', +} + +export enum OutputType { + /// A standard output. + Standard = 0, + /// Output is a coinbase output, must not be spent until maturity. + Coinbase = 1, + /// Output is a burned output and can not be spent ever. + Burn = 2, + /// Output containing a validator node registration + ValidatorNodeRegistration = 3, + /// Output containing a new re-usable code template. + CodeTemplateRegistration = 4, + /// Output containing a sidechain checkpoint + SidechainCheckpoint = 5, + /// Output containing a sidechain proof. + SidechainProof = 6, + /// Output containing a validator node exit + ValidatorNodeExit = 7, +} + +export interface WalletDetails { + description: string; + balance_credit: number; + balance_debit: number; + claimed_recipient_address?: string; + claimed_sender_address?: string; + memo_parsed?: string; + memo_hex?: string; + claimed_fee: number; + claimed_amount?: number; + confirmed_height?: number; + status: OutputStatus; + output_type: OutputType; + coinbase_extra: string; + details_type: TranactionDetailsType; +} + +/** @deprecated Use DisplayedTransaction instead */ +export interface WalletTransaction { + id: string; + account_id: number; + mined_height: number; + effective_date: string; + debit_balance: number; + credit_balance: number; + transaction_balance: number; + claimed_recipient_address?: string; + claimed_recipient_address_emoji?: string; + claimed_sender_address?: string; + claimed_sender_address_emoji?: string; + internal_transaction_type: InternalTransactionType; + memo_parsed?: string; + inputs: WalletDetails[]; + outputs: WalletDetails[]; + bridge_transaction_details?: { + status: UserTransactionDTO.status; + transactionHash?: string; + }; +} + export interface TransactionInfo { tx_id: number; source_address: string; diff --git a/src/types/backend-state.ts b/src/types/backend-state.ts index e939dd9c88..3e1bab1aa9 100644 --- a/src/types/backend-state.ts +++ b/src/types/backend-state.ts @@ -13,11 +13,13 @@ import { SetupPhase, ShowReleaseNotesPayload, TariAddressUpdatePayload, + WalletScanningProgressUpdatePayload, WalletUIMode, } from './events-payloads.ts'; import { BaseNodeStatus, CpuMinerStatus, + DisplayedTransaction, GpuMinerStatus, NetworkStatus, PoolStats, @@ -136,12 +138,8 @@ export type BackendStateUpdateEvent = payload: BackgroundNodeSyncUpdatePayload; } | { - event_type: 'InitWalletScanningProgress'; - payload: { - scanned_height: number; - total_height: number; - progress: number; - }; + event_type: 'WalletScanningProgressUpdate'; + payload: WalletScanningProgressUpdatePayload; } | { event_type: 'ConnectionStatus'; @@ -207,13 +205,6 @@ export type BackendStateUpdateEvent = event_type: 'AvailableMiners'; payload: Record; } - | { - event_type: 'WalletStatusUpdate'; - payload: { - loading: boolean; - unhealthy?: boolean; - }; - } | { event_type: 'UpdateCpuMinerControlsState'; payload: MinerControlsState; @@ -246,6 +237,18 @@ export type BackendStateUpdateEvent = event_type: 'ShuttingDown'; payload: undefined; } + | { + event_type: 'WalletTransactionsFound'; + payload: DisplayedTransaction[]; + } + | { + event_type: 'WalletTransactionsCleared'; + payload: undefined; + } + | { + event_type: 'WalletTransactionUpdated'; + payload: DisplayedTransaction; + } | { event_type: 'SetShowBatteryAlert'; payload: boolean; diff --git a/src/types/events-payloads.ts b/src/types/events-payloads.ts index 2d65e05fcd..ac51eda79d 100644 --- a/src/types/events-payloads.ts +++ b/src/types/events-payloads.ts @@ -25,6 +25,12 @@ export interface TariAddressUpdatePayload { tari_address_type: TariAddressType; } +export interface WalletScanningProgressUpdatePayload { + scanned_height: number; + total_height: number; + progress: number; + is_initial_scan_complete: boolean; +} export interface NewBlockHeightPayload { block_height: number; coinbase_transaction?: TransactionInfo; @@ -62,27 +68,27 @@ export interface NodeTypeUpdatePayload { export type BackgroundNodeSyncUpdatePayload = | { - step: 'Startup'; - initial_connected_peers: number; - required_peers: number; - } + step: 'Startup'; + initial_connected_peers: number; + required_peers: number; + } | { - step: 'Header'; - local_header_height: number; - tip_header_height: number; - local_block_height: number; - tip_block_height: number; - } + step: 'Header'; + local_header_height: number; + tip_header_height: number; + local_block_height: number; + tip_block_height: number; + } | { - step: 'Block'; - local_header_height: number; - tip_header_height: number; - local_block_height: number; - tip_block_height: number; - } + step: 'Block'; + local_header_height: number; + tip_header_height: number; + local_block_height: number; + tip_block_height: number; + } | { - step: 'Done'; - }; + step: 'Done'; + }; export type ConnectionStatusPayload = 'InProgress' | 'Succeed' | 'Failed'; diff --git a/src/types/invoke.d.ts b/src/types/invoke.d.ts index dbb7bf13ac..a30a1f8b2b 100644 --- a/src/types/invoke.d.ts +++ b/src/types/invoke.d.ts @@ -1,11 +1,4 @@ -import { - ApplicationsVersions, - TorConfig, - TransactionInfo, - BridgeEnvs, - TariAddressVariants, - BaseNodeStatus, -} from './app-status'; +import { ApplicationsVersions, TorConfig, BridgeEnvs, TariAddressVariants, BaseNodeStatus } from './app-status'; import { Language } from '@app/i18initializer'; import { PaperWalletDetails } from './app-status.ts'; import { displayMode } from '../store/types.ts'; @@ -57,10 +50,6 @@ declare module '@tauri-apps/api/core' { function invoke(param: 'exit_application'): Promise; function invoke(param: 'restart_application'): Promise; function invoke(param: 'set_use_tor', payload: { useTor: boolean }): Promise; - function invoke( - param: 'get_transactions', - payload: { offset?: number; limit?: number; statusBitflag?: number } - ): Promise; function invoke(param: 'import_seed_words', payload: { seedWords: string[] }): Promise; function invoke(param: 'get_tor_config'): Promise; function invoke(param: 'set_tor_config', payload: { config: TorConfig }): Promise; diff --git a/src/utils/getTxStatus.ts b/src/utils/getTxStatus.ts deleted file mode 100644 index 6d21d3be17..0000000000 --- a/src/utils/getTxStatus.ts +++ /dev/null @@ -1,89 +0,0 @@ -import i18n from 'i18next'; - -import { TransactionDirection, TransactionStatus } from '@app/types/transactions.ts'; -import { TransationType } from '@app/components/transactions/types.ts'; -import { UserTransactionDTO } from '@tari-project/wxtm-bridge-backend-api'; -import { CombinedBridgeWalletTransaction } from '@app/store'; - -const txTypes = { - oneSided: [ - TransactionStatus.OneSidedConfirmed, - TransactionStatus.OneSidedUnconfirmed, - TransactionStatus.MinedConfirmed, - TransactionStatus.MinedUnconfirmed, - ], - mined: [TransactionStatus.CoinbaseConfirmed, TransactionStatus.CoinbaseUnconfirmed], -}; - -const txStates = { - pending: [ - TransactionStatus.Completed, - TransactionStatus.Pending, - TransactionStatus.Broadcast, - TransactionStatus.Coinbase, - TransactionStatus.Queued, - ], - failed: [TransactionStatus.Rejected, TransactionStatus.NotFound, TransactionStatus.CoinbaseNotInBlockChain], - complete: [ - TransactionStatus.Imported, - TransactionStatus.OneSidedUnconfirmed, - TransactionStatus.OneSidedConfirmed, - TransactionStatus.MinedConfirmed, - TransactionStatus.MinedUnconfirmed, - TransactionStatus.CoinbaseConfirmed, - TransactionStatus.CoinbaseUnconfirmed, - ], -}; - -export function getTxTypeByStatus(transaction: CombinedBridgeWalletTransaction): TransationType { - if (txTypes.mined.includes(transaction.walletTransactionDetails.status)) { - return 'mined'; - } - if (txTypes.oneSided.includes(transaction.walletTransactionDetails.status)) { - return transaction.walletTransactionDetails.direction === TransactionDirection.Inbound ? 'received' : 'sent'; - } - return 'unknown'; -} - -export function getTxStatusTitleKey(transaction: CombinedBridgeWalletTransaction): string | undefined { - if (transaction.bridgeTransactionDetails) { - return transaction.bridgeTransactionDetails.status === UserTransactionDTO.status.SUCCESS - ? 'complete' - : 'pending'; - } - return Object.keys(txStates).find((key) => { - if (txStates[key].includes(transaction.walletTransactionDetails.status)) { - return key; - } - }); -} -export function getTxTitle(transaction: CombinedBridgeWalletTransaction): string { - if (transaction.bridgeTransactionDetails) { - return 'Bridge XTM to WXTM'; - } - - const itemType = getTxTypeByStatus(transaction); - const txMessage = transaction.paymentId; - const statusTitleKey = getTxStatusTitleKey(transaction); - const statusTitle = i18n.t(`common:${statusTitleKey}`); - const typeTitle = - itemType === 'unknown' - ? TransactionDirection[transaction.walletTransactionDetails.direction] - : i18n.t(`common:${itemType}`); - - if (itemType === 'mined' && transaction.mined_in_block_height) { - return `${i18n.t('sidebar:block')} #${transaction.mined_in_block_height}`; - } - - if (txMessage && !txMessage.includes('')) { - if (statusTitleKey !== 'complete') { - return `${txMessage} | ${statusTitle}`; - } - return txMessage; - } - - if (statusTitleKey !== 'complete') { - return `${typeTitle} | ${statusTitle}`; - } - return i18n.t(`common:${itemType}`); -}