diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 149b915de0..644e4d5823 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -515,7 +515,7 @@ jobs: # # This asserts the calibration still holds. If it fails, every # projection built on the simulator is suspect until it is fixed. - run: python3 scripts/dep-sim.py --cut-nothing --expect-names 279 + run: python3 scripts/dep-sim.py --cut-nothing --expect-names 268 - name: Guard — new feature-gated test modules must be acknowledged # Self-maintaining coverage: the set of source files that #[cfg]-gate a test on @@ -548,8 +548,6 @@ jobs: openhuman/tools/ops_tests.rs openhuman/voice/compile_status.rs openhuman/web3/stub.rs - openhuman/web3/wallet/primitives/address/evm/test.rs - openhuman/web3/wallet/primitives/address/test.rs openhuman/web3/wallet/stub.rs openhuman/web3/x402/stub.rs tui/runner.rs diff --git a/.gitmodules b/.gitmodules index 950fe6ee08..40407d23c9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -24,3 +24,6 @@ path = vendor/tinymemory url = https://github.com/tinyhumansai/tinymemory.git branch = main +[submodule "vendor/tinywallet"] + path = vendor/tinywallet + url = https://github.com/tinyhumansai/tinywallet diff --git a/Cargo.lock b/Cargo.lock index 459c609a86..dfa9b9d2a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -875,9 +875,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b6be4a5df2098cd811f3194f64ddb96c267606bffd9689ac7b0160097b01ad3" dependencies = [ "bs58", - "coins-core", + "coins-core 0.8.7", "digest 0.10.7", - "hmac", + "hmac 0.12.1", + "k256", + "serde", + "sha2 0.10.9", + "thiserror 1.0.69", +] + +[[package]] +name = "coins-bip32" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1fc16cf8742cbecd285d3465532affa37e23e8120a0ac813f613923c730cd9b" +dependencies = [ + "bs58", + "coins-core 0.13.1", + "digest 0.10.7", + "getrandom 0.2.17", + "getrandom 0.3.4", + "hmac 0.12.1", "k256", "serde", "sha2 0.10.9", @@ -891,8 +909,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3db8fba409ce3dc04f7d804074039eb68b960b0829161f8e06c95fea3f122528" dependencies = [ "bitvec", - "coins-bip32", - "hmac", + "coins-bip32 0.8.7", + "hmac 0.12.1", "once_cell", "pbkdf2", "rand 0.8.6", @@ -900,6 +918,23 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "coins-bip39" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ccb31dbb25bf261ba5bf34d4871445935f4ea106fbec3eddaec442516e03ff" +dependencies = [ + "bitvec", + "coins-bip32 0.13.1", + "getrandom 0.2.17", + "getrandom 0.3.4", + "hmac 0.12.1", + "pbkdf2", + "rand 0.9.4", + "sha2 0.10.9", + "thiserror 1.0.69", +] + [[package]] name = "coins-core" version = "0.8.7" @@ -912,7 +947,7 @@ dependencies = [ "digest 0.10.7", "generic-array", "hex", - "ripemd", + "ripemd 0.1.3", "serde", "serde_derive", "sha2 0.10.9", @@ -920,6 +955,25 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "coins-core" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d0aa7cd518496c76ecce929ae0df4e1a40ae84d0bf44bc719b4012daaa0453" +dependencies = [ + "base64 0.21.7", + "bech32 0.9.1", + "bs58", + "const-hex", + "digest 0.10.7", + "generic-array", + "ripemd 0.1.3", + "serde", + "sha2 0.10.9", + "sha3", + "thiserror 1.0.69", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -975,6 +1029,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-hex" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1531,6 +1597,7 @@ dependencies = [ "block-buffer 0.12.0", "const-oid 0.10.2", "crypto-common 0.2.1", + "ctutils", ] [[package]] @@ -2418,7 +2485,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", ] [[package]] @@ -2430,6 +2497,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "hostname" version = "0.4.2" @@ -4040,7 +4116,6 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", - "bech32 0.11.1", "block2 0.6.2", "bs58", "bytes", @@ -4048,8 +4123,7 @@ dependencies = [ "chrono", "chrono-tz", "clap", - "coins-bip32", - "coins-bip39", + "coins-bip39 0.8.7", "cpal", "cron", "crossterm", @@ -4070,7 +4144,7 @@ dependencies = [ "glob", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "hostname", "iana-time-zone", "k256", @@ -4092,7 +4166,6 @@ dependencies = [ "rdev", "regex", "reqwest", - "ripemd", "rppal", "rusqlite", "rustls", @@ -4103,7 +4176,6 @@ dependencies = [ "serde_repr", "serde_yaml", "sha2 0.10.9", - "sha3", "socketioxide", "starship-battery", "sysinfo", @@ -4122,6 +4194,7 @@ dependencies = [ "tinymemory-core", "tinymemory-tinycortex", "tinyplace", + "tinywallet", "tokio", "tokio-stream", "tokio-tungstenite 0.29.0", @@ -4297,7 +4370,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac", + "hmac 0.12.1", ] [[package]] @@ -5166,7 +5239,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -5193,6 +5266,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "ripemd" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dd4211456b4172d7e44261920c25acf07367c4f04bb5f5d54fc21b090d9b159" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "rppal" version = "0.22.1" @@ -6300,7 +6382,7 @@ dependencies = [ "futures", "futures-util", "hex", - "hmac", + "hmac 0.12.1", "lettre", "mail-parser", "parking_lot", @@ -6356,7 +6438,7 @@ dependencies = [ "tinyagents", "tinycortex-api", "tokio", - "toml 0.8.23", + "toml 1.1.2+spec-1.1.0", "tracing", "uuid", "walkdir", @@ -6447,7 +6529,6 @@ dependencies = [ "chrono", "dirs", "futures", - "git2", "log", "parking_lot", "rand 0.8.6", @@ -6495,7 +6576,7 @@ dependencies = [ "ed25519-dalek", "futures-util", "hkdf", - "hmac", + "hmac 0.12.1", "rand 0.8.6", "reqwest", "serde", @@ -6542,6 +6623,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tinywallet" +version = "0.4.0" +dependencies = [ + "async-trait", + "bech32 0.11.1", + "bs58", + "coins-bip32 0.8.7", + "coins-bip39 0.13.1", + "ed25519-dalek", + "hex", + "hmac 0.13.0", + "ripemd 0.2.0", + "serde", + "serde_json", + "sha2 0.11.0", + "sha3", + "thiserror 2.0.18", + "zeroize", +] + [[package]] name = "tokio" version = "1.52.3" @@ -7191,7 +7293,7 @@ dependencies = [ "futures", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "log", "md5", "once_cell", @@ -7278,7 +7380,7 @@ dependencies = [ "ghash 0.6.0", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "log", "prost", "rand 0.10.1", diff --git a/Cargo.toml b/Cargo.toml index 4acc8a506f..f36673c2fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -476,14 +476,23 @@ unicode-width = { version = "0.2", optional = true } # 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. +# Key derivation (`key`) and transaction signing (`tx`) are deliberately absent: +# both happen inside the loaded tinywallet module now, so this binary links +# neither. What is left is the wire contract, address validation, the EIP-712 / +# ABI encoders the x402 payment path builds with, the `Transport` seam, and +# `tx-codec` — the Tron *verifier*, which runs host-side before a transaction is +# sent for signing and needs no `bitcoin` build. +# +# `key` is re-enabled under [dev-dependencies] so test fixtures can still derive +# a known account. Dev-dependency features are not linked into the shipped +# binary, so that does not undo the shed. +tinywallet = { path = "vendor/tinywallet", default-features = false, features = ["btc", "evm", "solana", "tron", "keccak", "net", "wire", "eip712", "abi", "tx-codec"], 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. +# used, via tinywallet's `key` gate), so naming it directly costs nothing and +# is what lets `bitcoin` go. k256 = { version = "0.13", default-features = false, features = ["std", "ecdsa"], optional = true } -bech32 = { version = "0.11", optional = true } -ripemd = { version = "0.1", optional = true } -coins-bip32 = { version = "0.8", optional = true } -sha3 = { version = "0.10", optional = true } [target.'cfg(windows)'.dependencies] # Windows: tokio-tungstenite uses native-tls (schannel) so wss:// @@ -529,6 +538,13 @@ landlock = { version = "0.4", optional = true } rppal = { version = "0.22", optional = true } [dev-dependencies] +# Test fixtures derive a known account from the BIP-39 vector phrase. Production +# does not derive at all — see the note on the main `tinywallet` entry. Cargo +# does not link dev-dependency features into the shipped binary, so enabling +# `key` here does not put the derivation stack back into the product. +tinywallet = { path = "vendor/tinywallet", default-features = false, features = ["key", "btc", "evm", "solana", "tron"] } +k256 = { version = "0.13", default-features = false, features = ["std", "ecdsa"] } +coins-bip39 = { version = "0.8" } # The host's own tests drive `tinymemory-core`'s test helpers # (`chat::test_override`, `StaticChatProvider`, `tool_memory::test_helpers`). # They were `#[cfg(test)]` items in this crate before the memory extraction; a @@ -750,14 +766,17 @@ voice = [ # and `tinyplace/payment` use them for agent-network identity, which has nothing # to do with the wallet. Measured: excluding them costs 0, because tinyplace # pulls them in regardless. +# `k256` and `coins-bip39` are gone from this list: nothing in the shipped +# binary derives a key or signs with secp256k1 any more — the wallet module +# does both. Both crates remain under [dev-dependencies], where test fixtures +# still derive a known account and check a signature against a fixed vector. +# +# `curve25519-dalek` stays: `associated_token_account` needs the off-curve check +# to derive a Solana ATA, which is address arithmetic rather than signing, and +# tinyplace pulls it in through `ed25519-dalek` regardless. web3 = [ - "dep:k256", - "dep:bech32", - "dep:ripemd", - "dep:coins-bip32", - "dep:sha3", + "dep:tinywallet", "dep:curve25519-dalek", - "dep:coins-bip39", "modules", ] diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 379f374d70..e567c6a692 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -516,12 +516,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - [[package]] name = "base64" version = "0.21.7" @@ -546,12 +540,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bech32" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" - [[package]] name = "bech32" version = "0.11.1" @@ -606,18 +594,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "bitvec" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "blake2" version = "0.10.6" @@ -986,58 +962,6 @@ dependencies = [ "objc", ] -[[package]] -name = "coins-bip32" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b6be4a5df2098cd811f3194f64ddb96c267606bffd9689ac7b0160097b01ad3" -dependencies = [ - "bs58", - "coins-core", - "digest 0.10.7", - "hmac", - "k256", - "serde", - "sha2 0.10.9", - "thiserror 1.0.69", -] - -[[package]] -name = "coins-bip39" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db8fba409ce3dc04f7d804074039eb68b960b0829161f8e06c95fea3f122528" -dependencies = [ - "bitvec", - "coins-bip32", - "hmac", - "once_cell", - "pbkdf2", - "rand 0.8.7", - "sha2 0.10.9", - "thiserror 1.0.69", -] - -[[package]] -name = "coins-core" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5286a0843c21f8367f7be734f89df9b822e0321d8bcce8d6e735aadff7d74979" -dependencies = [ - "base64 0.21.7", - "bech32 0.9.1", - "bs58", - "digest 0.10.7", - "generic-array", - "hex", - "ripemd", - "serde", - "serde_derive", - "sha2 0.10.9", - "sha3", - "thiserror 1.0.69", -] - [[package]] name = "combine" version = "4.6.7" @@ -1329,18 +1253,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -1598,7 +1510,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -1816,20 +1727,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - [[package]] name = "ed25519" version = "2.2.3" @@ -1861,25 +1758,6 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest 0.10.7", - "ff", - "generic-array", - "group", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "email-encoding" version = "0.4.2" @@ -2090,16 +1968,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2261,12 +2129,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futures" version = "0.3.34" @@ -2454,7 +2316,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", - "zeroize", ] [[package]] @@ -2648,17 +2509,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "gtk" version = "0.18.2" @@ -3486,20 +3336,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "k256" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" -dependencies = [ - "cfg-if", - "ecdsa", - "elliptic-curve", - "once_cell", - "sha2 0.10.9", - "signature", -] - [[package]] name = "keccak" version = "0.1.6" @@ -4615,15 +4451,12 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", - "bech32 0.11.1", "block2 0.6.2", "bs58", "bytes", "chacha20poly1305", "chrono", "chrono-tz", - "coins-bip32", - "coins-bip39", "cpal", "cron", "curve25519-dalek", @@ -4642,7 +4475,6 @@ dependencies = [ "hmac", "hostname", "iana-time-zone", - "k256", "keyring", "lettre", "libc", @@ -4658,7 +4490,6 @@ dependencies = [ "rdev", "regex", "reqwest 0.12.28", - "ripemd", "rusqlite", "rustls", "schemars 1.2.2", @@ -4668,7 +4499,6 @@ dependencies = [ "serde_repr", "serde_yaml", "sha2 0.10.9", - "sha3", "socketioxide", "starship-battery", "sysinfo", @@ -4687,6 +4517,7 @@ dependencies = [ "tinymemory-core", "tinymemory-tinycortex", "tinyplace", + "tinywallet", "tokio", "tokio-stream", "tokio-tungstenite 0.29.0", @@ -4878,16 +4709,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest 0.10.7", - "hmac", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -5355,12 +5176,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "rand" version = "0.8.7" @@ -5648,16 +5463,6 @@ dependencies = [ "usvg", ] -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - [[package]] name = "rfd" version = "0.15.4" @@ -5707,11 +5512,11 @@ dependencies = [ [[package]] name = "ripemd" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +checksum = "4dd4211456b4172d7e44261920c25acf07367c4f04bb5f5d54fc21b090d9b159" dependencies = [ - "digest 0.10.7", + "digest 0.11.3", ] [[package]] @@ -5988,20 +5793,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", -] - [[package]] name = "security-framework" version = "2.11.1" @@ -6463,7 +6254,6 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest 0.10.7", "rand_core 0.6.4", ] @@ -6889,12 +6679,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "tar" version = "0.4.46" @@ -7716,6 +7500,22 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tinywallet" +version = "0.4.0" +dependencies = [ + "async-trait", + "bech32", + "bs58", + "hex", + "ripemd", + "serde", + "serde_json", + "sha2 0.11.0", + "sha3", + "thiserror 2.0.20", +] + [[package]] name = "tokio" version = "1.53.1" @@ -9493,15 +9293,6 @@ dependencies = [ "windows-version", ] -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "x11" version = "2.21.0" diff --git a/scripts/kernel-floor.limits b/scripts/kernel-floor.limits index 55430b0ab6..ed46bff963 100644 --- a/scripts/kernel-floor.limits +++ b/scripts/kernel-floor.limits @@ -13,6 +13,22 @@ # Simulate with: scripts/dep-sim.py --cut # # History +# 286/268/2 2026-08-15 tinywallet de-vendored back to a submodule + module +# (-11 packages / -11 names). The inlined `tinywallet` +# crate copy is deleted; key derivation and transaction +# signing for BTC/EVM/Tron/Solana move into the loaded +# `tinywallet` TinyBus module over confidential +# `SignTransaction`/`SignMessage` calls. `web3` stayed +# gated out of this profile already, so the shed here is +# not the wallet crate itself (never in `flows`) — it is +# `app/src-tauri/Cargo.lock`'s stale cryptocurrency +# dependency tail that the old inlined copy had pulled in +# and the delete-and-revendor removed (`chore(deps): prune +# unused cryptocurrency dependencies from Cargo.lock`). +# Measured on CI (Linux): `scripts/kernel-floor.sh flows +# --json` -> 286/268/2. (macOS resolves ~298/280/2 per the +# documented target skew; the ratchet stays calibrated on +# Linux.) # 297/279/2 2026-08-14 tinycortex + tinymemory advanced to their merge # commits for the git2-ownership move (-6 packages / # -2 names). A shed, not a gate: the newer submodules stop @@ -300,4 +316,4 @@ # (libsqlite3-sys, ring) — see docs/plans MIGRATION-PLAN G6. # 307/284 2026-08-12 Re-baseline after the upstream lockfile resolution; # `flows` remains at two native packages. -flows:297:279:2 +flows:286:268:2 diff --git a/src/openhuman/agent/harness/parse.rs b/src/openhuman/agent/harness/parse.rs index fc25a89e88..92596b9f65 100644 --- a/src/openhuman/agent/harness/parse.rs +++ b/src/openhuman/agent/harness/parse.rs @@ -13,8 +13,17 @@ //! production build even before the move. pub(crate) use tinyagents::harness::tool_calling::{ - extract_json_values, parse_arguments_value, parse_glm_style_tool_calls, parse_tool_call_value, - parse_tool_calls, parse_tool_calls_from_json_value, parse_tool_calls_with_pformat, + extract_json_values, parse_tool_calls, parse_tool_calls_with_pformat, +}; + +// The rest of the crate's re-exports are only reached from this module's own +// tests (`tests.rs`) and `harness_gap_tests.rs`, not from any production call +// site — gated so a non-test build doesn't warn (and fail `-D warnings`) on +// them. +#[cfg(test)] +pub(crate) use tinyagents::harness::tool_calling::{ + parse_arguments_value, parse_glm_style_tool_calls, parse_tool_call_value, + parse_tool_calls_from_json_value, }; #[cfg(test)] diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index 6e710c07ee..e6b6b5a32e 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -338,6 +338,54 @@ impl PostTurnHook for RecordingHook { } } +/// Point `OPENHUMAN_WORKSPACE` at a scratch directory for the lifetime of a +/// test, restoring the previous value on drop. +/// +/// Needed by any test that lets the harness reach `Config::load_or_init()` — +/// notably the triggered `agent_memory` path, whose deterministic fast path +/// (`subagent_runner::ops::runner::try_deterministic_memory_retrieval`, #4677) +/// loads the **host** config and queries the real memory tree behind it, not +/// the `Memory` handed to the `Agent` under test. Without this the test reads +/// the developer's own `~/.openhuman`: on a populated machine `fast_retrieve` +/// returns hits, the fast path short-circuits with zero provider calls, and the +/// mock provider's queued responses land on the wrong turns. CI has an empty +/// home, so the failure only ever reproduces locally. +/// +/// Same shape as the guards in `memory::ops::files` / `memory::query:: +/// test_workspace`; `TEST_ENV_LOCK` serializes it against them. +struct WorkspaceEnvGuard { + _lock: std::sync::MutexGuard<'static, ()>, + previous: Option, +} + +impl WorkspaceEnvGuard { + fn set(path: &std::path::Path) -> Self { + let lock = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous = std::env::var_os("OPENHUMAN_WORKSPACE"); + unsafe { + std::env::set_var("OPENHUMAN_WORKSPACE", path); + } + Self { + _lock: lock, + previous, + } + } +} + +impl Drop for WorkspaceEnvGuard { + fn drop(&mut self) { + unsafe { + if let Some(previous) = self.previous.take() { + std::env::set_var("OPENHUMAN_WORKSPACE", previous); + } else { + std::env::remove_var("OPENHUMAN_WORKSPACE"); + } + } + } +} + fn make_agent(visible_tool_names: Option>) -> Agent { // The embedding seam fails loudly when unwired; before the memory // extraction this was a direct call and needed no setup. @@ -964,6 +1012,12 @@ async fn turn_triggers_configured_memory_agent_before_parent_prompt() { let provider: Arc> = provider_impl.clone(); let workspace = tempfile::TempDir::new().expect("temp workspace"); let workspace_path = workspace.path().to_path_buf(); + // The triggered memory agent runs through `run_subagent`, whose + // deterministic fast path loads the host config and queries whatever memory + // tree it points at. Keep that inside this test's scratch workspace so the + // fast path finds nothing and the model-driven walk (the two-call sequence + // asserted below) is what actually runs. + let _workspace_env = WorkspaceEnvGuard::set(&workspace_path); let memory_cfg = crate::openhuman::config::MemoryConfig { backend: "none".into(), ..crate::openhuman::config::MemoryConfig::default() diff --git a/src/openhuman/modules/registry.rs b/src/openhuman/modules/registry.rs index b62405a5be..bfda832f29 100644 --- a/src/openhuman/modules/registry.rs +++ b/src/openhuman/modules/registry.rs @@ -103,71 +103,91 @@ const TINYDOCS: ModuleRecord = ModuleRecord { /// 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. +/// **This host sends it the recovery phrase, over confidential calls, and never +/// derives or signs itself.** All four chains — Bitcoin, EVM, Solana and Tron — +/// derive and sign inside the module. This binary links neither `tinywallet`'s +/// `key` feature nor `k256`; see the note on the `tinywallet` dependency. +/// +/// The phrase is only sent to a module tinybus has attested *and* whose digest +/// matches one of the entries below — `super::wallet::attested_proxy` checks +/// this table itself rather than trusting that some check happened. +/// +/// One call brings key material back: `ExportKey`, used solely for tiny.place's +/// `LocalSigner::from_seed`, which takes a seed and cannot be handed a message +/// to sign instead. Replacing that seam is what it would take to remove it. +/// +/// Three releases got here, and the order mattered. v0.2.3 changed no method at +/// all — it was the same module rebuilt against a bus that could attest it. +/// Attestation used to be recorded only from a `modules.toml` beside the +/// artifact, and a release download extracts into a temporary directory that has +/// none, so this module could never be an attested recipient however carefully +/// the digest below was pinned (tinybus#15 fixed that). Only then was it safe +/// for v0.3.0 to add methods that take a secret, and for v0.4.0 to add +/// `SignMessage` for the Solana and x402 encodings the wire contract does not +/// model. Adding them earlier would have made them unreachable in production and +/// reachable in a developer's tree, which is the worst of both. 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.1", - release_url: "https://github.com/tinyhumansai/tinywallet/releases/tag/v0.2.1", + version: "0.4.0", + release_url: "https://github.com/tinyhumansai/tinywallet/releases/tag/v0.4.0", assets: &[ PlatformAsset { host_key: "ubuntu-24.04-x86_64", - archive: "tinywallet-module-0.2.1-ubuntu-24.04-x86_64.tar.gz", - sha256: "42e3440d367c251d687505115b7c0e2bdcd8ec4f064438a03762bbb5c1b651c0", + archive: "tinywallet-module-0.4.0-ubuntu-24.04-x86_64.tar.gz", + sha256: "737a18c258bb9013ad85006433c72a5dc83b94de8f15a0d37723a3b96cf047fa", }, PlatformAsset { host_key: "ubuntu-24.04-arm64", - archive: "tinywallet-module-0.2.1-ubuntu-24.04-arm64.tar.gz", - sha256: "9b9f102e1bde35bc59ca9898c0531de87dc34408a6d495a56d821e173284f65a", + archive: "tinywallet-module-0.4.0-ubuntu-24.04-arm64.tar.gz", + sha256: "72217d4f4dc1a2328de08c83d24998cd51729e8157cd2e9cb3b034ec1da2ea94", }, PlatformAsset { host_key: "ubuntu-22.04-x86_64", - archive: "tinywallet-module-0.2.1-ubuntu-22.04-x86_64.tar.gz", - sha256: "77c3f8a188ac69d4faa4b7305c83065682b36683754e3072e8f6a1d64c8fe795", + archive: "tinywallet-module-0.4.0-ubuntu-22.04-x86_64.tar.gz", + sha256: "e7d2d1a40331b5fea1dc9d8870c206d093c756af91790a15e3fcc9fc1b160158", }, PlatformAsset { host_key: "ubuntu-22.04-arm64", - archive: "tinywallet-module-0.2.1-ubuntu-22.04-arm64.tar.gz", - sha256: "1a63b576eb5f07dd54cfd27f63f3f6c86718fb93e11726ec6482d7cc95db6863", + archive: "tinywallet-module-0.4.0-ubuntu-22.04-arm64.tar.gz", + sha256: "248fd13ba59ab9c00ccd605b60c533aabd41be0f82cd167758524842122510f1", }, PlatformAsset { host_key: "macos-26-arm64", - archive: "tinywallet-module-0.2.1-macos-26-arm64.tar.gz", - sha256: "3c89b41511156ced51267da77a83519d7fae0ab10b2efa311d1a5ffde748a360", + archive: "tinywallet-module-0.4.0-macos-26-arm64.tar.gz", + sha256: "e6df7dc830d595a63af6864cbec6e3e22e51f35af558e7b62fa655d6b16d0581", }, PlatformAsset { host_key: "macos-26-x86_64", - archive: "tinywallet-module-0.2.1-macos-26-x86_64.tar.gz", - sha256: "eb3ea578e0b05f03a8150af07de40b4c61b584e0d1c1944b2172bde8a356701c", + archive: "tinywallet-module-0.4.0-macos-26-x86_64.tar.gz", + sha256: "fd197ac908057b9b5b4c7aef1b86e74ea7369133eff2a4835c310c73e7816a01", }, PlatformAsset { host_key: "macos-15-arm64", - archive: "tinywallet-module-0.2.1-macos-15-arm64.tar.gz", - sha256: "768a6eb74ceff9ddcc6c7d0c79dc2942e29d4264a7df11d82152c921b426aa5a", + archive: "tinywallet-module-0.4.0-macos-15-arm64.tar.gz", + sha256: "28a56ed94827b46a972c054b07e614684b7217d8f8c69373e93b957de336901b", }, PlatformAsset { host_key: "macos-15-x86_64", - archive: "tinywallet-module-0.2.1-macos-15-x86_64.tar.gz", - sha256: "c9ee8e0367beb56ef9aafbe4d830c4e4a58b4b6d0150f7f42c4fb4536441099c", + archive: "tinywallet-module-0.4.0-macos-15-x86_64.tar.gz", + sha256: "2e97717f08efefb90a8be51f389cbf826fb132fc7111f11837e9ee717c527e58", }, PlatformAsset { host_key: "windows-2025-x86_64", - archive: "tinywallet-module-0.2.1-windows-2025-x86_64.zip", - sha256: "ef64bd36086fcba105f30703bc3ef7102a24a48e510e622c502b8b9bfa2ea68f", + archive: "tinywallet-module-0.4.0-windows-2025-x86_64.zip", + sha256: "c9393d6c0f171db34298950ad029c21ea6b41f3f77971cf6668ebbd7f34736b7", }, PlatformAsset { host_key: "windows-2022-x86_64", - archive: "tinywallet-module-0.2.1-windows-2022-x86_64.zip", - sha256: "1d9f18071ee185a8b13ffc6c93e0f83c232eac879dca46bb5d36d9e832a133e4", + archive: "tinywallet-module-0.4.0-windows-2022-x86_64.zip", + sha256: "8ed5e86977f951a8c54dbde82914f6f936d4402564beb30406f4140d4be02872", }, PlatformAsset { host_key: "windows-11-arm64", - archive: "tinywallet-module-0.2.1-windows-11-arm64.zip", - sha256: "20ed7d288fcd9d8a58eb774a30099a292bff06bc1d5b983a06b88d555e0b41dc", + archive: "tinywallet-module-0.4.0-windows-11-arm64.zip", + sha256: "7854dfeb1dd04afe99488616e223a0f3ce6d7c671e22f8fbc3089eb0523cbf51", }, ], load: LoadPolicy::Lazy, diff --git a/src/openhuman/modules/wallet.rs b/src/openhuman/modules/wallet.rs index 5b3cb004eb..1dcfb6e80d 100644 --- a/src/openhuman/modules/wallet.rs +++ b/src/openhuman/modules/wallet.rs @@ -1,14 +1,27 @@ -//! Calling the `tinywallet` module: build a transaction there, sign it here. +//! Calling the `tinywallet` module. //! -//! 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 host half of `ai.tinyhumans.tinywallet.Wallet`. Two ways to get a signed +//! transaction, and which one a chain uses is a property of that chain. //! -//! # The private key does not cross the bus +//! # The confidential flow — preferred, and what BTC, EVM and Tron use //! -//! 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: +//! [`sign_transaction_in_module`] sends the recovery phrase itself, once, and +//! the module derives, encodes, signs and assembles. No private key is ever +//! reassembled in this process. +//! +//! ```text +//! host ==SignTransaction{phrase, path, fields}==> module (confidential) +//! host <=={raw transaction, txid}================ module +//! ``` +//! +//! The phrase only goes to a recipient tinybus has attested, and +//! [`attested_proxy`] additionally insists the attested digest is one +//! `registry.rs` pinned — see there for why both checks are worth having. +//! +//! # The split flow — still here, still correct for some hosts +//! +//! [`sign_transaction`] keeps the key in this process: the module returns +//! digests, this process signs them, the module reassembles. //! //! ```text //! host --BuildUnsigned{fields, public key}--> module @@ -18,19 +31,31 @@ //! 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. +//! It is not deprecated. It is the only option for a backend reached across a +//! transport, where the bus cannot say what is on the other end, and it is what +//! Solana still uses here — Solana hand-builds SPL messages that +//! `TransactionSpec::Solana` does not model, so there is nothing to send. +//! +//! # What the confidential flow does and does not buy +//! +//! A loaded module shares this address space and could read the phrase out of +//! process memory whichever flow is used, so neither is a hard isolation +//! boundary and neither is claimed as one. +//! +//! What changes is that this binary no longer performs derivation or signing at +//! all — the key exists only inside the module, for the duration of one call, +//! rather than being assembled here and held across two round trips. The bus +//! also refuses to carry the phrase to anything that is not an allowlisted, +//! hash-verified module, which the split flow could not express. //! -//! # The fields are sent twice, deliberately +//! # The fields are sent twice in the split flow, 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. +//! transaction the digests were computed over. The confidential flow needs none +//! of this: it is one call. //! //! # Two signing schemes, and the difference matters //! @@ -41,9 +66,9 @@ //! 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 crate::openhuman::web3::wallet::primitives::wire::{ - AttachRequest, PublicKey, Scheme, Signature, SignedTransaction, SigningPayload, SigningRequest, - TransactionSpec, UnsignedTransaction, +use tinywallet::wire::{ + DerivedAccount, ExportRequest, ExportedKey, Scheme, SecretMaterial, SignMessageRequest, + SignRequest, Signature, SignedTransaction, TransactionSpec, }; use super::{host, ops, registry}; @@ -78,134 +103,201 @@ impl std::fmt::Display for WalletCallError { } } -/// Build, sign, and assemble a transaction for `chain`. +/// Derive, build, sign and assemble entirely inside the module. /// -/// `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. +/// The counterpart to [`sign_transaction`], and the one to prefer: the phrase +/// crosses the bus once and no key material is ever reassembled here. /// /// # Errors /// -/// [`WalletCallError`] describing whether the request, the module, or the -/// signing was at fault. -pub async fn sign_transaction( +/// [`WalletCallError`]. `Unavailable` if the module is not an attested +/// recipient — see [`attested_proxy`], which refuses to send the phrase at all +/// in that case. +pub async fn sign_transaction_in_module( config: &Config, transaction: &TransactionSpec, - secret: &[u8], - public_key: &[u8], + secret: &SecretMaterial, ) -> 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 { + let proxy = attested_proxy(config).await?; + log::debug!( + "[modules:wallet] sign_transaction chain={:?} module={MODULE_ID} (confidential)", + transaction.chain() + ); + proxy + .call_confidential( + "SignTransaction", + (SignRequest { + secret: secret.clone(), transaction: transaction.clone(), - public_key: public_key.clone(), },), ) .await - .map_err(|error| classify(&error))?; + .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::, _>>()?; +/// Ask the module for an address and public key. +/// +/// # Errors +/// +/// [`WalletCallError`]. +pub async fn derive_account( + config: &Config, + secret: &SecretMaterial, +) -> Result { + let proxy = attested_proxy(config).await?; + log::debug!( + "[modules:wallet] derive_account chain={:?} module={MODULE_ID} (confidential)", + secret.chain + ); + proxy + .call_confidential("DeriveAccount", (secret.clone(),)) + .await + .map_err(|error| classify(&error)) +} +/// Sign opaque bytes with the key derived from `secret`. +/// +/// Blind: the module cannot check what the bytes mean, so +/// [`sign_transaction_in_module`] is the right call wherever the request can be +/// expressed as a `TransactionSpec`. This exists for the two encodings the wire +/// contract does not model — Solana SPL transfers and x402 payments — where the +/// alternative is not a verified signature but deriving the key in this process +/// and signing here, which is what these callers used to do. +/// +/// # Errors +/// +/// [`WalletCallError`]. +pub async fn sign_message( + config: &Config, + secret: &SecretMaterial, + message: &[u8], + scheme: Scheme, +) -> Result { + let proxy = attested_proxy(config).await?; log::debug!( - "[modules:wallet] attach_signature chain={chain:?} signatures={}", - signatures.len() + "[modules:wallet] sign_message chain={:?} bytes={} module={MODULE_ID} (confidential)", + secret.chain, + message.len() ); proxy - .call( - "AttachSignature", - (AttachRequest { - transaction: transaction.clone(), - public_key, - signatures, + .call_confidential( + "SignMessage", + (SignMessageRequest { + secret: secret.clone(), + message_hex: hex(message), + scheme, },), ) .await .map_err(|error| classify(&error)) } -/// Sign one payload with whichever scheme it declares. +/// Ask the module for the raw derived key. /// -/// 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. -#[allow(unreachable_patterns)] -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(), - )), - } +/// The one call that brings key material back into this process. It exists for +/// signers this host must drive itself — tinyplace's `LocalSigner`, which takes +/// a seed and cannot be handed a transaction instead. Anything that can use +/// [`sign_transaction_in_module`] must. +/// +/// # Errors +/// +/// [`WalletCallError`]. +pub async fn export_key( + config: &Config, + secret: &SecretMaterial, +) -> Result { + let proxy = attested_proxy(config).await?; + log::debug!( + "[modules:wallet] export_key chain={:?} module={MODULE_ID} (confidential)", + secret.chain + ); + proxy + .call_confidential( + "ExportKey", + (ExportRequest { + secret: secret.clone(), + },), + ) + .await + .map_err(|error| classify(&error)) } -/// secp256k1 ECDSA over an already-computed digest, with the recovery id. +/// A proxy that has proved it is the artifact this build pinned. +/// +/// # Why check here when the broker already refuses +/// +/// The broker will not route a confidential call to an unattested recipient, so +/// omitting this would still be safe against the case it covers. Two reasons to +/// check anyway. +/// +/// The first is the error. A broker refusal arrives after the request has been +/// serialized, which means a frame containing the recovery phrase was built and +/// handed to the bus before anything said no. Checking first means the phrase is +/// never put into a buffer on a call that was always going to fail. /// -/// `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; +/// The second is that this compares the digest against **this build's own +/// table**, which the broker cannot do — it only knows the host vouched for +/// something. Here we can insist it vouched for one of the artifacts +/// `registry.rs` names. That closes the gap where a host is somehow induced to +/// attest a different artifact for this bus name. +/// +/// Both are belt-and-braces over a check that already exists. That is the right +/// posture for the one code path that hands over a recovery phrase. +async fn attested_proxy(config: &Config) -> Result { + let (runtime, record) = ready(config).await?; + let proxy = proxy(runtime, record)?; + + let attestation = proxy + .attestation() + .await + .map_err(|error| WalletCallError::Failed(error.to_string()))? + .ok_or_else(|| { + WalletCallError::Unavailable( + "the wallet module is loaded but not an attested recipient, so it cannot be sent \ + key material. This host is running a build whose module loader does not record \ + an attestation for pinned releases; upgrade it rather than working around this." + .to_string(), + ) + })?; - 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()))?; + if attestation.name.as_str() != record.bus_name { + return Err(WalletCallError::Failed(format!( + "the wallet module's attestation names '{}' rather than '{}'", + attestation.name.as_str(), + record.bus_name + ))); + } - Ok(Signature::Secp256k1 { - rs_hex: hex(&signature.to_bytes()), - recovery_id: recovery_id.to_byte(), - }) -} + if !digest_is_pinned(record, &attestation.sha256) { + return Err(WalletCallError::Failed( + "the loaded wallet module is not one of the artifacts this build pinned, so it will \ + not be sent key material" + .to_string(), + )); + } -/// ed25519 over the whole message. -fn sign_ed25519(message: &[u8], secret: &[u8]) -> Result { - use ed25519_dalek::{Signer as _, SigningKey}; + Ok(proxy) +} - 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()), - }) +/// Whether `sha256` is one of the artifacts this build pinned for `record`. +/// +/// Case-insensitive because the release manifest and the pinned table are +/// written by different hands and both are hex of the same bytes. Nothing +/// compared here is a secret, so a constant-time comparison would buy nothing. +/// +/// Every asset is checked rather than just the one for this platform: which +/// artifact loaded is not recorded, and a host that fell through to an older +/// build after an admission failure is running a pinned artifact either way. +fn digest_is_pinned(record: &super::ModuleRecord, sha256: &str) -> bool { + record + .assets + .iter() + .any(|asset| asset.sha256.eq_ignore_ascii_case(sha256)) } /// Load the wallet module if it is not already serving. /// -/// Callers do not have to invoke this — [`sign_transaction`] does — but a caller +/// Callers do not have to invoke this — the signing calls do — 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 diff --git a/src/openhuman/modules/wallet_tests.rs b/src/openhuman/modules/wallet_tests.rs index 89d651c728..f018d0ffe1 100644 --- a/src/openhuman/modules/wallet_tests.rs +++ b/src/openhuman/modules/wallet_tests.rs @@ -7,12 +7,10 @@ //! themselves are covered where they can be honest: `tinywallet`'s own loader //! E2E, which drives a real module over a real broker. -use crate::openhuman::web3::wallet::primitives::wire::{ - Scheme, Signature, SigningPayload, TransactionSpec, -}; -use crate::openhuman::web3::wallet::primitives::Chain; +use tinywallet::wire::{Scheme, Signature, SigningPayload, TransactionSpec}; +use tinywallet::Chain; -use super::{classify, sign_payload, WalletCallError}; +use super::{classify, WalletCallError}; use crate::openhuman::config::Config; /// The BIP-39 test vector mnemonic. Never use it for real funds. @@ -35,11 +33,16 @@ fn failure(name: &str) -> tinybus::Error { } } -fn evm_secret() -> Vec { - crate::openhuman::web3::wallet::primitives::key::derive(Chain::Evm, VECTOR, "m/44'/60'/0'/0/0") - .expect("the vector mnemonic derives") - .secret_bytes() - .to_vec() +/// The phrase and path a confidential call carries. +/// +/// No key is derived here any more: the module does that. This is the request, +/// not the secret it produces. +fn evm_signing_secret() -> tinywallet::wire::SecretMaterial { + tinywallet::wire::SecretMaterial { + mnemonic: VECTOR.to_string(), + derivation_path: "m/44'/60'/0'/0/0".to_string(), + chain: Chain::Evm, + } } #[test] @@ -103,106 +106,6 @@ fn every_error_renders_as_its_message() { } } -#[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 = crate::openhuman::web3::wallet::primitives::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(); @@ -219,7 +122,7 @@ async fn a_disabled_host_reports_unavailable_without_starting_a_broker() { }; assert!(matches!( - super::sign_transaction(&config, &spec, &evm_secret(), &[0x02; 33]).await, + super::sign_transaction_in_module(&config, &spec, &evm_signing_secret()).await, Err(WalletCallError::Unavailable(_)) )); assert!(matches!( @@ -227,3 +130,118 @@ async fn a_disabled_host_reports_unavailable_without_starting_a_broker() { Err(WalletCallError::Unavailable(_)) )); } + +// --------------------------------------------------------------------------- +// The attestation guard +// --------------------------------------------------------------------------- + +/// The digest check is the one part of `attested_proxy` that does not need a +/// live broker, and it is the part that decides whether a recovery phrase is +/// handed over. The rest of the guard — that an attestation exists at all — is +/// enforced by tinybus and covered by its own tests against a real `dlopen`. +mod attestation_guard { + use super::super::digest_is_pinned; + use crate::openhuman::modules::registry; + + fn record() -> &'static crate::openhuman::modules::ModuleRecord { + registry::find("tinywallet").expect("the tinywallet record is compiled in") + } + + #[test] + fn every_pinned_artifact_is_accepted() { + let record = record(); + assert!(!record.assets.is_empty()); + for asset in record.assets { + assert!( + digest_is_pinned(record, asset.sha256), + "pinned artifact {} was not accepted by its own table", + asset.archive + ); + } + } + + #[test] + fn a_digest_this_build_did_not_pin_is_refused() { + // The case that matters: an artifact the host attested but this build + // never named. Without the check, "the host vouched for something" + // would be enough to be sent a key. + assert!(!digest_is_pinned(record(), &"a".repeat(64))); + assert!(!digest_is_pinned(record(), "")); + } + + #[test] + fn a_pinned_digest_is_matched_regardless_of_hex_case() { + // The release manifest and this table are written by different hands. + // A case mismatch refusing a legitimate artifact would break signing + // for every user, and it would look like an attack rather than a typo. + let record = record(); + let upper = record.assets[0].sha256.to_ascii_uppercase(); + assert_ne!( + upper, record.assets[0].sha256, + "fixture must actually differ" + ); + assert!(digest_is_pinned(record, &upper)); + } +} + +/// The confidential request shapes, pinned against the module's own types. +/// +/// `call_confidential` is generic over its argument tuple, so nothing checks +/// that the value sent for a method is the type that method takes. That gap is +/// not theoretical: `ExportKey` was first called with a bare `SecretMaterial` +/// where the module expects an `ExportRequest` wrapping one. It compiled, and +/// it would have failed at deserialization on the far side of the bus — the +/// only signal being a runtime error on the one path that exports a key. +/// +/// These assert the two shapes are genuinely distinct, so the wrapper cannot be +/// dropped again without a test failing. +mod request_shapes { + use tinywallet::wire::{ExportRequest, SecretMaterial, SignMessageRequest, SignRequest}; + + fn secret() -> SecretMaterial { + super::evm_signing_secret() + } + + #[test] + fn an_export_request_is_not_interchangeable_with_a_bare_secret() { + let bare = serde_json::to_value(secret()).unwrap(); + assert!( + serde_json::from_value::(bare).is_err(), + "a bare SecretMaterial must not deserialize as an ExportRequest, or the \ + wrapper could be dropped at a call site without anything noticing" + ); + + let wrapped = serde_json::to_value(ExportRequest { secret: secret() }).unwrap(); + assert!(serde_json::from_value::(wrapped).is_ok()); + } + + #[test] + fn the_wrapped_request_types_do_not_accept_each_other() { + // `SignRequest` and `SignMessageRequest` both wrap a secret and both + // take a second field, so a call site that swapped them would still + // look plausible. `deny_unknown_fields` is what stops that. + let sign_message = serde_json::to_value(SignMessageRequest { + secret: secret(), + message_hex: "00".repeat(32), + scheme: tinywallet::wire::Scheme::Secp256k1Prehash, + }) + .unwrap(); + assert!(serde_json::from_value::(sign_message).is_err()); + assert!(serde_json::from_value::( + serde_json::to_value(SignRequest { + secret: secret(), + transaction: tinywallet::wire::TransactionSpec::Evm { + to: format!("0x{}", "11".repeat(20)), + 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, + }, + }) + .unwrap() + ) + .is_err()); + } +} diff --git a/src/openhuman/web3/wallet/abi.rs b/src/openhuman/web3/wallet/abi.rs index 0597c0ae41..10a8c3577e 100644 --- a/src/openhuman/web3/wallet/abi.rs +++ b/src/openhuman/web3/wallet/abi.rs @@ -5,7 +5,7 @@ //! grammar, a bignum, and their tails — to produce a four-byte selector //! followed by two 32-byte words. //! -//! `crate::openhuman::web3::wallet::primitives::abi` owns that encoding now, over `sha3` alone, and +//! `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. @@ -27,19 +27,18 @@ /// amount is not a non-negative integer that fits in 256 bits. #[allow(unreachable_patterns)] pub fn encode_erc20_transfer(to_address: &str, amount_raw: &str) -> Result { - crate::openhuman::web3::wallet::primitives::abi::encode_erc20_transfer(to_address, amount_raw) - .map_err(|error| match error { - crate::openhuman::web3::wallet::primitives::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. - crate::openhuman::web3::wallet::primitives::abi::Error::InvalidAmount { .. } => { - format!("amount '{amount_raw}' is not a valid non-negative integer") - } - _ => error.to_string(), - }) + 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)] diff --git a/src/openhuman/web3/wallet/chains/btc.rs b/src/openhuman/web3/wallet/chains/btc.rs index c35ad94b55..31662c0a16 100644 --- a/src/openhuman/web3/wallet/chains/btc.rs +++ b/src/openhuman/web3/wallet/chains/btc.rs @@ -14,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::{ - compressed_public_key, ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction, - TxLookupInfo, TxReceiptInfo, TxState, TxStatusInfo, + 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}; @@ -61,8 +61,7 @@ pub fn estimated_btc_fee_sats() -> u64 { /// specific, so the rules live where any host can reach them; what stays here /// is the `Result<_, String>` shape the rest of this domain speaks. pub fn validate_btc_address(addr: &str) -> Result { - let result = crate::openhuman::web3::wallet::primitives::address::btc::validate(addr) - .map_err(|e| e.to_string()); + let result = tinywallet::address::btc::validate(addr).map_err(|e| e.to_string()); debug!( "{LOG_PREFIX} validate_address role=recipient result={}", if result.is_ok() { @@ -82,8 +81,7 @@ pub fn validate_btc_address(addr: &str) -> Result { /// the recipient rule for a sender accepts an address that only fails later, /// at signing time. pub fn validate_btc_sender_address(addr: &str) -> Result { - let result = crate::openhuman::web3::wallet::primitives::address::btc::validate_sender(addr) - .map_err(|e| e.to_string()); + let result = tinywallet::address::btc::validate_sender(addr).map_err(|e| e.to_string()); debug!( "{LOG_PREFIX} validate_address role=sender result={}", if result.is_ok() { @@ -128,21 +126,19 @@ pub async fn broadcast_raw_hex(tx_hex: &str) -> Result { /// Delegates to the vendored [`tinywallet`] crate, which owns BIP-32 /// secp256k1 derivation. Custody stays here: the mnemonic is decrypted from /// the keyring by this crate and handed over as a `&str` that is not retained. +/// Test-only: production derives inside the wallet module. +#[cfg(test)] fn derive_btc_private_key( mnemonic: &str, derivation_path: &str, ) -> Result<(Vec, Vec), String> { - let derived = crate::openhuman::web3::wallet::primitives::key::derive( - crate::openhuman::web3::wallet::primitives::Chain::Btc, - mnemonic, - derivation_path, - ) - .map_err(|e| e.to_string())?; + let derived = tinywallet::key::derive(tinywallet::Chain::Btc, mnemonic, derivation_path) + .map_err(|e| e.to_string())?; 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) + let public_key = super::super::execution::compressed_public_key(&secret) .map_err(|_| "tinywallet returned an unusable BTC key".to_string())?; Ok((secret, public_key)) } @@ -206,7 +202,15 @@ pub async fn execute_btc_quote(mut quote: PreparedTransaction) -> Result Result Result ( - crate::openhuman::web3::wallet::primitives::address::evm::validate("e.to_address) - .map_err(|e| format!("invalid EVM recipient address '{}': {e}", quote.to_address))?, + tinywallet::address::evm::validate("e.to_address).map_err(|e| { + format!("invalid EVM recipient address '{}': {e}", quote.to_address) + })?, quote.amount_raw.clone(), None, ), @@ -157,7 +156,7 @@ pub async fn execute_evm_quote(mut quote: PreparedTransaction) -> Result Result Result { - let result = crate::openhuman::web3::wallet::primitives::address::evm::validate(addr) - .map_err(|e| e.to_string()); + let result = tinywallet::address::evm::validate(addr).map_err(|e| e.to_string()); debug!( "{LOG_PREFIX} validate_address result={}", if result.is_ok() { diff --git a/src/openhuman/web3/wallet/chains/solana.rs b/src/openhuman/web3/wallet/chains/solana.rs index b005131b3f..e523905e12 100644 --- a/src/openhuman/web3/wallet/chains/solana.rs +++ b/src/openhuman/web3/wallet/chains/solana.rs @@ -11,7 +11,8 @@ use base64::engine::{general_purpose::STANDARD as B64, Engine as _}; use curve25519_dalek::edwards::CompressedEdwardsY; -use ed25519_dalek::{Signer, SigningKey, SECRET_KEY_LENGTH}; +#[cfg(test)] +use ed25519_dalek::{SigningKey, SECRET_KEY_LENGTH}; use log::debug; use serde::Deserialize; use serde_json::json; @@ -66,8 +67,7 @@ struct BlockhashValue { /// format; this wrapper keeps the `Result<_, String>` shape the rest of the /// domain speaks. pub fn validate_solana_address(addr: &str) -> Result { - let result = crate::openhuman::web3::wallet::primitives::address::solana::validate(addr) - .map_err(|e| e.to_string()); + let result = tinywallet::address::solana::validate(addr).map_err(|e| e.to_string()); debug!( "{LOG_PREFIX} validate_address result={}", if result.is_ok() { @@ -103,13 +103,11 @@ pub async fn native_balance(address: &str) -> Result { /// its own error variant rather than folding it into a generic parse failure, /// because such a path is derivable-looking but underivable on ed25519 — and /// silently hardening it would return a different account than the path names. +/// Test-only: production derives inside the wallet module. +#[cfg(test)] fn derive_solana_keypair(mnemonic: &str, derivation_path: &str) -> Result { - let derived = crate::openhuman::web3::wallet::primitives::key::derive( - crate::openhuman::web3::wallet::primitives::Chain::Solana, - mnemonic, - derivation_path, - ) - .map_err(|e| e.to_string())?; + let derived = tinywallet::key::derive(tinywallet::Chain::Solana, mnemonic, derivation_path) + .map_err(|e| e.to_string())?; let bytes: [u8; SECRET_KEY_LENGTH] = derived .secret_bytes() .try_into() @@ -235,6 +233,105 @@ fn pubkey_to_b58(pubkey: &[u8; 32]) -> String { bs58::encode(pubkey).into_string() } +/// The wallet's Solana account, and the phrase to sign with. +/// +/// Derivation and signing both happen in the loaded wallet module; this process +/// holds the phrase only long enough to hand it over on a confidential call, +/// and never assembles a private key. The module is sent it only after proving +/// it is an artifact this build pinned — see `modules::wallet::attested_proxy`. +/// +/// # The `cfg(test)` branch +/// +/// Under `cfg(test)` this derives locally instead of calling the module, and so +/// does [`solana_sign`]. A unit test has no loaded module, and the coverage +/// these tests carry is the RPC choreography and wire format around signing — +/// how many calls go out, in what order, and what bytes get broadcast — none of +/// which is about *who* holds the key. +/// +/// What that deliberately does not cover is the module wiring itself. That is +/// covered where it can be honest: tinywallet's loader E2E signs through a real +/// `dlopen`'d module over a real broker, and `modules::wallet`'s own tests pin +/// the attestation guard. The local branch cannot exist in a shipped binary. +async fn solana_signer( + config: &crate::openhuman::config::Config, +) -> Result<(tinywallet::wire::SecretMaterial, [u8; 32]), String> { + let secret = secret_material(WalletChain::Solana).await?; + let mnemonic = crate::openhuman::security::encryption::rpc::decrypt_secret( + config, + &secret.encrypted_mnemonic, + ) + .await? + .value; + let signing_secret = tinywallet::wire::SecretMaterial { + mnemonic, + derivation_path: secret.derivation_path.clone(), + chain: tinywallet::Chain::Solana, + }; + #[cfg(test)] + { + let _ = config; + let derived = + derive_solana_keypair(&signing_secret.mnemonic, &signing_secret.derivation_path)?; + return Ok((signing_secret, derived.verifying_key().to_bytes())); + } + + #[cfg(not(test))] + { + let account = crate::openhuman::modules::wallet::derive_account(config, &signing_secret) + .await + .map_err(|e| format!("failed to derive the Solana account: {e}"))?; + let pubkey = b58_to_pubkey(&account.address)?; + Ok((signing_secret, pubkey)) + } +} + +/// Sign `message` with the wallet key, inside the module. +async fn solana_sign( + config: &crate::openhuman::config::Config, + signing_secret: &tinywallet::wire::SecretMaterial, + message: &[u8], +) -> Result<[u8; 64], String> { + #[cfg(test)] + { + use ed25519_dalek::Signer as _; + let _ = config; + let key = derive_solana_keypair(&signing_secret.mnemonic, &signing_secret.derivation_path)?; + return Ok(key.sign(message).to_bytes()); + } + + #[cfg(not(test))] + let signature = crate::openhuman::modules::wallet::sign_message( + config, + signing_secret, + message, + tinywallet::wire::Scheme::Ed25519, + ) + .await + .map_err(|e| format!("failed to sign the Solana message: {e}"))?; + #[cfg(not(test))] + { + let tinywallet::wire::Signature::Ed25519 { signature_hex } = signature else { + return Err("the wallet module returned a non-ed25519 Solana signature".to_string()); + }; + let bytes = hex_to_bytes(&signature_hex)?; + <[u8; 64]>::try_from(bytes.as_slice()) + .map_err(|_| "the wallet module returned a malformed Solana signature".to_string()) + } +} + +/// Decode lowercase hex. +fn hex_to_bytes(value: &str) -> Result, String> { + if !value.len().is_multiple_of(2) { + return Err("odd-length hex from the wallet module".to_string()); + } + (0..value.len() / 2) + .map(|i| { + u8::from_str_radix(&value[i * 2..i * 2 + 2], 16) + .map_err(|e| format!("invalid hex from the wallet module: {e}")) + }) + .collect() +} + fn build_native_transfer_message( from: [u8; 32], to: [u8; 32], @@ -330,16 +427,8 @@ pub async fn execute_solana_quote( .parse() .map_err(|e| format!("invalid Solana amount '{}': {e}", quote.amount_raw))?; - let secret = secret_material(WalletChain::Solana).await?; let config = config_rpc::load_config_with_timeout().await?; - let mnemonic = crate::openhuman::security::encryption::rpc::decrypt_secret( - &config, - &secret.encrypted_mnemonic, - ) - .await? - .value; - let signing_key = derive_solana_keypair(&mnemonic, &secret.derivation_path)?; - let from_pk = signing_key.verifying_key().to_bytes(); + let (signing_secret, from_pk) = solana_signer(&config).await?; let expected_from = b58_to_pubkey(&from_addr)?; if from_pk != expected_from { return Err(format!( @@ -379,8 +468,7 @@ pub async fn execute_solana_quote( } }; - let signature = signing_key.sign(&message_bytes); - let sig_bytes = signature.to_bytes(); + let sig_bytes = solana_sign(&config, &signing_secret, &message_bytes).await?; let mut wire = Vec::with_capacity(1 + 64 + message_bytes.len()); wire.extend(encode_shortvec(1)); wire.extend(&sig_bytes); @@ -455,16 +543,8 @@ pub(crate) async fn sign_and_broadcast_versioned( } // Derive our signing key. - let secret = secret_material(WalletChain::Solana).await?; let config = config_rpc::load_config_with_timeout().await?; - let mnemonic = crate::openhuman::security::encryption::rpc::decrypt_secret( - &config, - &secret.encrypted_mnemonic, - ) - .await? - .value; - let signing_key = derive_solana_keypair(&mnemonic, &secret.derivation_path)?; - let our_pubkey = signing_key.verifying_key().to_bytes(); + let (signing_secret, our_pubkey) = solana_signer(&config).await?; // Find our signer index. let mut our_index: Option = None; @@ -486,8 +566,7 @@ pub(crate) async fn sign_and_broadcast_versioned( } // Sign the message bytes and write into our signature slot. - let signature = signing_key.sign(message); - let sig_bytes = signature.to_bytes(); + let sig_bytes = solana_sign(&config, &signing_secret, message).await?; let slot_off = sigs_start + our_index * 64; wire[slot_off..slot_off + 64].copy_from_slice(&sig_bytes); @@ -612,20 +691,36 @@ pub async fn lookup_tx(hash: &str) -> Result { /// derivation at `wallet/chains/solana.rs:369–376`. pub(crate) async fn tinyplace_signer_seed() -> Result<[u8; 32], String> { log::debug!("[tinyplace] deriving signer seed from Solana wallet key"); - let secret = secret_material(WalletChain::Solana).await?; let config = config_rpc::load_config_with_timeout().await?; - // Mirror exactly the decrypt call at solana.rs:371-374 (.value extraction). + let secret = secret_material(WalletChain::Solana).await?; let mnemonic = crate::openhuman::security::encryption::rpc::decrypt_secret( &config, &secret.encrypted_mnemonic, ) .await? .value; - let signing_key = derive_solana_keypair(&mnemonic, &secret.derivation_path)?; - // Extract 32-byte SLIP-0010 secret — same bytes LocalSigner::from_seed expects. + let signing_secret = tinywallet::wire::SecretMaterial { + mnemonic, + derivation_path: secret.derivation_path.clone(), + chain: tinywallet::Chain::Solana, + }; + + // The one path that brings a private key back into this process. Every + // other Solana operation signs inside the module and never sees one; this + // cannot, because tiny.place's `LocalSigner::from_seed` takes a seed and + // has no way to be handed a message to sign instead. Replacing that seam is + // what it would take to remove this call. + // + // The key travels as the reply to a confidential call, so it is delivered + // to this connection and never fanned out, monitored, or carried to a peer. + let exported = crate::openhuman::modules::wallet::export_key(&config, &signing_secret) + .await + .map_err(|e| format!("failed to export the Solana signer seed: {e}"))?; + let bytes = hex_to_bytes(&exported.secret_key_hex)?; // Never logged: the log below omits the seed. log::debug!("[tinyplace] signer seed derived (seed not logged)"); - Ok(signing_key.to_bytes()) + <[u8; 32]>::try_from(bytes.as_slice()) + .map_err(|_| "the wallet module returned a malformed Solana seed".to_string()) } #[cfg(test)] diff --git a/src/openhuman/web3/wallet/chains/tron.rs b/src/openhuman/web3/wallet/chains/tron.rs index 3e67e4bae7..1832205f9c 100644 --- a/src/openhuman/web3/wallet/chains/tron.rs +++ b/src/openhuman/web3/wallet/chains/tron.rs @@ -8,14 +8,13 @@ use log::debug; use serde::Deserialize; use serde_json::{json, Value}; -use sha2::{Digest, Sha256}; use crate::openhuman::config::rpc as config_rpc; use super::super::defaults::{explorer_tx_url, rpc_url_for_chain}; use super::super::execution::{ - compressed_public_key, ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction, - TxLookupInfo, TxReceiptInfo, TxState, TxStatusInfo, + ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction, TxLookupInfo, + TxReceiptInfo, TxState, TxStatusInfo, }; use super::super::ops::{secret_material, WalletChain}; use super::super::rpc::rest_post_json; @@ -32,8 +31,7 @@ const TRC20_FEE_LIMIT_SUN: u64 = 15_000_000; /// format; this wrapper keeps the `Result<_, String>` shape the rest of the /// domain speaks. pub fn validate_tron_address(addr: &str) -> Result { - let result = crate::openhuman::web3::wallet::primitives::address::tron::validate(addr) - .map_err(|e| e.to_string()); + let result = tinywallet::address::tron::validate(addr).map_err(|e| e.to_string()); debug!( "{LOG_PREFIX} validate_address result={}", if result.is_ok() { @@ -54,8 +52,7 @@ pub fn validate_tron_address(addr: &str) -> Result { /// the wrong length used to produce a short hex string and fail further /// downstream at the API call. pub fn tron_address_to_hex(addr: &str) -> Result { - let result = crate::openhuman::web3::wallet::primitives::address::tron::to_hex(addr) - .map_err(|e| e.to_string()); + let result = tinywallet::address::tron::to_hex(addr).map_err(|e| e.to_string()); debug!( "{LOG_PREFIX} address_to_hex result={}", if result.is_ok() { @@ -93,239 +90,55 @@ 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>, -} - +/// What the node was asked to build, for verifying what it returned. +/// +/// [`tinywallet::wire::TronTransfer`] is that type — it is already on the +/// host/module wire contract, so a second local mirror of it would be one more +/// thing to keep in step for no gain. The one thing it deliberately does not +/// carry is the fee limit, because only the caller knows what it pinned; that +/// rides alongside as [`verify_contract`]'s last argument. +type TronTransferVerification = tinywallet::wire::TronTransfer; + +/// Check a node-built Tron transaction, then describe it for the signer. +/// +/// The verification itself lives in [`tinywallet::tx::tron::verify_contract`], +/// which parses `raw_data` structurally. The protobuf reader, the contract +/// unwrapping and the per-contract field checks used to be hand-rolled here; +/// they are the same rules for every host, so they moved into the crate. What +/// stays is the part that is OpenHuman's: the fee limit this client pins, and +/// the [`tinywallet::wire::TransactionSpec`] handed to the wallet module. 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( - crate::openhuman::web3::wallet::primitives::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()) +) -> Result { + let recomputed_txid = tinywallet::tx::tron::recompute_txid(&raw_tx.raw_data_hex) .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}")); + // The fee limit is ours, not the crate's: it is what this client pinned in + // the `createtransaction` request, and only a TRC-20 trigger carries one. + let fee_limit_sun = match transfer { + TronTransferVerification::Native { .. } => None, + TronTransferVerification::Trc20 { .. } => Some(TRC20_FEE_LIMIT_SUN), }; - 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) + tinywallet::tx::tron::verify_contract( + &raw_tx.raw_data_hex, + &expected_to, + &raw_tx.tx_id, + transfer, + fee_limit_sun, + ) + .map_err(|error| format!("Tron node response rejected: {error}"))?; + + Ok(tinywallet::wire::TransactionSpec::Tron { + raw_data_hex: raw_tx.raw_data_hex.clone(), + expected_to, + expected_txid: recomputed_txid, + // Carried onto the wire so the wallet module re-checks it against the + // bytes it is about to sign, rather than trusting this side's verdict. + transfer: transfer.clone(), + }) } /// Derive the Tron signing key and its base58check address. @@ -334,13 +147,11 @@ fn take_exact<'a>(input: &mut &'a [u8], length: usize) -> Result<&'a [u8], Strin /// 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. +/// Test-only: production derives inside the wallet module. +#[cfg(test)] fn derive_tron_keypair(mnemonic: &str, derivation_path: &str) -> Result<(Vec, String), String> { - let derived = crate::openhuman::web3::wallet::primitives::key::derive( - crate::openhuman::web3::wallet::primitives::Chain::Tron, - mnemonic, - derivation_path, - ) - .map_err(|e| e.to_string())?; + let derived = tinywallet::key::derive(tinywallet::Chain::Tron, mnemonic, derivation_path) + .map_err(|e| e.to_string())?; Ok(( derived.secret_bytes().to_vec(), derived.address().to_string(), @@ -437,7 +248,19 @@ pub async fn execute_tron_quote(mut quote: PreparedTransaction) -> Result Result "native", TronTransferVerification::Trc20 { .. } => "trc20", @@ -505,11 +327,10 @@ pub async fn execute_tron_quote(mut quote: PreparedTransaction) -> Result, number: u64, value: u64) { - out.extend(encode_protobuf_varint(number << 3)); - out.extend(encode_protobuf_varint(value)); + out.extend(tinywallet::tx::proto::encode_varint(number << 3)); + out.extend(tinywallet::tx::proto::encode_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(tinywallet::tx::proto::encode_varint((number << 3) | 2)); + out.extend(tinywallet::tx::proto::encode_varint(value.len() as u64)); out.extend(value); } @@ -778,7 +599,7 @@ mod tests { 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(); + let txid = tinywallet::tx::tron::recompute_txid(&raw).unwrap(); create.lock().push(payload); axum::Json(json!({ "txID": txid, @@ -796,7 +617,7 @@ mod tests { 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(); + let txid = tinywallet::tx::tron::recompute_txid(&raw).unwrap(); trigger.lock().push(payload); axum::Json(json!({ "transaction": { @@ -837,7 +658,7 @@ mod tests { 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_txid = tinywallet::tx::tron::recompute_txid(&native_raw_hex).unwrap(); let native_tx = CreateTransactionResponse { tx_id: native_txid.clone(), raw_data: json!({}), @@ -853,16 +674,21 @@ mod tests { .unwrap(); assert_eq!( native, - crate::openhuman::web3::wallet::primitives::wire::TransactionSpec::Tron { + tinywallet::wire::TransactionSpec::Tron { raw_data_hex: native_raw_hex, expected_to: recipient.to_string(), expected_txid: native_txid, + // Carried through to the module, which re-verifies it against + // the bytes rather than trusting this side's check. + transfer: TronTransferVerification::Native { + amount_sun: 1_000_000, + }, } ); let parameter = "01".repeat(64); let token_raw = trc20_raw(&contract_hex, ¶meter); - let token_txid = recompute_tron_txid(&token_raw).unwrap(); + let token_txid = tinywallet::tx::tron::recompute_txid(&token_raw).unwrap(); let token_tx = CreateTransactionResponse { tx_id: token_txid.clone(), raw_data: json!({}), @@ -878,10 +704,13 @@ mod tests { .unwrap(); assert_eq!( token, - crate::openhuman::web3::wallet::primitives::wire::TransactionSpec::Tron { + tinywallet::wire::TransactionSpec::Tron { raw_data_hex: token_raw, expected_to: contract.to_string(), expected_txid: token_txid, + transfer: TronTransferVerification::Trc20 { + parameter_hex: parameter.clone(), + }, } ); assert_ne!(contract, recipient); @@ -924,7 +753,7 @@ mod tests { ), ] { let altered_tx = CreateTransactionResponse { - tx_id: recompute_tron_txid(&raw_data_hex).unwrap(), + tx_id: tinywallet::tx::tron::recompute_txid(&raw_data_hex).unwrap(), raw_data: json!({}), raw_data_hex, }; @@ -943,11 +772,11 @@ mod tests { // 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)); + decoy.extend(tinywallet::tx::proto::encode_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(), + tx_id: tinywallet::tx::tron::recompute_txid(&spoofed_raw).unwrap(), raw_data: json!({}), raw_data_hex: spoofed_raw, }; @@ -1113,7 +942,7 @@ mod tests { 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(); + let txid = tinywallet::tx::tron::recompute_txid(&raw).unwrap(); axum::Json(json!({ "txID": txid, "raw_data": {"contract": []}, diff --git a/src/openhuman/web3/wallet/execution.rs b/src/openhuman/web3/wallet/execution.rs index d17a7b1062..8527718438 100644 --- a/src/openhuman/web3/wallet/execution.rs +++ b/src/openhuman/web3/wallet/execution.rs @@ -36,8 +36,9 @@ 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. +/// Test-only. Production never holds a secp256k1 secret: the wallet module +/// derives the key and reports the public half through `DeriveAccount`. +#[cfg(test)] 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())?; @@ -347,8 +348,8 @@ pub(crate) fn validate_amount(raw: &str) -> Result { /// /// Every arm delegates to the vendored [`tinywallet`] crate, which owns the /// four address formats. The dispatch stays here rather than calling -/// `crate::openhuman::web3::wallet::primitives::address::validate` directly because [`WalletChain`] is -/// OpenHuman's enum, and mapping it onto `crate::openhuman::web3::wallet::primitives::Chain` here keeps that +/// `tinywallet::address::validate` directly because [`WalletChain`] is +/// OpenHuman's enum, and mapping it onto `tinywallet::Chain` here keeps that /// translation in one place. /// /// For Bitcoin this is the **recipient** rule — any well-formed mainnet @@ -357,14 +358,13 @@ pub(crate) fn validate_amount(raw: &str) -> Result { /// the other three chains, so it cannot be expressed through this entry point. fn validate_address(chain: WalletChain, addr: &str) -> Result { let tw_chain = match chain { - WalletChain::Evm => crate::openhuman::web3::wallet::primitives::Chain::Evm, - WalletChain::Btc => crate::openhuman::web3::wallet::primitives::Chain::Btc, - WalletChain::Solana => crate::openhuman::web3::wallet::primitives::Chain::Solana, - WalletChain::Tron => crate::openhuman::web3::wallet::primitives::Chain::Tron, + WalletChain::Evm => tinywallet::Chain::Evm, + WalletChain::Btc => tinywallet::Chain::Btc, + WalletChain::Solana => tinywallet::Chain::Solana, + WalletChain::Tron => tinywallet::Chain::Tron, }; debug!("{LOG_PREFIX} validate_address chain={chain:?} role=recipient dispatch=tinywallet"); - let result = crate::openhuman::web3::wallet::primitives::address::validate(tw_chain, addr) - .map_err(|e| e.to_string()); + let result = tinywallet::address::validate(tw_chain, addr).map_err(|e| e.to_string()); debug!( "{LOG_PREFIX} validate_address chain={chain:?} role=recipient result={}", if result.is_ok() { diff --git a/src/openhuman/web3/wallet/mod.rs b/src/openhuman/web3/wallet/mod.rs index a29ac4469e..fc9316d186 100644 --- a/src/openhuman/web3/wallet/mod.rs +++ b/src/openhuman/web3/wallet/mod.rs @@ -33,8 +33,6 @@ mod execution; #[cfg(feature = "web3")] mod ops; #[cfg(feature = "web3")] -pub(crate) mod primitives; -#[cfg(feature = "web3")] pub(crate) mod rpc; #[cfg(feature = "web3")] diff --git a/src/openhuman/web3/wallet/primitives/abi/mod.rs b/src/openhuman/web3/wallet/primitives/abi/mod.rs deleted file mode 100644 index 0b10aff988..0000000000 --- a/src/openhuman/web3/wallet/primitives/abi/mod.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! The sliver of Ethereum ABI encoding a wallet actually needs. -//! -//! Exactly one call is encoded here — ERC-20 `transfer(address,uint256)` — and -//! that is deliberate. A general ABI encoder is a parser for a type grammar; a -//! token transfer is a four-byte selector followed by two 32-byte words. Taking -//! a full Ethereum library for the second is how a wallet ends up carrying the -//! first, along with a bignum type and a signer stack. -//! -//! This lives outside the `tx` gate on purpose. Calldata is an *input* to -//! building a transaction, so a host that has moved building into a loadable -//! module still needs to produce it — and would otherwise have to pay a bus -//! round trip for keccak over 68 bytes, or link the chain library it just spent -//! the effort removing. - -use crate::openhuman::web3::wallet::primitives::eip712::u256_from_decimal; - -/// `keccak256("transfer(address,uint256)")[..4]`. -/// -/// Pinned, and re-derived from the signature in the tests below. Every ERC-20 -/// transfer on every EVM chain starts with these four bytes; getting them wrong -/// produces a call that either reverts or, on a contract with a colliding -/// selector, does something else entirely. -const TRANSFER_SELECTOR: [u8; 4] = [0xa9, 0x05, 0x9c, 0xbb]; - -/// The signature the selector is taken from, kept beside it for the test. -#[cfg(test)] -const TRANSFER_SIGNATURE: &[u8] = b"transfer(address,uint256)"; - -/// Why calldata could not be encoded. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[non_exhaustive] -pub enum Error { - /// The recipient is not a valid EVM address. - #[error("invalid recipient: {reason}")] - InvalidRecipient { - /// What was wrong with it. - reason: String, - }, - - /// The amount is not a base-10 integer, or overflows 256 bits. - #[error("invalid amount: {reason}")] - InvalidAmount { - /// What was wrong with it. - reason: String, - }, -} - -/// Result alias for this module. -pub type Result = std::result::Result; - -/// ABI-encode an ERC-20 `transfer(address,uint256)` call. -/// -/// `amount` is a base-10 string rather than an integer because token amounts -/// are denominated in the token's own 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. See [`u256_from_decimal`]. -/// -/// Returns `0x`-prefixed hex, which is what `eth_call` and a transaction's -/// `data` field both take. -/// -/// # Errors -/// -/// [`Error::InvalidRecipient`] or [`Error::InvalidAmount`]. -/// -/// # Examples -/// -/// ```ignore -/// # #[cfg(all(feature = "web3", feature = "web3", feature = "web3"))] { -/// use crate::openhuman::web3::wallet::primitives::abi; -/// -/// let data = abi::encode_erc20_transfer( -/// "0x1111111111111111111111111111111111111111", -/// "1000000", -/// )?; -/// assert!(data.starts_with("0xa9059cbb")); -/// // Selector plus two 32-byte words, hex-encoded, plus the `0x`. -/// assert_eq!(data.len(), 2 + 8 + 128); -/// # } -/// # Ok::<(), crate::openhuman::web3::wallet::primitives::abi::Error>(()) -/// ``` -pub fn encode_erc20_transfer(to: &str, amount: &str) -> Result { - let recipient = crate::openhuman::web3::wallet::primitives::address::evm::validate(to) - .map_err(|e| Error::InvalidRecipient { - reason: e.to_string(), - })?; - let bytes = decode_evm_address(&recipient)?; - let value = u256_from_decimal(amount).map_err(|e| Error::InvalidAmount { - reason: e.to_string(), - })?; - - let mut out = String::with_capacity(2 + 8 + 128); - out.push_str("0x"); - for byte in TRANSFER_SELECTOR { - push_hex(&mut out, byte); - } - // Both arguments are static types, so each is one 32-byte word in order — - // no head/tail offsets, which is the entire reason this can be 20 lines. - for byte in left_pad_address(bytes) { - push_hex(&mut out, byte); - } - for byte in value { - push_hex(&mut out, byte); - } - Ok(out) -} - -/// The 20 raw bytes of an already-validated `0x`-prefixed EVM address. -fn decode_evm_address(address: &str) -> Result<[u8; 20]> { - let body = address.strip_prefix("0x").unwrap_or(address); - let mut out = [0u8; 20]; - for (index, slot) in out.iter_mut().enumerate() { - let pair = body.get(index * 2..index * 2 + 2).ok_or_else(|| { - // Unreachable via `encode_erc20_transfer`, which validates first. - // Mapped rather than unwrapped so a future caller cannot turn a - // malformed address into a panic inside a wallet. - Error::InvalidRecipient { - reason: "address is shorter than 20 bytes".to_string(), - } - })?; - *slot = u8::from_str_radix(pair, 16).map_err(|_| Error::InvalidRecipient { - reason: "address is not hex".to_string(), - })?; - } - Ok(out) -} - -/// An address as the left-padded 32-byte word the ABI encodes it as. -fn left_pad_address(address: [u8; 20]) -> [u8; 32] { - let mut out = [0u8; 32]; - out[12..].copy_from_slice(&address); - out -} - -/// Append one byte as two lowercase hex digits. -fn push_hex(out: &mut String, byte: u8) { - use std::fmt::Write as _; - // Writing into a String cannot fail; discarded rather than unwrapped so - // this stays panic-free. - let _ = write!(out, "{byte:02x}"); -} - -/// Keccak-256, used only by the selector test. -/// -/// Scoped to tests because production code uses the pinned -/// [`TRANSFER_SELECTOR`] rather than hashing the signature on every call. -#[cfg(test)] -fn keccak(bytes: &[u8]) -> [u8; 32] { - use sha3::{Digest as _, Keccak256}; - Keccak256::digest(bytes).into() -} - -#[cfg(test)] -mod test; diff --git a/src/openhuman/web3/wallet/primitives/abi/test.rs b/src/openhuman/web3/wallet/primitives/abi/test.rs deleted file mode 100644 index da8f087f8c..0000000000 --- a/src/openhuman/web3/wallet/primitives/abi/test.rs +++ /dev/null @@ -1,142 +0,0 @@ -//! Tests for ERC-20 calldata encoding. -//! -//! Calldata is signed, then executed by a contract that will do exactly what -//! the bytes say. A wrong recipient word or a wrong amount word produces a -//! transaction that succeeds and moves the wrong money, so the encoding is -//! checked against the ABI specification's layout rather than against itself. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::{encode_erc20_transfer, keccak, Error, TRANSFER_SELECTOR, TRANSFER_SIGNATURE}; - -const RECIPIENT: &str = "0x1111111111111111111111111111111111111111"; - -#[test] -fn the_pinned_selector_matches_its_signature() { - // The constant is pinned so the hash is not recomputed per call; this is - // the test that makes pinning safe rather than a place for a typo to hide. - assert_eq!(keccak(TRANSFER_SIGNATURE)[..4], TRANSFER_SELECTOR); -} - -#[test] -fn the_selector_is_the_published_erc20_transfer_selector() { - assert_eq!(TRANSFER_SELECTOR, [0xa9, 0x05, 0x9c, 0xbb]); -} - -#[test] -fn the_encoding_is_a_selector_and_two_left_padded_words() { - let data = encode_erc20_transfer(RECIPIENT, "1000000").unwrap(); - - // 0x + 4-byte selector + 2 x 32-byte words, hex. - assert_eq!(data.len(), 2 + 8 + 128); - assert_eq!( - data, - "0xa9059cbb\ - 0000000000000000000000001111111111111111111111111111111111111111\ - 00000000000000000000000000000000000000000000000000000000000f4240" - ); -} - -#[test] -fn the_recipient_is_right_aligned_in_its_word() { - // Left-padding is the ABI rule for `address`. Getting it backwards yields - // a well-formed call paying an address nobody controls. - let data = encode_erc20_transfer(RECIPIENT, "1").unwrap(); - let recipient_word = &data[10..74]; - assert!(recipient_word.starts_with(&"0".repeat(24))); - assert!(recipient_word.ends_with(&"11".repeat(20))); -} - -#[test] -fn an_amount_beyond_u64_encodes_exactly() { - // The reason the amount is a string: an 18-decimal token puts ordinary - // balances past u64, and truncating would silently transfer the wrong sum. - let data = encode_erc20_transfer(RECIPIENT, "340282366920938463463374607431768211456").unwrap(); - assert!(data.ends_with("0000000000000000000000000000000100000000000000000000000000000000")); -} - -#[test] -fn the_largest_representable_amount_is_accepted() { - let max = "115792089237316195423570985008687907853269984665640564039457584007913129639935"; - let data = encode_erc20_transfer(RECIPIENT, max).unwrap(); - assert!(data.ends_with(&"f".repeat(64))); -} - -#[test] -fn a_zero_amount_encodes_as_a_zero_word_not_an_empty_one() { - // Static types are always a full word; an empty encoding would shift the - // call's shape and make it unparseable by the contract. - let data = encode_erc20_transfer(RECIPIENT, "0").unwrap(); - assert_eq!(data.len(), 2 + 8 + 128); - assert!(data.ends_with(&"0".repeat(64))); -} - -#[test] -fn a_checksummed_recipient_encodes_the_same_as_a_lowercase_one() { - // EIP-55 casing is display metadata, not part of the address. - let lower = encode_erc20_transfer("0xab5801a7d398351b8be11c439e05c5b3259aec9b", "5").unwrap(); - let checksummed = - encode_erc20_transfer("0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B", "5").unwrap(); - assert_eq!(lower, checksummed); -} - -#[test] -fn an_invalid_recipient_is_refused() { - for bad in [ - "", - "0x", - "not-an-address", - "0x111", - &format!("0x{}", "1".repeat(41)), - ] { - assert!( - matches!( - encode_erc20_transfer(bad, "1"), - Err(Error::InvalidRecipient { .. }) - ), - "{bad:?} should be refused" - ); - } -} - -#[test] -fn a_non_numeric_or_overflowing_amount_is_refused() { - for bad in [ - "", - "12a", - "-1", - "1.5", - "0x10", - // 2^256 exactly: one past the top. - "115792089237316195423570985008687907853269984665640564039457584007913129639936", - ] { - assert!( - matches!( - encode_erc20_transfer(RECIPIENT, bad), - Err(Error::InvalidAmount { .. }) - ), - "{bad:?} should be refused" - ); - } -} - -#[test] -fn the_address_decoder_refuses_malformed_input_rather_than_panicking() { - // `encode_erc20_transfer` validates before calling this, so these arms are - // defensive — but defensive code that is never exercised is code nobody - // knows works, and the failure mode it guards against is a panic inside a - // wallet. Tested directly because the public path cannot reach it. - use super::decode_evm_address; - - assert!(matches!( - decode_evm_address("0x1111"), - Err(Error::InvalidRecipient { .. }) - )); - assert!(matches!( - decode_evm_address(&format!("0x{}", "zz".repeat(20))), - Err(Error::InvalidRecipient { .. }) - )); - - // The happy path, unprefixed, to pin that the `0x` is optional here. - assert_eq!(decode_evm_address(&"11".repeat(20)).unwrap(), [0x11u8; 20]); -} diff --git a/src/openhuman/web3/wallet/primitives/address/btc.rs b/src/openhuman/web3/wallet/primitives/address/btc.rs deleted file mode 100644 index a504d1f4f0..0000000000 --- a/src/openhuman/web3/wallet/primitives/address/btc.rs +++ /dev/null @@ -1,292 +0,0 @@ -//! Bitcoin address validation. -//! -//! Two functions, because Bitcoin has two different answers depending on which -//! side of a transaction the address sits on: -//! -//! - [`validate`] — any well-formed mainnet address. Correct for a -//! **recipient**: we do not care which address type they prefer, because -//! paying to a P2WPKH, P2TR, P2SH or P2PKH output is the same operation. -//! - [`validate_sender`] — additionally requires **P2WPKH** (`bc1q…` native -//! segwit). Correct for a **sender**, because that is the only script type -//! this crate's family of signing paths knows how to spend. -//! -//! Calling [`validate`] where [`validate_sender`] belongs is the dangerous -//! direction: it accepts an address that will fail much later, at signing -//! time, after a transaction has been assembled. The two are separate -//! functions rather than a boolean flag so that mistake reads wrong at the -//! call site. -//! -//! # Why this does not use the `bitcoin` crate -//! -//! It used to. The crate is excellent and this module is a strictly smaller -//! thing than what it offers — but it carries `secp256k1`, and therefore a -//! native C build, into every consumer that only ever wanted to check whether a -//! string is a well-formed address. That cost is invisible in a full wallet and -//! dominant in a host that has moved signing elsewhere. -//! -//! Address *parsing* is a safe thing to own directly, unlike the BIP-32 walk in -//! [`crate::openhuman::web3::wallet::primitives::key`], which deliberately still delegates. The distinction is -//! failure mode, not difficulty: a parser that is wrong rejects a good address -//! or accepts a malformed one, and both are caught immediately by the vectors -//! below. A derivation that is wrong returns a *valid key for the wrong -//! account* — silently, and unrecoverably. So this module is hand-rolled -//! against the published BIP-173 and BIP-350 vectors, and key derivation is -//! not. -//! -//! The five mainnet forms, in full: -//! -//! | Type | Encoding | Prefix / witness version | Program length | -//! | --- | --- | --- | --- | -//! | P2PKH | base58check | version byte `0x00` | 20 | -//! | P2SH | base58check | version byte `0x05` | 20 | -//! | P2WPKH | bech32 | `bc`, v0 | 20 | -//! | P2WSH | bech32 | `bc`, v0 | 32 | -//! | P2TR | bech32m | `bc`, v1 | 32 | -//! -//! Witness versions 2..=16 are accepted as recipients with a 2..=40 byte -//! program, per BIP-350. Refusing them would make this crate reject addresses -//! that are valid today and spendable by their owners, purely because a future -//! output type had not been invented when it was written. - -use crate::openhuman::web3::wallet::primitives::chain::Chain; -use crate::openhuman::web3::wallet::primitives::{Error, Result}; - -/// Human-readable part of a Bitcoin **mainnet** bech32 address. -const MAINNET_HRP: &str = "bc"; - -/// Base58check version byte for P2PKH. -const P2PKH_VERSION: u8 = 0x00; - -/// Base58check version byte for P2SH. -const P2SH_VERSION: u8 = 0x05; - -/// Base58check version bytes belonging to Bitcoin test networks. -const TEST_VERSIONS: [u8; 2] = [0x6f, 0xc4]; - -/// What a well-formed mainnet address turned out to be. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Kind { - /// Pay to public key hash — legacy, base58. - P2pkh, - /// Pay to script hash — base58. - P2sh, - /// Pay to witness public key hash — the only spendable-from type here. - P2wpkh, - /// Pay to witness script hash. - P2wsh, - /// A segwit output that is none of the above: taproot, or a future version. - OtherWitness, -} - -/// Validate a Bitcoin **mainnet** address of any type, returning it trimmed. -/// -/// Use this for transaction recipients. -/// -/// # Errors -/// -/// - [`Error::EmptyAddress`] if `address` is empty or all whitespace. -/// - [`Error::InvalidAddress`] if it does not parse as a Bitcoin address. -/// - [`Error::WrongNetwork`] if it parses but belongs to testnet, signet, or -/// regtest. -/// -/// # Examples -/// -/// ```ignore -/// use crate::openhuman::web3::wallet::primitives::address::btc; -/// -/// // Native segwit, wrapped segwit, legacy, and taproot are all accepted. -/// assert!(btc::validate("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4").is_ok()); -/// assert!(btc::validate("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2").is_ok()); -/// -/// // A testnet address is well-formed but on the wrong network. -/// assert!(btc::validate("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx").is_err()); -/// ``` -pub fn validate(address: &str) -> Result { - let trimmed = trimmed_non_empty(address)?; - parse(trimmed)?; - Ok(trimmed.to_string()) -} - -/// Validate a Bitcoin address usable as a **sender**, returning it trimmed. -/// -/// Everything [`validate`] requires, plus the address must be P2WPKH — native -/// segwit, the `bc1q…` form. Signing is only implemented for that script type, -/// so any other type would fail later with a much less obvious error. -/// -/// # Errors -/// -/// - Everything [`validate`] returns. -/// - [`Error::UnsupportedAddressType`] if the address is well-formed mainnet -/// but not P2WPKH. -/// -/// # Examples -/// -/// ```ignore -/// use crate::openhuman::web3::wallet::primitives::address::btc; -/// -/// // Native segwit: usable as a sender. -/// assert!(btc::validate_sender("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4").is_ok()); -/// -/// // A legacy address is a fine recipient but cannot be signed for here. -/// assert!(btc::validate("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2").is_ok()); -/// assert!(btc::validate_sender("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2").is_err()); -/// ``` -pub fn validate_sender(address: &str) -> Result { - let trimmed = trimmed_non_empty(address)?; - // Deliberately ordered: a malformed or wrong-network address is reported as - // such, never as an unsupported *type*, which would point at the wrong fix. - if parse(trimmed)? != Kind::P2wpkh { - return Err(Error::UnsupportedAddressType { - chain: Chain::Btc, - address: trimmed.to_string(), - reason: "only P2WPKH (bc1q… native segwit) can be signed for".to_string(), - }); - } - Ok(trimmed.to_string()) -} - -/// Encode a 20-byte public key hash as a mainnet P2WPKH (`bc1q…`) address. -/// -/// The counterpart to parsing: [`crate::openhuman::web3::wallet::primitives::key`] derives a public key and needs -/// its address, and doing that here keeps the bech32 encoding in the module -/// that also decodes it. -/// -/// # Errors -/// -/// [`Error::InvalidAddress`] only if bech32 encoding fails, which for a -/// fixed-length v0 program and a constant HRP it cannot. -pub(crate) fn encode_p2wpkh(pubkey_hash: &[u8; 20]) -> Result { - // `hrp::BC` rather than parsing `MAINNET_HRP`: the parse could not fail for - // a two-letter constant, and an error arm that cannot fire is one nothing - // can test. - bech32::segwit::encode_v0(bech32::hrp::BC, pubkey_hash).map_err(|e| Error::InvalidAddress { - chain: Chain::Btc, - address: String::new(), - reason: e.to_string(), - }) -} - -/// Trim `address` and reject it if nothing is left. -fn trimmed_non_empty(address: &str) -> Result<&str> { - let trimmed = address.trim(); - if trimmed.is_empty() { - return Err(Error::EmptyAddress { chain: Chain::Btc }); - } - Ok(trimmed) -} - -/// Identify a mainnet address, or say why it is not one. -/// -/// Dispatch is on *shape*, not on a list of known prefixes. A bech32 string is -/// an all-letter human-readable part, a `1` separator, then a data part drawn -/// from an alphabet that excludes `1` — so the last `1` is the separator, and -/// what precedes it is the HRP. -/// -/// Routing every bech32-shaped string to [`parse_bech32`], rather than only -/// those starting `bc1`, is what lets a testnet or foreign-chain address be -/// reported as the wrong network instead of as malformed base58. Matching on a -/// hardcoded prefix list left that check unreachable and gave a Litecoin -/// address a base58 error message. -fn parse(address: &str) -> Result { - let lower = address.to_ascii_lowercase(); - if let Some(separator) = lower.rfind('1') { - let hrp = &lower[..separator]; - if !hrp.is_empty() && hrp.chars().all(|c| c.is_ascii_lowercase()) { - return parse_bech32(address); - } - } - parse_base58(address) -} - -/// Decode a bech32 or bech32m segwit address. -/// -/// One call does the whole job: `bech32::segwit::decode` rejects a witness -/// version above 16, selects the checksum algorithm the version requires -/// (bech32 for v0, bech32m for v1+, per BIP-350), rejects mixed case, and -/// enforces the program-length rules — 20 or 32 bytes at v0, 2..=40 above it. -/// Re-checking any of that here would be a second, drifting implementation of -/// rules the crate already owns. -fn parse_bech32(address: &str) -> Result { - let (hrp, version, program) = - bech32::segwit::decode(address).map_err(|e| Error::InvalidAddress { - chain: Chain::Btc, - address: address.to_string(), - reason: e.to_string(), - })?; - - if hrp.as_str() != MAINNET_HRP { - return Err(wrong_network(address, "a non-mainnet human-readable part")); - } - - // Only v0 needs discriminating, because only P2WPKH is spendable here. - // Taproot and every future version are payable recipients and nothing more, - // so they share one arm rather than each earning a variant that no caller - // would branch on. - if version.to_u8() != 0 { - return Ok(Kind::OtherWitness); - } - match program.len() { - 20 => Ok(Kind::P2wpkh), - // Guaranteed 32 by the length validation above; spelled out rather than - // wildcarded so a future relaxation upstream cannot silently land here - // as "P2WSH". - 32 => Ok(Kind::P2wsh), - other => Err(Error::InvalidAddress { - chain: Chain::Btc, - address: address.to_string(), - reason: format!("witness v0 program must be 20 or 32 bytes, got {other}"), - }), - } -} - -/// Decode a base58check P2PKH or P2SH address. -fn parse_base58(address: &str) -> Result { - let decoded = bs58::decode(address) - .with_check(None) - .into_vec() - .map_err(|e| Error::InvalidAddress { - chain: Chain::Btc, - address: address.to_string(), - reason: e.to_string(), - })?; - - // base58check strips the 4-byte checksum, leaving version || payload. - let (version, payload) = decoded.split_first().ok_or_else(|| Error::InvalidAddress { - chain: Chain::Btc, - address: address.to_string(), - reason: "empty base58check payload".to_string(), - })?; - - if TEST_VERSIONS.contains(version) { - return Err(wrong_network(address, "a test network version byte")); - } - if payload.len() != 20 { - return Err(Error::InvalidAddress { - chain: Chain::Btc, - address: address.to_string(), - reason: format!("hash must be 20 bytes, got {}", payload.len()), - }); - } - match *version { - P2PKH_VERSION => Ok(Kind::P2pkh), - P2SH_VERSION => Ok(Kind::P2sh), - other => Err(Error::InvalidAddress { - chain: Chain::Btc, - address: address.to_string(), - reason: format!("unknown base58check version byte {other:#04x}"), - }), - } -} - -/// A well-formed address that belongs to another network. -fn wrong_network(address: &str, reason: &str) -> Error { - Error::WrongNetwork { - chain: Chain::Btc, - address: address.to_string(), - expected: "mainnet".to_string(), - reason: reason.to_string(), - } -} - -#[cfg(test)] -mod test; diff --git a/src/openhuman/web3/wallet/primitives/address/btc/test.rs b/src/openhuman/web3/wallet/primitives/address/btc/test.rs deleted file mode 100644 index 2170d113c6..0000000000 --- a/src/openhuman/web3/wallet/primitives/address/btc/test.rs +++ /dev/null @@ -1,288 +0,0 @@ -//! Unit tests for Bitcoin address validation. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::{validate, validate_sender}; -use crate::openhuman::web3::wallet::primitives::{Chain, Error}; - -/// P2WPKH — native segwit. The only type valid as a sender. -const P2WPKH: &str = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; -/// P2PKH — legacy. A fine recipient. -const P2PKH: &str = "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2"; -/// P2SH — wrapped segwit or multisig. A fine recipient. -const P2SH: &str = "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy"; -/// P2WSH — native segwit script hash. A fine recipient. -const P2WSH: &str = "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3"; - -#[test] -fn accepts_every_mainnet_address_type_as_a_recipient() { - for addr in [P2WPKH, P2PKH, P2SH, P2WSH] { - assert!(validate(addr).is_ok(), "{addr} should validate"); - } -} - -#[test] -fn trims_surrounding_whitespace() { - assert_eq!(validate(&format!(" {P2WPKH}\n")).unwrap(), P2WPKH); -} - -#[test] -fn rejects_an_empty_address() { - assert_eq!( - validate(" ").unwrap_err(), - Error::EmptyAddress { chain: Chain::Btc } - ); -} - -#[test] -fn rejects_a_malformed_address() { - assert!(matches!( - validate("not-an-address").unwrap_err(), - Error::InvalidAddress { .. } - )); -} - -#[test] -fn rejects_a_mistyped_address_via_its_checksum() { - // Bitcoin addresses are checksummed, so a single changed character is - // caught rather than naming a different account. - let mut chars: Vec = P2PKH.chars().collect(); - chars[5] = if chars[5] == 'a' { 'b' } else { 'a' }; - let typo: String = chars.into_iter().collect(); - assert_ne!(typo, P2PKH, "the fixture must actually differ"); - assert!( - validate(&typo).is_err(), - "a checksum failure must be caught" - ); -} - -#[test] -fn rejects_a_testnet_address_as_the_wrong_network() { - // Well-formed, but on the wrong network — a distinct variant because it is - // the failure a caller is likely to handle rather than merely report. - let testnet = "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"; - match validate(testnet).unwrap_err() { - Error::WrongNetwork { - chain, - address, - expected, - .. - } => { - assert_eq!(chain, Chain::Btc); - assert_eq!(address, testnet); - assert_eq!(expected, "mainnet"); - } - other => panic!("expected WrongNetwork, got {other:?}"), - } -} - -#[test] -fn accepts_p2wpkh_as_a_sender() { - assert_eq!(validate_sender(P2WPKH).unwrap(), P2WPKH); -} - -#[test] -fn rejects_every_non_p2wpkh_type_as_a_sender() { - // These are all valid recipients. The sender rule is strictly narrower - // because signing is only implemented for P2WPKH. - for addr in [P2PKH, P2SH, P2WSH] { - assert!( - validate(addr).is_ok(), - "{addr} must remain a valid recipient" - ); - match validate_sender(addr).unwrap_err() { - Error::UnsupportedAddressType { chain, address, .. } => { - assert_eq!(chain, Chain::Btc); - assert_eq!(address, addr); - } - other => panic!("expected UnsupportedAddressType for {addr}, got {other:?}"), - } - } -} - -#[test] -fn sender_validation_still_reports_the_underlying_failure_first() { - // A malformed or wrong-network address should not be reported as an - // unsupported *type* — that would point at the wrong fix. - assert!(matches!( - validate_sender("garbage").unwrap_err(), - Error::InvalidAddress { .. } - )); - assert!(matches!( - validate_sender("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx").unwrap_err(), - Error::WrongNetwork { .. } - )); - assert!(matches!( - validate_sender(" ").unwrap_err(), - Error::EmptyAddress { .. } - )); -} - -// --------------------------------------------------------------------------- -// Branch coverage for the hand-rolled parser. -// -// These are the paths that only exist because this module stopped delegating -// to the `bitcoin` crate. Each one is a rejection, and a rejection that never -// fires is indistinguishable from one that is wrong — so every arm gets a -// vector, drawn from BIP-173 and BIP-350 where they publish one. -// --------------------------------------------------------------------------- - -/// P2TR — taproot, witness v1, bech32m. A valid recipient, not a sender. -const P2TR: &str = "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0"; - -#[test] -fn accepts_taproot_as_a_recipient_but_not_as_a_sender() { - // Witness v1 uses bech32m rather than bech32; accepting it proves the - // checksum variant is selected by version rather than assumed. - assert_eq!(validate(P2TR).unwrap(), P2TR); - assert!(matches!( - validate_sender(P2TR).unwrap_err(), - Error::UnsupportedAddressType { .. } - )); -} - -#[test] -fn rejects_a_v0_address_carrying_a_bech32m_checksum() { - // BIP-350's central rule. Both strings below are well-formed bech32-ish; - // what separates them is which checksum constant they were built with, and - // accepting the wrong one would accept addresses no other wallet does. - let v0_with_bech32m = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kemeawh"; - assert!(validate(v0_with_bech32m).is_err()); -} - -#[test] -fn rejects_a_taproot_address_carrying_a_bech32_checksum() { - // The mirror of the case above: v1 must be bech32m. - let v1_with_bech32 = "bc1p38j9r5y49hruaue7wxjce0updqjuyyx0kh56v8s25huc6995vvpql3jow4"; - assert!(validate(v1_with_bech32).is_err()); -} - -#[test] -fn rejects_a_witness_program_with_the_wrong_checksum_for_version_three() { - // BIP-350: witness versions 1..=16 require bech32m, not bech32. - let v0_16_bytes = "bc1rw5uspcuh"; - assert!(validate(v0_16_bytes).is_err()); -} - -#[test] -fn rejects_a_mixed_case_bech32_address() { - // Mixed case is invalid per BIP-173 because it breaks the checksum's - // case-folding guarantee. - let mixed = "bc1QW508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; - assert!(validate(mixed).is_err()); -} - -#[test] -fn reports_a_testnet_base58_address_as_the_wrong_network_not_as_malformed() { - // A testnet P2PKH is perfectly well-formed; naming it correctly is the - // difference between a user fixing their address and thinking it is broken. - let testnet_p2pkh = "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn"; - assert!(matches!( - validate(testnet_p2pkh).unwrap_err(), - Error::WrongNetwork { .. } - )); -} - -#[test] -fn reports_a_regtest_bech32_address_as_the_wrong_network() { - let regtest = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080"; - assert!(matches!( - validate(regtest).unwrap_err(), - Error::WrongNetwork { .. } - )); -} - -#[test] -fn rejects_a_base58_address_with_an_unknown_version_byte() { - // Valid base58check, valid length, but a version byte that is neither - // P2PKH nor P2SH on mainnet — a namecoin address, for instance. - let unknown_version = "NCXn6ZQTr8GN5T4bB1oSHnLRcNPQXswcpv"; - match validate(unknown_version) { - Err(Error::InvalidAddress { .. } | Error::WrongNetwork { .. }) => {} - other => panic!("expected a rejection, got {other:?}"), - } -} - -#[test] -fn rejects_a_bech32_address_for_another_coin() { - // Well-formed bech32 with a human-readable part that is not Bitcoin's. - let not_bitcoin = "ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kgmn4n9"; - assert!(validate(not_bitcoin).is_err()); -} - -#[test] -fn encodes_a_p2wpkh_address_its_own_validator_accepts() { - // Closes the loop: what `key::btc` produces must parse back here, and the - // encoder is the only part of this module the validators do not exercise. - let pubkey_hash = [0x75u8; 20]; - let encoded = super::encode_p2wpkh(&pubkey_hash).unwrap(); - assert!(encoded.starts_with("bc1q")); - assert_eq!(validate_sender(&encoded).unwrap(), encoded); -} - -/// Encode `version || payload` as base58check, the way a real address is built. -/// -/// Constructed rather than copied from a block explorer because these vectors -/// have to be *valid* base58check that is wrong in one specific way — a -/// hand-typed string would fail its checksum first and never reach the rule -/// under test. -fn base58check(version: u8, payload: &[u8]) -> String { - let mut body = Vec::with_capacity(1 + payload.len()); - body.push(version); - body.extend_from_slice(payload); - bs58::encode(body).with_check().into_string() -} - -#[test] -fn rejects_a_base58_address_with_an_unrecognised_version_byte() { - // Valid checksum, 20-byte hash, but a version that is neither P2PKH (0x00) - // nor P2SH (0x05) on mainnet — a Litecoin P2PKH, for instance. - let litecoin = base58check(0x30, &[0x11; 20]); - match validate(&litecoin).unwrap_err() { - Error::InvalidAddress { reason, .. } => assert!(reason.contains("version"), "{reason}"), - other => panic!("expected InvalidAddress, got {other:?}"), - } -} - -#[test] -fn rejects_a_base58_address_whose_hash_is_the_wrong_length() { - // A well-formed base58check envelope around a 19-byte hash. Accepting it - // would build a transaction paying a script nobody can spend. - let short = base58check(0x00, &[0x11; 19]); - match validate(&short).unwrap_err() { - Error::InvalidAddress { reason, .. } => assert!(reason.contains("20 bytes"), "{reason}"), - other => panic!("expected InvalidAddress, got {other:?}"), - } -} - -#[test] -fn rejects_an_empty_base58check_payload() { - // Checksum over nothing at all: there is no version byte to read. - let empty = bs58::encode(Vec::::new()).with_check().into_string(); - assert!(validate(&empty).is_err()); -} - -#[test] -fn reports_a_testnet_p2sh_version_as_the_wrong_network() { - // 0xc4 is testnet P2SH. The sibling 0x6f (testnet P2PKH) is covered above - // by a real address; this one completes the pair. - let testnet_p2sh = base58check(0xc4, &[0x11; 20]); - assert!(matches!( - validate(&testnet_p2sh).unwrap_err(), - Error::WrongNetwork { .. } - )); -} - -#[test] -fn reports_a_foreign_bech32_chain_as_the_wrong_network_not_as_bad_base58() { - // The check this exercises was unreachable when dispatch matched on a - // hardcoded `bc1` prefix: a Litecoin bech32 address fell through to the - // base58 parser and came back with a nonsensical error. - let litecoin = "ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kgmn4n9"; - match validate(litecoin).unwrap_err() { - Error::WrongNetwork { reason, .. } => { - assert!(reason.contains("human-readable part"), "{reason}"); - } - other => panic!("expected WrongNetwork, got {other:?}"), - } -} diff --git a/src/openhuman/web3/wallet/primitives/address/evm.rs b/src/openhuman/web3/wallet/primitives/address/evm.rs deleted file mode 100644 index a6de753129..0000000000 --- a/src/openhuman/web3/wallet/primitives/address/evm.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! EVM address validation. -//! -//! An EVM address is 20 bytes rendered as 40 hex digits, conventionally with a -//! `0x` prefix. That is the entire format, which is why this module has no -//! dependencies: pulling a chain client in to call its `Address::from_str` -//! would drag a large secp256k1 and RLP stack in to check a string is hex. -//! -//! ## EIP-55 is checked separately, and that is deliberate -//! -//! [`validate`] accepts any correctly-shaped address, mixed case included, -//! without verifying an EIP-55 checksum. Rejecting a non-checksummed address -//! would break every lowercase address in the wild — they are valid, just -//! unchecksummed, and most tooling emits them. -//! -//! [`is_checksum_valid`] is offered separately for a caller that *has* a -//! mixed-case address and wants the typo protection EIP-55 provides. Keeping -//! the two apart means a host chooses its own strictness instead of inheriting -//! ours. - -use crate::openhuman::web3::wallet::primitives::chain::Chain; -use crate::openhuman::web3::wallet::primitives::{Error, Result}; - -/// Number of hex digits in an EVM address: 20 bytes, two digits each. -const ADDRESS_HEX_LEN: usize = 40; - -/// Validate an EVM address and return it trimmed. -/// -/// Accepts an address with or without the lowercase `0x` prefix. The hex body -/// may be any case; an uppercase `0X` prefix is **rejected**, since no tooling -/// emits it and accepting it would widen what counts as an address for no -/// benefit. The returned string is the input with surrounding whitespace -/// removed and is -/// otherwise **unmodified** — case and prefix are preserved, because callers -/// echo the address back to users and normalising it would silently change -/// what they typed. Use [`to_checksummed`] when a canonical form is wanted. -/// -/// # Errors -/// -/// - [`Error::EmptyAddress`] if `address` is empty or all whitespace. -/// - [`Error::InvalidAddress`] if it is not 40 hex digits after an optional -/// `0x` prefix. -/// -/// # Examples -/// -/// ```ignore -/// use crate::openhuman::web3::wallet::primitives::address::evm; -/// -/// let addr = evm::validate(" 0x52908400098527886E0F7030069857D2E4169EE7 ")?; -/// assert_eq!(addr, "0x52908400098527886E0F7030069857D2E4169EE7"); -/// -/// // The `0x` prefix is optional. -/// assert!(evm::validate("52908400098527886E0F7030069857D2E4169EE7").is_ok()); -/// -/// // But an uppercase `0X` prefix is not accepted. -/// assert!(evm::validate("0X52908400098527886E0F7030069857D2E4169EE7").is_err()); -/// # Ok::<(), crate::openhuman::web3::wallet::primitives::Error>(()) -/// ``` -pub fn validate(address: &str) -> Result { - let trimmed = address.trim(); - if trimmed.is_empty() { - return Err(Error::EmptyAddress { chain: Chain::Evm }); - } - - let body = strip_prefix(trimmed); - if body.len() != ADDRESS_HEX_LEN { - return Err(Error::InvalidAddress { - chain: Chain::Evm, - address: trimmed.to_string(), - reason: format!("expected {ADDRESS_HEX_LEN} hex digits, got {}", body.len()), - }); - } - if let Some(bad) = body.chars().find(|c| !c.is_ascii_hexdigit()) { - return Err(Error::InvalidAddress { - chain: Chain::Evm, - address: trimmed.to_string(), - reason: format!("contains a non-hex character '{bad}'"), - }); - } - Ok(trimmed.to_string()) -} - -/// Strip an optional lowercase `0x` prefix. -/// -/// Deliberately does not accept `0X`: see [`validate`]. -fn strip_prefix(address: &str) -> &str { - address.strip_prefix("0x").unwrap_or(address) -} - -/// Whether `address` carries a valid EIP-55 checksum. -/// -/// This compares the input against the canonical mixed-case rendering -/// [`to_checksummed`] produces: it returns `true` only when the two are -/// byte-for-byte identical. That is what makes a typo detectable — a wrong -/// character almost always breaks the agreement. -/// -/// Concretely, an all-uppercase address never matches, and an all-lowercase -/// one usually does not either, because the canonical form is normally -/// mixed-case. The `usually` matters: an address whose canonical form is -/// itself entirely lowercase (as with -/// `0xde709f2102306220921060314715629080e2fb77`) matches as-is. So this -/// answers "does the address carry a correct checksum", not "is it a valid -/// address" — pair it with [`validate`], which is the function that answers -/// the latter. -/// -/// # Errors -/// -/// Propagates [`validate`]'s errors: the address must be well-formed before a -/// checksum question is meaningful. -/// -/// # Examples -/// -/// ```ignore -/// use crate::openhuman::web3::wallet::primitives::address::evm; -/// -/// // A correctly checksummed address. -/// assert!(evm::is_checksum_valid("0x52908400098527886E0F7030069857D2E4169EE7")?); -/// // Valid, but carries no checksum information. -/// assert!(!evm::is_checksum_valid("0x52908400098527886e0f7030069857d2e4169ee7")?); -/// # Ok::<(), crate::openhuman::web3::wallet::primitives::Error>(()) -/// ``` -#[cfg(feature = "web3")] -pub fn is_checksum_valid(address: &str) -> Result { - let validated = validate(address)?; - Ok(to_checksummed(&validated)? == prefixed(strip_prefix(&validated))) -} - -/// Render `address` in canonical EIP-55 mixed-case form, `0x`-prefixed. -/// -/// # Errors -/// -/// Propagates [`validate`]'s errors. -/// -/// # Examples -/// -/// ```ignore -/// use crate::openhuman::web3::wallet::primitives::address::evm; -/// -/// let canonical = evm::to_checksummed("0x52908400098527886e0f7030069857d2e4169ee7")?; -/// assert_eq!(canonical, "0x52908400098527886E0F7030069857D2E4169EE7"); -/// # Ok::<(), crate::openhuman::web3::wallet::primitives::Error>(()) -/// ``` -#[cfg(feature = "web3")] -pub fn to_checksummed(address: &str) -> Result { - use sha3::{Digest, Keccak256}; - - let validated = validate(address)?; - let lower = strip_prefix(&validated).to_ascii_lowercase(); - let hash = Keccak256::digest(lower.as_bytes()); - - let mut out = String::with_capacity(2 + ADDRESS_HEX_LEN); - out.push_str("0x"); - for (i, c) in lower.chars().enumerate() { - // EIP-55: uppercase digit `i` when nibble `i` of the hash is >= 8. - // Nibbles are big-endian within each byte, so even indices take the - // high nibble. - let nibble = if i % 2 == 0 { - hash[i / 2] >> 4 - } else { - hash[i / 2] & 0x0f - }; - if c.is_ascii_digit() || nibble < 8 { - out.push(c); - } else { - out.push(c.to_ascii_uppercase()); - } - } - Ok(out) -} - -/// Re-attach the `0x` prefix to a bare hex body. -#[cfg(feature = "web3")] -fn prefixed(body: &str) -> String { - format!("0x{body}") -} - -#[cfg(test)] -mod test; diff --git a/src/openhuman/web3/wallet/primitives/address/evm/test.rs b/src/openhuman/web3/wallet/primitives/address/evm/test.rs deleted file mode 100644 index b6684a035e..0000000000 --- a/src/openhuman/web3/wallet/primitives/address/evm/test.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! Unit tests for EVM address validation. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::validate; -use crate::openhuman::web3::wallet::primitives::{Chain, Error}; - -/// The four EIP-55 test vectors from the specification. -const EIP55_VECTORS: [&str; 4] = [ - "0x52908400098527886E0F7030069857D2E4169EE7", - "0x8617E340B3D01FA5F11F306F4090FD50E238070D", - "0xde709f2102306220921060314715629080e2fb77", - "0x27b1fdb04752bbc536007a920d24acb045561c26", -]; - -#[test] -fn accepts_a_prefixed_address() { - assert_eq!( - validate("0x52908400098527886E0F7030069857D2E4169EE7").unwrap(), - "0x52908400098527886E0F7030069857D2E4169EE7" - ); -} - -#[test] -fn accepts_an_unprefixed_address() { - let bare = "52908400098527886E0F7030069857D2E4169EE7"; - assert_eq!(validate(bare).unwrap(), bare); -} - -#[test] -fn rejects_an_uppercase_prefix() { - // No tooling emits `0X`, so accepting it would widen what counts as an - // address for no benefit. It falls out as a length failure: `0X…` is 42 - // characters once the prefix is not stripped. - assert!(matches!( - validate("0X52908400098527886E0F7030069857D2E4169EE7").unwrap_err(), - Error::InvalidAddress { .. } - )); -} - -#[test] -fn preserves_the_input_rather_than_normalising_it() { - // Callers echo the returned address back to users, so changing its case or - // stripping its prefix would silently alter what they typed. - let lower = "0x52908400098527886e0f7030069857d2e4169ee7"; - assert_eq!(validate(lower).unwrap(), lower); -} - -#[test] -fn trims_surrounding_whitespace() { - assert_eq!( - validate(" 0x52908400098527886E0F7030069857D2E4169EE7\n").unwrap(), - "0x52908400098527886E0F7030069857D2E4169EE7" - ); -} - -#[test] -fn rejects_an_empty_address() { - assert_eq!( - validate(" ").unwrap_err(), - Error::EmptyAddress { chain: Chain::Evm } - ); -} - -#[test] -fn rejects_a_short_address() { - match validate("0xdeadbeef").unwrap_err() { - Error::InvalidAddress { chain, reason, .. } => { - assert_eq!(chain, Chain::Evm); - assert!(reason.contains("got 8"), "reason was {reason:?}"); - } - other => panic!("expected InvalidAddress, got {other:?}"), - } -} - -#[test] -fn rejects_a_long_address() { - let long = format!("0x{}", "a".repeat(41)); - assert!(matches!( - validate(&long).unwrap_err(), - Error::InvalidAddress { .. } - )); -} - -#[test] -fn rejects_a_non_hex_character() { - // 40 characters, but `z` is not hex — a length check alone would pass it. - let bad = format!("0x{}z", "a".repeat(39)); - match validate(&bad).unwrap_err() { - Error::InvalidAddress { reason, .. } => { - assert!( - reason.contains('z'), - "reason should name the char: {reason:?}" - ); - } - other => panic!("expected InvalidAddress, got {other:?}"), - } -} - -#[test] -fn the_error_carries_the_rejected_address_verbatim() { - // Diagnosing a rejection means seeing exactly what was rejected. - match validate("0xnope").unwrap_err() { - Error::InvalidAddress { address, .. } => assert_eq!(address, "0xnope"), - other => panic!("expected InvalidAddress, got {other:?}"), - } -} - -#[test] -fn accepts_every_eip55_vector_regardless_of_case() { - // Validation is case-insensitive: an unchecksummed lowercase address is - // valid, just unchecksummed. - for vector in EIP55_VECTORS { - assert!(validate(vector).is_ok(), "{vector} should validate"); - assert!(validate(&vector.to_lowercase()).is_ok()); - } -} - -#[cfg(feature = "web3")] -mod checksum { - use super::EIP55_VECTORS; - use crate::openhuman::web3::wallet::primitives::address::evm::{ - is_checksum_valid, to_checksummed, - }; - - #[test] - fn canonicalises_every_eip55_vector() { - for vector in EIP55_VECTORS { - assert_eq!( - to_checksummed(&vector.to_lowercase()).unwrap(), - *vector, - "EIP-55 vector {vector} did not round-trip" - ); - } - } - - #[test] - fn accepts_a_correctly_checksummed_address() { - for vector in EIP55_VECTORS { - assert!(is_checksum_valid(vector).unwrap(), "{vector}"); - } - } - - #[test] - fn rejects_a_wrongly_cased_address() { - // Flip the case of one letter in a checksummed vector. - let vector = "0x52908400098527886E0F7030069857D2E4169EE7"; - let broken = vector.replacen('E', "e", 1); - assert_ne!(broken, vector, "the fixture must actually differ"); - assert!(!is_checksum_valid(&broken).unwrap()); - } - - #[test] - fn reports_an_all_lowercase_address_as_unchecksummed() { - // Not a failure of validity — it simply carries no checksum data. - let lower = "0x52908400098527886e0f7030069857d2e4169ee7"; - assert!(crate::openhuman::web3::wallet::primitives::address::evm::validate(lower).is_ok()); - assert!(!is_checksum_valid(lower).unwrap()); - } - - #[test] - fn checksumming_is_idempotent() { - for vector in EIP55_VECTORS { - let once = to_checksummed(vector).unwrap(); - assert_eq!(to_checksummed(&once).unwrap(), once); - } - } - - #[test] - fn checksumming_accepts_an_unprefixed_address_and_adds_the_prefix() { - let bare = "52908400098527886e0f7030069857d2e4169ee7"; - assert_eq!( - to_checksummed(bare).unwrap(), - "0x52908400098527886E0F7030069857D2E4169EE7" - ); - } - - #[test] - fn checksum_helpers_reject_a_malformed_address() { - assert!(to_checksummed("0xdeadbeef").is_err()); - assert!(is_checksum_valid("0xdeadbeef").is_err()); - } -} diff --git a/src/openhuman/web3/wallet/primitives/address/mod.rs b/src/openhuman/web3/wallet/primitives/address/mod.rs deleted file mode 100644 index fd0eadcc04..0000000000 --- a/src/openhuman/web3/wallet/primitives/address/mod.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! Per-chain address validation. -//! -//! Each submodule owns one chain's address format and exposes the same core -//! shape: a `validate` returning the trimmed address, plus whatever -//! chain-specific conversions are genuinely useful (`solana::decode`, -//! `tron::to_hex`, `btc::validate_sender`). -//! -//! [`validate`] dispatches across all of them for chain-generic callers. -//! -//! ## What validation does and does not prove -//! -//! Every function here answers one question: *is this string a well-formed -//! address on this chain*. None of them touch the network, so none can tell -//! you an account exists, is funded, or is controlled by anyone in particular. -//! -//! How much a successful validation is worth also varies sharply by chain, and -//! it is worth being explicit about because it is easy to assume otherwise: -//! -//! | Chain | Checksum | A single typo is… | -//! | --- | --- | --- | -//! | Bitcoin | yes (base58check / bech32) | caught | -//! | Tron | yes (base58check) | caught | -//! | EVM | optional (EIP-55, only if mixed-case) | usually *not* caught | -//! | Solana | none | *not reliably* caught | -//! -//! For EVM, `evm::is_checksum_valid` recovers the typo protection when the -//! caller has a mixed-case address. For Solana there is nothing to recover: -//! confirm the address out of band. - -use crate::openhuman::web3::wallet::primitives::chain::Chain; -use crate::openhuman::web3::wallet::primitives::Result; - -#[cfg(feature = "web3")] -pub mod btc; -#[cfg(feature = "web3")] -pub mod evm; -#[cfg(feature = "web3")] -pub mod solana; -#[cfg(feature = "web3")] -pub mod tron; - -/// Validate `address` for `chain`, returning it trimmed. -/// -/// Dispatches to the chain's own module. For Bitcoin this is the -/// **recipient** rule — any well-formed mainnet address; call -/// `btc::validate_sender` directly when validating a sender, since the -/// distinction has no equivalent on the other chains and cannot be expressed -/// through this entry point. -/// -/// # Errors -/// -/// Whatever the chain's own `validate` returns, plus -/// [`crate::openhuman::web3::wallet::primitives::Error::ChainNotCompiled`] if the shared `web3` feature gate is -/// disabled in this build. That case is a build fact rather than a property of -/// the address: the validation code was not compiled, so there is no answer to -/// give, and silently accepting or rejecting would be a wrong answer dressed -/// up as a real one. -/// -/// # Examples -/// -/// ```ignore -/// # #[cfg(feature = "web3")] { -/// use crate::openhuman::web3::wallet::primitives::{address, chain::Chain}; -/// -/// let addr = address::validate(Chain::Solana, "11111111111111111111111111111111")?; -/// assert_eq!(addr, "11111111111111111111111111111111"); -/// # } -/// # Ok::<(), crate::openhuman::web3::wallet::primitives::Error>(()) -/// ``` -// With every chain gate off, only the `ChainNotCompiled` arm survives and -// `address` goes unread. That build is legal (a host may depend on this crate -// purely for `Chain`), so the unused binding is expected rather than a bug. -#[cfg_attr(not(feature = "web3"), allow(unused_variables))] -pub fn validate(chain: Chain, address: &str) -> Result { - match chain { - #[cfg(feature = "web3")] - Chain::Btc => btc::validate(address), - #[cfg(feature = "web3")] - Chain::Evm => evm::validate(address), - #[cfg(feature = "web3")] - Chain::Solana => solana::validate(address), - #[cfg(feature = "web3")] - Chain::Tron => tron::validate(address), - #[cfg(not(all(feature = "web3", feature = "web3", feature = "web3", feature = "web3")))] - other => Err( - crate::openhuman::web3::wallet::primitives::Error::ChainNotCompiled { chain: other }, - ), - } -} - -#[cfg(test)] -mod test; diff --git a/src/openhuman/web3/wallet/primitives/address/solana.rs b/src/openhuman/web3/wallet/primitives/address/solana.rs deleted file mode 100644 index 0fd2aba9ea..0000000000 --- a/src/openhuman/web3/wallet/primitives/address/solana.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! Solana address validation. -//! -//! A Solana address is an ed25519 public key — 32 raw bytes — rendered in -//! base58. There is no checksum and no version byte, so validation is exactly -//! two questions: does it decode as base58, and is the result 32 bytes. -//! -//! That absence of a checksum is worth knowing: unlike Bitcoin or Tron, a -//! single mistyped character in a Solana address usually produces *another -//! syntactically valid address*. Validation here catches malformed input, not -//! typos, and no amount of parsing can change that. - -use crate::openhuman::web3::wallet::primitives::chain::Chain; -use crate::openhuman::web3::wallet::primitives::{Error, Result}; - -/// Length in bytes of a decoded Solana address (an ed25519 public key). -pub const ADDRESS_BYTES: usize = 32; - -/// Validate a Solana address and return it trimmed. -/// -/// # Errors -/// -/// - [`Error::EmptyAddress`] if `address` is empty or all whitespace. -/// - [`Error::InvalidAddress`] if it is not base58, or does not decode to -/// exactly [`ADDRESS_BYTES`] bytes. -/// -/// # Examples -/// -/// ```ignore -/// use crate::openhuman::web3::wallet::primitives::address::solana; -/// -/// // The system program id — 32 zero bytes. -/// assert!(solana::validate("11111111111111111111111111111111").is_ok()); -/// -/// // `0` is not in the base58 alphabet. -/// assert!(solana::validate("0OIl").is_err()); -/// ``` -pub fn validate(address: &str) -> Result { - decode(address).map(|_| address.trim().to_string()) -} - -/// Validate a Solana address and return its decoded 32 bytes. -/// -/// The same check as [`validate`], for a caller that needs the key material -/// rather than the string — deriving an associated token account, say. Offered -/// so callers do not have to decode a second time immediately after -/// validating. -/// -/// # Errors -/// -/// Identical to [`validate`]. -/// -/// # Examples -/// -/// ```ignore -/// use crate::openhuman::web3::wallet::primitives::address::solana; -/// -/// let bytes = solana::decode("11111111111111111111111111111111")?; -/// assert_eq!(bytes, [0u8; 32]); -/// # Ok::<(), crate::openhuman::web3::wallet::primitives::Error>(()) -/// ``` -pub fn decode(address: &str) -> Result<[u8; ADDRESS_BYTES]> { - let trimmed = address.trim(); - if trimmed.is_empty() { - return Err(Error::EmptyAddress { - chain: Chain::Solana, - }); - } - - let decoded = bs58::decode(trimmed) - .into_vec() - .map_err(|e| Error::InvalidAddress { - chain: Chain::Solana, - address: trimmed.to_string(), - reason: format!("not valid base58: {e}"), - })?; - - decoded - .try_into() - .map_err(|v: Vec| Error::InvalidAddress { - chain: Chain::Solana, - address: trimmed.to_string(), - reason: format!("expected {ADDRESS_BYTES} bytes, got {}", v.len()), - }) -} - -/// Render 32 raw bytes as a base58 Solana address. -/// -/// The inverse of [`decode`]. Infallible: every 32-byte array is a -/// syntactically valid address. -/// -/// # Examples -/// -/// ```ignore -/// use crate::openhuman::web3::wallet::primitives::address::solana; -/// -/// assert_eq!(solana::encode(&[0u8; 32]), "11111111111111111111111111111111"); -/// ``` -#[must_use] -pub fn encode(bytes: &[u8; ADDRESS_BYTES]) -> String { - bs58::encode(bytes).into_string() -} - -#[cfg(test)] -mod test; diff --git a/src/openhuman/web3/wallet/primitives/address/solana/test.rs b/src/openhuman/web3/wallet/primitives/address/solana/test.rs deleted file mode 100644 index f182b52096..0000000000 --- a/src/openhuman/web3/wallet/primitives/address/solana/test.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! Unit tests for Solana address validation. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::{decode, encode, validate, ADDRESS_BYTES}; -use crate::openhuman::web3::wallet::primitives::{Chain, Error}; - -/// The system program id: 32 zero bytes. -const SYSTEM_PROGRAM: &str = "11111111111111111111111111111111"; -/// The SPL token program id — a real 32-byte key with a full alphabet. -const TOKEN_PROGRAM: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; - -#[test] -fn accepts_real_addresses() { - for addr in [SYSTEM_PROGRAM, TOKEN_PROGRAM] { - assert_eq!(validate(addr).unwrap(), addr); - } -} - -#[test] -fn trims_surrounding_whitespace() { - assert_eq!( - validate(&format!(" {TOKEN_PROGRAM}\n")).unwrap(), - TOKEN_PROGRAM - ); -} - -#[test] -fn rejects_an_empty_address() { - assert_eq!( - validate(" ").unwrap_err(), - Error::EmptyAddress { - chain: Chain::Solana - } - ); -} - -#[test] -fn rejects_characters_outside_the_base58_alphabet() { - // `0`, `O`, `I` and `l` are excluded from base58 precisely because they - // are visually ambiguous. - for bad in ["0OIl", "hello world", "not!base58"] { - match validate(bad).unwrap_err() { - Error::InvalidAddress { chain, reason, .. } => { - assert_eq!(chain, Chain::Solana); - assert!(reason.contains("base58"), "reason was {reason:?}"); - } - other => panic!("expected InvalidAddress for {bad:?}, got {other:?}"), - } - } -} - -#[test] -fn rejects_a_decoded_length_other_than_32_bytes() { - // Valid base58, wrong length — the check a base58 decode alone misses. - let short = encode_arbitrary(&[1u8; 16]); - match validate(&short).unwrap_err() { - Error::InvalidAddress { reason, .. } => { - assert!(reason.contains("32"), "reason was {reason:?}"); - assert!( - reason.contains("16"), - "reason should report the actual length" - ); - } - other => panic!("expected InvalidAddress, got {other:?}"), - } - - let long = encode_arbitrary(&[1u8; 33]); - assert!(validate(&long).is_err()); -} - -#[test] -fn decode_returns_the_raw_key_bytes() { - assert_eq!(decode(SYSTEM_PROGRAM).unwrap(), [0u8; ADDRESS_BYTES]); -} - -#[test] -fn encode_and_decode_round_trip() { - let mut bytes = [0u8; ADDRESS_BYTES]; - for (i, b) in bytes.iter_mut().enumerate() { - *b = u8::try_from(i).unwrap(); - } - assert_eq!(decode(&encode(&bytes)).unwrap(), bytes); -} - -#[test] -fn decode_rejects_exactly_what_validate_rejects() { - // The two share a code path; this pins that they cannot drift apart. - for input in ["", " ", "0OIl", &encode_arbitrary(&[7u8; 31])] { - assert_eq!( - validate(input).is_err(), - decode(input).is_err(), - "validate and decode disagreed on {input:?}" - ); - } -} - -#[test] -fn a_single_character_typo_is_not_caught() { - // Documenting a real property of the chain, not endorsing it: Solana - // addresses carry no checksum, so a typo usually yields another valid - // address. Callers must confirm addresses out of band. - let mut chars: Vec = TOKEN_PROGRAM.chars().collect(); - chars[4] = if chars[4] == 'a' { 'b' } else { 'a' }; - let typo: String = chars.into_iter().collect(); - assert_ne!(typo, TOKEN_PROGRAM); - assert!( - validate(&typo).is_ok(), - "a Solana typo is indistinguishable from a real address" - ); -} - -/// Base58-encode arbitrary bytes, bypassing the 32-byte contract, so tests can -/// build wrong-length-but-valid-base58 inputs. -fn encode_arbitrary(bytes: &[u8]) -> String { - bs58::encode(bytes).into_string() -} diff --git a/src/openhuman/web3/wallet/primitives/address/test.rs b/src/openhuman/web3/wallet/primitives/address/test.rs deleted file mode 100644 index 15516d6afc..0000000000 --- a/src/openhuman/web3/wallet/primitives/address/test.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! Unit tests for chain-generic address dispatch. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::validate; -use crate::openhuman::web3::wallet::primitives::{Chain, Error}; - -/// One valid mainnet address per chain. -const FIXTURES: [(Chain, &str); 4] = [ - (Chain::Btc, "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"), - (Chain::Evm, "0x52908400098527886E0F7030069857D2E4169EE7"), - (Chain::Solana, "11111111111111111111111111111111"), - (Chain::Tron, "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"), -]; - -/// Whether the feature gate behind `chain` is on in this build. -/// -/// The dispatch assertions have to reflect the gated contract: a chain whose -/// gate is off is *supposed* to answer `ChainNotCompiled`, so the tests expect -/// success exactly for the chains that are compiled in. -const fn chain_enabled(chain: Chain) -> bool { - match chain { - #[cfg(feature = "web3")] - Chain::Btc => true, - #[cfg(feature = "web3")] - Chain::Evm => true, - #[cfg(feature = "web3")] - Chain::Solana => true, - #[cfg(feature = "web3")] - Chain::Tron => true, - #[cfg(not(all(feature = "web3", feature = "web3", feature = "web3", feature = "web3")))] - _ => false, - } -} - -#[test] -fn dispatches_every_chain_to_its_own_validator() { - for (chain, address) in FIXTURES { - if chain_enabled(chain) { - assert_eq!( - validate(chain, address).unwrap(), - address, - "{chain} dispatch failed" - ); - } else { - assert!( - matches!( - validate(chain, address), - Err(Error::ChainNotCompiled { .. }) - ), - "{chain} gate is off in this build, so validation must report \ - ChainNotCompiled, not validate" - ); - } - } -} - -#[test] -fn every_known_chain_has_a_fixture() { - // If `Chain::ALL` grows, this test fails until the new chain is covered - // above — otherwise a new variant would silently go untested. - assert_eq!(Chain::ALL.len(), FIXTURES.len()); - for chain in Chain::ALL { - assert!( - FIXTURES.iter().any(|(c, _)| c == chain), - "no dispatch fixture for {chain}" - ); - } -} - -#[test] -fn an_address_from_the_wrong_chain_is_rejected() { - // The dispatch must actually route: a Solana address handed to the Tron - // arm has to fail, or the match is not doing its job. - for (chain, address) in FIXTURES { - for (other_chain, _) in FIXTURES { - if chain == other_chain { - continue; - } - assert!( - validate(other_chain, address).is_err(), - "{chain} address {address} was wrongly accepted as {other_chain}" - ); - } - } -} - -#[test] -fn dispatch_rejects_empty_input_on_every_chain() { - for (chain, _) in FIXTURES { - assert!( - validate(chain, " ").is_err(), - "{chain} accepted whitespace" - ); - } -} diff --git a/src/openhuman/web3/wallet/primitives/address/tron.rs b/src/openhuman/web3/wallet/primitives/address/tron.rs deleted file mode 100644 index b4a8c09ea8..0000000000 --- a/src/openhuman/web3/wallet/primitives/address/tron.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! Tron address validation and hex conversion. -//! -//! Tron addresses come in two forms, and any code touching the chain deals -//! with both: -//! -//! - **Base58check** (`T…`) — the user-facing form. 21 bytes: a `0x41` version -//! prefix plus a 20-byte payload, with a 4-byte checksum appended. -//! - **Hex** (`41…`) — the same 21 bytes, hex-encoded. This is what the -//! `TronGrid` API speaks. -//! -//! [`to_hex`] converts between them. Unlike Solana, Tron addresses *are* -//! checksummed, so a mistyped address is reliably caught here rather than -//! silently naming a different account. - -use crate::openhuman::web3::wallet::primitives::chain::Chain; -use crate::openhuman::web3::wallet::primitives::{Error, Result}; - -/// Tron mainnet address version prefix. -/// -/// Every decoded mainnet address starts with this byte; base58check decoding -/// verifies it, which is what makes a testnet or foreign-chain address fail -/// rather than decode to something plausible. -pub const MAINNET_PREFIX: u8 = 0x41; - -/// Length in bytes of a decoded Tron address: the version prefix plus a -/// 20-byte payload. -pub const ADDRESS_BYTES: usize = 21; - -/// Validate a Tron mainnet address and return it trimmed. -/// -/// # Errors -/// -/// - [`Error::EmptyAddress`] if `address` is empty or all whitespace. -/// - [`Error::InvalidAddress`] if base58check decoding fails — a bad checksum, -/// a non-base58 character, or a version byte other than -/// [`MAINNET_PREFIX`] — or if the payload is not [`ADDRESS_BYTES`] bytes. -/// -/// # Examples -/// -/// ```ignore -/// use crate::openhuman::web3::wallet::primitives::address::tron; -/// -/// assert!(tron::validate("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t").is_ok()); -/// -/// // One character changed: the checksum catches it. -/// assert!(tron::validate("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6u").is_err()); -/// ``` -pub fn validate(address: &str) -> Result { - decode(address).map(|_| address.trim().to_string()) -} - -/// Validate a Tron address and return its decoded 21 bytes, version prefix -/// included. -/// -/// # Errors -/// -/// Identical to [`validate`]. -pub fn decode(address: &str) -> Result<[u8; ADDRESS_BYTES]> { - let trimmed = address.trim(); - if trimmed.is_empty() { - return Err(Error::EmptyAddress { chain: Chain::Tron }); - } - - let decoded = bs58::decode(trimmed) - .with_check(Some(MAINNET_PREFIX)) - .into_vec() - .map_err(|e| Error::InvalidAddress { - chain: Chain::Tron, - address: trimmed.to_string(), - reason: format!("base58check decoding failed: {e}"), - })?; - - decoded - .try_into() - .map_err(|v: Vec| Error::InvalidAddress { - chain: Chain::Tron, - address: trimmed.to_string(), - reason: format!( - "expected {ADDRESS_BYTES} bytes after base58check, got {}", - v.len() - ), - }) -} - -/// Convert a base58check Tron address to its hex form. -/// -/// The result is 42 lowercase hex digits — the 21 decoded bytes including the -/// `41` version prefix, with no `0x`. That is the form the `TronGrid` API -/// expects; it is **not** an EVM address, despite the superficial resemblance. -/// -/// # Errors -/// -/// Identical to [`validate`]. -/// -/// # Examples -/// -/// ```ignore -/// use crate::openhuman::web3::wallet::primitives::address::tron; -/// -/// let hex = tron::to_hex("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t")?; -/// assert_eq!(hex.len(), 42); -/// assert!(hex.starts_with("41"), "the version prefix is retained"); -/// # Ok::<(), crate::openhuman::web3::wallet::primitives::Error>(()) -/// ``` -pub fn to_hex(address: &str) -> Result { - Ok(hex::encode(decode(address)?)) -} - -/// Render 21 decoded bytes as a base58check Tron address. -/// -/// The inverse of [`decode`]. The input must be a full mainnet address — -/// version prefix included — so its first byte must be [`MAINNET_PREFIX`]. -/// Enforcing that here means every successful result round-trips through both -/// [`decode`] and [`validate`]. -/// -/// # Errors -/// -/// - [`Error::WrongNetwork`] if the first byte is not [`MAINNET_PREFIX`]: the -/// bytes are then a well-formed address for some other Tron network, not -/// mainnet. -/// -/// # Examples -/// -/// ```ignore -/// use crate::openhuman::web3::wallet::primitives::address::tron; -/// -/// let bytes = tron::decode("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t")?; -/// assert_eq!(tron::encode(&bytes)?, "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"); -/// # Ok::<(), crate::openhuman::web3::wallet::primitives::Error>(()) -/// ``` -pub fn encode(bytes: &[u8; ADDRESS_BYTES]) -> Result { - if bytes[0] != MAINNET_PREFIX { - return Err(Error::WrongNetwork { - chain: Chain::Tron, - address: hex::encode(bytes), - expected: "mainnet".to_string(), - reason: format!( - "version prefix is {:#04x}, expected {MAINNET_PREFIX:#04x}", - bytes[0] - ), - }); - } - Ok(bs58::encode(bytes).with_check().into_string()) -} - -#[cfg(test)] -mod test; diff --git a/src/openhuman/web3/wallet/primitives/address/tron/test.rs b/src/openhuman/web3/wallet/primitives/address/tron/test.rs deleted file mode 100644 index 91c9f3b812..0000000000 --- a/src/openhuman/web3/wallet/primitives/address/tron/test.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! Unit tests for Tron address validation and hex conversion. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::{decode, encode, to_hex, validate, ADDRESS_BYTES, MAINNET_PREFIX}; -use crate::openhuman::web3::wallet::primitives::{Chain, Error}; - -/// The USDT TRC20 contract address — a real, checksummed mainnet address. -const USDT: &str = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; - -#[test] -fn accepts_a_real_mainnet_address() { - assert_eq!(validate(USDT).unwrap(), USDT); -} - -#[test] -fn trims_surrounding_whitespace() { - assert_eq!(validate(&format!(" {USDT}\n")).unwrap(), USDT); -} - -#[test] -fn rejects_an_empty_address() { - assert_eq!( - validate(" ").unwrap_err(), - Error::EmptyAddress { chain: Chain::Tron } - ); -} - -#[test] -fn rejects_a_mistyped_address_via_its_checksum() { - // Unlike Solana, Tron addresses are checksummed, so a typo is caught. - let mut chars: Vec = USDT.chars().collect(); - let last = chars.len() - 1; - chars[last] = if chars[last] == 'u' { 'v' } else { 'u' }; - let typo: String = chars.into_iter().collect(); - assert_ne!(typo, USDT, "the fixture must actually differ"); - - match validate(&typo).unwrap_err() { - Error::InvalidAddress { chain, address, .. } => { - assert_eq!(chain, Chain::Tron); - assert_eq!(address, typo); - } - other => panic!("expected InvalidAddress, got {other:?}"), - } -} - -#[test] -fn rejects_a_non_base58_address() { - assert!(matches!( - validate("not!an!address").unwrap_err(), - Error::InvalidAddress { .. } - )); -} - -#[test] -fn rejects_an_address_with_a_foreign_version_prefix() { - // Same 20-byte payload, a different version byte. Base58check verifies the - // prefix, which is what stops a foreign-chain address decoding to - // something plausible. - let mut bytes = [0u8; ADDRESS_BYTES]; - bytes[0] = 0x30; - let foreign = bs58::encode(bytes).with_check().into_string(); - assert!( - validate(&foreign).is_err(), - "a non-{MAINNET_PREFIX:#x} prefix must be rejected" - ); -} - -#[test] -fn rejects_a_base58check_value_with_the_right_prefix_but_wrong_length() { - let short = bs58::encode([MAINNET_PREFIX]).with_check().into_string(); - assert!(matches!( - decode(&short), - Err(Error::InvalidAddress { - chain: Chain::Tron, - .. - }) - )); -} - -#[test] -fn decode_retains_the_version_prefix() { - let bytes = decode(USDT).unwrap(); - assert_eq!(bytes.len(), ADDRESS_BYTES); - assert_eq!(bytes[0], MAINNET_PREFIX); -} - -#[test] -fn encode_and_decode_round_trip() { - assert_eq!(encode(&decode(USDT).unwrap()).unwrap(), USDT); -} - -#[test] -fn encode_rejects_a_non_mainnet_version_prefix() { - // `encode` must not mint an address that `validate` would reject: with any - // first byte other than the mainnet prefix the result is well-formed - // base58check for some *other* Tron network. - let mut bytes = [0u8; ADDRESS_BYTES]; - bytes[0] = 0x30; - match encode(&bytes).unwrap_err() { - Error::WrongNetwork { - chain, - address, - expected, - reason, - } => { - assert_eq!(chain, Chain::Tron); - assert!(address.starts_with("30"), "hex form: {address}"); - assert_eq!(expected, "mainnet"); - assert!(reason.contains(&format!("{MAINNET_PREFIX:#04x}"))); - } - other => panic!("expected WrongNetwork, got {other:?}"), - } -} - -#[test] -fn to_hex_produces_the_trongrid_form() { - let hex = to_hex(USDT).unwrap(); - // 21 bytes, two hex digits each — and no `0x`, because this is not an EVM - // address despite the resemblance. - assert_eq!(hex.len(), ADDRESS_BYTES * 2); - assert!(!hex.starts_with("0x")); - assert!( - hex.starts_with("41"), - "the version prefix is retained: {hex}" - ); - assert!(hex.chars().all(|c| c.is_ascii_hexdigit())); - assert_eq!(hex, hex.to_lowercase(), "hex output is lowercase"); -} - -#[test] -fn to_hex_rejects_exactly_what_validate_rejects() { - for input in [ - "", - " ", - "not!base58", - "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6u", - ] { - assert_eq!( - validate(input).is_err(), - to_hex(input).is_err(), - "validate and to_hex disagreed on {input:?}" - ); - } -} diff --git a/src/openhuman/web3/wallet/primitives/chain/mod.rs b/src/openhuman/web3/wallet/primitives/chain/mod.rs deleted file mode 100644 index 6a3c7f9383..0000000000 --- a/src/openhuman/web3/wallet/primitives/chain/mod.rs +++ /dev/null @@ -1,102 +0,0 @@ -//! The set of chains this crate understands. -//! -//! [`Chain`] exists so errors can name the chain they came from without every -//! variant carrying a stringly-typed label, and so a host can drive -//! chain-generic code — a dispatch table, a UI picker — off one enum rather -//! than its own parallel copy. -//! -//! It is deliberately **not** feature-gated. A host compiled with only the -//! `solana` gate should still be able to name and match on `Chain::Btc` -//! (in a config file it round-trips, say) without that failing to compile; -//! only the *validation functions* disappear with their gates. - -use std::fmt; -use std::str::FromStr; - -/// A blockchain this crate has address support for. -/// -/// Serde support is conditional so the enum stays dependency-free in builds -/// that do not need it. The representation is the lowercase variant name -/// (`"btc"`, `"evm"`, …), matching [`FromStr`] and [`fmt::Display`] below, so a -/// value written by one and read by the other agrees — this type crosses a -/// host/backend boundary in [`crate::openhuman::web3::wallet::primitives::wire`], where a mismatch between the text -/// and JSON forms would be a runtime deserialization failure. -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize, -)] -#[serde(rename_all = "lowercase")] -#[non_exhaustive] -pub enum Chain { - /// Bitcoin (mainnet). - Btc, - /// An EVM chain — Ethereum and every address-compatible network. - /// - /// One variant covers all of them because the address format is identical - /// across EVM chains; nothing about validating an address distinguishes - /// Ethereum from Polygon or Base. - Evm, - /// Solana (mainnet-beta). - Solana, - /// Tron (mainnet). - Tron, -} - -impl Chain { - /// Every chain this crate knows, in declaration order. - /// - /// Useful for a host enumerating supported chains. This is the full set - /// regardless of which feature gates are enabled — see the module docs. - pub const ALL: &'static [Self] = &[Self::Btc, Self::Evm, Self::Solana, Self::Tron]; - - /// The chain's lowercase machine-readable name (`"btc"`, `"evm"`, - /// `"solana"`, `"tron"`). - /// - /// This is the form [`Chain::from_str`] parses, so `chain.as_str()` always - /// round-trips. - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Btc => "btc", - Self::Evm => "evm", - Self::Solana => "solana", - Self::Tron => "tron", - } - } -} - -impl fmt::Display for Chain { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -/// Returned by [`Chain::from_str`] when the input names no known chain. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[error("unknown chain '{0}'")] -pub struct UnknownChain(pub String); - -impl FromStr for Chain { - type Err = UnknownChain; - - /// Parse a chain from its machine-readable name, case-insensitively. - /// - /// `"ethereum"` and `"eth"` are accepted as aliases for [`Chain::Evm`], - /// and `"bitcoin"` for [`Chain::Btc`], because those are the spellings - /// that show up in user-facing config. - /// - /// # Errors - /// - /// Returns [`UnknownChain`] if `s` names no known chain. - fn from_str(s: &str) -> std::result::Result { - match s.trim().to_ascii_lowercase().as_str() { - "btc" | "bitcoin" => Ok(Self::Btc), - "evm" | "eth" | "ethereum" => Ok(Self::Evm), - "solana" | "sol" => Ok(Self::Solana), - "tron" | "trx" => Ok(Self::Tron), - other => Err(UnknownChain(other.to_string())), - } - } -} - -#[cfg(test)] -mod test; diff --git a/src/openhuman/web3/wallet/primitives/chain/test.rs b/src/openhuman/web3/wallet/primitives/chain/test.rs deleted file mode 100644 index 7267b7103b..0000000000 --- a/src/openhuman/web3/wallet/primitives/chain/test.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Unit tests for the [`Chain`](super::Chain) enum. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use std::str::FromStr; - -use super::{Chain, UnknownChain}; - -#[test] -fn as_str_round_trips_through_from_str() { - for chain in Chain::ALL { - assert_eq!(Chain::from_str(chain.as_str()).unwrap(), *chain); - } -} - -#[test] -fn display_matches_as_str() { - for chain in Chain::ALL { - assert_eq!(chain.to_string(), chain.as_str()); - } -} - -#[test] -fn all_contains_no_duplicates() { - let mut seen = Chain::ALL.to_vec(); - seen.sort_unstable(); - seen.dedup(); - assert_eq!(seen.len(), Chain::ALL.len()); -} - -#[test] -fn parsing_is_case_and_whitespace_insensitive() { - assert_eq!(Chain::from_str(" BTC \n").unwrap(), Chain::Btc); - assert_eq!(Chain::from_str("SoLaNa").unwrap(), Chain::Solana); -} - -#[test] -fn common_aliases_parse() { - // These are the spellings that turn up in user-facing config. - for (input, expected) in [ - ("bitcoin", Chain::Btc), - ("eth", Chain::Evm), - ("ethereum", Chain::Evm), - ("sol", Chain::Solana), - ("trx", Chain::Tron), - ] { - assert_eq!(Chain::from_str(input).unwrap(), expected, "alias {input}"); - } -} - -#[test] -fn an_unknown_name_is_reported_with_the_input() { - assert_eq!( - Chain::from_str("dogecoin").unwrap_err(), - UnknownChain("dogecoin".to_string()) - ); -} diff --git a/src/openhuman/web3/wallet/primitives/eip712/mod.rs b/src/openhuman/web3/wallet/primitives/eip712/mod.rs deleted file mode 100644 index a99009d8c0..0000000000 --- a/src/openhuman/web3/wallet/primitives/eip712/mod.rs +++ /dev/null @@ -1,189 +0,0 @@ -//! EIP-712 typed-data hashing, and the EIP-3009 payload x402 signs. -//! -//! # Why this is here rather than in a chain library -//! -//! EIP-712 is a hashing scheme, not a chain client. Everything below is -//! keccak-256 over a fixed byte layout — there is no RPC, no signing, and no -//! elliptic curve involved. Hosting it here means the x402 payment path needs -//! `sha3` and nothing else, where routing it through a full Ethereum library -//! costs an ABI encoder, a bignum type, a signer stack, and their tails. -//! -//! # Integers are big-endian `[u8; 32]`, deliberately -//! -//! EIP-712 encodes every `uint256` as a 32-byte big-endian word, so that is the -//! type this module takes. Introducing a bignum just to convert it back to the -//! same 32 bytes would add a dependency to this crate and force one on every -//! caller. [`u256_from_u64`] and [`u256_from_decimal`] cover the two ways a -//! caller actually has the value. -//! -//! # Nothing here signs -//! -//! [`signing_digest`] returns the 32 bytes to sign and stops. That is the same -//! split the rest of this crate makes — see [`crate::openhuman::web3::wallet::primitives::wire`] — and it is what -//! lets the payload be built somewhere the signing key is not. - -use sha3::{Digest, Keccak256}; - -/// `keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")`. -/// -/// Pinned rather than computed at each call: it is a published constant, and a -/// test below recomputes it, so a typo in the type string is caught here rather -/// than as a signature a contract silently rejects. -const DOMAIN_TYPE_HASH: [u8; 32] = [ - 0x8b, 0x73, 0xc3, 0xc6, 0x9b, 0xb8, 0xfe, 0x3d, 0x51, 0x2e, 0xcc, 0x4c, 0xf7, 0x59, 0xcc, 0x79, - 0x23, 0x9f, 0x7b, 0x17, 0x9b, 0x0f, 0xfa, 0xca, 0xa9, 0xa7, 0x5d, 0x52, 0x2b, 0x39, 0x40, 0x0f, -]; - -/// The EIP-712 type string for the EIP-3009 authorization x402 uses. -const TRANSFER_WITH_AUTHORIZATION_TYPE: &[u8] = b"TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"; - -/// The EIP-712 domain string, kept beside its pinned hash. -/// -/// Test-only, and that is the point: production code uses [`DOMAIN_TYPE_HASH`] -/// directly rather than hashing this on every call, and the test re-derives the -/// hash from this string to prove the two agree. Keeping the string here is -/// what makes pinning the hash safe instead of merely fast — a typo in either -/// one fails the test rather than silently changing every signature. -#[cfg(test)] -const DOMAIN_TYPE: &[u8] = - b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"; - -/// A 32-byte big-endian unsigned integer, as EIP-712 encodes `uint256`. -pub type U256Bytes = [u8; 32]; - -/// An EVM address as its raw 20 bytes. -pub type Address20 = [u8; 20]; - -/// Widen a `u64` into the 32-byte big-endian form EIP-712 wants. -#[must_use] -pub fn u256_from_u64(value: u64) -> U256Bytes { - let mut out = [0u8; 32]; - out[24..].copy_from_slice(&value.to_be_bytes()); - out -} - -/// Parse a base-10 integer string into the 32-byte big-endian form. -/// -/// Token amounts arrive as decimal strings — a `u64` cannot hold 18-decimal -/// values — so this does the widening without a bignum dependency, by long -/// multiplication over the 32 bytes. -/// -/// # Errors -/// -/// [`Error::InvalidAmount`] if `value` is empty, holds a non-digit, or does not -/// fit in 256 bits. -pub fn u256_from_decimal(value: &str) -> Result { - let trimmed = value.trim(); - if trimmed.is_empty() || !trimmed.bytes().all(|b| b.is_ascii_digit()) { - return Err(Error::InvalidAmount { - reason: "expected a base-10 integer".to_string(), - }); - } - - let mut out = [0u8; 32]; - for digit in trimmed.bytes().map(|b| u32::from(b - b'0')) { - // out = out * 10 + digit, big-endian, carrying from the least - // significant byte upwards. - let mut carry = digit; - for byte in out.iter_mut().rev() { - let product = u32::from(*byte) * 10 + carry; - *byte = u8::try_from(product & 0xff).unwrap_or(0); - carry = product >> 8; - } - if carry != 0 { - return Err(Error::InvalidAmount { - reason: "value does not fit in 256 bits".to_string(), - }); - } - } - Ok(out) -} - -/// Why an EIP-712 payload could not be built. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[non_exhaustive] -pub enum Error { - /// An amount was not a base-10 integer, or overflowed 256 bits. - #[error("invalid amount: {reason}")] - InvalidAmount { - /// What was wrong with it. - reason: String, - }, -} - -/// Result alias for this module. -pub type Result = std::result::Result; - -/// The EIP-712 domain separator. -/// -/// `name` and `version` are the token contract's, not the caller's choice: USDC -/// uses `("USD Coin", "2")`, but an x402 `extra` may name different ones, and a -/// mismatch produces a signature the contract rejects rather than an error -/// anything local can detect. -#[must_use] -pub fn domain_separator( - verifying_contract: Address20, - chain_id: u64, - name: &str, - version: &str, -) -> [u8; 32] { - let mut encoded = Vec::with_capacity(5 * 32); - encoded.extend_from_slice(&DOMAIN_TYPE_HASH); - encoded.extend_from_slice(&keccak(name.as_bytes())); - encoded.extend_from_slice(&keccak(version.as_bytes())); - encoded.extend_from_slice(&u256_from_u64(chain_id)); - encoded.extend_from_slice(&left_pad_address(verifying_contract)); - keccak(&encoded) -} - -/// The EIP-3009 `TransferWithAuthorization` struct hash. -#[must_use] -pub fn transfer_with_authorization_hash( - from: Address20, - to: Address20, - value: U256Bytes, - valid_after: U256Bytes, - valid_before: U256Bytes, - nonce: [u8; 32], -) -> [u8; 32] { - let mut encoded = Vec::with_capacity(7 * 32); - encoded.extend_from_slice(&keccak(TRANSFER_WITH_AUTHORIZATION_TYPE)); - encoded.extend_from_slice(&left_pad_address(from)); - encoded.extend_from_slice(&left_pad_address(to)); - encoded.extend_from_slice(&value); - encoded.extend_from_slice(&valid_after); - encoded.extend_from_slice(&valid_before); - encoded.extend_from_slice(&nonce); - keccak(&encoded) -} - -/// The 32 bytes a caller signs: `keccak256(0x19 0x01 ‖ domain ‖ struct)`. -/// -/// The `0x1901` prefix is what keeps a typed-data signature from ever being -/// replayable as a transaction signature — it makes the preimage impossible to -/// confuse with an RLP-encoded transaction. -/// -/// Already hashed: sign it with a "prehash" entry point, never by hashing again. -#[must_use] -pub fn signing_digest(domain_separator: [u8; 32], struct_hash: [u8; 32]) -> [u8; 32] { - let mut preimage = Vec::with_capacity(2 + 64); - preimage.extend_from_slice(&[0x19, 0x01]); - preimage.extend_from_slice(&domain_separator); - preimage.extend_from_slice(&struct_hash); - keccak(&preimage) -} - -/// Keccak-256. -fn keccak(bytes: &[u8]) -> [u8; 32] { - Keccak256::digest(bytes).into() -} - -/// An address as a left-padded 32-byte word, which is how EIP-712 encodes it. -fn left_pad_address(address: Address20) -> [u8; 32] { - let mut out = [0u8; 32]; - out[12..].copy_from_slice(&address); - out -} - -#[cfg(test)] -mod test; diff --git a/src/openhuman/web3/wallet/primitives/eip712/test.rs b/src/openhuman/web3/wallet/primitives/eip712/test.rs deleted file mode 100644 index 9dcc92c118..0000000000 --- a/src/openhuman/web3/wallet/primitives/eip712/test.rs +++ /dev/null @@ -1,207 +0,0 @@ -//! Tests for EIP-712 hashing. -//! -//! A wrong hash here is not a crash — it is a well-formed signature over -//! something other than the intended payment, which the contract rejects with -//! no explanation, or worse, accepts. So the constants are checked against the -//! specifications rather than against this module's own output. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::{ - domain_separator, keccak, signing_digest, transfer_with_authorization_hash, u256_from_decimal, - u256_from_u64, Error, DOMAIN_TYPE, DOMAIN_TYPE_HASH, TRANSFER_WITH_AUTHORIZATION_TYPE, -}; - -fn hex(bytes: &[u8]) -> String { - bytes.iter().fold(String::new(), |mut out, b| { - use std::fmt::Write as _; - let _ = write!(out, "{b:02x}"); - out - }) -} - -#[test] -fn the_pinned_domain_type_hash_matches_its_type_string() { - // The constant is pinned so a typo in the type string cannot silently - // change every signature this module produces. This is the test that makes - // pinning safe rather than merely convenient. - assert_eq!(keccak(DOMAIN_TYPE), DOMAIN_TYPE_HASH); -} - -#[test] -fn the_domain_type_hash_is_the_published_constant() { - assert_eq!( - hex(&DOMAIN_TYPE_HASH), - "8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f" - ); -} - -#[test] -fn the_eip3009_type_hash_is_the_published_constant() { - // From EIP-3009. A wrong type hash produces a signature that every - // conforming token contract refuses. - assert_eq!( - hex(&keccak(TRANSFER_WITH_AUTHORIZATION_TYPE)), - "7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267" - ); -} - -#[test] -fn a_u64_widens_into_the_low_eight_bytes() { - let widened = u256_from_u64(1); - assert_eq!(widened[31], 1); - assert!(widened[..31].iter().all(|b| *b == 0)); - - assert_eq!( - hex(&u256_from_u64(u64::MAX)), - "000000000000000000000000000000000000000000000000ffffffffffffffff" - ); -} - -#[test] -fn a_decimal_string_widens_the_same_way_a_u64_does() { - // The two paths must agree wherever they overlap, or an amount's encoding - // would depend on which one the caller happened to use. - for value in [0u64, 1, 42, 1_000_000, u64::MAX] { - assert_eq!( - u256_from_decimal(&value.to_string()).unwrap(), - u256_from_u64(value), - "decimal and u64 widening disagree for {value}" - ); - } -} - -#[test] -fn a_decimal_string_carries_beyond_sixty_four_bits() { - // The reason the decimal path exists: an 18-decimal token amount does not - // fit in a u64. - let one_ether = "1000000000000000000000000000000000000000"; - let widened = u256_from_decimal(one_ether).unwrap(); - assert!( - widened[..8].iter().any(|b| *b != 0) || widened[8..24].iter().any(|b| *b != 0), - "a value past 2^64 must occupy the high bytes: {}", - hex(&widened) - ); - - // 2^128, checked exactly. - assert_eq!( - hex(&u256_from_decimal("340282366920938463463374607431768211456").unwrap()), - "0000000000000000000000000000000100000000000000000000000000000000" - ); -} - -#[test] -fn the_largest_representable_value_is_accepted_and_the_next_is_not() { - let max = "1157920892373161954235709850086879078532699846656405640394575840079131296399\ - 35"; - let max = max.replace(char::is_whitespace, ""); - assert_eq!(hex(&u256_from_decimal(&max).unwrap()), "ff".repeat(32)); - - // 2^256 exactly: one past the top. - let overflow = "115792089237316195423570985008687907853269984665640564039457584007913129639936"; - assert!(matches!( - u256_from_decimal(overflow).unwrap_err(), - Error::InvalidAmount { .. } - )); -} - -#[test] -fn a_non_numeric_amount_is_refused_rather_than_silently_zero() { - for bad in ["", " ", "12a", "-1", "1.5", "0x10"] { - assert!( - matches!(u256_from_decimal(bad), Err(Error::InvalidAmount { .. })), - "{bad:?} should be refused" - ); - } -} - -#[test] -fn the_domain_separator_depends_on_every_one_of_its_inputs() { - // Each field is part of the replay boundary: the same authorization must - // not verify on another chain, another contract, or another token. - let contract = [0x11u8; 20]; - let base = domain_separator(contract, 1, "USD Coin", "2"); - - assert_ne!(base, domain_separator([0x22u8; 20], 1, "USD Coin", "2")); - assert_ne!(base, domain_separator(contract, 8453, "USD Coin", "2")); - assert_ne!(base, domain_separator(contract, 1, "USDC", "2")); - assert_ne!(base, domain_separator(contract, 1, "USD Coin", "1")); -} - -#[test] -fn the_struct_hash_depends_on_every_one_of_its_inputs() { - let base = transfer_with_authorization_hash( - [0x11; 20], - [0x22; 20], - u256_from_u64(100), - u256_from_u64(0), - u256_from_u64(9_999), - [0x33; 32], - ); - - // Recipient and value especially: a hash insensitive to either would let a - // payment be redirected or resized after signing. - assert_ne!( - base, - transfer_with_authorization_hash( - [0x11; 20], - [0xaa; 20], - u256_from_u64(100), - u256_from_u64(0), - u256_from_u64(9_999), - [0x33; 32], - ) - ); - assert_ne!( - base, - transfer_with_authorization_hash( - [0x11; 20], - [0x22; 20], - u256_from_u64(101), - u256_from_u64(0), - u256_from_u64(9_999), - [0x33; 32], - ) - ); - assert_ne!( - base, - transfer_with_authorization_hash( - [0x11; 20], - [0x22; 20], - u256_from_u64(100), - u256_from_u64(0), - u256_from_u64(9_999), - [0x44; 32], - ) - ); -} - -#[test] -fn the_signing_digest_is_prefixed_so_it_cannot_be_a_transaction() { - // The 0x1901 prefix is the whole reason a typed-data signature cannot be - // replayed as a transaction signature. - let domain = [0x11u8; 32]; - let structure = [0x22u8; 32]; - - let mut preimage = vec![0x19, 0x01]; - preimage.extend_from_slice(&domain); - preimage.extend_from_slice(&structure); - - assert_eq!(signing_digest(domain, structure), keccak(&preimage)); - // And it must not be a bare hash of the concatenation. - assert_ne!( - signing_digest(domain, structure), - keccak(&[domain, structure].concat()) - ); -} - -#[test] -fn swapping_the_domain_and_struct_hashes_changes_the_digest() { - // Ordering inside the preimage is load-bearing and easy to get backwards. - let domain = [0x11u8; 32]; - let structure = [0x22u8; 32]; - assert_ne!( - signing_digest(domain, structure), - signing_digest(structure, domain) - ); -} diff --git a/src/openhuman/web3/wallet/primitives/error/mod.rs b/src/openhuman/web3/wallet/primitives/error/mod.rs deleted file mode 100644 index 85e3c8c7ea..0000000000 --- a/src/openhuman/web3/wallet/primitives/error/mod.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! Crate-wide error and result types. -//! -//! Every fallible public function in this crate returns [`Result`], and every -//! failure mode is a distinct [`Error`] variant. Add a variant rather than -//! encoding new context into an existing message: callers match on variants, -//! and message text is not a stable API. -//! -//! Errors carry the offending input verbatim. That is a deliberate choice for -//! this crate: an address is public data, and a caller diagnosing a rejected -//! address needs to see exactly what was rejected — a truncated or elided -//! address turns a one-line fix into a debugging session. **Nothing in this -//! crate ever puts a secret in an error**; key-material failures report the -//! failing step, never the material. - -use crate::openhuman::web3::wallet::primitives::chain::Chain; - -/// Errors returned by this crate. -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -#[non_exhaustive] -pub enum Error { - /// An address was empty or contained only whitespace. - #[error("{chain} address is empty")] - EmptyAddress { - /// The chain the address was being validated for. - chain: Chain, - }, - - /// An address was not well-formed for its chain. - /// - /// Covers every syntactic rejection: a bad base58 checksum, a wrong - /// length, a non-hex character, an invalid bech32 payload. - #[error("invalid {chain} address '{address}': {reason}")] - InvalidAddress { - /// The chain the address was being validated for. - chain: Chain, - /// The rejected address, verbatim. - address: String, - /// Why it was rejected. - reason: String, - }, - - /// An address was well-formed but belongs to the wrong network — a - /// testnet or regtest address where a mainnet one is required. - /// - /// Separate from [`Error::InvalidAddress`] because it is the one failure a - /// caller is likely to *handle* rather than merely report: it means the - /// user is pointed at the wrong network, not that they typo'd. - #[error("{chain} address '{address}' is not on {expected}: {reason}")] - WrongNetwork { - /// The chain the address was being validated for. - chain: Chain, - /// The rejected address, verbatim. - address: String, - /// The network that was required. - expected: String, - /// Detail from the underlying parser. - reason: String, - }, - - /// An address is well-formed but its type is not supported for the - /// requested role. - /// - /// Raised by `address::btc::validate_sender`: signing is only - /// implemented for P2WPKH, so a P2TR or P2SH address is a perfectly valid - /// *recipient* and an unusable *sender*. - #[error("{chain} address '{address}' is not supported as a sender: {reason}")] - UnsupportedAddressType { - /// The chain the address was being validated for. - chain: Chain, - /// The rejected address, verbatim. - address: String, - /// Which address types are supported instead. - reason: String, - }, - - /// The chain's feature gate was disabled when this crate was built. - /// - /// Only [`crate::openhuman::web3::wallet::primitives::address::validate`] can return this, and only for a chain - /// whose gate is off. It is a *build* fact, not a property of the input: - /// the validation code was not compiled, so there is no answer to give. - /// Reporting it as an error rather than silently accepting or rejecting - /// the address is the point — either of those would be a wrong answer - /// dressed up as a real one. - #[error( - "tinywallet was built without support for {chain}; \ - enable the '{chain}' feature to validate its addresses" - )] - ChainNotCompiled { - /// The chain whose feature gate is disabled. - chain: Chain, - }, -} - -/// The crate's standard result type. -/// -/// Use this alias in public signatures instead of spelling out -/// `std::result::Result`. -pub type Result = std::result::Result; - -#[cfg(test)] -mod test; diff --git a/src/openhuman/web3/wallet/primitives/error/test.rs b/src/openhuman/web3/wallet/primitives/error/test.rs deleted file mode 100644 index 2f5417d9b0..0000000000 --- a/src/openhuman/web3/wallet/primitives/error/test.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Unit tests for the crate-wide error type. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::Error; -use crate::openhuman::web3::wallet::primitives::Chain; - -#[test] -fn empty_address_names_its_chain() { - let err = Error::EmptyAddress { chain: Chain::Btc }; - assert_eq!(err.to_string(), "btc address is empty"); -} - -#[test] -fn invalid_address_shows_the_address_and_the_reason() { - // The rejected address appears verbatim: it is public data, and eliding it - // turns a one-line fix into a debugging session. - let err = Error::InvalidAddress { - chain: Chain::Solana, - address: "0OIl".to_string(), - reason: "not valid base58".to_string(), - }; - let rendered = err.to_string(); - assert!(rendered.contains("0OIl"), "{rendered}"); - assert!(rendered.contains("not valid base58"), "{rendered}"); - assert!(rendered.contains("solana"), "{rendered}"); -} - -#[test] -fn wrong_network_names_the_expected_network() { - let err = Error::WrongNetwork { - chain: Chain::Btc, - address: "tb1qexample".to_string(), - expected: "mainnet".to_string(), - reason: "address is testnet".to_string(), - }; - assert!(err.to_string().contains("mainnet")); -} - -#[test] -fn unsupported_address_type_explains_what_is_supported() { - let err = Error::UnsupportedAddressType { - chain: Chain::Btc, - address: "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2".to_string(), - reason: "only P2WPKH (bc1q… native segwit) can be signed for".to_string(), - }; - // A caller reading this should learn the fix, not just the failure. - assert!(err.to_string().contains("P2WPKH")); -} - -#[test] -fn errors_compare_by_value() { - // Callers assert on specific errors in their own tests, so equality has to - // be structural rather than by message. - assert_eq!( - Error::EmptyAddress { chain: Chain::Evm }, - Error::EmptyAddress { chain: Chain::Evm } - ); - assert_ne!( - Error::EmptyAddress { chain: Chain::Evm }, - Error::EmptyAddress { chain: Chain::Btc } - ); -} diff --git a/src/openhuman/web3/wallet/primitives/key/bip32.rs b/src/openhuman/web3/wallet/primitives/key/bip32.rs deleted file mode 100644 index 2981811cd0..0000000000 --- a/src/openhuman/web3/wallet/primitives/key/bip32.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! BIP-32 derivation on secp256k1, shared by Bitcoin, EVM and Tron. -//! -//! All three chains use the same scheme and differ only in what they do with -//! the resulting key, so the walk lives here once rather than three times. -//! -//! # This is delegated on purpose -//! -//! Rolling BIP-32 by hand is possible — it is HMAC-SHA512 plus a scalar -//! addition — but an off-by-one in the hardened-index encoding produces a -//! *valid key for the wrong account*, which is silent, unrecoverable, and -//! exactly the kind of bug not worth risking to avoid a dependency. -//! -//! That reasoning is unchanged from when this used `bitcoin`'s `Xpriv`. What -//! changed is which vetted implementation it delegates to: `coins-bip32`, whose -//! secp256k1 backend is the pure-Rust `k256` rather than the `secp256k1` C -//! library. The derived key is identical either way — BIP-32 is a specification, -//! not an implementation detail, and [`super::test`] pins the addresses against -//! the same fixed mnemonic as before the swap. `coins-bip32` is also the code -//! path `coins-bip39` already uses beneath [`super::seed_from_mnemonic`], so -//! this removes a native C build and a second elliptic-curve stack without -//! adding anything to the graph. -//! -//! Contrast [`crate::openhuman::web3::wallet::primitives::address::btc`], which *is* hand-rolled. The difference is -//! the failure mode, not the difficulty: a wrong parser is caught by the first -//! test vector, a wrong derivation is caught by nobody. - -use std::str::FromStr; - -use coins_bip32::path::DerivationPath; -use coins_bip32::prelude::SigningKey; -use coins_bip32::xkeys::XPriv; - -use super::{Error, Result}; - -/// A secp256k1 key derived at a BIP-32 path. -pub(super) struct Secp256k1Key { - pub(super) secret: SigningKey, -} - -impl Secp256k1Key { - /// The 65-byte uncompressed SEC1 encoding, `0x04` prefix included. - /// - /// EVM and Tron both hash this — minus the prefix byte — with Keccak-256 to - /// form an address. - pub(super) fn uncompressed_public(&self) -> [u8; 65] { - let encoded = self.secret.verifying_key().to_encoded_point(false); - let mut out = [0u8; 65]; - // Uncompressed SEC1 is 65 bytes by definition, so this cannot be short. - out.copy_from_slice(encoded.as_bytes()); - out - } - - /// The 33-byte compressed SEC1 encoding. - /// - /// Bitcoin hashes this — not the uncompressed form — to form a P2WPKH - /// address. Using the wrong one yields a well-formed address for an account - /// nobody holds the key to, which is why the two encodings are separate - /// named methods rather than one with a boolean. - pub(super) fn compressed_public(&self) -> [u8; 33] { - let encoded = self.secret.verifying_key().to_encoded_point(true); - let mut out = [0u8; 33]; - out.copy_from_slice(encoded.as_bytes()); - out - } - - /// The 32-byte secret scalar. - pub(super) fn secret_bytes(&self) -> [u8; 32] { - self.secret.to_bytes().into() - } -} - -/// Walk `path` from the master key for `seed`. -/// -/// The derived secret does not depend on a network: BIP-32 version bytes only -/// matter when an extended key is serialized, which never happens here. The -/// same walk is therefore correct for Bitcoin, EVM and Tron alike. -pub(super) fn derive(seed: &[u8], path: &str) -> Result { - let master = XPriv::root_from_seed(seed, None).map_err(|_| Error::Derivation { - step: "BIP-32 master key", - })?; - let parsed = DerivationPath::from_str(path).map_err(|e| Error::InvalidPath { - path: path.to_string(), - reason: e.to_string(), - })?; - - // Depth is checked here rather than left to the backend, because - // `coins-bip32` does not check it: `derive_child` increments a `u8` depth - // unguarded, which panics in a debug build and **wraps silently in a - // release build** — deriving at a wrapped depth instead of refusing. The - // `bitcoin` implementation this replaced returned `MaximumDepthExceeded`, - // so without this the swap would have traded a clean error for a wrong key. - // - // The master node is depth 0, leaving 255 usable levels. No real path comes - // close; the bound exists so a hostile or generated one cannot get through. - if parsed.len() > usize::from(u8::MAX) { - return Err(Error::InvalidPath { - path: path.to_string(), - reason: format!( - "BIP-32 depth is limited to {} levels, got {}", - u8::MAX, - parsed.len() - ), - }); - } - - let child = master.derive_path(parsed).map_err(|_| Error::Derivation { - step: "BIP-32 child key", - })?; - - let secret: &SigningKey = child.as_ref(); - Ok(Secp256k1Key { - secret: secret.clone(), - }) -} diff --git a/src/openhuman/web3/wallet/primitives/key/btc.rs b/src/openhuman/web3/wallet/primitives/key/btc.rs deleted file mode 100644 index ec632b5991..0000000000 --- a/src/openhuman/web3/wallet/primitives/key/btc.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Bitcoin key derivation: BIP-32 on secp256k1, P2WPKH address. -//! -//! Produces a native segwit (`bc1q…`) address, matching -//! [`crate::openhuman::web3::wallet::primitives::address::btc::validate_sender`] — the only script type this crate's -//! callers can sign for. Deriving a P2PKH or P2SH address here would hand back -//! something that passes recipient validation and then fails at signing time. -//! -//! The address is assembled here rather than by the `bitcoin` crate, which this -//! module used to route through. A P2WPKH address is fully specified by BIP-141 -//! and BIP-173 as `bech32(hrp="bc", version=0, hash160(compressed_pubkey))`, and -//! both halves of that are owned elsewhere: the bech32 encoding by -//! [`crate::openhuman::web3::wallet::primitives::address::btc::encode_p2wpkh`], which also decodes it, and the -//! BIP-32 walk by [`super::bip32`], which still delegates to a vetted -//! implementation. - -use ripemd::Ripemd160; -use sha2::{Digest, Sha256}; - -use super::{bip32, seed_from_mnemonic, DerivedKey, Error, Result}; -use crate::openhuman::web3::wallet::primitives::address::btc::encode_p2wpkh; -use crate::openhuman::web3::wallet::primitives::chain::Chain; - -/// Derive the Bitcoin signing key and P2WPKH address for `path`. -pub(super) fn derive(mnemonic: &str, path: &str) -> Result { - let seed = seed_from_mnemonic(mnemonic)?; - let key = bip32::derive(&seed, path)?; - - // The *compressed* encoding: a P2WPKH witness program is defined over it, - // and hashing the uncompressed form instead produces a valid-looking - // address for an account holding no funds. - let address = - encode_p2wpkh(&hash160(&key.compressed_public())).map_err(|_| Error::Derivation { - step: "BTC P2WPKH address", - })?; - - Ok(DerivedKey::new( - Chain::Btc, - address, - key.secret_bytes().to_vec(), - )) -} - -/// `RIPEMD160(SHA256(data))` — Bitcoin's HASH160. -fn hash160(data: &[u8]) -> [u8; 20] { - let sha = Sha256::digest(data); - let ripemd = Ripemd160::digest(sha); - let mut out = [0u8; 20]; - // RIPEMD-160 is 20 bytes by definition. - out.copy_from_slice(&ripemd); - out -} diff --git a/src/openhuman/web3/wallet/primitives/key/evm.rs b/src/openhuman/web3/wallet/primitives/key/evm.rs deleted file mode 100644 index 22fb22293d..0000000000 --- a/src/openhuman/web3/wallet/primitives/key/evm.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! EVM key derivation: BIP-32 on secp256k1, address via Keccak-256. - -use sha3::{Digest, Keccak256}; - -use super::{bip32, seed_from_mnemonic, DerivedKey, Result}; -use crate::openhuman::web3::wallet::primitives::chain::Chain; - -/// Derive the EVM signing key and address for `path`. -pub(super) fn derive(mnemonic: &str, path: &str) -> Result { - let seed = seed_from_mnemonic(mnemonic)?; - let key = bip32::derive(&seed, path)?; - let address = address_from_public(&key.uncompressed_public()); - Ok(DerivedKey::new( - Chain::Evm, - address, - key.secret_bytes().to_vec(), - )) -} - -/// An EVM address is the last 20 bytes of the Keccak-256 hash of the -/// uncompressed public key with its `0x04` prefix byte removed. -/// -/// Returned EIP-55 checksummed, which is the canonical display form and what -/// every explorer and wallet shows. -fn address_from_public(uncompressed: &[u8; 65]) -> String { - let hash = Keccak256::digest(&uncompressed[1..]); - let body = hex_lower(&hash[12..]); - // The address was just built from a hash, so it is well-formed by - // construction and checksumming cannot fail. - crate::openhuman::web3::wallet::primitives::address::evm::to_checksummed(&body) - .unwrap_or_else(|_| format!("0x{body}")) -} - -fn hex_lower(bytes: &[u8]) -> String { - use std::fmt::Write as _; - bytes.iter().fold(String::new(), |mut out, b| { - let _ = write!(out, "{b:02x}"); - out - }) -} diff --git a/src/openhuman/web3/wallet/primitives/key/mod.rs b/src/openhuman/web3/wallet/primitives/key/mod.rs deleted file mode 100644 index c254469dc0..0000000000 --- a/src/openhuman/web3/wallet/primitives/key/mod.rs +++ /dev/null @@ -1,248 +0,0 @@ -//! Deterministic key derivation from a BIP-39 mnemonic. -//! -//! [`derive()`] turns a mnemonic and a derivation path into the signing key and -//! address for one chain. It is a pure function: same inputs, same key, every -//! time, with no I/O and no global state. -//! -//! ## This crate derives keys; it does not keep them -//! -//! Nothing here reads or writes a keychain, a file, or an environment -//! variable, and no key is cached between calls. Custody is deliberately the -//! host's problem: where the mnemonic is sealed, what unlocks it, whether the -//! user is prompted, and how long a decrypted phrase may live in memory are -//! all policy decisions that depend on the host's threat model, and a library -//! that quietly picked an answer would be picking it for every host. -//! -//! The consequence for a caller is that the mnemonic arrives as a `&str` the -//! host already decrypted, and this crate's job is to touch it briefly and -//! forget it. -//! -//! ## Two derivation algorithms, not one -//! -//! | Chain | Curve | Scheme | -//! | --- | --- | --- | -//! | Bitcoin | secp256k1 | BIP-32 | -//! | EVM | secp256k1 | BIP-32 | -//! | Tron | secp256k1 | BIP-32 | -//! | Solana | ed25519 | SLIP-0010, hardened-only | -//! -//! The split is forced by the curve. BIP-32's non-hardened derivation needs -//! public-key addition, which ed25519 does not offer, so SLIP-0010 defines -//! hardened-only derivation for it. That is why [`Error::UnhardenedSolanaPath`] -//! exists: a path like `m/44'/501'/0'/0` is not merely unsupported here, it is -//! underivable, and accepting it by silently hardening the last segment would -//! hand back a *different account* than the path names. - -use zeroize::Zeroizing; - -use crate::openhuman::web3::wallet::primitives::chain::Chain; - -mod bip32; -mod slip10; - -#[cfg(feature = "web3")] -mod btc; -#[cfg(feature = "web3")] -mod evm; -#[cfg(feature = "web3")] -mod solana; -#[cfg(feature = "web3")] -mod tron; - -/// Errors raised while deriving a key. -/// -/// Every variant names the failing *step*. None carries key material, a seed, -/// or any part of a mnemonic — an error string is the single easiest way for a -/// secret to escape into a log, so nothing secret is ever put in one. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[non_exhaustive] -pub enum Error { - /// The mnemonic is not a valid BIP-39 phrase — wrong word count, a word - /// outside the wordlist, or a failed checksum. - /// - /// Deliberately carries no detail beyond this. The underlying error can - /// quote the offending word, which is one twelfth of a seed phrase. - #[error("invalid BIP-39 mnemonic")] - InvalidMnemonic, - - /// The derivation path is not well-formed. - #[error("invalid derivation path '{path}': {reason}")] - InvalidPath { - /// The rejected path. A path is not secret — it is public metadata - /// about which account was meant. - path: String, - /// Why it was rejected. - reason: String, - }, - - /// A Solana path contains a non-hardened segment. - /// - /// Separate from [`Error::InvalidPath`] because it is not a typo: the path - /// is syntactically fine and simply cannot be derived on ed25519. See the - /// module docs — silently hardening it would return a different account - /// than the caller asked for. - #[error( - "Solana path '{path}' has a non-hardened segment; ed25519 (SLIP-0010) \ - supports hardened derivation only, so every segment needs a trailing '" - )] - UnhardenedSolanaPath { - /// The rejected path. - path: String, - }, - - /// Key derivation failed arithmetically. - /// - /// Essentially unreachable in practice: BIP-32 specifies retrying with the - /// next index when a derived scalar falls outside the curve order, and the - /// odds of hitting that are negligible. It is a variant rather than a panic - /// because a wallet must not abort the process over it. - #[error("key derivation failed at {step}")] - Derivation { - /// Which step failed. - step: &'static str, - }, - - /// The chain's feature gate was disabled when this crate was built. - /// - /// A build fact, not a property of the inputs — the same reasoning as - /// [`crate::openhuman::web3::wallet::primitives::Error::ChainNotCompiled`]. - #[error( - "tinywallet was built without support for {chain}; \ - enable the '{chain}' feature to derive its keys" - )] - ChainNotCompiled { - /// The chain whose gate is disabled. - chain: Chain, - }, -} - -/// Result alias for key derivation. -pub type Result = std::result::Result; - -/// A derived signing key and the address it controls. -/// -/// The secret is held in [`Zeroizing`], so dropping this wipes it rather than -/// leaving it in freed memory for whatever allocates there next. -/// -/// `Debug` is implemented by hand and prints only the chain and address. -/// Deriving it would put raw key material into every `{:?}`, every -/// `unwrap()` panic message, and every log line that formats a struct -/// containing one — which is exactly how a private key ends up in a bug -/// report. -pub struct DerivedKey { - chain: Chain, - address: String, - secret: Zeroizing>, -} - -impl DerivedKey { - /// Build a derived key. Internal: the per-chain modules construct these. - fn new(chain: Chain, address: String, secret: Vec) -> Self { - Self { - chain, - address, - secret: Zeroizing::new(secret), - } - } - - /// The chain this key is for. - #[must_use] - pub const fn chain(&self) -> Chain { - self.chain - } - - /// The address this key controls, in the chain's canonical text form. - #[must_use] - pub fn address(&self) -> &str { - &self.address - } - - /// The raw secret key bytes. - /// - /// 32 bytes on every supported chain. Treat the returned slice as live key - /// material: do not copy it into a `String`, a log, or an error. It is - /// borrowed rather than returned by value so it cannot outlive the - /// zeroizing owner. - #[must_use] - pub fn secret_bytes(&self) -> &[u8] { - &self.secret - } -} - -impl std::fmt::Debug for DerivedKey { - /// Prints the chain and address only. See the type docs: a derived `Debug` - /// here would leak key material into panic messages and logs. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("DerivedKey") - .field("chain", &self.chain) - .field("address", &self.address) - .field("secret", &"") - .finish() - } -} - -/// Derive the signing key and address for `chain` from `mnemonic` at `path`. -/// -/// `mnemonic` is a BIP-39 phrase the host has already decrypted; it is used -/// for the duration of the call and not retained. `path` is a BIP-32 style -/// derivation path (`m/44'/60'/0'/0/0`). -/// -/// # Errors -/// -/// - [`Error::InvalidMnemonic`] if the phrase is not valid BIP-39. -/// - [`Error::InvalidPath`] if the path is malformed. -/// - [`Error::UnhardenedSolanaPath`] for a Solana path with a non-hardened -/// segment — see the module docs for why that is its own variant. -/// - [`Error::ChainNotCompiled`] if `chain`'s feature gate is off. -/// -/// # Examples -/// -/// ```ignore -/// # #[cfg(feature = "web3")] { -/// use crate::openhuman::web3::wallet::primitives::{key, Chain}; -/// -/// // The BIP-39 test vector mnemonic. Never use it for real funds. -/// let phrase = "abandon abandon abandon abandon abandon abandon \ -/// abandon abandon abandon abandon abandon about"; -/// let derived = key::derive(Chain::Evm, phrase, "m/44'/60'/0'/0/0")?; -/// -/// assert_eq!(derived.address(), "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"); -/// // Debug never prints the secret. -/// assert!(format!("{derived:?}").contains("")); -/// # } -/// # Ok::<(), crate::openhuman::web3::wallet::primitives::key::Error>(()) -/// ``` -pub fn derive(chain: Chain, mnemonic: &str, path: &str) -> Result { - match chain { - #[cfg(feature = "web3")] - Chain::Btc => btc::derive(mnemonic, path), - #[cfg(feature = "web3")] - Chain::Evm => evm::derive(mnemonic, path), - #[cfg(feature = "web3")] - Chain::Solana => solana::derive(mnemonic, path), - #[cfg(feature = "web3")] - Chain::Tron => tron::derive(mnemonic, path), - #[allow(unreachable_patterns)] - other => Err(Error::ChainNotCompiled { chain: other }), - } -} - -/// Turn a BIP-39 phrase into its 64-byte seed. -/// -/// Shared by every chain: the seed is scheme-independent, and only what -/// happens after it differs. The result zeroizes on drop. -fn seed_from_mnemonic(mnemonic: &str) -> Result>> { - use coins_bip39::{English, Mnemonic}; - - // The error is discarded on purpose: `coins_bip39` reports which word - // failed the wordlist check, and a word is one twelfth of a seed phrase. - let parsed: Mnemonic = mnemonic - .trim() - .parse() - .map_err(|_| Error::InvalidMnemonic)?; - let seed = parsed.to_seed(None).map_err(|_| Error::InvalidMnemonic)?; - Ok(Zeroizing::new(seed.to_vec())) -} - -#[cfg(test)] -mod test; diff --git a/src/openhuman/web3/wallet/primitives/key/slip10.rs b/src/openhuman/web3/wallet/primitives/key/slip10.rs deleted file mode 100644 index dfea18d1b5..0000000000 --- a/src/openhuman/web3/wallet/primitives/key/slip10.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! SLIP-0010 hardened-only derivation on ed25519, used by Solana. -//! -//! ed25519 cannot do BIP-32's non-hardened derivation: that step needs -//! public-key addition, which the curve's key format does not support. SLIP-0010 -//! defines the hardened-only variant instead, and this is it — about twenty -//! lines of HMAC-SHA512, with no scalar arithmetic and so no failure mode -//! beyond a malformed path. -//! -//! Solana wallets standardise on `m/44'/501'/N'/0'`, which is fully hardened, -//! so the restriction costs nothing in practice. - -use hmac::{Hmac, Mac}; -use sha2::Sha512; -use zeroize::Zeroizing; - -use super::{Error, Result}; - -type HmacSha512 = Hmac; - -/// The domain-separation key SLIP-0010 specifies for ed25519. -const CURVE_SEED: &[u8] = b"ed25519 seed"; - -/// Derive the 32-byte ed25519 secret for `path` from `seed`. -/// -/// `path` must be fully hardened. Each index is OR-ed with `0x8000_0000` -/// regardless, but [`parse_path`] rejects an unhardened segment first — see -/// [`Error::UnhardenedSolanaPath`] for why that is not silently tolerated. -pub(super) fn derive(seed: &[u8], path: &str) -> Result> { - let indices = parse_path(path)?; - - let mut mac = HmacSha512::new_from_slice(CURVE_SEED).map_err(|_| Error::Derivation { - step: "SLIP-0010 master HMAC", - })?; - mac.update(seed); - let digest = mac.finalize().into_bytes(); - - let mut key = Zeroizing::new([0u8; 32]); - let mut chain_code = Zeroizing::new([0u8; 32]); - key.copy_from_slice(&digest[..32]); - chain_code.copy_from_slice(&digest[32..]); - - for index in indices { - let hardened = index | 0x8000_0000; - let mut mac = - HmacSha512::new_from_slice(chain_code.as_slice()).map_err(|_| Error::Derivation { - step: "SLIP-0010 child HMAC", - })?; - // The leading zero byte is what marks this as the hardened form. - mac.update(&[0u8]); - mac.update(key.as_slice()); - mac.update(&hardened.to_be_bytes()); - let digest = mac.finalize().into_bytes(); - key.copy_from_slice(&digest[..32]); - chain_code.copy_from_slice(&digest[32..]); - } - - Ok(key) -} - -/// Parse a fully hardened path into its indices. -/// -/// # Errors -/// -/// [`Error::InvalidPath`] if the path does not start at `m`, has no segments, -/// or holds a non-numeric index. [`Error::UnhardenedSolanaPath`] if any segment -/// lacks its trailing apostrophe. -fn parse_path(path: &str) -> Result> { - let trimmed = path.trim(); - let mut segments = trimmed.split('/'); - - if segments.next() != Some("m") { - return Err(Error::InvalidPath { - path: path.to_string(), - reason: "must start with 'm'".to_string(), - }); - } - - let mut out = Vec::new(); - for segment in segments { - let Some(index) = segment.strip_suffix('\'') else { - return Err(Error::UnhardenedSolanaPath { - path: path.to_string(), - }); - }; - let index = index.parse::().map_err(|e| Error::InvalidPath { - path: path.to_string(), - reason: format!("segment '{segment}': {e}"), - })?; - if index >= 0x8000_0000 { - return Err(Error::InvalidPath { - path: path.to_string(), - reason: format!("segment '{segment}' exceeds the maximum raw index"), - }); - } - out.push(index); - } - - if out.is_empty() { - return Err(Error::InvalidPath { - path: path.to_string(), - reason: "has no segments".to_string(), - }); - } - Ok(out) -} diff --git a/src/openhuman/web3/wallet/primitives/key/solana.rs b/src/openhuman/web3/wallet/primitives/key/solana.rs deleted file mode 100644 index 4ca8b729a2..0000000000 --- a/src/openhuman/web3/wallet/primitives/key/solana.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Solana key derivation: SLIP-0010 on ed25519, address is the public key. - -use ed25519_dalek::SigningKey; - -use super::{seed_from_mnemonic, slip10, DerivedKey, Result}; -use crate::openhuman::web3::wallet::primitives::chain::Chain; - -/// Derive the Solana signing key and address for `path`. -/// -/// A Solana address *is* the ed25519 public key in base58 — there is no hash -/// and no version byte, unlike every other chain here. -pub(super) fn derive(mnemonic: &str, path: &str) -> Result { - let seed = seed_from_mnemonic(mnemonic)?; - let secret = slip10::derive(&seed, path)?; - let signing = SigningKey::from_bytes(&secret); - let address = crate::openhuman::web3::wallet::primitives::address::solana::encode( - &signing.verifying_key().to_bytes(), - ); - Ok(DerivedKey::new(Chain::Solana, address, secret.to_vec())) -} diff --git a/src/openhuman/web3/wallet/primitives/key/test.rs b/src/openhuman/web3/wallet/primitives/key/test.rs deleted file mode 100644 index efe8914c62..0000000000 --- a/src/openhuman/web3/wallet/primitives/key/test.rs +++ /dev/null @@ -1,294 +0,0 @@ -//! Unit tests for key derivation. -//! -//! These are pinned against the canonical BIP-39 test-vector mnemonic and the -//! addresses every mainstream wallet derives from it. That matters more here -//! than in most test suites: a derivation bug does not crash, it produces a -//! *valid key for the wrong account*, and the only way to catch that is to -//! compare against an address derived independently by other software. -//! -//! The mnemonic below is the published all-`abandon` test vector. It is public, -//! and its accounts have been swept continuously for years — never put funds -//! in an address derived from it. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::{derive, Error}; -use crate::openhuman::web3::wallet::primitives::chain::Chain; - -/// The canonical BIP-39 test vector: 11 × "abandon" + "about". -const VECTOR: &str = "abandon abandon abandon abandon abandon abandon \ - abandon abandon abandon abandon abandon about"; - -/// Standard first-account path per chain, matching each ecosystem's default. -const EVM_PATH: &str = "m/44'/60'/0'/0/0"; -const BTC_PATH: &str = "m/84'/0'/0'/0/0"; -const TRON_PATH: &str = "m/44'/195'/0'/0/0"; -const SOLANA_PATH: &str = "m/44'/501'/0'/0'"; - -#[test] -fn evm_matches_the_published_test_vector() { - // This is the address MetaMask, Trust and every EIP-55 tool derive from - // the vector mnemonic at the standard Ethereum path. - let key = derive(Chain::Evm, VECTOR, EVM_PATH).unwrap(); - assert_eq!(key.address(), "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"); - assert_eq!(key.chain(), Chain::Evm); - assert_eq!(key.secret_bytes().len(), 32); -} - -#[test] -fn btc_derives_the_published_native_segwit_vector() { - // BIP-84's own test vector for account 0, first receive address. - let key = derive(Chain::Btc, VECTOR, BTC_PATH).unwrap(); - assert_eq!(key.address(), "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu"); - assert!( - key.address().starts_with("bc1q"), - "must be P2WPKH — the only type this crate can sign for" - ); -} - -#[test] -fn btc_derives_an_address_its_own_sender_rule_accepts() { - // The derived address has to satisfy `validate_sender`, not merely - // `validate`. Deriving a P2PKH here would pass recipient validation and - // then fail at signing time. - let key = derive(Chain::Btc, VECTOR, BTC_PATH).unwrap(); - assert!( - crate::openhuman::web3::wallet::primitives::address::btc::validate_sender(key.address()) - .is_ok() - ); -} - -#[test] -fn solana_derives_the_published_vector() { - let key = derive(Chain::Solana, VECTOR, SOLANA_PATH).unwrap(); - assert_eq!( - key.address(), - "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" - ); - assert_eq!(key.chain(), Chain::Solana); -} - -#[test] -fn every_chain_derives_an_address_its_own_validator_accepts() { - // Cheap end-to-end coupling check between `key` and `address`: a - // derivation that produced a malformed address would be caught here even - // without a published vector to compare against. - for (chain, path) in [ - (Chain::Evm, EVM_PATH), - (Chain::Btc, BTC_PATH), - (Chain::Tron, TRON_PATH), - (Chain::Solana, SOLANA_PATH), - ] { - let key = derive(chain, VECTOR, path).unwrap(); - assert!( - crate::openhuman::web3::wallet::primitives::address::validate(chain, key.address()) - .is_ok(), - "{chain} derived an address its own validator rejects: {}", - key.address() - ); - assert_eq!(key.chain(), chain); - assert_eq!(key.secret_bytes().len(), 32, "{chain} secret length"); - } -} - -#[test] -fn tron_derives_a_mainnet_address_not_an_evm_one() { - // Tron reuses Ethereum's address construction then re-encodes it, so the - // easy bug is emitting the 20-byte EVM form. It must be 21 bytes with the - // 0x41 version prefix, in base58check. - let key = derive(Chain::Tron, VECTOR, TRON_PATH).unwrap(); - assert!( - key.address().starts_with('T'), - "expected base58check Tron form, got {}", - key.address() - ); - let decoded = - crate::openhuman::web3::wallet::primitives::address::tron::decode(key.address()).unwrap(); - assert_eq!(decoded.len(), 21); - assert_eq!( - decoded[0], - crate::openhuman::web3::wallet::primitives::address::tron::MAINNET_PREFIX - ); -} - -#[test] -fn evm_and_tron_share_a_key_but_not_an_address() { - // Both are secp256k1 + Keccak, so at the same path the secret is identical - // and only the encoding differs. Pinning this documents why Tron support - // costs almost nothing beyond an encoder. - let evm = derive(Chain::Evm, VECTOR, EVM_PATH).unwrap(); - let tron = derive(Chain::Tron, VECTOR, EVM_PATH).unwrap(); - assert_eq!(evm.secret_bytes(), tron.secret_bytes()); - assert_ne!(evm.address(), tron.address()); -} - -#[test] -fn derivation_is_deterministic() { - for (chain, path) in [(Chain::Evm, EVM_PATH), (Chain::Solana, SOLANA_PATH)] { - let first = derive(chain, VECTOR, path).unwrap(); - let second = derive(chain, VECTOR, path).unwrap(); - assert_eq!(first.address(), second.address()); - assert_eq!(first.secret_bytes(), second.secret_bytes()); - } -} - -#[test] -fn a_different_path_yields_a_different_account() { - let first = derive(Chain::Evm, VECTOR, "m/44'/60'/0'/0/0").unwrap(); - let second = derive(Chain::Evm, VECTOR, "m/44'/60'/0'/0/1").unwrap(); - assert_ne!(first.address(), second.address()); - assert_ne!(first.secret_bytes(), second.secret_bytes()); -} - -#[test] -fn the_mnemonic_is_trimmed_not_rejected_for_surrounding_whitespace() { - let padded = format!(" {VECTOR}\n"); - let key = derive(Chain::Evm, &padded, EVM_PATH).unwrap(); - assert_eq!(key.address(), "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"); -} - -#[test] -fn an_invalid_mnemonic_is_rejected_without_quoting_it() { - // The error must not echo any part of the phrase — an error string is the - // easiest way for a secret to reach a log. - let bad = "abandon abandon notaword abandon abandon abandon \ - abandon abandon abandon abandon abandon about"; - let err = derive(Chain::Evm, bad, EVM_PATH).unwrap_err(); - assert_eq!(err, Error::InvalidMnemonic); - let rendered = err.to_string(); - assert!(!rendered.contains("notaword"), "leaked a word: {rendered}"); - assert!(!rendered.contains("abandon"), "leaked a word: {rendered}"); -} - -#[test] -fn a_wrong_length_mnemonic_is_rejected() { - assert_eq!( - derive(Chain::Evm, "abandon about", EVM_PATH).unwrap_err(), - Error::InvalidMnemonic - ); -} - -#[test] -fn a_malformed_path_is_rejected_and_names_the_path() { - // A path is public metadata about which account was meant, so unlike the - // mnemonic it is safe — and useful — to echo. - match derive(Chain::Evm, VECTOR, "not-a-path").unwrap_err() { - Error::InvalidPath { path, .. } => assert_eq!(path, "not-a-path"), - other => panic!("expected InvalidPath, got {other:?}"), - } -} - -#[test] -fn an_unhardened_solana_path_is_rejected_rather_than_silently_hardened() { - // The heart of the SLIP-0010 restriction: this path is syntactically fine - // and simply cannot be derived on ed25519. Hardening it silently would - // return a DIFFERENT account than the caller named, which is the failure - // this variant exists to prevent. - match derive(Chain::Solana, VECTOR, "m/44'/501'/0'/0").unwrap_err() { - Error::UnhardenedSolanaPath { path } => assert_eq!(path, "m/44'/501'/0'/0"), - other => panic!("expected UnhardenedSolanaPath, got {other:?}"), - } -} - -#[test] -fn a_solana_path_with_no_segments_is_rejected() { - assert!(matches!( - derive(Chain::Solana, VECTOR, "m").unwrap_err(), - Error::InvalidPath { .. } - )); -} - -#[test] -fn a_solana_path_not_starting_at_m_is_rejected() { - assert!(matches!( - derive(Chain::Solana, VECTOR, "44'/501'/0'/0'").unwrap_err(), - Error::InvalidPath { .. } - )); -} - -#[test] -fn a_solana_path_with_a_non_numeric_segment_is_rejected() { - match derive(Chain::Solana, VECTOR, "m/44'/not-an-index'/0'").unwrap_err() { - Error::InvalidPath { path, reason } => { - assert_eq!(path, "m/44'/not-an-index'/0'"); - assert!(reason.contains("not-an-index"), "{reason}"); - } - other => panic!("expected InvalidPath, got {other:?}"), - } -} - -#[test] -fn a_solana_path_with_an_already_hardened_index_is_rejected() { - assert!(matches!( - derive(Chain::Solana, VECTOR, "m/44'/501'/2147483648'").unwrap_err(), - Error::InvalidPath { .. } - )); -} - -#[test] -fn derivation_backend_failures_remain_specific_without_leaking_inputs() { - // Drives the real derivation path rather than the backend's error mapper. - // The previous version of this test called two private helpers with a - // hand-built `bitcoin::bip32::Error`; both are gone, and one of them — - // the uncompressed-public-key mapper — no longer has a reachable failure - // mode at all, because the address is now encoded from the compressed - // SEC1 point directly. Asserting on behaviour instead means this test - // survives the next backend swap the way it did not survive this one. - // - // BIP-32 depth is a single byte, so a path past 255 levels cannot be - // walked. This must be a clean refusal: the `coins-bip32` backend - // increments its depth counter unguarded, so without tinywallet's own - // bound this input panics in debug and — far worse — silently wraps in - // release, deriving a real key at the wrong depth. - let too_deep = format!("m/{}", vec!["0"; 256].join("/")); - let error = derive(Chain::Btc, VECTOR, &too_deep).unwrap_err(); - - match &error { - Error::InvalidPath { path, reason } => { - assert_eq!(path, &too_deep); - assert!(reason.contains("255"), "{reason}"); - } - other => panic!("expected InvalidPath for an over-deep path, got {other:?}"), - } - - // The depth just under the limit must still derive, so the bound is a - // guard rather than an off-by-one that rejects legitimate paths. - let deepest = format!("m/{}", vec!["0"; 255].join("/")); - assert!( - derive(Chain::Btc, VECTOR, &deepest).is_ok(), - "255 levels is the documented maximum and must still derive" - ); - - // The whole point of collapsing backend errors into a fixed `step` string: - // the mnemonic and the path must not ride out inside the message. - let rendered = error.to_string(); - for secret in VECTOR.split_whitespace() { - assert!( - !rendered.contains(secret), - "derivation error leaked mnemonic word '{secret}': {rendered}" - ); - } -} - -#[test] -fn debug_never_prints_key_material() { - // A derived Debug here would put a private key into every panic message - // and every log line that formats a struct containing one. - let key = derive(Chain::Evm, VECTOR, EVM_PATH).unwrap(); - let rendered = format!("{key:?}"); - - assert!(rendered.contains(""), "{rendered}"); - assert!(rendered.contains(key.address()), "address is safe to show"); - - // The secret must not appear in any plausible encoding. - let hex = key.secret_bytes().iter().fold(String::new(), |mut out, b| { - use std::fmt::Write as _; - let _ = write!(out, "{b:02x}"); - out - }); - assert!(!rendered.contains(&hex), "leaked the secret as hex"); - assert!( - !rendered.contains(&format!("{:?}", key.secret_bytes())), - "leaked the secret as a byte slice" - ); -} diff --git a/src/openhuman/web3/wallet/primitives/key/tron.rs b/src/openhuman/web3/wallet/primitives/key/tron.rs deleted file mode 100644 index 9105dc8613..0000000000 --- a/src/openhuman/web3/wallet/primitives/key/tron.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! Tron key derivation: BIP-32 on secp256k1, address via Keccak-256 plus the -//! `0x41` version byte and a base58check envelope. -//! -//! Identical to EVM up to the Keccak hash — Tron reuses Ethereum's address -//! construction and then re-encodes it. That similarity is a trap worth naming: -//! the hex form of a Tron address looks like an EVM address but is 21 bytes, -//! not 20, because of the version prefix. - -use sha3::{Digest, Keccak256}; - -use super::{bip32, seed_from_mnemonic, DerivedKey, Error, Result}; -use crate::openhuman::web3::wallet::primitives::address::tron::{ADDRESS_BYTES, MAINNET_PREFIX}; -use crate::openhuman::web3::wallet::primitives::chain::Chain; - -/// Derive the Tron signing key and address for `path`. -pub(super) fn derive(mnemonic: &str, path: &str) -> Result { - let seed = seed_from_mnemonic(mnemonic)?; - let key = bip32::derive(&seed, path)?; - let address = address_from_public(&key.uncompressed_public())?; - Ok(DerivedKey::new( - Chain::Tron, - address, - key.secret_bytes().to_vec(), - )) -} - -/// Keccak-256 the uncompressed public key without its `0x04` prefix, take the -/// last 20 bytes, prepend the Tron mainnet version byte, and base58check it. -/// -/// `encode` verifies the version byte and so returns a `Result`. It cannot -/// fail here — the prefix is written two lines above — but the error is mapped -/// rather than unwrapped, because a panic in key derivation would take a -/// wallet down over an unreachable branch. -fn address_from_public(uncompressed: &[u8; 65]) -> Result { - let hash = Keccak256::digest(&uncompressed[1..]); - let mut bytes = [0u8; ADDRESS_BYTES]; - bytes[0] = MAINNET_PREFIX; - bytes[1..].copy_from_slice(&hash[12..]); - crate::openhuman::web3::wallet::primitives::address::tron::encode(&bytes).map_err(|_| { - Error::Derivation { - step: "Tron address encoding", - } - }) -} diff --git a/src/openhuman/web3/wallet/primitives/mod.rs b/src/openhuman/web3/wallet/primitives/mod.rs deleted file mode 100644 index 7fd1299095..0000000000 --- a/src/openhuman/web3/wallet/primitives/mod.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Agent-friendly multi-chain wallet primitives in Rust. -//! -//! `tinywallet` owns the parts of wallet handling that are pure: address -//! formats, their validation, and the conversions between their encodings. -//! Bitcoin, EVM chains, Solana, and Tron each get a module, and -//! [`address::validate`] dispatches across them for chain-generic callers. -//! -//! # What this crate deliberately does not do -//! -//! No network access, no RPC endpoints, no key storage, no transaction -//! broadcasting. Every function here is a deterministic pure function of its -//! arguments. -//! -//! That is the seam, not a gap. Endpoint selection, retry policy, and key -//! custody are things a host must own — they depend on its config, its threat -//! model, and its runtime — and a crate that guessed at any of them would be -//! wrong for every host that guessed differently. What is left is the part -//! that is genuinely the same everywhere, which is exactly what belongs in a -//! shared crate. -//! -//! # Example -//! -//! ```ignore -//! # #[cfg(all(feature = "web3", feature = "web3"))] { -//! use crate::openhuman::web3::wallet::primitives::{address, chain::Chain}; -//! -//! // Chain-generic dispatch. -//! let addr = address::validate(Chain::Btc, "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")?; -//! -//! // Or reach for a chain's own module when you need more than validation. -//! let hex = address::tron::to_hex("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t")?; -//! assert!(hex.starts_with("41")); -//! # } -//! # Ok::<(), crate::openhuman::web3::wallet::primitives::Error>(()) -//! ``` -//! -//! # Feature flags -//! -//! Every chain is a separate default-on gate, so a host that only needs one -//! chain does not pay for the others' parsers. -//! -//! | Feature | Default | Gates | -//! | --- | --- | --- | -//! | `btc` | on | Bitcoin addresses (pulls `bitcoin`) | -//! | `evm` | on | EVM addresses (no dependencies) | -//! | `solana` | on | Solana addresses (pulls `bs58`) | -//! | `tron` | on | Tron addresses (pulls `bs58`, `hex`) | -//! | `keccak` | on | EIP-55 checksums for EVM (pulls `sha3`) | -//! | `net` | on | the `rpc::Transport` network seam (pulls `async-trait`) | -//! | `key` | on | BIP-39/BIP-32/SLIP-0010 key derivation (`crate::openhuman::web3::wallet::primitives::key`) | -//! | `asset` | on | network and token reference data (`crate::openhuman::web3::wallet::primitives::asset`) | -//! | `client` | on | chain queries over the seam (`crate::openhuman::web3::wallet::primitives::client`) | -//! | `tx` | on | transaction building and signing (`crate::openhuman::web3::wallet::primitives::tx`) | -//! | `x402` | on | x402 machine-payment wire types (`crate::openhuman::web3::wallet::primitives::x402`) | - -mod error; - -#[cfg(feature = "web3")] -pub mod abi; -pub mod address; -pub mod chain; -#[cfg(feature = "web3")] -pub mod eip712; -#[cfg(feature = "web3")] -pub mod key; -#[cfg(feature = "web3")] -pub mod rpc; -#[cfg(feature = "web3")] -pub mod wire; -#[cfg(feature = "web3")] -pub mod x402; - -pub use chain::Chain; -pub use error::{Error, Result}; diff --git a/src/openhuman/web3/wallet/primitives/rpc/mod.rs b/src/openhuman/web3/wallet/primitives/rpc/mod.rs deleted file mode 100644 index 3bcd39ecd4..0000000000 --- a/src/openhuman/web3/wallet/primitives/rpc/mod.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! The network seam: [`Transport`], the trait a host implements so this crate -//! can reach a chain without owning an HTTP client. -//! -//! Everything else in `tinywallet` is a pure function. Chain work is not — a -//! balance, a nonce, a UTXO set and a broadcast all require a network round -//! trip — so the chain modules take a `&dyn Transport` and the host supplies -//! it. -//! -//! ## What the host keeps, and why the trait names a network rather than a URL -//! -//! No method here accepts a URL. That is the whole point of the seam: endpoint -//! selection is a host concern that this crate must not quietly take over. -//! A host typically resolves an endpoint from its own config, allows an -//! operator to override it per chain, fails over across an ordered list when -//! one is unreachable, and redacts the URL before it reaches a log. Every one -//! of those depends on the host's configuration and deployment, and a crate -//! that hardcoded even a default endpoint would silently route a user's -//! transactions through whichever provider this crate's author happened to -//! pick. -//! -//! So the division is: -//! -//! | This crate | The host | -//! | --- | --- | -//! | which RPC method, with which params | which endpoint answers it | -//! | how to encode and sign the payload | failover, retries, timeouts | -//! | what a response means | connection pooling, TLS, redaction in logs | -//! -//! ## Errors are split by retryability, and that distinction is load-bearing -//! -//! [`TransportError`] separates an endpoint being unreachable from a healthy -//! endpoint returning an authoritative error. A host that fails over across -//! endpoints must advance on the first and stop dead on the second: retrying a -//! genuine "insufficient funds" against three more endpoints yields the same -//! answer three more times, and — far worse — retrying an *ambiguous* failure -//! risks broadcasting a transaction twice. Collapsing the two into one error -//! type is how a failover loop turns a declined transaction into a -//! double-spend, so the distinction is in the type rather than left to a -//! string match on the message. - -use async_trait::async_trait; -use serde_json::Value; - -use crate::openhuman::web3::wallet::primitives::chain::Chain; - -/// Identifies which network a request is bound for. -/// -/// A bare [`Chain`] is not enough for EVM: Ethereum, Base, Polygon and Arbitrum -/// share an address format and an RPC dialect but are different networks with -/// different endpoints. The EIP-155 chain id is the universal discriminator, so -/// it is what this carries — a host's own network enum does not have to leak -/// into this crate for it to say which network it meant. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct NetworkId { - /// The chain family. - pub chain: Chain, - /// EIP-155 chain id, for [`Chain::Evm`] only. - /// - /// `None` on every other chain, and on EVM when the caller genuinely means - /// "the host's default EVM network" rather than a specific one. - pub evm_chain_id: Option, -} - -impl NetworkId { - /// A non-EVM network, identified by its chain alone. - #[must_use] - pub const fn chain(chain: Chain) -> Self { - Self { - chain, - evm_chain_id: None, - } - } - - /// A specific EVM network, by EIP-155 chain id. - #[must_use] - pub const fn evm(chain_id: u64) -> Self { - Self { - chain: Chain::Evm, - evm_chain_id: Some(chain_id), - } - } -} - -impl std::fmt::Display for NetworkId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self.evm_chain_id { - Some(id) => write!(f, "{}:{id}", self.chain), - None => write!(f, "{}", self.chain), - } - } -} - -/// A transport failure, split by whether retrying elsewhere could help. -/// -/// See the module docs: this distinction is what lets a host fail over safely, -/// and collapsing it is how a retry loop causes a double broadcast. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[non_exhaustive] -pub enum TransportError { - /// The endpoint could not be reached or did not answer usefully — DNS - /// failure, connection refused, a timeout, a 5xx, an unparseable body. - /// - /// **Safe to retry against another endpoint** *for a read*. A host must - /// still not blindly retry a broadcast on this: a request that timed out - /// may well have been accepted. - #[error("transport failure contacting {network}: {message}")] - Unreachable { - /// The network the request was bound for. - network: NetworkId, - /// What went wrong. - message: String, - }, - - /// A healthy endpoint answered with an error — an invalid transaction, - /// insufficient funds, a rejected signature, a malformed request. - /// - /// **Never retry this elsewhere.** It is the network's real answer, and - /// another endpoint will give the same one. - #[error("{network} returned an error: {message}")] - Rpc { - /// The network that answered. - network: NetworkId, - /// The error the node reported. - message: String, - }, -} - -impl TransportError { - /// Whether trying a different endpoint could plausibly produce a different - /// answer. - /// - /// True only for [`TransportError::Unreachable`]. Note this answers - /// "could the *result* differ", not "is retrying safe" — a broadcast that - /// timed out may already have been accepted, so a host must decide that - /// separately. - #[must_use] - pub const fn is_retryable(&self) -> bool { - matches!(self, Self::Unreachable { .. }) - } - - /// The network this failure relates to. - #[must_use] - pub const fn network(&self) -> NetworkId { - match self { - Self::Unreachable { network, .. } | Self::Rpc { network, .. } => *network, - } - } -} - -/// Result alias for transport operations. -pub type TransportResult = std::result::Result; - -/// The network seam a host implements. -/// -/// Implementations are shared across concurrent chain operations, hence -/// `Send + Sync`. A host is expected to hold one long-lived HTTP client behind -/// this rather than building one per call, since rebuilding a TLS connector -/// per request also discards connection pooling. -/// -/// # Implementing -/// -/// ```ignore -/// use async_trait::async_trait; -/// use serde_json::Value; -/// use crate::openhuman::web3::wallet::primitives::rpc::{NetworkId, Transport, TransportError, TransportResult}; -/// -/// struct MyTransport; -/// -/// #[async_trait] -/// impl Transport for MyTransport { -/// async fn json_rpc( -/// &self, -/// network: NetworkId, -/// method: &str, -/// _params: Value, -/// ) -> TransportResult { -/// // Resolve `network` to an endpoint from your own config, POST the -/// // JSON-RPC envelope, and map a node-level `error` member onto -/// // TransportError::Rpc rather than Unreachable. -/// Err(TransportError::Unreachable { -/// network, -/// message: format!("{method}: not wired up"), -/// }) -/// } -/// -/// async fn rest_get(&self, network: NetworkId, path: &str) -> TransportResult { -/// Err(TransportError::Unreachable { network, message: path.to_string() }) -/// } -/// -/// async fn rest_post( -/// &self, -/// network: NetworkId, -/// path: &str, -/// _body: String, -/// _content_type: &str, -/// ) -> TransportResult { -/// Err(TransportError::Unreachable { network, message: path.to_string() }) -/// } -/// } -/// ``` -#[async_trait] -pub trait Transport: Send + Sync { - /// Perform a JSON-RPC call and return the `result` member. - /// - /// Used by EVM and Solana. The implementation wraps `method` and `params` - /// in the JSON-RPC envelope, sends it to whichever endpoint serves - /// `network`, and returns the `result` member on success. - /// - /// # Errors - /// - /// [`TransportError::Rpc`] when the node answers with an `error` member — - /// this is an authoritative answer and must not be retried elsewhere. - /// [`TransportError::Unreachable`] for anything that prevented getting an - /// answer at all. - async fn json_rpc( - &self, - network: NetworkId, - method: &str, - params: Value, - ) -> TransportResult; - - /// Perform a REST GET and return the raw body. - /// - /// Used by Bitcoin (Esplora) and Tron (`TronGrid`), whose APIs are REST - /// rather than JSON-RPC. `path` is relative to whatever base the host has - /// configured for `network`, without a leading slash. - /// - /// # Errors - /// - /// As [`Transport::json_rpc`]. A non-2xx status is - /// [`TransportError::Rpc`] when the body carries the API's own error and - /// [`TransportError::Unreachable`] when it does not. - async fn rest_get(&self, network: NetworkId, path: &str) -> TransportResult; - - /// Perform a REST POST and return the raw body. - /// - /// `path` is relative to the host's configured base for `network`, without - /// a leading slash. `content_type` is passed because these APIs are not - /// uniform: Esplora takes a raw transaction as `text/plain`, while - /// `TronGrid` expects `application/json`. - /// - /// # Errors - /// - /// As [`Transport::json_rpc`]. - async fn rest_post( - &self, - network: NetworkId, - path: &str, - body: String, - content_type: &str, - ) -> TransportResult; -} - -/// Deserialize a JSON-RPC `result` into a typed value. -/// -/// A small helper so every chain module does not repeat the same -/// `serde_json::from_value` plus error-mapping dance. A body that does not -/// match the expected shape is [`TransportError::Rpc`], not `Unreachable`: -/// the endpoint answered, it simply did not answer what was asked, and -/// retrying elsewhere will not fix a schema mismatch. -/// -/// # Errors -/// -/// [`TransportError::Rpc`] if `value` does not deserialize into `T`. -pub fn decode( - network: NetworkId, - method: &str, - value: Value, -) -> TransportResult { - serde_json::from_value(value).map_err(|e| TransportError::Rpc { - network, - message: format!("{method}: unexpected response shape: {e}"), - }) -} - -#[cfg(test)] -mod test; diff --git a/src/openhuman/web3/wallet/primitives/rpc/test.rs b/src/openhuman/web3/wallet/primitives/rpc/test.rs deleted file mode 100644 index bd9fd5066d..0000000000 --- a/src/openhuman/web3/wallet/primitives/rpc/test.rs +++ /dev/null @@ -1,208 +0,0 @@ -//! Unit tests for the transport seam. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use async_trait::async_trait; -use serde_json::{json, Value}; - -use super::{decode, NetworkId, Transport, TransportError, TransportResult}; -use crate::openhuman::web3::wallet::primitives::chain::Chain; - -/// A transport that records what it was asked for and replays canned answers. -/// Stands in for a host implementation so the seam can be exercised without a -/// network. -struct FakeTransport { - answer: TransportResult, - calls: std::sync::Mutex>, -} - -impl FakeTransport { - fn ok(value: Value) -> Self { - Self { - answer: Ok(value), - calls: std::sync::Mutex::new(Vec::new()), - } - } - - fn err(error: TransportError) -> Self { - Self { - answer: Err(error), - calls: std::sync::Mutex::new(Vec::new()), - } - } - - fn calls(&self) -> Vec { - self.calls.lock().unwrap().clone() - } -} - -#[async_trait] -impl Transport for FakeTransport { - async fn json_rpc( - &self, - _network: NetworkId, - method: &str, - params: Value, - ) -> TransportResult { - self.calls - .lock() - .unwrap() - .push(format!("json_rpc {method} {params}")); - self.answer.clone() - } - - async fn rest_get(&self, _network: NetworkId, path: &str) -> TransportResult { - self.calls.lock().unwrap().push(format!("rest_get {path}")); - self.answer.clone().map(|v| v.to_string()) - } - - async fn rest_post( - &self, - _network: NetworkId, - path: &str, - body: String, - content_type: &str, - ) -> TransportResult { - self.calls - .lock() - .unwrap() - .push(format!("rest_post {path} {content_type} {body}")); - self.answer.clone().map(|v| v.to_string()) - } -} - -#[test] -fn network_id_names_a_non_evm_chain_by_chain_alone() { - let id = NetworkId::chain(Chain::Solana); - assert_eq!(id.chain, Chain::Solana); - assert_eq!(id.evm_chain_id, None); - assert_eq!(id.to_string(), "solana"); -} - -#[test] -fn network_id_distinguishes_evm_networks_by_chain_id() { - // Ethereum and Base share an address format and an RPC dialect, so the - // chain alone cannot say which endpoint should answer. - let mainnet = NetworkId::evm(1); - let base = NetworkId::evm(8453); - assert_ne!(mainnet, base); - assert_eq!(mainnet.chain, base.chain); - assert_eq!(mainnet.to_string(), "evm:1"); - assert_eq!(base.to_string(), "evm:8453"); -} - -#[test] -fn unreachable_is_retryable_and_rpc_is_not() { - // The whole reason these are separate variants: a failover loop advances - // on the first and must stop dead on the second. - let network = NetworkId::chain(Chain::Btc); - let unreachable = TransportError::Unreachable { - network, - message: "connection refused".to_string(), - }; - let authoritative = TransportError::Rpc { - network, - message: "insufficient funds".to_string(), - }; - assert!(unreachable.is_retryable()); - assert!(!authoritative.is_retryable()); -} - -#[test] -fn errors_report_the_network_they_relate_to() { - let network = NetworkId::evm(8453); - let err = TransportError::Rpc { - network, - message: "nonce too low".to_string(), - }; - assert_eq!(err.network(), network); - assert!(err.to_string().contains("evm:8453")); - assert!(err.to_string().contains("nonce too low")); -} - -#[tokio::test] -async fn json_rpc_passes_the_method_and_params_through() { - let transport = FakeTransport::ok(json!("0x1")); - let out = transport - .json_rpc( - NetworkId::evm(1), - "eth_getTransactionCount", - json!(["0xabc", "latest"]), - ) - .await - .unwrap(); - assert_eq!(out, json!("0x1")); - assert_eq!( - transport.calls(), - vec![r#"json_rpc eth_getTransactionCount ["0xabc","latest"]"#.to_string()] - ); -} - -#[tokio::test] -async fn rest_post_carries_the_content_type() { - // Esplora wants text/plain for a raw transaction and TronGrid wants JSON, - // so the content type cannot be assumed by the caller. - let transport = FakeTransport::ok(json!("txid")); - transport - .rest_post( - NetworkId::chain(Chain::Btc), - "tx", - "0200000001".to_string(), - "text/plain", - ) - .await - .unwrap(); - assert_eq!( - transport.calls(), - vec!["rest_post tx text/plain 0200000001".to_string()] - ); -} - -#[tokio::test] -async fn a_transport_error_surfaces_to_the_caller_unchanged() { - let network = NetworkId::chain(Chain::Solana); - let transport = FakeTransport::err(TransportError::Rpc { - network, - message: "blockhash not found".to_string(), - }); - let err = transport - .json_rpc(network, "sendTransaction", json!([])) - .await - .unwrap_err(); - assert!(!err.is_retryable()); - assert!(err.to_string().contains("blockhash not found")); -} - -#[test] -fn decode_turns_a_matching_result_into_a_typed_value() { - #[derive(serde::Deserialize, PartialEq, Debug)] - struct Balance { - value: u64, - } - let out: Balance = decode( - NetworkId::chain(Chain::Solana), - "getBalance", - json!({"value": 42}), - ) - .unwrap(); - assert_eq!(out, Balance { value: 42 }); -} - -#[test] -fn decode_reports_a_shape_mismatch_as_authoritative_not_retryable() { - // The endpoint answered; it just did not answer what was asked. Retrying - // elsewhere cannot fix a schema mismatch, so this must not be Unreachable. - #[derive(serde::Deserialize, Debug)] - struct Balance { - #[allow(dead_code)] - value: u64, - } - let err = decode::( - NetworkId::chain(Chain::Solana), - "getBalance", - json!({"nope": true}), - ) - .unwrap_err(); - assert!(!err.is_retryable(), "a shape mismatch is not retryable"); - assert!(err.to_string().contains("getBalance")); -} diff --git a/src/openhuman/web3/wallet/primitives/wire/mod.rs b/src/openhuman/web3/wallet/primitives/wire/mod.rs deleted file mode 100644 index 338e7df654..0000000000 --- a/src/openhuman/web3/wallet/primitives/wire/mod.rs +++ /dev/null @@ -1,279 +0,0 @@ -//! The wire contract between a host and a signing backend. -//! -//! # Why this module exists, and why it has no dependencies -//! -//! A host can run this crate's transaction building in-process, or it can run -//! it somewhere else — most usefully in a loadable module, so the chain -//! libraries that building requires (`bitcoin` and its native `secp256k1` -//! build, above all) are absent from the host binary entirely. -//! -//! For that second arrangement both sides must agree on a set of types, and -//! **only the host side may be free of the heavy dependencies**. So these types -//! live outside every format gate and pull in nothing but `serde`: a host can -//! take this crate with `default-features = false`, get the whole contract, and -//! still not link a single chain library. It is the same carve-out -//! `crate::openhuman::tools::implementations::document::format::spec` makes for documents. -//! -//! # The split: building is not signing -//! -//! Every type here exists to serve one rule — **key material never crosses this -//! boundary**. A backend receives transaction fields and returns the bytes that -//! need signing; the host signs them; the backend reassembles. Two round trips -//! instead of one, in exchange for a private key that never leaves the process -//! that owns it. -//! -//! That constraint is what shapes the API. A [`SigningRequest`] carries no -//! secret, and an [`AttachRequest`] carries the original fields **again** -//! alongside the signatures, rather than a handle to something the backend -//! remembered. A backend holding half-built transactions between calls would -//! need a store, bounds on that store, and an expiry policy for callers that -//! never come back — all of which is avoided by rebuilding. Building is -//! deterministic, so rebuilding from the same fields yields the same -//! transaction the digests were computed over. -//! -//! # Signature shapes -//! -//! Three of the four chains sign a 32-byte digest with secp256k1 ECDSA and need -//! the recovery id; Solana signs the message itself with ed25519 and does not. -//! [`Signature`] is an enum over exactly those two cases rather than a bag of -//! bytes, so a host cannot hand back an ed25519 signature for an EVM -//! transaction and have it fail somewhere deep in reassembly. - -use serde::{Deserialize, Serialize}; - -use crate::openhuman::web3::wallet::primitives::chain::Chain; - -/// Bytes a host must sign, and how. -/// -/// For secp256k1 chains this is a 32-byte digest that is signed directly — -/// **already hashed**, so a host must use a "sign prehash" entry point and must -/// not hash it again. For Solana it is the full serialized message, because -/// ed25519 hashes internally as part of signing. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SigningPayload { - /// Lowercase hex of the bytes to sign. - pub bytes_hex: String, - /// Which signing scheme these bytes expect. - pub scheme: Scheme, -} - -/// How a [`SigningPayload`] must be signed. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum Scheme { - /// secp256k1 ECDSA over an already-computed 32-byte digest, low-`s` - /// normalized, with the recovery id retained. - /// - /// Low-`s` is not optional: Bitcoin enforces it as a relay policy rule - /// (BIP-146) and Ethereum as a consensus rule (EIP-2), so a high-`s` - /// signature produces a transaction that is rejected rather than one that - /// merely looks different. Both `k256` and `secp256k1` normalize by - /// default; a host that implements signing itself must not skip it. - Secp256k1Prehash, - /// ed25519 over the full message, which the scheme hashes itself. - Ed25519, -} - -/// A signature handed back to a backend for reassembly. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "scheme", rename_all = "snake_case")] -#[non_exhaustive] -pub enum Signature { - /// secp256k1 ECDSA: 32-byte `r`, 32-byte `s`, and the recovery id. - Secp256k1 { - /// Lowercase hex of `r || s`, exactly 64 bytes. - rs_hex: String, - /// Recovery id, 0..=3. - /// - /// Carried even for Bitcoin, which does not use it, so one variant - /// serves all three secp256k1 chains. EVM folds it into EIP-155 `v` - /// and Tron appends it directly. - recovery_id: u8, - }, - /// ed25519: the 64-byte signature. - Ed25519 { - /// Lowercase hex of the signature, exactly 64 bytes. - signature_hex: String, - }, -} - -/// The public key controlling the account a transaction spends from. -/// -/// Public by definition, so unlike the secret it may cross the boundary freely. -/// A backend needs it for two things: Bitcoin puts it in the witness, and every -/// chain uses it to check that the key the host is about to sign with actually -/// controls the `from` address — a mismatch that would otherwise surface as an -/// unspendable broadcast transaction. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct PublicKey { - /// Lowercase hex. Compressed SEC1 (33 bytes) for secp256k1 chains, the - /// 32-byte public key for ed25519. - pub key_hex: String, -} - -/// Ask a backend what needs signing for a transaction. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SigningRequest { - /// The transaction to build, which names its own chain. - /// - /// There is deliberately no separate `chain` field. Carrying one alongside - /// this would let a request say `btc` while holding an EVM transaction — - /// a state the backend would have to detect and reject at runtime. Reading - /// the chain off the variant instead makes that disagreement unrepresentable. - pub transaction: TransactionSpec, - /// The public key that will sign. - pub public_key: PublicKey, -} - -/// Hand signatures back so a backend can assemble the final transaction. -/// -/// Carries `transaction` again rather than a handle: see the module docs on why -/// a backend deliberately keeps no state between the two calls. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct AttachRequest { - /// The same fields passed to the matching [`SigningRequest`]. - /// - /// As there, the chain comes from the variant rather than a parallel field. - pub transaction: TransactionSpec, - /// The public key that signed. - pub public_key: PublicKey, - /// One signature per [`SigningPayload`] returned, in the same order. - /// - /// Bitcoin needs one per selected input; the other three need exactly one. - pub signatures: Vec, -} - -/// What a backend answers a [`SigningRequest`] with. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct UnsignedTransaction { - /// Everything that needs a signature, in the order the signatures must be - /// returned. - pub payloads: Vec, -} - -/// What a backend answers an [`AttachRequest`] with. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SignedTransaction { - /// The broadcast-ready transaction, in whatever encoding the chain's RPC - /// expects: hex for Bitcoin, EVM and Tron, base64 for Solana. - pub raw: String, - /// The transaction id or hash a node will report, when the chain lets it be - /// computed locally. - pub txid: Option, -} - -/// A transaction to build, per chain. -/// -/// One enum rather than four methods so a host holds a single value and the -/// chain tag cannot disagree with the fields — the mismatch a pair of parallel -/// arguments would allow. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -#[non_exhaustive] -pub enum TransactionSpec { - /// A Bitcoin P2WPKH spend. - Btc { - /// Sender address; must be P2WPKH. - from: String, - /// Recipient address; any mainnet type. - to: String, - /// Amount in satoshis. - amount_sat: u64, - /// Absolute fee in satoshis. - /// - /// Bitcoin's fee is implicit — `sum(inputs) - sum(outputs)` — so it is - /// stated here rather than derived from a rate. A caller that thinks - /// in sat/vB converts before sending. - fee_sat: u64, - /// Every spendable output held by `from`. - utxos: Vec, - }, - /// An EVM legacy transaction. - Evm { - /// Recipient — the token contract for an ERC-20 transfer. - to: String, - /// Value in wei. - value_wei: String, - /// Call data, `0x`-prefixed hex. Empty for a native transfer. - data_hex: String, - /// Sender nonce. - nonce: u64, - /// Gas limit. - gas_limit: u64, - /// Gas price in wei. - gas_price_wei: String, - /// EIP-155 chain id. - chain_id: u64, - }, - /// A Solana native SOL transfer. - Solana { - /// Sender address. - from: String, - /// Recipient address. - to: String, - /// Amount in lamports. - lamports: u64, - /// A recent blockhash, base58. - recent_blockhash: String, - }, - /// A Tron transfer, already assembled by the node. - /// - /// Tron is the odd one out: `createtransaction` builds the transaction - /// server-side and returns it, so there is nothing for this crate to build - /// — only a payload to verify and sign. The verification is the point, and - /// it is why the recipient and amount are carried alongside: a node that - /// returned a transaction paying somebody else would otherwise be signed - /// without complaint. - Tron { - /// The node's `raw_data_hex`. - raw_data_hex: String, - /// The recipient the caller intended, base58check. - expected_to: String, - /// The txid the node reported, to be recomputed and compared. - expected_txid: String, - }, -} - -impl TransactionSpec { - /// Which chain this transaction belongs to. - /// - /// The single source of truth for the chain, which is why neither request - /// type carries it separately. - /// - /// Infallible, and deliberately so despite `#[non_exhaustive]`. That - /// attribute binds only *downstream* crates, and a downstream crate calls - /// this method rather than matching the enum itself — so there is no - /// wildcard arm to write here, and adding a variant is a compile error in - /// this file, which is where it should be caught. - #[must_use] - pub fn chain(&self) -> Chain { - match self { - Self::Btc { .. } => Chain::Btc, - Self::Evm { .. } => Chain::Evm, - Self::Solana { .. } => Chain::Solana, - Self::Tron { .. } => Chain::Tron, - } - } -} - -/// One spendable Bitcoin output. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Utxo { - /// Transaction id holding this output. - pub txid: String, - /// Output index within that transaction. - pub vout: u32, - /// Value in satoshis. - pub value: u64, -} - -#[cfg(test)] -mod test; diff --git a/src/openhuman/web3/wallet/primitives/wire/test.rs b/src/openhuman/web3/wallet/primitives/wire/test.rs deleted file mode 100644 index 8e87de3eec..0000000000 --- a/src/openhuman/web3/wallet/primitives/wire/test.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! Tests for the host/backend wire contract. -//! -//! These are contract tests, not logic tests: the module holds no behaviour. -//! What can break here is compatibility — a field renamed, a tag changed, an -//! enum representation altered — and each of those breaks a host and a backend -//! that were built from different revisions, at runtime, with a deserialization -//! error rather than a compile failure. So the shapes are pinned literally. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use serde_json::json; - -use super::{ - AttachRequest, PublicKey, Scheme, Signature, SignedTransaction, SigningPayload, SigningRequest, - TransactionSpec, UnsignedTransaction, Utxo, -}; - -#[test] -fn a_signing_request_round_trips_through_json() { - let request = SigningRequest { - transaction: TransactionSpec::Evm { - to: "0x1111111111111111111111111111111111111111".to_string(), - value_wei: "1000".to_string(), - data_hex: "0x".to_string(), - nonce: 7, - gas_limit: 21_000, - gas_price_wei: "20000000000".to_string(), - chain_id: 1, - }, - public_key: PublicKey { - key_hex: "02".repeat(33), - }, - }; - - let encoded = serde_json::to_string(&request).unwrap(); - let decoded: SigningRequest = serde_json::from_str(&encoded).unwrap(); - assert_eq!(decoded, request); -} - -#[test] -fn the_transaction_spec_tag_is_the_published_one() { - // A host and a backend from different revisions meet here. The tag and the - // field names are the contract, so they are asserted against literals - // rather than against a re-serialization of the same value, which would - // agree with itself no matter what it was renamed to. - let spec = TransactionSpec::Solana { - from: "11111111111111111111111111111112".to_string(), - to: "11111111111111111111111111111113".to_string(), - lamports: 5, - recent_blockhash: "11111111111111111111111111111114".to_string(), - }; - assert_eq!( - serde_json::to_value(&spec).unwrap(), - json!({ - "kind": "solana", - "from": "11111111111111111111111111111112", - "to": "11111111111111111111111111111113", - "lamports": 5, - "recent_blockhash": "11111111111111111111111111111114", - }) - ); -} - -#[test] -fn a_signature_is_tagged_by_its_scheme() { - assert_eq!( - serde_json::to_value(Signature::Secp256k1 { - rs_hex: "ab".repeat(64), - recovery_id: 1, - }) - .unwrap(), - json!({ "scheme": "secp256k1", "rs_hex": "ab".repeat(64), "recovery_id": 1 }) - ); - assert_eq!( - serde_json::to_value(Signature::Ed25519 { - signature_hex: "cd".repeat(64), - }) - .unwrap(), - json!({ "scheme": "ed25519", "signature_hex": "cd".repeat(64) }) - ); -} - -#[test] -fn an_ed25519_signature_cannot_deserialize_as_a_secp256k1_one() { - // The enum is tagged precisely so a host cannot return the wrong scheme's - // signature and have it fail deep inside reassembly instead of at the - // boundary. - let ed = json!({ "scheme": "ed25519", "signature_hex": "cd".repeat(64) }); - let decoded: Signature = serde_json::from_value(ed).unwrap(); - assert!(matches!(decoded, Signature::Ed25519 { .. })); - - let mismatched = json!({ - "scheme": "secp256k1", - "signature_hex": "cd".repeat(64) - }); - assert!(serde_json::from_value::(mismatched).is_err()); -} - -#[test] -fn unknown_fields_are_refused_rather_than_ignored() { - // A backend newer than its host would otherwise silently drop a field it - // was told about, which for a transaction means signing something other - // than what was asked for. - let with_extra = json!({ - "txid": "aa".repeat(32), - "vout": 0, - "value": 1000, - "surprise": true, - }); - assert!(serde_json::from_value::(with_extra).is_err()); -} - -#[test] -fn the_signing_scheme_names_are_stable() { - assert_eq!( - serde_json::to_value(Scheme::Secp256k1Prehash).unwrap(), - json!("secp256k1_prehash") - ); - assert_eq!( - serde_json::to_value(Scheme::Ed25519).unwrap(), - json!("ed25519") - ); -} - -#[test] -fn an_attach_request_carries_one_signature_per_payload() { - // Not a rule the type can enforce, but the pairing is the contract: the - // Bitcoin path returns one payload per selected input and expects them - // back in the same order. - let unsigned = UnsignedTransaction { - payloads: vec![ - SigningPayload { - bytes_hex: "11".repeat(32), - scheme: Scheme::Secp256k1Prehash, - }, - SigningPayload { - bytes_hex: "22".repeat(32), - scheme: Scheme::Secp256k1Prehash, - }, - ], - }; - let attach = AttachRequest { - transaction: TransactionSpec::Btc { - from: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(), - to: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(), - amount_sat: 1_000, - fee_sat: 5, - utxos: vec![], - }, - public_key: PublicKey { - key_hex: "02".repeat(33), - }, - signatures: vec![ - Signature::Secp256k1 { - rs_hex: "ab".repeat(64), - recovery_id: 0, - }, - Signature::Secp256k1 { - rs_hex: "cd".repeat(64), - recovery_id: 1, - }, - ], - }; - assert_eq!(attach.signatures.len(), unsigned.payloads.len()); - - let encoded = serde_json::to_string(&attach).unwrap(); - assert_eq!( - serde_json::from_str::(&encoded).unwrap(), - attach - ); -} - -#[test] -fn a_signed_transaction_may_omit_a_locally_unknowable_txid() { - let signed = SignedTransaction { - raw: "0xdeadbeef".to_string(), - txid: None, - }; - let encoded = serde_json::to_string(&signed).unwrap(); - assert_eq!( - serde_json::from_str::(&encoded).unwrap(), - signed - ); -} - -#[test] -fn every_transaction_names_its_own_chain() { - // `chain()` is the single source of truth now that the requests carry no - // `chain` field, so a wrong arm here would route a transaction to the - // wrong chain's builder — with a real key already loaded. - use crate::openhuman::web3::wallet::primitives::chain::Chain; - - let cases = [ - ( - TransactionSpec::Btc { - from: String::new(), - to: String::new(), - amount_sat: 0, - fee_sat: 0, - utxos: Vec::new(), - }, - Chain::Btc, - ), - ( - TransactionSpec::Evm { - to: String::new(), - value_wei: "0".to_string(), - data_hex: String::new(), - nonce: 0, - gas_limit: 0, - gas_price_wei: "0".to_string(), - chain_id: 1, - }, - Chain::Evm, - ), - ( - TransactionSpec::Solana { - from: String::new(), - to: String::new(), - lamports: 0, - recent_blockhash: String::new(), - }, - Chain::Solana, - ), - ( - TransactionSpec::Tron { - raw_data_hex: String::new(), - expected_to: String::new(), - expected_txid: String::new(), - }, - Chain::Tron, - ), - ]; - - for (spec, expected) in cases { - assert_eq!(spec.chain(), expected); - } -} diff --git a/src/openhuman/web3/wallet/primitives/x402/mod.rs b/src/openhuman/web3/wallet/primitives/x402/mod.rs deleted file mode 100644 index e0f949106d..0000000000 --- a/src/openhuman/web3/wallet/primitives/x402/mod.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! The x402 machine-payment protocol (v2). -//! -//! x402 revives HTTP's long-unused `402 Payment Required`. A server answers a -//! request with a 402 and a `PAYMENT-REQUIRED` header describing what it will -//! accept; the client pays, retries with a `PAYMENT-SIGNATURE` header carrying -//! the proof, and the server settles it through a facilitator and answers with -//! `PAYMENT-RESPONSE`. -//! -//! This module owns the **wire types** — the header payloads and the rules for -//! reading them. Every header payload is standard-base64-encoded JSON, and -//! networks are named in [CAIP-2] form (`solana:…`, `eip155:8453`). -//! -//! ## Amounts are strings, and that is not laziness -//! -//! [`PaymentRequirements::amount`] is a `String` of atomic units, not a number. -//! JSON numbers are IEEE 754 doubles in most parsers, which cannot represent -//! every `u64` exactly — and a token amount that survives a round trip through -//! a JavaScript facilitator only approximately is a payment for the wrong sum. -//! The protocol carries them as decimal strings for that reason, and so does -//! this module. -//! -//! ## The client signs an authorisation; the facilitator broadcasts -//! -//! In both supported schemes the payer never broadcasts. On Solana it hands -//! over a partially-signed transaction that the facilitator co-signs as fee -//! payer; on EVM it signs an EIP-3009 `transferWithAuthorization` the -//! facilitator submits. So a payment proof is a *capability someone else will -//! exercise* — which is why [`EvmAuthorization`] carries `valid_after`, -//! `valid_before` and a `nonce`: without them an authorisation would be -//! replayable indefinitely. -//! -//! [CAIP-2]: https://chainagnostic.org/CAIPs/caip-2 - -mod types; - -#[allow(unused_imports)] -pub use types::{ - EvmAuthorization, EvmPaymentProof, PaymentChain, PaymentExtra, PaymentPayload, PaymentProof, - PaymentRequired, PaymentRequirements, ResourceInfo, SettlementResponse, SolanaPaymentProof, - BASE_MAINNET_CAIP2, BASE_SEPOLIA_CAIP2, COMPUTE_BUDGET_PROGRAM, ETHEREUM_MAINNET_CAIP2, - HEADER_PAYMENT_REQUIRED, HEADER_PAYMENT_REQUIRED_V1, HEADER_PAYMENT_RESPONSE, - HEADER_PAYMENT_SIGNATURE, HEADER_PAYMENT_SIGNATURE_V1, SOLANA_DEVNET_CAIP2, - SOLANA_MAINNET_CAIP2, SPL_MEMO_PROGRAM, SPL_TOKEN_PROGRAM, USDC_BASE_MAINNET, - USDC_BASE_SEPOLIA, USDC_ETHEREUM_MAINNET, USDC_MINT_DEVNET, USDC_MINT_MAINNET, X402_VERSION, -}; diff --git a/src/openhuman/web3/wallet/primitives/x402/types.rs b/src/openhuman/web3/wallet/primitives/x402/types.rs deleted file mode 100644 index a69ac18c0b..0000000000 --- a/src/openhuman/web3/wallet/primitives/x402/types.rs +++ /dev/null @@ -1,513 +0,0 @@ -//! Wire types for the x402 protocol (v2). -//! -//! All header payloads are standard-base64-encoded JSON. Network identifiers -//! use CAIP-2 format (e.g. `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`). - -use serde::{Deserialize, Serialize}; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/// The protocol version this module implements. -pub const X402_VERSION: u8 = 2; - -/// Response header carrying the v2 402 challenge. -pub const HEADER_PAYMENT_REQUIRED: &str = "PAYMENT-REQUIRED"; -/// The v1 spelling of the challenge header, still sent by some servers. -pub const HEADER_PAYMENT_REQUIRED_V1: &str = "X-PAYMENT-REQUIRED"; -/// Request header carrying the v2 payment proof. -pub const HEADER_PAYMENT_SIGNATURE: &str = "PAYMENT-SIGNATURE"; -/// The v1 spelling of the payment-proof header. -pub const HEADER_PAYMENT_SIGNATURE_V1: &str = "X-PAYMENT"; -/// Response header carrying the settlement result. -pub const HEADER_PAYMENT_RESPONSE: &str = "PAYMENT-RESPONSE"; - -/// CAIP-2 identifier for Solana mainnet-beta. -pub const SOLANA_MAINNET_CAIP2: &str = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"; -/// CAIP-2 identifier for Solana devnet. -pub const SOLANA_DEVNET_CAIP2: &str = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; - -/// USDC SPL mint on Solana mainnet-beta. -pub const USDC_MINT_MAINNET: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; -/// USDC SPL mint on Solana devnet. Differs from mainnet. -pub const USDC_MINT_DEVNET: &str = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; - -/// The SPL Token program id. -pub const SPL_TOKEN_PROGRAM: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; -/// The SPL Memo program id, used for payment uniqueness. -pub const SPL_MEMO_PROGRAM: &str = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"; -/// The Compute Budget program id. -pub const COMPUTE_BUDGET_PROGRAM: &str = "ComputeBudget111111111111111111111111111111"; - -// EVM / Base chain constants (CAIP-2 format: eip155:) -/// CAIP-2 identifier for Base mainnet. -pub const BASE_MAINNET_CAIP2: &str = "eip155:8453"; -/// CAIP-2 identifier for Base Sepolia. -pub const BASE_SEPOLIA_CAIP2: &str = "eip155:84532"; -/// CAIP-2 identifier for Ethereum mainnet. -pub const ETHEREUM_MAINNET_CAIP2: &str = "eip155:1"; - -/// USDC contract on Base mainnet. -pub const USDC_BASE_MAINNET: &str = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; -/// USDC contract on Base Sepolia. -pub const USDC_BASE_SEPOLIA: &str = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; -/// USDC contract on Ethereum mainnet. -pub const USDC_ETHEREUM_MAINNET: &str = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; - -// --------------------------------------------------------------------------- -// 402 challenge — server → client (PAYMENT-REQUIRED header) -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -/// The 402 challenge a server sends: what it will accept, and for what. -pub struct PaymentRequired { - /// See the x402 v2 specification. - pub x402_version: u8, - #[serde(default, skip_serializing_if = "Option::is_none")] - /// See the x402 v2 specification. - pub error: Option, - /// See the x402 v2 specification. - pub resource: ResourceInfo, - /// See the x402 v2 specification. - pub accepts: Vec, - #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] - /// See the x402 v2 specification. - pub extensions: serde_json::Map, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -/// The resource a payment buys access to. -pub struct ResourceInfo { - /// See the x402 v2 specification. - pub url: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - /// See the x402 v2 specification. - pub description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - /// See the x402 v2 specification. - pub mime_type: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -/// One payment option a server will accept. -pub struct PaymentRequirements { - /// See the x402 v2 specification. - pub scheme: String, - /// See the x402 v2 specification. - pub network: String, - /// Amount in atomic token units, as a decimal string (1 USDC = `1000000`). - /// - /// A string rather than a number — see the module docs. - /// See the x402 v2 specification. - pub amount: String, - /// Token mint address (Solana) or contract address (EVM). - /// See the x402 v2 specification. - pub asset: String, - /// Recipient wallet address. - /// See the x402 v2 specification. - pub pay_to: String, - /// See the x402 v2 specification. - pub max_timeout_seconds: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - /// See the x402 v2 specification. - pub extra: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Scheme-specific extras a server attaches to a requirement. -pub struct PaymentExtra { - /// Facilitator pubkey that will co-sign as fee payer (Solana). - #[serde(default, skip_serializing_if = "Option::is_none")] - /// See the x402 v2 specification. - pub fee_payer: Option, - /// Required memo value for transaction uniqueness (Solana). - #[serde(default, skip_serializing_if = "Option::is_none")] - /// See the x402 v2 specification. - pub memo: Option, - /// EIP-712 domain name for the token contract (EVM, e.g. "USD Coin"). - #[serde(default, skip_serializing_if = "Option::is_none")] - /// See the x402 v2 specification. - pub name: Option, - /// EIP-712 domain version for the token contract (EVM, e.g. "2"). - #[serde(default, skip_serializing_if = "Option::is_none")] - /// See the x402 v2 specification. - pub version: Option, -} - -// --------------------------------------------------------------------------- -// Payment proof — client → server (PAYMENT-SIGNATURE header) -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -/// The proof a client sends back after paying. -pub struct PaymentPayload { - /// See the x402 v2 specification. - pub x402_version: u8, - #[serde(default, skip_serializing_if = "Option::is_none")] - /// See the x402 v2 specification. - pub resource: Option, - /// See the x402 v2 specification. - pub accepted: PaymentRequirements, - /// See the x402 v2 specification. - pub payload: PaymentProof, - #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] - /// See the x402 v2 specification. - pub extensions: serde_json::Map, -} - -/// Chain-specific payment proof. Serializes flat (untagged) so the facilitator -/// sees either `{ "transaction": "..." }` (Solana) or -/// `{ "signature": "0x...", "authorization": {...} }` (EVM). -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -/// A chain-specific payment proof. -/// -/// Serialises untagged, so a facilitator sees the chain's object directly. -pub enum PaymentProof { - /// A Solana partially-signed transaction. - Solana(SolanaPaymentProof), - /// An EVM EIP-3009 authorisation. - Evm(EvmPaymentProof), -} - -/// Solana `exact` scheme payload — a partially-signed `VersionedTransaction` -/// serialized as standard base64. The facilitator adds its fee-payer signature -/// and broadcasts. -#[derive(Debug, Clone, Serialize, Deserialize)] -/// Solana `exact` proof: a partially-signed transaction, base64. -/// -/// The facilitator adds its fee-payer signature and broadcasts. -pub struct SolanaPaymentProof { - /// See the x402 v2 specification. - pub transaction: String, -} - -/// EVM `exact` scheme payload — a signed EIP-3009 `transferWithAuthorization` -/// or plain ERC-20 transfer authorization for the facilitator to submit. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -/// EVM `exact` proof: a signed EIP-3009 authorisation for the -/// facilitator to submit. -pub struct EvmPaymentProof { - /// See the x402 v2 specification. - pub signature: String, - /// See the x402 v2 specification. - pub authorization: EvmAuthorization, -} - -/// EIP-3009 `transferWithAuthorization` parameters signed by the token holder. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -/// EIP-3009 `transferWithAuthorization` parameters signed by the token -/// holder. -/// -/// `valid_after`, `valid_before` and `nonce` are what stop the -/// authorisation being replayable — see the module docs. -pub struct EvmAuthorization { - /// See the x402 v2 specification. - pub from: String, - /// See the x402 v2 specification. - pub to: String, - /// See the x402 v2 specification. - pub value: String, - /// See the x402 v2 specification. - pub valid_after: String, - /// See the x402 v2 specification. - pub valid_before: String, - /// See the x402 v2 specification. - pub nonce: String, -} - -// --------------------------------------------------------------------------- -// Settlement response — server → client (PAYMENT-RESPONSE header) -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -/// The settlement result a server returns once the payment landed. -pub struct SettlementResponse { - /// See the x402 v2 specification. - pub success: bool, - /// Base58 transaction signature (Solana) or hex tx hash (EVM). - /// See the x402 v2 specification. - pub transaction: String, - /// See the x402 v2 specification. - pub network: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - /// See the x402 v2 specification. - pub payer: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - /// See the x402 v2 specification. - pub error_reason: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - /// See the x402 v2 specification. - pub amount: Option, - #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] - /// See the x402 v2 specification. - pub extensions: serde_json::Map, -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -impl PaymentRequired { - /// Find the first `accepts` entry whose network starts with `"solana:"` and - /// whose scheme is `"exact"`. - #[must_use] - pub fn solana_exact_requirement(&self) -> Option<&PaymentRequirements> { - self.accepts - .iter() - .find(|r| r.scheme == "exact" && r.network.starts_with("solana:")) - } - - /// Find the first `accepts` entry whose network starts with `"eip155:"` and - /// whose scheme is `"exact"`. - #[must_use] - pub fn evm_exact_requirement(&self) -> Option<&PaymentRequirements> { - self.accepts - .iter() - .find(|r| r.scheme == "exact" && r.network.starts_with("eip155:")) - } - - /// The preferred payment option: **Solana first, then EVM**. - /// - /// The order matters to a payer with funds on both chains, so it is stated - /// plainly here. The implementation this was extracted from carried a doc - /// comment claiming the opposite ("prefer EVM (Base), fall back to - /// Solana") while the code checked Solana first; the code's behaviour is - /// preserved and the comment corrected, since changing which chain a payer - /// spends from is not a documentation fix. - #[must_use] - pub fn best_exact_requirement(&self) -> Option<(&PaymentRequirements, PaymentChain)> { - if let Some(sol) = self.solana_exact_requirement() { - Some((sol, PaymentChain::Solana)) - } else if let Some(evm) = self.evm_exact_requirement() { - Some((evm, PaymentChain::Evm)) - } else { - None - } - } -} - -/// Which chain family a payment requirement targets. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -/// Which chain family a payment requirement targets. -pub enum PaymentChain { - /// A Solana `exact`-scheme payment. - Solana, - /// An EVM `exact`-scheme payment. - Evm, -} - -impl PaymentRequirements { - /// Whether this requirement targets Solana mainnet-beta. - #[must_use] - pub fn is_solana_mainnet(&self) -> bool { - self.network == SOLANA_MAINNET_CAIP2 - } - - /// Whether this requirement targets Base mainnet. - #[must_use] - pub fn is_base_mainnet(&self) -> bool { - self.network == BASE_MAINNET_CAIP2 - } - - /// Parse the EVM chain ID from an `eip155:` network string. - #[must_use] - pub fn evm_chain_id(&self) -> Option { - self.network - .strip_prefix("eip155:") - .and_then(|s| s.parse().ok()) - } - - /// The facilitator pubkey that will co-sign as fee payer, if the server - /// named one. - #[must_use] - pub fn fee_payer_pubkey(&self) -> Option<&str> { - self.extra.as_ref()?.fee_payer.as_deref() - } - - /// The memo the server requires for transaction uniqueness, if any. - #[must_use] - pub fn memo_value(&self) -> Option<&str> { - self.extra.as_ref()?.memo.as_deref() - } -} - -#[cfg(test)] -mod test { - #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - - use super::{ - PaymentChain, PaymentRequired, PaymentRequirements, BASE_MAINNET_CAIP2, - SOLANA_MAINNET_CAIP2, X402_VERSION, - }; - - fn requirement(scheme: &str, network: &str) -> PaymentRequirements { - PaymentRequirements { - scheme: scheme.to_string(), - network: network.to_string(), - amount: "1000000".to_string(), - asset: super::USDC_MINT_MAINNET.to_string(), - pay_to: "11111111111111111111111111111111".to_string(), - max_timeout_seconds: 60, - extra: None, - } - } - - fn challenge(accepts: Vec) -> PaymentRequired { - PaymentRequired { - x402_version: X402_VERSION, - error: None, - resource: super::ResourceInfo { - url: "https://example.test/thing".to_string(), - description: None, - mime_type: None, - }, - accepts, - extensions: serde_json::Map::new(), - } - } - - #[test] - fn only_the_exact_scheme_is_selected() { - // A server may offer schemes this crate cannot pay; picking one of - // those would produce a proof the facilitator rejects. - let c = challenge(vec![requirement("upto", SOLANA_MAINNET_CAIP2)]); - assert!(c.solana_exact_requirement().is_none()); - assert!(c.best_exact_requirement().is_none()); - } - - #[test] - fn requirements_are_matched_by_network_prefix_not_exact_string() { - // CAIP-2 names a specific chain, so devnet and mainnet differ — but - // both are Solana, and the selector must accept either. - let c = challenge(vec![requirement("exact", super::SOLANA_DEVNET_CAIP2)]); - assert!(c.solana_exact_requirement().is_some()); - - let c = challenge(vec![requirement("exact", super::BASE_SEPOLIA_CAIP2)]); - assert!(c.evm_exact_requirement().is_some()); - } - - #[test] - fn solana_is_preferred_when_both_are_offered() { - // Pinning the documented order: which chain a payer spends from is - // observable behaviour, not an implementation detail. - let c = challenge(vec![ - requirement("exact", BASE_MAINNET_CAIP2), - requirement("exact", SOLANA_MAINNET_CAIP2), - ]); - let (_, chain) = c.best_exact_requirement().unwrap(); - assert_eq!(chain, PaymentChain::Solana); - } - - #[test] - fn evm_is_used_when_it_is_the_only_option() { - let c = challenge(vec![requirement("exact", BASE_MAINNET_CAIP2)]); - let (req, chain) = c.best_exact_requirement().unwrap(); - assert_eq!(chain, PaymentChain::Evm); - assert_eq!(req.evm_chain_id(), Some(8453)); - } - - #[test] - fn the_evm_chain_id_is_parsed_from_the_caip2_network() { - assert_eq!( - requirement("exact", "eip155:1").evm_chain_id(), - Some(1), - "ethereum mainnet" - ); - assert_eq!( - requirement("exact", SOLANA_MAINNET_CAIP2).evm_chain_id(), - None, - "a Solana network has no EVM chain id" - ); - assert_eq!( - requirement("exact", "eip155:notanumber").evm_chain_id(), - None - ); - } - - #[test] - fn amounts_stay_strings_through_a_json_round_trip() { - // The reason the protocol uses strings: a u64 amount through a - // double-based JSON parser can come back as a different number. - let mut req = requirement("exact", SOLANA_MAINNET_CAIP2); - req.amount = "18446744073709551615".to_string(); // u64::MAX - let json = serde_json::to_string(&req).unwrap(); - let back: PaymentRequirements = serde_json::from_str(&json).unwrap(); - assert_eq!(back.amount, "18446744073709551615"); - } - - #[test] - fn the_wire_shape_is_camel_case() { - // The header payload is read by facilitators in other languages, so - // the field names are part of the contract. - let json = serde_json::to_string(&requirement("exact", SOLANA_MAINNET_CAIP2)).unwrap(); - assert!(json.contains("\"payTo\""), "{json}"); - assert!(json.contains("\"maxTimeoutSeconds\""), "{json}"); - assert!(!json.contains("pay_to"), "{json}"); - } - - #[test] - fn a_payment_proof_serialises_untagged() { - // The facilitator sees the chain-specific object directly, with no - // enum discriminant wrapping it. - let solana = super::PaymentProof::Solana(super::SolanaPaymentProof { - transaction: "base64tx".to_string(), - }); - let json = serde_json::to_string(&solana).unwrap(); - assert_eq!(json, r#"{"transaction":"base64tx"}"#); - - let evm = super::PaymentProof::Evm(super::EvmPaymentProof { - signature: "0xsig".to_string(), - authorization: super::EvmAuthorization { - from: "0xa".to_string(), - to: "0xb".to_string(), - value: "1".to_string(), - valid_after: "0".to_string(), - valid_before: "99".to_string(), - nonce: "0xn".to_string(), - }, - }); - let json = serde_json::to_string(&evm).unwrap(); - assert!(json.starts_with(r#"{"signature":"0xsig""#), "{json}"); - assert!(json.contains("\"validBefore\""), "{json}"); - } - - #[test] - fn optional_fields_are_omitted_rather_than_sent_as_null() { - let json = serde_json::to_string(&requirement("exact", SOLANA_MAINNET_CAIP2)).unwrap(); - assert!(!json.contains("extra"), "absent extras are omitted: {json}"); - } - - #[test] - fn a_challenge_round_trips() { - let c = challenge(vec![requirement("exact", SOLANA_MAINNET_CAIP2)]); - let json = serde_json::to_string(&c).unwrap(); - let back: PaymentRequired = serde_json::from_str(&json).unwrap(); - assert_eq!(back.x402_version, X402_VERSION); - assert_eq!(back.accepts.len(), 1); - assert_eq!(back.resource.url, "https://example.test/thing"); - } - - #[test] - fn unknown_extension_fields_are_preserved_not_rejected() { - // Unlike the document spec, this is a protocol other implementations - // extend, so an unknown key must not fail the parse. - let json = r#"{ - "x402Version": 2, - "resource": { "url": "https://example.test" }, - "accepts": [], - "extensions": { "somethingNew": true } - }"#; - let parsed: PaymentRequired = serde_json::from_str(json).unwrap(); - assert!(parsed.extensions.contains_key("somethingNew")); - } -} diff --git a/src/openhuman/web3/wallet/transport.rs b/src/openhuman/web3/wallet/transport.rs index e87d5fbeb9..3dba7ede1e 100644 --- a/src/openhuman/web3/wallet/transport.rs +++ b/src/openhuman/web3/wallet/transport.rs @@ -1,8 +1,8 @@ -//! OpenHuman's implementation of the [`crate::openhuman::web3::wallet::primitives::rpc::Transport`] seam. +//! OpenHuman's implementation of the [`tinywallet::rpc::Transport`] seam. //! //! `tinywallet` performs no I/O and takes no URLs: it names a -//! [`NetworkId`](crate::openhuman::web3::wallet::primitives::rpc::NetworkId) and asks a host to reach it. This -//! module is that host side — the adapter that lets `crate::openhuman::web3::wallet::primitives::client` and +//! [`NetworkId`](tinywallet::rpc::NetworkId) and asks a host to reach it. This +//! module is that host side — the adapter that lets `tinywallet::rpc` and //! the chain modules run against OpenHuman's existing RPC layer. //! //! Everything the crate deliberately refused to own lives on this side of the @@ -29,12 +29,10 @@ //! is the safe direction: a missed retry costs a request, a wrong retry can //! cost a duplicate transaction. -use crate::openhuman::web3::wallet::primitives::rpc::{ - NetworkId, Transport, TransportError, TransportResult, -}; use async_trait::async_trait; use log::debug; use serde_json::Value; +use tinywallet::rpc::{NetworkId, Transport, TransportError, TransportResult}; use super::defaults::{rpc_url_for_chain, rpc_url_for_evm_network, EvmNetwork}; use super::ops::WalletChain; @@ -59,7 +57,7 @@ impl OpenHumanTransport { #[allow(unreachable_patterns)] fn resolve(network: NetworkId) -> Result { match network.chain { - crate::openhuman::web3::wallet::primitives::Chain::Evm => { + tinywallet::Chain::Evm => { // An EVM request names its EIP-155 chain id; resolving it here is // what keeps `tinywallet` free of OpenHuman's network enum. let chain_id = network.evm_chain_id.ok_or_else(|| TransportError::Rpc { @@ -76,16 +74,10 @@ fn resolve(network: NetworkId) -> Result { })?; Ok(rpc_url_for_evm_network(evm)) } - crate::openhuman::web3::wallet::primitives::Chain::Btc => { - Ok(rpc_url_for_chain(WalletChain::Btc)) - } - crate::openhuman::web3::wallet::primitives::Chain::Solana => { - Ok(rpc_url_for_chain(WalletChain::Solana)) - } - crate::openhuman::web3::wallet::primitives::Chain::Tron => { - Ok(rpc_url_for_chain(WalletChain::Tron)) - } - // `crate::openhuman::web3::wallet::primitives::Chain` is `#[non_exhaustive]`, so a future variant must + tinywallet::Chain::Btc => Ok(rpc_url_for_chain(WalletChain::Btc)), + tinywallet::Chain::Solana => Ok(rpc_url_for_chain(WalletChain::Solana)), + tinywallet::Chain::Tron => Ok(rpc_url_for_chain(WalletChain::Tron)), + // `tinywallet::Chain` is `#[non_exhaustive]`, so a future variant must // be handled. Reporting it as authoritative is correct: no endpoint is // configured for it, and retrying elsewhere cannot change that. other => Err(TransportError::Rpc { @@ -246,10 +238,7 @@ mod tests { #[test] fn an_evm_request_without_a_chain_id_is_authoritative_not_retryable() { - let err = resolve(NetworkId::chain( - crate::openhuman::web3::wallet::primitives::Chain::Evm, - )) - .unwrap_err(); + let err = resolve(NetworkId::chain(tinywallet::Chain::Evm)).unwrap_err(); assert!(!err.is_retryable(), "{err}"); } @@ -264,9 +253,9 @@ mod tests { #[test] fn every_non_evm_chain_resolves() { for chain in [ - crate::openhuman::web3::wallet::primitives::Chain::Btc, - crate::openhuman::web3::wallet::primitives::Chain::Solana, - crate::openhuman::web3::wallet::primitives::Chain::Tron, + tinywallet::Chain::Btc, + tinywallet::Chain::Solana, + tinywallet::Chain::Tron, ] { assert!(resolve(NetworkId::chain(chain)).is_ok(), "{chain}"); } @@ -276,7 +265,7 @@ mod tests { fn transport_failures_are_retryable_and_everything_else_is_not() { // The conservative direction: only what this layer knows to be a // transport failure may drive a failover. - let network = NetworkId::chain(crate::openhuman::web3::wallet::primitives::Chain::Btc); + let network = NetworkId::chain(tinywallet::Chain::Btc); assert!( classify(network, "wallet RPC transport failed for x: refused".into()).is_retryable() ); diff --git a/src/openhuman/web3/x402/ops.rs b/src/openhuman/web3/x402/ops.rs index 91366f3ea1..a3b1cbfa8f 100644 --- a/src/openhuman/web3/x402/ops.rs +++ b/src/openhuman/web3/x402/ops.rs @@ -12,7 +12,7 @@ //! The facilitator submits the signed authorization on-chain. use base64::engine::{general_purpose::STANDARD as B64, Engine as _}; -use ed25519_dalek::{Signer, SigningKey}; + use log::{debug, warn}; use reqwest::header::HeaderMap; use sha2::{Digest, Sha256}; @@ -43,13 +43,16 @@ impl X402Client { /// Send a request. If the server returns 402 with a `PAYMENT-REQUIRED` /// header, attempt to pay using the wallet's Solana key and retry. /// - /// `signing_key` — the wallet's ed25519 key (caller derives from mnemonic). + /// The signing key is no longer a parameter: derivation and signing both + /// happen inside the loaded wallet module, so there is no key for a caller + /// to hold or pass. The wallet's phrase is resolved here and handed over on + /// a confidential call. + /// /// `max_amount` — optional ceiling in atomic units; rejects challenges above /// this to prevent runaway spending. pub async fn try_paid_request( &self, request: reqwest::Request, - signing_key: &SigningKey, max_amount: Option, ) -> Result { let method = request.method().clone(); @@ -106,7 +109,15 @@ impl X402Client { let payment = match chain { PaymentChain::Solana => { - build_solana_payment(signing_key, &challenge, requirement).await? + let (config, signing_secret, our_pubkey) = wallet_signer().await?; + build_solana_payment( + &config, + &signing_secret, + our_pubkey, + &challenge, + requirement, + ) + .await? } PaymentChain::Evm => build_evm_payment(&challenge, requirement).await?, }; @@ -176,7 +187,6 @@ pub fn handle_402( /// Separated from `try_paid_request` so callers that manage their own HTTP /// layer can still use the payment construction. pub async fn try_paid_request( - signing_key: &SigningKey, challenge: &PaymentRequired, requirement: &PaymentRequirements, ) -> Result { @@ -186,7 +196,11 @@ pub async fn try_paid_request( PaymentChain::Solana }; let payment = match chain { - PaymentChain::Solana => build_solana_payment(signing_key, challenge, requirement).await?, + PaymentChain::Solana => { + let (config, signing_secret, our_pubkey) = wallet_signer().await?; + build_solana_payment(&config, &signing_secret, our_pubkey, challenge, requirement) + .await? + } PaymentChain::Evm => build_evm_payment(challenge, requirement).await?, }; let json = serde_json::to_string(&payment) @@ -258,8 +272,15 @@ pub async fn handle_402_and_pay( let payment = match chain { PaymentChain::Solana => { - let signing_key = derive_wallet_signing_key().await?; - build_solana_payment(&signing_key, &challenge, requirement).await? + let (config, signing_secret, our_pubkey) = wallet_signer().await?; + build_solana_payment( + &config, + &signing_secret, + our_pubkey, + &challenge, + requirement, + ) + .await? } PaymentChain::Evm => build_evm_payment(&challenge, requirement).await?, }; @@ -279,7 +300,19 @@ pub async fn handle_402_and_pay( } /// Derive the wallet's Solana ed25519 signing key from the encrypted mnemonic. -async fn derive_wallet_signing_key() -> Result { +/// The phrase to sign a payment with, its config, and the wallet's public key. +/// +/// Derivation happens in the loaded wallet module; this process never holds the +/// private key. The phrase is handed over on a confidential call, and only to a +/// module that has proved it is an artifact this build pinned. +async fn wallet_signer() -> Result< + ( + crate::openhuman::config::Config, + tinywallet::wire::SecretMaterial, + [u8; 32], + ), + X402Error, +> { use crate::openhuman::web3::wallet::WalletChain; let secret = crate::openhuman::web3::wallet::secret_material(WalletChain::Solana) @@ -298,76 +331,16 @@ async fn derive_wallet_signing_key() -> Result { .map_err(|e| X402Error::Wallet(format!("decrypt mnemonic: {e}")))? .value; - derive_solana_keypair_from_mnemonic(&mnemonic, &secret.derivation_path) -} - -fn derive_solana_keypair_from_mnemonic( - mnemonic: &str, - derivation_path: &str, -) -> Result { - use coins_bip39::{English, Mnemonic}; - use ed25519_dalek::SECRET_KEY_LENGTH; - use hmac::{Hmac, Mac}; - use sha2::Sha512; - - let mnemonic_obj: Mnemonic = mnemonic - .trim() - .parse() - .map_err(|e| X402Error::Wallet(format!("invalid mnemonic: {e}")))?; - let seed = mnemonic_obj - .to_seed(None) - .map_err(|e| X402Error::Wallet(format!("seed derivation: {e}")))?; - - // SLIP-0010 ed25519 derivation - type HmacSha512 = Hmac; - let mut mac = HmacSha512::new_from_slice(b"ed25519 seed") - .map_err(|e| X402Error::Wallet(format!("HMAC init: {e}")))?; - mac.update(&seed); - let i = mac.finalize().into_bytes(); - let mut key = [0u8; 32]; - let mut chain_code = [0u8; 32]; - key.copy_from_slice(&i[..32]); - chain_code.copy_from_slice(&i[32..]); - - let path = parse_derivation_path(derivation_path)?; - for index in path { - let hardened = index | 0x8000_0000; - let mut mac = HmacSha512::new_from_slice(&chain_code) - .map_err(|e| X402Error::Wallet(format!("HMAC init: {e}")))?; - mac.update(&[0u8]); - mac.update(&key); - mac.update(&hardened.to_be_bytes()); - let i = mac.finalize().into_bytes(); - key.copy_from_slice(&i[..32]); - chain_code.copy_from_slice(&i[32..]); - } - - let bytes: [u8; SECRET_KEY_LENGTH] = key; - Ok(SigningKey::from_bytes(&bytes)) -} - -fn parse_derivation_path(path: &str) -> Result, X402Error> { - let trimmed = path.trim(); - let mut iter = trimmed.split('/'); - match iter.next() { - Some("m") => {} - _ => { - return Err(X402Error::Wallet(format!( - "path must start with 'm': {path}" - ))) - } - } - let mut out = Vec::new(); - for seg in iter { - let stripped = seg - .strip_suffix('\'') - .ok_or_else(|| X402Error::Wallet(format!("non-hardened segment in: {path}")))?; - let v: u32 = stripped - .parse() - .map_err(|e| X402Error::Wallet(format!("invalid path segment '{seg}': {e}")))?; - out.push(v); - } - Ok(out) + let signing_secret = tinywallet::wire::SecretMaterial { + mnemonic, + derivation_path: secret.derivation_path.clone(), + chain: tinywallet::Chain::Solana, + }; + let account = crate::openhuman::modules::wallet::derive_account(&config, &signing_secret) + .await + .map_err(|e| X402Error::Wallet(format!("derive account: {e}")))?; + let pubkey = b58_to_32(&account.address)?; + Ok((config, signing_secret, pubkey)) } // --------------------------------------------------------------------------- @@ -479,11 +452,12 @@ fn parse_settlement_response(b64_str: &str) -> Result Result { - let our_pubkey = signing_key.verifying_key().to_bytes(); let amount: u64 = req .amount .parse() @@ -551,8 +525,23 @@ async fn build_solana_payment( wire.extend(encode_shortvec(2)); // 2 required signatures wire.extend([0u8; 64]); // slot 0: fee_payer (left zeroed for facilitator) - let sig = signing_key.sign(&message); - wire.extend(sig.to_bytes()); // slot 1: our signature + // Signed in the module: the phrase goes over a confidential call and the + // private key is never assembled in this process. + let signature = crate::openhuman::modules::wallet::sign_message( + config, + signing_secret, + &message, + tinywallet::wire::Scheme::Ed25519, + ) + .await + .map_err(|e| X402Error::Wallet(format!("sign payment: {e}")))?; + let tinywallet::wire::Signature::Ed25519 { signature_hex } = signature else { + return Err(X402Error::Wallet( + "the wallet module returned a non-ed25519 signature".to_string(), + )); + }; + let sig_bytes = hex_to_32_bytes_64(&signature_hex)?; + wire.extend(sig_bytes); // slot 1: our signature wire.extend(&message); let tx_b64 = B64.encode(&wire); @@ -584,18 +573,65 @@ async fn build_evm_payment( challenge: &PaymentRequired, req: &PaymentRequirements, ) -> Result { - let (secret, from_address) = derive_evm_signer().await?; - build_evm_payment_with_signer(&secret, &from_address, challenge, req) + let (config, signing_secret, from_address) = evm_signer().await?; + let authorization = evm_payment_authorization(&from_address, req)?; + + // Signed in the wallet module over the prehashed EIP-712 digest. This + // process never holds the EVM key. + let signature = crate::openhuman::modules::wallet::sign_message( + &config, + &signing_secret, + &authorization.digest, + tinywallet::wire::Scheme::Secp256k1Prehash, + ) + .await + .map_err(|e| X402Error::Wallet(format!("sign EIP-3009: {e}")))?; + let tinywallet::wire::Signature::Secp256k1 { + rs_hex, + recovery_id, + } = signature + else { + return Err(X402Error::Wallet( + "the wallet module returned a non-secp256k1 signature".to_string(), + )); + }; + let rs = hex::decode(&rs_hex) + .map_err(|e| X402Error::Wallet(format!("invalid signature hex: {e}")))?; + if rs.len() != 64 { + return Err(X402Error::Wallet( + "the wallet module returned a malformed signature".to_string(), + )); + } + let mut sig_bytes = [0u8; 65]; + sig_bytes[..64].copy_from_slice(&rs); + sig_bytes[64] = recovery_id + .checked_add(27) + .ok_or_else(|| X402Error::Wallet("recovery id out of range".to_string()))?; + + evm_payment_payload(&authorization, sig_bytes, &from_address, challenge, req) } -/// Core EVM payment construction — separated from wallet derivation for testability. -pub(crate) fn build_evm_payment_with_signer( - secret: &[u8], +/// The EIP-712 digest to sign, and the fields the payload needs alongside it. +/// +/// Split out from signing so that production (which signs in the wallet module) +/// and the tests (which sign locally, to check the construction against a fixed +/// vector) share one implementation of the part that can be wrong. Only *who +/// holds the key* differs between them. +pub(crate) struct EvmPaymentAuthorization { + /// The 32-byte EIP-712 digest. + pub digest: [u8; 32], + /// The EIP-3009 nonce, echoed into the payload. + pub nonce: [u8; 32], + valid_after_secs: u64, + valid_before_secs: u64, +} + +/// Compute the EIP-3009 authorization and its EIP-712 digest. +pub(crate) fn evm_payment_authorization( from_address: &str, - challenge: &PaymentRequired, req: &PaymentRequirements, -) -> Result { - use crate::openhuman::web3::wallet::primitives::eip712; +) -> Result { + use tinywallet::eip712; let chain_id = req .evm_chain_id() @@ -653,17 +689,31 @@ pub(crate) fn build_evm_payment_with_signer( ); 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; + Ok(EvmPaymentAuthorization { + digest, + nonce, + valid_after_secs: 0, + valid_before_secs, + }) +} + +/// Assemble the payload from an authorization and its signature. +/// +/// 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. +pub(crate) fn evm_payment_payload( + authorization: &EvmPaymentAuthorization, + sig_bytes: [u8; 65], + from_address: &str, + challenge: &PaymentRequired, + req: &PaymentRequirements, +) -> Result { + let chain_id = req + .evm_chain_id() + .ok_or_else(|| X402Error::Protocol(format!("not an EVM network: {}", req.network)))?; + let valid_after = authorization.valid_after_secs; + let valid_before = authorization.valid_before_secs; + let nonce = authorization.nonce; let sig_hex = format!("0x{}", hex::encode(sig_bytes)); let nonce_hex = format!("0x{}", hex::encode(nonce)); @@ -683,8 +733,8 @@ pub(crate) fn build_evm_payment_with_signer( from: from_address.to_string(), to: req.pay_to.clone(), value: req.amount.clone(), - valid_after: "0".to_string(), - valid_before: valid_before_secs.to_string(), + valid_after: valid_after.to_string(), + valid_before: valid_before.to_string(), nonce: nonce_hex, }, }), @@ -695,10 +745,17 @@ pub(crate) fn build_evm_payment_with_signer( /// Derive the wallet's EVM signing key from the encrypted mnemonic. /// /// Returns the raw secret and the checksummed address it controls. Derivation -/// goes through `crate::openhuman::web3::wallet::primitives::key` — the same BIP-32 walk the wallet domain uses, +/// 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> { +async fn evm_signer() -> Result< + ( + crate::openhuman::config::Config, + tinywallet::wire::SecretMaterial, + String, + ), + X402Error, +> { use crate::openhuman::web3::wallet::WalletChain; let secret = crate::openhuman::web3::wallet::secret_material(WalletChain::Evm) @@ -717,22 +774,56 @@ async fn derive_evm_signer() -> Result<(Vec, String), X402Error> { .map_err(|e| X402Error::Wallet(format!("decrypt mnemonic: {e}")))? .value; - let derived = crate::openhuman::web3::wallet::primitives::key::derive( - crate::openhuman::web3::wallet::primitives::Chain::Evm, - mnemonic.as_str(), - &secret.derivation_path, - ) - .map_err(|e| X402Error::Wallet(format!("derive EVM signer: {e}")))?; + let signing_secret = tinywallet::wire::SecretMaterial { + mnemonic, + derivation_path: secret.derivation_path.clone(), + chain: tinywallet::Chain::Evm, + }; + let account = crate::openhuman::modules::wallet::derive_account(&config, &signing_secret) + .await + .map_err(|e| X402Error::Wallet(format!("derive EVM signer: {e}")))?; + + Ok((config, signing_secret, account.address)) +} + +/// Sign an EIP-712 digest locally. Test-only. +/// +/// Production signs in the wallet module; this exists so the payment +/// construction can be checked against a fixed vector without a broker. It is +/// the only remaining local use of a private key in this domain, and it is +/// compiled out of the shipped binary. +#[cfg(test)] +pub(crate) fn sign_evm_digest_locally( + secret: &[u8], + digest: &[u8; 32], +) -> Result<[u8; 65], X402Error> { + 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; + Ok(sig_bytes) +} - Ok(( - derived.secret_bytes().to_vec(), - derived.address().to_string(), - )) +/// The construction the tests drive: authorize, sign locally, assemble. +#[cfg(test)] +pub(crate) fn build_evm_payment_with_signer( + secret: &[u8], + from_address: &str, + challenge: &PaymentRequired, + req: &PaymentRequirements, +) -> Result { + let authorization = evm_payment_authorization(from_address, req)?; + let sig_bytes = sign_evm_digest_locally(secret, &authorization.digest)?; + evm_payment_payload(&authorization, sig_bytes, from_address, challenge, req) } /// The 20 raw bytes of an EVM address. fn evm_address_bytes(address: &str) -> Result<[u8; 20], X402Error> { - let validated = crate::openhuman::web3::wallet::primitives::address::evm::validate(address) + 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) @@ -919,3 +1010,18 @@ async fn fetch_recent_blockhash_for_x402() -> Result<[u8; 32], X402Error> { b58_to_32(&result.value.blockhash) } + +/// Decode a 64-byte signature returned as lowercase hex by the wallet module. +fn hex_to_32_bytes_64(value: &str) -> Result<[u8; 64], X402Error> { + if value.len() != 128 { + return Err(X402Error::Wallet( + "the wallet module returned a malformed signature".to_string(), + )); + } + let mut out = [0u8; 64]; + for (index, slot) in out.iter_mut().enumerate() { + *slot = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16) + .map_err(|e| X402Error::Wallet(format!("invalid signature hex: {e}")))?; + } + Ok(out) +} diff --git a/src/openhuman/web3/x402/x402_tests.rs b/src/openhuman/web3/x402/x402_tests.rs index d518653bf4..a0e3ad8147 100644 --- a/src/openhuman/web3/x402/x402_tests.rs +++ b/src/openhuman/web3/x402/x402_tests.rs @@ -394,11 +394,11 @@ fn solana_payment_proof_serializes_correctly() { #[test] fn eip712_domain_separator_is_deterministic() { - // Now `crate::openhuman::web3::wallet::primitives::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 crate::openhuman::web3::wallet::primitives::eip712::domain_separator; + // Now `tinywallet::eip712`; the crate's own suite pins its hashes against + // the published EIP-712/EIP-3009 vectors. What this test checks is the + // property that matters at this layer: the separator is deterministic and + // 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"); @@ -411,17 +411,13 @@ fn eip712_domain_separator_is_deterministic() { /// The BIP-39 vector mnemonic's EVM account: raw secret and its address. /// -/// Derived through `crate::openhuman::web3::wallet::primitives::key`, which is what the production path uses, +/// 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 = crate::openhuman::web3::wallet::primitives::key::derive( - crate::openhuman::web3::wallet::primitives::Chain::Evm, - test_mnemonic, - "m/44'/60'/0'/0/0", - ) - .unwrap(); + 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(), @@ -440,9 +436,7 @@ fn address_bytes(hex: &str) -> [u8; 20] { #[test] fn eip3009_struct_hash_is_deterministic() { - use crate::openhuman::web3::wallet::primitives::eip712::{ - transfer_with_authorization_hash, u256_from_u64, - }; + use tinywallet::eip712::{transfer_with_authorization_hash, u256_from_u64}; let from = address_bytes(&"aa".repeat(20)); let to = address_bytes(&"bb".repeat(20)); @@ -538,7 +532,7 @@ 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 `crate::openhuman::web3::wallet::primitives::address::evm` renders it, and as + // Checksummed, as `tinywallet::address::evm` renders it, and as // the requirement itself carried it. assert_eq!( evm.authorization.to, @@ -546,8 +540,8 @@ fn build_evm_payment_with_test_key_produces_valid_payload() { ); assert_eq!(evm.authorization.from, from_address); - use crate::openhuman::web3::wallet::primitives::eip712; 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"); diff --git a/vendor/tinybus b/vendor/tinybus index 6ca0b0b673..c35105f95b 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit 6ca0b0b6739a49396e36be21d450f07cf85b9de2 +Subproject commit c35105f95b5efd49f63aec3f82f8bc2154694977 diff --git a/vendor/tinywallet b/vendor/tinywallet new file mode 160000 index 0000000000..54bcfeb4c3 --- /dev/null +++ b/vendor/tinywallet @@ -0,0 +1 @@ +Subproject commit 54bcfeb4c32ab3df46ce84a6afdfa4133d8b4501