diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index a1dcf69c83..7a5ca57b92 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -788,19 +788,28 @@ jobs: - name: Run cargo llvm-cov for Tauri shell run: | - # Write raw profiles to the container's local filesystem first. On - # hosted container jobs the workspace bind mount can accept the test - # binary but silently fail LLVM's profile writer. Move the completed - # profiles into llvm-cov's target before reporting. - profile_dir="/tmp/openhuman-tauri-profraw" + # Write raw profiles to the container's in-memory filesystem first. + # Both the workspace bind mount and the container overlay have + # dropped LLVM profile writes on hosted container jobs. /dev/shm is + # container-local and these raw counter files are only a few MiB. + profile_dir="/dev/shm/openhuman-tauri-profraw-${GITHUB_RUN_ID}" rm -rf "$profile_dir" mkdir -p "$profile_dir" + # Source cargo-llvm-cov's instrumentation environment, then override + # the raw-profile path before invoking Cargo directly. Calling + # `cargo llvm-cov` here would overwrite LLVM_PROFILE_FILE again. + export CARGO_TARGET_DIR="$PWD/app/src-tauri/target/llvm-cov-target" + # Do not chain the runner's sccache wrapper underneath llvm-cov. + # Its shared cache can return objects compiled without coverage, + # yielding a passing test binary that never writes a raw profile. + unset RUSTC_WRAPPER + eval "$(cargo llvm-cov show-env --manifest-path app/src-tauri/Cargo.toml --sh)" export LLVM_PROFILE_FILE="$profile_dir/tauri-%p-%m.profraw" - bash scripts/ci-cancel-aware.sh cargo llvm-cov --manifest-path app/src-tauri/Cargo.toml --no-report + bash scripts/ci-cancel-aware.sh cargo test --manifest-path app/src-tauri/Cargo.toml shopt -s nullglob profiles=("$profile_dir"/*.profraw) test "${#profiles[@]}" -gt 0 - mv "${profiles[@]}" app/src-tauri/target/llvm-cov-target/ + mv "${profiles[@]}" "$CARGO_TARGET_DIR/" bash scripts/ci-cancel-aware.sh cargo llvm-cov report --manifest-path app/src-tauri/Cargo.toml --lcov --output-path lcov-tauri.info env: # Serialize codegen/link to cap peak memory + disk during the diff --git a/Cargo.lock b/Cargo.lock index 8b8c37ed9d..9c0de86674 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -224,12 +224,6 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - [[package]] name = "assert-json-diff" version = "2.0.2" @@ -335,17 +329,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "auto_impl" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "autocfg" version = "1.5.0" @@ -462,16 +445,6 @@ 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" @@ -550,54 +523,6 @@ 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" @@ -715,12 +640,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" -[[package]] -name = "byte-slice-cast" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" - [[package]] name = "bytemuck" version = "1.25.0" @@ -991,7 +910,7 @@ dependencies = [ "coins-bip32", "hmac", "once_cell", - "pbkdf2 0.12.2", + "pbkdf2", "rand 0.8.6", "sha2 0.10.9", "thiserror 1.0.69", @@ -1072,18 +991,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const-hex" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20d9a563d167a9cce0f94153382b33cb6eded6dfabff03c69ad65a28ea1514e0" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "proptest", - "serde_core", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -1116,27 +1023,6 @@ dependencies = [ "tiny-keccak", ] -[[package]] -name = "const_format" -version = "0.2.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" -dependencies = [ - "const_format_proc_macros", - "konst", -] - -[[package]] -name = "const_format_proc_macros" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" -dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", -] - [[package]] name = "convert_case" version = "0.10.0" @@ -1400,7 +1286,7 @@ checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ "bitflags 2.13.1", "crossterm_winapi", - "derive_more 2.1.1", + "derive_more", "document-features", "mio", "parking_lot", @@ -1583,7 +1469,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" dependencies = [ "serde", - "uuid 1.23.1", + "uuid", ] [[package]] @@ -1622,33 +1508,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "derive_more" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" -dependencies = [ - "derive_more-impl 1.0.0", -] - [[package]] name = "derive_more" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "derive_more-impl 2.1.1", -] - -[[package]] -name = "derive_more-impl" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "derive_more-impl", ] [[package]] @@ -1993,122 +1859,6 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" -[[package]] -name = "eth-keystore" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fda3bf123be441da5260717e0661c25a2fd9cb2b2c1d20bf2e05580047158ab" -dependencies = [ - "aes", - "ctr", - "digest 0.10.7", - "hex", - "hmac", - "pbkdf2 0.11.0", - "rand 0.8.6", - "scrypt", - "serde", - "serde_json", - "sha2 0.10.9", - "sha3", - "thiserror 1.0.69", - "uuid 0.8.2", -] - -[[package]] -name = "ethabi" -version = "18.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7413c5f74cc903ea37386a8965a936cbeb334bd270862fdece542c1b2dcbc898" -dependencies = [ - "ethereum-types", - "hex", - "once_cell", - "regex", - "serde", - "serde_json", - "sha3", - "thiserror 1.0.69", - "uint", -] - -[[package]] -name = "ethbloom" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c22d4b5885b6aa2fe5e8b9329fb8d232bf739e434e6b87347c63bdd00c120f60" -dependencies = [ - "crunchy", - "fixed-hash", - "impl-codec", - "impl-rlp", - "impl-serde", - "scale-info", - "tiny-keccak", -] - -[[package]] -name = "ethereum-types" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d215cbf040552efcbe99a38372fe80ab9d00268e20012b79fcd0f073edd8ee" -dependencies = [ - "ethbloom", - "fixed-hash", - "impl-codec", - "impl-rlp", - "impl-serde", - "primitive-types", - "scale-info", - "uint", -] - -[[package]] -name = "ethers-core" -version = "2.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d80cc6ad30b14a48ab786523af33b37f28a8623fc06afd55324816ef18fb1f" -dependencies = [ - "arrayvec", - "bytes", - "chrono", - "const-hex", - "elliptic-curve", - "ethabi", - "generic-array", - "k256", - "num_enum", - "open-fastrlp", - "rand 0.8.6", - "rlp", - "serde", - "serde_json", - "strum 0.26.3", - "tempfile", - "thiserror 1.0.69", - "tiny-keccak", - "unicode-xid", -] - -[[package]] -name = "ethers-signers" -version = "2.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "228875491c782ad851773b652dd8ecac62cda8571d3bc32a5853644dd26766c2" -dependencies = [ - "async-trait", - "coins-bip32", - "coins-bip39", - "const-hex", - "elliptic-curve", - "eth-keystore", - "ethers-core", - "rand 0.8.6", - "sha2 0.10.9", - "thiserror 1.0.69", - "tracing", -] - [[package]] name = "euclid" version = "0.22.14" @@ -2277,18 +2027,6 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" -[[package]] -name = "fixed-hash" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" -dependencies = [ - "byteorder", - "rand 0.8.6", - "rustc-hex", - "static_assertions", -] - [[package]] name = "fixedbitset" version = "0.4.2" @@ -2713,21 +2451,6 @@ 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 = "hifijson" version = "0.5.0" @@ -3149,44 +2872,6 @@ dependencies = [ "nom 7.1.3", ] -[[package]] -name = "impl-codec" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" -dependencies = [ - "parity-scale-codec", -] - -[[package]] -name = "impl-rlp" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28220f89297a075ddc7245cd538076ee98b01f2a9c23a53a4f1105d5a322808" -dependencies = [ - "rlp", -] - -[[package]] -name = "impl-serde" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc88fc67028ae3db0c853baa36269d398d5f45b6982f95549ff5def78c935cd" -dependencies = [ - "serde", -] - -[[package]] -name = "impl-trait-for-tuples" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -3471,21 +3156,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "konst" -version = "0.2.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" -dependencies = [ - "konst_macro_rules", -] - -[[package]] -name = "konst_macro_rules" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" - [[package]] name = "lab" version = "0.11.0" @@ -4421,31 +4091,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "open-fastrlp" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786393f80485445794f6043fd3138854dd109cc6c4bd1a6383db304c9ce9b9ce" -dependencies = [ - "arrayvec", - "auto_impl", - "bytes", - "ethereum-types", - "open-fastrlp-derive", -] - -[[package]] -name = "open-fastrlp-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "003b2be5c6c53c1cfeb0a238b8a1c3915cd410feb684457a36c10038f764bb1c" -dependencies = [ - "bytes", - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "openhuman" version = "0.63.7" @@ -4458,7 +4103,6 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", - "bitcoin", "block2 0.6.2", "bs58", "bytes", @@ -4478,8 +4122,6 @@ dependencies = [ "ed25519-dalek", "enigo", "env_logger", - "ethers-core", - "ethers-signers", "fantoccini", "filetime", "flate2", @@ -4494,6 +4136,7 @@ dependencies = [ "hostname", "hound", "iana-time-zone", + "k256", "keyring", "landlock", "lettre", @@ -4556,7 +4199,7 @@ dependencies = [ "unicode-width", "url", "urlencoding", - "uuid 1.23.1", + "uuid", "wait-timeout", "walkdir", "windows-sys 0.61.2", @@ -4671,34 +4314,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "parity-scale-codec" -version = "3.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" -dependencies = [ - "arrayvec", - "bitvec", - "byte-slice-cast", - "const_format", - "impl-trait-for-tuples", - "parity-scale-codec-derive", - "rustversion", - "serde", -] - -[[package]] -name = "parity-scale-codec-derive" -version = "3.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "parking" version = "2.2.1" @@ -4739,15 +4354,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "pbkdf2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" -dependencies = [ - "digest 0.10.7", -] - [[package]] name = "pbkdf2" version = "0.12.2" @@ -5082,20 +4688,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "primitive-types" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" -dependencies = [ - "fixed-hash", - "impl-codec", - "impl-rlp", - "impl-serde", - "scale-info", - "uint", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -5413,7 +5005,7 @@ dependencies = [ "lru", "palette", "serde", - "strum 0.28.0", + "strum", "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", @@ -5477,7 +5069,7 @@ dependencies = [ "line-clipping", "ratatui-core", "serde", - "strum 0.28.0", + "strum", "time", "unicode-segmentation", "unicode-width", @@ -5694,28 +5286,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "rlp" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" -dependencies = [ - "bytes", - "rlp-derive", - "rustc-hex", -] - -[[package]] -name = "rlp-derive" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e33d7b2abe0c340d8797fe2907d3f20d3b5ea5908683618bfe80df7f621f672a" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "rppal" version = "0.22.1" @@ -5768,12 +5338,6 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" -[[package]] -name = "rustc-hex" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" - [[package]] name = "rustc_version" version = "0.4.1" @@ -5870,15 +5434,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "salsa20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" -dependencies = [ - "cipher", -] - [[package]] name = "same-file" version = "1.0.6" @@ -5888,30 +5443,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scale-info" -version = "2.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346a3b32eba2640d17a9cb5927056b08f3de90f65b72fe09402c2ad07d684d0b" -dependencies = [ - "cfg-if", - "derive_more 1.0.0", - "parity-scale-codec", - "scale-info-derive", -] - -[[package]] -name = "scale-info-derive" -version = "2.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6630024bf739e2179b91fb424b28898baf819414262c5d376677dbff1fe7ebf" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "schannel" version = "0.1.29" @@ -5952,18 +5483,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "scrypt" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f9e24d2b632954ded8ab2ef9fea0a0c769ea56ea98bddbafbad22caeeadf45d" -dependencies = [ - "hmac", - "pbkdf2 0.11.0", - "salsa20", - "sha2 0.10.9", -] - [[package]] name = "sec1" version = "0.7.3" @@ -5978,26 +5497,6 @@ 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" @@ -6147,7 +5646,7 @@ dependencies = [ "thiserror 2.0.18", "time", "url", - "uuid 1.23.1", + "uuid", ] [[package]] @@ -6554,35 +6053,13 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros 0.26.4", -] - [[package]] name = "strum" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ - "strum_macros 0.28.0", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.117", + "strum_macros", ] [[package]] @@ -6971,7 +6448,7 @@ dependencies = [ "tracing", "url", "urlencoding", - "uuid 1.23.1", + "uuid", "wacore", "webpki-roots 1.0.7", "whatsapp-rust", @@ -7006,7 +6483,7 @@ dependencies = [ "tokio", "toml 0.8.23", "tracing", - "uuid 1.23.1", + "uuid", "walkdir", ] @@ -7021,7 +6498,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "thiserror 2.0.18", - "uuid 1.23.1", + "uuid", ] [[package]] @@ -7110,7 +6587,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "thiserror 2.0.18", - "uuid 1.23.1", + "uuid", ] [[package]] @@ -7187,15 +6664,17 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tinywallet" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", - "bitcoin", + "bech32 0.11.1", "bs58", + "coins-bip32", "coins-bip39", "ed25519-dalek", "hex", "hmac", + "ripemd", "serde", "serde_json", "sha2 0.10.9", @@ -7621,18 +7100,6 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" -[[package]] -name = "uint" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" -dependencies = [ - "byteorder", - "crunchy", - "hex", - "static_assertions", -] - [[package]] name = "uname" version = "0.1.1" @@ -7806,16 +7273,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" -dependencies = [ - "getrandom 0.2.17", - "serde", -] - [[package]] name = "uuid" version = "1.23.1" @@ -7881,7 +7338,7 @@ dependencies = [ "log", "md5", "once_cell", - "pbkdf2 0.12.2", + "pbkdf2", "prost", "rand 0.10.1", "serde", @@ -7959,7 +7416,7 @@ dependencies = [ "chrono", "ctr", "curve25519-dalek", - "derive_more 2.1.1", + "derive_more", "displaydoc", "ghash 0.6.0", "hex", @@ -7973,7 +7430,7 @@ dependencies = [ "sha2 0.10.9", "subtle", "thiserror 2.0.18", - "uuid 1.23.1", + "uuid", "waproto", "x25519-dalek", ] @@ -8247,7 +7704,7 @@ dependencies = [ "mac_address", "sha2 0.10.9", "thiserror 1.0.69", - "uuid 1.23.1", + "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4099f978ec..f47ca94210 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -388,13 +388,10 @@ fs2 = "0.4" # only by `openhuman::scheduler_gate::signals` to decide when to throttle # background LLM work on laptops. starship-battery = { version = "0.10", optional = true } -ethers-core = { version = "2.0.14", default-features = false, optional = true } -ethers-signers = { version = "2.0.14", default-features = false, optional = true } # Multi-chain wallet signing. # - bitcoin: P2WPKH PSBT build/sign/broadcast (includes secp256k1). # - ed25519-dalek: Solana transaction signing. # - bs58: Solana base58 addresses + Tron base58check addresses. -bitcoin = { version = "0.32", default-features = false, features = ["std", "secp-recovery", "rand-std"], optional = true } ed25519-dalek = { version = "2", default-features = false, features = ["std", "rand_core"] } bs58 = { version = "0.5", default-features = false, features = ["std", "check"] } # Shared BIP-39 mnemonic → seed for non-EVM chains (BTC P2WPKH derivation, @@ -455,7 +452,17 @@ tinydocs = { path = "vendor/tinydocs", default-features = false, optional = true # After cloning: `git submodule update --init vendor/tinywallet`. # # Optional: exclusive to the default-ON `web3` feature. -tinywallet = { path = "vendor/tinywallet", default-features = false, features = ["btc", "evm", "solana", "tron", "keccak", "key", "net", "asset", "client", "tx"], optional = true } +# Taken WITHOUT `tx` or `client`, which is the whole point: those are the only +# gates that pull the `bitcoin` crate and its native secp256k1 build, and +# transaction building now happens in the loaded `tinywallet` module instead. +# What stays is address validation, key derivation, the `Transport` seam, the +# wire contract, EIP-712 hashing and ERC-20 calldata — none of which needs a +# chain library. +tinywallet = { path = "vendor/tinywallet", default-features = false, features = ["btc", "evm", "solana", "tron", "keccak", "key", "net", "wire", "eip712", "abi", "x402"], optional = true } +# secp256k1 signing over the digests the wallet module hands back. Pure Rust, +# and already in the graph beneath `coins-bip32` (which derives the key being +# used), so naming it directly costs nothing and is what lets `bitcoin` go. +k256 = { version = "0.13", default-features = false, features = ["std", "ecdsa"], optional = true } [target.'cfg(windows)'.dependencies] # Windows: tokio-tungstenite uses native-tls (schannel) so wss:// @@ -708,10 +715,8 @@ voice = [ # to do with the wallet. Measured: excluding them costs 0, because tinyplace # pulls them in regardless. web3 = [ - "dep:bitcoin", + "dep:k256", "dep:curve25519-dalek", - "dep:ethers-core", - "dep:ethers-signers", "dep:coins-bip39", "dep:tinywallet", ] diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 00b0fb977e..9b62d48651 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -53,7 +53,7 @@ dependencies = [ "tokio-util", "toml 0.8.2", "url", - "uuid 1.23.1", + "uuid", "windows-sys 0.59.0", ] @@ -497,17 +497,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "auto_impl" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "autocfg" version = "1.5.0" @@ -602,16 +591,6 @@ 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" @@ -681,54 +660,6 @@ 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" @@ -856,12 +787,6 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" -[[package]] -name = "byte-slice-cast" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" - [[package]] name = "bytemuck" version = "1.25.0" @@ -1000,7 +925,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" dependencies = [ "byteorder", "fnv", - "uuid 1.23.1", + "uuid", ] [[package]] @@ -1156,7 +1081,7 @@ dependencies = [ "coins-bip32", "hmac", "once_cell", - "pbkdf2 0.12.2", + "pbkdf2", "rand 0.8.6", "sha2 0.10.9", "thiserror 1.0.69", @@ -1223,18 +1148,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const-hex" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20d9a563d167a9cce0f94153382b33cb6eded6dfabff03c69ad65a28ea1514e0" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "proptest", - "serde_core", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -1267,27 +1180,6 @@ dependencies = [ "tiny-keccak", ] -[[package]] -name = "const_format" -version = "0.2.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" -dependencies = [ - "const_format_proc_macros", - "konst", -] - -[[package]] -name = "const_format_proc_macros" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" -dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", -] - [[package]] name = "cookie" version = "0.18.1" @@ -1689,7 +1581,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" dependencies = [ "serde", - "uuid 1.23.1", + "uuid", ] [[package]] @@ -1723,33 +1615,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "derive_more" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" -dependencies = [ - "derive_more-impl 1.0.0", -] - [[package]] name = "derive_more" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "derive_more-impl 2.1.1", -] - -[[package]] -name = "derive_more-impl" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "derive_more-impl", ] [[package]] @@ -2218,122 +2090,6 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" -[[package]] -name = "eth-keystore" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fda3bf123be441da5260717e0661c25a2fd9cb2b2c1d20bf2e05580047158ab" -dependencies = [ - "aes", - "ctr", - "digest 0.10.7", - "hex", - "hmac", - "pbkdf2 0.11.0", - "rand 0.8.6", - "scrypt", - "serde", - "serde_json", - "sha2 0.10.9", - "sha3", - "thiserror 1.0.69", - "uuid 0.8.2", -] - -[[package]] -name = "ethabi" -version = "18.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7413c5f74cc903ea37386a8965a936cbeb334bd270862fdece542c1b2dcbc898" -dependencies = [ - "ethereum-types", - "hex", - "once_cell", - "regex", - "serde", - "serde_json", - "sha3", - "thiserror 1.0.69", - "uint", -] - -[[package]] -name = "ethbloom" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c22d4b5885b6aa2fe5e8b9329fb8d232bf739e434e6b87347c63bdd00c120f60" -dependencies = [ - "crunchy", - "fixed-hash", - "impl-codec", - "impl-rlp", - "impl-serde", - "scale-info", - "tiny-keccak", -] - -[[package]] -name = "ethereum-types" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d215cbf040552efcbe99a38372fe80ab9d00268e20012b79fcd0f073edd8ee" -dependencies = [ - "ethbloom", - "fixed-hash", - "impl-codec", - "impl-rlp", - "impl-serde", - "primitive-types", - "scale-info", - "uint", -] - -[[package]] -name = "ethers-core" -version = "2.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d80cc6ad30b14a48ab786523af33b37f28a8623fc06afd55324816ef18fb1f" -dependencies = [ - "arrayvec", - "bytes", - "chrono", - "const-hex", - "elliptic-curve", - "ethabi", - "generic-array", - "k256", - "num_enum", - "open-fastrlp", - "rand 0.8.6", - "rlp", - "serde", - "serde_json", - "strum", - "tempfile", - "thiserror 1.0.69", - "tiny-keccak", - "unicode-xid", -] - -[[package]] -name = "ethers-signers" -version = "2.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "228875491c782ad851773b652dd8ecac62cda8571d3bc32a5853644dd26766c2" -dependencies = [ - "async-trait", - "coins-bip32", - "coins-bip39", - "const-hex", - "elliptic-curve", - "eth-keystore", - "ethers-core", - "rand 0.8.6", - "sha2 0.10.9", - "thiserror 1.0.69", - "tracing", -] - [[package]] name = "euclid" version = "0.22.14" @@ -2457,18 +2213,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "fixed-hash" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" -dependencies = [ - "byteorder", - "rand 0.8.6", - "rustc-hex", - "static_assertions", -] - [[package]] name = "flate2" version = "1.1.9" @@ -3149,21 +2893,6 @@ 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 = "hifijson" version = "0.5.0" @@ -3524,44 +3253,6 @@ dependencies = [ "nom 7.1.3", ] -[[package]] -name = "impl-codec" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" -dependencies = [ - "parity-scale-codec", -] - -[[package]] -name = "impl-rlp" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28220f89297a075ddc7245cd538076ee98b01f2a9c23a53a4f1105d5a322808" -dependencies = [ - "rlp", -] - -[[package]] -name = "impl-serde" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc88fc67028ae3db0c853baa36269d398d5f45b6982f95549ff5def78c935cd" -dependencies = [ - "serde", -] - -[[package]] -name = "impl-trait-for-tuples" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "indexmap" version = "1.9.3" @@ -3908,21 +3599,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "konst" -version = "0.2.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" -dependencies = [ - "konst_macro_rules", -] - -[[package]] -name = "konst_macro_rules" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" - [[package]] name = "kurbo" version = "0.11.3" @@ -5002,31 +4678,6 @@ dependencies = [ "pathdiff", ] -[[package]] -name = "open-fastrlp" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786393f80485445794f6043fd3138854dd109cc6c4bd1a6383db304c9ce9b9ce" -dependencies = [ - "arrayvec", - "auto_impl", - "bytes", - "ethereum-types", - "open-fastrlp-derive", -] - -[[package]] -name = "open-fastrlp-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "003b2be5c6c53c1cfeb0a238b8a1c3915cd410feb684457a36c10038f764bb1c" -dependencies = [ - "bytes", - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "openhuman" version = "0.63.7" @@ -5039,7 +4690,6 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", - "bitcoin", "block2 0.6.2", "bs58", "bytes", @@ -5055,8 +4705,6 @@ dependencies = [ "dotenvy", "ed25519-dalek", "enigo", - "ethers-core", - "ethers-signers", "flate2", "fs2", "futures", @@ -5069,6 +4717,7 @@ dependencies = [ "hostname", "hound", "iana-time-zone", + "k256", "keyring", "lettre", "libc", @@ -5125,7 +4774,7 @@ dependencies = [ "tracing-subscriber", "url", "urlencoding", - "uuid 1.23.1", + "uuid", "wait-timeout", "walkdir", "windows-sys 0.61.2", @@ -5265,34 +4914,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "parity-scale-codec" -version = "3.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" -dependencies = [ - "arrayvec", - "bitvec", - "byte-slice-cast", - "const_format", - "impl-trait-for-tuples", - "parity-scale-codec-derive", - "rustversion", - "serde", -] - -[[package]] -name = "parity-scale-codec-derive" -version = "3.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "parking" version = "2.2.1" @@ -5339,15 +4960,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" -[[package]] -name = "pbkdf2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" -dependencies = [ - "digest 0.10.7", -] - [[package]] name = "pbkdf2" version = "0.12.2" @@ -5637,20 +5249,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "primitive-types" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" -dependencies = [ - "fixed-hash", - "impl-codec", - "impl-rlp", - "impl-serde", - "scale-info", - "uint", -] - [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -5713,21 +5311,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proptest" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" -dependencies = [ - "bitflags 2.11.1", - "num-traits", - "rand 0.9.4", - "rand_chacha 0.9.0", - "rand_xorshift", - "regex-syntax", - "unarray", -] - [[package]] name = "prost" version = "0.14.3" @@ -5954,15 +5537,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" -[[package]] -name = "rand_xorshift" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" -dependencies = [ - "rand_core 0.9.5", -] - [[package]] name = "raw-window-handle" version = "0.6.2" @@ -6266,28 +5840,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "rlp" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" -dependencies = [ - "bytes", - "rlp-derive", - "rustc-hex", -] - -[[package]] -name = "rlp-derive" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e33d7b2abe0c340d8797fe2907d3f20d3b5ea5908683618bfe80df7f621f672a" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "roxmltree" version = "0.20.0" @@ -6341,12 +5893,6 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" -[[package]] -name = "rustc-hex" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" - [[package]] name = "rustc_version" version = "0.4.1" @@ -6474,15 +6020,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "salsa20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" -dependencies = [ - "cipher", -] - [[package]] name = "same-file" version = "1.0.6" @@ -6492,30 +6029,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scale-info" -version = "2.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346a3b32eba2640d17a9cb5927056b08f3de90f65b72fe09402c2ad07d684d0b" -dependencies = [ - "cfg-if", - "derive_more 1.0.0", - "parity-scale-codec", - "scale-info-derive", -] - -[[package]] -name = "scale-info-derive" -version = "2.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6630024bf739e2179b91fb424b28898baf819414262c5d376677dbff1fe7ebf" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "schannel" version = "0.1.29" @@ -6537,7 +6050,7 @@ dependencies = [ "serde", "serde_json", "url", - "uuid 1.23.1", + "uuid", ] [[package]] @@ -6601,18 +6114,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "scrypt" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f9e24d2b632954ded8ab2ef9fea0a0c769ea56ea98bddbafbad22caeeadf45d" -dependencies = [ - "hmac", - "pbkdf2 0.11.0", - "salsa20", - "sha2 0.10.9", -] - [[package]] name = "sec1" version = "0.7.3" @@ -6627,26 +6128,6 @@ 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" @@ -6691,7 +6172,7 @@ checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ "bitflags 2.11.1", "cssparser", - "derive_more 2.1.1", + "derive_more", "log", "new_debug_unreachable", "phf 0.13.1", @@ -6819,7 +6300,7 @@ dependencies = [ "thiserror 2.0.18", "time", "url", - "uuid 1.23.1", + "uuid", ] [[package]] @@ -7364,28 +6845,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.117", -] - [[package]] name = "subtle" version = "2.6.1" @@ -7426,7 +6885,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", - "quote", "unicode-ident", ] @@ -7653,7 +7111,7 @@ dependencies = [ "thiserror 2.0.18", "time", "url", - "uuid 1.23.1", + "uuid", "walkdir", ] @@ -7898,7 +7356,7 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", "url", "urlpattern", - "uuid 1.23.1", + "uuid", "walkdir", ] @@ -8166,7 +7624,7 @@ dependencies = [ "tracing", "url", "urlencoding", - "uuid 1.23.1", + "uuid", "webpki-roots 1.0.7", ] @@ -8197,7 +7655,7 @@ dependencies = [ "tokio", "toml 0.8.2", "tracing", - "uuid 1.23.1", + "uuid", "walkdir", ] @@ -8212,7 +7670,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "thiserror 2.0.18", - "uuid 1.23.1", + "uuid", ] [[package]] @@ -8301,7 +7759,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "thiserror 2.0.18", - "uuid 1.23.1", + "uuid", ] [[package]] @@ -8369,15 +7827,17 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tinywallet" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", - "bitcoin", + "bech32 0.11.1", "bs58", + "coins-bip32", "coins-bip39", "ed25519-dalek", "hex", "hmac", + "ripemd", "serde", "serde_json", "sha2 0.10.9", @@ -8833,18 +8293,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "uint" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" -dependencies = [ - "byteorder", - "crunchy", - "hex", - "static_assertions", -] - [[package]] name = "uname" version = "0.1.1" @@ -8854,12 +8302,6 @@ dependencies = [ "libc", ] -[[package]] -name = "unarray" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" - [[package]] name = "unic-char-property" version = "0.9.0" @@ -9110,16 +8552,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" -dependencies = [ - "getrandom 0.2.17", - "serde", -] - [[package]] name = "uuid" version = "1.23.1" @@ -10469,7 +9901,7 @@ dependencies = [ "tokio", "tracing", "uds_windows", - "uuid 1.23.1", + "uuid", "windows-sys 0.61.2", "winnow 1.0.2", "zbus_macros", diff --git a/src/openhuman/modules/mod.rs b/src/openhuman/modules/mod.rs index 96b30ad2ad..53b5a4a314 100644 --- a/src/openhuman/modules/mod.rs +++ b/src/openhuman/modules/mod.rs @@ -28,6 +28,8 @@ //! //! - [`registry`] — the compiled-in set of loadable modules and their digests. //! - [`documents`] — the host half of the `tinydocs` module's three operations. +//! - [`wallet`] — the host half of the `tinywallet` module, which builds and +//! assembles transactions while the signing key stays in this process. //! - [`platform`] — which published artifact belongs to this host. //! - [`host`] — the module broker, connection and loader. //! - [`ops`] — resolving, loading, and reporting status. @@ -35,6 +37,7 @@ //! - [`boot`] — what happens at startup. pub mod boot; +#[cfg(feature = "documents")] pub mod documents; pub mod host; pub mod ops; @@ -42,6 +45,8 @@ pub mod platform; pub mod registry; pub mod schemas; pub mod types; +#[cfg(feature = "web3")] +pub mod wallet; pub use ops::ensure_loaded; pub use schemas::{all_controller_schemas, all_registered_controllers}; diff --git a/src/openhuman/modules/ops.rs b/src/openhuman/modules/ops.rs index b4ae108e65..418807457e 100644 --- a/src/openhuman/modules/ops.rs +++ b/src/openhuman/modules/ops.rs @@ -311,12 +311,12 @@ pub fn list(config: &Config) -> Vec { /// Status of one module. fn status_of(config: &Config, record: &ModuleRecord) -> ModuleStatus { let (state, detail) = match cached_resolution(record.id) { - Some(Resolution::Ready) => (ModuleState::Ready, None), - Some(Resolution::Failed(reason)) => (ModuleState::Failed, Some(reason)), - None if !config.modules.enabled => ( + _ if !config.modules.enabled => ( ModuleState::Unsupported, Some("modules are disabled in configuration".to_string()), ), + Some(Resolution::Ready) => (ModuleState::Ready, None), + Some(Resolution::Failed(reason)) => (ModuleState::Failed, Some(reason)), None => { let supported = platform::host_candidates() .iter() diff --git a/src/openhuman/modules/ops_tests.rs b/src/openhuman/modules/ops_tests.rs index 2727a987a8..d963a8cf79 100644 --- a/src/openhuman/modules/ops_tests.rs +++ b/src/openhuman/modules/ops_tests.rs @@ -39,21 +39,21 @@ fn list_reports_every_registry_entry() { } #[test] -fn a_module_is_available_or_unsupported_before_anything_is_loaded() { - // Which one depends on whether this host has a published artifact, and both - // are correct answers — what must not happen is `Ready` for something that - // has never been loaded. +fn a_module_reports_a_coherent_state() { + // The full suite runs in one process and another test may have loaded a + // module already. The process-global resolution cache intentionally cannot + // be reset because tinybus cannot unload a library, so Ready is valid here. for status in list(&offline_config()) { assert!( matches!( status.state, - ModuleState::Available | ModuleState::Unsupported + ModuleState::Available | ModuleState::Unsupported | ModuleState::Ready ), - "{} reported {:?} before any load", + "{} reported unexpected state {:?}", status.id, status.state ); - if status.state == ModuleState::Unsupported { + if matches!(status.state, ModuleState::Unsupported) { assert!( status.detail.is_some(), "an unsupported module must say why" diff --git a/src/openhuman/modules/registry.rs b/src/openhuman/modules/registry.rs index 0b025330d1..fd755015fe 100644 --- a/src/openhuman/modules/registry.rs +++ b/src/openhuman/modules/registry.rs @@ -97,8 +97,84 @@ const TINYDOCS: ModuleRecord = ModuleRecord { load: LoadPolicy::Lazy, }; +/// The `tinywallet` module: transaction building and assembly for four chains. +/// +/// Lazy for the same reason as [`TINYDOCS`], and more so: most sessions never +/// touch a wallet, and this artifact carries `bitcoin` and a native `secp256k1` +/// build that would otherwise be resident for all of them. +/// +/// **The signing key is never sent to this module.** It returns the bytes that +/// need signing and reassembles once this process has signed them — see +/// [`super::wallet`]. Nothing in its interface accepts key material. +const TINYWALLET: ModuleRecord = ModuleRecord { + id: "tinywallet", + description: "Transaction building and assembly for Bitcoin, EVM, Solana and Tron", + bus_name: "ai.tinyhumans.tinywallet.Wallet", + object_path: "/ai/tinyhumans/tinywallet/Wallet", + version: "0.2.0", + release_url: "https://github.com/tinyhumansai/tinywallet/releases/tag/v0.2.0", + assets: &[ + PlatformAsset { + host_key: "ubuntu-24.04-x86_64", + archive: "tinywallet-module-0.2.0-ubuntu-24.04-x86_64.tar.gz", + sha256: "827ae2721f4173f76247d7728c1383bd54a590608abb994a0ca2b4742ff2bd85", + }, + PlatformAsset { + host_key: "ubuntu-24.04-arm64", + archive: "tinywallet-module-0.2.0-ubuntu-24.04-arm64.tar.gz", + sha256: "5e014a6eca418c94d85f333bd804853a115ab26552e29ee6c779bb87497b116b", + }, + PlatformAsset { + host_key: "ubuntu-22.04-x86_64", + archive: "tinywallet-module-0.2.0-ubuntu-22.04-x86_64.tar.gz", + sha256: "be87ddf38ee1c2033fd568d65b22e7171c4dc24ee264cade62e20632bc5defc1", + }, + PlatformAsset { + host_key: "ubuntu-22.04-arm64", + archive: "tinywallet-module-0.2.0-ubuntu-22.04-arm64.tar.gz", + sha256: "393e68fc9a5184b3b0d71731ed066e1a0e06186aa59fe5fb8ab4a5f60cca833c", + }, + PlatformAsset { + host_key: "macos-26-arm64", + archive: "tinywallet-module-0.2.0-macos-26-arm64.tar.gz", + sha256: "ce87e1c3b4e6bbb2d41735a8bb10001001d0555cb7841fec09df5fb4d0bd99a4", + }, + PlatformAsset { + host_key: "macos-26-x86_64", + archive: "tinywallet-module-0.2.0-macos-26-x86_64.tar.gz", + sha256: "bc700a2993c403140e262e82e74dafc58d71a0b252a0e7c8aa5aa3c5f81cf55d", + }, + PlatformAsset { + host_key: "macos-15-arm64", + archive: "tinywallet-module-0.2.0-macos-15-arm64.tar.gz", + sha256: "6e61acdd6afa48efc72069e25bf9c918905e960522e60244db9e738db8f0207a", + }, + PlatformAsset { + host_key: "macos-15-x86_64", + archive: "tinywallet-module-0.2.0-macos-15-x86_64.tar.gz", + sha256: "a63da64043fc960ed13747c30ff6e8e396ba0cba7099b0616b9c6dbc54cb4a8d", + }, + PlatformAsset { + host_key: "windows-2025-x86_64", + archive: "tinywallet-module-0.2.0-windows-2025-x86_64.zip", + sha256: "bd4b7156dd031ce1821d563759fad52e73aad30aa9e8388ec0d486a8b804161e", + }, + PlatformAsset { + host_key: "windows-2022-x86_64", + archive: "tinywallet-module-0.2.0-windows-2022-x86_64.zip", + sha256: "8fbc5438adb86078b1ea4d0c6a4223daf298029e2f5db2dcd5666d446d9d6dd8", + }, + PlatformAsset { + host_key: "windows-11-arm64", + archive: "tinywallet-module-0.2.0-windows-11-arm64.zip", + sha256: "59e705e458248e8225a9dd5a103b6165e3559699921c7ff78e5b26b938e779a2", + }, + ], + load: LoadPolicy::Lazy, +}; + /// Every module this build can load. -pub const ALL: &[ModuleRecord] = &[TINYDOCS]; +pub const ALL: &[ModuleRecord] = &[TINYDOCS, TINYWALLET]; /// The record for `id`, if this build knows it. #[must_use] diff --git a/src/openhuman/modules/wallet.rs b/src/openhuman/modules/wallet.rs new file mode 100644 index 0000000000..664586c6be --- /dev/null +++ b/src/openhuman/modules/wallet.rs @@ -0,0 +1,295 @@ +//! Calling the `tinywallet` module: build a transaction there, sign it here. +//! +//! The host half of `ai.tinyhumans.tinywallet.Wallet`. One entry point, +//! [`sign_transaction`], drives both bus calls and does the signing in between, +//! so the four chain modules stay unaware that a bus is involved at all. +//! +//! # The private key does not cross the bus +//! +//! This is the whole shape of the thing. The module knows how to encode a +//! transaction for four chains and nothing about keys; this process knows the +//! key and nothing about transaction encoding. So: +//! +//! ```text +//! host --BuildUnsigned{fields, public key}--> module +//! host <--[digests to sign]------------------- module +//! host (signs locally, with a key the module never sees) +//! host --AttachSignature{fields, signatures}-> module +//! host <--{raw transaction, txid}------------- module +//! ``` +//! +//! A loaded module shares this address space, so this is not a hard isolation +//! boundary and is not claimed as one — a hostile module could read the seed out +//! of process memory regardless. It is a refusal to widen what crosses a +//! boundary that already exists, which is worth doing on its own terms and costs +//! only a second round trip over an in-process bus. +//! +//! # The fields are sent twice, deliberately +//! +//! `AttachSignature` re-sends everything `BuildUnsigned` was given rather than a +//! handle to something the module remembered. That is what lets the module hold +//! no state between the calls — no store, no bound on it, no expiry for a host +//! that never comes back. Building is deterministic, so the module rebuilds the +//! transaction the digests were computed over. +//! +//! # Two signing schemes, and the difference matters +//! +//! A `Secp256k1Prehash` payload is **already hashed** and must be signed with a +//! prehash entry point; hashing it again produces a valid signature over the +//! wrong thing. An `Ed25519` payload is the whole message and must **not** be +//! pre-hashed, because ed25519 hashes internally. The module tags every payload +//! with which it is, and [`sign_payload`] dispatches on the tag rather than on +//! the chain — so a chain that changes scheme cannot silently sign wrongly. + +use tinywallet::wire::{ + AttachRequest, PublicKey, Scheme, Signature, SignedTransaction, SigningPayload, SigningRequest, + TransactionSpec, UnsignedTransaction, +}; + +use super::{host, ops, registry}; +use crate::openhuman::config::Config; + +/// Registry id of the module these calls go to. +const MODULE_ID: &str = "tinywallet"; + +/// Why a wallet call did not produce a signed transaction. +/// +/// Three variants because the wallet tools map them onto three different +/// user-facing outcomes: something the caller can correct, something it cannot, +/// and a capability that is not present on this host at all. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WalletCallError { + /// The module is not loaded and cannot be: unsupported host, downloads off, + /// disabled in config, or a load that already failed in this process. + Unavailable(String), + /// The request was rejected. The caller can act on this. + InvalidInput(String), + /// Building, signing, or assembly failed. + Failed(String), +} + +impl std::fmt::Display for WalletCallError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unavailable(message) | Self::InvalidInput(message) | Self::Failed(message) => { + f.write_str(message) + } + } + } +} + +/// Build, sign, and assemble a transaction for `chain`. +/// +/// `secret` is the raw signing key and never leaves this process. `public_key` +/// is its compressed SEC1 form for secp256k1 chains, or the 32-byte public key +/// for Solana — the module needs it to check that the key controls the sender +/// before it will build anything. +/// +/// # Errors +/// +/// [`WalletCallError`] describing whether the request, the module, or the +/// signing was at fault. +pub async fn sign_transaction( + config: &Config, + transaction: &TransactionSpec, + secret: &[u8], + public_key: &[u8], +) -> Result { + let (runtime, record) = ready(config).await?; + let proxy = proxy(runtime, record)?; + let public_key = PublicKey { + key_hex: hex(public_key), + }; + + let chain = transaction.chain(); + log::debug!("[modules:wallet] build_unsigned chain={chain:?} module={MODULE_ID}"); + let unsigned: UnsignedTransaction = proxy + .call( + "BuildUnsigned", + (SigningRequest { + transaction: transaction.clone(), + public_key: public_key.clone(), + },), + ) + .await + .map_err(|error| classify(&error))?; + + // Signed here. Bitcoin returns one payload per selected input and the + // signatures must come back in the same order, so this maps rather than + // handling a single value. + let signatures = unsigned + .payloads + .iter() + .map(|payload| sign_payload(payload, secret)) + .collect::, _>>()?; + + log::debug!( + "[modules:wallet] attach_signature chain={chain:?} signatures={}", + signatures.len() + ); + proxy + .call( + "AttachSignature", + (AttachRequest { + transaction: transaction.clone(), + public_key, + signatures, + },), + ) + .await + .map_err(|error| classify(&error)) +} + +/// Sign one payload with whichever scheme it declares. +/// +/// Dispatches on the payload's own tag, never on the chain: the module is the +/// authority on what it needs signed and how, and a host that decided for itself +/// would sign wrongly the moment the two disagreed. +fn sign_payload(payload: &SigningPayload, secret: &[u8]) -> Result { + let bytes = unhex(&payload.bytes_hex)?; + match payload.scheme { + Scheme::Secp256k1Prehash => { + let digest: [u8; 32] = bytes.try_into().map_err(|_| { + WalletCallError::Failed( + "the module asked for a prehash signature over something that is not 32 bytes" + .to_string(), + ) + })?; + sign_secp256k1_prehash(&digest, secret) + } + Scheme::Ed25519 => sign_ed25519(&bytes, secret), + // `Scheme` is `#[non_exhaustive]`. A module newer than this build may + // name a scheme we cannot perform, and guessing would produce a + // signature over the wrong preimage. + _ => Err(WalletCallError::Failed( + "the module asked for a signing scheme this build does not implement".to_string(), + )), + } +} + +/// secp256k1 ECDSA over an already-computed digest, with the recovery id. +/// +/// `k256` rather than the `bitcoin` crate's `secp256k1`: this is the entire +/// reason the host can drop that dependency and its native C build, and `k256` +/// is already in the tree beneath `coins-bip32`, which derives the key being +/// used here. It produces low-`s` signatures by default, which Bitcoin requires +/// as relay policy (BIP-146) and Ethereum as consensus (EIP-2). +fn sign_secp256k1_prehash(digest: &[u8; 32], secret: &[u8]) -> Result { + use k256::ecdsa::SigningKey; + + let key = SigningKey::from_slice(secret) + .map_err(|_| WalletCallError::Failed("not a valid secp256k1 secret key".to_string()))?; + let (signature, recovery_id) = key + .sign_prehash_recoverable(digest) + .map_err(|_| WalletCallError::Failed("secp256k1 signing failed".to_string()))?; + + Ok(Signature::Secp256k1 { + rs_hex: hex(&signature.to_bytes()), + recovery_id: recovery_id.to_byte(), + }) +} + +/// ed25519 over the whole message. +fn sign_ed25519(message: &[u8], secret: &[u8]) -> Result { + use ed25519_dalek::{Signer as _, SigningKey}; + + let key: [u8; 32] = secret.try_into().map_err(|_| { + WalletCallError::Failed("an ed25519 secret key must be 32 bytes".to_string()) + })?; + let signature = SigningKey::from_bytes(&key).sign(message); + Ok(Signature::Ed25519 { + signature_hex: hex(&signature.to_bytes()), + }) +} + +/// Load the wallet module if it is not already serving. +/// +/// Callers do not have to invoke this — [`sign_transaction`] does — but a caller +/// that wraps its work in a deadline should, *outside* that deadline. A first +/// use may download and verify an artifact, and charging that against a +/// transaction timeout means the first transfer a user ever makes is the one +/// that fails. +/// +/// # Errors +/// +/// [`WalletCallError::Unavailable`]. +pub async fn ensure_ready(config: &Config) -> Result<(), WalletCallError> { + ops::ensure_loaded(config, MODULE_ID) + .await + .map_err(WalletCallError::Unavailable) +} + +/// Ensure the module is serving and hand back what a call needs. +async fn ready( + config: &Config, +) -> Result<(&'static host::ModuleRuntime, &'static super::ModuleRecord), WalletCallError> { + ops::ensure_loaded(config, MODULE_ID) + .await + .map_err(WalletCallError::Unavailable)?; + let record = registry::find(MODULE_ID) + .ok_or_else(|| WalletCallError::Unavailable(format!("unknown module '{MODULE_ID}'")))?; + let runtime = host::runtime() + .await + .map_err(|_| WalletCallError::Unavailable("the module bus is not running".to_string()))?; + Ok((runtime, record)) +} + +/// A proxy for the module's object. +fn proxy( + runtime: &'static host::ModuleRuntime, + record: &super::ModuleRecord, +) -> Result { + runtime + .proxy(record.bus_name, record.object_path) + .map_err(|error| WalletCallError::Failed(error.to_string())) +} + +/// Map a bus failure onto the shape a caller can act on. +/// +/// The wire name is the contract; the message is for a human. An unrecognised +/// name is `Failed` rather than `InvalidInput`, because telling a caller its +/// input was wrong when it was not points at the wrong fix. +fn classify(error: &tinybus::Error) -> WalletCallError { + let message = error.to_string(); + match error.wire_name() { + "ai.tinyhumans.tinywallet.Error.InvalidInput" => WalletCallError::InvalidInput(message), + "ai.tinyhumans.tinywallet.Error.UnsupportedChain" => WalletCallError::Unavailable(message), + // The module is loaded but not answering: refused, faulted, or gone. + name if name.contains("ModuleUnavailable") => WalletCallError::Unavailable(message), + _ => WalletCallError::Failed(message), + } +} + +/// Lowercase hex, unprefixed — the form every field in the wire contract uses. +fn hex(bytes: &[u8]) -> String { + bytes.iter().fold(String::new(), |mut out, byte| { + use std::fmt::Write as _; + let _ = write!(out, "{byte:02x}"); + out + }) +} + +/// Decode lowercase hex from the module. +fn unhex(value: &str) -> Result, WalletCallError> { + if !value.len().is_multiple_of(2) { + return Err(WalletCallError::Failed( + "the module returned a payload with an odd number of hex characters".to_string(), + )); + } + value + .as_bytes() + .chunks_exact(2) + .map(|pair| { + std::str::from_utf8(pair) + .ok() + .and_then(|pair| u8::from_str_radix(pair, 16).ok()) + .ok_or_else(|| { + WalletCallError::Failed("the module returned a non-hex payload".to_string()) + }) + }) + .collect() +} + +#[cfg(test)] +#[path = "wallet_tests.rs"] +mod tests; diff --git a/src/openhuman/modules/wallet_tests.rs b/src/openhuman/modules/wallet_tests.rs new file mode 100644 index 0000000000..ba784a1e77 --- /dev/null +++ b/src/openhuman/modules/wallet_tests.rs @@ -0,0 +1,222 @@ +//! Tests for the wallet module client. +//! +//! Nothing here loads a module. What is testable without one is the part that +//! decides what a caller does next: how a bus failure is classified, that the +//! unavailable path is reached without a broker, and — most importantly — that +//! the local signing produces what the module asked for. The round trips +//! themselves are covered where they can be honest: `tinywallet`'s own loader +//! E2E, which drives a real module over a real broker. + +use tinywallet::wire::{Scheme, Signature, SigningPayload, TransactionSpec}; +use tinywallet::Chain; + +use super::{classify, sign_payload, WalletCallError}; +use crate::openhuman::config::Config; + +/// The BIP-39 test vector mnemonic. Never use it for real funds. +const VECTOR: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + +/// A config with modules enabled but nothing fetchable. +fn offline_config() -> Config { + let mut config = Config::default(); + config.modules.enabled = true; + config.modules.allow_download = false; + config +} + +/// A bus failure carrying `name`. +fn failure(name: &str) -> tinybus::Error { + tinybus::Error::MethodFailed { + name: name.to_string(), + message: "something went wrong".to_string(), + } +} + +fn evm_secret() -> Vec { + tinywallet::key::derive(Chain::Evm, VECTOR, "m/44'/60'/0'/0/0") + .expect("the vector mnemonic derives") + .secret_bytes() + .to_vec() +} + +#[test] +fn an_invalid_input_is_reported_as_something_the_caller_can_fix() { + assert!(matches!( + classify(&failure("ai.tinyhumans.tinywallet.Error.InvalidInput")), + WalletCallError::InvalidInput(_) + )); +} + +#[test] +fn a_build_failure_is_not_an_input_error() { + // Telling a caller its transaction was wrong when the encoder broke points + // at the wrong fix. + assert!(matches!( + classify(&failure("ai.tinyhumans.tinywallet.Error.BuildFailed")), + WalletCallError::Failed(_) + )); +} + +#[test] +fn an_unsupported_chain_reads_as_unavailable_not_invalid() { + // Defensive: the current module never emits this name — an unrecognised + // transaction shape comes back as `InvalidInput` instead. The arm exists + // so a future module that does distinguish "chain not compiled in" from + // "bad request" is classified as a missing capability rather than as the + // caller's fault. + assert!(matches!( + classify(&failure("ai.tinyhumans.tinywallet.Error.UnsupportedChain")), + WalletCallError::Unavailable(_) + )); +} + +#[test] +fn an_unrecognised_wire_name_is_a_failure_not_an_input_error() { + assert!(matches!( + classify(&failure("ai.tinyhumans.tinywallet.Error.SomethingNewer")), + WalletCallError::Failed(_) + )); +} + +#[test] +fn a_missing_module_reads_as_unavailable() { + assert!(matches!( + classify(&failure("ai.tinyhumans.tinybus.Error.ModuleUnavailable")), + WalletCallError::Unavailable(_) + )); +} + +#[test] +fn every_error_renders_as_its_message() { + assert_eq!( + WalletCallError::InvalidInput("bad recipient".to_string()).to_string(), + "bad recipient" + ); + for error in [ + WalletCallError::Unavailable("gone".to_string()), + WalletCallError::Failed("encoder stopped".to_string()), + ] { + assert!(!error.to_string().is_empty()); + } +} + +#[test] +fn a_prehash_payload_is_signed_without_being_hashed_again() { + // The single most dangerous confusion in this file: hashing a digest a + // second time yields a valid signature over the wrong preimage, which the + // chain accepts as a different transaction or rejects with no explanation. + // Verified by recovering the signature against the digest itself. + use k256::ecdsa::signature::hazmat::PrehashVerifier as _; + use k256::ecdsa::{Signature as K256Signature, SigningKey, VerifyingKey}; + + let secret = evm_secret(); + let digest = [0x42u8; 32]; + let payload = SigningPayload { + bytes_hex: "42".repeat(32), + scheme: Scheme::Secp256k1Prehash, + }; + + let Signature::Secp256k1 { rs_hex, .. } = sign_payload(&payload, &secret).unwrap() else { + panic!("a prehash payload must produce a secp256k1 signature"); + }; + + let raw: Vec = (0..rs_hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&rs_hex[i..i + 2], 16).unwrap()) + .collect(); + let signature = K256Signature::from_slice(&raw).unwrap(); + let verifying: VerifyingKey = *SigningKey::from_slice(&secret).unwrap().verifying_key(); + + verifying + .verify_prehash(&digest, &signature) + .expect("the signature must verify against the digest, not a rehash of it"); +} + +#[test] +fn a_prehash_payload_that_is_not_thirty_two_bytes_is_refused() { + let payload = SigningPayload { + bytes_hex: "42".repeat(16), + scheme: Scheme::Secp256k1Prehash, + }; + assert!(matches!( + sign_payload(&payload, &evm_secret()), + Err(WalletCallError::Failed(_)) + )); +} + +#[test] +fn an_ed25519_payload_is_signed_over_the_whole_message() { + // ed25519 hashes internally, so the payload is the message. Verified + // against the public key rather than merely checked for a length. + use ed25519_dalek::{Signature as EdSignature, SigningKey, Verifier as _}; + + let derived = tinywallet::key::derive(Chain::Solana, VECTOR, "m/44'/501'/0'/0'").unwrap(); + let secret = derived.secret_bytes(); + let message = b"a solana message that is clearly longer than thirty-two bytes"; + + let payload = SigningPayload { + bytes_hex: message.iter().fold(String::new(), |mut out, b| { + use std::fmt::Write as _; + let _ = write!(out, "{b:02x}"); + out + }), + scheme: Scheme::Ed25519, + }; + + let Signature::Ed25519 { signature_hex } = sign_payload(&payload, secret).unwrap() else { + panic!("an ed25519 payload must produce an ed25519 signature"); + }; + + let raw: Vec = (0..signature_hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&signature_hex[i..i + 2], 16).unwrap()) + .collect(); + let key: [u8; 32] = secret.try_into().unwrap(); + SigningKey::from_bytes(&key) + .verifying_key() + .verify(message, &EdSignature::from_slice(&raw).unwrap()) + .expect("the signature must verify over the message"); +} + +#[test] +fn a_malformed_payload_from_the_module_is_refused_rather_than_signed() { + for bytes_hex in ["abc", "zz".repeat(32).as_str(), "aéb"] { + let payload = SigningPayload { + bytes_hex: bytes_hex.to_string(), + scheme: Scheme::Secp256k1Prehash, + }; + assert!( + matches!( + sign_payload(&payload, &evm_secret()), + Err(WalletCallError::Failed(_)) + ), + "{bytes_hex:?} should be refused" + ); + } +} + +#[tokio::test] +async fn a_disabled_host_reports_unavailable_without_starting_a_broker() { + let mut config = offline_config(); + config.modules.enabled = false; + + let spec = TransactionSpec::Evm { + to: "0x3535353535353535353535353535353535353535".to_string(), + value_wei: "1".to_string(), + data_hex: "0x".to_string(), + nonce: 0, + gas_limit: 21_000, + gas_price_wei: "1".to_string(), + chain_id: 1, + }; + + assert!(matches!( + super::sign_transaction(&config, &spec, &evm_secret(), &[0x02; 33]).await, + Err(WalletCallError::Unavailable(_)) + )); + assert!(matches!( + super::ensure_ready(&config).await, + Err(WalletCallError::Unavailable(_)) + )); +} diff --git a/src/openhuman/web3/wallet/abi.rs b/src/openhuman/web3/wallet/abi.rs index 6aae3929d6..9cac177438 100644 --- a/src/openhuman/web3/wallet/abi.rs +++ b/src/openhuman/web3/wallet/abi.rs @@ -1,40 +1,43 @@ -use std::str::FromStr; - -use ethers_core::abi::{Function, Param, ParamType, StateMutability, Token}; -use ethers_core::types::{Address, U256}; +//! ERC-20 calldata, delegated to `tinywallet`. +//! +//! This used to hand-build an `ethers_core::abi::Function` to encode one call. +//! That worked, and it cost the whole `ethers-core` ABI machinery — a type +//! grammar, a bignum, and their tails — to produce a four-byte selector +//! followed by two 32-byte words. +//! +//! `tinywallet::abi` owns that encoding now, over `sha3` alone, and +//! deliberately sits outside its `tx` gate: calldata is an *input* to building +//! a transaction, so a host that builds elsewhere still needs it locally rather +//! than paying a bus round trip for keccak over 68 bytes. +//! +//! What stays here is the error shape. The wallet's RPC surface and its agent +//! tool both report failures as a plain `String`, so the crate's typed error is +//! flattened rather than propagated — the same host-side mapping every other +//! call in this domain does. +/// ABI-encode an ERC-20 `transfer(address,uint256)` call. +/// +/// `amount_raw` is a base-10 string in the token's smallest unit: an +/// 18-decimal token puts ordinary balances past `u64`, and a caller almost +/// always has the value as text from an RPC or a user. +/// +/// # Errors +/// +/// A human-readable message if the recipient is not a valid EVM address or the +/// amount is not a non-negative integer that fits in 256 bits. pub fn encode_erc20_transfer(to_address: &str, amount_raw: &str) -> Result { - let to = Address::from_str(to_address.trim()) - .map_err(|e| format!("invalid EVM recipient address '{to_address}': {e}"))?; - let amount = U256::from_dec_str(amount_raw.trim()) - .map_err(|_| format!("amount '{amount_raw}' is not a valid non-negative integer"))?; - #[allow(deprecated)] - let function = Function { - name: "transfer".to_string(), - inputs: vec![ - Param { - name: "to".to_string(), - kind: ParamType::Address, - internal_type: None, - }, - Param { - name: "amount".to_string(), - kind: ParamType::Uint(256), - internal_type: None, - }, - ], - outputs: vec![Param { - name: "".to_string(), - kind: ParamType::Bool, - internal_type: None, - }], - constant: None, - state_mutability: StateMutability::NonPayable, - }; - let bytes = function - .encode_input(&[Token::Address(to), Token::Uint(amount)]) - .map_err(|e| format!("failed to encode ERC20 transfer calldata: {e}"))?; - Ok(format!("0x{}", hex::encode(bytes))) + tinywallet::abi::encode_erc20_transfer(to_address, amount_raw).map_err(|error| match error { + tinywallet::abi::Error::InvalidRecipient { .. } => { + format!("invalid EVM recipient address '{to_address}': {error}") + } + // Preserves the wording the previous implementation used, because the + // agent tool's schema documents it and a model reads it to correct + // itself. + tinywallet::abi::Error::InvalidAmount { .. } => { + format!("amount '{amount_raw}' is not a valid non-negative integer") + } + _ => error.to_string(), + }) } #[cfg(test)] @@ -57,4 +60,32 @@ mod tests { .unwrap(); assert!(calldata.starts_with("0xa9059cbb")); } + + #[test] + fn the_encoding_is_unchanged_by_the_delegation() { + // Pinned against the bytes the `ethers-core` implementation produced, + // so moving the encoder cannot quietly change what gets signed. + assert_eq!( + encode_erc20_transfer("0x1111111111111111111111111111111111111111", "1000000").unwrap(), + "0xa9059cbb\ + 0000000000000000000000001111111111111111111111111111111111111111\ + 00000000000000000000000000000000000000000000000000000000000f4240" + ); + } + + #[test] + fn an_invalid_recipient_still_names_the_address() { + let error = encode_erc20_transfer("not-an-address", "5").unwrap_err(); + assert!(error.contains("not-an-address"), "{error}"); + } + + #[test] + fn an_invalid_amount_still_reads_the_way_the_tool_schema_says() { + let error = + encode_erc20_transfer("0x1111111111111111111111111111111111111111", "-1").unwrap_err(); + assert!( + error.contains("not a valid non-negative integer"), + "{error}" + ); + } } diff --git a/src/openhuman/web3/wallet/chains/btc.rs b/src/openhuman/web3/wallet/chains/btc.rs index 71024c6f05..c025836fd7 100644 --- a/src/openhuman/web3/wallet/chains/btc.rs +++ b/src/openhuman/web3/wallet/chains/btc.rs @@ -7,16 +7,6 @@ //! seeded with a standard recovery phrase + this path produces a `bc1q…` //! native segwit address. -use bitcoin::absolute::LockTime; -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; @@ -24,8 +14,8 @@ 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, TxLookupInfo, - TxReceiptInfo, TxState, TxStatusInfo, + compressed_public_key, ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction, + TxLookupInfo, TxReceiptInfo, TxState, TxStatusInfo, }; use super::super::ops::{secret_material, WalletChain}; use super::super::rpc::{rest_get_json, rest_get_text, rest_post_text}; @@ -103,8 +93,6 @@ pub fn validate_btc_sender_address(addr: &str) -> Result { result } -use std::str::FromStr; - pub async fn native_balance(address: &str) -> Result { validate_btc_address(address)?; let base = rpc_url_for_chain(WalletChain::Btc); @@ -133,15 +121,6 @@ pub async fn broadcast_raw_hex(tx_hex: &str) -> Result { rest_post_text(&url, tx_hex, "text/plain").await } -/// Best-effort lookup of an address's spending scriptPubKey by parsing it. -fn script_pubkey_for_addr(addr: &str) -> Result { - let parsed = Address::from_str(addr) - .map_err(|e| format!("invalid address '{addr}': {e}"))? - .require_network(Network::Bitcoin) - .map_err(|e| format!("address '{addr}' is not mainnet: {e}"))?; - Ok(parsed.script_pubkey()) -} - /// Derive the P2WPKH signing key for `derivation_path` from a BIP-39 mnemonic. /// /// Delegates to the vendored [`tinywallet`] crate, which owns BIP-32 @@ -150,16 +129,16 @@ fn script_pubkey_for_addr(addr: &str) -> Result { fn derive_btc_private_key( mnemonic: &str, derivation_path: &str, -) -> Result<(PrivateKey, CompressedPublicKey), String> { +) -> Result<(Vec, Vec), String> { let derived = tinywallet::key::derive(tinywallet::Chain::Btc, mnemonic, derivation_path) .map_err(|e| e.to_string())?; - let secret = bitcoin::secp256k1::SecretKey::from_slice(derived.secret_bytes()) - .map_err(|e| format!("tinywallet returned an unusable BTC key: {e}"))?; - let secp = Secp256k1::signing_only(); - let private_key = PrivateKey::new(secret, 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)) + let secret = derived.secret_bytes().to_vec(); + // Compressed, because a P2WPKH witness program is defined over the + // compressed encoding — the uncompressed form yields a valid-looking + // address for an account holding no funds. + let public_key = compressed_public_key(&secret) + .map_err(|_| "tinywallet returned an unusable BTC key".to_string())?; + Ok((secret, public_key)) } /// Select UTXOs to cover `amount_sats + fee_sats`, returning the selected @@ -223,70 +202,39 @@ pub async fn execute_btc_quote(mut quote: PreparedTransaction) -> Result 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, + value: utxo.value, + }) + .collect(), }; + // One signature per selected input, produced in this process and returned + // to the module in input order — see `modules::wallet`. + let signed = crate::openhuman::modules::wallet::sign_transaction( + &config, + &transaction, + &private_key, + &public_key, + ) + .await + .map_err(|e| format!("failed to sign BTC transaction: {e}"))?; - // 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) { - input.witness = witness; - } - - let tx_hex = serialize_hex(&tx); + let tx_hex = signed.raw; let txid_hex = broadcast_raw_hex(&tx_hex).await?; quote.estimated_fee_raw = fee_sats.to_string(); quote.status = PreparedStatus::Broadcasted; @@ -546,6 +494,17 @@ mod tests { assert!(err.contains("insufficient"), "got: {err}"); } + // Drives the real wallet module, so it must be the only such test in its + // process: tinybus never unloads a module, and the module bus belongs to + // whichever tokio runtime created it — a second `#[tokio::test]` finds a + // broker whose tasks died with the first and the call fails with + // "connection closed". Verified passing in isolation: + // + // cargo test -p openhuman --lib --features "$(bash scripts/ci/product-features.sh)" \ + // execute_btc_quote_builds_psbt_signs_and_broadcasts -- --ignored --test-threads=1 + // + // Same constraint tinydocs documents for its module-backed tool tests. + #[ignore = "drives the loaded wallet module; must run alone in its process"] #[tokio::test] async fn execute_btc_quote_builds_psbt_signs_and_broadcasts() { let _guard = TEST_LOCK.lock(); @@ -683,17 +642,23 @@ mod tests { // 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: + let (secret, pubkey) = derive_btc_private_key(mnemonic, "m/84'/0'/0'/0/0").unwrap(); + assert_eq!(secret.len(), 32); + // Compressed SEC1. The uncompressed form would be 65 bytes and would + // hash to a different — spendable by nobody — address. + assert_eq!(pubkey.len(), 33); + assert!(matches!(pubkey[0], 0x02 | 0x03)); + + // The known-good vector for this mnemonic and path, unchanged by the + // move off the `bitcoin` crate. Derived through `tinywallet`, which is + // the same code the address in `execute_btc_quote` comes from. + let derived = + tinywallet::key::derive(tinywallet::Chain::Btc, mnemonic, "m/84'/0'/0'/0/0").unwrap(); assert_eq!( - addr.to_string(), + derived.address(), "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" ); - let _ = secp; // suppress unused + assert_eq!(derived.secret_bytes(), secret.as_slice()); } #[tokio::test] diff --git a/src/openhuman/web3/wallet/chains/evm.rs b/src/openhuman/web3/wallet/chains/evm.rs index d3cc944ace..172b5805af 100644 --- a/src/openhuman/web3/wallet/chains/evm.rs +++ b/src/openhuman/web3/wallet/chains/evm.rs @@ -6,11 +6,6 @@ //! `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::{LocalWallet, Signer}; use log::debug; use serde_json::json; @@ -21,7 +16,7 @@ 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, + compressed_public_key, hex_to_u128, u128_to_hex, ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction, RawBroadcastResult, TxLookupInfo, TxReceiptInfo, TxState, TxStatusInfo, }; use super::super::ops::{secret_material, WalletChain}; @@ -29,9 +24,9 @@ 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 { +pub async fn evm_balance(network: EvmNetwork, address: &str) -> Result { let raw: String = evm_rpc_call(network, "eth_getBalance", json!([address, "latest"])).await?; - hex_to_u256(&raw) + hex_to_u128(&raw) } /// Sign an EVM transaction `(to, value, data)` from the wallet's encrypted @@ -43,10 +38,10 @@ pub async fn evm_balance(network: EvmNetwork, address: &str) -> Result, -) -> Result<(String, U256), String> { +) -> Result<(String, u128), String> { 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?; @@ -56,20 +51,22 @@ async fn sign_and_broadcast( ) .await? .value; - // Derivation is delegated to the vendored `tinywallet` crate, which owns - // BIP-32 secp256k1 for every chain; ethers is left holding only the - // signing itself. Custody is unchanged — the mnemonic is decrypted from - // the keyring above and handed over as a `&str` that is not retained. + // Derivation stays here, and so does the key. `tinywallet::key` owns BIP-32 + // for every chain; what changed is that the *signing* no longer happens in + // this binary either — the transaction is encoded by the loaded wallet + // module, which hands back a digest for this process to sign. Custody is + // unchanged: the mnemonic is decrypted from the keyring above, handed over + // as a `&str` that is not retained, and never crosses the bus. let derived = tinywallet::key::derive( tinywallet::Chain::Evm, mnemonic.as_str(), &secret.derivation_path, ) .map_err(|e| e.to_string())?; - let signer = LocalWallet::from_bytes(derived.secret_bytes()) - .map_err(|e| format!("failed to build EVM signer from the derived key: {e}"))?; - let from = Address::from_str(from_address) - .map_err(|e| format!("invalid stored EVM sender address '{from_address}': {e}"))?; + let public_key = compressed_public_key(derived.secret_bytes())?; + + let to = tinywallet::address::evm::validate(to) + .map_err(|e| format!("invalid EVM target address '{to}': {e}"))?; 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 @@ -81,16 +78,21 @@ async fn sign_and_broadcast( ) .await?; let gas_price_hex: String = rpc_call_to(&rpc_url, "eth_gasPrice", json!([])).await?; + let value = value_raw + .trim() + .parse::() + .map_err(|e| format!("invalid native value '{value_raw}': {e}"))?; let mut estimate_tx = json!({ "from": from_address, - "to": format!("{tx_to:#x}"), - "value": u256_to_hex(tx_value), + "to": to, + "value": u128_to_hex(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(); + let chain_id = u64::try_from(hex_to_u128(&chain_id_hex)?) + .map_err(|_| format!("EVM RPC reported an implausible chain_id '{chain_id_hex}'"))?; if chain_id != network.chain_id() { return Err(format!( "EVM RPC chain_id mismatch: rpc reported {} but network {} expects {}", @@ -99,34 +101,35 @@ async fn sign_and_broadcast( 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 nonce = u64::try_from(hex_to_u128(&nonce_hex)?) + .map_err(|_| format!("EVM RPC reported an implausible nonce '{nonce_hex}'"))?; + let gas_price = hex_to_u128(&gas_price_hex)?; + let gas_limit = u64::try_from(hex_to_u128(&gas_hex)?) + .map_err(|_| format!("EVM RPC reported an implausible gas limit '{gas_hex}'"))?; - let tx_data_bytes = tx_data - .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?; - let fee = gas_price.checked_mul(gas).unwrap_or_default(); + let transaction = tinywallet::wire::TransactionSpec::Evm { + to, + value_wei: value.to_string(), + data_hex: tx_data.unwrap_or_default(), + nonce, + gas_limit, + gas_price_wei: gas_price.to_string(), + chain_id, + }; + let signed = crate::openhuman::modules::wallet::sign_transaction( + &config, + &transaction, + derived.secret_bytes(), + &public_key, + ) + .await + .map_err(|e| format!("failed to sign EVM transaction: {e}"))?; + + let tx_hash: String = + rpc_call_to(&rpc_url, "eth_sendRawTransaction", json!([signed.raw])).await?; + let fee = gas_price + .checked_mul(u128::from(gas_limit)) + .unwrap_or_default(); debug!( "{LOG_PREFIX} sign_and_broadcast network={} tx_hash={}", network.as_str(), @@ -138,15 +141,16 @@ async fn sign_and_broadcast( pub async fn execute_evm_quote(mut quote: PreparedTransaction) -> Result { let network = quote.evm_network.unwrap_or(EvmNetwork::EthereumMainnet); let (tx_to, tx_value, tx_data) = match quote.kind { + // A native transfer pays the recipient directly and carries no data. PreparedKind::NativeTransfer => ( - Address::from_str("e.to_address).map_err(|e| { + tinywallet::address::evm::validate("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) - })?, + quote.amount_raw.clone(), None, ), + // A token transfer pays the *contract* zero and puts the recipient and + // the amount in the calldata instead. PreparedKind::TokenTransfer => { let token = quote .token_address @@ -154,16 +158,16 @@ pub async fn execute_evm_quote(mut quote: PreparedTransaction) -> Result Result { let account = super::super::execution::require_evm_account().await?; - let tx_to = Address::from_str(to.trim()) - .map_err(|e| format!("invalid EVM target address '{to}': {e}"))?; - let tx_value = U256::from_dec_str(value_raw.trim()) - .map_err(|e| format!("invalid native value '{value_raw}': {e}"))?; let data = data_hex .map(|d| super::super::execution::validate_calldata(&d)) .transpose()?; - let (tx_hash, fee) = sign_and_broadcast(network, &account, tx_to, tx_value, data).await?; + // `to` and `value_raw` are validated inside `sign_and_broadcast`, which is + // the only place that needs them parsed. + let (tx_hash, fee) = sign_and_broadcast(network, &account, to, value_raw, data).await?; Ok(RawBroadcastResult { transaction_hash: tx_hash.clone(), explorer_url: explorer_tx_url_for_evm_network(network, &tx_hash), @@ -235,19 +237,22 @@ pub async fn tx_status(network: EvmNetwork, hash: &str) -> Result { let head_hex: String = rpc_call_to(&rpc_url, "eth_blockNumber", json!([])).await?; - hex_to_u256(&head_hex) - .ok() - .map(|head| head.as_u64().saturating_sub(bn).saturating_add(1)) + hex_to_u128(&head_hex).ok().map(|head| { + u64::try_from(head) + .unwrap_or(u64::MAX) + .saturating_sub(bn) + .saturating_add(1) + }) } None => None, }; @@ -290,20 +295,20 @@ pub async fn tx_receipt(network: EvmNetwork, hash: &str) -> Result g.checked_mul(p).map(|f| f.to_string()), _ => None, @@ -522,6 +527,17 @@ mod tests { assert!(info.found); } + // Drives the real wallet module, so it must be the only such test in its + // process: tinybus never unloads a module, and the module bus belongs to + // whichever tokio runtime created it — a second `#[tokio::test]` finds a + // broker whose tasks died with the first and the call fails with + // "connection closed". Verified passing in isolation: + // + // cargo test -p openhuman --lib --features "$(bash scripts/ci/product-features.sh)" \ + // sign_and_broadcast_evm_signs_raw_calldata -- --ignored --test-threads=1 + // + // Same constraint tinydocs documents for its module-backed tool tests. + #[ignore = "drives the loaded wallet module; must run alone in its process"] #[tokio::test] async fn sign_and_broadcast_evm_signs_raw_calldata() { let _guard = TEST_LOCK.lock(); diff --git a/src/openhuman/web3/wallet/chains/tron.rs b/src/openhuman/web3/wallet/chains/tron.rs index 8847c1be34..f5f5e3b51f 100644 --- a/src/openhuman/web3/wallet/chains/tron.rs +++ b/src/openhuman/web3/wallet/chains/tron.rs @@ -5,7 +5,6 @@ //! 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 log::debug; use serde::Deserialize; use serde_json::{json, Value}; @@ -15,8 +14,8 @@ 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, TxLookupInfo, - TxReceiptInfo, TxState, TxStatusInfo, + compressed_public_key, ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction, + TxLookupInfo, TxReceiptInfo, TxState, TxStatusInfo, }; use super::super::ops::{secret_material, WalletChain}; use super::super::rpc::rest_post_json; @@ -92,21 +91,252 @@ struct TriggerSmartContractResponse { transaction: CreateTransactionResponse, } +#[derive(Debug)] +enum TronTransferVerification { + Native { amount_sun: u64 }, + Trc20 { parameter_hex: String }, +} + +#[derive(Debug)] +enum ProtoValue<'a> { + Varint(u64), + Bytes(&'a [u8]), + Other, +} + +#[derive(Debug)] +struct ProtoField<'a> { + number: u64, + value: ProtoValue<'a>, +} + +fn tron_transaction_spec( + raw_tx: &CreateTransactionResponse, + expected_to: String, + transfer: &TronTransferVerification, +) -> Result { + let recomputed_txid = recompute_tron_txid(&raw_tx.raw_data_hex)?; + if !recomputed_txid.eq_ignore_ascii_case(raw_tx.tx_id.trim()) { + return Err("Tron node txID does not match sha256(raw_data)".to_string()); + } + + let raw = hex::decode(raw_tx.raw_data_hex.trim()) + .map_err(|error| format!("invalid Tron raw_data_hex: {error}"))?; + let expected_recipient = hex::decode(tron_address_to_hex(&expected_to)?) + .map_err(|error| format!("invalid Tron recipient encoding: {error}"))?; + let raw_fields = parse_proto_fields(&raw)?; + let contract = parse_single_tron_contract(&raw_fields)?; + match transfer { + TronTransferVerification::Native { amount_sun } => { + if contract.kind != 1 || !contract.type_url.ends_with(".TransferContract") { + return Err("Tron node transaction is not a native transfer".to_string()); + } + let payload = parse_proto_fields(contract.payload)?; + let recipient = one_bytes(&payload, 2, "TransferContract.to_address")?; + let amount = one_varint(&payload, 3, "TransferContract.amount")?; + if recipient != expected_recipient { + return Err( + "Tron node transaction does not pay the requested recipient".to_string() + ); + } + if amount != *amount_sun { + return Err("Tron node transaction has a different native amount".to_string()); + } + } + TronTransferVerification::Trc20 { parameter_hex } => { + if contract.kind != 31 || !contract.type_url.ends_with(".TriggerSmartContract") { + return Err("Tron node transaction is not a smart-contract trigger".to_string()); + } + let payload = parse_proto_fields(contract.payload)?; + let recipient = one_bytes(&payload, 2, "TriggerSmartContract.contract_address")?; + if recipient != expected_recipient { + return Err("Tron node transaction targets a different contract".to_string()); + } + let call_value = + optional_varint(&payload, 3, "TriggerSmartContract.call_value")?.unwrap_or(0); + if call_value != 0 { + return Err("Tron node transaction has non-zero TRC20 call_value".to_string()); + } + if let Some(fee_limit) = optional_varint(&raw_fields, 18, "Transaction.raw.fee_limit")? + { + if fee_limit != TRC20_FEE_LIMIT_SUN { + return Err("Tron node transaction has a different fee_limit".to_string()); + } + } + let parameter = hex::decode(parameter_hex) + .map_err(|error| format!("invalid TRC20 parameter: {error}"))?; + let mut expected_data = hex::decode("a9059cbb").expect("fixed selector is valid hex"); + expected_data.extend(parameter); + let data = one_bytes(&payload, 4, "TriggerSmartContract.data")?; + if data != expected_data { + return Err("Tron node transaction has different TRC20 transfer data".to_string()); + } + } + } + + Ok(tinywallet::wire::TransactionSpec::Tron { + raw_data_hex: raw_tx.raw_data_hex.clone(), + expected_to, + expected_txid: recomputed_txid, + }) +} + +fn encode_protobuf_varint(mut value: u64) -> Vec { + let mut encoded = Vec::new(); + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + encoded.push(byte); + if value == 0 { + return encoded; + } + } +} + +fn recompute_tron_txid(raw_data_hex: &str) -> Result { + let raw = hex::decode(raw_data_hex.trim()) + .map_err(|error| format!("invalid Tron raw_data_hex: {error}"))?; + Ok(hex::encode(Sha256::digest(raw))) +} + +struct ParsedTronContract<'a> { + kind: u64, + type_url: &'a str, + payload: &'a [u8], +} + +fn parse_single_tron_contract<'a>( + raw_fields: &[ProtoField<'a>], +) -> Result, String> { + let contract_bytes = one_bytes(raw_fields, 11, "Transaction.raw.contract")?; + let contract_fields = parse_proto_fields(contract_bytes)?; + let kind = one_varint(&contract_fields, 1, "Transaction.Contract.type")?; + let any_bytes = one_bytes(&contract_fields, 2, "Transaction.Contract.parameter")?; + let any_fields = parse_proto_fields(any_bytes)?; + let type_url = std::str::from_utf8(one_bytes(&any_fields, 1, "Any.type_url")?) + .map_err(|_| "Tron contract type_url is not UTF-8".to_string())?; + let payload = one_bytes(&any_fields, 2, "Any.value")?; + Ok(ParsedTronContract { + kind, + type_url, + payload, + }) +} + +fn one_bytes<'a>(fields: &[ProtoField<'a>], number: u64, name: &str) -> Result<&'a [u8], String> { + let mut matches = fields.iter().filter(|field| field.number == number); + let Some(field) = matches.next() else { + return Err(format!("Tron protobuf is missing {name}")); + }; + if matches.next().is_some() { + return Err(format!("Tron protobuf repeats singular field {name}")); + } + match field.value { + ProtoValue::Bytes(value) => Ok(value), + _ => Err(format!( + "Tron protobuf field {name} has the wrong wire type" + )), + } +} + +fn one_varint(fields: &[ProtoField<'_>], number: u64, name: &str) -> Result { + optional_varint(fields, number, name)?.ok_or_else(|| format!("Tron protobuf is missing {name}")) +} + +fn optional_varint( + fields: &[ProtoField<'_>], + number: u64, + name: &str, +) -> Result, String> { + let mut matches = fields.iter().filter(|field| field.number == number); + let Some(field) = matches.next() else { + return Ok(None); + }; + if matches.next().is_some() { + return Err(format!("Tron protobuf repeats singular field {name}")); + } + match field.value { + ProtoValue::Varint(value) => Ok(Some(value)), + _ => Err(format!( + "Tron protobuf field {name} has the wrong wire type" + )), + } +} + +fn parse_proto_fields(mut input: &[u8]) -> Result>, String> { + let mut fields = Vec::new(); + while !input.is_empty() { + let key = take_varint(&mut input)?; + let number = key >> 3; + if number == 0 { + return Err("Tron protobuf contains field zero".to_string()); + } + let value = match key & 0x07 { + 0 => ProtoValue::Varint(take_varint(&mut input)?), + 1 => { + take_exact(&mut input, 8)?; + ProtoValue::Other + } + 2 => { + let length = usize::try_from(take_varint(&mut input)?) + .map_err(|_| "Tron protobuf field length is too large".to_string())?; + ProtoValue::Bytes(take_exact(&mut input, length)?) + } + 5 => { + take_exact(&mut input, 4)?; + ProtoValue::Other + } + wire => return Err(format!("unsupported Tron protobuf wire type {wire}")), + }; + fields.push(ProtoField { number, value }); + } + Ok(fields) +} + +fn take_varint(input: &mut &[u8]) -> Result { + let mut value = 0u64; + for shift in (0..=63).step_by(7) { + let (&byte, rest) = input + .split_first() + .ok_or_else(|| "truncated Tron protobuf varint".to_string())?; + *input = rest; + let part = u64::from(byte & 0x7f); + if shift == 63 && part > 1 { + return Err("Tron protobuf varint overflows u64".to_string()); + } + value |= part << shift; + if byte & 0x80 == 0 { + return Ok(value); + } + } + Err("Tron protobuf varint is too long".to_string()) +} + +fn take_exact<'a>(input: &mut &'a [u8], length: usize) -> Result<&'a [u8], String> { + if input.len() < length { + return Err("truncated Tron protobuf field".to_string()); + } + let (value, rest) = input.split_at(length); + *input = rest; + Ok(value) +} + /// Derive the Tron signing key and its base58check address. /// /// Delegates to the vendored [`tinywallet`] crate, which owns BIP-32 /// secp256k1 derivation and the Keccak-then-base58check address construction. /// The hand-rolled BIP-32 walk and path parser that used to live here moved /// there wholesale. Custody stays here. -fn derive_tron_keypair( - mnemonic: &str, - derivation_path: &str, -) -> Result<(SecretKey, String), String> { +fn derive_tron_keypair(mnemonic: &str, derivation_path: &str) -> Result<(Vec, String), String> { let derived = tinywallet::key::derive(tinywallet::Chain::Tron, mnemonic, derivation_path) .map_err(|e| e.to_string())?; - let secret = SecretKey::from_slice(derived.secret_bytes()) - .map_err(|e| format!("tinywallet returned an unusable Tron key: {e}"))?; - Ok((secret, derived.address().to_string())) + Ok(( + derived.secret_bytes().to_vec(), + derived.address().to_string(), + )) } fn pad_left_32(bytes: &[u8]) -> Vec { @@ -207,12 +437,22 @@ pub async fn execute_tron_quote(mut quote: PreparedTransaction) -> Result { 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? + ( + quote.to_address.clone(), + TronTransferVerification::Native { amount_sun }, + create_native_transaction(&owner_hex, &to_hex, amount_sun).await?, + ) } PreparedKind::TokenTransfer => { let contract = quote @@ -222,25 +462,50 @@ pub async fn execute_tron_quote(mut quote: PreparedTransaction) -> Result "native", + TronTransferVerification::Trc20 { .. } => "trc20", + }; + let transaction = match tron_transaction_spec(&raw_tx, verified_recipient, &transfer) { + Ok(transaction) => { + debug!( + "{LOG_PREFIX} validation=accepted quote_id={} txid={} kind={transfer_kind}", + quote.quote_id, raw_tx.tx_id + ); + transaction + } + Err(error) => { + debug!( + "{LOG_PREFIX} validation=rejected quote_id={} txid={} kind={transfer_kind} reason={error}", + quote.quote_id, raw_tx.tx_id + ); + return Err(error); + } + }; + let signed = crate::openhuman::modules::wallet::sign_transaction( + &config, + &transaction, + &sk, + &public_key, + ) + .await + .map_err(|e| format!("failed to sign Tron transaction: {e}"))?; + let sig_hex = signed.raw; let mut tx_with_sig = serde_json::to_value(serde_json::json!({ "txID": raw_tx.tx_id, @@ -426,27 +691,92 @@ mod tests { broadcast_calls: Arc>>, } + fn push_varint_field(out: &mut Vec, number: u64, value: u64) { + out.extend(encode_protobuf_varint(number << 3)); + out.extend(encode_protobuf_varint(value)); + } + + fn push_bytes_field(out: &mut Vec, number: u64, value: &[u8]) { + out.extend(encode_protobuf_varint((number << 3) | 2)); + out.extend(encode_protobuf_varint(value.len() as u64)); + out.extend(value); + } + + fn tron_raw_contract(kind: u64, type_name: &str, payload: &[u8]) -> String { + let mut any = Vec::new(); + push_bytes_field( + &mut any, + 1, + format!("type.googleapis.com/protocol.{type_name}").as_bytes(), + ); + push_bytes_field(&mut any, 2, payload); + + let mut contract = Vec::new(); + push_varint_field(&mut contract, 1, kind); + push_bytes_field(&mut contract, 2, &any); + + let mut raw = Vec::new(); + push_bytes_field(&mut raw, 11, &contract); + hex::encode(raw) + } + + fn native_raw(recipient_hex: &str, amount: u64) -> String { + let mut payload = Vec::new(); + push_bytes_field(&mut payload, 2, &hex::decode(recipient_hex).unwrap()); + push_varint_field(&mut payload, 3, amount); + tron_raw_contract(1, "TransferContract", &payload) + } + + fn trc20_raw_with_values( + contract_hex: &str, + parameter_hex: &str, + call_value: Option, + fee_limit: Option, + ) -> String { + let mut payload = Vec::new(); + push_bytes_field(&mut payload, 2, &hex::decode(contract_hex).unwrap()); + if let Some(call_value) = call_value { + push_varint_field(&mut payload, 3, call_value); + } + let mut data = hex::decode("a9059cbb").unwrap(); + data.extend(hex::decode(parameter_hex).unwrap()); + push_bytes_field(&mut payload, 4, &data); + let mut raw = hex::decode(tron_raw_contract(31, "TriggerSmartContract", &payload)).unwrap(); + if let Some(fee_limit) = fee_limit { + push_varint_field(&mut raw, 18, fee_limit); + } + hex::encode(raw) + } + + fn trc20_raw(contract_hex: &str, parameter_hex: &str) -> String { + trc20_raw_with_values( + contract_hex, + parameter_hex, + Some(0), + Some(TRC20_FEE_LIMIT_SUN), + ) + } + async fn start_tron_mock(record: TronMockRecord) -> std::net::SocketAddr { let create = record.create_calls.clone(); let trigger = record.trigger_calls.clone(); let broadcast = record.broadcast_calls.clone(); - // Fixed raw_data_hex shape — minimal but valid hex so sha256 + sign work. - let canned_tx = json!({ - "txID": "ab".repeat(32), - "raw_data": {"contract": []}, - "raw_data_hex": "0a02ab1d2208deadbeef00deadbe40c89efd8a82325802", - }); - let canned_tx_create = canned_tx.clone(); - let canned_tx_trigger = canned_tx.clone(); let app = Router::new() .route( "/wallet/createtransaction", post(move |axum::Json(payload): axum::Json| { let create = create.clone(); - let canned = canned_tx_create.clone(); async move { + let recipient = payload["to_address"].as_str().unwrap(); + let amount = payload["amount"].as_u64().unwrap(); + let raw = native_raw(recipient, amount); + let txid = recompute_tron_txid(&raw).unwrap(); create.lock().push(payload); - axum::Json(canned) + axum::Json(json!({ + "txID": txid, + "raw_data": {"contract": []}, + "raw_data_hex": raw, + })) } }), ) @@ -454,10 +784,19 @@ mod tests { "/wallet/triggersmartcontract", post(move |axum::Json(payload): axum::Json| { let trigger = trigger.clone(); - let canned = canned_tx_trigger.clone(); async move { + let contract = payload["contract_address"].as_str().unwrap(); + let parameter = payload["parameter"].as_str().unwrap(); + let raw = trc20_raw(contract, parameter); + let txid = recompute_tron_txid(&raw).unwrap(); trigger.lock().push(payload); - axum::Json(json!({ "transaction": canned })) + axum::Json(json!({ + "transaction": { + "txID": txid, + "raw_data": {"contract": []}, + "raw_data_hex": raw, + } + })) } }), ) @@ -482,6 +821,150 @@ mod tests { addr } + #[test] + fn tron_specs_bind_native_and_trc20_verification_fields() { + let recipient = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + let contract = "TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH"; + let recipient_hex = tron_address_to_hex(recipient).unwrap(); + let contract_hex = tron_address_to_hex(contract).unwrap(); + + let native_raw_hex = native_raw(&recipient_hex, 1_000_000); + let native_txid = recompute_tron_txid(&native_raw_hex).unwrap(); + let native_tx = CreateTransactionResponse { + tx_id: native_txid.clone(), + raw_data: json!({}), + raw_data_hex: native_raw_hex.clone(), + }; + let native = tron_transaction_spec( + &native_tx, + recipient.to_string(), + &TronTransferVerification::Native { + amount_sun: 1_000_000, + }, + ) + .unwrap(); + assert_eq!( + native, + tinywallet::wire::TransactionSpec::Tron { + raw_data_hex: native_raw_hex, + expected_to: recipient.to_string(), + expected_txid: native_txid, + } + ); + + let parameter = "01".repeat(64); + let token_raw = trc20_raw(&contract_hex, ¶meter); + let token_txid = recompute_tron_txid(&token_raw).unwrap(); + let token_tx = CreateTransactionResponse { + tx_id: token_txid.clone(), + raw_data: json!({}), + raw_data_hex: token_raw.clone(), + }; + let token = tron_transaction_spec( + &token_tx, + contract.to_string(), + &TronTransferVerification::Trc20 { + parameter_hex: parameter.clone(), + }, + ) + .unwrap(); + assert_eq!( + token, + tinywallet::wire::TransactionSpec::Tron { + raw_data_hex: token_raw, + expected_to: contract.to_string(), + expected_txid: token_txid, + } + ); + assert_ne!(contract, recipient); + + assert!(tron_transaction_spec( + &native_tx, + recipient.to_string(), + &TronTransferVerification::Native { amount_sun: 2 }, + ) + .unwrap_err() + .contains("different native amount")); + assert!(tron_transaction_spec( + &token_tx, + contract.to_string(), + &TronTransferVerification::Trc20 { + parameter_hex: "02".repeat(64), + }, + ) + .unwrap_err() + .contains("different TRC20 transfer data")); + + for (raw_data_hex, expected_error) in [ + ( + trc20_raw_with_values( + &contract_hex, + ¶meter, + Some(1), + Some(TRC20_FEE_LIMIT_SUN), + ), + "non-zero TRC20 call_value", + ), + ( + trc20_raw_with_values( + &contract_hex, + ¶meter, + Some(0), + Some(TRC20_FEE_LIMIT_SUN + 1), + ), + "different fee_limit", + ), + ] { + let altered_tx = CreateTransactionResponse { + tx_id: recompute_tron_txid(&raw_data_hex).unwrap(), + raw_data: json!({}), + raw_data_hex, + }; + assert!(tron_transaction_spec( + &altered_tx, + contract.to_string(), + &TronTransferVerification::Trc20 { + parameter_hex: parameter.clone(), + }, + ) + .unwrap_err() + .contains(expected_error)); + } + + // A matching value hidden in an unrelated raw-data field must not + // satisfy validation when the selected contract pays something else. + let mut spoofed_raw = hex::decode(native_raw(&contract_hex, 2)).unwrap(); + let mut decoy = hex::decode(&recipient_hex).unwrap(); + decoy.extend(encode_protobuf_varint(1_000_000)); + push_bytes_field(&mut spoofed_raw, 10, &decoy); + let spoofed_raw = hex::encode(spoofed_raw); + let spoofed_tx = CreateTransactionResponse { + tx_id: recompute_tron_txid(&spoofed_raw).unwrap(), + raw_data: json!({}), + raw_data_hex: spoofed_raw, + }; + assert!(tron_transaction_spec( + &spoofed_tx, + recipient.to_string(), + &TronTransferVerification::Native { + amount_sun: 1_000_000, + }, + ) + .unwrap_err() + .contains("requested recipient")); + } + + // Drives the real wallet module, so it must be the only such test in its + // process: tinybus never unloads a module, and the module bus belongs to + // whichever tokio runtime created it — a second `#[tokio::test]` finds a + // broker whose tasks died with the first and the call fails with + // "connection closed". Verified passing in isolation: + // + // cargo test -p openhuman --lib --features "$(bash scripts/ci/product-features.sh)" \ + // execute_tron_quote_signs_and_broadcasts_native_transfer -- --ignored --test-threads=1 + // + // Same constraint tinydocs documents for its module-backed tool tests. + #[ignore = "drives the loaded wallet module; must run alone in its process"] #[tokio::test] async fn execute_tron_quote_signs_and_broadcasts_native_transfer() { let _guard = TEST_LOCK.lock(); @@ -534,6 +1017,17 @@ mod tests { assert_eq!(sig.len(), 130, "expected 65-byte signature, got: {sig}"); } + // Drives the real wallet module, so it must be the only such test in its + // process: tinybus never unloads a module, and the module bus belongs to + // whichever tokio runtime created it — a second `#[tokio::test]` finds a + // broker whose tasks died with the first and the call fails with + // "connection closed". Verified passing in isolation: + // + // cargo test -p openhuman --lib --features "$(bash scripts/ci/product-features.sh)" \ + // execute_tron_quote_signs_and_broadcasts_trc20_transfer -- --ignored --test-threads=1 + // + // Same constraint tinydocs documents for its module-backed tool tests. + #[ignore = "drives the loaded wallet module; must run alone in its process"] #[tokio::test] async fn execute_tron_quote_signs_and_broadcasts_trc20_transfer() { let _guard = TEST_LOCK.lock(); @@ -585,6 +1079,17 @@ mod tests { assert_eq!(param.len(), 128, "64-byte ABI args, hex-encoded"); } + // Drives the real wallet module, so it must be the only such test in its + // process: tinybus never unloads a module, and the module bus belongs to + // whichever tokio runtime created it — a second `#[tokio::test]` finds a + // broker whose tasks died with the first and the call fails with + // "connection closed". Verified passing in isolation: + // + // cargo test -p openhuman --lib --features "$(bash scripts/ci/product-features.sh)" \ + // execute_tron_quote_surfaces_node_rejection -- --ignored --test-threads=1 + // + // Same constraint tinydocs documents for its module-backed tool tests. + #[ignore = "drives the loaded wallet module; must run alone in its process"] #[tokio::test] async fn execute_tron_quote_surfaces_node_rejection() { let _guard = TEST_LOCK.lock(); @@ -596,11 +1101,15 @@ mod tests { let app = Router::new() .route( "/wallet/createtransaction", - post(|| async { + post(|axum::Json(payload): axum::Json| async move { + let recipient = payload["to_address"].as_str().unwrap(); + let amount = payload["amount"].as_u64().unwrap(); + let raw = native_raw(recipient, amount); + let txid = recompute_tron_txid(&raw).unwrap(); axum::Json(json!({ - "txID": "cd".repeat(32), + "txID": txid, "raw_data": {"contract": []}, - "raw_data_hex": "0a02ab1d2208deadbeef00deadbe40c89efd8a82325802", + "raw_data_hex": raw, })) }), ) diff --git a/src/openhuman/web3/wallet/execution.rs b/src/openhuman/web3/wallet/execution.rs index 295579c4ef..6f75155565 100644 --- a/src/openhuman/web3/wallet/execution.rs +++ b/src/openhuman/web3/wallet/execution.rs @@ -12,7 +12,6 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; -use ethers_core::types::U256; use log::{debug, warn}; use once_cell::sync::Lazy; use parking_lot::Mutex; @@ -35,6 +34,20 @@ const QUOTE_STORE_CAP: usize = 64; static QUOTE_STORE: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); static QUOTE_COUNTER: AtomicU64 = AtomicU64::new(1); +/// Return the compressed SEC1 public key for a secp256k1 secret. +/// +/// The wallet module uses this public data to confirm that the locally held +/// secret controls the transaction sender. +pub(super) fn compressed_public_key(secret: &[u8]) -> Result, String> { + let key = k256::ecdsa::SigningKey::from_slice(secret) + .map_err(|_| "derived key is not a valid secp256k1 scalar".to_string())?; + Ok(key + .verifying_key() + .to_encoded_point(true) + .as_bytes() + .to_vec()) +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ChainStatus { @@ -508,14 +521,28 @@ pub(crate) fn insert_quote_for_test(quote: PreparedTransaction) -> PreparedTrans store_quote(quote) } -pub fn hex_to_u256(hex_value: &str) -> Result { +/// Parse an `0x`-prefixed hex quantity, as every EVM JSON-RPC result encodes +/// integers. +/// +/// `u128` rather than a 256-bit type. Nothing this wallet reads from a node — +/// a nonce, a gas price, a gas limit, a wei balance — approaches 2^128, which +/// is about 3.4e20 ETH, and carrying `ethers-core` for a bignum that is never +/// exercised past 128 bits is the trade this port exists to stop making. A +/// value that genuinely did overflow is reported rather than truncated. +/// +/// # Errors +/// +/// A message naming the offending value if it is not hex, or does not fit. +pub fn hex_to_u128(hex_value: &str) -> Result { let trimmed = hex_value.trim(); let normalized = trimmed.strip_prefix("0x").unwrap_or(trimmed); - U256::from_str_radix(normalized, 16) + u128::from_str_radix(normalized, 16) .map_err(|e| format!("invalid hex quantity '{hex_value}': {e}")) } -pub fn u256_to_hex(value: U256) -> String { +/// Render an integer the way an EVM JSON-RPC parameter expects it. +#[must_use] +pub fn u128_to_hex(value: u128) -> String { format!("0x{value:x}") } diff --git a/src/openhuman/web3/wallet/execution_tests.rs b/src/openhuman/web3/wallet/execution_tests.rs index 71b0039f9b..2cc7d37e5c 100644 --- a/src/openhuman/web3/wallet/execution_tests.rs +++ b/src/openhuman/web3/wallet/execution_tests.rs @@ -257,6 +257,17 @@ async fn tx_status_rejects_empty_hash() { assert!(err.contains("tx hash is empty"), "got: {err}"); } +// Drives the real wallet module, so it must be the only such test in its +// process: tinybus never unloads a module, and the module bus belongs to +// whichever tokio runtime created it — a second `#[tokio::test]` finds a +// broker whose tasks died with the first and the call fails with +// "connection closed". Verified passing in isolation: +// +// cargo test -p openhuman --lib --features "$(bash scripts/ci/product-features.sh)" \ +// execute_prepared_broadcasts_native_evm_transaction -- --ignored --test-threads=1 +// +// Same constraint tinydocs documents for its module-backed tool tests. +#[ignore = "drives the loaded wallet module; must run alone in its process"] #[tokio::test] async fn execute_prepared_broadcasts_native_evm_transaction() { let _guard = TEST_LOCK.lock(); @@ -294,6 +305,17 @@ async fn execute_prepared_broadcasts_native_evm_transaction() { ); } +// Drives the real wallet module, so it must be the only such test in its +// process: tinybus never unloads a module, and the module bus belongs to +// whichever tokio runtime created it — a second `#[tokio::test]` finds a +// broker whose tasks died with the first and the call fails with +// "connection closed". Verified passing in isolation: +// +// cargo test -p openhuman --lib --features "$(bash scripts/ci/product-features.sh)" \ +// execute_prepared_broadcasts_erc20_transfer_using_default_token_catalog -- --ignored --test-threads=1 +// +// Same constraint tinydocs documents for its module-backed tool tests. +#[ignore = "drives the loaded wallet module; must run alone in its process"] #[tokio::test] async fn execute_prepared_broadcasts_erc20_transfer_using_default_token_catalog() { let _guard = TEST_LOCK.lock(); @@ -335,6 +357,17 @@ async fn execute_prepared_broadcasts_erc20_transfer_using_default_token_catalog( assert!(data.starts_with("0xa9059cbb")); } +// Drives the real wallet module, so it must be the only such test in its +// process: tinybus never unloads a module, and the module bus belongs to +// whichever tokio runtime created it — a second `#[tokio::test]` finds a +// broker whose tasks died with the first and the call fails with +// "connection closed". Verified passing in isolation: +// +// cargo test -p openhuman --lib --features "$(bash scripts/ci/product-features.sh)" \ +// execute_prepared_broadcasts_native_evm_on_base_with_chain_id_8453 -- --ignored --test-threads=1 +// +// Same constraint tinydocs documents for its module-backed tool tests. +#[ignore = "drives the loaded wallet module; must run alone in its process"] #[tokio::test] async fn execute_prepared_broadcasts_native_evm_on_base_with_chain_id_8453() { let _guard = TEST_LOCK.lock(); diff --git a/src/openhuman/web3/x402/ops.rs b/src/openhuman/web3/x402/ops.rs index c9936a4ce9..16175e4b8c 100644 --- a/src/openhuman/web3/x402/ops.rs +++ b/src/openhuman/web3/x402/ops.rs @@ -584,45 +584,38 @@ async fn build_evm_payment( challenge: &PaymentRequired, req: &PaymentRequirements, ) -> Result { - let (signer, from_address) = derive_evm_signer().await?; - build_evm_payment_with_signer(&signer, from_address, challenge, req) + let (secret, from_address) = derive_evm_signer().await?; + build_evm_payment_with_signer(&secret, &from_address, challenge, req) } /// Core EVM payment construction — separated from wallet derivation for testability. pub(crate) fn build_evm_payment_with_signer( - signer: ðers_signers::LocalWallet, - from_address: ethers_core::types::Address, + secret: &[u8], + from_address: &str, challenge: &PaymentRequired, req: &PaymentRequirements, ) -> Result { - use ethers_core::types::{Address, U256}; - - use std::str::FromStr; + use tinywallet::eip712; let chain_id = req .evm_chain_id() .ok_or_else(|| X402Error::Protocol(format!("not an EVM network: {}", req.network)))?; - let amount = U256::from_dec_str(&req.amount) + let amount = eip712::u256_from_decimal(&req.amount) .map_err(|e| X402Error::Protocol(format!("invalid amount '{}': {e}", req.amount)))?; - let pay_to = Address::from_str(&req.pay_to).map_err(|e| { - X402Error::Protocol(format!("invalid EVM payTo address '{}': {e}", req.pay_to)) - })?; - - let token_address = Address::from_str(&req.asset).map_err(|e| { - X402Error::Protocol(format!("invalid EVM token address '{}': {e}", req.asset)) - })?; + let from_bytes = evm_address_bytes(from_address)?; + let pay_to = evm_address_bytes(&req.pay_to)?; + let token_address = evm_address_bytes(&req.asset)?; // EIP-3009 parameters - let valid_after = U256::zero(); - let valid_before = U256::from( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - + req.max_timeout_seconds, - ); + let valid_after = eip712::u256_from_u64(0); + let valid_before_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .saturating_add(req.max_timeout_seconds); + let valid_before = eip712::u256_from_u64(valid_before_secs); // Random nonce for EIP-3009 let nonce = { @@ -649,36 +642,35 @@ pub(crate) fn build_evm_payment_with_signer( .and_then(|e| e.version.as_deref()) .unwrap_or("2"); let domain_separator = - eip712_domain_separator_named(token_address, chain_id, domain_name, domain_version); - let struct_hash = eip3009_struct_hash( - from_address, + eip712::domain_separator(token_address, chain_id, domain_name, domain_version); + let struct_hash = eip712::transfer_with_authorization_hash( + from_bytes, pay_to, amount, valid_after, valid_before, nonce, ); - - let mut digest_input = Vec::with_capacity(66); - digest_input.extend(b"\x19\x01"); - digest_input.extend(domain_separator); - digest_input.extend(struct_hash); - let digest: [u8; 32] = { - use ethers_core::types::H256; - let h = H256::from(ethers_core::utils::keccak256(&digest_input)); - h.into() - }; - - let signature = signer - .sign_hash(ethers_core::types::H256::from(digest)) + let digest = eip712::signing_digest(domain_separator, struct_hash); + + // Signed here with `k256`, over the prehashed digest. An EIP-712 signature + // is `r ‖ s ‖ v` where `v` is the recovery id offset by 27 — not EIP-155's + // chain-mixed `v`, because typed data is not a transaction. + let key = k256::ecdsa::SigningKey::from_slice(secret) + .map_err(|_| X402Error::Wallet("derived EVM key is unusable".to_string()))?; + let (signature, recovery_id) = key + .sign_prehash_recoverable(&digest) .map_err(|e| X402Error::Wallet(format!("EVM sign EIP-3009: {e}")))?; + let mut sig_bytes = [0u8; 65]; + sig_bytes[..64].copy_from_slice(&signature.to_bytes()); + sig_bytes[64] = recovery_id.to_byte() + 27; - let sig_hex = format!("0x{}", hex::encode(signature.to_vec())); + let sig_hex = format!("0x{}", hex::encode(sig_bytes)); let nonce_hex = format!("0x{}", hex::encode(nonce)); debug!( - "{LOG_PREFIX} built EVM payment chain_id={chain_id} amount={} asset={} from={:#x} to={:#x}", - req.amount, req.asset, from_address, pay_to + "{LOG_PREFIX} built EVM payment chain_id={chain_id} amount={} asset={} from={} to={}", + req.amount, req.asset, from_address, req.pay_to ); Ok(PaymentPayload { @@ -688,11 +680,11 @@ pub(crate) fn build_evm_payment_with_signer( payload: PaymentProof::Evm(EvmPaymentProof { signature: sig_hex, authorization: EvmAuthorization { - from: format!("{from_address:#x}"), - to: format!("{pay_to:#x}"), + from: from_address.to_string(), + to: req.pay_to.clone(), value: req.amount.clone(), valid_after: "0".to_string(), - valid_before: valid_before.to_string(), + valid_before: valid_before_secs.to_string(), nonce: nonce_hex, }, }), @@ -700,11 +692,14 @@ pub(crate) fn build_evm_payment_with_signer( }) } -/// Derive the wallet's EVM signer from the encrypted mnemonic. -async fn derive_evm_signer( -) -> Result<(ethers_signers::LocalWallet, ethers_core::types::Address), X402Error> { +/// Derive the wallet's EVM signing key from the encrypted mnemonic. +/// +/// Returns the raw secret and the checksummed address it controls. Derivation +/// goes through `tinywallet::key` — the same BIP-32 walk the wallet domain uses, +/// so an x402 payment is signed by exactly the account the wallet reports — and +/// the key stays in this process. +async fn derive_evm_signer() -> Result<(Vec, String), X402Error> { use crate::openhuman::web3::wallet::WalletChain; - use ethers_signers::{coins_bip39::English, MnemonicBuilder, Signer}; let secret = crate::openhuman::web3::wallet::secret_material(WalletChain::Evm) .await @@ -722,100 +717,29 @@ async fn derive_evm_signer( .map_err(|e| X402Error::Wallet(format!("decrypt mnemonic: {e}")))? .value; - let wallet = MnemonicBuilder::::default() - .phrase(mnemonic.as_str()) - .derivation_path(&secret.derivation_path) - .map_err(|e| { - X402Error::Wallet(format!( - "invalid EVM derivation path '{}': {e}", - secret.derivation_path - )) - })? - .build() - .map_err(|e| X402Error::Wallet(format!("derive EVM signer: {e}")))?; - - let address = wallet.address(); - Ok((wallet, address)) -} - -/// EIP-712 domain separator with default USDC params ("USD Coin", "2"). -pub(crate) fn eip712_domain_separator( - verifying_contract: ethers_core::types::Address, - chain_id: u64, -) -> [u8; 32] { - eip712_domain_separator_named(verifying_contract, chain_id, "USD Coin", "2") -} - -/// EIP-712 domain separator with explicit name and version from the 402 extra. -pub(crate) fn eip712_domain_separator_named( - verifying_contract: ethers_core::types::Address, - chain_id: u64, - name: &str, - version: &str, -) -> [u8; 32] { - use ethers_core::utils::keccak256; - - let type_hash = keccak256( - b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)", - ); - let name_hash = keccak256(name.as_bytes()); - let version_hash = keccak256(version.as_bytes()); - - let mut encoded = Vec::with_capacity(5 * 32); - encoded.extend(type_hash); - encoded.extend(name_hash); - encoded.extend(version_hash); - let mut chain_id_bytes = [0u8; 32]; - chain_id_bytes[24..].copy_from_slice(&chain_id.to_be_bytes()); - encoded.extend(chain_id_bytes); - let mut addr_bytes = [0u8; 32]; - addr_bytes[12..].copy_from_slice(verifying_contract.as_bytes()); - encoded.extend(addr_bytes); - - keccak256(&encoded) -} - -/// EIP-3009 `TransferWithAuthorization` struct hash. -pub(crate) fn eip3009_struct_hash( - from: ethers_core::types::Address, - to: ethers_core::types::Address, - value: ethers_core::types::U256, - valid_after: ethers_core::types::U256, - valid_before: ethers_core::types::U256, - nonce: [u8; 32], -) -> [u8; 32] { - use ethers_core::utils::keccak256; - - let type_hash = keccak256( - b"TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)", - ); - - let mut encoded = Vec::with_capacity(7 * 32); - encoded.extend(type_hash); - - let mut from_bytes = [0u8; 32]; - from_bytes[12..].copy_from_slice(from.as_bytes()); - encoded.extend(from_bytes); - - let mut to_bytes = [0u8; 32]; - to_bytes[12..].copy_from_slice(to.as_bytes()); - encoded.extend(to_bytes); - - let mut value_bytes = [0u8; 32]; - value.to_big_endian(&mut value_bytes); - encoded.extend(value_bytes); - - let mut va_bytes = [0u8; 32]; - valid_after.to_big_endian(&mut va_bytes); - encoded.extend(va_bytes); - - let mut vb_bytes = [0u8; 32]; - valid_before.to_big_endian(&mut vb_bytes); - encoded.extend(vb_bytes); - - encoded.extend(nonce); - - keccak256(&encoded) + let derived = tinywallet::key::derive( + tinywallet::Chain::Evm, + mnemonic.as_str(), + &secret.derivation_path, + ) + .map_err(|e| X402Error::Wallet(format!("derive EVM signer: {e}")))?; + + Ok(( + derived.secret_bytes().to_vec(), + derived.address().to_string(), + )) +} + +/// The 20 raw bytes of an EVM address. +fn evm_address_bytes(address: &str) -> Result<[u8; 20], X402Error> { + let validated = tinywallet::address::evm::validate(address) + .map_err(|e| X402Error::Protocol(format!("invalid EVM address '{address}': {e}")))?; + let body = validated.strip_prefix("0x").unwrap_or(&validated); + let decoded = hex::decode(body) + .map_err(|_| X402Error::Protocol(format!("non-hex EVM address '{address}'")))?; + decoded + .try_into() + .map_err(|_| X402Error::Protocol(format!("truncated EVM address '{address}'"))) } // --------------------------------------------------------------------------- diff --git a/src/openhuman/web3/x402/x402_tests.rs b/src/openhuman/web3/x402/x402_tests.rs index d1b8746294..80d8d6efc3 100644 --- a/src/openhuman/web3/x402/x402_tests.rs +++ b/src/openhuman/web3/x402/x402_tests.rs @@ -1,4 +1,4 @@ -use super::ops::{build_evm_payment_with_signer, eip3009_struct_hash, eip712_domain_separator}; +use super::ops::build_evm_payment_with_signer; use super::types::*; use base64::engine::{general_purpose::STANDARD as B64, Engine as _}; @@ -394,37 +394,64 @@ fn solana_payment_proof_serializes_correctly() { #[test] fn eip712_domain_separator_is_deterministic() { - use std::str::FromStr; - let contract = - ethers_core::types::Address::from_str("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913") - .unwrap(); - let sep1 = eip712_domain_separator(contract, 8453); - let sep2 = eip712_domain_separator(contract, 8453); + // Now `tinywallet::eip712`, which also pins the hashes against the + // published EIP-712/EIP-3009 constants. What this still checks is the + // property that matters at this layer: the separator binds the chain, so + // an authorization cannot be replayed on another one. + use tinywallet::eip712::domain_separator; + + let contract = base_usdc(); + let sep1 = domain_separator(contract, 8453, "USD Coin", "2"); + let sep2 = domain_separator(contract, 8453, "USD Coin", "2"); assert_eq!(sep1, sep2); - // Different chain ID produces different separator - let sep_eth = eip712_domain_separator(contract, 1); + let sep_eth = domain_separator(contract, 1, "USD Coin", "2"); assert_ne!(sep1, sep_eth); } +/// The BIP-39 vector mnemonic's EVM account: raw secret and its address. +/// +/// Derived through `tinywallet::key`, which is what the production path uses, +/// so the test signs as exactly the account the wallet would. +fn test_signer() -> (Vec, String) { + let test_mnemonic = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + let derived = + tinywallet::key::derive(tinywallet::Chain::Evm, test_mnemonic, "m/44'/60'/0'/0/0").unwrap(); + ( + derived.secret_bytes().to_vec(), + derived.address().to_string(), + ) +} + +/// Base mainnet USDC, as raw address bytes. +fn base_usdc() -> [u8; 20] { + address_bytes("833589fCD6eDb6E08f4c7C32D4f71b54bdA02913") +} + +/// Decode an unprefixed 20-byte hex address. +fn address_bytes(hex: &str) -> [u8; 20] { + hex::decode(hex).unwrap().try_into().unwrap() +} + #[test] fn eip3009_struct_hash_is_deterministic() { - use std::str::FromStr; - let from = ethers_core::types::Address::from_str("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - .unwrap(); - let to = ethers_core::types::Address::from_str("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") - .unwrap(); - let value = ethers_core::types::U256::from(1_000_000u64); - let valid_after = ethers_core::types::U256::zero(); - let valid_before = ethers_core::types::U256::from(99999u64); + use tinywallet::eip712::{transfer_with_authorization_hash, u256_from_u64}; + + let from = address_bytes(&"aa".repeat(20)); + let to = address_bytes(&"bb".repeat(20)); + let value = u256_from_u64(1_000_000); + let valid_after = u256_from_u64(0); + let valid_before = u256_from_u64(99_999); let nonce = [42u8; 32]; - let h1 = eip3009_struct_hash(from, to, value, valid_after, valid_before, nonce); - let h2 = eip3009_struct_hash(from, to, value, valid_after, valid_before, nonce); + let h1 = transfer_with_authorization_hash(from, to, value, valid_after, valid_before, nonce); + let h2 = transfer_with_authorization_hash(from, to, value, valid_after, valid_before, nonce); assert_eq!(h1, h2); // Different nonce produces different hash - let h3 = eip3009_struct_hash(from, to, value, valid_after, valid_before, [43u8; 32]); + let h3 = + transfer_with_authorization_hash(from, to, value, valid_after, valid_before, [43u8; 32]); assert_ne!(h1, h3); } @@ -462,17 +489,8 @@ fn parse_twit_sh_402_challenge() { #[test] fn build_evm_payment_with_test_key_produces_valid_payload() { - use ethers_signers::{coins_bip39::English, MnemonicBuilder, Signer}; - use std::str::FromStr; - - let test_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; - let wallet = MnemonicBuilder::::default() - .phrase(test_mnemonic) - .derivation_path("m/44'/60'/0'/0/0") - .unwrap() - .build() - .unwrap(); - let from_address = wallet.address(); + let (wallet, from_address) = test_signer(); + assert_eq!(from_address, "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"); let challenge = PaymentRequired { x402_version: 2, @@ -500,7 +518,7 @@ fn build_evm_payment_with_test_key_produces_valid_payload() { }; let req = &challenge.accepts[0]; - let payload = build_evm_payment_with_signer(&wallet, from_address, &challenge, req).unwrap(); + let payload = build_evm_payment_with_signer(&wallet, &from_address, &challenge, req).unwrap(); assert_eq!(payload.x402_version, 2); assert_eq!(payload.accepted.network, BASE_MAINNET_CAIP2); @@ -514,17 +532,49 @@ fn build_evm_payment_with_test_key_produces_valid_payload() { assert_eq!(evm.authorization.value, "2500"); assert_eq!(evm.authorization.valid_after, "0"); assert!(evm.authorization.nonce.starts_with("0x")); + // Checksummed, as `tinywallet::address::evm` renders it, and as + // the requirement itself carried it. assert_eq!( evm.authorization.to, - format!( - "{:#x}", - ethers_core::types::Address::from_str( - "0x9DBA414637c611a16BEa6f0796BFcbcBdc410df8" - ) + "0x9DBA414637c611a16BEa6f0796BFcbcBdc410df8" + ); + assert_eq!(evm.authorization.from, from_address); + + use k256::ecdsa::{RecoveryId, Signature, VerifyingKey}; + use tinywallet::eip712; + + let raw = hex::decode(evm.signature.trim_start_matches("0x")).unwrap(); + assert!(matches!(raw[64], 27 | 28), "invalid recovery byte"); + let signature = Signature::from_slice(&raw[..64]).unwrap(); + let recovery_id = RecoveryId::try_from(raw[64] - 27).unwrap(); + let nonce: [u8; 32] = hex::decode(evm.authorization.nonce.trim_start_matches("0x")) + .unwrap() + .try_into() + .unwrap(); + let domain = eip712::domain_separator( + base_usdc(), + 8453, + req.extra.as_ref().unwrap().name.as_deref().unwrap(), + req.extra.as_ref().unwrap().version.as_deref().unwrap(), + ); + let structure = eip712::transfer_with_authorization_hash( + address_bytes(evm.authorization.from.trim_start_matches("0x")), + address_bytes(evm.authorization.to.trim_start_matches("0x")), + eip712::u256_from_decimal(&evm.authorization.value).unwrap(), + eip712::u256_from_u64(evm.authorization.valid_after.parse().unwrap()), + eip712::u256_from_u64(evm.authorization.valid_before.parse().unwrap()), + nonce, + ); + let digest = eip712::signing_digest(domain, structure); + let recovered = + VerifyingKey::recover_from_prehash(&digest, &signature, recovery_id).unwrap(); + assert_eq!( + recovered, + *k256::ecdsa::SigningKey::from_slice(&wallet) .unwrap() - ) + .verifying_key(), + "the recovered signer must control the pinned from_address" ); - assert_eq!(evm.authorization.from, format!("{from_address:#x}")); } PaymentProof::Solana(_) => panic!("expected EVM proof, got Solana"), } @@ -543,15 +593,7 @@ fn build_evm_payment_with_test_key_produces_valid_payload() { #[test] fn build_evm_payment_rejects_solana_network() { - use ethers_signers::{coins_bip39::English, MnemonicBuilder, Signer}; - - let test_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; - let wallet = MnemonicBuilder::::default() - .phrase(test_mnemonic) - .derivation_path("m/44'/60'/0'/0/0") - .unwrap() - .build() - .unwrap(); + let (wallet, from_address) = test_signer(); let challenge = PaymentRequired { x402_version: 2, @@ -574,7 +616,7 @@ fn build_evm_payment_rejects_solana_network() { }; let req = &challenge.accepts[0]; - let result = build_evm_payment_with_signer(&wallet, wallet.address(), &challenge, req); + let result = build_evm_payment_with_signer(&wallet, &from_address, &challenge, req); assert!(result.is_err()); let err_msg = format!("{}", result.unwrap_err()); assert!(err_msg.contains("not an EVM network")); diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 3cbab29d97..b00a798ad3 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -15,6 +15,7 @@ use axum::routing::{get, post}; use axum::{Json, Router}; use futures_util::StreamExt; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; use tempfile::tempdir; use tinyagents::harness::message::Message; use tinyagents::harness::model::ModelRequest; @@ -5830,6 +5831,7 @@ async fn json_rpc_wallet_setup_round_trips_status() { /// #1396 — wallet execution surface: balances/supported_assets/chain_status /// read tools, prepare_transfer + execute_prepared write boundary. #[tokio::test] +#[ignore = "drives the loaded wallet module; run alone with an installed tinywallet artifact"] async fn json_rpc_wallet_execution_surface_round_trips() { let _env_lock = json_rpc_e2e_env_lock(); let tmp = tempdir().expect("tempdir"); @@ -6341,31 +6343,103 @@ struct MockTronState { broadcast_hits: Arc>, } -async fn mock_tron_create(State(state): State, Json(_): Json) -> Json { +fn mock_tron_push_varint(out: &mut Vec, mut value: u64) { + while value >= 0x80 { + out.push((value as u8) | 0x80); + value >>= 7; + } + out.push(value as u8); +} + +fn mock_tron_varint_field(out: &mut Vec, number: u64, value: u64) { + mock_tron_push_varint(out, number << 3); + mock_tron_push_varint(out, value); +} + +fn mock_tron_bytes_field(out: &mut Vec, number: u64, value: &[u8]) { + mock_tron_push_varint(out, (number << 3) | 2); + mock_tron_push_varint(out, value.len() as u64); + out.extend_from_slice(value); +} + +fn mock_tron_raw_contract(kind: u64, type_name: &str, payload: &[u8]) -> Vec { + let mut any = Vec::new(); + mock_tron_bytes_field( + &mut any, + 1, + format!("type.googleapis.com/protocol.{type_name}").as_bytes(), + ); + mock_tron_bytes_field(&mut any, 2, payload); + + let mut contract = Vec::new(); + mock_tron_varint_field(&mut contract, 1, kind); + mock_tron_bytes_field(&mut contract, 2, &any); + + let mut raw = Vec::new(); + mock_tron_bytes_field(&mut raw, 11, &contract); + raw +} + +fn mock_tron_transaction(raw: Vec) -> Value { + json!({ + "txID": hex::encode(Sha256::digest(&raw)), + "raw_data": {"contract": []}, + "raw_data_hex": hex::encode(raw), + }) +} + +async fn mock_tron_create( + State(state): State, + Json(payload): Json, +) -> Json { if let Ok(mut g) = state.create_hits.lock() { *g += 1; } - Json(json!({ - "txID": "cd".repeat(32), - "raw_data": {"contract": []}, - "raw_data_hex": "0a02ab1d2208deadbeef00deadbe40c89efd8a82325802", - })) + let mut contract = Vec::new(); + mock_tron_bytes_field( + &mut contract, + 1, + &hex::decode(payload["owner_address"].as_str().unwrap()).unwrap(), + ); + mock_tron_bytes_field( + &mut contract, + 2, + &hex::decode(payload["to_address"].as_str().unwrap()).unwrap(), + ); + mock_tron_varint_field(&mut contract, 3, payload["amount"].as_u64().unwrap()); + Json(mock_tron_transaction(mock_tron_raw_contract( + 1, + "TransferContract", + &contract, + ))) } async fn mock_tron_trigger( State(state): State, - Json(_): Json, + Json(payload): Json, ) -> Json { if let Ok(mut g) = state.trigger_hits.lock() { *g += 1; } - Json(json!({ - "transaction": { - "txID": "cd".repeat(32), - "raw_data": {"contract": []}, - "raw_data_hex": "0a02ab1d2208deadbeef00deadbe40c89efd8a82325802", - } - })) + let mut contract = Vec::new(); + mock_tron_bytes_field( + &mut contract, + 1, + &hex::decode(payload["owner_address"].as_str().unwrap()).unwrap(), + ); + mock_tron_bytes_field( + &mut contract, + 2, + &hex::decode(payload["contract_address"].as_str().unwrap()).unwrap(), + ); + mock_tron_varint_field(&mut contract, 3, payload["call_value"].as_u64().unwrap()); + let mut data = hex::decode("a9059cbb").unwrap(); + data.extend(hex::decode(payload["parameter"].as_str().unwrap()).unwrap()); + mock_tron_bytes_field(&mut contract, 4, &data); + + let mut raw = mock_tron_raw_contract(31, "TriggerSmartContract", &contract); + mock_tron_varint_field(&mut raw, 18, payload["fee_limit"].as_u64().unwrap()); + Json(json!({"transaction": mock_tron_transaction(raw)})) } async fn mock_tron_broadcast( @@ -6415,6 +6489,7 @@ async fn wallet_setup_via_rpc(rpc_base: &str, encrypted_mnemonic: &str) { /// `evmNetwork: base_mainnet` routes signing + broadcast to the Base RPC /// override and *not* the Ethereum default. #[tokio::test] +#[ignore = "drives the loaded wallet module; run alone with an installed tinywallet artifact"] 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"); @@ -6499,6 +6574,7 @@ async fn json_rpc_wallet_evm_base_network_prepare_execute_round_trips() { /// BTC: P2WPKH native segwit transfer end-to-end through controllers. #[tokio::test] +#[ignore = "drives the loaded wallet module; run alone with an installed tinywallet artifact"] async fn json_rpc_wallet_btc_prepare_execute_round_trips() { let _env_lock = json_rpc_e2e_env_lock(); let tmp = tempdir().expect("tempdir"); @@ -6660,6 +6736,7 @@ async fn json_rpc_wallet_solana_prepare_execute_round_trips() { /// Tron: native TRX transfer end-to-end through controllers. #[tokio::test] +#[ignore = "drives the loaded wallet module; run alone with an installed tinywallet artifact"] async fn json_rpc_wallet_tron_prepare_execute_round_trips() { let _env_lock = json_rpc_e2e_env_lock(); let tmp = tempdir().expect("tempdir"); @@ -6748,6 +6825,7 @@ async fn json_rpc_wallet_tron_prepare_execute_round_trips() { /// Tron TRC20 lifecycle — verifies the triggersmartcontract path is used. #[tokio::test] +#[ignore = "drives the loaded wallet module; run alone with an installed tinywallet artifact"] async fn json_rpc_wallet_tron_trc20_prepare_execute_round_trips() { let _env_lock = json_rpc_e2e_env_lock(); let tmp = tempdir().expect("tempdir"); diff --git a/vendor/tinywallet b/vendor/tinywallet index 2dc91f1cb3..21674b799d 160000 --- a/vendor/tinywallet +++ b/vendor/tinywallet @@ -1 +1 @@ -Subproject commit 2dc91f1cb306504c5e823ef7727c334418585675 +Subproject commit 21674b799d47293203389729e377ac3325f7063c