mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
feat(wallet): complete multi-chain signing + broadcast (EVM/BTC/Solana/Tron) (#2519)
This commit is contained in:
Generated
+106
-1
@@ -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
@@ -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"] }
|
||||
|
||||
Generated
+177
-252
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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("e.from_address).map_err(|e| {
|
||||
format!(
|
||||
"invalid stored EVM sender address '{}': {e}",
|
||||
quote.from_address
|
||||
)
|
||||
})?;
|
||||
let (tx_to, tx_value, tx_data) = match quote.kind {
|
||||
PreparedKind::NativeTransfer => (
|
||||
Address::from_str("e.to_address).map_err(|e| {
|
||||
format!("invalid EVM recipient address '{}': {e}", quote.to_address)
|
||||
})?,
|
||||
U256::from_dec_str("e.amount_raw).map_err(|e| {
|
||||
format!("invalid prepared native value '{}': {e}", quote.amount_raw)
|
||||
})?,
|
||||
None,
|
||||
),
|
||||
PreparedKind::TokenTransfer => {
|
||||
let token = quote
|
||||
.token_address
|
||||
.as_deref()
|
||||
.ok_or_else(|| "prepared token transfer is missing token_address".to_string())?;
|
||||
let calldata = encode_erc20_transfer("e.to_address, "e.amount_raw)?;
|
||||
(
|
||||
Address::from_str(token)
|
||||
.map_err(|e| format!("invalid ERC20 token contract address '{token}': {e}"))?,
|
||||
U256::zero(),
|
||||
Some(calldata),
|
||||
)
|
||||
}
|
||||
PreparedKind::ContractCall => (
|
||||
Address::from_str("e.to_address)
|
||||
.map_err(|e| format!("invalid contract target '{}': {e}", quote.to_address))?,
|
||||
U256::from_dec_str("e.amount_raw).map_err(|e| {
|
||||
format!(
|
||||
"invalid prepared contract value '{}': {e}",
|
||||
quote.amount_raw
|
||||
)
|
||||
})?,
|
||||
quote.calldata.clone(),
|
||||
),
|
||||
PreparedKind::Swap => {
|
||||
return Err(
|
||||
"swap broadcast is not implemented yet; keep it in quote-only mode".to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let chain_id_hex: String = rpc_call_to(&rpc_url, "eth_chainId", json!([])).await?;
|
||||
// Use "pending" so already-submitted-but-not-mined txs don't cause a
|
||||
// nonce collision when two confirmations land back-to-back.
|
||||
let nonce_hex: String = rpc_call_to(
|
||||
&rpc_url,
|
||||
"eth_getTransactionCount",
|
||||
json!([quote.from_address, "pending"]),
|
||||
)
|
||||
.await?;
|
||||
let gas_price_hex: String = rpc_call_to(&rpc_url, "eth_gasPrice", json!([])).await?;
|
||||
let mut estimate_tx = json!({
|
||||
"from": quote.from_address,
|
||||
"to": format!("{tx_to:#x}"),
|
||||
"value": u256_to_hex(tx_value),
|
||||
});
|
||||
if let Some(data_hex) = tx_data.as_deref() {
|
||||
estimate_tx["data"] = json!(data_hex);
|
||||
}
|
||||
let gas_hex: String = rpc_call_to(&rpc_url, "eth_estimateGas", json!([estimate_tx])).await?;
|
||||
let chain_id = hex_to_u256(&chain_id_hex)?.as_u64();
|
||||
if chain_id != network.chain_id() {
|
||||
return Err(format!(
|
||||
"EVM RPC chain_id mismatch: rpc reported {} but network {} expects {}",
|
||||
chain_id,
|
||||
network.as_str(),
|
||||
network.chain_id()
|
||||
));
|
||||
}
|
||||
let nonce = hex_to_u256(&nonce_hex)?;
|
||||
let gas_price = hex_to_u256(&gas_price_hex)?;
|
||||
let gas = hex_to_u256(&gas_hex)?;
|
||||
|
||||
let tx_data_bytes = tx_data
|
||||
.clone()
|
||||
.map(|value| hex_to_bytes(&value).map(Bytes::from))
|
||||
.transpose()?;
|
||||
let mut request = TransactionRequest::new()
|
||||
.from(from)
|
||||
.to(NameOrAddress::Address(tx_to))
|
||||
.value(tx_value)
|
||||
.nonce(nonce)
|
||||
.gas(gas)
|
||||
.gas_price(gas_price)
|
||||
.chain_id(chain_id);
|
||||
if let Some(data) = tx_data_bytes {
|
||||
request = request.data(data);
|
||||
}
|
||||
let tx: TypedTransaction = request.into();
|
||||
let signature = signer
|
||||
.with_chain_id(chain_id)
|
||||
.sign_transaction(&tx)
|
||||
.await
|
||||
.map_err(|e| format!("failed to sign EVM transaction: {e}"))?;
|
||||
let raw_bytes = tx.rlp_signed(&signature);
|
||||
let raw_tx = format!("0x{}", hex::encode(raw_bytes));
|
||||
let tx_hash: String = rpc_call_to(&rpc_url, "eth_sendRawTransaction", json!([raw_tx])).await?;
|
||||
quote.estimated_fee_raw = gas_price.checked_mul(gas).unwrap_or_default().to_string();
|
||||
quote.status = PreparedStatus::Broadcasted;
|
||||
debug!(
|
||||
"{LOG_PREFIX} execute_prepared quote_id={} network={} tx_hash={}",
|
||||
quote.quote_id,
|
||||
network.as_str(),
|
||||
tx_hash
|
||||
);
|
||||
Ok(ExecutionResult {
|
||||
quote_id: quote.quote_id.clone(),
|
||||
status: PreparedStatus::Broadcasted,
|
||||
chain: WalletChain::Evm,
|
||||
evm_network: Some(network),
|
||||
transaction_hash: tx_hash.clone(),
|
||||
explorer_url: explorer_tx_url_for_evm_network(network, &tx_hash),
|
||||
transaction: quote,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_evm_address(addr: &str) -> Result<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())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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("e.from_address)?;
|
||||
validate_tron_address("e.to_address)?;
|
||||
let amount: u128 = quote
|
||||
.amount_raw
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid Tron amount '{}': {e}", quote.amount_raw))?;
|
||||
|
||||
let owner_hex = tron_address_to_hex("e.from_address)?;
|
||||
let to_hex = tron_address_to_hex("e.to_address)?;
|
||||
|
||||
let secret = secret_material(WalletChain::Tron).await?;
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let mnemonic =
|
||||
crate::openhuman::encryption::rpc::decrypt_secret(&config, &secret.encrypted_mnemonic)
|
||||
.await?
|
||||
.value;
|
||||
let (sk, derived_addr) = derive_tron_keypair(&mnemonic, &secret.derivation_path)?;
|
||||
if derived_addr != quote.from_address {
|
||||
return Err(format!(
|
||||
"Tron key derivation mismatch: derived {derived_addr} but expected {}",
|
||||
quote.from_address
|
||||
));
|
||||
}
|
||||
|
||||
let raw_tx = match quote.kind {
|
||||
PreparedKind::NativeTransfer => {
|
||||
let amount_sun: u64 = amount
|
||||
.try_into()
|
||||
.map_err(|_| format!("Tron amount {amount} exceeds u64"))?;
|
||||
create_native_transaction(&owner_hex, &to_hex, amount_sun).await?
|
||||
}
|
||||
PreparedKind::TokenTransfer => {
|
||||
let contract = quote
|
||||
.token_address
|
||||
.as_deref()
|
||||
.ok_or_else(|| "TRC20 transfer missing token_address".to_string())?;
|
||||
validate_tron_address(contract)?;
|
||||
let contract_hex = tron_address_to_hex(contract)?;
|
||||
let parameter = encode_trc20_transfer_param(&to_hex, amount)?;
|
||||
trigger_trc20_transfer(&owner_hex, &contract_hex, ¶meter).await?
|
||||
}
|
||||
other => {
|
||||
return Err(format!(
|
||||
"Tron execution only supports native + TRC20 transfers; got {other:?}"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Tron signs sha256(raw_data_hex bytes).
|
||||
let raw_bytes = hex::decode(&raw_tx.raw_data_hex)
|
||||
.map_err(|e| format!("invalid raw_data_hex from Tron: {e}"))?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&raw_bytes);
|
||||
let hash: [u8; 32] = hasher.finalize().into();
|
||||
|
||||
let secp = Secp256k1::new();
|
||||
let msg = Message::from_digest(hash);
|
||||
let sig = secp.sign_ecdsa_recoverable(&msg, &sk);
|
||||
let (rec_id, compact) = sig.serialize_compact();
|
||||
let mut sig_bytes = [0u8; 65];
|
||||
sig_bytes[..64].copy_from_slice(&compact);
|
||||
sig_bytes[64] = rec_id.to_i32() as u8;
|
||||
let sig_hex = hex::encode(sig_bytes);
|
||||
|
||||
let mut tx_with_sig = serde_json::to_value(&serde_json::json!({
|
||||
"txID": raw_tx.tx_id,
|
||||
"raw_data": raw_tx.raw_data,
|
||||
"raw_data_hex": raw_tx.raw_data_hex,
|
||||
"signature": [sig_hex],
|
||||
}))
|
||||
.map_err(|e| format!("failed to build Tron signed tx: {e}"))?;
|
||||
// visible: false flag for broadcast
|
||||
tx_with_sig
|
||||
.as_object_mut()
|
||||
.expect("object")
|
||||
.insert("visible".to_string(), Value::Bool(false));
|
||||
|
||||
let response = broadcast_signed(tx_with_sig).await?;
|
||||
let ok = response
|
||||
.get("result")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
if !ok {
|
||||
let code = response.get("code").and_then(Value::as_str).unwrap_or("");
|
||||
let msg = response
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"Tron broadcast rejected: code={code} message={msg}"
|
||||
));
|
||||
}
|
||||
let txid = response
|
||||
.get("txid")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| raw_tx.tx_id.clone());
|
||||
|
||||
quote.status = PreparedStatus::Broadcasted;
|
||||
debug!(
|
||||
"{LOG_PREFIX} broadcast quote_id={} txid={} kind={:?}",
|
||||
quote.quote_id, txid, quote.kind
|
||||
);
|
||||
let explorer_url = explorer_tx_url(WalletChain::Tron, &txid);
|
||||
Ok(ExecutionResult {
|
||||
quote_id: quote.quote_id.clone(),
|
||||
status: PreparedStatus::Broadcasted,
|
||||
chain: WalletChain::Tron,
|
||||
evm_network: None,
|
||||
transaction_hash: txid,
|
||||
explorer_url,
|
||||
transaction: quote,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::wallet::execution::{
|
||||
insert_quote_for_test, now_ms, reset_quote_store_for_tests, PreparedKind, PreparedStatus,
|
||||
PreparedTransaction,
|
||||
};
|
||||
use crate::openhuman::wallet::test_support::{sample_tron_address, setup_wallet_in, TEST_LOCK};
|
||||
use axum::{routing::post, Router};
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct TronMockRecord {
|
||||
create_calls: Arc<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]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+325
-323
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user