diff --git a/Cargo.lock b/Cargo.lock index 6b0a1c4bb..b91c34515 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -517,6 +517,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals", + "bitcoin_hashes", +] + [[package]] name = "base64" version = "0.21.7" @@ -541,6 +551,12 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + [[package]] name = "bincode" version = "2.0.1" @@ -581,6 +597,54 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bitcoin" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf93e61f2dbc3e3c41234ca26a65e2c0b0975c52e0f069ab9893ebbede584d3" +dependencies = [ + "base58ck", + "bech32 0.11.1", + "bitcoin-internals", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative", + "hex_lit", + "secp256k1", +] + +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" + +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin-units" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346568ebaab2918487cea76dd55dae13c27bb618cdb737c952e69eb2017c4118" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1011,7 +1075,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5286a0843c21f8367f7be734f89df9b822e0321d8bcce8d6e735aadff7d74979" dependencies = [ "base64 0.21.7", - "bech32", + "bech32 0.9.1", "bs58", "digest 0.10.7", "generic-array", @@ -2861,6 +2925,21 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + [[package]] name = "hkdf" version = "0.12.4" @@ -4991,19 +5070,24 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "bitcoin", "block2 0.6.2", + "bs58", "chacha20poly1305", "chrono", "chrono-tz", "clap", "clap_complete", + "coins-bip39", "console", "cpal", "cron", + "curve25519-dalek", "dialoguer", "directories", "dirs 5.0.1", "dotenvy", + "ed25519-dalek", "enigo", "env_logger", "ethers-core", @@ -5046,6 +5130,7 @@ dependencies = [ "regex", "reqwest 0.12.28", "ring", + "ripemd", "rppal", "rusqlite", "rustls", @@ -6802,6 +6887,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.6", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + [[package]] name = "security-framework" version = "2.11.1" diff --git a/Cargo.toml b/Cargo.toml index 66123466e..41821b045 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -146,6 +146,22 @@ fs2 = "0.4" starship-battery = "0.10" ethers-core = { version = "2.0.14", default-features = false } ethers-signers = { version = "2.0.14", default-features = false } +# Multi-chain wallet signing. +# - bitcoin: P2WPKH PSBT build/sign/broadcast (includes secp256k1). +# - ed25519-dalek: Solana transaction signing. +# - bs58: Solana base58 addresses + Tron base58check addresses. +# - ripemd: RIPEMD160 for BTC HASH160 (P2WPKH) and Tron address hash. +bitcoin = { version = "0.32", default-features = false, features = ["std", "secp-recovery", "rand-std"] } +ed25519-dalek = { version = "2", default-features = false, features = ["std", "rand_core"] } +bs58 = { version = "0.5", default-features = false, features = ["std", "check"] } +ripemd = "0.1" +# Shared BIP-39 mnemonic → seed for non-EVM chains (BTC P2WPKH derivation, +# Tron secp256k1 derivation, Solana ed25519 SLIP-0010 derivation). Same crate +# ethers-signers uses internally, exposed as a direct dep so we can derive +# off the recovery phrase without going through the EVM signer wrapper. +coins-bip39 = "0.8" +# Solana off-curve check for ATA derivation (find_program_address). +curve25519-dalek = { version = "4", default-features = false, features = ["alloc"] } 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 316583830..0e86f5e05 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -584,6 +584,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals", + "bitcoin_hashes", +] + [[package]] name = "base64" version = "0.21.7" @@ -608,6 +618,12 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + [[package]] name = "bindgen" version = "0.72.1" @@ -643,6 +659,54 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitcoin" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf93e61f2dbc3e3c41234ca26a65e2c0b0975c52e0f069ab9893ebbede584d3" +dependencies = [ + "base58ck", + "bech32 0.11.1", + "bitcoin-internals", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative", + "hex_lit", + "secp256k1", +] + +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" + +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin-units" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346568ebaab2918487cea76dd55dae13c27bb618cdb737c952e69eb2017c4118" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -703,15 +767,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - [[package]] name = "block2" version = "0.5.1" @@ -868,15 +923,6 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - [[package]] name = "cc" version = "1.2.62" @@ -1169,7 +1215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5286a0843c21f8367f7be734f89df9b822e0321d8bcce8d6e735aadff7d74979" dependencies = [ "base64 0.21.7", - "bech32", + "bech32 0.9.1", "bs58", "digest 0.10.7", "generic-array", @@ -1649,6 +1695,33 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.23.0" @@ -1712,24 +1785,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "dbus-secret-service" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" -dependencies = [ - "aes", - "block-padding", - "cbc", - "dbus", - "fastrand", - "hkdf", - "num", - "once_cell", - "sha2 0.10.9", - "zeroize", -] - [[package]] name = "debugid" version = "0.8.0" @@ -2083,6 +2138,31 @@ dependencies = [ "spki", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -2489,6 +2569,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -3247,14 +3333,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "hkdf" -version = "0.12.4" +name = "hex-conservative" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" dependencies = [ - "hmac 0.12.1", + "arrayvec", ] +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + [[package]] name = "hmac" version = "0.12.1" @@ -3714,7 +3806,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", "generic-array", ] @@ -3949,9 +4040,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ "byteorder", - "dbus-secret-service", + "linux-keyutils", "log", - "secret-service", "security-framework 2.11.1", "security-framework 3.7.0", "windows-sys 0.60.2", @@ -4138,6 +4228,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4515,7 +4615,6 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", - "memoffset", ] [[package]] @@ -4567,7 +4666,7 @@ dependencies = [ "mac-notification-sys", "serde", "tauri-winrt-notification", - "zbus 5.15.0", + "zbus", ] [[package]] @@ -4598,39 +4697,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - [[package]] name = "num-conv" version = "0.2.1" @@ -4648,37 +4714,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -5187,19 +5222,24 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "bitcoin", "block2 0.6.2", + "bs58", "chacha20poly1305", "chrono", "chrono-tz", "clap", "clap_complete", + "coins-bip39", "console", "cpal", "cron", + "curve25519-dalek", "dialoguer", "directories 6.0.0", "dirs 5.0.1", "dotenvy", + "ed25519-dalek", "enigo", "env_logger", "ethers-core", @@ -5237,6 +5277,7 @@ dependencies = [ "regex", "reqwest 0.12.28", "ring", + "ripemd", "rusqlite", "rustls", "rustls-pki-types", @@ -6981,22 +7022,23 @@ dependencies = [ ] [[package]] -name = "secret-service" -version = "4.0.0" +name = "secp256k1" +version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "aes", - "cbc", - "futures-util", - "generic-array", - "hkdf", - "num", - "once_cell", + "bitcoin_hashes", "rand 0.8.6", - "serde", - "sha2 0.10.9", - "zbus 4.4.0", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", ] [[package]] @@ -8199,7 +8241,7 @@ dependencies = [ "thiserror 2.0.18", "url", "windows 0.61.3", - "zbus 5.15.0", + "zbus", ] [[package]] @@ -8214,7 +8256,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", - "zbus 5.15.0", + "zbus", ] [[package]] @@ -10633,16 +10675,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "xdg-home" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "xkbcommon" version = "0.8.0" @@ -10698,38 +10730,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zbus" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" -dependencies = [ - "async-broadcast", - "async-process", - "async-recursion", - "async-trait", - "enumflags2", - "event-listener 5.4.1", - "futures-core", - "futures-sink", - "futures-util", - "hex", - "nix 0.29.0", - "ordered-stream", - "rand 0.8.6", - "serde", - "serde_repr", - "sha1", - "static_assertions", - "tracing", - "uds_windows", - "windows-sys 0.52.0", - "xdg-home", - "zbus_macros 4.4.0", - "zbus_names 3.0.0", - "zvariant 4.2.0", -] - [[package]] name = "zbus" version = "5.15.0" @@ -10760,22 +10760,9 @@ dependencies = [ "uuid 1.23.1", "windows-sys 0.61.2", "winnow 1.0.2", - "zbus_macros 5.15.0", - "zbus_names 4.3.2", - "zvariant 5.11.0", -] - -[[package]] -name = "zbus_macros" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", - "zvariant_utils 2.1.0", + "zbus_macros", + "zbus_names", + "zvariant", ] [[package]] @@ -10788,20 +10775,9 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zbus_names 4.3.2", - "zvariant 5.11.0", - "zvariant_utils 3.3.1", -] - -[[package]] -name = "zbus_names" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" -dependencies = [ - "serde", - "static_assertions", - "zvariant 4.2.0", + "zbus_names", + "zvariant", + "zvariant_utils", ] [[package]] @@ -10812,7 +10788,7 @@ checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", "winnow 1.0.2", - "zvariant 5.11.0", + "zvariant", ] [[package]] @@ -10861,20 +10837,6 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] [[package]] name = "zerotrie" @@ -10971,19 +10933,6 @@ dependencies = [ "zune-core", ] -[[package]] -name = "zvariant" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" -dependencies = [ - "endi", - "enumflags2", - "serde", - "static_assertions", - "zvariant_derive 4.2.0", -] - [[package]] name = "zvariant" version = "5.11.0" @@ -10994,21 +10943,8 @@ dependencies = [ "enumflags2", "serde", "winnow 1.0.2", - "zvariant_derive 5.11.0", - "zvariant_utils 3.3.1", -] - -[[package]] -name = "zvariant_derive" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", - "zvariant_utils 2.1.0", + "zvariant_derive", + "zvariant_utils", ] [[package]] @@ -11021,18 +10957,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zvariant_utils 3.3.1", -] - -[[package]] -name = "zvariant_utils" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "zvariant_utils", ] [[package]] diff --git a/src/openhuman/tools/impl/wallet/prepare_transfer.rs b/src/openhuman/tools/impl/wallet/prepare_transfer.rs index b7430d915..1fb1a833d 100644 --- a/src/openhuman/tools/impl/wallet/prepare_transfer.rs +++ b/src/openhuman/tools/impl/wallet/prepare_transfer.rs @@ -41,6 +41,11 @@ impl Tool for WalletPrepareTransferTool { "assetSymbol": { "type": "string", "description": "Asset symbol (e.g. ETH, USDC). Defaults to the native asset." + }, + "evmNetwork": { + "type": "string", + "enum": ["ethereum_mainnet", "base_mainnet", "arbitrum_one", "optimism_mainnet", "polygon_mainnet"], + "description": "Optional EVM network when chain='evm'. Defaults to ethereum_mainnet." } }, "required": ["chain", "toAddress", "amountRaw"], diff --git a/src/openhuman/wallet/chains/btc.rs b/src/openhuman/wallet/chains/btc.rs new file mode 100644 index 000000000..df1c4cd04 --- /dev/null +++ b/src/openhuman/wallet/chains/btc.rs @@ -0,0 +1,561 @@ +//! Bitcoin P2WPKH signing + broadcast. Uses the `bitcoin` crate plus the +//! Esplora REST API (https://blockstream.info/api) for UTXO discovery and +//! transaction broadcast. Tests can point at any URL via +//! `OPENHUMAN_WALLET_RPC_BTC`. +//! +//! Address derivation uses BIP84 (`m/84'/0'/0'/0/0` mainnet) so any wallet +//! seeded with a standard recovery phrase + this path produces a `bc1q…` +//! native segwit address. + +use bitcoin::absolute::LockTime; +use bitcoin::bip32::{DerivationPath, Xpriv}; +use bitcoin::hashes::Hash; +use bitcoin::key::{CompressedPublicKey, PrivateKey}; +use bitcoin::secp256k1::{Message, Secp256k1}; +use bitcoin::sighash::{EcdsaSighashType, SighashCache}; +use bitcoin::transaction::Version; +use bitcoin::{ + consensus::encode::serialize_hex, Address, Amount, Network, OutPoint, ScriptBuf, Sequence, + Transaction, TxIn, TxOut, Witness, +}; +use log::debug; +use serde::Deserialize; + +use crate::openhuman::config::rpc as config_rpc; + +use super::super::defaults::{explorer_tx_url, rpc_url_for_chain}; +use super::super::execution::{ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction}; +use super::super::ops::{secret_material, WalletChain}; +use super::super::rpc::{rest_get_json, rest_post_text}; + +const LOG_PREFIX: &str = "[wallet::btc]"; +/// Hardcoded fee rate (sat/vbyte) used to estimate fees for prepared quotes +/// and to size the change output. Conservative — Bitcoin mempools cap out +/// around 50 sat/vB during congested periods; 20 keeps us in-range without +/// burning sats during quiet times. +const DEFAULT_FEE_RATE_SAT_VB: u64 = 20; +/// Approx vbytes of a 1-input, 2-output P2WPKH tx (in/out + witness). +const TYPICAL_TX_VBYTES: u64 = 141; + +#[derive(Debug, Deserialize, Clone)] +pub struct EsploraUtxo { + pub txid: String, + pub vout: u32, + pub value: u64, +} + +#[derive(Debug, Deserialize)] +struct EsploraAddressInfo { + chain_stats: EsploraAddressStats, + mempool_stats: EsploraAddressStats, +} + +#[derive(Debug, Deserialize)] +struct EsploraAddressStats { + funded_txo_sum: u64, + spent_txo_sum: u64, +} + +pub fn estimated_btc_fee_sats() -> u64 { + DEFAULT_FEE_RATE_SAT_VB * TYPICAL_TX_VBYTES +} + +/// Generic BTC address validation — any well-formed mainnet address is OK. +/// Used for recipients (we don't care what address type they prefer; the +/// `bitcoin` crate's script_pubkey() will encode P2WPKH/P2TR/P2SH correctly). +pub fn validate_btc_address(addr: &str) -> Result { + let trimmed = addr.trim(); + if trimmed.is_empty() { + return Err("BTC address is empty".to_string()); + } + Address::from_str(trimmed) + .map_err(|e| format!("invalid BTC address '{trimmed}': {e}"))? + .require_network(Network::Bitcoin) + .map_err(|e| format!("BTC address '{trimmed}' is not mainnet: {e}"))?; + Ok(trimmed.to_string()) +} + +/// Sender-side validation — must be P2WPKH because we only know how to +/// derive + sign for native segwit (`bc1q…`). Recipients can be any type. +pub fn validate_btc_sender_address(addr: &str) -> Result { + let trimmed = validate_btc_address(addr)?; + let parsed = Address::from_str(&trimmed) + .map_err(|e| format!("invalid BTC sender '{trimmed}': {e}"))? + .assume_checked(); + if !parsed.script_pubkey().is_p2wpkh() { + return Err(format!( + "BTC sender '{trimmed}' is not P2WPKH (only bc1q… native segwit is supported for signing)" + )); + } + Ok(trimmed) +} + +use std::str::FromStr; + +pub async fn native_balance(address: &str) -> Result { + validate_btc_address(address)?; + let base = rpc_url_for_chain(WalletChain::Btc); + let url = format!("{}/address/{}", base.trim_end_matches('/'), address); + let info: EsploraAddressInfo = rest_get_json(&url).await?; + let confirmed = info + .chain_stats + .funded_txo_sum + .saturating_sub(info.chain_stats.spent_txo_sum); + let pending = info + .mempool_stats + .funded_txo_sum + .saturating_sub(info.mempool_stats.spent_txo_sum); + Ok((confirmed + pending) as u128) +} + +pub async fn fetch_utxos(address: &str) -> Result, String> { + let base = rpc_url_for_chain(WalletChain::Btc); + let url = format!("{}/address/{}/utxo", base.trim_end_matches('/'), address); + rest_get_json(&url).await +} + +pub async fn broadcast_raw_hex(tx_hex: &str) -> Result { + let base = rpc_url_for_chain(WalletChain::Btc); + let url = format!("{}/tx", base.trim_end_matches('/')); + rest_post_text(&url, tx_hex, "text/plain").await +} + +/// Best-effort lookup of an address's spending scriptPubKey by parsing it. +fn script_pubkey_for_addr(addr: &str) -> Result { + let parsed = Address::from_str(addr) + .map_err(|e| format!("invalid address '{addr}': {e}"))? + .require_network(Network::Bitcoin) + .map_err(|e| format!("address '{addr}' is not mainnet: {e}"))?; + Ok(parsed.script_pubkey()) +} + +/// Derive a P2WPKH PrivateKey for `derivation_path` from a BIP39 mnemonic. +fn derive_btc_private_key( + mnemonic: &str, + derivation_path: &str, +) -> Result<(PrivateKey, CompressedPublicKey), String> { + use coins_bip39::{English, Mnemonic}; + let mnemonic: Mnemonic = mnemonic + .trim() + .parse() + .map_err(|e| format!("invalid BIP39 mnemonic: {e}"))?; + let seed = mnemonic + .to_seed(None) + .map_err(|e| format!("failed to derive BIP39 seed: {e}"))?; + let xpriv = Xpriv::new_master(Network::Bitcoin, &seed) + .map_err(|e| format!("failed to derive BTC master key: {e}"))?; + let secp = Secp256k1::signing_only(); + let path = DerivationPath::from_str(derivation_path) + .map_err(|e| format!("invalid BTC derivation path '{derivation_path}': {e}"))?; + let derived = xpriv + .derive_priv(&secp, &path) + .map_err(|e| format!("failed to derive BTC child key: {e}"))?; + let private_key = PrivateKey::new(derived.private_key, Network::Bitcoin); + let public_key = CompressedPublicKey::from_private_key(&secp, &private_key) + .map_err(|e| format!("failed to derive BTC public key: {e}"))?; + Ok((private_key, public_key)) +} + +/// Select UTXOs to cover `amount_sats + fee_sats`, returning the selected +/// set and the change. Simple greedy: largest-first. +fn select_utxos( + utxos: &[EsploraUtxo], + amount_sats: u64, + fee_sats: u64, +) -> Result<(Vec, u64), String> { + let mut sorted = utxos.to_vec(); + sorted.sort_by(|a, b| b.value.cmp(&a.value)); + let target = amount_sats + .checked_add(fee_sats) + .ok_or_else(|| "amount + fee overflow".to_string())?; + let mut total: u64 = 0; + let mut chosen = Vec::new(); + for utxo in sorted { + total = total + .checked_add(utxo.value) + .ok_or_else(|| "utxo sum overflow".to_string())?; + chosen.push(utxo); + if total >= target { + return Ok((chosen, total - target)); + } + } + Err(format!( + "insufficient BTC: have {total} sats, need {target} (amount {amount_sats} + fee {fee_sats})" + )) +} + +pub async fn execute_btc_quote(mut quote: PreparedTransaction) -> Result { + if !matches!(quote.kind, PreparedKind::NativeTransfer) { + return Err(format!( + "BTC only supports native transfers; got kind {:?}", + quote.kind + )); + } + let amount_sats: u64 = quote + .amount_raw + .parse() + .map_err(|e| format!("invalid BTC amount '{}': {e}", quote.amount_raw))?; + let from_addr = quote.from_address.clone(); + let to_addr = quote.to_address.clone(); + validate_btc_sender_address(&from_addr)?; + validate_btc_address(&to_addr)?; + + let utxos = fetch_utxos(&from_addr).await?; + if utxos.is_empty() { + return Err(format!("no spendable UTXOs for {from_addr}")); + } + let fee_sats = estimated_btc_fee_sats(); + let (selected, change_sats) = select_utxos(&utxos, amount_sats, fee_sats)?; + + let secret = secret_material(WalletChain::Btc).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 (private_key, public_key) = derive_btc_private_key(&mnemonic, &secret.derivation_path)?; + + let from_spk = script_pubkey_for_addr(&from_addr)?; + let to_spk = script_pubkey_for_addr(&to_addr)?; + + // Build inputs. + let mut tx_inputs = Vec::with_capacity(selected.len()); + for utxo in &selected { + let txid = bitcoin::Txid::from_str(&utxo.txid) + .map_err(|e| format!("invalid utxo txid '{}': {e}", utxo.txid))?; + tx_inputs.push(TxIn { + previous_output: OutPoint { + txid, + vout: utxo.vout, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::default(), + }); + } + // Outputs: recipient + (optional) change to self. + let mut tx_outputs = vec![TxOut { + value: Amount::from_sat(amount_sats), + script_pubkey: to_spk, + }]; + if change_sats > 546 { + tx_outputs.push(TxOut { + value: Amount::from_sat(change_sats), + script_pubkey: from_spk.clone(), + }); + } + + let mut tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: tx_inputs, + output: tx_outputs, + }; + + // Sign each input (BIP143 segwit sighash). + let secp = Secp256k1::signing_only(); + let mut sighash_cache = SighashCache::new(&mut tx); + let mut witnesses = Vec::with_capacity(selected.len()); + for (idx, utxo) in selected.iter().enumerate() { + let sighash = sighash_cache + .p2wpkh_signature_hash( + idx, + &from_spk, + Amount::from_sat(utxo.value), + EcdsaSighashType::All, + ) + .map_err(|e| format!("failed to compute BTC sighash: {e}"))?; + let msg = Message::from_digest(sighash.to_byte_array()); + let sig = secp.sign_ecdsa(&msg, &private_key.inner); + let mut witness = Witness::new(); + let mut sig_bytes = sig.serialize_der().to_vec(); + sig_bytes.push(EcdsaSighashType::All as u8); + witness.push(sig_bytes); + witness.push(public_key.to_bytes()); + witnesses.push(witness); + } + for (input, witness) in tx.input.iter_mut().zip(witnesses.into_iter()) { + input.witness = witness; + } + + let tx_hex = serialize_hex(&tx); + let txid_hex = broadcast_raw_hex(&tx_hex).await?; + quote.estimated_fee_raw = fee_sats.to_string(); + quote.status = PreparedStatus::Broadcasted; + debug!( + "{LOG_PREFIX} broadcast quote_id={} txid={} amount_sats={} change_sats={}", + quote.quote_id, txid_hex, amount_sats, change_sats + ); + let explorer_url = explorer_tx_url(WalletChain::Btc, &txid_hex); + Ok(ExecutionResult { + quote_id: quote.quote_id.clone(), + status: PreparedStatus::Broadcasted, + chain: WalletChain::Btc, + evm_network: None, + transaction_hash: txid_hex, + explorer_url, + transaction: quote, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::wallet::execution::{ + insert_quote_for_test, now_ms, reset_quote_store_for_tests, PreparedKind, PreparedStatus, + PreparedTransaction, + }; + use crate::openhuman::wallet::test_support::{sample_btc_address, setup_wallet_in, TEST_LOCK}; + use axum::{ + routing::{get, post}, + Router, + }; + use serde_json::json; + use std::sync::Arc; + use tempfile::TempDir; + use tokio::net::TcpListener; + + #[test] + fn validate_btc_address_accepts_known_p2wpkh() { + // bech32 P2WPKH from BIP173 examples. + let addr = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; + assert_eq!(validate_btc_address(addr).unwrap(), addr); + } + + #[test] + fn validate_btc_address_rejects_testnet() { + let err = + validate_btc_address("tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3") + .unwrap_err(); + assert!( + err.contains("not mainnet") || err.contains("invalid"), + "got: {err}" + ); + } + + #[test] + fn validate_btc_sender_address_rejects_p2tr() { + // P2TR (bech32m, bc1p…) is a valid recipient but cannot be a sender — + // we only know how to sign P2WPKH inputs in this iteration. + let p2tr = "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr"; + // Generic validation must accept it (recipients can be any type). + assert_eq!(validate_btc_address(p2tr).unwrap(), p2tr); + // Sender validation must reject it. + let err = validate_btc_sender_address(p2tr).unwrap_err(); + assert!(err.contains("not P2WPKH"), "got: {err}"); + } + + #[test] + fn select_utxos_largest_first_returns_change() { + let utxos = vec![ + EsploraUtxo { + txid: "a".into(), + vout: 0, + value: 5000, + }, + EsploraUtxo { + txid: "b".into(), + vout: 0, + value: 10_000, + }, + EsploraUtxo { + txid: "c".into(), + vout: 0, + value: 1_000, + }, + ]; + let (chosen, change) = select_utxos(&utxos, 6_000, 2_000).unwrap(); + assert_eq!(chosen.len(), 1); + assert_eq!(chosen[0].txid, "b"); + assert_eq!(change, 2_000); + } + + #[test] + fn select_utxos_combines_multiple_when_needed() { + let utxos = vec![ + EsploraUtxo { + txid: "a".into(), + vout: 0, + value: 5000, + }, + EsploraUtxo { + txid: "b".into(), + vout: 0, + value: 5000, + }, + EsploraUtxo { + txid: "c".into(), + vout: 0, + value: 5000, + }, + ]; + let (chosen, change) = select_utxos(&utxos, 11_000, 1_000).unwrap(); + assert_eq!(chosen.len(), 3); + assert_eq!(change, 3_000); + } + + #[test] + fn select_utxos_errors_when_insufficient() { + let utxos = vec![EsploraUtxo { + txid: "a".into(), + vout: 0, + value: 1_000, + }]; + let err = select_utxos(&utxos, 5_000, 1_000).unwrap_err(); + assert!(err.contains("insufficient"), "got: {err}"); + } + + #[tokio::test] + async fn execute_btc_quote_builds_psbt_signs_and_broadcasts() { + let _guard = TEST_LOCK.lock(); + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + reset_quote_store_for_tests(); + let temp = TempDir::new().unwrap(); + setup_wallet_in(&temp).await.unwrap(); + + // Mock state: collect raw tx hex posted to /tx. + let raw_txs: Arc>> = + Arc::new(parking_lot::Mutex::new(Vec::new())); + let raw_txs_clone = raw_txs.clone(); + let from_addr = sample_btc_address().to_string(); + // Real-shaped UTXO; value high enough to cover amount + fee. + let utxo_txid = "1111111111111111111111111111111111111111111111111111111111111111"; + let utxo_json = json!([ + { "txid": utxo_txid, "vout": 0, "value": 100_000u64 } + ]); + let utxo_clone = utxo_json.clone(); + let app = Router::new() + .route( + "/address/{addr}/utxo", + get(move || { + let body = utxo_clone.clone(); + async move { axum::Json(body) } + }), + ) + .route( + "/tx", + post(move |body: String| { + let raw_txs = raw_txs_clone.clone(); + async move { + raw_txs.lock().push(body); + // Return a known fake txid the test can assert on. + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + .to_string() + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + std::env::set_var("OPENHUMAN_WALLET_RPC_BTC", format!("http://{addr}")); + + let now = now_ms(); + let quote = PreparedTransaction { + quote_id: "q_btc_native_1".to_string(), + kind: PreparedKind::NativeTransfer, + chain: WalletChain::Btc, + evm_network: None, + from_address: from_addr.clone(), + to_address: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(), + asset_symbol: "BTC".to_string(), + amount_raw: "50000".to_string(), + amount_formatted: "0.00050000".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![], + }; + insert_quote_for_test(quote.clone()); + + let result = execute_btc_quote(quote).await.expect("btc broadcast ok"); + assert_eq!(result.status, PreparedStatus::Broadcasted); + assert_eq!( + result.transaction_hash, + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + ); + let raw = raw_txs.lock().clone(); + assert_eq!(raw.len(), 1, "exactly one broadcast call"); + let tx_hex = &raw[0]; + assert!(!tx_hex.is_empty(), "tx hex must be non-empty"); + // Witness-segwit transactions include the BIP141 marker+flag (0x0001). + assert!( + tx_hex.contains("0001"), + "expected segwit marker, got: {tx_hex}" + ); + } + + #[tokio::test] + async fn execute_btc_quote_rejects_insufficient_utxos() { + let _guard = TEST_LOCK.lock(); + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + reset_quote_store_for_tests(); + let temp = TempDir::new().unwrap(); + setup_wallet_in(&temp).await.unwrap(); + + // Empty UTXO set — must error. + let app = Router::new().route( + "/address/{addr}/utxo", + get(|| async { axum::Json(json!([])) }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + std::env::set_var("OPENHUMAN_WALLET_RPC_BTC", format!("http://{addr}")); + + let now = now_ms(); + let quote = PreparedTransaction { + quote_id: "q_btc_native_empty".to_string(), + kind: PreparedKind::NativeTransfer, + chain: WalletChain::Btc, + evm_network: None, + from_address: sample_btc_address().to_string(), + to_address: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(), + asset_symbol: "BTC".to_string(), + amount_raw: "50000".to_string(), + amount_formatted: "0.00050000".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![], + }; + let err = execute_btc_quote(quote).await.unwrap_err(); + assert!(err.contains("no spendable UTXOs"), "got: {err}"); + } + + #[test] + fn derive_btc_key_produces_known_p2wpkh_from_test_mnemonic() { + // BIP84 m/84'/0'/0'/0/0 from "abandon x11 about" → bc1qcr8... + // The compressed pubkey should serialize to 33 bytes. + let mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let (pk, pubkey) = derive_btc_private_key(mnemonic, "m/84'/0'/0'/0/0").unwrap(); + assert_eq!(pk.network, bitcoin::NetworkKind::Main); + assert_eq!(pubkey.to_bytes().len(), 33); + let secp = Secp256k1::signing_only(); + let addr = Address::p2wpkh(&pubkey, Network::Bitcoin); + // Known good vector for this mnemonic + path: + assert_eq!( + addr.to_string(), + "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + ); + let _ = secp; // suppress unused + } +} diff --git a/src/openhuman/wallet/chains/evm.rs b/src/openhuman/wallet/chains/evm.rs new file mode 100644 index 000000000..144b77088 --- /dev/null +++ b/src/openhuman/wallet/chains/evm.rs @@ -0,0 +1,186 @@ +//! EVM signing + broadcast (Ethereum mainnet + L2s: Base, Arbitrum, +//! Optimism, Polygon). Single key derivation path (`m/44'/60'/...`) — every +//! EVM network shares the same address. +//! +//! Network selection comes from `PreparedTransaction.evm_network`. If it's +//! `None` (legacy quotes or callers that didn't specify), Ethereum mainnet +//! is assumed. + +use std::str::FromStr; + +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; +use serde_json::json; + +use crate::openhuman::config::rpc as config_rpc; + +use super::super::abi::encode_erc20_transfer; +use super::super::defaults::{ + explorer_tx_url_for_evm_network, rpc_url_for_evm_network, EvmNetwork, +}; +use super::super::execution::{ + hex_to_bytes, hex_to_u256, u256_to_hex, ExecutionResult, PreparedKind, PreparedStatus, + PreparedTransaction, +}; +use super::super::ops::{secret_material, WalletChain}; +use super::super::rpc::{evm_rpc_call, rpc_call_to}; + +const LOG_PREFIX: &str = "[wallet::evm]"; + +pub async fn evm_balance(network: EvmNetwork, address: &str) -> Result { + let raw: String = evm_rpc_call(network, "eth_getBalance", json!([address, "latest"])).await?; + hex_to_u256(&raw) +} + +pub async fn execute_evm_quote(mut quote: PreparedTransaction) -> Result { + let network = quote.evm_network.unwrap_or(EvmNetwork::EthereumMainnet); + let rpc_url = rpc_url_for_evm_network(network); + 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_to(&rpc_url, "eth_chainId", json!([])).await?; + // Use "pending" so already-submitted-but-not-mined txs don't cause a + // nonce collision when two confirmations land back-to-back. + let nonce_hex: String = rpc_call_to( + &rpc_url, + "eth_getTransactionCount", + json!([quote.from_address, "pending"]), + ) + .await?; + let gas_price_hex: String = rpc_call_to(&rpc_url, "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_to(&rpc_url, "eth_estimateGas", json!([estimate_tx])).await?; + let chain_id = hex_to_u256(&chain_id_hex)?.as_u64(); + if chain_id != network.chain_id() { + return Err(format!( + "EVM RPC chain_id mismatch: rpc reported {} but network {} expects {}", + chain_id, + network.as_str(), + network.chain_id() + )); + } + 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_to(&rpc_url, "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={} network={} tx_hash={}", + quote.quote_id, + network.as_str(), + tx_hash + ); + Ok(ExecutionResult { + quote_id: quote.quote_id.clone(), + status: PreparedStatus::Broadcasted, + chain: WalletChain::Evm, + evm_network: Some(network), + transaction_hash: tx_hash.clone(), + explorer_url: explorer_tx_url_for_evm_network(network, &tx_hash), + transaction: quote, + }) +} + +pub fn validate_evm_address(addr: &str) -> Result { + let trimmed = addr.trim(); + if trimmed.is_empty() { + return Err("address is empty".to_string()); + } + Address::from_str(trimmed).map_err(|e| format!("invalid EVM address '{trimmed}': {e}"))?; + Ok(trimmed.to_string()) +} diff --git a/src/openhuman/wallet/chains/mod.rs b/src/openhuman/wallet/chains/mod.rs new file mode 100644 index 000000000..47f7491fb --- /dev/null +++ b/src/openhuman/wallet/chains/mod.rs @@ -0,0 +1,17 @@ +//! Per-chain wallet executors. Each submodule owns its own key derivation, +//! transaction encoding, signing, and broadcast against a JSON-RPC or REST +//! endpoint. The shared [`super::execution`] module is a thin dispatcher. +//! +//! All execute_* fns are async and return [`ExecutionResult`] on success. +//! All preparation logic (validation, fee estimate, quote storage) lives in +//! `execution.rs`; chain modules only run when a quote is being broadcast. +//! +//! Every chain exposes a small surface: +//! - `execute(quote: PreparedTransaction) -> Result` +//! - `native_balance(address: &str) -> Result` +//! - `validate_address(addr: &str) -> Result` + +pub(super) mod btc; +pub(super) mod evm; +pub(super) mod solana; +pub(super) mod tron; diff --git a/src/openhuman/wallet/chains/solana.rs b/src/openhuman/wallet/chains/solana.rs new file mode 100644 index 000000000..2e9c104b3 --- /dev/null +++ b/src/openhuman/wallet/chains/solana.rs @@ -0,0 +1,801 @@ +//! Solana native SOL + SPL token transfer. We hand-build the wire format so +//! we don't pull in `solana-sdk` (which transitively brings in ~150 crates). +//! +//! Key derivation: SLIP-0010 ed25519 (`m/44'/501'/0'/0'`). Solana mainnet +//! addresses are 32-byte ed25519 public keys, base58-encoded. +//! +//! Wire format references: +//! - https://docs.solana.com/developing/programming-model/transactions +//! - https://docs.solana.com/developing/programming-model/runtime#compact-u16 +//! - https://spl.solana.com/token + +use base64::engine::{general_purpose::STANDARD as B64, Engine as _}; +use curve25519_dalek::edwards::CompressedEdwardsY; +use ed25519_dalek::{Signer, SigningKey, SECRET_KEY_LENGTH}; +use hmac::{Hmac, Mac}; +use log::debug; +use serde::Deserialize; +use serde_json::json; +use sha2::{Digest, Sha256, Sha512}; + +use crate::openhuman::config::rpc as config_rpc; + +use super::super::defaults::explorer_tx_url; +use super::super::execution::{ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction}; +use super::super::ops::{secret_material, WalletChain}; +use super::super::rpc::rpc_call; + +const LOG_PREFIX: &str = "[wallet::sol]"; + +/// System Program ID (all zeros). +const SYSTEM_PROGRAM_ID: [u8; 32] = [0u8; 32]; + +fn token_program_id() -> [u8; 32] { + let mut out = [0u8; 32]; + let v = bs58::decode("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") + .into_vec() + .expect("static base58"); + out.copy_from_slice(&v); + out +} + +fn ata_program_id() -> [u8; 32] { + let mut out = [0u8; 32]; + let v = bs58::decode("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") + .into_vec() + .expect("static base58"); + out.copy_from_slice(&v); + out +} + +#[derive(Debug, Deserialize)] +struct BlockhashResponse { + value: BlockhashValue, +} + +#[derive(Debug, Deserialize)] +struct BlockhashValue { + blockhash: String, +} + +pub fn validate_solana_address(addr: &str) -> Result { + let trimmed = addr.trim(); + if trimmed.is_empty() { + return Err("Solana address is empty".to_string()); + } + let decoded = bs58::decode(trimmed) + .into_vec() + .map_err(|e| format!("invalid Solana base58 address '{trimmed}': {e}"))?; + if decoded.len() != 32 { + return Err(format!( + "invalid Solana address '{trimmed}': expected 32 bytes, got {}", + decoded.len() + )); + } + Ok(trimmed.to_string()) +} + +pub async fn native_balance(address: &str) -> Result { + validate_solana_address(address)?; + #[derive(Deserialize)] + struct BalanceResult { + value: u64, + } + let result: BalanceResult = + rpc_call(WalletChain::Solana, "getBalance", json!([address])).await?; + Ok(result.value as u128) +} + +/// SLIP-0010 ed25519 hardened-only derivation. Solana wallets standardize +/// on `m/44'/501'/N'/0'` so we never need to support non-hardened indices. +fn slip10_ed25519_derive(seed: &[u8], path: &[u32]) -> Result<[u8; 32], String> { + type HmacSha512 = Hmac; + let mut mac = HmacSha512::new_from_slice(b"ed25519 seed") + .map_err(|e| format!("HMAC init failed: {e}"))?; + mac.update(seed); + let i = mac.finalize().into_bytes(); + let mut key = [0u8; 32]; + let mut chain_code = [0u8; 32]; + key.copy_from_slice(&i[..32]); + chain_code.copy_from_slice(&i[32..]); + for index in path { + let hardened = *index | 0x8000_0000; + let mut mac = HmacSha512::new_from_slice(&chain_code) + .map_err(|e| format!("HMAC init failed: {e}"))?; + mac.update(&[0u8]); + mac.update(&key); + mac.update(&hardened.to_be_bytes()); + let i = mac.finalize().into_bytes(); + key.copy_from_slice(&i[..32]); + chain_code.copy_from_slice(&i[32..]); + } + Ok(key) +} + +fn parse_path(path: &str) -> Result, String> { + let trimmed = path.trim(); + let mut iter = trimmed.split('/'); + match iter.next() { + Some("m") => {} + _ => return Err(format!("Solana path '{path}' must start with 'm'")), + } + let mut out = Vec::new(); + for seg in iter { + let stripped = seg + .strip_suffix('\'') + .ok_or_else(|| format!("Solana path '{path}' requires all-hardened segments"))?; + let v: u32 = stripped + .parse() + .map_err(|e| format!("Solana path '{path}' segment '{seg}': {e}"))?; + out.push(v); + } + if out.is_empty() { + return Err(format!("Solana path '{path}' has no segments")); + } + Ok(out) +} + +fn derive_solana_keypair(mnemonic: &str, derivation_path: &str) -> Result { + use coins_bip39::{English, Mnemonic}; + let mnemonic_obj: Mnemonic = mnemonic + .trim() + .parse() + .map_err(|e| format!("invalid BIP39 mnemonic: {e}"))?; + let seed = mnemonic_obj + .to_seed(None) + .map_err(|e| format!("failed to derive BIP39 seed: {e}"))?; + let path = parse_path(derivation_path)?; + let secret = slip10_ed25519_derive(&seed, &path)?; + let bytes: [u8; SECRET_KEY_LENGTH] = secret; + Ok(SigningKey::from_bytes(&bytes)) +} + +/// Solana compact-u16 (shortvec) encoding. +fn encode_shortvec(value: u16) -> Vec { + let mut out = Vec::new(); + let mut v = value as u32; + loop { + let mut byte = (v & 0x7f) as u8; + v >>= 7; + if v == 0 { + out.push(byte); + return out; + } + byte |= 0x80; + out.push(byte); + } +} + +#[derive(Debug, Clone)] +struct CompiledInstruction { + program_id_index: u8, + accounts: Vec, + data: Vec, +} + +fn encode_compiled_instruction(ins: &CompiledInstruction) -> Vec { + let mut out = Vec::new(); + out.push(ins.program_id_index); + out.extend(encode_shortvec(ins.accounts.len() as u16)); + out.extend(&ins.accounts); + out.extend(encode_shortvec(ins.data.len() as u16)); + out.extend(&ins.data); + out +} + +fn encode_message( + header: [u8; 3], + account_keys: &[[u8; 32]], + recent_blockhash: &[u8; 32], + instructions: &[CompiledInstruction], +) -> Vec { + let mut out = Vec::new(); + out.extend(&header); + out.extend(encode_shortvec(account_keys.len() as u16)); + for key in account_keys { + out.extend(key); + } + out.extend(recent_blockhash); + out.extend(encode_shortvec(instructions.len() as u16)); + for ins in instructions { + out.extend(encode_compiled_instruction(ins)); + } + out +} + +/// Solana `find_program_address` — iterates a bump seed 255..=0, returning +/// the first off-curve PDA. Used to derive Associated Token Accounts. +fn find_program_address(seeds: &[&[u8]], program_id: &[u8; 32]) -> Result<([u8; 32], u8), String> { + let pda_marker = b"ProgramDerivedAddress"; + for bump in (0u8..=255).rev() { + let mut hasher = Sha256::new(); + for seed in seeds { + hasher.update(seed); + } + hasher.update([bump]); + hasher.update(program_id); + hasher.update(pda_marker); + let candidate: [u8; 32] = hasher.finalize().into(); + // Off-curve means it cannot be a public key. + if CompressedEdwardsY(candidate).decompress().is_none() { + return Ok((candidate, bump)); + } + } + Err("no off-curve PDA found".to_string()) +} + +pub fn associated_token_account(owner: &[u8; 32], mint: &[u8; 32]) -> Result<[u8; 32], String> { + let token_program = token_program_id(); + let ata_program = ata_program_id(); + let (pda, _bump) = + find_program_address(&[&owner[..], &token_program[..], &mint[..]], &ata_program)?; + Ok(pda) +} + +fn b58_to_pubkey(addr: &str) -> Result<[u8; 32], String> { + let v = bs58::decode(addr) + .into_vec() + .map_err(|e| format!("invalid base58 '{addr}': {e}"))?; + if v.len() != 32 { + return Err(format!("expected 32-byte pubkey, got {}", v.len())); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&v); + Ok(out) +} + +fn pubkey_to_b58(pubkey: &[u8; 32]) -> String { + bs58::encode(pubkey).into_string() +} + +fn build_native_transfer_message( + from: [u8; 32], + to: [u8; 32], + lamports: u64, + recent_blockhash: [u8; 32], +) -> Vec { + // accounts: [from (signer, writable), to (writable), system_program (read-only)] + let account_keys = vec![from, to, SYSTEM_PROGRAM_ID]; + // header: 1 required sig, 0 readonly signed, 1 readonly unsigned (system program) + let header = [1u8, 0u8, 1u8]; + let mut data = vec![2u8, 0u8, 0u8, 0u8]; // SystemInstruction::Transfer = 2 + data.extend(&lamports.to_le_bytes()); + let ins = CompiledInstruction { + program_id_index: 2, + accounts: vec![0, 1], + data, + }; + encode_message(header, &account_keys, &recent_blockhash, &[ins]) +} + +fn build_spl_transfer_message( + from_owner: [u8; 32], + src_ata: [u8; 32], + dst_ata: [u8; 32], + amount: u64, + recent_blockhash: [u8; 32], +) -> Vec { + let token_program = token_program_id(); + // accounts: + // 0: from_owner (signer, writable — to pay fee) + // 1: src_ata (writable) + // 2: dst_ata (writable) + // 3: token_program (readonly, unsigned) + let account_keys = vec![from_owner, src_ata, dst_ata, token_program]; + let header = [1u8, 0u8, 1u8]; + let mut data = vec![3u8]; // SPL Token instruction: Transfer = 3 + data.extend(&amount.to_le_bytes()); + let ins = CompiledInstruction { + program_id_index: 3, + accounts: vec![1, 2, 0], // src, dst, owner(signer) + data, + }; + encode_message(header, &account_keys, &recent_blockhash, &[ins]) +} + +/// Best-effort `getAccountInfo` check — returns `Ok(true)` when the account +/// exists, `Ok(false)` when the RPC reports `value: null`, or propagates the +/// transport error. +async fn account_exists(address_b58: &str) -> Result { + #[derive(Deserialize)] + struct AccountInfoResponse { + value: serde_json::Value, + } + let resp: AccountInfoResponse = rpc_call( + WalletChain::Solana, + "getAccountInfo", + json!([address_b58, {"encoding": "base64"}]), + ) + .await?; + Ok(!resp.value.is_null()) +} + +async fn fetch_recent_blockhash() -> Result<[u8; 32], String> { + let result: BlockhashResponse = rpc_call( + WalletChain::Solana, + "getLatestBlockhash", + json!([{"commitment": "finalized"}]), + ) + .await?; + b58_to_pubkey(&result.value.blockhash) +} + +async fn broadcast_solana(signed: &[u8]) -> Result { + let b64 = B64.encode(signed); + let tx_sig: String = rpc_call( + WalletChain::Solana, + "sendTransaction", + json!([b64, {"encoding": "base64", "preflightCommitment": "processed"}]), + ) + .await?; + Ok(tx_sig) +} + +pub async fn execute_solana_quote( + mut quote: PreparedTransaction, +) -> Result { + let from_addr = quote.from_address.clone(); + let to_addr = quote.to_address.clone(); + validate_solana_address(&from_addr)?; + validate_solana_address(&to_addr)?; + let amount: u64 = quote + .amount_raw + .parse() + .map_err(|e| format!("invalid Solana amount '{}': {e}", quote.amount_raw))?; + + let secret = secret_material(WalletChain::Solana).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 signing_key = derive_solana_keypair(&mnemonic, &secret.derivation_path)?; + let from_pk = signing_key.verifying_key().to_bytes(); + let expected_from = b58_to_pubkey(&from_addr)?; + if from_pk != expected_from { + return Err(format!( + "Solana key derivation mismatch: derived {} but expected {}", + pubkey_to_b58(&from_pk), + from_addr + )); + } + + let recent_blockhash = fetch_recent_blockhash().await?; + let to_pubkey = b58_to_pubkey(&to_addr)?; + + let message_bytes = match quote.kind { + PreparedKind::NativeTransfer => { + build_native_transfer_message(from_pk, to_pubkey, amount, recent_blockhash) + } + PreparedKind::TokenTransfer => { + let mint_addr = quote + .token_address + .as_deref() + .ok_or_else(|| "SPL transfer missing token_address (mint)".to_string())?; + let mint = b58_to_pubkey(mint_addr)?; + let src_ata = associated_token_account(&from_pk, &mint)?; + let dst_ata = associated_token_account(&to_pubkey, &mint)?; + // Preflight: refuse to send to a non-existent ATA so we don't + // burn the broadcast on a guaranteed on-chain failure. The + // caller (or a future PR) can prepend a CreateAssociatedTokenAccount + // instruction; for now we fail loudly with a clear message. + if !account_exists(&pubkey_to_b58(&dst_ata)).await? { + return Err(format!( + "SPL preflight: destination Associated Token Account does not exist for mint {} owner {}; create it before transferring", + mint_addr, + pubkey_to_b58(&to_pubkey) + )); + } + build_spl_transfer_message(from_pk, src_ata, dst_ata, amount, recent_blockhash) + } + other => { + return Err(format!( + "Solana execution only supports native + SPL transfers; got {other:?}" + )); + } + }; + + let signature = signing_key.sign(&message_bytes); + let sig_bytes = signature.to_bytes(); + let mut wire = Vec::with_capacity(1 + 64 + message_bytes.len()); + wire.extend(encode_shortvec(1)); + wire.extend(&sig_bytes); + wire.extend(&message_bytes); + + let tx_sig = broadcast_solana(&wire).await?; + quote.status = PreparedStatus::Broadcasted; + debug!( + "{LOG_PREFIX} broadcast quote_id={} sig={} kind={:?}", + quote.quote_id, tx_sig, quote.kind + ); + let explorer_url = explorer_tx_url(WalletChain::Solana, &tx_sig); + Ok(ExecutionResult { + quote_id: quote.quote_id.clone(), + status: PreparedStatus::Broadcasted, + chain: WalletChain::Solana, + evm_network: None, + transaction_hash: tx_sig, + explorer_url, + transaction: quote, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::wallet::execution::{ + insert_quote_for_test, now_ms, reset_quote_store_for_tests, PreparedKind, PreparedStatus, + PreparedTransaction, + }; + use crate::openhuman::wallet::test_support::{ + sample_solana_address, setup_wallet_in, TEST_LOCK, + }; + use axum::{routing::post, Router}; + use std::sync::Arc; + use tempfile::TempDir; + use tokio::net::TcpListener; + + #[test] + fn shortvec_encodes_small_and_large_values() { + assert_eq!(encode_shortvec(0), vec![0]); + assert_eq!(encode_shortvec(1), vec![1]); + assert_eq!(encode_shortvec(127), vec![127]); + assert_eq!(encode_shortvec(128), vec![0x80, 1]); + assert_eq!(encode_shortvec(16_383), vec![0xff, 0x7f]); + assert_eq!(encode_shortvec(16_384), vec![0x80, 0x80, 1]); + } + + #[test] + fn validate_solana_address_accepts_known_32_byte_pubkey() { + let addr = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"; + assert_eq!(validate_solana_address(addr).unwrap(), addr); + } + + #[test] + fn validate_solana_address_rejects_wrong_length() { + // "tooShort" decodes to ~6 bytes, not 32. + let err = validate_solana_address("tooShort").unwrap_err(); + assert!(err.contains("32 bytes"), "got: {err}"); + } + + #[test] + fn parse_path_requires_hardened_segments() { + assert!(parse_path("m/44'/501'/0'/0'").is_ok()); + assert!(parse_path("m/44/501/0/0").is_err()); + assert!(parse_path("m").is_err()); + } + + #[test] + fn derive_solana_keypair_produces_known_address_for_test_mnemonic() { + // SLIP-0010 ed25519 hardened derivation at m/44'/501'/0'/0' from the + // standard "abandon × 11 about" mnemonic. Deterministic output — + // pinned here so a regression in HMAC-SHA512 path traversal or seed + // derivation flips this test before it ships. + let mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let signing = derive_solana_keypair(mnemonic, "m/44'/501'/0'/0'").unwrap(); + let pk = signing.verifying_key().to_bytes(); + let addr = pubkey_to_b58(&pk); + assert_eq!(addr, "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk"); + validate_solana_address(&addr).expect("derived addr is 32-byte base58"); + } + + #[test] + fn native_transfer_message_round_trips_basic_structure() { + let from = [1u8; 32]; + let to = [2u8; 32]; + let bh = [3u8; 32]; + let msg = build_native_transfer_message(from, to, 1_000_000, bh); + // header is first 3 bytes. + assert_eq!(&msg[..3], &[1u8, 0u8, 1u8]); + // shortvec(3) = [3], then 3 keys = 96 bytes. + assert_eq!(msg[3], 3); + assert_eq!(&msg[4..36], &from); + assert_eq!(&msg[36..68], &to); + assert_eq!(&msg[68..100], &SYSTEM_PROGRAM_ID); + // blockhash next + assert_eq!(&msg[100..132], &bh); + // shortvec(1) instructions = [1] + assert_eq!(msg[132], 1); + // program_id_index = 2 (system program) + assert_eq!(msg[133], 2); + // shortvec(2) accounts = [2] + assert_eq!(msg[134], 2); + assert_eq!(msg[135], 0); // from + assert_eq!(msg[136], 1); // to + // shortvec(12) data length = [12] + assert_eq!(msg[137], 12); + // Transfer discriminator + 8 LE amount bytes + assert_eq!(&msg[138..142], &[2u8, 0u8, 0u8, 0u8]); + let amt = u64::from_le_bytes(msg[142..150].try_into().unwrap()); + assert_eq!(amt, 1_000_000); + } + + async fn start_solana_mock( + sig: &'static str, + ) -> ( + std::net::SocketAddr, + Arc>>, + ) { + let calls: Arc>> = + Arc::new(parking_lot::Mutex::new(Vec::new())); + let calls_clone = calls.clone(); + let app = Router::new().route( + "/", + post(move |axum::Json(payload): axum::Json| { + let calls = calls_clone.clone(); + async move { + calls.lock().push(payload.clone()); + let method = payload + .get("method") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let result = match method { + "getLatestBlockhash" => json!({ + "context": {"slot": 0}, + "value": { + "blockhash": "GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi", + "lastValidBlockHeight": 0u64 + } + }), + "getBalance" => json!({ + "context": {"slot": 0}, + "value": 1_000_000u64 + }), + "getAccountInfo" => json!({ + "context": {"slot": 0}, + "value": { + "lamports": 2_039_280u64, + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "data": ["", "base64"], + "executable": false, + "rentEpoch": 0u64 + } + }), + "sendTransaction" => serde_json::Value::String(sig.to_string()), + _ => serde_json::Value::Null, + }; + axum::Json(json!({"jsonrpc":"2.0","id":1,"result":result})) + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (addr, calls) + } + + #[tokio::test] + async fn execute_solana_quote_signs_and_broadcasts_native_transfer() { + let _guard = TEST_LOCK.lock(); + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + reset_quote_store_for_tests(); + let temp = TempDir::new().unwrap(); + setup_wallet_in(&temp).await.unwrap(); + + let fake_sig = "5xS9pXmqVz8R1nuRZTfsdsAxBdBFmtnAtuYbCsmK5DYzGn5vR4VqWGmiR5McLnYx8oFqLdo62q4qiUZpQyR4Hkn3"; + let (addr, calls) = start_solana_mock(fake_sig).await; + std::env::set_var("OPENHUMAN_WALLET_RPC_SOLANA", format!("http://{addr}")); + + let now = now_ms(); + let quote = PreparedTransaction { + quote_id: "q_sol_native_1".to_string(), + kind: PreparedKind::NativeTransfer, + chain: WalletChain::Solana, + evm_network: None, + from_address: sample_solana_address().to_string(), + to_address: "Vote111111111111111111111111111111111111111".to_string(), + asset_symbol: "SOL".to_string(), + amount_raw: "1000".to_string(), + amount_formatted: "0.000001000".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![], + }; + insert_quote_for_test(quote.clone()); + + let result = execute_solana_quote(quote) + .await + .expect("solana broadcast ok"); + assert_eq!(result.status, PreparedStatus::Broadcasted); + assert_eq!(result.transaction_hash, fake_sig); + // Two RPC calls: getLatestBlockhash + sendTransaction. + let recorded = calls.lock().clone(); + assert_eq!(recorded.len(), 2); + assert_eq!( + recorded[0].get("method").and_then(|v| v.as_str()), + Some("getLatestBlockhash") + ); + assert_eq!( + recorded[1].get("method").and_then(|v| v.as_str()), + Some("sendTransaction") + ); + } + + #[tokio::test] + async fn execute_solana_quote_signs_and_broadcasts_spl_transfer() { + let _guard = TEST_LOCK.lock(); + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + reset_quote_store_for_tests(); + let temp = TempDir::new().unwrap(); + setup_wallet_in(&temp).await.unwrap(); + + let fake_sig = "5xS9pXmqVz8R1nuRZTfsdsAxBdBFmtnAtuYbCsmK5DYzGn5vR4VqWGmiR5McLnYx8oFqLdo62q4qiUZpQyR4Hkn3"; + let (addr, calls) = start_solana_mock(fake_sig).await; + std::env::set_var("OPENHUMAN_WALLET_RPC_SOLANA", format!("http://{addr}")); + + let now = now_ms(); + let quote = PreparedTransaction { + quote_id: "q_sol_spl_1".to_string(), + kind: PreparedKind::TokenTransfer, + chain: WalletChain::Solana, + evm_network: None, + from_address: sample_solana_address().to_string(), + to_address: "Vote111111111111111111111111111111111111111".to_string(), + asset_symbol: "USDC".to_string(), + amount_raw: "1000000".to_string(), + amount_formatted: "1.000000".to_string(), + receive_symbol: None, + min_receive_raw: None, + calldata: None, + token_address: Some("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string()), + estimated_fee_raw: "5000".to_string(), + status: PreparedStatus::AwaitingConfirmation, + created_at_ms: now, + expires_at_ms: now + 60_000, + notes: vec![], + }; + insert_quote_for_test(quote.clone()); + + let result = execute_solana_quote(quote).await.expect("spl broadcast ok"); + assert_eq!(result.status, PreparedStatus::Broadcasted); + let recorded = calls.lock().clone(); + // SPL preflight calls getAccountInfo somewhere in the request set, plus + // getLatestBlockhash + sendTransaction. + assert_eq!(recorded.len(), 3); + assert!( + recorded + .iter() + .any(|c| c.get("method").and_then(|v| v.as_str()) == Some("getAccountInfo")), + "SPL preflight must call getAccountInfo" + ); + // The sendTransaction param[0] is base64-encoded signed tx; pull the + // base64 string and decode it to confirm it carries the SPL token + // program ID in its account_keys. + // sendTransaction is the last call after getAccountInfo + getLatestBlockhash. + let send_call = recorded + .iter() + .rev() + .find(|c| c.get("method").and_then(|v| v.as_str()) == Some("sendTransaction")) + .expect("sendTransaction call recorded"); + let params = send_call.get("params").and_then(|v| v.as_array()).unwrap(); + let tx_b64 = params[0].as_str().unwrap(); + let raw = B64.decode(tx_b64).expect("valid base64"); + // shortvec(1) signature + 64-byte sig + message + assert_eq!(raw[0], 1, "exactly one signature"); + let message = &raw[1 + 64..]; + // header (3) + shortvec(4) + 4*32 keys: token program must be one of them. + let token_program = token_program_id(); + assert!( + message.windows(32).any(|w| w == token_program), + "expected token program in account_keys" + ); + } + + #[tokio::test] + async fn execute_solana_quote_refuses_spl_when_destination_ata_missing() { + let _guard = TEST_LOCK.lock(); + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + reset_quote_store_for_tests(); + let temp = TempDir::new().unwrap(); + setup_wallet_in(&temp).await.unwrap(); + + // Custom mock that returns null for getAccountInfo — simulates an ATA + // that was never created on-chain. + let app = Router::new().route( + "/", + post( + |axum::Json(payload): axum::Json| async move { + let method = payload + .get("method") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let result = match method { + "getAccountInfo" => json!({"context": {"slot": 0}, "value": null}), + "getLatestBlockhash" => json!({ + "context": {"slot": 0}, + "value": { + "blockhash": "GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi", + "lastValidBlockHeight": 0u64 + } + }), + _ => serde_json::Value::Null, + }; + axum::Json(json!({"jsonrpc":"2.0","id":1,"result":result})) + }, + ), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + std::env::set_var("OPENHUMAN_WALLET_RPC_SOLANA", format!("http://{addr}")); + + let now = now_ms(); + let quote = PreparedTransaction { + quote_id: "q_sol_spl_missing_ata".to_string(), + kind: PreparedKind::TokenTransfer, + chain: WalletChain::Solana, + evm_network: None, + from_address: sample_solana_address().to_string(), + to_address: "Vote111111111111111111111111111111111111111".to_string(), + asset_symbol: "USDC".to_string(), + amount_raw: "1000000".to_string(), + amount_formatted: "1.000000".to_string(), + receive_symbol: None, + min_receive_raw: None, + calldata: None, + token_address: Some("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string()), + estimated_fee_raw: "5000".to_string(), + status: PreparedStatus::AwaitingConfirmation, + created_at_ms: now, + expires_at_ms: now + 60_000, + notes: vec![], + }; + insert_quote_for_test(quote.clone()); + + let err = execute_solana_quote(quote).await.unwrap_err(); + assert!( + err.contains("SPL preflight") + && err.contains("Associated Token Account does not exist"), + "got: {err}" + ); + } + + #[test] + fn associated_token_account_derives_off_curve_pda_for_usdc_mint() { + // find_program_address must produce an off-curve point (else it + // would be a valid pubkey, which violates the ATA program's + // contract). We verify two invariants: + // - derivation is deterministic for fixed (owner, mint) + // - result is off-curve (CompressedEdwardsY::decompress is None) + let owner = b58_to_pubkey("HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk").unwrap(); + let mint = b58_to_pubkey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v").unwrap(); + let ata_a = associated_token_account(&owner, &mint).unwrap(); + let ata_b = associated_token_account(&owner, &mint).unwrap(); + assert_eq!(ata_a, ata_b, "ATA derivation must be deterministic"); + assert!( + CompressedEdwardsY(ata_a).decompress().is_none(), + "ATA must be off-curve" + ); + } + + #[test] + fn spl_transfer_message_uses_token_program_and_correct_accounts() { + let from = [1u8; 32]; + let src = [2u8; 32]; + let dst = [3u8; 32]; + let bh = [4u8; 32]; + let msg = build_spl_transfer_message(from, src, dst, 42, bh); + // 4 account keys: from, src, dst, token_program + assert_eq!(msg[3], 4); + let token_program = token_program_id(); + let key3 = &msg[4 + 96..4 + 128]; + assert_eq!(key3, &token_program); + } +} diff --git a/src/openhuman/wallet/chains/tron.rs b/src/openhuman/wallet/chains/tron.rs new file mode 100644 index 000000000..86437ccf1 --- /dev/null +++ b/src/openhuman/wallet/chains/tron.rs @@ -0,0 +1,660 @@ +//! Tron native TRX + TRC20 token transfer. Uses the TronGrid REST API +//! (https://api.trongrid.io) — `wallet/createtransaction`, +//! `wallet/triggersmartcontract`, `wallet/broadcasttransaction`. +//! +//! Derivation: BIP44 m/44'/195'/0'/0/0 → secp256k1 key. Tron addresses are +//! `sha3_256(uncompressed_pubkey[1..])[12..]` prefixed with 0x41, base58check. + +use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; +use ethers_core::utils::keccak256; +use hmac::{Hmac, Mac}; +use log::debug; +use serde::Deserialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256, Sha512}; + +use crate::openhuman::config::rpc as config_rpc; + +use super::super::defaults::{explorer_tx_url, rpc_url_for_chain}; +use super::super::execution::{ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction}; +use super::super::ops::{secret_material, WalletChain}; +use super::super::rpc::rest_post_json; + +const LOG_PREFIX: &str = "[wallet::tron]"; +/// Tron address prefix (mainnet). +const TRON_PREFIX: u8 = 0x41; +/// Fixed TRC20 fee_limit (15 TRX = 15_000_000 SUN). Safe upper bound. +const TRC20_FEE_LIMIT_SUN: u64 = 15_000_000; + +pub fn validate_tron_address(addr: &str) -> Result { + let trimmed = addr.trim(); + if trimmed.is_empty() { + return Err("Tron address is empty".to_string()); + } + let bytes = bs58::decode(trimmed) + .with_check(Some(TRON_PREFIX)) + .into_vec() + .map_err(|e| format!("invalid Tron address '{trimmed}': {e}"))?; + if bytes.len() != 21 { + return Err(format!( + "invalid Tron address '{trimmed}': expected 21 bytes after base58check, got {}", + bytes.len() + )); + } + Ok(trimmed.to_string()) +} + +pub fn tron_address_to_hex(addr: &str) -> Result { + let bytes = bs58::decode(addr) + .with_check(Some(TRON_PREFIX)) + .into_vec() + .map_err(|e| format!("invalid Tron address '{addr}': {e}"))?; + Ok(hex::encode(&bytes)) +} + +pub async fn native_balance(address: &str) -> Result { + validate_tron_address(address)?; + let base = rpc_url_for_chain(WalletChain::Tron); + let url = format!("{}/wallet/getaccount", base.trim_end_matches('/')); + let body = json!({ + "address": tron_address_to_hex(address)?, + "visible": false, + }); + let resp: Value = rest_post_json(&url, &body).await?; + let balance = resp.get("balance").and_then(Value::as_u64).unwrap_or(0); + Ok(balance as u128) +} + +#[derive(Debug, Deserialize)] +struct CreateTransactionResponse { + #[serde(rename = "txID")] + tx_id: String, + raw_data: Value, + raw_data_hex: String, +} + +#[derive(Debug, Deserialize)] +struct TriggerSmartContractResponse { + transaction: CreateTransactionResponse, +} + +fn derive_tron_keypair( + mnemonic: &str, + derivation_path: &str, +) -> Result<(SecretKey, String), String> { + use coins_bip39::{English, Mnemonic}; + let mnemonic_obj: Mnemonic = mnemonic + .trim() + .parse() + .map_err(|e| format!("invalid BIP39 mnemonic: {e}"))?; + let seed = mnemonic_obj + .to_seed(None) + .map_err(|e| format!("failed to derive BIP39 seed: {e}"))?; + + // BIP32 derivation (secp256k1) — same algorithm as BTC, distinct from Solana. + type HmacSha512 = Hmac; + let mut mac = HmacSha512::new_from_slice(b"Bitcoin seed") + .map_err(|e| format!("HMAC init failed: {e}"))?; + mac.update(&seed); + let i = mac.finalize().into_bytes(); + let mut key = [0u8; 32]; + let mut chain_code = [0u8; 32]; + key.copy_from_slice(&i[..32]); + chain_code.copy_from_slice(&i[32..]); + + let segments = parse_bip32_path(derivation_path)?; + let secp = Secp256k1::signing_only(); + for (idx, hardened) in segments { + let mut mac = HmacSha512::new_from_slice(&chain_code) + .map_err(|e| format!("HMAC init failed: {e}"))?; + if hardened { + mac.update(&[0u8]); + mac.update(&key); + } else { + let sk = SecretKey::from_slice(&key).map_err(|e| format!("bad sk: {e}"))?; + let pk = sk.public_key(&secp); + mac.update(&pk.serialize()); + } + let composite = idx | if hardened { 0x8000_0000 } else { 0 }; + mac.update(&composite.to_be_bytes()); + let i = mac.finalize().into_bytes(); + let mut il = [0u8; 32]; + il.copy_from_slice(&i[..32]); + // child = (parent + IL) mod n + let mut child_sk = SecretKey::from_slice(&il).map_err(|e| format!("bad il: {e}"))?; + let parent = bitcoin::secp256k1::Scalar::from_be_bytes(key) + .map_err(|e| format!("scalar from key: {e}"))?; + child_sk = child_sk + .add_tweak(&parent) + .map_err(|e| format!("child tweak failed: {e}"))?; + key = child_sk.secret_bytes(); + chain_code.copy_from_slice(&i[32..]); + } + + let sk = SecretKey::from_slice(&key).map_err(|e| format!("bad final sk: {e}"))?; + let secp_all = Secp256k1::new(); + let pk = sk.public_key(&secp_all); + let uncompressed = pk.serialize_uncompressed(); + // Drop the 0x04 prefix, then keccak256 the 64-byte payload. + let hash = keccak256(&uncompressed[1..]); + // Tron address = TRON_PREFIX || hash[12..] + let mut addr_bytes = [0u8; 21]; + addr_bytes[0] = TRON_PREFIX; + addr_bytes[1..].copy_from_slice(&hash[12..]); + let address = bs58::encode(&addr_bytes).with_check().into_string(); + Ok((sk, address)) +} + +fn parse_bip32_path(path: &str) -> Result, String> { + let trimmed = path.trim(); + let mut iter = trimmed.split('/'); + match iter.next() { + Some("m") => {} + _ => return Err(format!("BIP32 path '{path}' must start with 'm'")), + } + let mut out = Vec::new(); + for seg in iter { + let (idx_str, hardened) = if let Some(stripped) = seg.strip_suffix('\'') { + (stripped, true) + } else { + (seg, false) + }; + let v: u32 = idx_str + .parse() + .map_err(|e| format!("BIP32 path '{path}' segment '{seg}': {e}"))?; + out.push((v, hardened)); + } + Ok(out) +} + +fn pad_left_32(bytes: &[u8]) -> Vec { + let mut out = vec![0u8; 32]; + if bytes.len() <= 32 { + out[32 - bytes.len()..].copy_from_slice(bytes); + } else { + out.copy_from_slice(&bytes[bytes.len() - 32..]); + } + out +} + +fn encode_trc20_transfer_param(to_hex: &str, amount: u128) -> Result { + // For TRC20 triggerSmartContract `parameter` field: hex-encoded ABI args + // (no 4-byte selector — TronGrid prepends it from `function_selector`). + // arg0: address (left-padded to 32 bytes, drop the 0x41 prefix → keep + // last 20 bytes of the hex address). + let addr_bytes = hex::decode(to_hex).map_err(|e| format!("invalid hex addr: {e}"))?; + if addr_bytes.len() != 21 { + return Err(format!( + "expected 21-byte Tron address, got {}", + addr_bytes.len() + )); + } + let mut param = vec![0u8; 32]; + param[12..].copy_from_slice(&addr_bytes[1..]); // skip the 0x41 prefix + let amount_bytes = amount.to_be_bytes(); + param.extend(pad_left_32(&amount_bytes[..])); + Ok(hex::encode(param)) +} + +async fn create_native_transaction( + owner_hex: &str, + to_hex: &str, + amount_sun: u64, +) -> Result { + let base = rpc_url_for_chain(WalletChain::Tron); + let url = format!("{}/wallet/createtransaction", base.trim_end_matches('/')); + let body = json!({ + "owner_address": owner_hex, + "to_address": to_hex, + "amount": amount_sun, + "visible": false, + }); + rest_post_json(&url, &body).await +} + +async fn trigger_trc20_transfer( + owner_hex: &str, + contract_hex: &str, + parameter_hex: &str, +) -> Result { + let base = rpc_url_for_chain(WalletChain::Tron); + let url = format!("{}/wallet/triggersmartcontract", base.trim_end_matches('/')); + let body = json!({ + "owner_address": owner_hex, + "contract_address": contract_hex, + "function_selector": "transfer(address,uint256)", + "parameter": parameter_hex, + "fee_limit": TRC20_FEE_LIMIT_SUN, + "call_value": 0, + "visible": false, + }); + let resp: TriggerSmartContractResponse = rest_post_json(&url, &body).await?; + Ok(resp.transaction) +} + +async fn broadcast_signed(tx_json: Value) -> Result { + let base = rpc_url_for_chain(WalletChain::Tron); + let url = format!("{}/wallet/broadcasttransaction", base.trim_end_matches('/')); + rest_post_json(&url, &tx_json).await +} + +pub async fn execute_tron_quote(mut quote: PreparedTransaction) -> Result { + validate_tron_address("e.from_address)?; + validate_tron_address("e.to_address)?; + let amount: u128 = quote + .amount_raw + .parse() + .map_err(|e| format!("invalid Tron amount '{}': {e}", quote.amount_raw))?; + + let owner_hex = tron_address_to_hex("e.from_address)?; + let to_hex = tron_address_to_hex("e.to_address)?; + + let secret = secret_material(WalletChain::Tron).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 (sk, derived_addr) = derive_tron_keypair(&mnemonic, &secret.derivation_path)?; + if derived_addr != quote.from_address { + return Err(format!( + "Tron key derivation mismatch: derived {derived_addr} but expected {}", + quote.from_address + )); + } + + let raw_tx = match quote.kind { + PreparedKind::NativeTransfer => { + let amount_sun: u64 = amount + .try_into() + .map_err(|_| format!("Tron amount {amount} exceeds u64"))?; + create_native_transaction(&owner_hex, &to_hex, amount_sun).await? + } + PreparedKind::TokenTransfer => { + let contract = quote + .token_address + .as_deref() + .ok_or_else(|| "TRC20 transfer missing token_address".to_string())?; + validate_tron_address(contract)?; + let contract_hex = tron_address_to_hex(contract)?; + let parameter = encode_trc20_transfer_param(&to_hex, amount)?; + trigger_trc20_transfer(&owner_hex, &contract_hex, ¶meter).await? + } + other => { + return Err(format!( + "Tron execution only supports native + TRC20 transfers; got {other:?}" + )); + } + }; + + // Tron signs sha256(raw_data_hex bytes). + let raw_bytes = hex::decode(&raw_tx.raw_data_hex) + .map_err(|e| format!("invalid raw_data_hex from Tron: {e}"))?; + let mut hasher = Sha256::new(); + hasher.update(&raw_bytes); + let hash: [u8; 32] = hasher.finalize().into(); + + let secp = Secp256k1::new(); + let msg = Message::from_digest(hash); + let sig = secp.sign_ecdsa_recoverable(&msg, &sk); + let (rec_id, compact) = sig.serialize_compact(); + let mut sig_bytes = [0u8; 65]; + sig_bytes[..64].copy_from_slice(&compact); + sig_bytes[64] = rec_id.to_i32() as u8; + let sig_hex = hex::encode(sig_bytes); + + let mut tx_with_sig = serde_json::to_value(&serde_json::json!({ + "txID": raw_tx.tx_id, + "raw_data": raw_tx.raw_data, + "raw_data_hex": raw_tx.raw_data_hex, + "signature": [sig_hex], + })) + .map_err(|e| format!("failed to build Tron signed tx: {e}"))?; + // visible: false flag for broadcast + tx_with_sig + .as_object_mut() + .expect("object") + .insert("visible".to_string(), Value::Bool(false)); + + let response = broadcast_signed(tx_with_sig).await?; + let ok = response + .get("result") + .and_then(Value::as_bool) + .unwrap_or(false); + if !ok { + let code = response.get("code").and_then(Value::as_str).unwrap_or(""); + let msg = response + .get("message") + .and_then(Value::as_str) + .unwrap_or_default(); + return Err(format!( + "Tron broadcast rejected: code={code} message={msg}" + )); + } + let txid = response + .get("txid") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| raw_tx.tx_id.clone()); + + quote.status = PreparedStatus::Broadcasted; + debug!( + "{LOG_PREFIX} broadcast quote_id={} txid={} kind={:?}", + quote.quote_id, txid, quote.kind + ); + let explorer_url = explorer_tx_url(WalletChain::Tron, &txid); + Ok(ExecutionResult { + quote_id: quote.quote_id.clone(), + status: PreparedStatus::Broadcasted, + chain: WalletChain::Tron, + evm_network: None, + transaction_hash: txid, + explorer_url, + transaction: quote, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::wallet::execution::{ + insert_quote_for_test, now_ms, reset_quote_store_for_tests, PreparedKind, PreparedStatus, + PreparedTransaction, + }; + use crate::openhuman::wallet::test_support::{sample_tron_address, setup_wallet_in, TEST_LOCK}; + use axum::{routing::post, Router}; + use std::sync::Arc; + use tempfile::TempDir; + use tokio::net::TcpListener; + + #[derive(Clone, Default)] + struct TronMockRecord { + create_calls: Arc>>, + trigger_calls: Arc>>, + broadcast_calls: Arc>>, + } + + async fn start_tron_mock(record: TronMockRecord) -> std::net::SocketAddr { + let create = record.create_calls.clone(); + let trigger = record.trigger_calls.clone(); + let broadcast = record.broadcast_calls.clone(); + // Fixed raw_data_hex shape — minimal but valid hex so sha256 + sign work. + let canned_tx = json!({ + "txID": "ab".repeat(32), + "raw_data": {"contract": []}, + "raw_data_hex": "0a02ab1d2208deadbeef00deadbe40c89efd8a82325802", + }); + let canned_tx_create = canned_tx.clone(); + let canned_tx_trigger = canned_tx.clone(); + let app = Router::new() + .route( + "/wallet/createtransaction", + post(move |axum::Json(payload): axum::Json| { + let create = create.clone(); + let canned = canned_tx_create.clone(); + async move { + create.lock().push(payload); + axum::Json(canned) + } + }), + ) + .route( + "/wallet/triggersmartcontract", + post(move |axum::Json(payload): axum::Json| { + let trigger = trigger.clone(); + let canned = canned_tx_trigger.clone(); + async move { + trigger.lock().push(payload); + axum::Json(json!({ "transaction": canned })) + } + }), + ) + .route( + "/wallet/broadcasttransaction", + post(move |axum::Json(payload): axum::Json| { + let broadcast = broadcast.clone(); + async move { + broadcast.lock().push(payload); + axum::Json(json!({ + "result": true, + "txid": "ab".repeat(32), + })) + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + addr + } + + #[tokio::test] + async fn execute_tron_quote_signs_and_broadcasts_native_transfer() { + let _guard = TEST_LOCK.lock(); + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + reset_quote_store_for_tests(); + let temp = TempDir::new().unwrap(); + setup_wallet_in(&temp).await.unwrap(); + + let record = TronMockRecord::default(); + let addr = start_tron_mock(record.clone()).await; + std::env::set_var("OPENHUMAN_WALLET_RPC_TRON", format!("http://{addr}")); + + let now = now_ms(); + let quote = PreparedTransaction { + quote_id: "q_tron_native_1".to_string(), + kind: PreparedKind::NativeTransfer, + chain: WalletChain::Tron, + evm_network: None, + from_address: sample_tron_address().to_string(), + to_address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".to_string(), + asset_symbol: "TRX".to_string(), + amount_raw: "1000000".to_string(), + amount_formatted: "1.000000".to_string(), + receive_symbol: None, + min_receive_raw: None, + calldata: None, + token_address: None, + estimated_fee_raw: "1000000".to_string(), + status: PreparedStatus::AwaitingConfirmation, + created_at_ms: now, + expires_at_ms: now + 60_000, + notes: vec![], + }; + insert_quote_for_test(quote.clone()); + + let result = execute_tron_quote(quote).await.expect("tron broadcast ok"); + assert_eq!(result.status, PreparedStatus::Broadcasted); + assert_eq!(result.transaction_hash, "ab".repeat(32)); + assert_eq!(record.create_calls.lock().len(), 1); + assert_eq!(record.trigger_calls.lock().len(), 0); + assert_eq!(record.broadcast_calls.lock().len(), 1); + // Signed broadcast carries a 65-byte signature (hex = 130 chars). + let payload = record.broadcast_calls.lock()[0].clone(); + let sig = payload + .get("signature") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|v| v.as_str()) + .unwrap(); + assert_eq!(sig.len(), 130, "expected 65-byte signature, got: {sig}"); + } + + #[tokio::test] + async fn execute_tron_quote_signs_and_broadcasts_trc20_transfer() { + let _guard = TEST_LOCK.lock(); + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + reset_quote_store_for_tests(); + let temp = TempDir::new().unwrap(); + setup_wallet_in(&temp).await.unwrap(); + + let record = TronMockRecord::default(); + let addr = start_tron_mock(record.clone()).await; + std::env::set_var("OPENHUMAN_WALLET_RPC_TRON", format!("http://{addr}")); + + let now = now_ms(); + let quote = PreparedTransaction { + quote_id: "q_tron_trc20_1".to_string(), + kind: PreparedKind::TokenTransfer, + chain: WalletChain::Tron, + evm_network: None, + from_address: sample_tron_address().to_string(), + to_address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".to_string(), + asset_symbol: "USDT".to_string(), + amount_raw: "5000000".to_string(), + amount_formatted: "5.000000".to_string(), + receive_symbol: None, + min_receive_raw: None, + calldata: None, + token_address: Some("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".to_string()), + estimated_fee_raw: "15000000".to_string(), + status: PreparedStatus::AwaitingConfirmation, + created_at_ms: now, + expires_at_ms: now + 60_000, + notes: vec![], + }; + insert_quote_for_test(quote.clone()); + + let result = execute_tron_quote(quote).await.expect("trc20 broadcast ok"); + assert_eq!(result.status, PreparedStatus::Broadcasted); + assert_eq!(record.create_calls.lock().len(), 0); + assert_eq!(record.trigger_calls.lock().len(), 1); + assert_eq!(record.broadcast_calls.lock().len(), 1); + // The triggersmartcontract payload must carry the ABI parameter and + // selector for transfer(address,uint256). + let trigger = record.trigger_calls.lock()[0].clone(); + assert_eq!( + trigger.get("function_selector").and_then(|v| v.as_str()), + Some("transfer(address,uint256)") + ); + let param = trigger.get("parameter").and_then(|v| v.as_str()).unwrap(); + assert_eq!(param.len(), 128, "64-byte ABI args, hex-encoded"); + } + + #[tokio::test] + async fn execute_tron_quote_surfaces_node_rejection() { + let _guard = TEST_LOCK.lock(); + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + reset_quote_store_for_tests(); + let temp = TempDir::new().unwrap(); + setup_wallet_in(&temp).await.unwrap(); + + // Custom mock returning result=false on broadcast. + let app = Router::new() + .route( + "/wallet/createtransaction", + post(|| async { + axum::Json(json!({ + "txID": "cd".repeat(32), + "raw_data": {"contract": []}, + "raw_data_hex": "0a02ab1d2208deadbeef00deadbe40c89efd8a82325802", + })) + }), + ) + .route( + "/wallet/broadcasttransaction", + post(|| async { + axum::Json(json!({ + "result": false, + "code": "BANDWIDTH_ERROR", + "message": "not enough bandwidth", + })) + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + std::env::set_var("OPENHUMAN_WALLET_RPC_TRON", format!("http://{addr}")); + + let now = now_ms(); + let quote = PreparedTransaction { + quote_id: "q_tron_reject_1".to_string(), + kind: PreparedKind::NativeTransfer, + chain: WalletChain::Tron, + evm_network: None, + from_address: sample_tron_address().to_string(), + to_address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".to_string(), + asset_symbol: "TRX".to_string(), + amount_raw: "1000000".to_string(), + amount_formatted: "1.000000".to_string(), + receive_symbol: None, + min_receive_raw: None, + calldata: None, + token_address: None, + estimated_fee_raw: "1000000".to_string(), + status: PreparedStatus::AwaitingConfirmation, + created_at_ms: now, + expires_at_ms: now + 60_000, + notes: vec![], + }; + let err = execute_tron_quote(quote).await.unwrap_err(); + assert!(err.contains("BANDWIDTH_ERROR"), "got: {err}"); + } + + #[test] + fn validate_tron_address_accepts_known_address() { + // USDT TRC20 contract address — real mainnet, valid base58check. + let addr = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + assert_eq!(validate_tron_address(addr).unwrap(), addr); + } + + #[test] + fn validate_tron_address_rejects_btc_format() { + let err = validate_tron_address("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4").unwrap_err(); + assert!(err.contains("invalid"), "got: {err}"); + } + + #[test] + fn tron_address_to_hex_roundtrips_prefix_byte() { + let addr = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + let h = tron_address_to_hex(addr).unwrap(); + assert!(h.starts_with("41"), "expected 0x41 prefix, got: {h}"); + assert_eq!(h.len(), 42); // 21 bytes * 2 hex chars + } + + #[test] + fn derive_tron_address_for_known_test_mnemonic() { + // BIP44 m/44'/195'/0'/0/0 from the standard "abandon × 11 about" mnemonic. + // Deterministic output of our SLIP-44 / secp256k1 / keccak256 / base58check + // pipeline — pinning here so regressions in any of those primitives are caught. + let mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let (_sk, addr) = derive_tron_keypair(mnemonic, "m/44'/195'/0'/0/0").unwrap(); + assert_eq!(addr, "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH"); + // Address must be a valid base58check 0x41 mainnet address. + validate_tron_address(&addr).expect("derived addr passes validation"); + } + + #[test] + fn encode_trc20_transfer_param_pads_addr_and_amount() { + let to_hex = tron_address_to_hex("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t").unwrap(); + let param = encode_trc20_transfer_param(&to_hex, 12345).unwrap(); + // 64 bytes hex = 32 bytes addr param + 32 bytes amount param = 128 hex chars. + assert_eq!(param.len(), 128); + // First 12 bytes = 24 hex chars zero-padded. + assert!( + param.starts_with("000000000000000000000000"), + "expected 12-byte zero padding, got: {param}" + ); + // Amount 12345 = 0x3039 → last 8 hex chars should be "00003039". + assert!(param.ends_with("00003039"), "got: {param}"); + } + + #[test] + fn pad_left_32_zero_pads_short_input() { + let p = pad_left_32(&[1, 2, 3]); + assert_eq!(p.len(), 32); + assert_eq!(&p[..29], &[0u8; 29]); + assert_eq!(&p[29..], &[1, 2, 3]); + } +} diff --git a/src/openhuman/wallet/defaults.rs b/src/openhuman/wallet/defaults.rs index 68586627c..97ddced9f 100644 --- a/src/openhuman/wallet/defaults.rs +++ b/src/openhuman/wallet/defaults.rs @@ -1,17 +1,123 @@ -use serde::Serialize; +//! Wallet defaults — RPC URLs, env-var overrides, explorer URLs, and +//! per-asset catalogs. EVM is parameterized by [`EvmNetwork`] so the same +//! `WalletChain::Evm` variant covers Ethereum mainnet plus L2s (Base, +//! Arbitrum, Optimism, Polygon). + +use serde::{Deserialize, 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 BASESCAN_TX_BASE: &str = "https://basescan.org/tx/"; +const ARBISCAN_TX_BASE: &str = "https://arbiscan.io/tx/"; +const OPTIMISTIC_TX_BASE: &str = "https://optimistic.etherscan.io/tx/"; +const POLYGONSCAN_TX_BASE: &str = "https://polygonscan.com/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/"; +const DEFAULT_BTC_REST_URL: &str = "https://blockstream.info/api"; +const DEFAULT_SOLANA_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; +const DEFAULT_TRON_REST_URL: &str = "https://api.trongrid.io"; + +/// Recognized EVM networks. New L2s plug in here. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum EvmNetwork { + EthereumMainnet, + BaseMainnet, + ArbitrumOne, + OptimismMainnet, + PolygonMainnet, +} + +impl EvmNetwork { + pub const ALL: [Self; 5] = [ + Self::EthereumMainnet, + Self::BaseMainnet, + Self::ArbitrumOne, + Self::OptimismMainnet, + Self::PolygonMainnet, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Self::EthereumMainnet => "ethereum_mainnet", + Self::BaseMainnet => "base_mainnet", + Self::ArbitrumOne => "arbitrum_one", + Self::OptimismMainnet => "optimism_mainnet", + Self::PolygonMainnet => "polygon_mainnet", + } + } + + pub fn chain_id(self) -> u64 { + match self { + Self::EthereumMainnet => 1, + Self::BaseMainnet => 8453, + Self::ArbitrumOne => 42161, + Self::OptimismMainnet => 10, + Self::PolygonMainnet => 137, + } + } + + pub fn rpc_env_var(self) -> &'static str { + match self { + Self::EthereumMainnet => "OPENHUMAN_WALLET_RPC_EVM", + Self::BaseMainnet => "OPENHUMAN_WALLET_RPC_BASE", + Self::ArbitrumOne => "OPENHUMAN_WALLET_RPC_ARBITRUM", + Self::OptimismMainnet => "OPENHUMAN_WALLET_RPC_OPTIMISM", + Self::PolygonMainnet => "OPENHUMAN_WALLET_RPC_POLYGON", + } + } + + pub fn default_rpc_url(self) -> &'static str { + match self { + Self::EthereumMainnet => "https://ethereum-rpc.publicnode.com", + Self::BaseMainnet => "https://mainnet.base.org", + Self::ArbitrumOne => "https://arb1.arbitrum.io/rpc", + Self::OptimismMainnet => "https://mainnet.optimism.io", + Self::PolygonMainnet => "https://polygon-rpc.com", + } + } + + pub fn explorer_tx_base(self) -> &'static str { + match self { + Self::EthereumMainnet => ETHERSCAN_TX_BASE, + Self::BaseMainnet => BASESCAN_TX_BASE, + Self::ArbitrumOne => ARBISCAN_TX_BASE, + Self::OptimismMainnet => OPTIMISTIC_TX_BASE, + Self::PolygonMainnet => POLYGONSCAN_TX_BASE, + } + } + + pub fn network_label(self) -> &'static str { + match self { + Self::EthereumMainnet => "ethereum-mainnet", + Self::BaseMainnet => "base-mainnet", + Self::ArbitrumOne => "arbitrum-one", + Self::OptimismMainnet => "optimism-mainnet", + Self::PolygonMainnet => "polygon-mainnet", + } + } + + pub fn rpc_url(self) -> String { + std::env::var(self.rpc_env_var()) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| self.default_rpc_url().to_string()) + } + + pub fn rpc_source(self) -> RpcSource { + let raw = std::env::var(self.rpc_env_var()).unwrap_or_default(); + if raw.trim().is_empty() { + RpcSource::Default + } else { + RpcSource::EnvOverride + } + } +} + #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum RpcSource { @@ -23,6 +129,8 @@ pub enum RpcSource { #[serde(rename_all = "camelCase")] pub struct WalletAssetDefinition { pub chain: WalletChain, + #[serde(skip_serializing_if = "Option::is_none")] + pub evm_network: Option, pub symbol: String, pub name: String, pub native: bool, @@ -35,6 +143,8 @@ pub struct WalletAssetDefinition { #[serde(rename_all = "camelCase")] pub struct WalletNetworkDefaults { pub chain: WalletChain, + #[serde(skip_serializing_if = "Option::is_none")] + pub evm_network: Option, pub network: String, #[serde(skip_serializing_if = "Option::is_none")] pub chain_id: Option, @@ -49,24 +159,48 @@ pub struct WalletNetworkDefaults { pub fn default_rpc_url(chain: WalletChain) -> &'static str { match chain { - WalletChain::Evm => DEFAULT_EVM_RPC_URL, - WalletChain::Btc => DEFAULT_BTC_RPC_URL, + WalletChain::Evm => EvmNetwork::EthereumMainnet.default_rpc_url(), + WalletChain::Btc => DEFAULT_BTC_REST_URL, WalletChain::Solana => DEFAULT_SOLANA_RPC_URL, - WalletChain::Tron => DEFAULT_TRON_RPC_URL, + WalletChain::Tron => DEFAULT_TRON_REST_URL, } } pub fn rpc_url_for_chain(chain: WalletChain) -> String { - std::env::var(env_var_for_chain(chain)) + match chain { + WalletChain::Evm => EvmNetwork::EthereumMainnet.rpc_url(), + WalletChain::Btc => env_or_default("OPENHUMAN_WALLET_RPC_BTC", DEFAULT_BTC_REST_URL), + WalletChain::Solana => { + env_or_default("OPENHUMAN_WALLET_RPC_SOLANA", DEFAULT_SOLANA_RPC_URL) + } + WalletChain::Tron => env_or_default("OPENHUMAN_WALLET_RPC_TRON", DEFAULT_TRON_REST_URL), + } +} + +pub fn rpc_url_for_evm_network(network: EvmNetwork) -> String { + network.rpc_url() +} + +fn env_or_default(env_var: &str, default: &str) -> String { + std::env::var(env_var) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) - .unwrap_or_else(|| default_rpc_url(chain).to_string()) + .unwrap_or_else(|| default.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() { + match chain { + WalletChain::Evm => EvmNetwork::EthereumMainnet.rpc_source(), + WalletChain::Btc => env_or_source("OPENHUMAN_WALLET_RPC_BTC"), + WalletChain::Solana => env_or_source("OPENHUMAN_WALLET_RPC_SOLANA"), + WalletChain::Tron => env_or_source("OPENHUMAN_WALLET_RPC_TRON"), + } +} + +fn env_or_source(env_var: &str) -> RpcSource { + let raw = std::env::var(env_var).unwrap_or_default(); + if raw.trim().is_empty() { RpcSource::Default } else { RpcSource::EnvOverride @@ -75,7 +209,7 @@ pub fn rpc_source_for_chain(chain: WalletChain) -> RpcSource { pub fn explorer_tx_url(chain: WalletChain, tx_hash: &str) -> Option { let base = match chain { - WalletChain::Evm => ETHERSCAN_TX_BASE, + WalletChain::Evm => EvmNetwork::EthereumMainnet.explorer_tx_base(), WalletChain::Btc => BLOCKSTREAM_TX_BASE, WalletChain::Solana => SOLSCAN_TX_BASE, WalletChain::Tron => TRONSCAN_TX_BASE, @@ -83,6 +217,10 @@ pub fn explorer_tx_url(chain: WalletChain, tx_hash: &str) -> Option { Some(format!("{base}{tx_hash}")) } +pub fn explorer_tx_url_for_evm_network(network: EvmNetwork, tx_hash: &str) -> Option { + Some(format!("{}{}", network.explorer_tx_base(), tx_hash)) +} + pub fn env_var_for_chain(chain: WalletChain) -> &'static str { match chain { WalletChain::Evm => "OPENHUMAN_WALLET_RPC_EVM", @@ -94,25 +232,96 @@ pub fn env_var_for_chain(chain: WalletChain) -> &'static str { pub fn asset_catalog(chain: WalletChain) -> Vec { match chain { - WalletChain::Evm => vec![ + WalletChain::Evm => evm_asset_catalog(EvmNetwork::EthereumMainnet), + WalletChain::Btc => vec![WalletAssetDefinition { + chain, + evm_network: None, + symbol: "BTC".to_string(), + name: "Bitcoin".to_string(), + native: true, + decimals: 8, + contract_address: None, + }], + WalletChain::Solana => vec![ WalletAssetDefinition { chain, - symbol: "ETH".to_string(), - name: "Ether".to_string(), + evm_network: None, + symbol: "SOL".to_string(), + name: "Solana".to_string(), native: true, - decimals: 18, + decimals: 9, contract_address: None, }, WalletAssetDefinition { chain, + evm_network: None, symbol: "USDC".to_string(), - name: "USD Coin".to_string(), + name: "USD Coin (Solana)".to_string(), native: false, decimals: 6, - contract_address: Some("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string()), + contract_address: Some("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string()), + }, + ], + WalletChain::Tron => vec![ + WalletAssetDefinition { + chain, + evm_network: None, + symbol: "TRX".to_string(), + name: "Tron".to_string(), + native: true, + decimals: 6, + contract_address: None, }, WalletAssetDefinition { chain, + evm_network: None, + symbol: "USDT".to_string(), + name: "Tether USD (TRC20)".to_string(), + native: false, + decimals: 6, + contract_address: Some("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".to_string()), + }, + ], + } +} + +pub fn evm_asset_catalog(network: EvmNetwork) -> Vec { + let mut assets = vec![WalletAssetDefinition { + chain: WalletChain::Evm, + evm_network: Some(network), + symbol: "ETH".to_string(), + name: if network == EvmNetwork::PolygonMainnet { + "Polygon (MATIC)".to_string() + } else { + "Ether".to_string() + }, + native: true, + decimals: 18, + contract_address: None, + }]; + // Per-L2 USDC native addresses. + if let Some(usdc) = match network { + EvmNetwork::EthereumMainnet => Some("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + EvmNetwork::BaseMainnet => Some("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"), + EvmNetwork::ArbitrumOne => Some("0xaf88d065e77c8cC2239327C5EDb3A432268e5831"), + EvmNetwork::OptimismMainnet => Some("0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85"), + EvmNetwork::PolygonMainnet => Some("0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359"), + } { + assets.push(WalletAssetDefinition { + chain: WalletChain::Evm, + evm_network: Some(network), + symbol: "USDC".to_string(), + name: "USD Coin".to_string(), + native: false, + decimals: 6, + contract_address: Some(usdc.to_string()), + }); + } + if matches!(network, EvmNetwork::EthereumMainnet) { + assets.extend([ + WalletAssetDefinition { + chain: WalletChain::Evm, + evm_network: Some(network), symbol: "USDT".to_string(), name: "Tether USD".to_string(), native: false, @@ -120,7 +329,8 @@ pub fn asset_catalog(chain: WalletChain) -> Vec { contract_address: Some("0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string()), }, WalletAssetDefinition { - chain, + chain: WalletChain::Evm, + evm_network: Some(network), symbol: "DAI".to_string(), name: "Dai".to_string(), native: false, @@ -128,81 +338,81 @@ pub fn asset_catalog(chain: WalletChain) -> Vec { contract_address: Some("0x6B175474E89094C44Da98b954EedeAC495271d0F".to_string()), }, WalletAssetDefinition { - chain, + chain: WalletChain::Evm, + evm_network: Some(network), 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, - }], + ]); } + assets } 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() + let mut out = Vec::new(); + for network in EvmNetwork::ALL { + out.push(WalletNetworkDefaults { + chain: WalletChain::Evm, + evm_network: Some(network), + network: network.network_label().to_string(), + chain_id: Some(network.chain_id()), + rpc_url: network.rpc_url(), + rpc_source: network.rpc_source(), + explorer_tx_url_base: network.explorer_tx_base().to_string(), + supports_broadcast: true, + supports_token_transfers: true, + supports_contract_calls: true, + assets: evm_asset_catalog(network), + }); + } + for chain in [WalletChain::Btc, WalletChain::Solana, WalletChain::Tron] { + out.push(WalletNetworkDefaults { + chain, + evm_network: None, + network: match chain { + WalletChain::Btc => "bitcoin-mainnet".to_string(), + WalletChain::Solana => "solana-mainnet-beta".to_string(), + WalletChain::Tron => "tron-mainnet".to_string(), + WalletChain::Evm => unreachable!(), + }, + chain_id: None, + rpc_url: rpc_url_for_chain(chain), + rpc_source: rpc_source_for_chain(chain), + explorer_tx_url_base: match chain { + WalletChain::Btc => BLOCKSTREAM_TX_BASE, + WalletChain::Solana => SOLSCAN_TX_BASE, + WalletChain::Tron => TRONSCAN_TX_BASE, + WalletChain::Evm => unreachable!(), + } + .to_string(), + supports_broadcast: true, + supports_token_transfers: !matches!(chain, WalletChain::Btc), + supports_contract_calls: false, + assets: asset_catalog(chain), + }); + } + out } pub fn find_asset(chain: WalletChain, symbol: &str) -> Option { + find_asset_for_network(chain, None, symbol) +} + +pub fn find_asset_for_network( + chain: WalletChain, + network: Option, + symbol: &str, +) -> Option { let needle = symbol.trim(); - asset_catalog(chain) + let catalog = match (chain, network) { + (WalletChain::Evm, Some(net)) => evm_asset_catalog(net), + (WalletChain::Evm, None) => evm_asset_catalog(EvmNetwork::EthereumMainnet), + (other, _) => asset_catalog(other), + }; + catalog .into_iter() .find(|asset| asset.symbol.eq_ignore_ascii_case(needle)) } @@ -219,4 +429,42 @@ mod tests { .iter() .any(|asset| asset.symbol == "ETH" && asset.native)); } + + #[test] + fn base_network_resolves_chain_id_8453() { + assert_eq!(EvmNetwork::BaseMainnet.chain_id(), 8453); + let catalog = evm_asset_catalog(EvmNetwork::BaseMainnet); + let usdc = catalog + .iter() + .find(|asset| asset.symbol == "USDC") + .expect("Base USDC present"); + assert_eq!( + usdc.contract_address.as_deref(), + Some("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913") + ); + } + + #[test] + fn network_defaults_lists_all_evm_networks_and_three_other_chains() { + let defaults = network_defaults(); + let evm_count = defaults + .iter() + .filter(|d| d.chain == WalletChain::Evm) + .count(); + assert_eq!(evm_count, EvmNetwork::ALL.len()); + for chain in [WalletChain::Btc, WalletChain::Solana, WalletChain::Tron] { + assert!( + defaults.iter().any(|d| d.chain == chain), + "missing default entry for {chain:?}" + ); + } + } + + #[test] + fn find_asset_for_network_finds_base_usdc() { + let usdc = find_asset_for_network(WalletChain::Evm, Some(EvmNetwork::BaseMainnet), "usdc") + .expect("base usdc lookup"); + assert_eq!(usdc.decimals, 6); + assert_eq!(usdc.evm_network, Some(EvmNetwork::BaseMainnet)); + } } diff --git a/src/openhuman/wallet/execution.rs b/src/openhuman/wallet/execution.rs index cc61a5e52..42333636c 100644 --- a/src/openhuman/wallet/execution.rs +++ b/src/openhuman/wallet/execution.rs @@ -5,33 +5,29 @@ //! 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. +//! - EVM (Ethereum + Base/Arbitrum/Optimism/Polygon L2s), Bitcoin (P2WPKH), +//! Solana (native + SPL), and Tron (native + TRC20) all sign and broadcast. +//! Swap broadcast is still quote-only on every chain. 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 ethers_core::types::{Address, U256}; 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::abi::encode_erc20_transfer; +use super::chains::{btc as chain_btc, evm as chain_evm, solana as chain_sol, tron as chain_tron}; use super::defaults::{ - explorer_tx_url, find_asset, network_defaults as default_networks, rpc_url_for_chain, - WalletAssetDefinition, WalletNetworkDefaults, + evm_asset_catalog, explorer_tx_url, find_asset_for_network, + network_defaults as default_networks, rpc_url_for_chain, EvmNetwork, WalletAssetDefinition, + WalletNetworkDefaults, }; -use super::ops::{secret_material, status as wallet_status, WalletAccount, WalletChain}; -use super::rpc::rpc_call; +use super::ops::{status as wallet_status, WalletAccount, WalletChain}; const LOG_PREFIX: &str = "[wallet]"; const QUOTE_TTL_MS: u64 = 5 * 60 * 1000; @@ -44,6 +40,8 @@ static QUOTE_COUNTER: AtomicU64 = AtomicU64::new(1); #[serde(rename_all = "camelCase")] pub struct ChainStatus { pub chain: WalletChain, + #[serde(skip_serializing_if = "Option::is_none")] + pub evm_network: Option, pub configured: bool, pub provider_status: ProviderStatus, pub rpc_url: String, @@ -60,6 +58,8 @@ pub enum ProviderStatus { #[serde(rename_all = "camelCase")] pub struct SupportedAsset { pub chain: WalletChain, + #[serde(skip_serializing_if = "Option::is_none")] + pub evm_network: Option, pub symbol: String, pub name: String, pub native: bool, @@ -72,6 +72,8 @@ pub struct SupportedAsset { #[serde(rename_all = "camelCase")] pub struct BalanceInfo { pub chain: WalletChain, + #[serde(skip_serializing_if = "Option::is_none")] + pub evm_network: Option, pub address: String, pub asset_symbol: String, pub decimals: u8, @@ -103,6 +105,8 @@ pub struct PreparedTransaction { pub quote_id: String, pub kind: PreparedKind, pub chain: WalletChain, + #[serde(skip_serializing_if = "Option::is_none")] + pub evm_network: Option, pub from_address: String, pub to_address: String, pub asset_symbol: String, @@ -129,6 +133,8 @@ pub struct ExecutionResult { pub quote_id: String, pub status: PreparedStatus, pub chain: WalletChain, + #[serde(skip_serializing_if = "Option::is_none")] + pub evm_network: Option, pub transaction_hash: String, #[serde(skip_serializing_if = "Option::is_none")] pub explorer_url: Option, @@ -143,6 +149,8 @@ pub struct PrepareTransferParams { pub amount_raw: String, #[serde(default)] pub asset_symbol: Option, + #[serde(default)] + pub evm_network: Option, } #[derive(Debug, Deserialize)] @@ -154,6 +162,8 @@ pub struct PrepareSwapParams { pub amount_in_raw: String, pub slippage_bps: u32, pub router_address: String, + #[serde(default)] + pub evm_network: Option, } #[derive(Debug, Deserialize)] @@ -164,6 +174,8 @@ pub struct PrepareContractCallParams { pub calldata: String, #[serde(default = "zero_string")] pub value_raw: String, + #[serde(default)] + pub evm_network: Option, } #[derive(Debug, Deserialize)] @@ -177,7 +189,7 @@ fn zero_string() -> String { "0".to_string() } -fn now_ms() -> u64 { +pub(crate) fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) @@ -201,7 +213,7 @@ async fn require_account(chain: WalletChain) -> Result { .ok_or_else(|| format!("no wallet account derived for chain '{}'", chain_str(chain))) } -fn chain_str(chain: WalletChain) -> &'static str { +pub(crate) fn chain_str(chain: WalletChain) -> &'static str { match chain { WalletChain::Evm => "evm", WalletChain::Btc => "btc", @@ -210,7 +222,7 @@ fn chain_str(chain: WalletChain) -> &'static str { } } -fn validate_amount(raw: &str) -> Result { +pub(crate) fn validate_amount(raw: &str) -> Result { let trimmed = raw.trim(); if trimmed.is_empty() { return Err("amount is empty".to_string()); @@ -225,13 +237,19 @@ fn validate_address(chain: WalletChain, addr: &str) -> Result { 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}"))?; + match chain { + WalletChain::Evm => { + Address::from_str(trimmed) + .map_err(|e| format!("invalid EVM address '{trimmed}': {e}"))?; + Ok(trimmed.to_string()) + } + WalletChain::Btc => chain_btc::validate_btc_address(trimmed), + WalletChain::Solana => chain_sol::validate_solana_address(trimmed), + WalletChain::Tron => chain_tron::validate_tron_address(trimmed), } - Ok(trimmed.to_string()) } -fn validate_calldata(data: &str) -> Result { +pub(crate) fn validate_calldata(data: &str) -> Result { let trimmed = data.trim(); if !trimmed.starts_with("0x") { return Err("calldata must be 0x-prefixed hex".to_string()); @@ -246,7 +264,7 @@ fn validate_calldata(data: &str) -> Result { Ok(trimmed.to_string()) } -fn format_amount(raw: u128, decimals: u8) -> String { +pub(crate) fn format_amount(raw: u128, decimals: u8) -> String { if decimals == 0 { return raw.to_string(); } @@ -267,7 +285,11 @@ fn estimated_fee_raw(chain: WalletChain, kind: PreparedKind) -> String { (WalletChain::Evm, PreparedKind::Swap) => 200_000u128 * 30_000_000_000, (WalletChain::Evm, PreparedKind::ContractCall) => 100_000u128 * 30_000_000_000, (WalletChain::Btc, _) => 5_000, + (WalletChain::Solana, PreparedKind::NativeTransfer) => 5_000, + (WalletChain::Solana, PreparedKind::TokenTransfer) => 5_000, (WalletChain::Solana, _) => 5_000, + (WalletChain::Tron, PreparedKind::NativeTransfer) => 1_000_000, + (WalletChain::Tron, PreparedKind::TokenTransfer) => 15_000_000, (WalletChain::Tron, _) => 1_000_000, }; base.to_string() @@ -276,6 +298,7 @@ fn estimated_fee_raw(chain: WalletChain, kind: PreparedKind) -> String { fn asset_to_supported(asset: WalletAssetDefinition) -> SupportedAsset { SupportedAsset { chain: asset.chain, + evm_network: asset.evm_network, symbol: asset.symbol, name: asset.name, native: asset.native, @@ -340,198 +363,32 @@ pub fn prepared_quotes_for_test() -> Vec { } #[cfg(test)] -fn reset_quote_store_for_tests() { +pub(crate) fn reset_quote_store_for_tests() { QUOTE_STORE.lock().clear(); } -fn hex_to_u256(hex_value: &str) -> Result { +#[cfg(test)] +pub(crate) fn insert_quote_for_test(quote: PreparedTransaction) -> PreparedTransaction { + store_quote(quote) +} + +pub 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 { +pub fn u256_to_hex(value: U256) -> String { format!("0x{value:x}") } -fn hex_to_bytes(value: &str) -> Result, String> { +pub 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()); @@ -542,16 +399,17 @@ pub async fn network_defaults() -> Result> } pub async fn supported_assets() -> Result>, String> { - let assets = [ - WalletChain::Evm, - WalletChain::Btc, - WalletChain::Solana, - WalletChain::Tron, - ] - .into_iter() - .flat_map(super::defaults::asset_catalog) - .map(asset_to_supported) - .collect::>(); + let mut assets: Vec = Vec::new(); + for network in EvmNetwork::ALL { + for asset in evm_asset_catalog(network) { + assets.push(asset_to_supported(asset)); + } + } + for chain in [WalletChain::Btc, WalletChain::Solana, WalletChain::Tron] { + for asset in super::defaults::asset_catalog(chain) { + assets.push(asset_to_supported(asset)); + } + } debug!("{LOG_PREFIX} supported_assets count={}", assets.len()); Ok(RpcOutcome::new( assets, @@ -561,17 +419,29 @@ pub async fn supported_assets() -> Result>, Strin pub async fn chain_status() -> Result>, String> { let status = wallet_status().await?.value; - let rows = [ - WalletChain::Evm, - WalletChain::Btc, - WalletChain::Solana, - WalletChain::Tron, - ] - .into_iter() - .map(|chain| { + let mut rows = Vec::new(); + for network in EvmNetwork::ALL { + let has_account = status + .accounts + .iter() + .any(|account| account.chain == WalletChain::Evm); + rows.push(ChainStatus { + chain: WalletChain::Evm, + evm_network: Some(network), + configured: has_account, + provider_status: if has_account { + ProviderStatus::Ready + } else { + ProviderStatus::Missing + }, + rpc_url: network.rpc_url(), + }); + } + for chain in [WalletChain::Btc, WalletChain::Solana, WalletChain::Tron] { let has_account = status.accounts.iter().any(|account| account.chain == chain); - ChainStatus { + rows.push(ChainStatus { chain, + evm_network: None, configured: has_account, provider_status: if has_account { ProviderStatus::Ready @@ -579,9 +449,8 @@ pub async fn chain_status() -> Result>, String> { ProviderStatus::Missing }, rpc_url: rpc_url_for_chain(chain), - } - }) - .collect::>(); + }); + } debug!("{LOG_PREFIX} chain_status reported chains={}", rows.len()); Ok(RpcOutcome::new( rows, @@ -605,27 +474,58 @@ pub async fn balances() -> Result>, String> { 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), + let (raw, provider_status) = match account.chain { + WalletChain::Evm => { + match chain_evm::evm_balance(EvmNetwork::EthereumMainnet, &account.address).await { + Ok(balance) => (balance.to_string(), ProviderStatus::Ready), + Err(error) => { + warn!( + "{LOG_PREFIX} balances chain=evm address={} falling back to zero: {error}", + account.address + ); + ("0".to_string(), ProviderStatus::Missing) + } + } + } + WalletChain::Btc => match chain_btc::native_balance(&account.address).await { + Ok(sats) => (sats.to_string(), ProviderStatus::Ready), Err(error) => { warn!( - "{LOG_PREFIX} balances chain=evm address={} falling back to zero placeholder: {}", - account.address, error + "{LOG_PREFIX} balances chain=btc address={} falling back to zero: {error}", + account.address ); ("0".to_string(), ProviderStatus::Missing) } - } - } else { - warn!( - "{LOG_PREFIX} balances chain={} uses placeholder until native provider support lands", - chain_str(account.chain) - ); - ("0".to_string(), ProviderStatus::Ready) + }, + WalletChain::Solana => match chain_sol::native_balance(&account.address).await { + Ok(lamports) => (lamports.to_string(), ProviderStatus::Ready), + Err(error) => { + warn!( + "{LOG_PREFIX} balances chain=solana address={} falling back to zero: {error}", + account.address + ); + ("0".to_string(), ProviderStatus::Missing) + } + }, + WalletChain::Tron => match chain_tron::native_balance(&account.address).await { + Ok(sun) => (sun.to_string(), ProviderStatus::Ready), + Err(error) => { + warn!( + "{LOG_PREFIX} balances chain=tron address={} falling back to zero: {error}", + account.address + ); + ("0".to_string(), ProviderStatus::Missing) + } + }, }; let raw_u128 = raw.parse::().unwrap_or(0); out.push(BalanceInfo { chain: account.chain, + evm_network: if account.chain == WalletChain::Evm { + Some(EvmNetwork::EthereumMainnet) + } else { + None + }, address: account.address.clone(), asset_symbol: asset.symbol, decimals: asset.decimals, @@ -649,40 +549,57 @@ pub async fn prepare_transfer( if amount == 0 { return Err("transfer amount must be greater than zero".to_string()); } + let network = if params.chain == WalletChain::Evm { + Some(params.evm_network.unwrap_or(EvmNetwork::EthereumMainnet)) + } else { + None + }; 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(|| { + None | Some("") => { + // native asset for the chain (or chosen EVM network). + let catalog = if let Some(net) = network { + evm_asset_catalog(net) + } else { + super::defaults::asset_catalog(params.chain) + }; + catalog + .into_iter() + .find(|value| value.native) + .ok_or_else(|| { + format!( + "native asset metadata missing for '{}'", + chain_str(params.chain) + ) + })? + } + Some(symbol) => find_asset_for_network(params.chain, network, 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 }; + // BTC has no native token concept; reject TokenTransfer on btc. + if matches!(params.chain, WalletChain::Btc) && !asset.native { + return Err("token transfers are not supported on Bitcoin".to_string()); + } let now = now_ms(); + let label = if let Some(net) = network { + format!("{} ({})", chain_str(params.chain), net.network_label()) + } else { + chain_str(params.chain).to_string() + }; let quote = PreparedTransaction { quote_id: next_quote_id(), kind, chain: params.chain, + evm_network: network, from_address: account.address.clone(), to_address: to, asset_symbol: asset.symbol.clone(), @@ -698,8 +615,7 @@ pub async fn prepare_transfer( expires_at_ms: now + QUOTE_TTL_MS, notes: vec![format!( "Prepared {} transfer on {} using default network settings.", - asset.symbol, - chain_str(params.chain) + asset.symbol, label )], }; debug!( @@ -734,17 +650,31 @@ pub async fn prepare_swap( } let router = validate_address(params.chain, ¶ms.router_address)?; let account = require_account(params.chain).await?; - let native_decimals = super::defaults::asset_catalog(params.chain) - .into_iter() - .find(|value| value.native) - .map(|value| value.decimals) - .unwrap_or(18); + let network = if params.chain == WalletChain::Evm { + Some(params.evm_network.unwrap_or(EvmNetwork::EthereumMainnet)) + } else { + None + }; + let native_decimals = if let Some(net) = network { + evm_asset_catalog(net) + .into_iter() + .find(|value| value.native) + .map(|value| value.decimals) + .unwrap_or(18) + } else { + 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 now = now_ms(); let quote = PreparedTransaction { quote_id: next_quote_id(), kind: PreparedKind::Swap, chain: params.chain, + evm_network: network, from_address: account.address.clone(), to_address: router, asset_symbol: params.from_symbol.clone(), @@ -790,7 +720,8 @@ pub async fn prepare_contract_call( let calldata = validate_calldata(¶ms.calldata)?; let value = validate_amount(¶ms.value_raw)?; let account = require_account(params.chain).await?; - let native = super::defaults::asset_catalog(params.chain) + let network = params.evm_network.unwrap_or(EvmNetwork::EthereumMainnet); + let native = evm_asset_catalog(network) .into_iter() .find(|value| value.native) .ok_or_else(|| "missing native asset metadata for evm".to_string())?; @@ -799,6 +730,7 @@ pub async fn prepare_contract_call( quote_id: next_quote_id(), kind: PreparedKind::ContractCall, chain: params.chain, + evm_network: Some(network), from_address: account.address.clone(), to_address: contract, asset_symbol: native.symbol, @@ -832,19 +764,44 @@ pub async fn execute_prepared( if !params.confirmed { return Err("execute_prepared requires `confirmed: true`".to_string()); } - 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) - )); + // Atomically remove the quote *before* broadcasting so two concurrent + // confirmations can't both pass get_quote() and double-submit. If signing + // or broadcast fails we restore the quote to keep it retryable. + let quote = take_quote(¶ms.quote_id)?; + let chain = quote.chain; + let restorable = quote.clone(); + let result = match chain { + WalletChain::Evm => chain_evm::execute_evm_quote(quote).await, + WalletChain::Btc => chain_btc::execute_btc_quote(quote).await, + WalletChain::Solana => chain_sol::execute_solana_quote(quote).await, + WalletChain::Tron => chain_tron::execute_tron_quote(quote).await, + }; + let result = match result { + Ok(value) => value, + Err(error) => { + // Restore the quote so the caller can fix the cause and retry. + // Refresh the TTL window so a slow chain call (network timeouts + // can chew through the original 5-min budget) doesn't hand back + // an immediately-expired quote. + let mut refreshed = restorable; + let now = now_ms(); + refreshed.expires_at_ms = now + QUOTE_TTL_MS; + store_quote(refreshed); + warn!( + "{LOG_PREFIX} execute chain={} quote_id={} failed (quote restored, ttl refreshed): {error}", + chain_str(chain), + params.quote_id + ); + return Err(error); } }; - let _ = take_quote(¶ms.quote_id)?; + let explorer_fallback = explorer_tx_url(chain, &result.transaction_hash); + let mut final_result = result; + if final_result.explorer_url.is_none() { + final_result.explorer_url = explorer_fallback; + } Ok(RpcOutcome::new( - result, + final_result, vec!["wallet transaction broadcast".to_string()], )) } @@ -856,19 +813,21 @@ mod tests { use axum::{extract::State, routing::post, Json, Router}; use once_cell::sync::Lazy; - use serde_json::Value; + use serde_json::{json, Value}; use tempfile::TempDir; use tokio::net::TcpListener; use super::*; + use crate::openhuman::config::rpc as config_rpc; use crate::openhuman::wallet::ops::{setup, WalletSetupParams, WalletSetupSource}; - static TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); + pub(crate) static TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); #[derive(Clone)] struct MockRpcState { estimate_calls: Arc>>, raw_txs: Arc>>, + chain_id: String, } fn sample_account(chain: WalletChain) -> super::WalletAccount { @@ -876,13 +835,13 @@ mod tests { chain, address: match chain { WalletChain::Evm => "0x9858EfFD232B4033E47d90003D41EC34EcaEda94".to_string(), - WalletChain::Btc => "btc".to_string(), - WalletChain::Solana => "sol".to_string(), - WalletChain::Tron => "tron".to_string(), + WalletChain::Btc => "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu".to_string(), + WalletChain::Solana => "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk".to_string(), + WalletChain::Tron => "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH".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::Btc => "m/84'/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(), }, @@ -903,7 +862,7 @@ mod tests { .cloned() .unwrap_or_default(); let result = match method { - "eth_chainId" => Value::String("0x1".to_string()), + "eth_chainId" => Value::String(state.chain_id.clone()), "eth_getTransactionCount" => Value::String("0x7".to_string()), "eth_gasPrice" => Value::String("0x3b9aca00".to_string()), "eth_estimateGas" => { @@ -928,13 +887,15 @@ mod tests { Json(json!({"jsonrpc":"2.0","id":1,"result":result})) } - async fn start_mock_rpc( + pub(crate) async fn start_mock_rpc_with_chain_id( + chain_id_hex: &str, ) -> 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(), + chain_id: chain_id_hex.to_string(), }; let app = Router::new().route("/", post(mock_rpc)).with_state(state); let listener = TcpListener::bind("127.0.0.1:0") @@ -949,7 +910,12 @@ mod tests { Ok((addr, estimate_calls, raw_txs)) } - async fn setup_wallet(temp: &TempDir) -> Result<(), String> { + async fn start_mock_rpc( + ) -> Result<(SocketAddr, Arc>>, Arc>>), String> { + start_mock_rpc_with_chain_id("0x1").await + } + + pub(crate) async fn setup_wallet_in(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( @@ -1010,11 +976,6 @@ mod tests { #[test] fn quote_store_round_trips_and_expires() { - // Must hold TEST_LOCK before clobbering the process-wide quote store, - // otherwise this races the async execute_prepared_* tests that store - // a quote and then await — `reset_quote_store_for_tests()` here can - // wipe their quote between store + await, surfacing as - // "quote 'q_retry' not found" in CI (intermittent). let _guard = TEST_LOCK.lock(); reset_quote_store_for_tests(); let now = now_ms(); @@ -1022,6 +983,7 @@ mod tests { quote_id: "q_test_1".to_string(), kind: PreparedKind::NativeTransfer, chain: WalletChain::Evm, + evm_network: Some(EvmNetwork::EthereumMainnet), from_address: "0xfrom".to_string(), to_address: "0xto".to_string(), asset_symbol: "ETH".to_string(), @@ -1061,53 +1023,21 @@ mod tests { } #[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" - ); - } - - #[tokio::test] - async fn supported_assets_lists_default_erc20s() { + async fn supported_assets_lists_default_erc20s_and_l2() { let out = supported_assets().await.unwrap(); - assert!(out.value.iter().any(|asset| asset.symbol == "USDC")); + assert!(out + .value + .iter() + .any(|asset| asset.symbol == "USDC" + && asset.evm_network == Some(EvmNetwork::BaseMainnet))); assert!(out .value .iter() .any(|asset| asset.symbol == "ETH" && asset.native)); + assert!(out + .value + .iter() + .any(|asset| asset.symbol == "USDT" && asset.chain == WalletChain::Tron)); } #[tokio::test] @@ -1118,12 +1048,13 @@ mod tests { .unwrap_or_else(|e| e.into_inner()); reset_quote_store_for_tests(); let temp = TempDir::new().unwrap(); - setup_wallet(&temp).await.unwrap(); + setup_wallet_in(&temp).await.unwrap(); let err = prepare_transfer(PrepareTransferParams { chain: WalletChain::Evm, to_address: "0x1111111111111111111111111111111111111111".into(), amount_raw: "1".into(), asset_symbol: Some("NOPE".into()), + evm_network: None, }) .await .unwrap_err(); @@ -1137,6 +1068,7 @@ mod tests { contract_address: "addr".into(), calldata: "0x".into(), value_raw: "0".into(), + evm_network: None, }) .await .unwrap_err(); @@ -1151,7 +1083,7 @@ mod tests { .unwrap_or_else(|e| e.into_inner()); reset_quote_store_for_tests(); let temp = TempDir::new().unwrap(); - setup_wallet(&temp).await.unwrap(); + setup_wallet_in(&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}")); @@ -1160,6 +1092,7 @@ mod tests { to_address: "0x1111111111111111111111111111111111111111".into(), amount_raw: "1000".into(), asset_symbol: None, + evm_network: None, }) .await .unwrap() @@ -1190,7 +1123,7 @@ mod tests { .unwrap_or_else(|e| e.into_inner()); reset_quote_store_for_tests(); let temp = TempDir::new().unwrap(); - setup_wallet(&temp).await.unwrap(); + setup_wallet_in(&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}")); @@ -1199,6 +1132,7 @@ mod tests { to_address: "0x1111111111111111111111111111111111111111".into(), amount_raw: "5000000".into(), asset_symbol: Some("USDC".into()), + evm_network: None, }) .await .unwrap() @@ -1224,4 +1158,72 @@ mod tests { .expect("token transfer calldata"); assert!(data.starts_with("0xa9059cbb")); } + + #[tokio::test] + async fn execute_prepared_broadcasts_native_evm_on_base_with_chain_id_8453() { + let _guard = TEST_LOCK.lock(); + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + reset_quote_store_for_tests(); + let temp = TempDir::new().unwrap(); + setup_wallet_in(&temp).await.unwrap(); + // Base uses chain_id 8453 = 0x2105. + let (addr, _estimate_calls, raw_txs) = + start_mock_rpc_with_chain_id("0x2105").await.unwrap(); + std::env::set_var("OPENHUMAN_WALLET_RPC_BASE", format!("http://{addr}")); + + let prepared = prepare_transfer(PrepareTransferParams { + chain: WalletChain::Evm, + to_address: "0x1111111111111111111111111111111111111111".into(), + amount_raw: "1000".into(), + asset_symbol: None, + evm_network: Some(EvmNetwork::BaseMainnet), + }) + .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!(executed.evm_network, Some(EvmNetwork::BaseMainnet)); + assert_eq!(raw_txs.lock().len(), 1); + } + + #[tokio::test] + async fn execute_prepared_rejects_evm_chain_id_mismatch() { + let _guard = TEST_LOCK.lock(); + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + reset_quote_store_for_tests(); + let temp = TempDir::new().unwrap(); + setup_wallet_in(&temp).await.unwrap(); + // Quote says Base; mock reports Ethereum (0x1) — must fail. + let (addr, _e, _r) = start_mock_rpc_with_chain_id("0x1").await.unwrap(); + std::env::set_var("OPENHUMAN_WALLET_RPC_BASE", format!("http://{addr}")); + + let prepared = prepare_transfer(PrepareTransferParams { + chain: WalletChain::Evm, + to_address: "0x1111111111111111111111111111111111111111".into(), + amount_raw: "1000".into(), + asset_symbol: None, + evm_network: Some(EvmNetwork::BaseMainnet), + }) + .await + .unwrap() + .value; + let err = execute_prepared(ExecutePreparedParams { + quote_id: prepared.quote_id.clone(), + confirmed: true, + }) + .await + .unwrap_err(); + assert!(err.contains("chain_id mismatch"), "got: {err}"); + } } diff --git a/src/openhuman/wallet/mod.rs b/src/openhuman/wallet/mod.rs index 027b1267e..fb5c54800 100644 --- a/src/openhuman/wallet/mod.rs +++ b/src/openhuman/wallet/mod.rs @@ -1,19 +1,24 @@ //! Core-owned wallet onboarding metadata, derived account visibility, and //! the agent-facing execution surface (balances, transfers, swaps, -//! contract calls). See [`execution`] for the prepare/confirm/execute flow. +//! contract calls). See [`execution`] for the prepare/confirm/execute flow +//! and [`chains`] for the per-chain signing/broadcast implementations. mod abi; +mod chains; mod defaults; mod execution; mod ops; mod rpc; mod schemas; +#[cfg(test)] +pub(crate) mod test_support; + 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, + asset_catalog, default_rpc_url, env_var_for_chain, evm_asset_catalog, explorer_tx_url, + find_asset, find_asset_for_network, network_defaults, rpc_source_for_chain, rpc_url_for_chain, + rpc_url_for_evm_network, EvmNetwork, RpcSource, WalletAssetDefinition, WalletNetworkDefaults, }; pub use execution::{ balances, chain_status, execute_prepared, network_defaults as wallet_network_defaults, diff --git a/src/openhuman/wallet/rpc.rs b/src/openhuman/wallet/rpc.rs index 42a5ae9da..eadf8586f 100644 --- a/src/openhuman/wallet/rpc.rs +++ b/src/openhuman/wallet/rpc.rs @@ -1,13 +1,31 @@ +//! Wallet network helpers — JSON-RPC POST + REST GET/POST primitives. +//! +//! JSON-RPC is used for EVM and Solana. REST is used for BTC (Esplora) and +//! Tron (TronGrid). Both honor an `OPENHUMAN_WALLET_RPC_` env override +//! so tests can point everything at an axum mock. + use std::time::Duration; +use once_cell::sync::Lazy; use serde::de::DeserializeOwned; use serde_json::{json, Value}; -use super::defaults::rpc_url_for_chain; +use super::defaults::{rpc_url_for_chain, rpc_url_for_evm_network, EvmNetwork}; use super::ops::WalletChain; const LOG_PREFIX: &str = "[wallet::rpc]"; +/// Process-wide shared client so reqwest's connection pool stays hot across +/// repeated wallet RPC/REST calls. Building a new Client per call rebuilds +/// the TLS connector each time and tears down connection pooling — the +/// recommended pattern per reqwest 0.12 docs is to reuse a single Client. +static SHARED_CLIENT: Lazy = Lazy::new(|| { + reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .expect("wallet RPC client builder must succeed with default settings") +}); + fn redact_rpc_url(raw: &str) -> String { match reqwest::Url::parse(raw) { Ok(url) => match url.host_str() { @@ -18,12 +36,33 @@ fn redact_rpc_url(raw: &str) -> String { } } +fn client() -> reqwest::Client { + SHARED_CLIENT.clone() +} + +/// JSON-RPC POST against a chain's default/override endpoint. pub async fn rpc_call( chain: WalletChain, method: &str, params: Value, ) -> Result { - let url = rpc_url_for_chain(chain); + rpc_call_to(&rpc_url_for_chain(chain), method, params).await +} + +/// JSON-RPC POST against a specific EVM network's RPC URL. +pub async fn evm_rpc_call( + network: EvmNetwork, + method: &str, + params: Value, +) -> Result { + rpc_call_to(&rpc_url_for_evm_network(network), method, params).await +} + +pub async fn rpc_call_to( + url: &str, + method: &str, + params: Value, +) -> Result { let payload = json!({ "jsonrpc": "2.0", "id": 1, @@ -31,15 +70,11 @@ pub async fn rpc_call( "params": params, }); log::debug!( - "{LOG_PREFIX} chain={:?} method={} url={}", - chain, + "{LOG_PREFIX} jsonrpc method={} url={}", method, - redact_rpc_url(&url) + 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 client = client(); let response = client .post(url) .json(&payload) @@ -52,10 +87,7 @@ pub async fn rpc_call( .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, + "{LOG_PREFIX} jsonrpc method={method} status={status} body_len={}", raw_body.len() ); if !status.is_success() { @@ -65,11 +97,6 @@ pub async fn rpc_call( } 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}")); } @@ -81,6 +108,83 @@ pub async fn rpc_call( .map_err(|e| format!("wallet RPC invalid result for {method}: {e}")) } +/// Plain REST GET returning JSON or text. +pub async fn rest_get_text(url: &str) -> Result { + log::debug!("{LOG_PREFIX} rest_get url={}", redact_rpc_url(url)); + let response = client() + .get(url) + .send() + .await + .map_err(|e| format!("wallet REST GET transport failed: {e}"))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("wallet REST GET read body failed: {e}"))?; + if !status.is_success() { + return Err(format!( + "wallet REST GET HTTP failure: status={status} body={body}" + )); + } + Ok(body) +} + +pub async fn rest_get_json(url: &str) -> Result { + let body = rest_get_text(url).await?; + serde_json::from_str(&body) + .map_err(|e| format!("wallet REST GET decode failed: {e}; body={body}")) +} + +/// Plain REST POST with a raw text body (e.g. Esplora /tx accepts hex). +pub async fn rest_post_text(url: &str, body: &str, content_type: &str) -> Result { + log::debug!( + "{LOG_PREFIX} rest_post url={} body_len={}", + redact_rpc_url(url), + body.len() + ); + let response = client() + .post(url) + .header("content-type", content_type) + .body(body.to_string()) + .send() + .await + .map_err(|e| format!("wallet REST POST transport failed: {e}"))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|e| format!("wallet REST POST read body failed: {e}"))?; + if !status.is_success() { + return Err(format!( + "wallet REST POST HTTP failure: status={status} body={text}" + )); + } + Ok(text) +} + +/// REST POST with a JSON body. +pub async fn rest_post_json(url: &str, body: &Value) -> Result { + log::debug!("{LOG_PREFIX} rest_post_json url={}", redact_rpc_url(url)); + let response = client() + .post(url) + .json(body) + .send() + .await + .map_err(|e| format!("wallet REST POST transport failed: {e}"))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|e| format!("wallet REST POST read body failed: {e}"))?; + if !status.is_success() { + return Err(format!( + "wallet REST POST HTTP failure: status={status} body={text}" + )); + } + serde_json::from_str(&text) + .map_err(|e| format!("wallet REST POST decode failed: {e}; body={text}")) +} + #[cfg(test)] mod tests { use super::redact_rpc_url; diff --git a/src/openhuman/wallet/schemas.rs b/src/openhuman/wallet/schemas.rs index 7de5cebdf..6f1916604 100644 --- a/src/openhuman/wallet/schemas.rs +++ b/src/openhuman/wallet/schemas.rs @@ -225,17 +225,23 @@ pub fn wallet_schemas(function: &str) -> ControllerSchema { namespace: "wallet", function: "prepare_transfer", description: - "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.", + "Build a native or token-transfer quote. All four chains (EVM, BTC, Solana, Tron) sign + broadcast on execute_prepared after explicit confirmation. BTC supports only native transfers (no token concept).", inputs: vec![ required_json("chain", "Target chain (evm | btc | solana | tron)."), required_json("toAddress", "Recipient address on the target chain."), - required_json("amountRaw", "Amount in the asset's smallest unit (wei/sat/lamport) as a decimal string."), + required_json("amountRaw", "Amount in the asset's smallest unit (wei/sat/lamport/sun) as a decimal string."), FieldSchema { name: "assetSymbol", ty: TypeSchema::Option(Box::new(TypeSchema::String)), comment: "Optional. Omit / null for the chain's native asset; otherwise a token symbol from wallet.supported_assets.", required: false, }, + FieldSchema { + name: "evmNetwork", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Optional. Required only for chain='evm' to pick a network: ethereum_mainnet | base_mainnet | arbitrum_one | optimism_mainnet | polygon_mainnet. Defaults to ethereum_mainnet.", + required: false, + }, ], outputs: vec![FieldSchema { name: "result", @@ -256,6 +262,12 @@ pub fn wallet_schemas(function: &str) -> ControllerSchema { required_json("amountInRaw", "Input amount in the from-asset's smallest unit, as a decimal string."), required_json("slippageBps", "Slippage tolerance in basis points (max 5000 = 50%)."), required_json("routerAddress", "Router / aggregator contract address."), + FieldSchema { + name: "evmNetwork", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Optional. EVM network selector when chain='evm'. Defaults to ethereum_mainnet.", + required: false, + }, ], outputs: vec![FieldSchema { name: "result", @@ -279,6 +291,12 @@ pub fn wallet_schemas(function: &str) -> ControllerSchema { comment: "Native value attached, smallest unit. Defaults to '0'.", required: false, }, + FieldSchema { + name: "evmNetwork", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Optional. EVM network selector. Defaults to ethereum_mainnet.", + required: false, + }, ], outputs: vec![FieldSchema { name: "result", diff --git a/src/openhuman/wallet/test_support.rs b/src/openhuman/wallet/test_support.rs new file mode 100644 index 000000000..e69695a0f --- /dev/null +++ b/src/openhuman/wallet/test_support.rs @@ -0,0 +1,98 @@ +//! Shared test plumbing for wallet unit tests across all chains. +//! +//! Provides: +//! - [`TEST_LOCK`]: serializes wallet tests that mutate the global quote store +//! and per-chain env-var overrides. +//! - [`setup_wallet_in`]: writes a configured wallet state into a +//! [`tempfile::TempDir`] using the standard "abandon × 11 about" mnemonic +//! so every chain's signer derives a deterministic address. +//! - Sample addresses corresponding to that mnemonic (one per chain). + +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use tempfile::TempDir; + +use super::ops::{setup, WalletAccount, WalletChain, WalletSetupParams, WalletSetupSource}; +use crate::openhuman::config::rpc as config_rpc; + +pub(crate) static TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); + +/// Standard BIP-39 test mnemonic — produces deterministic accounts per chain. +pub(crate) const TEST_MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +pub(crate) fn sample_evm_address() -> &'static str { + "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" +} +pub(crate) fn sample_btc_address() -> &'static str { + "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" +} +pub(crate) fn sample_solana_address() -> &'static str { + "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" +} +pub(crate) fn sample_tron_address() -> &'static str { + "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH" +} + +pub(crate) fn sample_account(chain: WalletChain) -> WalletAccount { + WalletAccount { + chain, + address: match chain { + WalletChain::Evm => sample_evm_address().to_string(), + WalletChain::Btc => sample_btc_address().to_string(), + WalletChain::Solana => sample_solana_address().to_string(), + WalletChain::Tron => sample_tron_address().to_string(), + }, + derivation_path: match chain { + WalletChain::Evm => "m/44'/60'/0'/0/0".to_string(), + WalletChain::Btc => "m/84'/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(), + }, + } +} + +/// RAII guard returned to callers so `OPENHUMAN_WORKSPACE` is restored when +/// the test scope ends — prevents one test's tempdir from leaking into the +/// next test in the same process. Drop the guard explicitly or let it fall +/// out of scope at the end of the test. +pub(crate) struct WorkspaceEnvGuard { + prev: Option, +} + +impl Drop for WorkspaceEnvGuard { + fn drop(&mut self) { + match self.prev.take() { + Some(v) => std::env::set_var("OPENHUMAN_WORKSPACE", v), + None => std::env::remove_var("OPENHUMAN_WORKSPACE"), + } + } +} + +pub(crate) async fn setup_wallet_in(temp: &TempDir) -> Result<(), String> { + // We intentionally leak the env-var change for the duration of the test + // (wallet state lookups rely on it). The shared `TEST_LOCK` mutex + // serializes tests so concurrent tests can't race on the var. + 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, TEST_MNEMONIC) + .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(()) +} diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 17d659ebd..504587c08 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -2832,7 +2832,7 @@ async fn json_rpc_wallet_setup_round_trips_status() { "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" }, + { "chain": "btc", "address": "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", "derivationPath": "m/84'/0'/0'/0/0" }, { "chain": "solana", "address": "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk", "derivationPath": "m/44'/501'/0'/0'" }, { "chain": "tron", "address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", "derivationPath": "m/44'/195'/0'/0/0" } ] @@ -2939,7 +2939,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { "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" }, + { "chain": "btc", "address": "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", "derivationPath": "m/84'/0'/0'/0/0" }, { "chain": "solana", "address": "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk", "derivationPath": "m/44'/501'/0'/0'" }, { "chain": "tron", "address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", "derivationPath": "m/44'/195'/0'/0/0" } ] @@ -2959,11 +2959,30 @@ 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(), - 8, - "expected four native assets plus EVM tokens: {result}" - ); + // Pin the actual expected multi-chain catalog (not just a lower bound) so a + // regression that silently drops a network is caught. + for expected_evm in [ + "ethereum_mainnet", + "base_mainnet", + "arbitrum_one", + "optimism_mainnet", + "polygon_mainnet", + ] { + assert!( + list.iter() + .any(|a| a.get("evmNetwork").and_then(Value::as_str) == Some(expected_evm)), + "expected {expected_evm} asset row in catalog: {result}" + ); + } + for (chain, symbol) in [("btc", "BTC"), ("solana", "SOL"), ("tron", "TRX")] { + assert!( + list.iter() + .any(|a| a.get("chain").and_then(Value::as_str) == Some(chain) + && a.get("symbol").and_then(Value::as_str) == Some(symbol) + && a.get("native").and_then(Value::as_bool) == Some(true)), + "expected native {symbol} on {chain}: {result}" + ); + } assert!( list.iter().any( |asset| asset.get("symbol").and_then(Value::as_str) == Some("ETH") @@ -2984,7 +3003,8 @@ async fn json_rpc_wallet_execution_surface_round_trips() { let body = assert_no_jsonrpc_error(&cs, "wallet_chain_status"); let result = body.get("result").unwrap_or(&body); let rows = result.as_array().expect("chain_status array"); - assert_eq!(rows.len(), 4); + // 5 EVM rows (one per L2 / mainnet) + 3 non-EVM chains. + assert_eq!(rows.len(), 8); assert!( rows.iter() .all(|r| r.get("providerStatus").and_then(Value::as_str) == Some("ready")), @@ -2997,9 +3017,16 @@ async fn json_rpc_wallet_execution_surface_round_trips() { let result = body.get("result").unwrap_or(&body); let rows = result.as_array().expect("balances array"); assert_eq!(rows.len(), 4); - assert!(rows - .iter() - .all(|r| r.get("raw").and_then(Value::as_str) == Some("0"))); + // Every row reports a raw integer string. Don't require zero — the + // BTC/Solana/Tron default REST endpoints may have network access in CI + // and return non-placeholder values for the deterministic test addresses. + for r in rows { + let raw = r.get("raw").and_then(Value::as_str).expect("raw present"); + assert!( + raw.chars().all(|c| c.is_ascii_digit()), + "raw must be a decimal string, got: {raw}" + ); + } // prepare_transfer + execute_prepared (happy path). let prep = post_json_rpc( @@ -3090,6 +3117,732 @@ async fn json_rpc_wallet_execution_surface_round_trips() { rpc_join.abort(); } +// --------------------------------------------------------------------------- +// Multi-chain wallet E2E suite (PR multi-chain-complete). +// +// One test per chain exercising prepare_transfer → execute_prepared via the +// public JSON-RPC controllers, with a chain-specific axum mock for the +// upstream RPC/REST endpoint. The mocks return canned but real-shaped +// responses; the assertions verify that the core actually signs and +// broadcasts (not just that the controllers wire up). +// --------------------------------------------------------------------------- + +const E2E_TEST_MNEMONIC_ADDRS_EVM: &str = "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"; +const E2E_TEST_MNEMONIC_ADDRS_BTC: &str = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu"; +const E2E_TEST_MNEMONIC_ADDRS_SOL: &str = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk"; +const E2E_TEST_MNEMONIC_ADDRS_TRON: &str = "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH"; + +fn wallet_setup_accounts_value() -> Value { + json!([ + { "chain": "evm", "address": E2E_TEST_MNEMONIC_ADDRS_EVM, "derivationPath": "m/44'/60'/0'/0/0" }, + { "chain": "btc", "address": E2E_TEST_MNEMONIC_ADDRS_BTC, "derivationPath": "m/84'/0'/0'/0/0" }, + { "chain": "solana", "address": E2E_TEST_MNEMONIC_ADDRS_SOL, "derivationPath": "m/44'/501'/0'/0'" }, + { "chain": "tron", "address": E2E_TEST_MNEMONIC_ADDRS_TRON, "derivationPath": "m/44'/195'/0'/0/0" } + ]) +} + +#[derive(Clone)] +struct MockBaseRpcState { + raw_txs: Arc>>, + chain_id_hex: String, +} + +async fn mock_evm_chain_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(state.chain_id_hex.clone()), + "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(p) => p.into_inner().push(raw.to_string()), + } + } + Value::String( + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + ) + } + _ => Value::Null, + }; + Json(json!({"jsonrpc":"2.0","id":1,"result":result})) +} + +async fn start_mock_evm_with_chain_id(chain_id_hex: &str) -> (SocketAddr, Arc>>) { + let raw_txs = Arc::new(Mutex::new(Vec::new())); + let state = MockBaseRpcState { + raw_txs: raw_txs.clone(), + chain_id_hex: chain_id_hex.to_string(), + }; + let app = Router::new() + .route("/", post(mock_evm_chain_rpc)) + .with_state(state); + let (addr, _join) = serve_on_ephemeral(app).await; + (addr, raw_txs) +} + +#[derive(Clone)] +struct MockBtcRestState { + utxo: Value, + broadcast_txs: Arc>>, + queried_addresses: Arc>>, +} + +async fn mock_btc_utxo( + axum::extract::Path(addr): axum::extract::Path, + State(state): State, +) -> Json { + match state.queried_addresses.lock() { + Ok(mut g) => g.push(addr), + Err(p) => p.into_inner().push(addr), + } + Json(state.utxo) +} + +async fn mock_btc_broadcast(State(state): State, body: String) -> String { + match state.broadcast_txs.lock() { + Ok(mut g) => g.push(body), + Err(p) => p.into_inner().push(body), + } + "ababababababababababababababababababababababababababababababab".to_string() +} + +struct MockBtcHandle { + addr: SocketAddr, + broadcast_txs: Arc>>, + queried_addresses: Arc>>, +} + +async fn start_mock_btc() -> MockBtcHandle { + let broadcast = Arc::new(Mutex::new(Vec::new())); + let queried_addresses = Arc::new(Mutex::new(Vec::new())); + let utxo = json!([ + { "txid": "1111111111111111111111111111111111111111111111111111111111111111", + "vout": 0, "value": 100_000u64 } + ]); + let state = MockBtcRestState { + utxo, + broadcast_txs: broadcast.clone(), + queried_addresses: queried_addresses.clone(), + }; + let app = Router::new() + .route("/address/{addr}/utxo", axum::routing::get(mock_btc_utxo)) + .route("/tx", post(mock_btc_broadcast)) + .with_state(state); + let (addr, _join) = serve_on_ephemeral(app).await; + MockBtcHandle { + addr, + broadcast_txs: broadcast, + queried_addresses, + } +} + +async fn mock_solana_rpc(Json(payload): Json) -> Json { + let method = payload + .get("method") + .and_then(Value::as_str) + .unwrap_or_default(); + let result = match method { + "getLatestBlockhash" => json!({ + "context": {"slot": 0}, + "value": { + "blockhash": "GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi", + "lastValidBlockHeight": 0u64 + } + }), + "getBalance" => json!({"context": {"slot": 0}, "value": 0u64}), + "sendTransaction" => Value::String( + "5xS9pXmqVz8R1nuRZTfsdsAxBdBFmtnAtuYbCsmK5DYzGn5vR4VqWGmiR5McLnYx8oFqLdo62q4qiUZpQyR4Hkn3".to_string(), + ), + _ => Value::Null, + }; + Json(json!({"jsonrpc":"2.0","id":1,"result":result})) +} + +async fn start_mock_solana() -> SocketAddr { + let app = Router::new().route("/", post(mock_solana_rpc)); + let (addr, _join) = serve_on_ephemeral(app).await; + addr +} + +#[derive(Clone, Default)] +struct MockTronState { + create_hits: Arc>, + trigger_hits: Arc>, + broadcast_hits: Arc>, +} + +async fn mock_tron_create(State(state): State, Json(_): Json) -> Json { + if let Ok(mut g) = state.create_hits.lock() { + *g += 1; + } + Json(json!({ + "txID": "cd".repeat(32), + "raw_data": {"contract": []}, + "raw_data_hex": "0a02ab1d2208deadbeef00deadbe40c89efd8a82325802", + })) +} + +async fn mock_tron_trigger( + State(state): State, + Json(_): Json, +) -> Json { + if let Ok(mut g) = state.trigger_hits.lock() { + *g += 1; + } + Json(json!({ + "transaction": { + "txID": "cd".repeat(32), + "raw_data": {"contract": []}, + "raw_data_hex": "0a02ab1d2208deadbeef00deadbe40c89efd8a82325802", + } + })) +} + +async fn mock_tron_broadcast( + State(state): State, + Json(_): Json, +) -> Json { + if let Ok(mut g) = state.broadcast_hits.lock() { + *g += 1; + } + Json(json!({"result": true, "txid": "cd".repeat(32)})) +} + +struct MockTronHandle { + addr: SocketAddr, + state: MockTronState, +} + +async fn start_mock_tron() -> MockTronHandle { + let state = MockTronState::default(); + let app = Router::new() + .route("/wallet/createtransaction", post(mock_tron_create)) + .route("/wallet/triggersmartcontract", post(mock_tron_trigger)) + .route("/wallet/broadcasttransaction", post(mock_tron_broadcast)) + .with_state(state.clone()); + let (addr, _join) = serve_on_ephemeral(app).await; + MockTronHandle { addr, state } +} + +async fn wallet_setup_via_rpc(rpc_base: &str, encrypted_mnemonic: &str) { + let setup = post_json_rpc( + rpc_base, + 9001, + "openhuman.wallet_setup", + json!({ + "consentGranted": true, + "source": "imported", + "mnemonicWordCount": 12, + "encryptedMnemonic": encrypted_mnemonic, + "accounts": wallet_setup_accounts_value(), + }), + ) + .await; + assert_no_jsonrpc_error(&setup, "wallet_setup_for_chain_e2e"); +} + +/// EVM L2 selection: Base mainnet (chain_id 8453 = 0x2105). Verifies +/// `evmNetwork: base_mainnet` routes signing + broadcast to the Base RPC +/// override and *not* the Ethereum default. +#[tokio::test] +async fn json_rpc_wallet_evm_base_network_prepare_execute_round_trips() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + 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_guard = EnvVarGuard::unset("OPENHUMAN_WALLET_RPC_EVM"); + let _base_guard = EnvVarGuard::unset("OPENHUMAN_WALLET_RPC_BASE"); + + let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; + let mock_origin = format!("http://{}", mock_addr); + write_min_config(&openhuman_home, &mock_origin); + + 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; + + // Mock Base RPC at 0x2105 = 8453. + let (base_rpc_addr, base_raw_txs) = start_mock_evm_with_chain_id("0x2105").await; + std::env::set_var( + "OPENHUMAN_WALLET_RPC_BASE", + format!("http://{base_rpc_addr}"), + ); + + wallet_setup_via_rpc(&rpc_base, &encrypted_mnemonic).await; + + let prep = post_json_rpc( + &rpc_base, + 9101, + "openhuman.wallet_prepare_transfer", + json!({ + "chain": "evm", + "evmNetwork": "base_mainnet", + "toAddress": "0x1111111111111111111111111111111111111111", + "amountRaw": "1000", + }), + ) + .await; + let prep_body = assert_no_jsonrpc_error(&prep, "wallet_prepare_transfer_base"); + let prep_result = prep_body.get("result").unwrap_or(prep_body); + assert_eq!( + prep_result.get("evmNetwork").and_then(Value::as_str), + Some("base_mainnet"), + ); + let quote_id = prep_result + .get("quoteId") + .and_then(Value::as_str) + .expect("quoteId") + .to_string(); + + let exec = post_json_rpc( + &rpc_base, + 9102, + "openhuman.wallet_execute_prepared", + json!({"quoteId": quote_id, "confirmed": true}), + ) + .await; + let exec_body = assert_no_jsonrpc_error(&exec, "wallet_execute_prepared_base"); + let exec_result = exec_body.get("result").unwrap_or(exec_body); + assert_eq!( + exec_result.get("status").and_then(Value::as_str), + Some("broadcasted"), + ); + assert_eq!( + exec_result.get("evmNetwork").and_then(Value::as_str), + Some("base_mainnet"), + ); + let raw_count = match base_raw_txs.lock() { + Ok(g) => g.len(), + Err(p) => p.into_inner().len(), + }; + assert_eq!(raw_count, 1, "expected one raw tx broadcast on Base RPC"); + + mock_join.abort(); + rpc_join.abort(); +} + +/// BTC: P2WPKH native segwit transfer end-to-end through controllers. +#[tokio::test] +async fn json_rpc_wallet_btc_prepare_execute_round_trips() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + 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 _btc_guard = EnvVarGuard::unset("OPENHUMAN_WALLET_RPC_BTC"); + + let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; + let mock_origin = format!("http://{}", mock_addr); + write_min_config(&openhuman_home, &mock_origin); + + 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 btc_mock = start_mock_btc().await; + std::env::set_var( + "OPENHUMAN_WALLET_RPC_BTC", + format!("http://{}", btc_mock.addr), + ); + + wallet_setup_via_rpc(&rpc_base, &encrypted_mnemonic).await; + + let prep = post_json_rpc( + &rpc_base, + 9201, + "openhuman.wallet_prepare_transfer", + json!({ + "chain": "btc", + "toAddress": "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + "amountRaw": "50000", + }), + ) + .await; + let prep_body = assert_no_jsonrpc_error(&prep, "wallet_prepare_transfer_btc"); + let prep_result = prep_body.get("result").unwrap_or(prep_body); + let quote_id = prep_result + .get("quoteId") + .and_then(Value::as_str) + .expect("quoteId") + .to_string(); + + let exec = post_json_rpc( + &rpc_base, + 9202, + "openhuman.wallet_execute_prepared", + json!({"quoteId": quote_id, "confirmed": true}), + ) + .await; + let exec_body = assert_no_jsonrpc_error(&exec, "wallet_execute_prepared_btc"); + let exec_result = exec_body.get("result").unwrap_or(exec_body); + assert_eq!( + exec_result.get("status").and_then(Value::as_str), + Some("broadcasted"), + ); + let (broadcast_count, last_tx_hex) = match btc_mock.broadcast_txs.lock() { + Ok(g) => (g.len(), g.last().cloned()), + Err(p) => { + let g = p.into_inner(); + (g.len(), g.last().cloned()) + } + }; + assert_eq!(broadcast_count, 1, "exactly one BTC broadcast call"); + // Broadcast body must be non-empty segwit hex. + let raw_hex = last_tx_hex.expect("broadcast body recorded"); + assert!( + !raw_hex.is_empty() && raw_hex.chars().all(|c| c.is_ascii_hexdigit()), + "broadcast body must be hex, got: {raw_hex}" + ); + // UTXO endpoint must be queried for the BIP84-derived sender, proving + // the address that flows into signing is the one we expect. + let queried = match btc_mock.queried_addresses.lock() { + Ok(g) => g.clone(), + Err(p) => p.into_inner().clone(), + }; + assert!( + queried.iter().any(|a| a == E2E_TEST_MNEMONIC_ADDRS_BTC), + "UTXO endpoint must be queried for the sender's bc1q… address, got: {queried:?}" + ); + + mock_join.abort(); + rpc_join.abort(); +} + +/// Solana: native SOL transfer end-to-end through controllers. +#[tokio::test] +async fn json_rpc_wallet_solana_prepare_execute_round_trips() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + 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 _sol_guard = EnvVarGuard::unset("OPENHUMAN_WALLET_RPC_SOLANA"); + + let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; + let mock_origin = format!("http://{}", mock_addr); + write_min_config(&openhuman_home, &mock_origin); + + 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 sol_addr = start_mock_solana().await; + std::env::set_var("OPENHUMAN_WALLET_RPC_SOLANA", format!("http://{sol_addr}")); + + wallet_setup_via_rpc(&rpc_base, &encrypted_mnemonic).await; + + let prep = post_json_rpc( + &rpc_base, + 9301, + "openhuman.wallet_prepare_transfer", + json!({ + "chain": "solana", + "toAddress": "Vote111111111111111111111111111111111111111", + "amountRaw": "1000", + }), + ) + .await; + let prep_body = assert_no_jsonrpc_error(&prep, "wallet_prepare_transfer_solana"); + let prep_result = prep_body.get("result").unwrap_or(prep_body); + let quote_id = prep_result + .get("quoteId") + .and_then(Value::as_str) + .expect("quoteId") + .to_string(); + + let exec = post_json_rpc( + &rpc_base, + 9302, + "openhuman.wallet_execute_prepared", + json!({"quoteId": quote_id, "confirmed": true}), + ) + .await; + let exec_body = assert_no_jsonrpc_error(&exec, "wallet_execute_prepared_solana"); + let exec_result = exec_body.get("result").unwrap_or(exec_body); + assert_eq!( + exec_result.get("status").and_then(Value::as_str), + Some("broadcasted"), + ); + assert_eq!( + exec_result.get("transactionHash").and_then(Value::as_str), + Some("5xS9pXmqVz8R1nuRZTfsdsAxBdBFmtnAtuYbCsmK5DYzGn5vR4VqWGmiR5McLnYx8oFqLdo62q4qiUZpQyR4Hkn3"), + ); + + mock_join.abort(); + rpc_join.abort(); +} + +/// Tron: native TRX transfer end-to-end through controllers. +#[tokio::test] +async fn json_rpc_wallet_tron_prepare_execute_round_trips() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + 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 _tron_guard = EnvVarGuard::unset("OPENHUMAN_WALLET_RPC_TRON"); + + let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; + let mock_origin = format!("http://{}", mock_addr); + write_min_config(&openhuman_home, &mock_origin); + + 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 tron_mock = start_mock_tron().await; + std::env::set_var( + "OPENHUMAN_WALLET_RPC_TRON", + format!("http://{}", tron_mock.addr), + ); + + wallet_setup_via_rpc(&rpc_base, &encrypted_mnemonic).await; + + let prep = post_json_rpc( + &rpc_base, + 9401, + "openhuman.wallet_prepare_transfer", + json!({ + "chain": "tron", + "toAddress": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "amountRaw": "1000000", + }), + ) + .await; + let prep_body = assert_no_jsonrpc_error(&prep, "wallet_prepare_transfer_tron"); + let prep_result = prep_body.get("result").unwrap_or(prep_body); + let quote_id = prep_result + .get("quoteId") + .and_then(Value::as_str) + .expect("quoteId") + .to_string(); + + let exec = post_json_rpc( + &rpc_base, + 9402, + "openhuman.wallet_execute_prepared", + json!({"quoteId": quote_id, "confirmed": true}), + ) + .await; + let exec_body = assert_no_jsonrpc_error(&exec, "wallet_execute_prepared_tron"); + let exec_result = exec_body.get("result").unwrap_or(exec_body); + assert_eq!( + exec_result.get("status").and_then(Value::as_str), + Some("broadcasted"), + ); + assert_eq!( + exec_result.get("transactionHash").and_then(Value::as_str), + Some(format!("{}", "cd".repeat(32)).as_str()), + ); + // Native TRX must go through createtransaction, NOT triggersmartcontract. + let create_hits = *tron_mock.state.create_hits.lock().unwrap(); + let trigger_hits = *tron_mock.state.trigger_hits.lock().unwrap(); + let broadcast_hits = *tron_mock.state.broadcast_hits.lock().unwrap(); + assert_eq!( + create_hits, 1, + "native TRX must hit /wallet/createtransaction" + ); + assert_eq!( + trigger_hits, 0, + "native TRX must NOT hit /wallet/triggersmartcontract" + ); + assert_eq!( + broadcast_hits, 1, + "exactly one /wallet/broadcasttransaction call" + ); + + mock_join.abort(); + rpc_join.abort(); +} + +/// Tron TRC20 lifecycle — verifies the triggersmartcontract path is used. +#[tokio::test] +async fn json_rpc_wallet_tron_trc20_prepare_execute_round_trips() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + 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 _tron_guard = EnvVarGuard::unset("OPENHUMAN_WALLET_RPC_TRON"); + + let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; + let mock_origin = format!("http://{}", mock_addr); + write_min_config(&openhuman_home, &mock_origin); + + 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 tron_mock = start_mock_tron().await; + std::env::set_var( + "OPENHUMAN_WALLET_RPC_TRON", + format!("http://{}", tron_mock.addr), + ); + + wallet_setup_via_rpc(&rpc_base, &encrypted_mnemonic).await; + + let prep = post_json_rpc( + &rpc_base, + 9501, + "openhuman.wallet_prepare_transfer", + json!({ + "chain": "tron", + "toAddress": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "amountRaw": "5000000", + "assetSymbol": "USDT", + }), + ) + .await; + let prep_body = assert_no_jsonrpc_error(&prep, "wallet_prepare_transfer_trc20"); + let prep_result = prep_body.get("result").unwrap_or(prep_body); + assert_eq!( + prep_result.get("kind").and_then(Value::as_str), + Some("token_transfer"), + ); + let quote_id = prep_result + .get("quoteId") + .and_then(Value::as_str) + .expect("quoteId") + .to_string(); + + let exec = post_json_rpc( + &rpc_base, + 9502, + "openhuman.wallet_execute_prepared", + json!({"quoteId": quote_id, "confirmed": true}), + ) + .await; + let exec_body = assert_no_jsonrpc_error(&exec, "wallet_execute_prepared_trc20"); + let exec_result = exec_body.get("result").unwrap_or(exec_body); + assert_eq!( + exec_result.get("status").and_then(Value::as_str), + Some("broadcasted"), + ); + // TRC20 must go through triggersmartcontract, NOT createtransaction. + let create_hits = *tron_mock.state.create_hits.lock().unwrap(); + let trigger_hits = *tron_mock.state.trigger_hits.lock().unwrap(); + let broadcast_hits = *tron_mock.state.broadcast_hits.lock().unwrap(); + assert_eq!( + trigger_hits, 1, + "TRC20 transfer must hit /wallet/triggersmartcontract" + ); + assert_eq!( + create_hits, 0, + "TRC20 transfer must NOT hit /wallet/createtransaction" + ); + assert_eq!( + broadcast_hits, 1, + "exactly one /wallet/broadcasttransaction call" + ); + + mock_join.abort(); + rpc_join.abort(); +} + +/// Wallet network_defaults must surface every supported EVM L2 plus BTC, +/// Solana, and Tron, with chain_id populated for EVM rows. +#[tokio::test] +async fn json_rpc_wallet_network_defaults_lists_all_chains() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + 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 (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; + let mock_origin = format!("http://{}", mock_addr); + write_min_config(&openhuman_home, &mock_origin); + + 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 resp = post_json_rpc( + &rpc_base, + 9601, + "openhuman.wallet_network_defaults", + json!({}), + ) + .await; + let body = assert_no_jsonrpc_error(&resp, "wallet_network_defaults"); + let result = body.get("result").unwrap_or(body); + let rows = result.as_array().expect("array"); + // Pin every expected EVM L2 + chain_id + the three non-EVM chains. + for (expected_evm, expected_chain_id) in [ + ("ethereum_mainnet", 1u64), + ("base_mainnet", 8453), + ("arbitrum_one", 42161), + ("optimism_mainnet", 10), + ("polygon_mainnet", 137), + ] { + let row = rows + .iter() + .find(|r| r.get("evmNetwork").and_then(Value::as_str) == Some(expected_evm)) + .unwrap_or_else(|| panic!("{expected_evm} row missing from network_defaults")); + assert_eq!( + row.get("chainId").and_then(Value::as_u64), + Some(expected_chain_id), + "{expected_evm} should expose chain_id {expected_chain_id}" + ); + } + for expected_chain in ["btc", "solana", "tron"] { + assert!( + rows.iter().any( + |r| r.get("chain").and_then(Value::as_str) == Some(expected_chain) + && r.get("evmNetwork").is_none() + ), + "{expected_chain} row missing from network_defaults" + ); + } + + mock_join.abort(); + rpc_join.abort(); +} + /// Verify that when `chat_onboarding_completed` is unset in config.toml (fresh /// user), the `openhuman.app_state_snapshot` RPC surfaces the flag as `false` /// (its serde default). The field is deprecated but still surfaced for backward compat.