feat(wallet): complete multi-chain signing + broadcast (EVM/BTC/Solana/Tron) (#2519)

This commit is contained in:
Steven Enamakel
2026-05-23 01:13:01 -07:00
committed by GitHub
parent ebdd03e302
commit 97553caf23
16 changed files with 4197 additions and 693 deletions
Generated
+106 -1
View File
@@ -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"
+16
View File
@@ -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"] }
+177 -252
View File
@@ -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]]
@@ -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"],
+561
View File
@@ -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<String, String> {
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<String, String> {
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<u128, String> {
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<Vec<EsploraUtxo>, 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<String, String> {
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<ScriptBuf, String> {
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<English> = 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<EsploraUtxo>, 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<ExecutionResult, String> {
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<parking_lot::Mutex<Vec<String>>> =
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
}
}
+186
View File
@@ -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<U256, String> {
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<ExecutionResult, String> {
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::<English>::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(&quote.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(&quote.to_address).map_err(|e| {
format!("invalid EVM recipient address '{}': {e}", quote.to_address)
})?,
U256::from_dec_str(&quote.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(&quote.to_address, &quote.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(&quote.to_address)
.map_err(|e| format!("invalid contract target '{}': {e}", quote.to_address))?,
U256::from_dec_str(&quote.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<String, String> {
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())
}
+17
View File
@@ -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<ExecutionResult, String>`
//! - `native_balance(address: &str) -> Result<u128, String>`
//! - `validate_address(addr: &str) -> Result<String, String>`
pub(super) mod btc;
pub(super) mod evm;
pub(super) mod solana;
pub(super) mod tron;
+801
View File
@@ -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<String, String> {
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<u128, String> {
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<Sha512>;
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<Vec<u32>, 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<SigningKey, String> {
use coins_bip39::{English, Mnemonic};
let mnemonic_obj: Mnemonic<English> = 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<u8> {
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<u8>,
data: Vec<u8>,
}
fn encode_compiled_instruction(ins: &CompiledInstruction) -> Vec<u8> {
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<u8> {
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<u8> {
// 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<u8> {
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<bool, String> {
#[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<String, String> {
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<ExecutionResult, String> {
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<parking_lot::Mutex<Vec<serde_json::Value>>>,
) {
let calls: Arc<parking_lot::Mutex<Vec<serde_json::Value>>> =
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<serde_json::Value>| {
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<serde_json::Value>| 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);
}
}
+660
View File
@@ -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<String, String> {
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<String, String> {
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<u128, String> {
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<English> = 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<Sha512>;
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<Vec<(u32, bool)>, 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<u8> {
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<String, String> {
// 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<CreateTransactionResponse, String> {
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<CreateTransactionResponse, String> {
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<Value, String> {
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<ExecutionResult, String> {
validate_tron_address(&quote.from_address)?;
validate_tron_address(&quote.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(&quote.from_address)?;
let to_hex = tron_address_to_hex(&quote.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, &parameter).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<parking_lot::Mutex<Vec<Value>>>,
trigger_calls: Arc<parking_lot::Mutex<Vec<Value>>>,
broadcast_calls: Arc<parking_lot::Mutex<Vec<Value>>>,
}
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<Value>| {
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<Value>| {
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<Value>| {
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]);
}
}
+330 -82
View File
@@ -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<EvmNetwork>,
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<EvmNetwork>,
pub network: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub chain_id: Option<u64>,
@@ -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<String> {
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<String> {
Some(format!("{base}{tx_hash}"))
}
pub fn explorer_tx_url_for_evm_network(network: EvmNetwork, tx_hash: &str) -> Option<String> {
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<WalletAssetDefinition> {
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<WalletAssetDefinition> {
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<WalletAssetDefinition> {
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<WalletAssetDefinition> {
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<WalletNetworkDefaults> {
[
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<WalletAssetDefinition> {
find_asset_for_network(chain, None, symbol)
}
pub fn find_asset_for_network(
chain: WalletChain,
network: Option<EvmNetwork>,
symbol: &str,
) -> Option<WalletAssetDefinition> {
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));
}
}
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -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,
+122 -18
View File
@@ -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_<CHAIN>` 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<reqwest::Client> = 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<T: DeserializeOwned>(
chain: WalletChain,
method: &str,
params: Value,
) -> Result<T, String> {
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<T: DeserializeOwned>(
network: EvmNetwork,
method: &str,
params: Value,
) -> Result<T, String> {
rpc_call_to(&rpc_url_for_evm_network(network), method, params).await
}
pub async fn rpc_call_to<T: DeserializeOwned>(
url: &str,
method: &str,
params: Value,
) -> Result<T, String> {
let payload = json!({
"jsonrpc": "2.0",
"id": 1,
@@ -31,15 +70,11 @@ pub async fn rpc_call<T: DeserializeOwned>(
"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<T: DeserializeOwned>(
.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<T: DeserializeOwned>(
}
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<T: DeserializeOwned>(
.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<String, String> {
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<T: DeserializeOwned>(url: &str) -> Result<T, String> {
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<String, String> {
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<T: DeserializeOwned>(url: &str, body: &Value) -> Result<T, String> {
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;
+20 -2
View File
@@ -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",
+98
View File
@@ -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<Mutex<()>> = 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<std::ffi::OsString>,
}
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(())
}
+764 -11
View File
@@ -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<Mutex<Vec<String>>>,
chain_id_hex: String,
}
async fn mock_evm_chain_rpc(
State(state): State<MockBaseRpcState>,
Json(payload): Json<Value>,
) -> Json<Value> {
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<Mutex<Vec<String>>>) {
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<Mutex<Vec<String>>>,
queried_addresses: Arc<Mutex<Vec<String>>>,
}
async fn mock_btc_utxo(
axum::extract::Path(addr): axum::extract::Path<String>,
State(state): State<MockBtcRestState>,
) -> Json<Value> {
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<MockBtcRestState>, 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<Mutex<Vec<String>>>,
queried_addresses: Arc<Mutex<Vec<String>>>,
}
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<Value>) -> Json<Value> {
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<Mutex<u32>>,
trigger_hits: Arc<Mutex<u32>>,
broadcast_hits: Arc<Mutex<u32>>,
}
async fn mock_tron_create(State(state): State<MockTronState>, Json(_): Json<Value>) -> Json<Value> {
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<MockTronState>,
Json(_): Json<Value>,
) -> Json<Value> {
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<MockTronState>,
Json(_): Json<Value>,
) -> Json<Value> {
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.