From 40a384ed65c1e52752262a10bcf31b9c77302e8c Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sat, 16 May 2026 15:12:09 -0700 Subject: [PATCH] feat(wallet): add default rpc and EVM execution tools (#1964) --- .env.example | 16 + Cargo.lock | 788 ++++++++++++++- Cargo.toml | 2 + app/src-tauri/Cargo.lock | 796 ++++++++++++++- .../__tests__/ConnectionsPanel.test.tsx | 2 + .../__tests__/RecoveryPhrasePanel.test.tsx | 1 + .../setupLocalWalletFromMnemonic.test.ts | 29 + .../wallet/setupLocalWalletFromMnemonic.ts | 6 + app/src/services/walletApi.test.ts | 2 + app/src/services/walletApi.ts | 2 + app/test/Mnemonic.test.tsx | 6 + .../specs/chat-harness-wallet-flow.spec.ts | 223 +++++ .../agent/agents/crypto_agent/agent.toml | 2 + .../agent/agents/crypto_agent/prompt.md | 4 +- src/openhuman/agent/agents/loader.rs | 2 + .../agent/harness/test_support_test.rs | 297 +++++- src/openhuman/test_support/introspect.rs | 19 + src/openhuman/test_support/schemas.rs | 38 + src/openhuman/wallet/abi.rs | 60 ++ src/openhuman/wallet/defaults.rs | 222 +++++ src/openhuman/wallet/execution.rs | 941 +++++++++++++----- src/openhuman/wallet/mod.rs | 16 +- src/openhuman/wallet/ops.rs | 114 ++- src/openhuman/wallet/rpc.rs | 100 ++ src/openhuman/wallet/schemas.rs | 120 ++- tests/json_rpc_e2e.rs | 119 ++- 26 files changed, 3581 insertions(+), 346 deletions(-) create mode 100644 app/test/e2e/specs/chat-harness-wallet-flow.spec.ts create mode 100644 src/openhuman/wallet/abi.rs create mode 100644 src/openhuman/wallet/defaults.rs create mode 100644 src/openhuman/wallet/rpc.rs diff --git a/.env.example b/.env.example index 483828077..bef927066 100644 --- a/.env.example +++ b/.env.example @@ -154,6 +154,22 @@ OLLAMA_BIN= # [optional] Bot username for managed Telegram DM linking (default: openhuman_bot) OPENHUMAN_TELEGRAM_BOT_USERNAME=openhuman_bot +# --------------------------------------------------------------------------- +# Wallet RPC overrides +# --------------------------------------------------------------------------- +# [optional] Wallet chain RPC URLs. When unset, core falls back to built-in +# public mainnet defaults: +# EVM -> https://ethereum-rpc.publicnode.com +# BTC -> https://blockstream.info/api +# Solana -> https://api.mainnet-beta.solana.com +# Tron -> https://api.trongrid.io +# Override these when you want a private/provisioned RPC, a staging fork, or +# a deterministic local chain during tests. +# OPENHUMAN_WALLET_RPC_EVM= +# OPENHUMAN_WALLET_RPC_BTC= +# OPENHUMAN_WALLET_RPC_SOLANA= +# OPENHUMAN_WALLET_RPC_TRON= + # --------------------------------------------------------------------------- # Skills # --------------------------------------------------------------------------- diff --git a/Cargo.lock b/Cargo.lock index 168a7af51..1a1523282 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -306,7 +306,7 @@ checksum = "a78dceaba06f029d8f4d7df20addd4b7370a30206e3926267ecda2915b0f3f66" dependencies = [ "async-channel 2.5.0", "async-compression", - "base64", + "base64 0.22.1", "bytes", "chrono", "futures", @@ -371,6 +371,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -407,7 +418,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "axum-macros", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -490,6 +501,18 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -502,6 +525,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + [[package]] name = "bincode" version = "2.0.1" @@ -563,6 +592,18 @@ version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6" +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "blake2" version = "0.10.6" @@ -643,6 +684,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" dependencies = [ + "sha2 0.10.9", "tinyvec", ] @@ -652,6 +694,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + [[package]] name = "bytecount" version = "0.6.9" @@ -914,6 +962,58 @@ dependencies = [ "objc", ] +[[package]] +name = "coins-bip32" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b6be4a5df2098cd811f3194f64ddb96c267606bffd9689ac7b0160097b01ad3" +dependencies = [ + "bs58", + "coins-core", + "digest 0.10.7", + "hmac 0.12.1", + "k256", + "serde", + "sha2 0.10.9", + "thiserror 1.0.69", +] + +[[package]] +name = "coins-bip39" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db8fba409ce3dc04f7d804074039eb68b960b0829161f8e06c95fea3f122528" +dependencies = [ + "bitvec", + "coins-bip32", + "hmac 0.12.1", + "once_cell", + "pbkdf2 0.12.2", + "rand 0.8.6", + "sha2 0.10.9", + "thiserror 1.0.69", +] + +[[package]] +name = "coins-core" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5286a0843c21f8367f7be734f89df9b822e0321d8bcce8d6e735aadff7d74979" +dependencies = [ + "base64 0.21.7", + "bech32", + "bs58", + "digest 0.10.7", + "generic-array", + "hex", + "ripemd", + "serde", + "serde_derive", + "sha2 0.10.9", + "sha3", + "thiserror 1.0.69", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -968,6 +1068,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-hex" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20d9a563d167a9cce0f94153382b33cb6eded6dfabff03c69ad65a28ea1514e0" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -980,6 +1092,27 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst 0.2.20", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "const_panic" version = "0.2.15" @@ -1241,6 +1374,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1397,7 +1542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" dependencies = [ "serde", - "uuid", + "uuid 1.23.1", ] [[package]] @@ -1574,6 +1719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -1718,6 +1864,20 @@ dependencies = [ "cipher", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -1750,13 +1910,32 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email-encoding" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" dependencies = [ - "base64", + "base64 0.22.1", "memchr", ] @@ -1787,7 +1966,7 @@ version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2b48069eef4227bde0e5a8e0601ddabfbdef887c05b831cd314d2990726a461" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -1892,6 +2071,122 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "eth-keystore" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fda3bf123be441da5260717e0661c25a2fd9cb2b2c1d20bf2e05580047158ab" +dependencies = [ + "aes", + "ctr", + "digest 0.10.7", + "hex", + "hmac 0.12.1", + "pbkdf2 0.11.0", + "rand 0.8.6", + "scrypt", + "serde", + "serde_json", + "sha2 0.10.9", + "sha3", + "thiserror 1.0.69", + "uuid 0.8.2", +] + +[[package]] +name = "ethabi" +version = "18.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7413c5f74cc903ea37386a8965a936cbeb334bd270862fdece542c1b2dcbc898" +dependencies = [ + "ethereum-types", + "hex", + "once_cell", + "regex", + "serde", + "serde_json", + "sha3", + "thiserror 1.0.69", + "uint", +] + +[[package]] +name = "ethbloom" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c22d4b5885b6aa2fe5e8b9329fb8d232bf739e434e6b87347c63bdd00c120f60" +dependencies = [ + "crunchy", + "fixed-hash", + "impl-codec", + "impl-rlp", + "impl-serde", + "scale-info", + "tiny-keccak", +] + +[[package]] +name = "ethereum-types" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d215cbf040552efcbe99a38372fe80ab9d00268e20012b79fcd0f073edd8ee" +dependencies = [ + "ethbloom", + "fixed-hash", + "impl-codec", + "impl-rlp", + "impl-serde", + "primitive-types", + "scale-info", + "uint", +] + +[[package]] +name = "ethers-core" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d80cc6ad30b14a48ab786523af33b37f28a8623fc06afd55324816ef18fb1f" +dependencies = [ + "arrayvec", + "bytes", + "chrono", + "const-hex", + "elliptic-curve", + "ethabi", + "generic-array", + "k256", + "num_enum", + "open-fastrlp", + "rand 0.8.6", + "rlp", + "serde", + "serde_json", + "strum", + "tempfile", + "thiserror 1.0.69", + "tiny-keccak", + "unicode-xid", +] + +[[package]] +name = "ethers-signers" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "228875491c782ad851773b652dd8ecac62cda8571d3bc32a5853644dd26766c2" +dependencies = [ + "async-trait", + "coins-bip32", + "coins-bip39", + "const-hex", + "elliptic-curve", + "eth-keystore", + "ethers-core", + "rand 0.8.6", + "sha2 0.10.9", + "thiserror 1.0.69", + "tracing", +] + [[package]] name = "euclid" version = "0.20.14" @@ -1990,7 +2285,7 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7737298823a6f9ca743e372e8cb03658d55354fbab843424f575706ba9563046" dependencies = [ - "base64", + "base64 0.22.1", "cookie 0.18.1", "http 1.4.0", "http-body-util", @@ -2027,6 +2322,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2061,6 +2366,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand 0.8.6", + "rustc-hex", + "static_assertions", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2163,6 +2480,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futf" version = "0.1.5" @@ -2278,6 +2601,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2387,6 +2711,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "growable-bloom-filter" version = "2.1.1" @@ -2480,7 +2815,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "headers-core", "http 1.4.0", @@ -2707,7 +3042,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2997,6 +3332,44 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "impl-codec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "impl-rlp" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28220f89297a075ddc7245cd538076ee98b01f2a9c23a53a4f1105d5a322808" +dependencies = [ + "rlp", +] + +[[package]] +name = "impl-serde" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc88fc67028ae3db0c853baa36269d398d5f45b6982f95549ff5def78c935cd" +dependencies = [ + "serde", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "include_dir" version = "0.7.4" @@ -3221,6 +3594,38 @@ dependencies = [ "serde_core", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2 0.10.9", + "signature", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + [[package]] name = "konst" version = "0.3.17" @@ -3241,6 +3646,12 @@ dependencies = [ "typewit", ] +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "landlock" version = "0.4.4" @@ -3282,7 +3693,7 @@ version = "0.11.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7" dependencies = [ - "base64", + "base64 0.22.1", "email-encoding", "email_address", "fastrand", @@ -3711,7 +4122,7 @@ dependencies = [ "itertools 0.14.0", "js_option", "matrix-sdk-common", - "pbkdf2", + "pbkdf2 0.12.2", "rand 0.8.6", "rmp-serde", "ruma", @@ -3737,7 +4148,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51c32df5db1985460431ca44fc4f41b3fdd38b1e33bd7fcf23f4941398a492b4" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "futures-util", "getrandom 0.2.17", "gloo-utils", @@ -3756,7 +4167,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", - "uuid", + "uuid 1.23.1", "wasm-bindgen", "web-sys", "zeroize", @@ -3796,12 +4207,12 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a872689adca327ce6dbadaac93bd9558ae3a7c5ab4c3177596dd01492085f747" dependencies = [ - "base64", + "base64 0.22.1", "blake3", "chacha20poly1305", "getrandom 0.2.17", "hmac 0.12.1", - "pbkdf2", + "pbkdf2 0.12.2", "rand 0.8.6", "rmp-serde", "serde", @@ -4163,7 +4574,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -4520,6 +4931,31 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "open-fastrlp" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786393f80485445794f6043fd3138854dd109cc6c4bd1a6383db304c9ce9b9ce" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", + "ethereum-types", + "open-fastrlp-derive", +] + +[[package]] +name = "open-fastrlp-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "003b2be5c6c53c1cfeb0a238b8a1c3915cd410feb684457a36c10038f764bb1c" +dependencies = [ + "bytes", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "openhuman" version = "0.53.49" @@ -4531,7 +4967,7 @@ dependencies = [ "async-imap", "async-trait", "axum", - "base64", + "base64 0.22.1", "block2 0.6.2", "chacha20poly1305", "chrono", @@ -4547,6 +4983,8 @@ dependencies = [ "dotenvy", "enigo", "env_logger", + "ethers-core", + "ethers-signers", "fantoccini", "flate2", "fs2", @@ -4615,7 +5053,7 @@ dependencies = [ "unicode-width", "url", "urlencoding", - "uuid", + "uuid 1.23.1", "wacore", "wait-timeout", "walkdir", @@ -4769,6 +5207,34 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "parking" version = "2.2.1" @@ -4809,6 +5275,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -5002,7 +5477,7 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ - "base64", + "base64 0.22.1", "indexmap", "quick-xml", "serde", @@ -5097,7 +5572,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" dependencies = [ - "base64", + "base64 0.22.1", "byteorder", "bytes", "fallible-iterator 0.2.0", @@ -5167,6 +5642,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primitive-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" +dependencies = [ + "fixed-hash", + "impl-codec", + "impl-rlp", + "impl-serde", + "scale-info", + "uint", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -5220,6 +5709,21 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags 2.11.1", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + [[package]] name = "prost" version = "0.13.5" @@ -5425,6 +5929,12 @@ dependencies = [ "scheduled-thread-pool", ] +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.6" @@ -5501,6 +6011,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "rand_xoshiro" version = "0.7.0" @@ -5633,7 +6152,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -5680,7 +6199,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -5714,6 +6233,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -5728,6 +6257,37 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "rlp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" +dependencies = [ + "bytes", + "rlp-derive", + "rustc-hex", +] + +[[package]] +name = "rlp-derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e33d7b2abe0c340d8797fe2907d3f20d3b5ea5908683618bfe80df7f621f672a" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rmp" version = "0.8.15" @@ -5814,7 +6374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a01993f22d291320b7c9267675e7395775e95269ff526e2c8c3ed5e13175b" dependencies = [ "as_variant", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "getrandom 0.2.17", @@ -5822,7 +6382,7 @@ dependencies = [ "indexmap", "js-sys", "js_int", - "konst", + "konst 0.3.17", "percent-encoding", "rand 0.8.6", "regex", @@ -5835,7 +6395,7 @@ dependencies = [ "time", "tracing", "url", - "uuid", + "uuid 1.23.1", "web-time", "wildmatch", "zeroize", @@ -5932,7 +6492,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146ace2cd59b60ec80d3e801a84e7e6a91e3e01d18a9f5d896ea7ca16a6b8e08" dependencies = [ - "base64", + "base64 0.22.1", "ed25519-dalek", "pkcs8", "rand 0.8.6", @@ -5968,6 +6528,12 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[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" @@ -6079,6 +6645,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -6088,6 +6663,30 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scale-info" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346a3b32eba2640d17a9cb5927056b08f3de90f65b72fe09402c2ad07d684d0b" +dependencies = [ + "cfg-if", + "derive_more 1.0.0", + "parity-scale-codec", + "scale-info-derive", +] + +[[package]] +name = "scale-info-derive" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6630024bf739e2179b91fb424b28898baf819414262c5d376677dbff1fe7ebf" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "schannel" version = "0.1.29" @@ -6137,6 +6736,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f9e24d2b632954ded8ab2ef9fea0a0c769ea56ea98bddbafbad22caeeadf45d" +dependencies = [ + "hmac 0.12.1", + "pbkdf2 0.11.0", + "salsa20", + "sha2 0.10.9", +] + [[package]] name = "sealed" version = "0.6.0" @@ -6148,6 +6759,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -6288,7 +6913,7 @@ dependencies = [ "thiserror 2.0.18", "time", "url", - "uuid", + "uuid 1.23.1", ] [[package]] @@ -6466,6 +7091,16 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -6512,6 +7147,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest 0.10.7", "rand_core 0.6.4", ] @@ -6664,6 +7300,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stop-token" version = "0.7.0" @@ -6718,6 +7360,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -6785,6 +7449,12 @@ dependencies = [ "windows 0.57.0", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tar" version = "0.4.45" @@ -6914,6 +7584,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.7.6" @@ -7082,7 +7761,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-sink", @@ -7177,7 +7856,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "bytes", "http 1.4.0", "http-body", @@ -7424,6 +8103,18 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e36a83ea2b3c704935a01b4642946aadd445cea40b10935e3f8bd8052b8193d6" +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + [[package]] name = "ulid" version = "1.2.1" @@ -7443,6 +8134,12 @@ dependencies = [ "libc", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.9.0" @@ -7548,7 +8245,7 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "base64", + "base64 0.22.1", "cookie_store", "log", "percent-encoding", @@ -7567,7 +8264,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ - "base64", + "base64 0.22.1", "http 1.4.0", "httparse", "log", @@ -7616,6 +8313,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.17", + "serde", +] + [[package]] name = "uuid" version = "1.23.1" @@ -7660,7 +8367,7 @@ checksum = "c022a277687e4e8685d72b95a7ca3ccfec907daa946678e715f8badaa650883d" dependencies = [ "aes", "arrayvec", - "base64", + "base64 0.22.1", "base64ct", "cbc", "chacha20poly1305", @@ -7694,7 +8401,7 @@ dependencies = [ "async-channel 2.5.0", "async-lock", "async-trait", - "base64", + "base64 0.22.1", "bytes", "chrono", "ctr", @@ -7707,7 +8414,7 @@ dependencies = [ "log", "md5", "once_cell", - "pbkdf2", + "pbkdf2 0.12.2", "prost 0.14.3", "rand 0.10.1", "serde", @@ -7799,7 +8506,7 @@ dependencies = [ "sha2 0.10.9", "subtle", "thiserror 2.0.18", - "uuid", + "uuid 1.23.1", "waproto", "x25519-dalek", ] @@ -8064,7 +8771,7 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91d53921e1bef27512fa358179c9a22428d55778d2c2ae3c5c37a52b82ce6e92" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "cookie 0.16.2", "http 0.2.12", @@ -8121,7 +8828,7 @@ dependencies = [ "async-channel 2.5.0", "async-lock", "async-trait", - "base64", + "base64 0.22.1", "bytes", "chrono", "env_logger", @@ -8870,6 +9577,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" diff --git a/Cargo.toml b/Cargo.toml index 9d00c076d..528ed7639 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -132,6 +132,8 @@ fs2 = "0.4" # only by `openhuman::scheduler_gate::signals` to decide when to throttle # background LLM work on laptops. starship-battery = "0.10" +ethers-core = { version = "2.0.14", default-features = false } +ethers-signers = { version = "2.0.14", default-features = false } matrix-sdk = { version = "0.16", optional = true, default-features = false, features = ["e2e-encryption", "rustls-tls", "markdown"] } fantoccini = { version = "0.22.0", optional = true, default-features = false, features = ["rustls-tls"] } diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index d46e5e996..e93433dda 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -459,6 +459,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -567,6 +578,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -585,6 +602,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + [[package]] name = "bindgen" version = "0.72.1" @@ -635,6 +658,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "blake2" version = "0.10.6" @@ -705,6 +740,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" dependencies = [ + "sha2 0.10.9", "tinyvec", ] @@ -714,6 +750,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + [[package]] name = "bytemuck" version = "1.25.0" @@ -876,7 +918,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" dependencies = [ "byteorder", "fnv", - "uuid", + "uuid 1.23.1", ] [[package]] @@ -1070,6 +1112,58 @@ dependencies = [ "objc", ] +[[package]] +name = "coins-bip32" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b6be4a5df2098cd811f3194f64ddb96c267606bffd9689ac7b0160097b01ad3" +dependencies = [ + "bs58", + "coins-core", + "digest 0.10.7", + "hmac 0.12.1", + "k256", + "serde", + "sha2 0.10.9", + "thiserror 1.0.69", +] + +[[package]] +name = "coins-bip39" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db8fba409ce3dc04f7d804074039eb68b960b0829161f8e06c95fea3f122528" +dependencies = [ + "bitvec", + "coins-bip32", + "hmac 0.12.1", + "once_cell", + "pbkdf2 0.12.2", + "rand 0.8.6", + "sha2 0.10.9", + "thiserror 1.0.69", +] + +[[package]] +name = "coins-core" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5286a0843c21f8367f7be734f89df9b822e0321d8bcce8d6e735aadff7d74979" +dependencies = [ + "base64 0.21.7", + "bech32", + "bs58", + "digest 0.10.7", + "generic-array", + "hex", + "ripemd", + "serde", + "serde_derive", + "sha2 0.10.9", + "sha3", + "thiserror 1.0.69", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -1123,6 +1217,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-hex" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20d9a563d167a9cce0f94153382b33cb6eded6dfabff03c69ad65a28ea1514e0" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -1149,6 +1261,27 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -1398,6 +1531,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1556,7 +1701,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" dependencies = [ "serde", - "uuid", + "uuid 1.23.1", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", ] [[package]] @@ -1593,13 +1748,33 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", +] + [[package]] name = "derive_more" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "derive_more-impl", + "derive_more-impl 2.1.1", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1634,6 +1809,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -1645,7 +1821,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.1", "ctutils", ] @@ -1857,12 +2033,45 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email-encoding" version = "0.4.1" @@ -2034,6 +2243,122 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "eth-keystore" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fda3bf123be441da5260717e0661c25a2fd9cb2b2c1d20bf2e05580047158ab" +dependencies = [ + "aes", + "ctr", + "digest 0.10.7", + "hex", + "hmac 0.12.1", + "pbkdf2 0.11.0", + "rand 0.8.6", + "scrypt", + "serde", + "serde_json", + "sha2 0.10.9", + "sha3", + "thiserror 1.0.69", + "uuid 0.8.2", +] + +[[package]] +name = "ethabi" +version = "18.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7413c5f74cc903ea37386a8965a936cbeb334bd270862fdece542c1b2dcbc898" +dependencies = [ + "ethereum-types", + "hex", + "once_cell", + "regex", + "serde", + "serde_json", + "sha3", + "thiserror 1.0.69", + "uint", +] + +[[package]] +name = "ethbloom" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c22d4b5885b6aa2fe5e8b9329fb8d232bf739e434e6b87347c63bdd00c120f60" +dependencies = [ + "crunchy", + "fixed-hash", + "impl-codec", + "impl-rlp", + "impl-serde", + "scale-info", + "tiny-keccak", +] + +[[package]] +name = "ethereum-types" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d215cbf040552efcbe99a38372fe80ab9d00268e20012b79fcd0f073edd8ee" +dependencies = [ + "ethbloom", + "fixed-hash", + "impl-codec", + "impl-rlp", + "impl-serde", + "primitive-types", + "scale-info", + "uint", +] + +[[package]] +name = "ethers-core" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d80cc6ad30b14a48ab786523af33b37f28a8623fc06afd55324816ef18fb1f" +dependencies = [ + "arrayvec", + "bytes", + "chrono", + "const-hex", + "elliptic-curve", + "ethabi", + "generic-array", + "k256", + "num_enum", + "open-fastrlp", + "rand 0.8.6", + "rlp", + "serde", + "serde_json", + "strum", + "tempfile", + "thiserror 1.0.69", + "tiny-keccak", + "unicode-xid", +] + +[[package]] +name = "ethers-signers" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "228875491c782ad851773b652dd8ecac62cda8571d3bc32a5853644dd26766c2" +dependencies = [ + "async-trait", + "coins-bip32", + "coins-bip39", + "const-hex", + "elliptic-curve", + "eth-keystore", + "ethers-core", + "rand 0.8.6", + "sha2 0.10.9", + "thiserror 1.0.69", + "tracing", +] + [[package]] name = "euclid" version = "0.22.14" @@ -2109,6 +2434,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -2147,6 +2482,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand 0.8.6", + "rustc-hex", + "static_assertions", +] + [[package]] name = "flate2" version = "1.1.9" @@ -2279,6 +2626,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futf" version = "0.1.5" @@ -2488,6 +2841,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2682,6 +3036,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -3204,6 +3569,44 @@ dependencies = [ "nom 7.1.3", ] +[[package]] +name = "impl-codec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "impl-rlp" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28220f89297a075ddc7245cd538076ee98b01f2a9c23a53a4f1105d5a322808" +dependencies = [ + "rlp", +] + +[[package]] +name = "impl-serde" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc88fc67028ae3db0c853baa36269d398d5f45b6982f95549ff5def78c935cd" +dependencies = [ + "serde", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -3478,6 +3881,29 @@ dependencies = [ "serde_json", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2 0.10.9", + "signature", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -3489,6 +3915,21 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -4585,6 +5026,31 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "open-fastrlp" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786393f80485445794f6043fd3138854dd109cc6c4bd1a6383db304c9ce9b9ce" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", + "ethereum-types", + "open-fastrlp-derive", +] + +[[package]] +name = "open-fastrlp-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "003b2be5c6c53c1cfeb0a238b8a1c3915cd410feb684457a36c10038f764bb1c" +dependencies = [ + "bytes", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "openhuman" version = "0.53.49" @@ -4612,6 +5078,8 @@ dependencies = [ "dotenvy", "enigo", "env_logger", + "ethers-core", + "ethers-signers", "flate2", "fs2", "futures", @@ -4674,7 +5142,7 @@ dependencies = [ "unicode-width", "url", "urlencoding", - "uuid", + "uuid 1.23.1", "wait-timeout", "walkdir", "webpki-roots 1.0.7", @@ -4883,6 +5351,34 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "parking" version = "2.2.1" @@ -4929,6 +5425,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -5189,6 +5704,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -5370,6 +5895,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primitive-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" +dependencies = [ + "fixed-hash", + "impl-codec", + "impl-rlp", + "impl-serde", + "scale-info", + "uint", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -5452,6 +5991,21 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags 2.11.1", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + [[package]] name = "prost" version = "0.14.3" @@ -5588,6 +6142,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.7.3" @@ -5715,6 +6275,15 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -5920,6 +6489,16 @@ dependencies = [ "usvg", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "rgb" version = "0.8.53" @@ -5943,6 +6522,37 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "rlp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" +dependencies = [ + "bytes", + "rlp-derive", + "rustc-hex", +] + +[[package]] +name = "rlp-derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e33d7b2abe0c340d8797fe2907d3f20d3b5ea5908683618bfe80df7f621f672a" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -5985,6 +6595,12 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[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" @@ -6114,6 +6730,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -6123,6 +6748,30 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scale-info" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346a3b32eba2640d17a9cb5927056b08f3de90f65b72fe09402c2ad07d684d0b" +dependencies = [ + "cfg-if", + "derive_more 1.0.0", + "parity-scale-codec", + "scale-info-derive", +] + +[[package]] +name = "scale-info-derive" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6630024bf739e2179b91fb424b28898baf819414262c5d376677dbff1fe7ebf" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "schannel" version = "0.1.29" @@ -6156,7 +6805,7 @@ dependencies = [ "serde", "serde_json", "url", - "uuid", + "uuid 1.23.1", ] [[package]] @@ -6177,6 +6826,32 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f9e24d2b632954ded8ab2ef9fea0a0c769ea56ea98bddbafbad22caeeadf45d" +dependencies = [ + "hmac 0.12.1", + "pbkdf2 0.11.0", + "salsa20", + "sha2 0.10.9", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -6358,7 +7033,7 @@ dependencies = [ "thiserror 2.0.18", "time", "url", - "uuid", + "uuid 1.23.1", ] [[package]] @@ -6605,6 +7280,16 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -6645,6 +7330,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -6828,6 +7523,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -6852,6 +7557,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stop-token" version = "0.7.0" @@ -6939,6 +7650,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -7087,6 +7820,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tar" version = "0.4.45" @@ -7195,7 +7934,7 @@ dependencies = [ "thiserror 2.0.18", "time", "url", - "uuid", + "uuid 1.23.1", "walkdir", ] @@ -7449,7 +8188,7 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", "url", "urlpattern", - "uuid", + "uuid 1.23.1", "walkdir", ] @@ -8157,6 +8896,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + [[package]] name = "uname" version = "0.1.1" @@ -8166,6 +8917,12 @@ dependencies = [ "libc", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unic-char-property" version = "0.9.0" @@ -8435,6 +9192,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.17", + "serde", +] + [[package]] name = "uuid" version = "1.23.1" @@ -9685,6 +10452,15 @@ dependencies = [ "windows-version", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" @@ -9815,7 +10591,7 @@ dependencies = [ "serde_repr", "tracing", "uds_windows", - "uuid", + "uuid 1.23.1", "windows-sys 0.61.2", "winnow 1.0.2", "zbus_macros", diff --git a/app/src/components/settings/panels/__tests__/ConnectionsPanel.test.tsx b/app/src/components/settings/panels/__tests__/ConnectionsPanel.test.tsx index ebc506400..e8bd4324e 100644 --- a/app/src/components/settings/panels/__tests__/ConnectionsPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/ConnectionsPanel.test.tsx @@ -21,6 +21,7 @@ const sampleConfigured = { configured: true, onboardingCompleted: true, consentGranted: true, + secretStored: true, source: 'generated' as const, mnemonicWordCount: 12, accounts: [ @@ -36,6 +37,7 @@ const sampleUnconfigured = { configured: false, onboardingCompleted: false, consentGranted: false, + secretStored: false, source: null, mnemonicWordCount: null, accounts: [], diff --git a/app/src/components/settings/panels/__tests__/RecoveryPhrasePanel.test.tsx b/app/src/components/settings/panels/__tests__/RecoveryPhrasePanel.test.tsx index e25bd8a7d..6a0328d71 100644 --- a/app/src/components/settings/panels/__tests__/RecoveryPhrasePanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/RecoveryPhrasePanel.test.tsx @@ -16,6 +16,7 @@ vi.mock('../../../../services/walletApi', () => ({ configured: true, onboardingCompleted: true, consentGranted: true, + secretStored: true, source: 'generated', mnemonicWordCount: 12, accounts: [], diff --git a/app/src/features/wallet/setupLocalWalletFromMnemonic.test.ts b/app/src/features/wallet/setupLocalWalletFromMnemonic.test.ts index d9d0eec27..30eb80642 100644 --- a/app/src/features/wallet/setupLocalWalletFromMnemonic.test.ts +++ b/app/src/features/wallet/setupLocalWalletFromMnemonic.test.ts @@ -3,11 +3,16 @@ import { describe, expect, it, vi } from 'vitest'; import { persistLocalWalletFromMnemonic } from './setupLocalWalletFromMnemonic'; const mockSetupLocalWallet = vi.fn(); +const mockEncryptSecret = vi.fn(); vi.mock('../../services/walletApi', () => ({ setupLocalWallet: (...args: unknown[]) => mockSetupLocalWallet(...args), })); +vi.mock('../../utils/tauriCommands/auth', () => ({ + openhumanEncryptSecret: (...args: unknown[]) => mockEncryptSecret(...args), +})); + describe('persistLocalWalletFromMnemonic', () => { it('derives multi-chain wallet metadata and persists it after storing the AES key', async () => { const setEncryptionKey = vi.fn(async () => undefined); @@ -15,11 +20,13 @@ describe('persistLocalWalletFromMnemonic', () => { configured: true, onboardingCompleted: true, consentGranted: true, + secretStored: true, source: 'generated', mnemonicWordCount: 12, accounts: [], updatedAtMs: 123, }); + mockEncryptSecret.mockResolvedValueOnce({ result: 'enc2:wallet-secret' }); const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; @@ -33,6 +40,7 @@ describe('persistLocalWalletFromMnemonic', () => { consentGranted: true, source: 'generated', mnemonicWordCount: 12, + encryptedMnemonic: 'enc2:wallet-secret', accounts: [ { chain: 'evm', @@ -61,10 +69,31 @@ describe('persistLocalWalletFromMnemonic', () => { it('rejects whitespace-only input without touching encryption key or wallet store', async () => { const setEncryptionKey = vi.fn(async () => undefined); mockSetupLocalWallet.mockReset(); + mockEncryptSecret.mockReset(); await expect( persistLocalWalletFromMnemonic({ mnemonic: ' \t ', source: 'imported', setEncryptionKey }) ).rejects.toThrow(/recovery phrase is required/i); + expect(setEncryptionKey).not.toHaveBeenCalled(); + expect(mockEncryptSecret).not.toHaveBeenCalled(); + expect(mockSetupLocalWallet).not.toHaveBeenCalled(); + }); + + it('rejects empty encrypted mnemonic output before persisting wallet state', async () => { + const setEncryptionKey = vi.fn(async () => undefined); + mockSetupLocalWallet.mockReset(); + mockEncryptSecret.mockReset(); + mockEncryptSecret.mockResolvedValueOnce({ result: ' ' }); + + await expect( + persistLocalWalletFromMnemonic({ + mnemonic: + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + source: 'imported', + setEncryptionKey, + }) + ).rejects.toThrow(/failed to secure recovery phrase/i); + expect(setEncryptionKey).not.toHaveBeenCalled(); expect(mockSetupLocalWallet).not.toHaveBeenCalled(); }); diff --git a/app/src/features/wallet/setupLocalWalletFromMnemonic.ts b/app/src/features/wallet/setupLocalWalletFromMnemonic.ts index 4274e9411..d135f2ef7 100644 --- a/app/src/features/wallet/setupLocalWalletFromMnemonic.ts +++ b/app/src/features/wallet/setupLocalWalletFromMnemonic.ts @@ -4,6 +4,7 @@ import { deriveWalletAccountsFromMnemonic, type WalletSetupSource, } from '../../utils/cryptoKeys'; +import { openhumanEncryptSecret } from '../../utils/tauriCommands/auth'; export async function persistLocalWalletFromMnemonic(args: { mnemonic: string; @@ -17,12 +18,17 @@ export async function persistLocalWalletFromMnemonic(args: { } const normalizedMnemonic = words.join(' '); const aesKey = deriveAesKeyFromMnemonic(normalizedMnemonic); + const encryptedMnemonic = (await openhumanEncryptSecret(normalizedMnemonic)).result?.trim(); + if (!encryptedMnemonic) { + throw new Error('Failed to secure recovery phrase. Please try again.'); + } await setEncryptionKey(aesKey); await setupLocalWallet({ consentGranted: true, source, mnemonicWordCount: words.length, + encryptedMnemonic, accounts: deriveWalletAccountsFromMnemonic(normalizedMnemonic), }); } diff --git a/app/src/services/walletApi.test.ts b/app/src/services/walletApi.test.ts index 4fdd6c4d1..230c3855f 100644 --- a/app/src/services/walletApi.test.ts +++ b/app/src/services/walletApi.test.ts @@ -17,6 +17,7 @@ describe('walletApi', () => { configured: true, onboardingCompleted: true, consentGranted: true, + secretStored: true, source: 'generated', mnemonicWordCount: 12, accounts: [], @@ -36,6 +37,7 @@ describe('walletApi', () => { consentGranted: true, source: 'imported' as const, mnemonicWordCount: 24, + encryptedMnemonic: 'enc2:wallet-secret', accounts: [{ chain: 'evm' as const, address: '0xabc', derivationPath: "m/44'/60'/0'/0/0" }], }; mockCallCoreRpc.mockResolvedValueOnce({ result: { configured: true } }); diff --git a/app/src/services/walletApi.ts b/app/src/services/walletApi.ts index b9cfd165e..30fe3129e 100644 --- a/app/src/services/walletApi.ts +++ b/app/src/services/walletApi.ts @@ -13,6 +13,7 @@ export interface WalletStatus { configured: boolean; onboardingCompleted: boolean; consentGranted: boolean; + secretStored: boolean; source: WalletSetupSource | null; mnemonicWordCount: number | null; accounts: WalletAccount[]; @@ -23,6 +24,7 @@ export interface SetupWalletParams { consentGranted: boolean; source: WalletSetupSource; mnemonicWordCount: number; + encryptedMnemonic?: string; accounts: WalletAccount[]; } diff --git a/app/test/Mnemonic.test.tsx b/app/test/Mnemonic.test.tsx index d504a6088..756f8d416 100644 --- a/app/test/Mnemonic.test.tsx +++ b/app/test/Mnemonic.test.tsx @@ -35,12 +35,14 @@ const { mockValidateMnemonicPhrase, mockDeriveAesKey, mockSetEncryptionKey, + mockEncryptSecret, mockUseCoreState, } = vi.hoisted(() => ({ mockGenerateMnemonicPhrase: vi.fn(() => FIXED_MNEMONIC), mockValidateMnemonicPhrase: vi.fn(() => true), mockDeriveAesKey: vi.fn(() => 'aes-key-hex'), mockSetEncryptionKey: vi.fn().mockResolvedValue(undefined), + mockEncryptSecret: vi.fn().mockResolvedValue({ result: 'enc2:wallet-secret' }), mockUseCoreState: vi.fn(), })); @@ -52,6 +54,9 @@ vi.mock('../src/utils/cryptoKeys', () => ({ })); vi.mock('../src/providers/CoreStateProvider', () => ({ useCoreState: () => mockUseCoreState() })); +vi.mock('../src/utils/tauriCommands/auth', () => ({ + openhumanEncryptSecret: (...args: unknown[]) => mockEncryptSecret(...args), +})); // LottieAnimation makes network calls; stub it out vi.mock('../src/components/LottieAnimation', () => ({ @@ -93,6 +98,7 @@ beforeEach(() => { mockValidateMnemonicPhrase.mockClear(); mockDeriveAesKey.mockClear(); mockSetEncryptionKey.mockClear(); + mockEncryptSecret.mockClear(); mockUseCoreState.mockReturnValue({ snapshot: { currentUser: mockUser, sessionToken: 'jwt-token' }, setEncryptionKey: mockSetEncryptionKey, diff --git a/app/test/e2e/specs/chat-harness-wallet-flow.spec.ts b/app/test/e2e/specs/chat-harness-wallet-flow.spec.ts new file mode 100644 index 000000000..ca8f290b9 --- /dev/null +++ b/app/test/e2e/specs/chat-harness-wallet-flow.spec.ts @@ -0,0 +1,223 @@ +/** + * Chat harness + wallet flow end-to-end. + * + * This spec locks down the current wallet contract as it exists today: + * + * 1. A user can complete local wallet setup through the real + * Recovery Phrase panel, which persists `state/wallet-state.json` + * in the workspace. + * 2. A real `/chat` turn can route through the orchestrator into the + * crypto sub-agent and invoke `wallet_prepare_transfer`. + * 3. The resulting prepared quote is visible from Rust-side + * test-support introspection, proving the agent flow crossed the + * UI → core → tool-dispatch boundary. + * + * What this does NOT claim: + * - on-chain broadcast + * - desktop-keystore signing + * + * The current core implementation explicitly stops at + * `execute_prepared -> ReadyToSign`, so the E2E keeps its assertion + * surface honest and checks the prepared quote boundary. + */ +import { waitForApp } from '../helpers/app-helpers'; +import { + clickByTitle, + clickSend, + getSelectedThreadId, + hexEncodeThreadId, + typeIntoComposer, +} from '../helpers/chat-harness'; +import { callOpenhumanRpc } from '../helpers/core-rpc'; +import { clickText, clickToggle, textExists } from '../helpers/element-helpers'; +import { resetApp } from '../helpers/reset-app'; +import { navigateViaHash } from '../helpers/shared-flows'; +import { + clearRequestLog, + getRequestLog, + setMockBehavior, + startMockServer, + stopMockServer, +} from '../mock-server'; + +const USER_ID = 'e2e-chat-harness-wallet-flow'; +const CANARY = 'wallet-quote-canary-8d13'; +const JOHN_ADDRESS = '0x00000000000000000000000000000000000000aa'; +const WALLET_PROMPT = `Send John $5 on EVM at ${JOHN_ADDRESS} and tell me ${CANARY}.`; + +const FORCED_RESPONSES = [ + { + content: '', + toolCalls: [ + { + id: 'call_delegate_do_crypto_1', + name: 'delegate_do_crypto', + arguments: JSON.stringify({ + prompt: `Prepare a $5 EVM transfer to John at ${JOHN_ADDRESS}.`, + }), + }, + ], + }, + { + content: '', + toolCalls: [{ id: 'call_wallet_status_1', name: 'wallet_status', arguments: '{}' }], + }, + { + content: '', + toolCalls: [{ id: 'call_wallet_chain_status_1', name: 'wallet_chain_status', arguments: '{}' }], + }, + { + content: '', + toolCalls: [ + { + id: 'call_wallet_prepare_transfer_1', + name: 'wallet_prepare_transfer', + arguments: JSON.stringify({ + chain: 'evm', + toAddress: JOHN_ADDRESS, + amountRaw: '5000000000000000000', + }), + }, + ], + }, + { content: `Prepared a wallet quote for John. ${CANARY}` }, + { content: `Done. ${CANARY}` }, +]; + +async function clickRecoveryConsentCheckbox(): Promise { + const checkbox = await browser.$('input[type="checkbox"]'); + if (!(await checkbox.isExisting())) { + throw new Error('Recovery phrase consent checkbox not found'); + } + if (!(await checkbox.isSelected())) { + await clickToggle(); + } + await expect(checkbox).toBeSelected(); +} + +describe('Chat harness — wallet flow', () => { + before(async () => { + await startMockServer(); + await waitForApp(); + await resetApp(USER_ID); + }); + + after(async () => { + setMockBehavior('llmForcedResponses', ''); + setMockBehavior('llmStreamChunkDelayMs', ''); + await stopMockServer(); + }); + + it('sets up the local wallet through the Recovery Phrase panel and persists wallet state', async () => { + await navigateViaHash('/settings/recovery-phrase'); + await browser.waitUntil(async () => await textExists('Save Recovery Phrase'), { + timeout: 15_000, + timeoutMsg: 'Recovery Phrase panel did not mount', + }); + + await clickRecoveryConsentCheckbox(); + await clickText('Save Recovery Phrase', 10_000); + + await browser.waitUntil(async () => await textExists('Recovery phrase saved'), { + timeout: 20_000, + timeoutMsg: 'wallet setup success message never rendered', + }); + + await browser.waitUntil( + async () => { + const status = await callOpenhumanRpc<{ result: { configured: boolean } }>( + 'openhuman.wallet_status', + {} + ); + return status.ok && status.result?.result?.configured === true; + }, + { timeout: 20_000, timeoutMsg: 'wallet_status never became configured' } + ); + + const walletState = await callOpenhumanRpc<{ + result: { content_utf8: string; truncated: boolean }; + }>('openhuman.test_support_read_workspace_file', { + rel_path: 'state/wallet-state.json', + max_bytes: 131_072, + }); + expect(walletState.ok).toBe(true); + const content = walletState.result?.result?.content_utf8 ?? ''; + expect(content).toContain('"consentGranted": true'); + expect(content).toContain('"source": "generated"'); + expect(content).toContain('"chain": "evm"'); + expect(content).toContain('"chain": "btc"'); + expect(content).toContain('"chain": "solana"'); + expect(content).toContain('"chain": "tron"'); + }); + + it('routes a real chat turn through the crypto agent and creates a prepared wallet quote', async () => { + clearRequestLog(); + setMockBehavior('llmForcedResponses', JSON.stringify(FORCED_RESPONSES)); + setMockBehavior('llmStreamChunkDelayMs', '10'); + + await navigateViaHash('/chat'); + await browser.waitUntil(async () => await textExists('Threads'), { + timeout: 15_000, + timeoutMsg: 'Conversations did not mount', + }); + expect(await clickByTitle('New thread', 8_000)).toBe(true); + + const threadId = (await browser.waitUntil(async () => await getSelectedThreadId(), { + timeout: 8_000, + timeoutMsg: 'thread.selectedThreadId never populated', + })) as string; + expect(typeof threadId).toBe('string'); + + await typeIntoComposer(WALLET_PROMPT); + expect( + await browser.waitUntil(async () => await clickSend(), { + timeout: 5_000, + timeoutMsg: 'Send button never enabled', + }) + ).toBe(true); + + await browser.waitUntil(async () => await textExists(CANARY), { + timeout: 30_000, + timeoutMsg: 'wallet chat flow never rendered the final canary', + }); + + await browser.waitUntil( + async () => { + const quotes = await callOpenhumanRpc<{ + result: { + count: number; + quotes: Array<{ toAddress: string; amountRaw: string; status: string; kind: string }>; + }; + }>('openhuman.test_support_wallet_prepared_quotes', {}); + if (!quotes.ok) return false; + return (quotes.result?.result?.quotes ?? []).some( + quote => + quote.toAddress === JOHN_ADDRESS && + quote.amountRaw === '5000000000000000000' && + quote.status === 'awaiting_confirmation' && + quote.kind === 'native_transfer' + ); + }, + { + timeout: 15_000, + timeoutMsg: 'prepared wallet quote never appeared in Rust-side introspection', + } + ); + + const log = getRequestLog() as Array<{ method: string; url: string }>; + const llmHits = log.filter( + entry => entry.method === 'POST' && entry.url.includes('/openai/v1/chat/completions') + ); + expect(llmHits.length).toBeGreaterThanOrEqual(4); + + const relPath = `memory/conversations/threads/${hexEncodeThreadId(threadId)}.jsonl`; + const read = await callOpenhumanRpc<{ result: { content_utf8: string } }>( + 'openhuman.test_support_read_workspace_file', + { rel_path: relPath, max_bytes: 131_072 } + ); + expect(read.ok).toBe(true); + const threadContent = read.result?.result?.content_utf8 ?? ''; + expect(threadContent).toContain(CANARY); + expect(threadContent).toContain(WALLET_PROMPT); + }); +}); diff --git a/src/openhuman/agent/agents/crypto_agent/agent.toml b/src/openhuman/agent/agents/crypto_agent/agent.toml index 6239ac0da..f9ccbba27 100644 --- a/src/openhuman/agent/agents/crypto_agent/agent.toml +++ b/src/openhuman/agent/agents/crypto_agent/agent.toml @@ -32,8 +32,10 @@ named = [ # Read-only wallet inspection. "wallet_status", "wallet_balances", + "wallet_network_defaults", "wallet_supported_assets", "wallet_chain_status", + "wallet_encode_erc20_transfer", # Quote / prepare. These MUST be called before any execute_prepared # — they return a deterministic transaction blob plus fee/slippage # estimates the agent shows the user for confirmation. diff --git a/src/openhuman/agent/agents/crypto_agent/prompt.md b/src/openhuman/agent/agents/crypto_agent/prompt.md index f79918c15..719dc3371 100644 --- a/src/openhuman/agent/agents/crypto_agent/prompt.md +++ b/src/openhuman/agent/agents/crypto_agent/prompt.md @@ -20,8 +20,8 @@ You are the **Crypto Agent** — OpenHuman's specialist for wallet and market op ## Hard rules 1. **No fabrication.** Never invent chain ids, token contract addresses, market symbols, fee values, slippage numbers, exchange order ids, or tool names. If you don't have it from a tool result or the user, ask. If a tool isn't in your tool list, say so — do not pretend it exists. -2. **Read before write.** Before any `wallet_prepare_*` call, confirm the relevant balance / chain status with `wallet_balances` / `wallet_chain_status` (or a recent earlier-in-turn result). Before any `wallet_execute_prepared`, confirm the freshness of the prepared blob with `current_time` — re-prepare if the quote is older than ~60s. -3. **Quote before execute.** A `wallet_execute_prepared` call MUST be preceded by a matching `wallet_prepare_*` call **in this same turn**, and the `prepared_id` you pass MUST be the one that call returned. No exceptions. +2. **Read before write.** Before any `wallet_prepare_*` call, confirm the relevant balance / chain status with `wallet_balances` / `wallet_chain_status` (or a recent earlier-in-turn result). Use `wallet_network_defaults` when you need the default RPC / explorer / asset catalog for a chain. Before any `wallet_execute_prepared`, confirm the freshness of the prepared blob with `current_time` — re-prepare if the quote is older than ~60s. +3. **Quote before execute.** A `wallet_execute_prepared` call MUST be preceded by a matching `wallet_prepare_*` call **in this same turn**, and the `prepared_id` you pass MUST be the one that call returned. No exceptions. For ERC-20 transfers, `wallet_encode_erc20_transfer` exists if you need ABI calldata inspection, but prefer `wallet_prepare_transfer` for the actual execution flow. 4. **Confirm before execute.** Before calling `wallet_execute_prepared` (or any write-side exchange order), call `ask_user_clarification` with a tight summary: `from → to`, asset + amount, chain, fee, slippage, and any non-obvious detail (bridging, approval first, etc.). Only proceed on an explicit yes. 5. **Stop cleanly on missing setup.** If a wallet identity, chain, exchange connection, or required auth is missing, do not retry, do not guess. Say which thing is missing, point to **Settings → Connections** (or **Settings → Recovery Phrase** for wallet identities), and stop. 6. **Stop cleanly on insufficient liquidity / balance.** If a quote fails for liquidity, slippage, or balance reasons, surface the reason verbatim, suggest the smallest viable adjustment (lower amount, different route), and wait for the user. diff --git a/src/openhuman/agent/agents/loader.rs b/src/openhuman/agent/agents/loader.rs index f4bd639ab..42638cc97 100644 --- a/src/openhuman/agent/agents/loader.rs +++ b/src/openhuman/agent/agents/loader.rs @@ -525,8 +525,10 @@ mod tests { for required in [ "wallet_status", "wallet_balances", + "wallet_network_defaults", "wallet_supported_assets", "wallet_chain_status", + "wallet_encode_erc20_transfer", ] { assert!( tools.iter().any(|t| t == required), diff --git a/src/openhuman/agent/harness/test_support_test.rs b/src/openhuman/agent/harness/test_support_test.rs index fe4737990..2436ac522 100644 --- a/src/openhuman/agent/harness/test_support_test.rs +++ b/src/openhuman/agent/harness/test_support_test.rs @@ -288,6 +288,59 @@ impl RecordingTool { } } +struct SequencedTool { + name_str: String, + result: ToolResult, + calls: Arc>>, + sequence: Arc>>, +} + +impl SequencedTool { + fn new( + name: &str, + result: ToolResult, + sequence: Arc>>, + ) -> (Self, Arc>>) { + let calls = Arc::new(parking_lot::Mutex::new(Vec::new())); + ( + Self { + name_str: name.to_string(), + result, + calls: calls.clone(), + sequence, + }, + calls, + ) + } +} + +#[async_trait] +impl Tool for SequencedTool { + fn name(&self) -> &str { + &self.name_str + } + fn description(&self) -> &str { + &self.name_str + } + fn parameters_schema(&self) -> serde_json::Value { + json!({"type": "object", "additionalProperties": true}) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::ReadOnly + } + fn scope(&self) -> ToolScope { + ToolScope::All + } + fn category(&self) -> ToolCategory { + ToolCategory::System + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + self.sequence.lock().push(self.name_str.clone()); + self.calls.lock().push(args); + Ok(self.result.clone()) + } +} + #[async_trait] impl Tool for RecordingTool { fn name(&self) -> &str { @@ -462,6 +515,248 @@ async fn keyword_provider_chains_multiple_tools_across_iterations() { assert_eq!(send_calls.lock().len(), 1); } +// ── 4. Crypto wallet flow: inspect → quote → confirm → execute ─── + +#[tokio::test] +async fn crypto_wallet_send_flow_sequences_wallet_tools_and_confirmation_gate() { + let provider = KeywordScriptedProvider::new(vec![ + KeywordRule::tool_call( + "send john $5", + ScriptedToolCall::new("wallet_status", json!({})), + ), + KeywordRule::tool_call( + "wallet_status-ok", + ScriptedToolCall::new("wallet_balances", json!({})), + ), + KeywordRule::tool_call( + "wallet_balances-ok", + ScriptedToolCall::new("wallet_chain_status", json!({})), + ), + KeywordRule::tool_call( + "wallet_chain_status-ok", + ScriptedToolCall::new( + "wallet_prepare_transfer", + json!({ + "chain": "evm", + "to_address": "0x00000000000000000000000000000000000000aa", + "amount_raw": "5000000", + }), + ), + ), + KeywordRule::tool_call( + "wallet_prepare_transfer-ok", + ScriptedToolCall::new( + "ask_user_clarification", + json!({"question": "Send $5 to John on EVM?"}), + ), + ), + KeywordRule::tool_call( + "ask_user_clarification-ok", + ScriptedToolCall::new( + "wallet_execute_prepared", + json!({"quoteId": "q_test_send_john", "confirmed": true}), + ), + ), + KeywordRule::final_reply( + "wallet_execute_prepared-ok", + "Prepared quote confirmed and handed to the wallet signer.", + ), + ]) + .with_native_tools(true); + + let sequence = Arc::new(parking_lot::Mutex::new(Vec::new())); + let (wallet_status, wallet_status_calls) = SequencedTool::new( + "wallet_status", + ToolResult::success("wallet_status-ok"), + sequence.clone(), + ); + let (wallet_balances, wallet_balances_calls) = SequencedTool::new( + "wallet_balances", + ToolResult::success("wallet_balances-ok"), + sequence.clone(), + ); + let (wallet_chain_status, wallet_chain_status_calls) = SequencedTool::new( + "wallet_chain_status", + ToolResult::success("wallet_chain_status-ok"), + sequence.clone(), + ); + let (wallet_prepare_transfer, wallet_prepare_transfer_calls) = SequencedTool::new( + "wallet_prepare_transfer", + ToolResult::success("wallet_prepare_transfer-ok"), + sequence.clone(), + ); + let (ask_user_clarification, ask_user_clarification_calls) = SequencedTool::new( + "ask_user_clarification", + ToolResult::success("ask_user_clarification-ok"), + sequence.clone(), + ); + let (wallet_execute_prepared, wallet_execute_prepared_calls) = SequencedTool::new( + "wallet_execute_prepared", + ToolResult::success("wallet_execute_prepared-ok"), + sequence.clone(), + ); + + let tools: Vec> = vec![ + Box::new(wallet_status), + Box::new(wallet_balances), + Box::new(wallet_chain_status), + Box::new(wallet_prepare_transfer), + Box::new(ask_user_clarification), + Box::new(wallet_execute_prepared), + ]; + let mut history = vec![ChatMessage::user("Please send John $5 on EVM.")]; + + let out = run_tool_call_loop( + &provider, + &mut history, + &tools, + "mock-native", + "test-model", + 0.0, + true, + None, + "web", + &mm(), + 10, + None, + None, + &[], + None, + None, + ) + .await + .expect("crypto wallet flow should complete"); + + assert_eq!( + out, + "Prepared quote confirmed and handed to the wallet signer." + ); + assert_eq!( + sequence.lock().clone(), + vec![ + "wallet_status", + "wallet_balances", + "wallet_chain_status", + "wallet_prepare_transfer", + "ask_user_clarification", + "wallet_execute_prepared", + ] + ); + assert_eq!(wallet_status_calls.lock().len(), 1); + assert_eq!(wallet_balances_calls.lock().len(), 1); + assert_eq!(wallet_chain_status_calls.lock().len(), 1); + assert_eq!( + wallet_prepare_transfer_calls.lock()[0], + json!({ + "chain": "evm", + "to_address": "0x00000000000000000000000000000000000000aa", + "amount_raw": "5000000", + }) + ); + assert_eq!( + ask_user_clarification_calls.lock()[0]["question"], + "Send $5 to John on EVM?" + ); + assert_eq!( + wallet_execute_prepared_calls.lock()[0], + json!({"quoteId": "q_test_send_john", "confirmed": true}) + ); +} + +#[tokio::test] +async fn crypto_wallet_send_flow_does_not_execute_when_confirmation_is_not_granted() { + let provider = KeywordScriptedProvider::new(vec![ + KeywordRule::tool_call( + "send john $5", + ScriptedToolCall::new("wallet_status", json!({})), + ), + KeywordRule::tool_call( + "wallet_status-ok", + ScriptedToolCall::new("wallet_prepare_transfer", json!({"chain": "evm"})), + ), + KeywordRule::tool_call( + "wallet_prepare_transfer-ok", + ScriptedToolCall::new( + "ask_user_clarification", + json!({"question": "Confirm the transfer?"}), + ), + ), + KeywordRule::final_reply( + "user declined the transfer", + "Cancelled before execution because the user did not confirm.", + ), + ]) + .with_native_tools(true); + + let sequence = Arc::new(parking_lot::Mutex::new(Vec::new())); + let (wallet_status, _) = SequencedTool::new( + "wallet_status", + ToolResult::success("wallet_status-ok"), + sequence.clone(), + ); + let (wallet_prepare_transfer, _) = SequencedTool::new( + "wallet_prepare_transfer", + ToolResult::success("wallet_prepare_transfer-ok"), + sequence.clone(), + ); + let (ask_user_clarification, _) = SequencedTool::new( + "ask_user_clarification", + ToolResult::success("user declined the transfer"), + sequence.clone(), + ); + let (wallet_execute_prepared, wallet_execute_prepared_calls) = SequencedTool::new( + "wallet_execute_prepared", + ToolResult::success("wallet_execute_prepared-ok"), + sequence.clone(), + ); + + let tools: Vec> = vec![ + Box::new(wallet_status), + Box::new(wallet_prepare_transfer), + Box::new(ask_user_clarification), + Box::new(wallet_execute_prepared), + ]; + let mut history = vec![ChatMessage::user("Please send John $5 on EVM.")]; + + let out = run_tool_call_loop( + &provider, + &mut history, + &tools, + "mock-native", + "test-model", + 0.0, + true, + None, + "telegram", + &mm(), + 8, + None, + None, + &[], + None, + None, + ) + .await + .expect("declined flow should still complete"); + + assert_eq!( + out, + "Cancelled before execution because the user did not confirm." + ); + assert_eq!( + sequence.lock().clone(), + vec![ + "wallet_status", + "wallet_prepare_transfer", + "ask_user_clarification", + ] + ); + assert!( + wallet_execute_prepared_calls.lock().is_empty(), + "execute tool must not run after a declined confirmation" + ); +} + #[tokio::test] async fn keyword_provider_uses_latest_tool_result_to_drive_the_next_tool_call() { let provider = KeywordScriptedProvider::new(vec![ @@ -590,7 +885,7 @@ async fn keyword_provider_executes_multiple_native_tool_calls_from_one_turn() { assert_eq!(turns[0].emitted_tool_calls[1].name, "enrich_tool"); } -// ── 4. Unknown tool name handled gracefully ─────────────────────── +// ── 6. Unknown tool name handled gracefully ─────────────────────── #[tokio::test] async fn keyword_provider_unknown_tool_surfaces_error_and_loop_continues() { diff --git a/src/openhuman/test_support/introspect.rs b/src/openhuman/test_support/introspect.rs index 7e33ab03d..272095974 100644 --- a/src/openhuman/test_support/introspect.rs +++ b/src/openhuman/test_support/introspect.rs @@ -16,6 +16,7 @@ use tokio::fs; use crate::openhuman::channels::providers::web::in_flight_entries_for_test; use crate::openhuman::config::Config; +use crate::openhuman::wallet::prepared_quotes_for_test; use crate::rpc::RpcOutcome; /// Maximum bytes returned by `read_workspace_file`. Specs that need bigger @@ -263,3 +264,21 @@ pub async fn in_flight_chats() -> Result, String> { format!("in_flight_chats: {count} entries"), )) } + +// ── wallet_prepared_quotes ──────────────────────────────────────── + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreparedQuotesResult { + pub count: usize, + pub quotes: Vec, +} + +pub async fn wallet_prepared_quotes() -> Result, String> { + let quotes = prepared_quotes_for_test(); + let count = quotes.len(); + Ok(RpcOutcome::single_log( + PreparedQuotesResult { count, quotes }, + format!("wallet_prepared_quotes: {count} quotes"), + )) +} diff --git a/src/openhuman/test_support/schemas.rs b/src/openhuman/test_support/schemas.rs index 2633b9a5f..a210e2216 100644 --- a/src/openhuman/test_support/schemas.rs +++ b/src/openhuman/test_support/schemas.rs @@ -12,6 +12,7 @@ pub fn all_controller_schemas() -> Vec { schemas("list_workspace_files"), schemas("read_workspace_file"), schemas("in_flight_chats"), + schemas("wallet_prepared_quotes"), ] } @@ -37,6 +38,10 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("in_flight_chats"), handler: handle_in_flight_chats, }, + RegisteredController { + schema: schemas("wallet_prepared_quotes"), + handler: handle_wallet_prepared_quotes, + }, ] } @@ -215,6 +220,35 @@ pub fn schemas(function: &str) -> ControllerSchema { required: true, }], }, + "wallet_prepared_quotes" => ControllerSchema { + namespace: "test_support", + function: "wallet_prepared_quotes", + description: + "Snapshot the in-memory wallet prepared-quote store so E2E specs can prove \ + agent-driven wallet flows reached the quote/simulate boundary.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::Object { + fields: vec![ + FieldSchema { + name: "count", + ty: TypeSchema::U64, + comment: "Number of non-consumed prepared quotes currently retained.", + required: true, + }, + FieldSchema { + name: "quotes", + ty: TypeSchema::String, + comment: "Array of prepared-transaction objects (serialized).", + required: true, + }, + ], + }, + comment: "Prepared-quote snapshot.", + required: true, + }], + }, _other => ControllerSchema { namespace: "test_support", function: "unknown", @@ -273,6 +307,10 @@ fn handle_in_flight_chats(_params: Map) -> ControllerFuture { Box::pin(async move { to_json(introspect::in_flight_chats().await?) }) } +fn handle_wallet_prepared_quotes(_params: Map) -> ControllerFuture { + Box::pin(async move { to_json(introspect::wallet_prepared_quotes().await?) }) +} + fn to_json(outcome: RpcOutcome) -> Result { outcome.into_cli_compatible_json() } diff --git a/src/openhuman/wallet/abi.rs b/src/openhuman/wallet/abi.rs new file mode 100644 index 000000000..6aae3929d --- /dev/null +++ b/src/openhuman/wallet/abi.rs @@ -0,0 +1,60 @@ +use std::str::FromStr; + +use ethers_core::abi::{Function, Param, ParamType, StateMutability, Token}; +use ethers_core::types::{Address, U256}; + +pub fn encode_erc20_transfer(to_address: &str, amount_raw: &str) -> Result { + let to = Address::from_str(to_address.trim()) + .map_err(|e| format!("invalid EVM recipient address '{to_address}': {e}"))?; + let amount = U256::from_dec_str(amount_raw.trim()) + .map_err(|_| format!("amount '{amount_raw}' is not a valid non-negative integer"))?; + #[allow(deprecated)] + let function = Function { + name: "transfer".to_string(), + inputs: vec![ + Param { + name: "to".to_string(), + kind: ParamType::Address, + internal_type: None, + }, + Param { + name: "amount".to_string(), + kind: ParamType::Uint(256), + internal_type: None, + }, + ], + outputs: vec![Param { + name: "".to_string(), + kind: ParamType::Bool, + internal_type: None, + }], + constant: None, + state_mutability: StateMutability::NonPayable, + }; + let bytes = function + .encode_input(&[Token::Address(to), Token::Uint(amount)]) + .map_err(|e| format!("failed to encode ERC20 transfer calldata: {e}"))?; + Ok(format!("0x{}", hex::encode(bytes))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encode_erc20_transfer_matches_known_selector() { + let calldata = + encode_erc20_transfer("0x1111111111111111111111111111111111111111", "5").unwrap(); + assert!(calldata.starts_with("0xa9059cbb")); + } + + #[test] + fn encode_erc20_transfer_accepts_full_u256_amounts() { + let calldata = encode_erc20_transfer( + "0x1111111111111111111111111111111111111111", + "340282366920938463463374607431768211456", + ) + .unwrap(); + assert!(calldata.starts_with("0xa9059cbb")); + } +} diff --git a/src/openhuman/wallet/defaults.rs b/src/openhuman/wallet/defaults.rs new file mode 100644 index 000000000..68586627c --- /dev/null +++ b/src/openhuman/wallet/defaults.rs @@ -0,0 +1,222 @@ +use serde::Serialize; + +use super::ops::WalletChain; + +const DEFAULT_EVM_RPC_URL: &str = "https://ethereum-rpc.publicnode.com"; +const DEFAULT_BTC_RPC_URL: &str = "https://blockstream.info/api"; +const DEFAULT_SOLANA_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; +const DEFAULT_TRON_RPC_URL: &str = "https://api.trongrid.io"; + +const ETHERSCAN_TX_BASE: &str = "https://etherscan.io/tx/"; +const BLOCKSTREAM_TX_BASE: &str = "https://blockstream.info/tx/"; +const SOLSCAN_TX_BASE: &str = "https://solscan.io/tx/"; +const TRONSCAN_TX_BASE: &str = "https://tronscan.org/#/transaction/"; + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RpcSource { + Default, + EnvOverride, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WalletAssetDefinition { + pub chain: WalletChain, + pub symbol: String, + pub name: String, + pub native: bool, + pub decimals: u8, + #[serde(skip_serializing_if = "Option::is_none")] + pub contract_address: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WalletNetworkDefaults { + pub chain: WalletChain, + pub network: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub chain_id: Option, + pub rpc_url: String, + pub rpc_source: RpcSource, + pub explorer_tx_url_base: String, + pub supports_broadcast: bool, + pub supports_token_transfers: bool, + pub supports_contract_calls: bool, + pub assets: Vec, +} + +pub fn default_rpc_url(chain: WalletChain) -> &'static str { + match chain { + WalletChain::Evm => DEFAULT_EVM_RPC_URL, + WalletChain::Btc => DEFAULT_BTC_RPC_URL, + WalletChain::Solana => DEFAULT_SOLANA_RPC_URL, + WalletChain::Tron => DEFAULT_TRON_RPC_URL, + } +} + +pub fn rpc_url_for_chain(chain: WalletChain) -> String { + std::env::var(env_var_for_chain(chain)) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| default_rpc_url(chain).to_string()) +} + +pub fn rpc_source_for_chain(chain: WalletChain) -> RpcSource { + let url = std::env::var(env_var_for_chain(chain)).unwrap_or_default(); + if url.trim().is_empty() { + RpcSource::Default + } else { + RpcSource::EnvOverride + } +} + +pub fn explorer_tx_url(chain: WalletChain, tx_hash: &str) -> Option { + let base = match chain { + WalletChain::Evm => ETHERSCAN_TX_BASE, + WalletChain::Btc => BLOCKSTREAM_TX_BASE, + WalletChain::Solana => SOLSCAN_TX_BASE, + WalletChain::Tron => TRONSCAN_TX_BASE, + }; + Some(format!("{base}{tx_hash}")) +} + +pub fn env_var_for_chain(chain: WalletChain) -> &'static str { + match chain { + WalletChain::Evm => "OPENHUMAN_WALLET_RPC_EVM", + WalletChain::Btc => "OPENHUMAN_WALLET_RPC_BTC", + WalletChain::Solana => "OPENHUMAN_WALLET_RPC_SOLANA", + WalletChain::Tron => "OPENHUMAN_WALLET_RPC_TRON", + } +} + +pub fn asset_catalog(chain: WalletChain) -> Vec { + match chain { + WalletChain::Evm => vec![ + WalletAssetDefinition { + chain, + symbol: "ETH".to_string(), + name: "Ether".to_string(), + native: true, + decimals: 18, + contract_address: None, + }, + WalletAssetDefinition { + chain, + symbol: "USDC".to_string(), + name: "USD Coin".to_string(), + native: false, + decimals: 6, + contract_address: Some("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string()), + }, + WalletAssetDefinition { + chain, + symbol: "USDT".to_string(), + name: "Tether USD".to_string(), + native: false, + decimals: 6, + contract_address: Some("0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string()), + }, + WalletAssetDefinition { + chain, + symbol: "DAI".to_string(), + name: "Dai".to_string(), + native: false, + decimals: 18, + contract_address: Some("0x6B175474E89094C44Da98b954EedeAC495271d0F".to_string()), + }, + WalletAssetDefinition { + chain, + symbol: "WETH".to_string(), + name: "Wrapped Ether".to_string(), + native: false, + decimals: 18, + contract_address: Some("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2".to_string()), + }, + ], + WalletChain::Btc => vec![WalletAssetDefinition { + chain, + symbol: "BTC".to_string(), + name: "Bitcoin".to_string(), + native: true, + decimals: 8, + contract_address: None, + }], + WalletChain::Solana => vec![WalletAssetDefinition { + chain, + symbol: "SOL".to_string(), + name: "Solana".to_string(), + native: true, + decimals: 9, + contract_address: None, + }], + WalletChain::Tron => vec![WalletAssetDefinition { + chain, + symbol: "TRX".to_string(), + name: "Tron".to_string(), + native: true, + decimals: 6, + contract_address: None, + }], + } +} + +pub fn network_defaults() -> Vec { + [ + WalletChain::Evm, + WalletChain::Btc, + WalletChain::Solana, + WalletChain::Tron, + ] + .into_iter() + .map(|chain| WalletNetworkDefaults { + chain, + network: match chain { + WalletChain::Evm => "ethereum-mainnet".to_string(), + WalletChain::Btc => "bitcoin-mainnet".to_string(), + WalletChain::Solana => "solana-mainnet-beta".to_string(), + WalletChain::Tron => "tron-mainnet".to_string(), + }, + chain_id: match chain { + WalletChain::Evm => Some(1), + _ => None, + }, + rpc_url: rpc_url_for_chain(chain), + rpc_source: rpc_source_for_chain(chain), + explorer_tx_url_base: match chain { + WalletChain::Evm => ETHERSCAN_TX_BASE, + WalletChain::Btc => BLOCKSTREAM_TX_BASE, + WalletChain::Solana => SOLSCAN_TX_BASE, + WalletChain::Tron => TRONSCAN_TX_BASE, + } + .to_string(), + supports_broadcast: matches!(chain, WalletChain::Evm), + supports_token_transfers: matches!(chain, WalletChain::Evm), + supports_contract_calls: matches!(chain, WalletChain::Evm), + assets: asset_catalog(chain), + }) + .collect() +} + +pub fn find_asset(chain: WalletChain, symbol: &str) -> Option { + let needle = symbol.trim(); + asset_catalog(chain) + .into_iter() + .find(|asset| asset.symbol.eq_ignore_ascii_case(needle)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn asset_catalog_includes_default_erc20s() { + let evm = asset_catalog(WalletChain::Evm); + assert!(evm.iter().any(|asset| asset.symbol == "USDC")); + assert!(evm + .iter() + .any(|asset| asset.symbol == "ETH" && asset.native)); + } +} diff --git a/src/openhuman/wallet/execution.rs b/src/openhuman/wallet/execution.rs index 710b77e81..30a8f7d0e 100644 --- a/src/openhuman/wallet/execution.rs +++ b/src/openhuman/wallet/execution.rs @@ -1,60 +1,58 @@ -//! Wallet execution surface — read tools (balances / supported assets / chain -//! status) and write tools (prepare-then-execute) for native sends, token -//! transfers, swaps, and contract calls. +//! Wallet execution surface — read tools (balances / supported assets / +//! network defaults / chain status) and write tools (prepare-then-execute) +//! for native sends, token transfers, swaps, and contract calls. //! -//! Design rules (see issue #1396): -//! - Quote / simulate first, then explicit confirm-and-execute. No one-shot -//! hidden execution. -//! - Signing material stays local. `execute_prepared` returns a -//! `ReadyToSign` structured payload that the desktop keystore consumes — -//! this module never touches mnemonics or private keys. -//! - Wallet must be configured (see [`crate::openhuman::wallet::status`]) -//! before any read or write tool is callable. -//! - Every decision point emits a grep-friendly `[wallet]` debug log. -//! -//! On-chain RPC providers are not yet configured (#1395 ships the keystore; -//! provider config lives behind `OPENHUMAN_WALLET_RPC_*` env vars). Until a -//! provider is wired, balances surface `provider_status: "unconfigured"` -//! with zero values rather than fabricating numbers. +//! Execution is intentionally narrower than the metadata surface: +//! - Every write must be prepared first, then explicitly confirmed. +//! - Secret material stays encrypted at rest in core-owned storage. +//! - Mainnet EVM signing + broadcast are implemented here today. +//! - BTC / Solana / Tron remain read-only / quote-only until their +//! providers and signing flows are actually wired. + +use std::str::FromStr; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use ethers_core::types::transaction::eip2718::TypedTransaction; +use ethers_core::types::{Address, Bytes, NameOrAddress, TransactionRequest, U256}; +use ethers_signers::{coins_bip39::English, MnemonicBuilder, Signer}; use log::{debug, warn}; use once_cell::sync::Lazy; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; +use serde_json::json; +use crate::openhuman::config::rpc as config_rpc; use crate::rpc::RpcOutcome; -use super::ops::{status as wallet_status, WalletAccount, WalletChain}; +use super::abi::encode_erc20_transfer; +use super::defaults::{ + explorer_tx_url, find_asset, network_defaults as default_networks, rpc_url_for_chain, + WalletAssetDefinition, WalletNetworkDefaults, +}; +use super::ops::{secret_material, status as wallet_status, WalletAccount, WalletChain}; +use super::rpc::rpc_call; const LOG_PREFIX: &str = "[wallet]"; -/// Prepared-transaction TTL. Quotes older than this are rejected at execute time. const QUOTE_TTL_MS: u64 = 5 * 60 * 1000; -/// Cap on stored quotes; oldest entries are pruned when exceeded. const QUOTE_STORE_CAP: usize = 64; static QUOTE_STORE: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); static QUOTE_COUNTER: AtomicU64 = AtomicU64::new(1); -// -- Public types ----------------------------------------------------------- - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ChainStatus { pub chain: WalletChain, pub configured: bool, pub provider_status: ProviderStatus, + pub rpc_url: String, } #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ProviderStatus { - /// Wallet account exists for this chain and an RPC provider is reachable. Ready, - /// Wallet account exists but no RPC provider has been configured yet. - Unconfigured, - /// Chain has no derived wallet account yet — run wallet setup first. Missing, } @@ -62,10 +60,12 @@ pub enum ProviderStatus { #[serde(rename_all = "camelCase")] pub struct SupportedAsset { pub chain: WalletChain, - pub symbol: &'static str, - pub name: &'static str, + pub symbol: String, + pub name: String, pub native: bool, pub decimals: u8, + #[serde(skip_serializing_if = "Option::is_none")] + pub contract_address: Option, } #[derive(Debug, Clone, Serialize)] @@ -73,7 +73,7 @@ pub struct SupportedAsset { pub struct BalanceInfo { pub chain: WalletChain, pub address: String, - pub asset_symbol: &'static str, + pub asset_symbol: String, pub decimals: u8, pub raw: String, pub formatted: String, @@ -92,11 +92,8 @@ pub enum PreparedKind { #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum PreparedStatus { - /// Quote has been simulated and is awaiting explicit user confirmation. AwaitingConfirmation, - /// `execute_prepared` was invoked — payload is ready for the keystore. - ReadyToSign, - /// Quote expired or was already consumed. + Broadcasted, Consumed, } @@ -107,52 +104,43 @@ pub struct PreparedTransaction { pub kind: PreparedKind, pub chain: WalletChain, pub from_address: String, - /// For transfers: recipient. For swaps: pool / router contract. For - /// contract calls: target contract. pub to_address: String, pub asset_symbol: String, pub amount_raw: String, pub amount_formatted: String, - /// For swaps only — the symbol the user expects to receive. #[serde(skip_serializing_if = "Option::is_none")] pub receive_symbol: Option, - /// For swaps only — minimum amount out (raw integer string). #[serde(skip_serializing_if = "Option::is_none")] pub min_receive_raw: Option, - /// For contract calls only — encoded calldata (hex, 0x-prefixed). #[serde(skip_serializing_if = "Option::is_none")] pub calldata: Option, - /// Estimated network fee in the chain's native units (raw integer string). + #[serde(skip_serializing_if = "Option::is_none")] + pub token_address: Option, pub estimated_fee_raw: String, pub status: PreparedStatus, pub created_at_ms: u64, pub expires_at_ms: u64, - /// Human-readable reasons surfaced from simulation, for the confirmation - /// dialog (e.g. `slippage 0.5%`, `fee bump`). pub notes: Vec, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct ReadyToSign { +pub struct ExecutionResult { pub quote_id: String, pub status: PreparedStatus, pub chain: WalletChain, - /// Full prepared transaction the keystore should sign. + pub transaction_hash: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub explorer_url: Option, pub transaction: PreparedTransaction, } -// -- Param types ------------------------------------------------------------ - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PrepareTransferParams { pub chain: WalletChain, pub to_address: String, - /// Raw integer amount in the asset's smallest unit (wei / sat / lamports). pub amount_raw: String, - /// `null` / absent => native asset for the chain. Otherwise a token symbol - /// returned by `wallet.supported_assets`. #[serde(default)] pub asset_symbol: Option, } @@ -164,9 +152,7 @@ pub struct PrepareSwapParams { pub from_symbol: String, pub to_symbol: String, pub amount_in_raw: String, - /// Slippage tolerance in basis points (e.g. `50` = 0.5%). pub slippage_bps: u32, - /// Router / aggregator contract address. Caller selects the venue. pub router_address: String, } @@ -175,28 +161,21 @@ pub struct PrepareSwapParams { pub struct PrepareContractCallParams { pub chain: WalletChain, pub contract_address: String, - /// Hex-encoded calldata (`0x`-prefixed). pub calldata: String, - /// Native value to attach (raw, smallest unit). `"0"` for view / pure - /// state mutations on EVM. #[serde(default = "zero_string")] pub value_raw: String, } -fn zero_string() -> String { - "0".to_string() -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExecutePreparedParams { pub quote_id: String, - /// Caller MUST set this to `true`. If absent / false, the call is - /// rejected — this is the safety boundary between simulate and execute. pub confirmed: bool, } -// -- Helpers ---------------------------------------------------------------- +fn zero_string() -> String { + "0".to_string() +} fn now_ms() -> u64 { SystemTime::now() @@ -218,7 +197,7 @@ async fn require_account(chain: WalletChain) -> Result { status .accounts .into_iter() - .find(|a| a.chain == chain) + .find(|account| account.chain == chain) .ok_or_else(|| format!("no wallet account derived for chain '{}'", chain_str(chain))) } @@ -231,51 +210,6 @@ fn chain_str(chain: WalletChain) -> &'static str { } } -fn native_asset(chain: WalletChain) -> SupportedAsset { - match chain { - WalletChain::Evm => SupportedAsset { - chain, - symbol: "ETH", - name: "Ether", - native: true, - decimals: 18, - }, - WalletChain::Btc => SupportedAsset { - chain, - symbol: "BTC", - name: "Bitcoin", - native: true, - decimals: 8, - }, - WalletChain::Solana => SupportedAsset { - chain, - symbol: "SOL", - name: "Solana", - native: true, - decimals: 9, - }, - WalletChain::Tron => SupportedAsset { - chain, - symbol: "TRX", - name: "Tron", - native: true, - decimals: 6, - }, - } -} - -fn provider_env_set(chain: WalletChain) -> bool { - let key = match chain { - WalletChain::Evm => "OPENHUMAN_WALLET_RPC_EVM", - WalletChain::Btc => "OPENHUMAN_WALLET_RPC_BTC", - WalletChain::Solana => "OPENHUMAN_WALLET_RPC_SOLANA", - WalletChain::Tron => "OPENHUMAN_WALLET_RPC_TRON", - }; - std::env::var(key) - .map(|v| !v.trim().is_empty()) - .unwrap_or(false) -} - fn validate_amount(raw: &str) -> Result { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -286,27 +220,30 @@ fn validate_amount(raw: &str) -> Result { .map_err(|_| format!("amount '{trimmed}' is not a valid non-negative integer")) } -fn validate_address(addr: &str) -> Result { +fn validate_address(chain: WalletChain, addr: &str) -> Result { let trimmed = addr.trim(); if trimmed.is_empty() { return Err("address is empty".to_string()); } + if matches!(chain, WalletChain::Evm) { + Address::from_str(trimmed).map_err(|e| format!("invalid EVM address '{trimmed}': {e}"))?; + } Ok(trimmed.to_string()) } fn validate_calldata(data: &str) -> Result { - let t = data.trim(); - if !t.starts_with("0x") { + let trimmed = data.trim(); + if !trimmed.starts_with("0x") { return Err("calldata must be 0x-prefixed hex".to_string()); } - let body = &t[2..]; + let body = &trimmed[2..]; if body.len() % 2 != 0 { return Err("calldata hex must be byte-aligned".to_string()); } if !body.chars().all(|c| c.is_ascii_hexdigit()) { return Err("calldata contains non-hex characters".to_string()); } - Ok(t.to_string()) + Ok(trimmed.to_string()) } fn format_amount(raw: u128, decimals: u8) -> String { @@ -324,8 +261,6 @@ fn format_amount(raw: u128, decimals: u8) -> String { } fn estimated_fee_raw(chain: WalletChain, kind: PreparedKind) -> String { - // Pessimistic stub estimates so simulation has a non-zero number to show. - // Real values come from the chain's fee oracle once a provider is wired. let base = match (chain, kind) { (WalletChain::Evm, PreparedKind::NativeTransfer) => 21_000u128 * 30_000_000_000, (WalletChain::Evm, PreparedKind::TokenTransfer) => 65_000u128 * 30_000_000_000, @@ -338,6 +273,17 @@ fn estimated_fee_raw(chain: WalletChain, kind: PreparedKind) -> String { base.to_string() } +fn asset_to_supported(asset: WalletAssetDefinition) -> SupportedAsset { + SupportedAsset { + chain: asset.chain, + symbol: asset.symbol, + name: asset.name, + native: asset.native, + decimals: asset.decimals, + contract_address: asset.contract_address, + } +} + fn store_quote(quote: PreparedTransaction) -> PreparedTransaction { let mut store = QUOTE_STORE.lock(); let cutoff = now_ms(); @@ -349,6 +295,23 @@ fn store_quote(quote: PreparedTransaction) -> PreparedTransaction { quote } +fn get_quote(quote_id: &str) -> Result { + let store = QUOTE_STORE.lock(); + let now = now_ms(); + let quote = store + .iter() + .find(|q| q.quote_id == quote_id) + .cloned() + .ok_or_else(|| format!("quote '{quote_id}' not found"))?; + if quote.status == PreparedStatus::Consumed { + return Err(format!("quote '{quote_id}' already executed")); + } + if quote.expires_at_ms <= now { + return Err(format!("quote '{quote_id}' expired")); + } + Ok(quote) +} + fn take_quote(quote_id: &str) -> Result { let mut store = QUOTE_STORE.lock(); let now = now_ms(); @@ -366,23 +329,229 @@ fn take_quote(quote_id: &str) -> Result { Ok(quote) } +pub fn prepared_quotes_for_test() -> Vec { + let now = now_ms(); + QUOTE_STORE + .lock() + .iter() + .filter(|q| q.expires_at_ms > now && q.status != PreparedStatus::Consumed) + .cloned() + .collect() +} + #[cfg(test)] fn reset_quote_store_for_tests() { QUOTE_STORE.lock().clear(); } -// -- Operations ------------------------------------------------------------- +fn hex_to_u256(hex_value: &str) -> Result { + let trimmed = hex_value.trim(); + let normalized = trimmed.strip_prefix("0x").unwrap_or(trimmed); + U256::from_str_radix(normalized, 16) + .map_err(|e| format!("invalid hex quantity '{hex_value}': {e}")) +} + +fn u256_to_hex(value: U256) -> String { + format!("0x{value:x}") +} + +fn hex_to_bytes(value: &str) -> Result, String> { + let trimmed = value.trim(); + let normalized = trimmed.strip_prefix("0x").unwrap_or(trimmed); + hex::decode(normalized).map_err(|e| format!("invalid hex bytes '{value}': {e}")) +} + +async fn evm_balance(address: &str) -> Result { + let raw: String = rpc_call( + WalletChain::Evm, + "eth_getBalance", + json!([address, "latest"]), + ) + .await?; + hex_to_u256(&raw) +} + +async fn evm_tx_context( + from_address: &str, + to_address: &str, + value: U256, + data: Option, +) -> Result<(u64, U256, U256), String> { + let chain_id_hex: String = rpc_call(WalletChain::Evm, "eth_chainId", json!([])).await?; + let nonce_hex: String = rpc_call( + WalletChain::Evm, + "eth_getTransactionCount", + json!([from_address, "latest"]), + ) + .await?; + let gas_price_hex: String = rpc_call(WalletChain::Evm, "eth_gasPrice", json!([])).await?; + let mut tx = json!({ + "from": from_address, + "to": to_address, + "value": u256_to_hex(value), + }); + if let Some(data_hex) = data.as_deref() { + tx["data"] = json!(data_hex); + } + let gas_hex: String = rpc_call(WalletChain::Evm, "eth_estimateGas", json!([tx])).await?; + Ok(( + hex_to_u256(&chain_id_hex)?.as_u64(), + hex_to_u256(&nonce_hex)?, + hex_to_u256(&gas_price_hex)? * hex_to_u256(&gas_hex)?, + )) +} + +async fn execute_evm_quote(mut quote: PreparedTransaction) -> Result { + let secret = secret_material(WalletChain::Evm).await?; + let config = config_rpc::load_config_with_timeout().await?; + let mnemonic = + crate::openhuman::encryption::rpc::decrypt_secret(&config, &secret.encrypted_mnemonic) + .await? + .value; + let signer = MnemonicBuilder::::default() + .phrase(mnemonic.as_str()) + .derivation_path(&secret.derivation_path) + .map_err(|e| { + format!( + "invalid EVM derivation path '{}': {e}", + secret.derivation_path + ) + })? + .build() + .map_err(|e| format!("failed to derive EVM signer from wallet secret: {e}"))?; + let from = Address::from_str("e.from_address).map_err(|e| { + format!( + "invalid stored EVM sender address '{}': {e}", + quote.from_address + ) + })?; + let (tx_to, tx_value, tx_data) = match quote.kind { + PreparedKind::NativeTransfer => ( + Address::from_str("e.to_address).map_err(|e| { + format!("invalid EVM recipient address '{}': {e}", quote.to_address) + })?, + U256::from_dec_str("e.amount_raw).map_err(|e| { + format!("invalid prepared native value '{}': {e}", quote.amount_raw) + })?, + None, + ), + PreparedKind::TokenTransfer => { + let token = quote + .token_address + .as_deref() + .ok_or_else(|| "prepared token transfer is missing token_address".to_string())?; + let calldata = encode_erc20_transfer("e.to_address, "e.amount_raw)?; + ( + Address::from_str(token) + .map_err(|e| format!("invalid ERC20 token contract address '{token}': {e}"))?, + U256::zero(), + Some(calldata), + ) + } + PreparedKind::ContractCall => ( + Address::from_str("e.to_address) + .map_err(|e| format!("invalid contract target '{}': {e}", quote.to_address))?, + U256::from_dec_str("e.amount_raw).map_err(|e| { + format!( + "invalid prepared contract value '{}': {e}", + quote.amount_raw + ) + })?, + quote.calldata.clone(), + ), + PreparedKind::Swap => { + return Err( + "swap broadcast is not implemented yet; keep it in quote-only mode".to_string(), + ); + } + }; + + let chain_id_hex: String = rpc_call(WalletChain::Evm, "eth_chainId", json!([])).await?; + let nonce_hex: String = rpc_call( + WalletChain::Evm, + "eth_getTransactionCount", + json!([quote.from_address, "latest"]), + ) + .await?; + let gas_price_hex: String = rpc_call(WalletChain::Evm, "eth_gasPrice", json!([])).await?; + let mut estimate_tx = json!({ + "from": quote.from_address, + "to": format!("{tx_to:#x}"), + "value": u256_to_hex(tx_value), + }); + if let Some(data_hex) = tx_data.as_deref() { + estimate_tx["data"] = json!(data_hex); + } + let gas_hex: String = + rpc_call(WalletChain::Evm, "eth_estimateGas", json!([estimate_tx])).await?; + let chain_id = hex_to_u256(&chain_id_hex)?.as_u64(); + let nonce = hex_to_u256(&nonce_hex)?; + let gas_price = hex_to_u256(&gas_price_hex)?; + let gas = hex_to_u256(&gas_hex)?; + + let tx_data_bytes = tx_data + .clone() + .map(|value| hex_to_bytes(&value).map(Bytes::from)) + .transpose()?; + let mut request = TransactionRequest::new() + .from(from) + .to(NameOrAddress::Address(tx_to)) + .value(tx_value) + .nonce(nonce) + .gas(gas) + .gas_price(gas_price) + .chain_id(chain_id); + if let Some(data) = tx_data_bytes { + request = request.data(data); + } + let tx: TypedTransaction = request.into(); + let signature = signer + .with_chain_id(chain_id) + .sign_transaction(&tx) + .await + .map_err(|e| format!("failed to sign EVM transaction: {e}"))?; + let raw_bytes = tx.rlp_signed(&signature); + let raw_tx = format!("0x{}", hex::encode(raw_bytes)); + let tx_hash: String = + rpc_call(WalletChain::Evm, "eth_sendRawTransaction", json!([raw_tx])).await?; + quote.estimated_fee_raw = gas_price.checked_mul(gas).unwrap_or_default().to_string(); + quote.status = PreparedStatus::Broadcasted; + debug!( + "{LOG_PREFIX} execute_prepared quote_id={} chain=evm tx_hash={} rpc={}", + quote.quote_id, + tx_hash, + rpc_url_for_chain(WalletChain::Evm) + ); + Ok(ExecutionResult { + quote_id: quote.quote_id.clone(), + status: PreparedStatus::Broadcasted, + chain: WalletChain::Evm, + transaction_hash: tx_hash.clone(), + explorer_url: explorer_tx_url(WalletChain::Evm, &tx_hash), + transaction: quote, + }) +} + +pub async fn network_defaults() -> Result>, String> { + let rows = default_networks(); + debug!("{LOG_PREFIX} network_defaults count={}", rows.len()); + Ok(RpcOutcome::new( + rows, + vec!["wallet network defaults listed".to_string()], + )) +} pub async fn supported_assets() -> Result>, String> { - let assets: Vec = [ + let assets = [ WalletChain::Evm, WalletChain::Btc, WalletChain::Solana, WalletChain::Tron, ] .into_iter() - .map(native_asset) - .collect(); + .flat_map(super::defaults::asset_catalog) + .map(asset_to_supported) + .collect::>(); debug!("{LOG_PREFIX} supported_assets count={}", assets.len()); Ok(RpcOutcome::new( assets, @@ -392,27 +561,27 @@ pub async fn supported_assets() -> Result>, Strin pub async fn chain_status() -> Result>, String> { let status = wallet_status().await?.value; - let mut rows = Vec::with_capacity(4); - for chain in [ + let rows = [ WalletChain::Evm, WalletChain::Btc, WalletChain::Solana, WalletChain::Tron, - ] { - let has_account = status.accounts.iter().any(|a| a.chain == chain); - let provider_status = if !has_account { - ProviderStatus::Missing - } else if provider_env_set(chain) { - ProviderStatus::Ready - } else { - ProviderStatus::Unconfigured - }; - rows.push(ChainStatus { + ] + .into_iter() + .map(|chain| { + let has_account = status.accounts.iter().any(|account| account.chain == chain); + ChainStatus { chain, configured: has_account, - provider_status, - }); - } + provider_status: if has_account { + ProviderStatus::Ready + } else { + ProviderStatus::Missing + }, + rpc_url: rpc_url_for_chain(chain), + } + }) + .collect::>(); debug!("{LOG_PREFIX} chain_status reported chains={}", rows.len()); Ok(RpcOutcome::new( rows, @@ -427,25 +596,41 @@ pub async fn balances() -> Result>, String> { } let mut out = Vec::with_capacity(status.accounts.len()); for account in &status.accounts { - let asset = native_asset(account.chain); - let provider_status = if provider_env_set(account.chain) { - ProviderStatus::Ready + let asset = super::defaults::asset_catalog(account.chain) + .into_iter() + .find(|value| value.native) + .ok_or_else(|| { + format!( + "native asset metadata missing for '{}'", + chain_str(account.chain) + ) + })?; + let (raw, provider_status) = if account.chain == WalletChain::Evm { + match evm_balance(&account.address).await { + Ok(balance) => (balance.to_string(), ProviderStatus::Ready), + Err(error) => { + warn!( + "{LOG_PREFIX} balances chain=evm address={} falling back to zero placeholder: {}", + account.address, error + ); + ("0".to_string(), ProviderStatus::Missing) + } + } } else { - ProviderStatus::Unconfigured - }; - if provider_status == ProviderStatus::Unconfigured { warn!( - "{LOG_PREFIX} balances chain={} provider unconfigured; returning zero placeholder", + "{LOG_PREFIX} balances chain={} uses placeholder until native provider support lands", chain_str(account.chain) ); - } + ("0".to_string(), ProviderStatus::Ready) + }; + let raw_u128 = raw.parse::().unwrap_or(0); out.push(BalanceInfo { chain: account.chain, address: account.address.clone(), asset_symbol: asset.symbol, decimals: asset.decimals, - raw: "0".to_string(), - formatted: format_amount(0, asset.decimals), + raw, + formatted: format_amount(raw_u128, asset.decimals), provider_status, }); } @@ -459,31 +644,40 @@ pub async fn balances() -> Result>, String> { pub async fn prepare_transfer( params: PrepareTransferParams, ) -> Result, String> { - let to = validate_address(¶ms.to_address)?; + let to = validate_address(params.chain, ¶ms.to_address)?; let amount = validate_amount(¶ms.amount_raw)?; if amount == 0 { return Err("transfer amount must be greater than zero".to_string()); } - let native = native_asset(params.chain); - let (kind, asset_symbol, decimals) = match params.asset_symbol.as_deref().map(str::trim) { - None | Some("") => ( - PreparedKind::NativeTransfer, - native.symbol.to_string(), - native.decimals, - ), - Some(sym) if sym.eq_ignore_ascii_case(native.symbol) => ( - PreparedKind::NativeTransfer, - native.symbol.to_string(), - native.decimals, - ), - Some(sym) => { - return Err(format!( - "unsupported asset_symbol '{sym}'; only native assets are listed in wallet.supported_assets today" - )); - } - }; let account = require_account(params.chain).await?; - + let asset = match params.asset_symbol.as_deref().map(str::trim) { + None | Some("") => super::defaults::asset_catalog(params.chain) + .into_iter() + .find(|value| value.native) + .ok_or_else(|| { + format!( + "native asset metadata missing for '{}'", + chain_str(params.chain) + ) + })?, + Some(symbol) => find_asset(params.chain, symbol).ok_or_else(|| { + format!( + "unsupported asset_symbol '{symbol}' for chain '{}'", + chain_str(params.chain) + ) + })?, + }; + if !asset.native && params.chain != WalletChain::Evm { + return Err(format!( + "token transfers are currently implemented only for EVM; got chain '{}'", + chain_str(params.chain) + )); + } + let kind = if asset.native { + PreparedKind::NativeTransfer + } else { + PreparedKind::TokenTransfer + }; let now = now_ms(); let quote = PreparedTransaction { quote_id: next_quote_id(), @@ -491,26 +685,30 @@ pub async fn prepare_transfer( chain: params.chain, from_address: account.address.clone(), to_address: to, - asset_symbol: asset_symbol.clone(), + asset_symbol: asset.symbol.clone(), amount_raw: amount.to_string(), - amount_formatted: format_amount(amount, decimals), + amount_formatted: format_amount(amount, asset.decimals), receive_symbol: None, min_receive_raw: None, calldata: None, + token_address: asset.contract_address.clone(), estimated_fee_raw: estimated_fee_raw(params.chain, kind), status: PreparedStatus::AwaitingConfirmation, created_at_ms: now, expires_at_ms: now + QUOTE_TTL_MS, notes: vec![format!( - "Simulation only — confirm to forward to keystore. Asset: {asset_symbol}." + "Prepared {} transfer on {} using default network settings.", + asset.symbol, + chain_str(params.chain) )], }; debug!( - "{LOG_PREFIX} prepare_transfer chain={} kind={:?} quote_id={} amount={}", + "{LOG_PREFIX} prepare_transfer chain={} kind={:?} quote_id={} amount={} asset={}", chain_str(params.chain), kind, quote.quote_id, - quote.amount_raw + quote.amount_raw, + quote.asset_symbol ); Ok(RpcOutcome::new( store_quote(quote), @@ -534,15 +732,14 @@ pub async fn prepare_swap( if amount == 0 { return Err("swap amount_in_raw must be greater than zero".to_string()); } - let router = validate_address(¶ms.router_address)?; + let router = validate_address(params.chain, ¶ms.router_address)?; let account = require_account(params.chain).await?; - - // Conservative min-out: amount * (10000 - slippage) / 10000. Without a - // real quote we cannot compute the swap rate; this lets the UI display a - // floor and forces explicit caller-side rate input via the router quote - // pre-step once the provider lands. + let native_decimals = super::defaults::asset_catalog(params.chain) + .into_iter() + .find(|value| value.native) + .map(|value| value.decimals) + .unwrap_or(18); let min_out = amount.saturating_mul((10_000 - params.slippage_bps) as u128) / 10_000; - let native = native_asset(params.chain); let now = now_ms(); let quote = PreparedTransaction { quote_id: next_quote_id(), @@ -552,10 +749,11 @@ pub async fn prepare_swap( to_address: router, asset_symbol: params.from_symbol.clone(), amount_raw: amount.to_string(), - amount_formatted: format_amount(amount, native.decimals), + amount_formatted: format_amount(amount, native_decimals), receive_symbol: Some(params.to_symbol.clone()), min_receive_raw: Some(min_out.to_string()), calldata: None, + token_address: None, estimated_fee_raw: estimated_fee_raw(params.chain, PreparedKind::Swap), status: PreparedStatus::AwaitingConfirmation, created_at_ms: now, @@ -582,18 +780,20 @@ pub async fn prepare_swap( pub async fn prepare_contract_call( params: PrepareContractCallParams, ) -> Result, String> { - if !matches!(params.chain, WalletChain::Evm | WalletChain::Tron) { + if params.chain != WalletChain::Evm { return Err(format!( - "contract calls are only supported on EVM and Tron chains; got '{}'", + "contract calls are currently implemented only for EVM; got '{}'", chain_str(params.chain) )); } - let contract = validate_address(¶ms.contract_address)?; + let contract = validate_address(params.chain, ¶ms.contract_address)?; let calldata = validate_calldata(¶ms.calldata)?; let value = validate_amount(¶ms.value_raw)?; let account = require_account(params.chain).await?; - - let native = native_asset(params.chain); + let native = super::defaults::asset_catalog(params.chain) + .into_iter() + .find(|value| value.native) + .ok_or_else(|| "missing native asset metadata for evm".to_string())?; let now = now_ms(); let quote = PreparedTransaction { quote_id: next_quote_id(), @@ -601,17 +801,18 @@ pub async fn prepare_contract_call( chain: params.chain, from_address: account.address.clone(), to_address: contract, - asset_symbol: native.symbol.to_string(), + asset_symbol: native.symbol, amount_raw: value.to_string(), amount_formatted: format_amount(value, native.decimals), receive_symbol: None, min_receive_raw: None, calldata: Some(calldata), + token_address: None, estimated_fee_raw: estimated_fee_raw(params.chain, PreparedKind::ContractCall), status: PreparedStatus::AwaitingConfirmation, created_at_ms: now, expires_at_ms: now + QUOTE_TTL_MS, - notes: vec!["Contract call simulation — verify ABI before signing.".to_string()], + notes: vec!["Contract call prepared from caller-supplied ABI/calldata.".to_string()], }; debug!( "{LOG_PREFIX} prepare_contract_call chain={} quote_id={} value={}", @@ -627,35 +828,154 @@ pub async fn prepare_contract_call( pub async fn execute_prepared( params: ExecutePreparedParams, -) -> Result, String> { +) -> Result, String> { if !params.confirmed { return Err("execute_prepared requires `confirmed: true`".to_string()); } - let mut quote = take_quote(¶ms.quote_id)?; - quote.status = PreparedStatus::ReadyToSign; - debug!( - "{LOG_PREFIX} execute_prepared quote_id={} chain={} kind={:?} -> ReadyToSign", - quote.quote_id, - chain_str(quote.chain), - quote.kind - ); - let result = ReadyToSign { - quote_id: quote.quote_id.clone(), - status: quote.status, - chain: quote.chain, - transaction: quote, + let quote = get_quote(¶ms.quote_id)?; + let result = match quote.chain { + WalletChain::Evm => execute_evm_quote(quote).await?, + other => { + return Err(format!( + "on-chain execution is not implemented yet for chain '{}'; prepare-only mode remains active", + chain_str(other) + )); + } }; + let _ = take_quote(¶ms.quote_id)?; Ok(RpcOutcome::new( result, - vec!["wallet quote handed to keystore".to_string()], + vec!["wallet transaction broadcast".to_string()], )) } -// -- Tests ------------------------------------------------------------------ - #[cfg(test)] mod tests { + use std::net::SocketAddr; + use std::sync::Arc; + + use axum::{extract::State, routing::post, Json, Router}; + use once_cell::sync::Lazy; + use serde_json::Value; + use tempfile::TempDir; + use tokio::net::TcpListener; + use super::*; + use crate::openhuman::wallet::ops::{setup, WalletSetupParams, WalletSetupSource}; + + static TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); + + #[derive(Clone)] + struct MockRpcState { + estimate_calls: Arc>>, + raw_txs: Arc>>, + } + + fn sample_account(chain: WalletChain) -> super::WalletAccount { + super::WalletAccount { + chain, + address: match chain { + WalletChain::Evm => "0x9858EfFD232B4033E47d90003D41EC34EcaEda94".to_string(), + WalletChain::Btc => "btc".to_string(), + WalletChain::Solana => "sol".to_string(), + WalletChain::Tron => "tron".to_string(), + }, + derivation_path: match chain { + WalletChain::Evm => "m/44'/60'/0'/0/0".to_string(), + WalletChain::Btc => "m/44'/0'/0'/0/0".to_string(), + WalletChain::Solana => "m/44'/501'/0'/0'".to_string(), + WalletChain::Tron => "m/44'/195'/0'/0/0".to_string(), + }, + } + } + + async fn mock_rpc( + State(state): State, + Json(payload): Json, + ) -> Json { + let method = payload + .get("method") + .and_then(Value::as_str) + .unwrap_or_default(); + let params = payload + .get("params") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let result = match method { + "eth_chainId" => Value::String("0x1".to_string()), + "eth_getTransactionCount" => Value::String("0x7".to_string()), + "eth_gasPrice" => Value::String("0x3b9aca00".to_string()), + "eth_estimateGas" => { + state + .estimate_calls + .lock() + .push(params.first().cloned().unwrap_or(Value::Null)); + Value::String("0x5208".to_string()) + } + "eth_sendRawTransaction" => { + if let Some(raw) = params.first().and_then(Value::as_str) { + state.raw_txs.lock().push(raw.to_string()); + } + Value::String( + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + ) + } + "eth_getBalance" => Value::String("0xde0b6b3a7640000".to_string()), + _ => Value::Null, + }; + Json(json!({"jsonrpc":"2.0","id":1,"result":result})) + } + + async fn start_mock_rpc( + ) -> Result<(SocketAddr, Arc>>, Arc>>), String> { + let estimate_calls = Arc::new(Mutex::new(Vec::new())); + let raw_txs = Arc::new(Mutex::new(Vec::new())); + let state = MockRpcState { + estimate_calls: estimate_calls.clone(), + raw_txs: raw_txs.clone(), + }; + let app = Router::new().route("/", post(mock_rpc)).with_state(state); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .map_err(|e| format!("failed to bind mock rpc: {e}"))?; + let addr = listener + .local_addr() + .map_err(|e| format!("failed to read mock rpc addr: {e}"))?; + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + Ok((addr, estimate_calls, raw_txs)) + } + + async fn setup_wallet(temp: &TempDir) -> Result<(), String> { + std::env::set_var("OPENHUMAN_WORKSPACE", temp.path()); + let config = config_rpc::load_config_with_timeout().await?; + let encrypted = crate::openhuman::encryption::rpc::encrypt_secret( + &config, + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + ) + .await? + .value; + setup(WalletSetupParams { + consent_granted: true, + source: WalletSetupSource::Imported, + mnemonic_word_count: 12, + encrypted_mnemonic: Some(encrypted), + accounts: [ + WalletChain::Evm, + WalletChain::Btc, + WalletChain::Solana, + WalletChain::Tron, + ] + .into_iter() + .map(sample_account) + .collect(), + }) + .await?; + Ok(()) + } #[test] fn validates_amount_rejects_empty_and_non_numeric() { @@ -704,6 +1024,7 @@ mod tests { receive_symbol: None, min_receive_raw: None, calldata: None, + token_address: None, estimated_fee_raw: "0".to_string(), status: PreparedStatus::AwaitingConfirmation, created_at_ms: now, @@ -715,7 +1036,6 @@ mod tests { assert_eq!(taken.quote_id, "q_test_1"); assert!(take_quote("q_test_1").is_err(), "second take must fail"); - // Expired quote: store and then try to take. q.quote_id = "q_test_2".to_string(); q.expires_at_ms = now.saturating_sub(1); store_quote(q); @@ -723,77 +1043,170 @@ mod tests { assert!(err.contains("expired"), "got: {err}"); } - #[test] - fn execute_prepared_requires_confirmed_flag() { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async { - let err = execute_prepared(ExecutePreparedParams { - quote_id: "missing".to_string(), - confirmed: false, - }) - .await - .unwrap_err(); - assert!(err.contains("confirmed: true"), "got: {err}"); - }); + #[tokio::test] + async fn execute_prepared_requires_confirmed_flag() { + let err = execute_prepared(ExecutePreparedParams { + quote_id: "missing".to_string(), + confirmed: false, + }) + .await + .unwrap_err(); + assert!(err.contains("confirmed: true"), "got: {err}"); } - #[test] - fn supported_assets_lists_four_natives() { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async { - let out = supported_assets().await.unwrap(); - assert_eq!(out.value.len(), 4); - assert!(out.value.iter().all(|a| a.native)); - }); + #[tokio::test] + async fn execute_prepared_keeps_quote_when_chain_is_not_supported_for_broadcast() { + let _guard = TEST_LOCK.lock(); + reset_quote_store_for_tests(); + let now = now_ms(); + let quote = PreparedTransaction { + quote_id: "q_retry".to_string(), + kind: PreparedKind::NativeTransfer, + chain: WalletChain::Btc, + from_address: "btc-from".to_string(), + to_address: "btc-to".to_string(), + asset_symbol: "BTC".to_string(), + amount_raw: "1".to_string(), + amount_formatted: "0.00000001".to_string(), + receive_symbol: None, + min_receive_raw: None, + calldata: None, + token_address: None, + estimated_fee_raw: "5000".to_string(), + status: PreparedStatus::AwaitingConfirmation, + created_at_ms: now, + expires_at_ms: now + 60_000, + notes: vec![], + }; + store_quote(quote); + + let err = execute_prepared(ExecutePreparedParams { + quote_id: "q_retry".to_string(), + confirmed: true, + }) + .await + .unwrap_err(); + + assert!(err.contains("not implemented yet"), "got: {err}"); + assert!( + get_quote("q_retry").is_ok(), + "quote should remain retryable" + ); } - #[test] - fn prepare_swap_rejects_same_symbol() { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async { - let err = prepare_swap(PrepareSwapParams { - chain: WalletChain::Evm, - from_symbol: "USDC".into(), - to_symbol: "usdc".into(), - amount_in_raw: "100".into(), - slippage_bps: 50, - router_address: "0xrouter".into(), - }) - .await - .unwrap_err(); - assert!(err.contains("must differ"), "got: {err}"); - }); + #[tokio::test] + async fn supported_assets_lists_default_erc20s() { + let out = supported_assets().await.unwrap(); + assert!(out.value.iter().any(|asset| asset.symbol == "USDC")); + assert!(out + .value + .iter() + .any(|asset| asset.symbol == "ETH" && asset.native)); } - #[test] - fn prepare_transfer_rejects_unsupported_asset_symbol() { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async { - let err = prepare_transfer(PrepareTransferParams { - chain: WalletChain::Evm, - to_address: "0xabc".into(), - amount_raw: "1".into(), - asset_symbol: Some("USDC".into()), - }) - .await - .unwrap_err(); - assert!(err.contains("unsupported asset_symbol"), "got: {err}"); - }); + #[tokio::test] + async fn prepare_transfer_rejects_unknown_asset_symbol() { + let _guard = TEST_LOCK.lock(); + reset_quote_store_for_tests(); + let temp = TempDir::new().unwrap(); + setup_wallet(&temp).await.unwrap(); + let err = prepare_transfer(PrepareTransferParams { + chain: WalletChain::Evm, + to_address: "0x1111111111111111111111111111111111111111".into(), + amount_raw: "1".into(), + asset_symbol: Some("NOPE".into()), + }) + .await + .unwrap_err(); + assert!(err.contains("unsupported asset_symbol"), "got: {err}"); } - #[test] - fn prepare_contract_call_rejects_non_evm_chain() { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async { - let err = prepare_contract_call(PrepareContractCallParams { - chain: WalletChain::Btc, - contract_address: "addr".into(), - calldata: "0x".into(), - value_raw: "0".into(), - }) - .await - .unwrap_err(); - assert!(err.contains("only supported"), "got: {err}"); - }); + #[tokio::test] + async fn prepare_contract_call_rejects_non_evm_chain() { + let err = prepare_contract_call(PrepareContractCallParams { + chain: WalletChain::Btc, + contract_address: "addr".into(), + calldata: "0x".into(), + value_raw: "0".into(), + }) + .await + .unwrap_err(); + assert!(err.contains("only for EVM"), "got: {err}"); + } + + #[tokio::test] + async fn execute_prepared_broadcasts_native_evm_transaction() { + let _guard = TEST_LOCK.lock(); + reset_quote_store_for_tests(); + let temp = TempDir::new().unwrap(); + setup_wallet(&temp).await.unwrap(); + let (addr, estimate_calls, raw_txs) = start_mock_rpc().await.unwrap(); + std::env::set_var("OPENHUMAN_WALLET_RPC_EVM", format!("http://{addr}")); + + let prepared = prepare_transfer(PrepareTransferParams { + chain: WalletChain::Evm, + to_address: "0x1111111111111111111111111111111111111111".into(), + amount_raw: "1000".into(), + asset_symbol: None, + }) + .await + .unwrap() + .value; + let executed = execute_prepared(ExecutePreparedParams { + quote_id: prepared.quote_id.clone(), + confirmed: true, + }) + .await + .unwrap() + .value; + + assert_eq!(executed.status, PreparedStatus::Broadcasted); + assert!(executed.transaction_hash.starts_with("0xaaaa")); + assert_eq!(raw_txs.lock().len(), 1); + let estimate = estimate_calls.lock()[0].clone(); + assert_eq!( + estimate.get("to").and_then(Value::as_str), + Some("0x1111111111111111111111111111111111111111") + ); + } + + #[tokio::test] + async fn execute_prepared_broadcasts_erc20_transfer_using_default_token_catalog() { + let _guard = TEST_LOCK.lock(); + reset_quote_store_for_tests(); + let temp = TempDir::new().unwrap(); + setup_wallet(&temp).await.unwrap(); + let (addr, estimate_calls, raw_txs) = start_mock_rpc().await.unwrap(); + std::env::set_var("OPENHUMAN_WALLET_RPC_EVM", format!("http://{addr}")); + + let prepared = prepare_transfer(PrepareTransferParams { + chain: WalletChain::Evm, + to_address: "0x1111111111111111111111111111111111111111".into(), + amount_raw: "5000000".into(), + asset_symbol: Some("USDC".into()), + }) + .await + .unwrap() + .value; + let executed = execute_prepared(ExecutePreparedParams { + quote_id: prepared.quote_id.clone(), + confirmed: true, + }) + .await + .unwrap() + .value; + + assert_eq!(executed.status, PreparedStatus::Broadcasted); + assert_eq!(raw_txs.lock().len(), 1); + let estimate = estimate_calls.lock()[0].clone(); + assert_eq!( + estimate.get("to").and_then(Value::as_str), + Some("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") + ); + let data = estimate + .get("data") + .and_then(Value::as_str) + .expect("token transfer calldata"); + assert!(data.starts_with("0xa9059cbb")); } } diff --git a/src/openhuman/wallet/mod.rs b/src/openhuman/wallet/mod.rs index ead24ae8f..659e0e605 100644 --- a/src/openhuman/wallet/mod.rs +++ b/src/openhuman/wallet/mod.rs @@ -2,15 +2,25 @@ //! the agent-facing execution surface (balances, transfers, swaps, //! contract calls). See [`execution`] for the prepare/confirm/execute flow. +mod abi; +mod defaults; mod execution; mod ops; +mod rpc; mod schemas; +pub use abi::encode_erc20_transfer; +pub use defaults::{ + asset_catalog, default_rpc_url, env_var_for_chain, explorer_tx_url, find_asset, + network_defaults, rpc_source_for_chain, rpc_url_for_chain, RpcSource, WalletAssetDefinition, + WalletNetworkDefaults, +}; pub use execution::{ - balances, chain_status, execute_prepared, prepare_contract_call, prepare_swap, - prepare_transfer, supported_assets, BalanceInfo, ChainStatus, ExecutePreparedParams, + balances, chain_status, execute_prepared, network_defaults as wallet_network_defaults, + prepare_contract_call, prepare_swap, prepare_transfer, prepared_quotes_for_test, + supported_assets, BalanceInfo, ChainStatus, ExecutePreparedParams, ExecutionResult, PrepareContractCallParams, PrepareSwapParams, PrepareTransferParams, PreparedKind, - PreparedStatus, PreparedTransaction, ProviderStatus, ReadyToSign, SupportedAsset, + PreparedStatus, PreparedTransaction, ProviderStatus, SupportedAsset, }; pub use ops::{ setup, status, WalletAccount, WalletChain, WalletSetupParams, WalletSetupSource, WalletStatus, diff --git a/src/openhuman/wallet/ops.rs b/src/openhuman/wallet/ops.rs index c19f709f6..8676ecef3 100644 --- a/src/openhuman/wallet/ops.rs +++ b/src/openhuman/wallet/ops.rs @@ -63,6 +63,8 @@ pub struct WalletSetupParams { pub consent_granted: bool, pub source: WalletSetupSource, pub mnemonic_word_count: u8, + #[serde(default)] + pub encrypted_mnemonic: Option, pub accounts: Vec, } @@ -72,16 +74,25 @@ struct StoredWalletState { pub consent_granted: bool, pub source: WalletSetupSource, pub mnemonic_word_count: u8, + #[serde(default)] + pub encrypted_mnemonic: Option, pub accounts: Vec, pub updated_at_ms: u64, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WalletSecretMaterial { + pub encrypted_mnemonic: String, + pub derivation_path: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WalletStatus { pub configured: bool, pub onboarding_completed: bool, pub consent_granted: bool, + pub secret_stored: bool, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -178,6 +189,7 @@ fn load_stored_wallet_state_unlocked(config: &Config) -> Result Result, Stri .join(", ") )); } + if params + .encrypted_mnemonic + .as_ref() + .map(|value| value.trim().is_empty()) + .unwrap_or(true) + { + return Err( + "wallet setup requires encrypted mnemonic material for signing-enabled local wallets" + .to_string(), + ); + } let mut normalized = Vec::with_capacity(params.accounts.len()); for account in ¶ms.accounts { @@ -311,6 +334,11 @@ fn to_status(state: Option) -> WalletStatus { configured: true, onboarding_completed: state.consent_granted && !state.accounts.is_empty(), consent_granted: state.consent_granted, + secret_stored: state + .encrypted_mnemonic + .as_ref() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false), source: Some(state.source), mnemonic_word_count: Some(state.mnemonic_word_count), accounts: state.accounts, @@ -320,6 +348,7 @@ fn to_status(state: Option) -> WalletStatus { configured: false, onboarding_completed: false, consent_granted: false, + secret_stored: false, source: None, mnemonic_word_count: None, accounts: Vec::new(), @@ -349,10 +378,20 @@ pub async fn status() -> Result, String> { pub async fn setup(params: WalletSetupParams) -> Result, String> { let config = config_rpc::load_config_with_timeout().await?; let accounts = validate_setup(¶ms)?; + let encrypted_mnemonic = params + .encrypted_mnemonic + .as_ref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "wallet setup requires encrypted mnemonic material for signing-enabled local wallets" + .to_string() + })?; let state = StoredWalletState { consent_granted: params.consent_granted, source: params.source, mnemonic_word_count: params.mnemonic_word_count, + encrypted_mnemonic: Some(encrypted_mnemonic), accounts, updated_at_ms: current_time_ms(), }; @@ -362,10 +401,11 @@ pub async fn setup(params: WalletSetupParams) -> Result let status = to_status(Some(state)); debug!( - "{LOG_PREFIX} setup saved source={:?} account_count={} mnemonic_words={}", + "{LOG_PREFIX} setup saved source={:?} account_count={} mnemonic_words={} secret_stored={}", status.source, status.accounts.len(), - status.mnemonic_word_count.unwrap_or_default() + status.mnemonic_word_count.unwrap_or_default(), + status.secret_stored ); Ok(RpcOutcome::new( @@ -374,6 +414,63 @@ pub async fn setup(params: WalletSetupParams) -> Result )) } +pub(crate) async fn secret_material(chain: WalletChain) -> Result { + debug!( + "{LOG_PREFIX} secret_material loading config chain={}", + chain.as_str() + ); + let config = config_rpc::load_config_with_timeout().await?; + debug!( + "{LOG_PREFIX} secret_material acquiring state lock chain={}", + chain.as_str() + ); + let _guard = WALLET_STATE_FILE_LOCK.lock(); + let state = match load_stored_wallet_state_unlocked(&config)? { + Some(state) => state, + None => { + debug!( + "{LOG_PREFIX} secret_material missing wallet state chain={}", + chain.as_str() + ); + return Err("wallet is not configured; run wallet setup first".to_string()); + } + }; + let encrypted_mnemonic = state + .encrypted_mnemonic + .as_ref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + debug!( + "{LOG_PREFIX} secret_material missing encrypted mnemonic chain={}", + chain.as_str() + ); + "wallet secret material is missing; re-import the recovery phrase to enable signing" + .to_string() + })?; + let derivation_path = state + .accounts + .iter() + .find(|account| account.chain == chain) + .map(|account| account.derivation_path.clone()) + .ok_or_else(|| { + debug!( + "{LOG_PREFIX} secret_material missing account chain={}", + chain.as_str() + ); + format!("no wallet account derived for chain '{}'", chain.as_str()) + })?; + debug!( + "{LOG_PREFIX} secret_material loaded chain={} derivation_path={}", + chain.as_str(), + derivation_path + ); + Ok(WalletSecretMaterial { + encrypted_mnemonic, + derivation_path, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -391,6 +488,7 @@ mod tests { consent_granted: true, source: WalletSetupSource::Imported, mnemonic_word_count: 12, + encrypted_mnemonic: Some("enc2:abc".to_string()), accounts: WalletChain::ALL.into_iter().map(sample_account).collect(), } } @@ -429,11 +527,21 @@ mod tests { .contains("unsupported mnemonic word count")); } + #[test] + fn validate_setup_rejects_missing_encrypted_mnemonic() { + let mut params = sample_params(); + params.encrypted_mnemonic = Some(" ".to_string()); + assert!(validate_setup(¶ms) + .expect_err("missing encrypted mnemonic should fail") + .contains("encrypted mnemonic material")); + } + #[test] fn status_defaults_to_unconfigured() { let status = to_status(None); assert!(!status.configured); assert!(!status.onboarding_completed); + assert!(!status.secret_stored); assert!(status.accounts.is_empty()); } @@ -443,12 +551,14 @@ mod tests { consent_granted: true, source: WalletSetupSource::Generated, mnemonic_word_count: 24, + encrypted_mnemonic: Some("enc2:abc".to_string()), accounts: WalletChain::ALL.into_iter().map(sample_account).collect(), updated_at_ms: 123, }; let status = to_status(Some(state)); assert!(status.configured); assert!(status.onboarding_completed); + assert!(status.secret_stored); assert_eq!(status.accounts.len(), 4); assert_eq!(status.updated_at_ms, Some(123)); } diff --git a/src/openhuman/wallet/rpc.rs b/src/openhuman/wallet/rpc.rs new file mode 100644 index 000000000..42a5ae9da --- /dev/null +++ b/src/openhuman/wallet/rpc.rs @@ -0,0 +1,100 @@ +use std::time::Duration; + +use serde::de::DeserializeOwned; +use serde_json::{json, Value}; + +use super::defaults::rpc_url_for_chain; +use super::ops::WalletChain; + +const LOG_PREFIX: &str = "[wallet::rpc]"; + +fn redact_rpc_url(raw: &str) -> String { + match reqwest::Url::parse(raw) { + Ok(url) => match url.host_str() { + Some(host) => format!("{}://{}", url.scheme(), host), + None => format!("{}://", url.scheme()), + }, + Err(_) => "".to_string(), + } +} + +pub async fn rpc_call( + chain: WalletChain, + method: &str, + params: Value, +) -> Result { + let url = rpc_url_for_chain(chain); + let payload = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }); + log::debug!( + "{LOG_PREFIX} chain={:?} method={} url={}", + chain, + method, + redact_rpc_url(&url) + ); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .map_err(|e| format!("wallet RPC client build failed for {method}: {e}"))?; + let response = client + .post(url) + .json(&payload) + .send() + .await + .map_err(|e| format!("wallet RPC transport failed for {method}: {e}"))?; + let status = response.status(); + let raw_body = response + .text() + .await + .map_err(|e| format!("wallet RPC read body failed for {method}: {e}"))?; + log::debug!( + "{LOG_PREFIX} chain={:?} method={} status={} body_len={}", + chain, + method, + status, + raw_body.len() + ); + if !status.is_success() { + return Err(format!( + "wallet RPC HTTP failure for {method}: status={status} body={raw_body}" + )); + } + let body: Value = serde_json::from_str(&raw_body) + .map_err(|e| format!("wallet RPC decode failed for {method}: {e}; body={raw_body}"))?; + log::debug!( + "{LOG_PREFIX} chain={:?} method={} decoded_json=true", + chain, + method + ); + if let Some(error) = body.get("error") { + return Err(format!("wallet RPC error for {method}: {error}")); + } + let result = body + .get("result") + .cloned() + .ok_or_else(|| format!("wallet RPC missing result for {method}"))?; + serde_json::from_value(result) + .map_err(|e| format!("wallet RPC invalid result for {method}: {e}")) +} + +#[cfg(test)] +mod tests { + use super::redact_rpc_url; + + #[test] + fn redact_rpc_url_strips_path_and_query() { + assert_eq!( + redact_rpc_url("https://user:pass@example.com/path/secret?apiKey=123"), + "https://example.com" + ); + } + + #[test] + fn redact_rpc_url_handles_invalid_values() { + assert_eq!(redact_rpc_url("not a url"), ""); + } +} diff --git a/src/openhuman/wallet/schemas.rs b/src/openhuman/wallet/schemas.rs index 3b9b431c5..7de5cebdf 100644 --- a/src/openhuman/wallet/schemas.rs +++ b/src/openhuman/wallet/schemas.rs @@ -5,11 +5,12 @@ use crate::core::all::{ControllerFuture, RegisteredController}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; use super::execution::{ - balances, chain_status, execute_prepared, prepare_contract_call, prepare_swap, - prepare_transfer, supported_assets, ExecutePreparedParams, PrepareContractCallParams, - PrepareSwapParams, PrepareTransferParams, + balances, chain_status, execute_prepared, network_defaults, prepare_contract_call, + prepare_swap, prepare_transfer, supported_assets, ExecutePreparedParams, + PrepareContractCallParams, PrepareSwapParams, PrepareTransferParams, }; use super::ops::{WalletAccount, WalletSetupParams, WalletSetupSource}; +use super::{encode_erc20_transfer, WalletChain}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -17,9 +18,18 @@ struct SetupWalletParams { consent_granted: bool, source: WalletSetupSource, mnemonic_word_count: u8, + encrypted_mnemonic: String, accounts: Vec, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EncodeErc20TransferParams { + chain: WalletChain, + to_address: String, + amount_raw: String, +} + pub fn all_controller_schemas() -> Vec { all_wallet_controller_schemas() } @@ -37,7 +47,9 @@ pub fn all_wallet_controller_schemas() -> Vec { wallet_schemas("status"), wallet_schemas("setup"), wallet_schemas("balances"), + wallet_schemas("network_defaults"), wallet_schemas("supported_assets"), + wallet_schemas("encode_erc20_transfer"), wallet_schemas("chain_status"), wallet_schemas("prepare_transfer"), wallet_schemas("prepare_swap"), @@ -60,10 +72,18 @@ pub fn all_wallet_registered_controllers() -> Vec { schema: wallet_schemas("balances"), handler: handle_balances, }, + RegisteredController { + schema: wallet_schemas("network_defaults"), + handler: handle_network_defaults, + }, RegisteredController { schema: wallet_schemas("supported_assets"), handler: handle_supported_assets, }, + RegisteredController { + schema: wallet_schemas("encode_erc20_transfer"), + handler: handle_encode_erc20_transfer, + }, RegisteredController { schema: wallet_schemas("chain_status"), handler: handle_chain_status, @@ -113,6 +133,13 @@ pub fn wallet_schemas(function: &str) -> ControllerSchema { "mnemonicWordCount", "The number of words in the validated recovery phrase.", ), + FieldSchema { + name: "encryptedMnemonic", + ty: TypeSchema::String, + comment: + "Encrypted recovery phrase payload created via openhuman.encrypt_secret. Required for on-chain signing/broadcast.", + required: true, + }, required_json( "accounts", "Exactly one derived account for each supported chain: EVM, BTC, Solana, and Tron.", @@ -129,7 +156,7 @@ pub fn wallet_schemas(function: &str) -> ControllerSchema { namespace: "wallet", function: "balances", description: - "List native-asset balances for every derived wallet account. Each row carries a providerStatus indicating whether a chain RPC is configured.", + "List native-asset balances for every derived wallet account. EVM balances are read live from the configured/default RPC; other chains remain placeholder-zero until provider support lands.", inputs: vec![], outputs: vec![FieldSchema { name: "result", @@ -138,16 +165,46 @@ pub fn wallet_schemas(function: &str) -> ControllerSchema { required: true, }], }, - "supported_assets" => ControllerSchema { + "network_defaults" => ControllerSchema { namespace: "wallet", - function: "supported_assets", + function: "network_defaults", description: - "Catalog of natively supported assets (one native per chain) the wallet surface understands.", + "List default RPC URLs, explorer bases, capability flags, and asset catalogs for supported wallet chains.", inputs: vec![], outputs: vec![FieldSchema { name: "result", ty: TypeSchema::Json, - comment: "Array of {chain, symbol, name, native, decimals}.", + comment: "Array of {chain, network, chainId?, rpcUrl, rpcSource, explorerTxUrlBase, supportsBroadcast, supportsTokenTransfers, supportsContractCalls, assets[]}.", + required: true, + }], + }, + "supported_assets" => ControllerSchema { + namespace: "wallet", + function: "supported_assets", + description: + "Catalog of built-in asset defaults the wallet surface understands, including default ERC-20s on EVM.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::Json, + comment: "Array of {chain, symbol, name, native, decimals, contractAddress?}.", + required: true, + }], + }, + "encode_erc20_transfer" => ControllerSchema { + namespace: "wallet", + function: "encode_erc20_transfer", + description: + "Encode ERC-20 transfer(address,uint256) calldata for EVM token sends.", + inputs: vec![ + required_json("chain", "Target chain. Must be evm."), + required_json("toAddress", "Recipient EVM address."), + required_json("amountRaw", "Token amount in the token's smallest unit as a decimal string."), + ], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::Json, + comment: "0x-prefixed calldata string for transfer(address,uint256).", required: true, }], }, @@ -155,12 +212,12 @@ pub fn wallet_schemas(function: &str) -> ControllerSchema { namespace: "wallet", function: "chain_status", description: - "Per-chain readiness: whether a wallet account is derived and whether an RPC provider is configured.", + "Per-chain readiness: whether a wallet account is derived plus the active RPC URL (default or env override).", inputs: vec![], outputs: vec![FieldSchema { name: "result", ty: TypeSchema::Json, - comment: "Array of {chain, configured, providerStatus} (providerStatus ∈ ready|unconfigured|missing).", + comment: "Array of {chain, configured, providerStatus, rpcUrl}.", required: true, }], }, @@ -168,7 +225,7 @@ pub fn wallet_schemas(function: &str) -> ControllerSchema { namespace: "wallet", function: "prepare_transfer", description: - "Build a simulated native or token-transfer quote. Returns a quoteId; call wallet.execute_prepared with confirmed=true to forward to the keystore.", + "Build a native or token-transfer quote. For EVM, default ERC-20 symbols are supported and wallet.execute_prepared will sign+broadcast after explicit confirmation.", inputs: vec![ required_json("chain", "Target chain (evm | btc | solana | tron)."), required_json("toAddress", "Recipient address on the target chain."), @@ -211,9 +268,9 @@ pub fn wallet_schemas(function: &str) -> ControllerSchema { namespace: "wallet", function: "prepare_contract_call", description: - "Build a contract-call simulation quote (EVM and Tron). Caller supplies pre-encoded calldata.", + "Build a contract-call quote for EVM. Caller supplies pre-encoded calldata.", inputs: vec![ - required_json("chain", "Target chain (evm | tron). Other chains reject."), + required_json("chain", "Target chain. Must be evm."), required_json("contractAddress", "Target contract address."), required_json("calldata", "0x-prefixed hex calldata."), FieldSchema { @@ -234,7 +291,7 @@ pub fn wallet_schemas(function: &str) -> ControllerSchema { namespace: "wallet", function: "execute_prepared", description: - "Confirm a previously prepared quote and hand it off to the local keystore for signing. Requires confirmed=true; signing happens in the desktop shell, never in core.", + "Confirm and execute a previously prepared quote. EVM transfers and contract calls are signed in core from encrypted local secret material, then broadcast to the configured/default RPC.", inputs: vec![ required_json("quoteId", "quoteId returned by a prior wallet.prepare_* call."), required_json("confirmed", "Must be true; explicit safety boundary between simulate and execute."), @@ -242,7 +299,7 @@ pub fn wallet_schemas(function: &str) -> ControllerSchema { outputs: vec![FieldSchema { name: "result", ty: TypeSchema::Json, - comment: "ReadyToSign payload: {quoteId, status, chain, transaction}.", + comment: "ExecutionResult payload: {quoteId, status, chain, transactionHash, explorerUrl?, transaction}.", required: true, }], }, @@ -277,6 +334,7 @@ fn handle_setup(params: Map) -> ControllerFuture { consent_granted: payload.consent_granted, source: payload.source, mnemonic_word_count: payload.mnemonic_word_count, + encrypted_mnemonic: Some(payload.encrypted_mnemonic), accounts: payload.accounts, }) .await? @@ -288,10 +346,29 @@ fn handle_balances(_params: Map) -> ControllerFuture { Box::pin(async move { balances().await?.into_cli_compatible_json() }) } +fn handle_network_defaults(_params: Map) -> ControllerFuture { + Box::pin(async move { network_defaults().await?.into_cli_compatible_json() }) +} + fn handle_supported_assets(_params: Map) -> ControllerFuture { Box::pin(async move { supported_assets().await?.into_cli_compatible_json() }) } +fn handle_encode_erc20_transfer(params: Map) -> ControllerFuture { + Box::pin(async move { + let parsed: EncodeErc20TransferParams = serde_json::from_value(Value::Object(params)) + .map_err(|e| format!("invalid params: {e}"))?; + if parsed.chain != WalletChain::Evm { + return Err("encode_erc20_transfer only supports the evm chain".to_string()); + } + serde_json::to_value(encode_erc20_transfer( + &parsed.to_address, + &parsed.amount_raw, + )?) + .map_err(|e| format!("failed to encode ERC20 transfer output: {e}")) + }) +} + fn handle_chain_status(_params: Map) -> ControllerFuture { Box::pin(async move { chain_status().await?.into_cli_compatible_json() }) } @@ -345,12 +422,12 @@ mod tests { #[test] fn all_schemas_lists_every_controller() { - assert_eq!(all_wallet_controller_schemas().len(), 9); + assert_eq!(all_wallet_controller_schemas().len(), 11); } #[test] fn all_controllers_lists_every_handler() { - assert_eq!(all_wallet_registered_controllers().len(), 9); + assert_eq!(all_wallet_registered_controllers().len(), 11); } #[test] @@ -364,8 +441,13 @@ mod tests { #[test] fn setup_schema_requires_all_inputs() { let schema = wallet_schemas("setup"); - assert_eq!(schema.inputs.len(), 4); - assert!(schema.inputs.iter().all(|field| field.required)); + assert_eq!(schema.inputs.len(), 5); + let encrypted = schema + .inputs + .iter() + .find(|field| field.name == "encryptedMnemonic") + .expect("encryptedMnemonic input present"); + assert!(encrypted.required); } #[test] diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index a556f9e30..40d483b42 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -5,9 +5,10 @@ use std::net::SocketAddr; use std::path::Path; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; +use axum::extract::State; use axum::http::{header::AUTHORIZATION, HeaderMap, StatusCode, Uri}; use axum::routing::{get, post}; use axum::{Json, Router}; @@ -521,6 +522,58 @@ fn mock_upstream_router() -> Router { ) } +#[derive(Clone)] +struct MockWalletRpcState { + raw_txs: Arc>>, +} + +async fn mock_wallet_evm_rpc( + State(state): State, + Json(payload): Json, +) -> Json { + let method = payload + .get("method") + .and_then(Value::as_str) + .unwrap_or_default(); + let params = payload + .get("params") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let result = match method { + "eth_chainId" => Value::String("0x1".to_string()), + "eth_getTransactionCount" => Value::String("0x7".to_string()), + "eth_gasPrice" => Value::String("0x3b9aca00".to_string()), + "eth_estimateGas" => Value::String("0x5208".to_string()), + "eth_sendRawTransaction" => { + if let Some(raw) = params.first().and_then(Value::as_str) { + match state.raw_txs.lock() { + Ok(mut guard) => guard.push(raw.to_string()), + Err(poisoned) => poisoned.into_inner().push(raw.to_string()), + } + } + Value::String( + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + ) + } + "eth_getBalance" => Value::String("0x0".to_string()), + _ => Value::Null, + }; + Json(json!({"jsonrpc":"2.0","id":1,"result":result})) +} + +async fn start_mock_wallet_evm_rpc() -> (SocketAddr, Arc>>) { + let raw_txs = Arc::new(Mutex::new(Vec::new())); + let state = MockWalletRpcState { + raw_txs: raw_txs.clone(), + }; + let app = Router::new() + .route("/", post(mock_wallet_evm_rpc)) + .with_state(state); + let (addr, _join) = serve_on_ephemeral(app).await; + (addr, raw_txs) +} + async fn serve_on_ephemeral( app: Router, ) -> ( @@ -722,6 +775,19 @@ async fn wait_for_chat_completion_requests_len(expected_len: usize) -> Vec String { + let config = openhuman_core::openhuman::config::load_config_with_timeout() + .await + .expect("load config for encrypted test mnemonic"); + openhuman_core::openhuman::encryption::rpc::encrypt_secret( + &config, + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + ) + .await + .expect("encrypt test mnemonic") + .value +} + fn assert_no_jsonrpc_error<'a>(v: &'a Value, context: &str) -> &'a Value { if let Some(err) = v.get("error") { panic!("{context}: JSON-RPC error: {err}"); @@ -2470,6 +2536,7 @@ async fn json_rpc_wallet_setup_round_trips_status() { let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; let rpc_base = format!("http://{}", rpc_addr); tokio::time::sleep(Duration::from_millis(100)).await; + let encrypted_mnemonic = encrypt_test_mnemonic().await; let initial_status = post_json_rpc(&rpc_base, 1005, "openhuman.wallet_status", json!({})).await; let initial_body = assert_no_jsonrpc_error(&initial_status, "wallet_status_initial"); @@ -2488,6 +2555,7 @@ async fn json_rpc_wallet_setup_round_trips_status() { "consentGranted": true, "source": "generated", "mnemonicWordCount": 12, + "encryptedMnemonic": encrypted_mnemonic, "accounts": [ { "chain": "evm", "address": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", "derivationPath": "m/44'/60'/0'/0/0" }, { "chain": "btc", "address": "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", "derivationPath": "m/44'/0'/0'/0/0" }, @@ -2567,7 +2635,11 @@ async fn json_rpc_wallet_execution_surface_round_trips() { let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); - let _evm_provider_guard = EnvVarGuard::unset("OPENHUMAN_WALLET_RPC_EVM"); + let (wallet_rpc_addr, raw_txs) = start_mock_wallet_evm_rpc().await; + let _evm_provider_guard = EnvVarGuard::set( + "OPENHUMAN_WALLET_RPC_EVM", + &format!("http://{wallet_rpc_addr}"), + ); let _btc_provider_guard = EnvVarGuard::unset("OPENHUMAN_WALLET_RPC_BTC"); let _sol_provider_guard = EnvVarGuard::unset("OPENHUMAN_WALLET_RPC_SOLANA"); let _tron_provider_guard = EnvVarGuard::unset("OPENHUMAN_WALLET_RPC_TRON"); @@ -2579,6 +2651,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; let rpc_base = format!("http://{}", rpc_addr); tokio::time::sleep(Duration::from_millis(100)).await; + let encrypted_mnemonic = encrypt_test_mnemonic().await; // Configure wallet (required precondition for balances / prepare_*). let setup = post_json_rpc( @@ -2589,6 +2662,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { "consentGranted": true, "source": "imported", "mnemonicWordCount": 12, + "encryptedMnemonic": encrypted_mnemonic, "accounts": [ { "chain": "evm", "address": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", "derivationPath": "m/44'/60'/0'/0/0" }, { "chain": "btc", "address": "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", "derivationPath": "m/44'/0'/0'/0/0" }, @@ -2600,7 +2674,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { .await; assert_no_jsonrpc_error(&setup, "wallet_setup_for_execution"); - // supported_assets: 4 natives. + // supported_assets: 4 native assets plus the default EVM token catalog. let assets = post_json_rpc( &rpc_base, 2002, @@ -2611,9 +2685,27 @@ async fn json_rpc_wallet_execution_surface_round_trips() { let body = assert_no_jsonrpc_error(&assets, "wallet_supported_assets"); let result = body.get("result").unwrap_or(&body); let list = result.as_array().expect("supported_assets array"); - assert_eq!(list.len(), 4, "expected four native assets: {result}"); + assert_eq!( + list.len(), + 8, + "expected four native assets plus EVM tokens: {result}" + ); + assert!( + list.iter().any( + |asset| asset.get("symbol").and_then(Value::as_str) == Some("ETH") + && asset.get("native").and_then(Value::as_bool) == Some(true) + ), + "expected native ETH asset in catalog: {result}" + ); + assert!( + list.iter().any( + |asset| asset.get("symbol").and_then(Value::as_str) == Some("USDC") + && asset.get("native").and_then(Value::as_bool) == Some(false) + ), + "expected default USDC token in catalog: {result}" + ); - // chain_status: every chain configured but providers unconfigured. + // chain_status: every chain is configured, so the provider row is ready. let cs = post_json_rpc(&rpc_base, 2003, "openhuman.wallet_chain_status", json!({})).await; let body = assert_no_jsonrpc_error(&cs, "wallet_chain_status"); let result = body.get("result").unwrap_or(&body); @@ -2621,8 +2713,8 @@ async fn json_rpc_wallet_execution_surface_round_trips() { assert_eq!(rows.len(), 4); assert!( rows.iter() - .all(|r| r.get("providerStatus").and_then(Value::as_str) == Some("unconfigured")), - "expected providerStatus=unconfigured for every row: {result}" + .all(|r| r.get("providerStatus").and_then(Value::as_str) == Some("ready")), + "expected providerStatus=ready for configured chain rows: {result}" ); // balances: zero placeholders for each derived account. @@ -2676,7 +2768,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { "expected error for unconfirmed execute: {bad}" ); - // Confirmed execute moves the quote to ReadyToSign and consumes it. + // Confirmed execute signs and broadcasts the prepared transaction. let exec = post_json_rpc( &rpc_base, 2007, @@ -2688,7 +2780,11 @@ async fn json_rpc_wallet_execution_surface_round_trips() { let result = body.get("result").unwrap_or(&body); assert_eq!( result.get("status").and_then(Value::as_str), - Some("ready_to_sign"), + Some("broadcasted"), + ); + assert_eq!( + result.get("transactionHash").and_then(Value::as_str), + Some("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), ); assert_eq!( result @@ -2697,6 +2793,11 @@ async fn json_rpc_wallet_execution_surface_round_trips() { .and_then(Value::as_str), Some(quote_id.as_str()), ); + let sent_raw_count = match raw_txs.lock() { + Ok(guard) => guard.len(), + Err(poisoned) => poisoned.into_inner().len(), + }; + assert_eq!(sent_raw_count, 1, "expected one raw tx broadcast"); // A second execute on the same quote must fail (quote consumed). let dup = post_json_rpc(