diff --git a/.github/workflows/usdt-tests.yml b/.github/workflows/usdt-tests.yml new file mode 100644 index 0000000..f20056a --- /dev/null +++ b/.github/workflows/usdt-tests.yml @@ -0,0 +1,35 @@ +name: USDT Tests + +on: + pull_request: + push: + branches: [master, main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + usdt-tests: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libdbus-1-dev pkg-config protobuf-compiler + - name: Check formatting + run: cargo fmt --check + - name: Run USDT tests + run: cargo test --locked --lib modules::usdt + - name: Check Rust code + run: cargo clippy --locked --lib --tests diff --git a/.gitignore b/.gitignore index 3b5da3e..94644d9 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ bindings/ios/BitkitCore.xcframework.zip .claude/worktrees *.local.* !*.local.template* + +/tests/usdt-fork/node_modules/ diff --git a/AGENTS.md b/AGENTS.md index c182e83..743a64e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Android bindings are built and published by `.github/workflows/gradle-publish.ym ```bash cargo test # All tests -cargo test modules:: # Single module (scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky) +cargo test modules:: # Single module (scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky, usdt) ``` ## Lint & Format @@ -35,7 +35,7 @@ Android bindings use ktlint via Gradle plugin (`org.jlleitschuh.gradle.ktlint`), ## Architecture - `src/lib.rs` — UniFFI exports and module re-exports -- `src/modules/`: core modules: scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky +- `src/modules/`: core modules: scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky, usdt - `bindings/` — Platform-specific binding outputs (ios/, android/, python/) - `build.sh`, `build_ios.sh`, `build_android.sh`, `build_python.sh` — Build scripts diff --git a/Cargo.lock b/Cargo.lock index 5366796..e3347fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,7 +14,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -75,6 +75,127 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloy-json-abi" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "858acd7fdad1e4a7057fd9c1b39c1f2cd6bcd57ebf3c56e2853c02ead049e816" +dependencies = [ + "alloy-primitives", + "alloy-sol-type-parser", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-primitives" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5e9dbd49258ac3ab893a481d46be29b58be7f734dcac46cd80b6b13ee36566c" +dependencies = [ + "alloy-rlp", + "bytes", + "cfg-if", + "const-hex", + "derive_more", + "fixed-cache", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "indexmap 2.13.0", + "itoa", + "k256", + "keccak-asm", + "paste", + "proptest", + "rand 0.9.2", + "rapidhash", + "ruint", + "rustc-hash", + "secp256k1 0.31.1", + "serde", + "sha3 0.11.0", +] + +[[package]] +name = "alloy-rlp" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24671b1f62edcf0f9b62994c7bf72cd621a04a4b99f5020ece1a647b40e2f103" +dependencies = [ + "arrayvec", + "bytes", +] + +[[package]] +name = "alloy-sol-macro" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dd79f578c3912f1fc2a150dbeb8110b8cfb976c60f98ebf6de9d5da5965a2b" +dependencies = [ + "alloy-sol-macro-expander", + "alloy-sol-macro-input", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "alloy-sol-macro-expander" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edb8520f2f94275e1caa73c85207dcc78ec402a9a4c429240f9a0783a8f16c0" +dependencies = [ + "alloy-sol-macro-input", + "const-hex", + "heck", + "indexmap 2.13.0", + "proc-macro-error3", + "proc-macro2", + "quote", + "sha3 0.11.0", + "syn 2.0.117", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-macro-input" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66af2d9344882172993be5f5cbfd349fdfa52cb548f7af8715b446fb35ef6001" +dependencies = [ + "const-hex", + "dunce", + "heck", + "macro-string", + "proc-macro2", + "quote", + "syn 2.0.117", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-type-parser" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c2a8abc3334044013b23d5e438cc5dc802e5544cc42b713d0840d5185967cc" +dependencies = [ + "serde", + "winnow 1.0.4", +] + +[[package]] +name = "alloy-sol-types" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6f87cf007caa54e95455875fbd25879b008d0d9c7c340a2c258b3a3400a28c" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-macro", + "serde", +] + [[package]] name = "android_log-sys" version = "0.3.2" @@ -169,6 +290,269 @@ dependencies = [ "password-hash", ] +[[package]] +name = "ark-ff" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6" +dependencies = [ + "ark-ff-asm 0.3.0", + "ark-ff-macros 0.3.0", + "ark-serialize 0.3.0", + "ark-std 0.3.0", + "derivative", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.3.3", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm 0.4.2", + "ark-ff-macros 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "digest 0.10.7", + "itertools 0.10.5", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.4.1", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm 0.5.0", + "ark-ff-macros 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" +dependencies = [ + "ark-ff-asm 0.6.0", + "ark-ff-macros 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "educe", + "num-bigint", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-ff-asm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-ff-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" +dependencies = [ + "num-bigint", + "num-traits", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-ff-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-serialize" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671" +dependencies = [ + "ark-std 0.3.0", + "digest 0.9.0", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-std 0.4.0", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" +dependencies = [ + "ark-serialize-derive", + "ark-std 0.6.0", + "digest 0.10.7", + "num-bigint", + "serde_with", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-std" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "ark-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -220,7 +604,7 @@ dependencies = [ "memchr", "serde", "serde_derive", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -262,6 +646,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -429,6 +824,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "unicode-normalization", + "zeroize", ] [[package]] @@ -441,7 +837,7 @@ dependencies = [ "base85", "bip39", "bitcoin 0.32.8", - "sha3", + "sha3 0.10.9", ] [[package]] @@ -589,8 +985,11 @@ dependencies = [ [[package]] name = "bitkitcore" -version = "0.5.18" +version = "0.6.0" dependencies = [ + "alloy-primitives", + "alloy-rlp", + "alloy-sol-types", "android_logger", "async-trait", "base64 0.22.1", @@ -655,7 +1054,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -681,6 +1080,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -710,7 +1118,7 @@ dependencies = [ "dbus", "dbus-tokio", "futures", - "itertools", + "itertools 0.14.0", "log", "serde", "serde-xml-rs", @@ -802,6 +1210,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" dependencies = [ "sha2", + "tinyvec", ] [[package]] @@ -838,6 +1247,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + [[package]] name = "bytecheck" version = "0.6.12" @@ -871,6 +1286,9 @@ name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] [[package]] name = "camino" @@ -898,7 +1316,7 @@ checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" dependencies = [ "camino", "cargo-platform", - "semver", + "semver 1.0.27", "serde", "serde_json", "thiserror 2.0.18", @@ -1001,7 +1419,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] @@ -1079,18 +1497,60 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-hex" +version = "1.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e59eef12462b0f9b0a3620219be5d639afd79fe39dff0a42c3997061f9298b4" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "constant_time_eq" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -1261,6 +1721,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "crypto_secretbox" version = "0.1.1" @@ -1294,9 +1763,9 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", - "rustc_version", + "rustc_version 0.4.1", "subtle", "zeroize", ] @@ -1418,6 +1887,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -1426,18 +1930,37 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", - "crypto-common", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1492,10 +2015,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", + "spki", ] [[package]] @@ -1523,6 +2047,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "either" version = "1.15.0" @@ -1586,10 +2122,11 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", + "pkcs8", "rand_core 0.6.4", "sec1", "subtle", @@ -1617,6 +2154,26 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -1684,6 +2241,28 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fastrlp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "fastrlp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + [[package]] name = "ff" version = "0.13.1" @@ -1706,6 +2285,28 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixed-cache" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe63500644ef0269fe6b744e7e5dc5c20b5eebf3d881bc2be53f194636f6583" +dependencies = [ + "equivalent", + "rapidhash", +] + +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand 0.8.5", + "rustc-hex", + "static_assertions", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -1739,6 +2340,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1944,7 +2551,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", + "windows-link 0.2.1", "windows-result 0.4.1", ] @@ -2100,7 +2707,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -2109,6 +2716,17 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] + [[package]] name = "hashlink" version = "0.9.1" @@ -2126,7 +2744,7 @@ checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" dependencies = [ "atomic-polyfill", "hash32", - "rustc_version", + "rustc_version 0.4.1", "serde", "spin 0.9.8", "stable_deref_trait", @@ -2208,7 +2826,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -2256,6 +2874,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -2466,6 +3093,37 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "impl-codec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -2529,6 +3187,24 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2575,10 +3251,12 @@ checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46" dependencies = [ "defmt", "jiff-static", + "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", "serde_core", + "windows-link 0.2.1", ] [[package]] @@ -2592,6 +3270,21 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.19.0" @@ -2656,6 +3349,7 @@ dependencies = [ "cfg-if", "ecdsa", "elliptic-curve", + "once_cell", "sha2", ] @@ -2668,6 +3362,41 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "keccak-asm" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5dc2c0d691cbf7595cde551ced329cca99c2387c2cbc97754c5d0cd045d3ee" +dependencies = [ + "digest 0.10.7", + "sha3-asm", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "lazy-regex" version = "3.6.0" @@ -2915,6 +3644,17 @@ dependencies = [ "libc", ] +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "mainline" version = "5.4.0" @@ -3112,6 +3852,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -3241,6 +3982,34 @@ dependencies = [ "winapi", ] +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "parking" version = "2.2.1" @@ -3306,13 +4075,19 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pbkdf2" version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", + "digest 0.10.7", "hmac", ] @@ -3322,6 +4097,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" +dependencies = [ + "memchr", + "ucd-trie", +] + [[package]] name = "petgraph" version = "0.7.1" @@ -3329,7 +4114,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.13.0", ] [[package]] @@ -3540,6 +4325,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primitive-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" +dependencies = [ + "fixed-hash", + "impl-codec", + "uint", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -3559,6 +4355,16 @@ dependencies = [ "quote", ] +[[package]] +name = "proc-macro-error-attr3" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82366fd7d8b7a440d66d13418820c69df9b3908bcb1a0476d7f5ce5d12f5a04d" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "proc-macro-error2" version = "2.0.1" @@ -3571,6 +4377,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "proc-macro-error3" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b511283ea8a74b4b39447b128c5d00f03a356b7424554b13e298a5550100d9ac" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3580,6 +4398,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags 2.11.0", + "num-traits", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + [[package]] name = "prost" version = "0.13.5" @@ -3597,7 +4430,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck", - "itertools", + "itertools 0.14.0", "log", "multimap", "once_cell", @@ -3617,7 +4450,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -3917,6 +4750,7 @@ checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", + "serde", ] [[package]] @@ -3966,6 +4800,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", + "serde", ] [[package]] @@ -3974,6 +4809,15 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "rand_xoshiro" version = "0.8.1" @@ -3983,6 +4827,15 @@ dependencies = [ "rand_core 0.10.0", ] +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -4001,6 +4854,26 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "regex" version = "1.12.3" @@ -4113,7 +4986,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -4145,6 +5018,51 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rlp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" +dependencies = [ + "bytes", + "rustc-hex", +] + +[[package]] +name = "ruint" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2973657b5127d510e230f5c63d2d106af9c8f79393d8b9f4647323e8196bdde5" +dependencies = [ + "alloy-rlp", + "ark-ff 0.3.0", + "ark-ff 0.4.2", + "ark-ff 0.5.0", + "ark-ff 0.6.0", + "bytes", + "fastrlp 0.3.1", + "fastrlp 0.4.0", + "num-bigint", + "num-integer", + "num-traits", + "parity-scale-codec", + "primitive-types", + "proptest", + "rand 0.8.5", + "rand 0.9.2", + "rlp", + "ruint-macro", + "serde_core", + "valuable", + "zeroize", +] + +[[package]] +name = "ruint-macro" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" + [[package]] name = "rusb" version = "0.9.4" @@ -4206,13 +5124,28 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustc_version" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +dependencies = [ + "semver 0.11.0", +] + [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver", + "semver 1.0.27", ] [[package]] @@ -4368,6 +5301,30 @@ dependencies = [ "parking_lot 0.12.5", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4431,6 +5388,7 @@ dependencies = [ "base16ct", "der", "generic-array", + "pkcs8", "subtle", "zeroize", ] @@ -4470,6 +5428,17 @@ dependencies = [ "secp256k1-sys 0.10.1", ] +[[package]] +name = "secp256k1" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" +dependencies = [ + "bitcoin_hashes 0.14.1", + "rand 0.9.2", + "secp256k1-sys 0.11.0", +] + [[package]] name = "secp256k1" version = "0.32.0-beta.2" @@ -4498,6 +5467,15 @@ dependencies = [ "cc", ] +[[package]] +name = "secp256k1-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" +dependencies = [ + "cc", +] + [[package]] name = "secp256k1-sys" version = "0.12.0" @@ -4559,6 +5537,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] + [[package]] name = "semver" version = "1.0.27" @@ -4569,6 +5556,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "semver-parser" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" +dependencies = [ + "pest", +] + [[package]] name = "serde" version = "1.0.228" @@ -4667,6 +5663,26 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "time", +] + [[package]] name = "serial_test" version = "3.4.0" @@ -4719,7 +5735,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -4736,7 +5752,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -4745,8 +5761,28 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" dependencies = [ - "digest", - "keccak", + "digest 0.10.7", + "keccak 0.1.6", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.2", +] + +[[package]] +name = "sha3-asm" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6287fd675f713484342a89cbf0a386abef5f15919cfad607e5e1f19e1e15331" +dependencies = [ + "cc", + "cfg-if", ] [[package]] @@ -4780,7 +5816,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -4943,6 +5979,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-solidity" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb6a2e3c7f7a3e4e83d1752cec5d1e357ced0cf96e85419b6a07f227db3def3a" +dependencies = [ + "paste", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -5271,10 +6330,10 @@ version = "0.25.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ - "indexmap", + "indexmap 2.13.0", "toml_datetime", "toml_parser", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -5283,7 +6342,7 @@ version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ - "winnow", + "winnow 0.7.15", ] [[package]] @@ -5458,6 +6517,30 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unescaper" version = "0.1.10" @@ -5482,6 +6565,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -5518,7 +6607,7 @@ dependencies = [ "glob", "goblin", "heck", - "indexmap", + "indexmap 2.13.0", "once_cell", "serde", "tempfile", @@ -5537,6 +6626,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98" dependencies = [ "anyhow", + "async-compat", "bytes", "once_cell", "static_assertions", @@ -5549,7 +6639,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09acd2ce09c777dd65ee97c251d33c8a972afc04873f1e3b21eb3492ade16933" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "proc-macro2", "quote", "syn 2.0.117", @@ -5592,7 +6682,7 @@ checksum = "dd76b3ac8a2d964ca9fce7df21c755afb4c77b054a85ad7a029ad179cc5abb8a" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "tempfile", "uniffi_internal_macros", ] @@ -5615,7 +6705,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -5836,7 +6926,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "wasm-encoder", "wasmparser", ] @@ -5849,8 +6939,8 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap", - "semver", + "indexmap 2.13.0", + "semver 1.0.27", ] [[package]] @@ -6204,6 +7294,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -6232,7 +7331,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -6263,7 +7362,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.0", - "indexmap", + "indexmap 2.13.0", "log", "serde", "serde_derive", @@ -6282,9 +7381,9 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.0", "log", - "semver", + "semver 1.0.27", "serde", "serde_derive", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 2c97c1e..e02db8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [package] name = "bitkitcore" -version = "0.5.18" +version = "0.6.0" edition = "2021" +rust-version = "1.90" [lib] crate-type = ["cdylib", "staticlib", "rlib"] @@ -9,7 +10,7 @@ name = "bitkitcore" path = "src/lib.rs" [dependencies] -uniffi = { version = "0.29.4", features = [ "cli", "bindgen" ] } +uniffi = { version = "0.29.4", features = [ "cli", "bindgen", "tokio" ] } serde_json = "1.0.114" serde = { version = "^1.0.209", features = ["derive"] } tokio = { version = "1.40.0", features = ["rt", "rt-multi-thread", "macros", "time", "sync"] } @@ -35,7 +36,7 @@ r2d2_sqlite = "0.25.0" bitcoin-address-generator = "0.2.0" uuid = { version = "1.16.0", features = ["v4"] } hex = "0.4.3" -bip39 = "2.0" +bip39 = { version = "2.0", features = ["zeroize"] } bdk = { version = "0.30.2", features = ["all-keys"] } boltz-client = { version = "0.4.1", default-features = false, features = ["electrum", "ws"] } base64 = "0.22" @@ -48,6 +49,9 @@ pubky-app-specs = "0.4" # Bluetooth (for Android JNI initialization) # btleplug is also pulled in transitively by trezor-connect-rs btleplug = "0.11" +alloy-primitives = { version = "1", default-features = false, features = ["std", "serde"] } +alloy-sol-types = "1" +alloy-rlp = "0.3" # Trezor connect library - non-iOS platforms get USB + Bluetooth [target.'cfg(not(target_os = "ios"))'.dependencies] @@ -73,7 +77,7 @@ jade-client-rs = { git = "https://github.com/synonymdev/jade-client-rs", rev = " jade-client-rs = { git = "https://github.com/synonymdev/jade-client-rs", rev = "d46d02a", features = ["reqwest-pinserver", "serial"] } [dev-dependencies] -tokio = { version = "1.40.0", features = ["full"] } +tokio = { version = "1.40.0", features = ["full", "test-util"] } serde_json = "1.0.114" hex = "0.4.3" test-case = "3.3.1" diff --git a/Package.swift b/Package.swift index 3a5ef3a..794f6ec 100644 --- a/Package.swift +++ b/Package.swift @@ -2,11 +2,14 @@ // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription +import Foundation -let tag = "v0.5.18" -let checksum = "c2bae82e20dd485cc82285cff7667d487d0c64bbd6f4686cb4e8f599e2f6a1eb" +let tag = "v0.6.0" +let checksum = "ac07f72265cde752e9f7951383e8d1a01f3568676d156ee592a715bc5a38d32a" let url = "https://github.com/synonymdev/bitkit-core/releases/download/\(tag)/BitkitCore.xcframework.zip" +let localBinary = ProcessInfo.processInfo.environment["BITKIT_CORE_LOCAL"] == "1" + let package = Package( name: "bitkitcore", platforms: [ @@ -26,10 +29,15 @@ let package = Package( path: "./bindings/ios", sources: ["bitkitcore.swift"] ), - .binaryTarget( - name: "BitkitCoreFFI", - url: url, - checksum: checksum - ) + localBinary + ? .binaryTarget( + name: "BitkitCoreFFI", + path: "./dist/ios/BitkitCore.xcframework" + ) + : .binaryTarget( + name: "BitkitCoreFFI", + url: url, + checksum: checksum + ) ] ) diff --git a/README.md b/README.md index 790e807..f7093b6 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ - Channel opening and management - Just-in-time channel creation - Gift code payments and redemption +- [USDT Module](src/modules/usdt/README.md) + - Seed-derived Arbitrum EIP-7702 accounts and USDT-only payment fees + - Quotes, signing, payment recovery and persistent activity - Trezor Module - Integration with Trezor hardware wallets through deep linking - Get device features and capabilities diff --git a/bindings/android/gradle.properties b/bindings/android/gradle.properties index 402d8e0..750a009 100644 --- a/bindings/android/gradle.properties +++ b/bindings/android/gradle.properties @@ -3,4 +3,4 @@ android.useAndroidX=true android.enableJetifier=true kotlin.code.style=official group=com.synonym -version=0.5.18 +version=0.6.0 diff --git a/bindings/ios/bitkitcore.swift b/bindings/ios/bitkitcore.swift index 27fbb8c..5b7f48d 100644 --- a/bindings/ios/bitkitcore.swift +++ b/bindings/ios/bitkitcore.swift @@ -2561,6 +2561,306 @@ public func FfiConverterTypeUrDecoder_lower(_ value: UrDecoder) -> UnsafeMutable + + +public protocol UsdtWalletProtocol: AnyObject, Sendable { + + func balance() async throws -> UInt64 + + /** + * Checks recent direct-payment execution with a bounded request budget. + * Requires the expected operation and transfer in a canonical receipt; current-tip execution is provisional. + * Does not rebroadcast, expire payments or reconcile nonces. Missing evidence leaves the payment pending. + */ + func checkRecentExecution(id: String) async throws -> UsdtTransfer? + + func history() throws -> [UsdtTransfer] + + func quoteTransfer(recipient: String, amount: UInt64) async throws -> UsdtQuote + + func receiveAddress() -> String + + func receiveUri() -> String + + /** + * Reconciles pending execution using chain proofs and may rebroadcast the identical signed operation. + */ + func refreshTransfers() async throws -> [UsdtTransfer] + + /** + * Repeating a quote ID returns its stored outcome, which may already be failed or replaced. + * A pending outcome is durable and retryable; it does not imply bundler acceptance. + */ + func send(quoteId: String, mnemonic: String, passphrase: String?) async throws -> UsdtTransfer + + /** + * Saves resumable history progress; returns true when caught up and false when more work remains. + * Call between send flows. The soft budget permits an in-flight receipt to finish before yielding. + */ + func syncHistory() async throws -> Bool + +} +open class UsdtWallet: UsdtWalletProtocol, @unchecked Sendable { + fileprivate let pointer: UnsafeMutableRawPointer! + + /// Used to instantiate a [FFIObject] without an actual pointer, for fakes in tests, mostly. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public struct NoPointer { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { + self.pointer = pointer + } + + // This constructor can be used to instantiate a fake object. + // - Parameter noPointer: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing [Pointer] the FFI lower functions will crash. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public init(noPointer: NoPointer) { + self.pointer = nil + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public func uniffiClonePointer() -> UnsafeMutableRawPointer { + return try! rustCall { uniffi_bitkitcore_fn_clone_usdtwallet(self.pointer, $0) } + } + /** + * Creates the sole owner of this wallet's database; reuse it for all calls until it is dropped. + */ +public convenience init(address: String, storagePath: String, rpcUrl: String, bundlerUrl: String)throws { + let pointer = + try rustCallWithError(FfiConverterTypeUsdtError_lift) { + uniffi_bitkitcore_fn_constructor_usdtwallet_new( + FfiConverterString.lower(address), + FfiConverterString.lower(storagePath), + FfiConverterString.lower(rpcUrl), + FfiConverterString.lower(bundlerUrl),$0 + ) +} + self.init(unsafeFromRawPointer: pointer) +} + + deinit { + guard let pointer = pointer else { + return + } + + try! rustCall { uniffi_bitkitcore_fn_free_usdtwallet(pointer, $0) } + } + + + + +open func balance()async throws -> UInt64 { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtwallet_balance( + self.uniffiClonePointer() + + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_u64, + completeFunc: ffi_bitkitcore_rust_future_complete_u64, + freeFunc: ffi_bitkitcore_rust_future_free_u64, + liftFunc: FfiConverterUInt64.lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + + /** + * Checks recent direct-payment execution with a bounded request budget. + * Requires the expected operation and transfer in a canonical receipt; current-tip execution is provisional. + * Does not rebroadcast, expire payments or reconcile nonces. Missing evidence leaves the payment pending. + */ +open func checkRecentExecution(id: String)async throws -> UsdtTransfer? { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtwallet_check_recent_execution( + self.uniffiClonePointer(), + FfiConverterString.lower(id) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterOptionTypeUsdtTransfer.lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + +open func history()throws -> [UsdtTransfer] { + return try FfiConverterSequenceTypeUsdtTransfer.lift(try rustCallWithError(FfiConverterTypeUsdtError_lift) { + uniffi_bitkitcore_fn_method_usdtwallet_history(self.uniffiClonePointer(),$0 + ) +}) +} + +open func quoteTransfer(recipient: String, amount: UInt64)async throws -> UsdtQuote { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtwallet_quote_transfer( + self.uniffiClonePointer(), + FfiConverterString.lower(recipient),FfiConverterUInt64.lower(amount) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeUsdtQuote_lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + +open func receiveAddress() -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_bitkitcore_fn_method_usdtwallet_receive_address(self.uniffiClonePointer(),$0 + ) +}) +} + +open func receiveUri() -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_bitkitcore_fn_method_usdtwallet_receive_uri(self.uniffiClonePointer(),$0 + ) +}) +} + + /** + * Reconciles pending execution using chain proofs and may rebroadcast the identical signed operation. + */ +open func refreshTransfers()async throws -> [UsdtTransfer] { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtwallet_refresh_transfers( + self.uniffiClonePointer() + + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterSequenceTypeUsdtTransfer.lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + + /** + * Repeating a quote ID returns its stored outcome, which may already be failed or replaced. + * A pending outcome is durable and retryable; it does not imply bundler acceptance. + */ +open func send(quoteId: String, mnemonic: String, passphrase: String?)async throws -> UsdtTransfer { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtwallet_send( + self.uniffiClonePointer(), + FfiConverterString.lower(quoteId),FfiConverterString.lower(mnemonic),FfiConverterOptionString.lower(passphrase) + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_rust_buffer, + completeFunc: ffi_bitkitcore_rust_future_complete_rust_buffer, + freeFunc: ffi_bitkitcore_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeUsdtTransfer_lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + + /** + * Saves resumable history progress; returns true when caught up and false when more work remains. + * Call between send flows. The soft budget permits an in-flight receipt to finish before yielding. + */ +open func syncHistory()async throws -> Bool { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_bitkitcore_fn_method_usdtwallet_sync_history( + self.uniffiClonePointer() + + ) + }, + pollFunc: ffi_bitkitcore_rust_future_poll_i8, + completeFunc: ffi_bitkitcore_rust_future_complete_i8, + freeFunc: ffi_bitkitcore_rust_future_free_i8, + liftFunc: FfiConverterBool.lift, + errorHandler: FfiConverterTypeUsdtError_lift + ) +} + + +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUsdtWallet: FfiConverter { + + typealias FfiType = UnsafeMutableRawPointer + typealias SwiftType = UsdtWallet + + public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> UsdtWallet { + return UsdtWallet(unsafeFromRawPointer: pointer) + } + + public static func lower(_ value: UsdtWallet) -> UnsafeMutableRawPointer { + return value.uniffiClonePointer() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtWallet { + let v: UInt64 = try readInt(&buf) + // The Rust code won't compile if a pointer won't fit in a UInt64. + // We have to go via `UInt` because that's the thing that's the size of a pointer. + let ptr = UnsafeMutableRawPointer(bitPattern: UInt(truncatingIfNeeded: v)) + if (ptr == nil) { + throw UniffiInternalError.unexpectedNullPointer + } + return try lift(ptr!) + } + + public static func write(_ value: UsdtWallet, into buf: inout [UInt8]) { + // This fiddling is because `Int` is the thing that's the same size as a pointer. + // The Rust code won't compile if a pointer won't fit in a `UInt64`. + writeInt(&buf, UInt64(bitPattern: Int64(Int(bitPattern: lower(value))))) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtWallet_lift(_ pointer: UnsafeMutableRawPointer) throws -> UsdtWallet { + return try FfiConverterTypeUsdtWallet.lift(pointer) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtWallet_lower(_ value: UsdtWallet) -> UnsafeMutableRawPointer { + return FfiConverterTypeUsdtWallet.lower(value) +} + + + + /** * Grouped address lists for an account. */ @@ -15657,67 +15957,73 @@ public func FfiConverterTypeUrDecoderStatus_lower(_ value: UrDecoderStatus) -> R } -public struct ValidationResult { - public var address: String - public var network: NetworkType - public var addressType: AddressType +public struct UsdtPaymentRequest { + public var recipient: String + public var amount: UInt64? + /** + * An explicit network in the payment URI; bare addresses have no restriction. + */ + public var chainId: UInt64? // Default memberwise initializers are never public by default, so we // declare one manually. - public init(address: String, network: NetworkType, addressType: AddressType) { - self.address = address - self.network = network - self.addressType = addressType + public init(recipient: String, amount: UInt64?, + /** + * An explicit network in the payment URI; bare addresses have no restriction. + */chainId: UInt64?) { + self.recipient = recipient + self.amount = amount + self.chainId = chainId } } #if compiler(>=6) -extension ValidationResult: Sendable {} +extension UsdtPaymentRequest: Sendable {} #endif -extension ValidationResult: Equatable, Hashable { - public static func ==(lhs: ValidationResult, rhs: ValidationResult) -> Bool { - if lhs.address != rhs.address { +extension UsdtPaymentRequest: Equatable, Hashable { + public static func ==(lhs: UsdtPaymentRequest, rhs: UsdtPaymentRequest) -> Bool { + if lhs.recipient != rhs.recipient { return false } - if lhs.network != rhs.network { + if lhs.amount != rhs.amount { return false } - if lhs.addressType != rhs.addressType { + if lhs.chainId != rhs.chainId { return false } return true } public func hash(into hasher: inout Hasher) { - hasher.combine(address) - hasher.combine(network) - hasher.combine(addressType) + hasher.combine(recipient) + hasher.combine(amount) + hasher.combine(chainId) } } -extension ValidationResult: Codable {} +extension UsdtPaymentRequest: Codable {} #if swift(>=5.8) @_documentation(visibility: private) #endif -public struct FfiConverterTypeValidationResult: FfiConverterRustBuffer { - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ValidationResult { +public struct FfiConverterTypeUsdtPaymentRequest: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtPaymentRequest { return - try ValidationResult( - address: FfiConverterString.read(from: &buf), - network: FfiConverterTypeNetworkType.read(from: &buf), - addressType: FfiConverterTypeAddressType.read(from: &buf) + try UsdtPaymentRequest( + recipient: FfiConverterString.read(from: &buf), + amount: FfiConverterOptionUInt64.read(from: &buf), + chainId: FfiConverterOptionUInt64.read(from: &buf) ) } - public static func write(_ value: ValidationResult, into buf: inout [UInt8]) { - FfiConverterString.write(value.address, into: &buf) - FfiConverterTypeNetworkType.write(value.network, into: &buf) - FfiConverterTypeAddressType.write(value.addressType, into: &buf) + public static func write(_ value: UsdtPaymentRequest, into buf: inout [UInt8]) { + FfiConverterString.write(value.recipient, into: &buf) + FfiConverterOptionUInt64.write(value.amount, into: &buf) + FfiConverterOptionUInt64.write(value.chainId, into: &buf) } } @@ -15725,29 +16031,347 @@ public struct FfiConverterTypeValidationResult: FfiConverterRustBuffer { #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeValidationResult_lift(_ buf: RustBuffer) throws -> ValidationResult { - return try FfiConverterTypeValidationResult.lift(buf) +public func FfiConverterTypeUsdtPaymentRequest_lift(_ buf: RustBuffer) throws -> UsdtPaymentRequest { + return try FfiConverterTypeUsdtPaymentRequest.lift(buf) } #if swift(>=5.8) @_documentation(visibility: private) #endif -public func FfiConverterTypeValidationResult_lower(_ value: ValidationResult) -> RustBuffer { - return FfiConverterTypeValidationResult.lower(value) +public func FfiConverterTypeUsdtPaymentRequest_lower(_ value: UsdtPaymentRequest) -> RustBuffer { + return FfiConverterTypeUsdtPaymentRequest.lower(value) } -/** - * Balance breakdown from BDK. - */ -public struct WalletBalance { - /** - * Confirmed and spendable balance (sats) - */ - public var confirmed: UInt64 - /** - * Immature coinbase outputs (sats) - */ +public struct UsdtQuote { + public var id: String + public var recipient: String + public var amount: UInt64 + public var maximumFee: UInt64 + public var expiresAt: UInt64 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: String, recipient: String, amount: UInt64, maximumFee: UInt64, expiresAt: UInt64) { + self.id = id + self.recipient = recipient + self.amount = amount + self.maximumFee = maximumFee + self.expiresAt = expiresAt + } +} + +#if compiler(>=6) +extension UsdtQuote: Sendable {} +#endif + + +extension UsdtQuote: Equatable, Hashable { + public static func ==(lhs: UsdtQuote, rhs: UsdtQuote) -> Bool { + if lhs.id != rhs.id { + return false + } + if lhs.recipient != rhs.recipient { + return false + } + if lhs.amount != rhs.amount { + return false + } + if lhs.maximumFee != rhs.maximumFee { + return false + } + if lhs.expiresAt != rhs.expiresAt { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(recipient) + hasher.combine(amount) + hasher.combine(maximumFee) + hasher.combine(expiresAt) + } +} + +extension UsdtQuote: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUsdtQuote: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtQuote { + return + try UsdtQuote( + id: FfiConverterString.read(from: &buf), + recipient: FfiConverterString.read(from: &buf), + amount: FfiConverterUInt64.read(from: &buf), + maximumFee: FfiConverterUInt64.read(from: &buf), + expiresAt: FfiConverterUInt64.read(from: &buf) + ) + } + + public static func write(_ value: UsdtQuote, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterString.write(value.recipient, into: &buf) + FfiConverterUInt64.write(value.amount, into: &buf) + FfiConverterUInt64.write(value.maximumFee, into: &buf) + FfiConverterUInt64.write(value.expiresAt, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtQuote_lift(_ buf: RustBuffer) throws -> UsdtQuote { + return try FfiConverterTypeUsdtQuote.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtQuote_lower(_ value: UsdtQuote) -> RustBuffer { + return FfiConverterTypeUsdtQuote.lower(value) +} + + +public struct UsdtTransfer { + public var id: String + /** + * Source transaction hash, absent until execution is observed. + */ + public var txHash: String? + public var userOperationHash: String? + public var recipient: String + public var amount: UInt64 + public var receivedAmount: UInt64 + public var fee: UInt64? + public var isIncoming: Bool + public var status: UsdtTransferStatus + public var timestamp: UInt64 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: String, + /** + * Source transaction hash, absent until execution is observed. + */txHash: String?, userOperationHash: String?, recipient: String, amount: UInt64, receivedAmount: UInt64, fee: UInt64?, isIncoming: Bool, status: UsdtTransferStatus, timestamp: UInt64) { + self.id = id + self.txHash = txHash + self.userOperationHash = userOperationHash + self.recipient = recipient + self.amount = amount + self.receivedAmount = receivedAmount + self.fee = fee + self.isIncoming = isIncoming + self.status = status + self.timestamp = timestamp + } +} + +#if compiler(>=6) +extension UsdtTransfer: Sendable {} +#endif + + +extension UsdtTransfer: Equatable, Hashable { + public static func ==(lhs: UsdtTransfer, rhs: UsdtTransfer) -> Bool { + if lhs.id != rhs.id { + return false + } + if lhs.txHash != rhs.txHash { + return false + } + if lhs.userOperationHash != rhs.userOperationHash { + return false + } + if lhs.recipient != rhs.recipient { + return false + } + if lhs.amount != rhs.amount { + return false + } + if lhs.receivedAmount != rhs.receivedAmount { + return false + } + if lhs.fee != rhs.fee { + return false + } + if lhs.isIncoming != rhs.isIncoming { + return false + } + if lhs.status != rhs.status { + return false + } + if lhs.timestamp != rhs.timestamp { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(txHash) + hasher.combine(userOperationHash) + hasher.combine(recipient) + hasher.combine(amount) + hasher.combine(receivedAmount) + hasher.combine(fee) + hasher.combine(isIncoming) + hasher.combine(status) + hasher.combine(timestamp) + } +} + +extension UsdtTransfer: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUsdtTransfer: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtTransfer { + return + try UsdtTransfer( + id: FfiConverterString.read(from: &buf), + txHash: FfiConverterOptionString.read(from: &buf), + userOperationHash: FfiConverterOptionString.read(from: &buf), + recipient: FfiConverterString.read(from: &buf), + amount: FfiConverterUInt64.read(from: &buf), + receivedAmount: FfiConverterUInt64.read(from: &buf), + fee: FfiConverterOptionUInt64.read(from: &buf), + isIncoming: FfiConverterBool.read(from: &buf), + status: FfiConverterTypeUsdtTransferStatus.read(from: &buf), + timestamp: FfiConverterUInt64.read(from: &buf) + ) + } + + public static func write(_ value: UsdtTransfer, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) + FfiConverterOptionString.write(value.txHash, into: &buf) + FfiConverterOptionString.write(value.userOperationHash, into: &buf) + FfiConverterString.write(value.recipient, into: &buf) + FfiConverterUInt64.write(value.amount, into: &buf) + FfiConverterUInt64.write(value.receivedAmount, into: &buf) + FfiConverterOptionUInt64.write(value.fee, into: &buf) + FfiConverterBool.write(value.isIncoming, into: &buf) + FfiConverterTypeUsdtTransferStatus.write(value.status, into: &buf) + FfiConverterUInt64.write(value.timestamp, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtTransfer_lift(_ buf: RustBuffer) throws -> UsdtTransfer { + return try FfiConverterTypeUsdtTransfer.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtTransfer_lower(_ value: UsdtTransfer) -> RustBuffer { + return FfiConverterTypeUsdtTransfer.lower(value) +} + + +public struct ValidationResult { + public var address: String + public var network: NetworkType + public var addressType: AddressType + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(address: String, network: NetworkType, addressType: AddressType) { + self.address = address + self.network = network + self.addressType = addressType + } +} + +#if compiler(>=6) +extension ValidationResult: Sendable {} +#endif + + +extension ValidationResult: Equatable, Hashable { + public static func ==(lhs: ValidationResult, rhs: ValidationResult) -> Bool { + if lhs.address != rhs.address { + return false + } + if lhs.network != rhs.network { + return false + } + if lhs.addressType != rhs.addressType { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(address) + hasher.combine(network) + hasher.combine(addressType) + } +} + +extension ValidationResult: Codable {} + + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeValidationResult: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ValidationResult { + return + try ValidationResult( + address: FfiConverterString.read(from: &buf), + network: FfiConverterTypeNetworkType.read(from: &buf), + addressType: FfiConverterTypeAddressType.read(from: &buf) + ) + } + + public static func write(_ value: ValidationResult, into buf: inout [UInt8]) { + FfiConverterString.write(value.address, into: &buf) + FfiConverterTypeNetworkType.write(value.network, into: &buf) + FfiConverterTypeAddressType.write(value.addressType, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeValidationResult_lift(_ buf: RustBuffer) throws -> ValidationResult { + return try FfiConverterTypeValidationResult.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeValidationResult_lower(_ value: ValidationResult) -> RustBuffer { + return FfiConverterTypeValidationResult.lower(value) +} + + +/** + * Balance breakdown from BDK. + */ +public struct WalletBalance { + /** + * Confirmed and spendable balance (sats) + */ + public var confirmed: UInt64 + /** + * Immature coinbase outputs (sats) + */ public var immature: UInt64 /** * Unconfirmed UTXOs from trusted sources (own change) (sats) @@ -22781,12 +23405,282 @@ extension UrPayload: Codable {} -// Note that we don't yet support `indirect` for enums. -// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. -/** - * Which wallet a connection should open. - * - * Passed to `trezor_connect` and consumed at connect time — the passphrase is + +public enum UsdtError: Swift.Error { + + + + case InvalidAmount + case InvalidAddress + case WrongNetwork + case InvalidCredentials + case UnsupportedDelegation + case InsufficientBalance + case QuoteExpired + case PendingTransfer + case UnsupportedRoute + case NotConfigured + case NetworkUnavailable + case RateLimited + case LogRangeTooLarge + case TransactionRejected(reason: String + ) + case Storage(reason: String + ) + case InvalidResponse +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUsdtError: FfiConverterRustBuffer { + typealias SwiftType = UsdtError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .InvalidAmount + case 2: return .InvalidAddress + case 3: return .WrongNetwork + case 4: return .InvalidCredentials + case 5: return .UnsupportedDelegation + case 6: return .InsufficientBalance + case 7: return .QuoteExpired + case 8: return .PendingTransfer + case 9: return .UnsupportedRoute + case 10: return .NotConfigured + case 11: return .NetworkUnavailable + case 12: return .RateLimited + case 13: return .LogRangeTooLarge + case 14: return .TransactionRejected( + reason: try FfiConverterString.read(from: &buf) + ) + case 15: return .Storage( + reason: try FfiConverterString.read(from: &buf) + ) + case 16: return .InvalidResponse + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: UsdtError, into buf: inout [UInt8]) { + switch value { + + + + + + case .InvalidAmount: + writeInt(&buf, Int32(1)) + + + case .InvalidAddress: + writeInt(&buf, Int32(2)) + + + case .WrongNetwork: + writeInt(&buf, Int32(3)) + + + case .InvalidCredentials: + writeInt(&buf, Int32(4)) + + + case .UnsupportedDelegation: + writeInt(&buf, Int32(5)) + + + case .InsufficientBalance: + writeInt(&buf, Int32(6)) + + + case .QuoteExpired: + writeInt(&buf, Int32(7)) + + + case .PendingTransfer: + writeInt(&buf, Int32(8)) + + + case .UnsupportedRoute: + writeInt(&buf, Int32(9)) + + + case .NotConfigured: + writeInt(&buf, Int32(10)) + + + case .NetworkUnavailable: + writeInt(&buf, Int32(11)) + + + case .RateLimited: + writeInt(&buf, Int32(12)) + + + case .LogRangeTooLarge: + writeInt(&buf, Int32(13)) + + + case let .TransactionRejected(reason): + writeInt(&buf, Int32(14)) + FfiConverterString.write(reason, into: &buf) + + + case let .Storage(reason): + writeInt(&buf, Int32(15)) + FfiConverterString.write(reason, into: &buf) + + + case .InvalidResponse: + writeInt(&buf, Int32(16)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtError_lift(_ buf: RustBuffer) throws -> UsdtError { + return try FfiConverterTypeUsdtError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtError_lower(_ value: UsdtError) -> RustBuffer { + return FfiConverterTypeUsdtError.lower(value) +} + + +extension UsdtError: Equatable, Hashable {} + +extension UsdtError: Codable {} + + + + +extension UsdtError: Foundation.LocalizedError { + public var errorDescription: String? { + String(reflecting: self) + } +} + + + + +// Note that we don't yet support `indirect` for enums. +// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. + +public enum UsdtTransferStatus { + + /** + * Signed payment awaiting a conclusive source-chain outcome. + */ + case pending + /** + * Payment received on its destination chain. + */ + case confirmed + /** + * Source payment failed or was proven not to have executed. + */ + case failed + /** + * Another operation consumed the payment nonce. + */ + case replaced +} + + +#if compiler(>=6) +extension UsdtTransferStatus: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeUsdtTransferStatus: FfiConverterRustBuffer { + typealias SwiftType = UsdtTransferStatus + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UsdtTransferStatus { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .pending + + case 2: return .confirmed + + case 3: return .failed + + case 4: return .replaced + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: UsdtTransferStatus, into buf: inout [UInt8]) { + switch value { + + + case .pending: + writeInt(&buf, Int32(1)) + + + case .confirmed: + writeInt(&buf, Int32(2)) + + + case .failed: + writeInt(&buf, Int32(3)) + + + case .replaced: + writeInt(&buf, Int32(4)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtTransferStatus_lift(_ buf: RustBuffer) throws -> UsdtTransferStatus { + return try FfiConverterTypeUsdtTransferStatus.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeUsdtTransferStatus_lower(_ value: UsdtTransferStatus) -> RustBuffer { + return FfiConverterTypeUsdtTransferStatus.lower(value) +} + + +extension UsdtTransferStatus: Equatable, Hashable {} + +extension UsdtTransferStatus: Codable {} + + + + + + +// Note that we don't yet support `indirect` for enums. +// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. +/** + * Which wallet a connection should open. + * + * Passed to `trezor_connect` and consumed at connect time — the passphrase is * a one-shot input, not retained anywhere afterwards. On THP devices (Safe * 5/7) it is bound to the session at `ThpCreateNewSession`; on legacy devices * the mid-operation `PassphraseRequest` is answered from the UI callback @@ -23905,6 +24799,30 @@ fileprivate struct FfiConverterOptionTypeTrezorFeatures: FfiConverterRustBuffer } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeUsdtTransfer: FfiConverterRustBuffer { + typealias SwiftType = UsdtTransfer? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeUsdtTransfer.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeUsdtTransfer.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -25260,6 +26178,31 @@ fileprivate struct FfiConverterSequenceTypeTxOutput: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeUsdtTransfer: FfiConverterRustBuffer { + typealias SwiftType = [UsdtTransfer] + + public static func write(_ value: [UsdtTransfer], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeUsdtTransfer.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [UsdtTransfer] { + let len: Int32 = try readInt(&buf) + var seq = [UsdtTransfer]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeUsdtTransfer.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -28041,6 +28984,35 @@ public func urEncodeCryptoPsbt(psbt: String, maxFragmentLength: UInt32)throws - ) }) } +public func usdtAddress(mnemonic: String, passphrase: String?)throws -> String { + return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeUsdtError_lift) { + uniffi_bitkitcore_fn_func_usdt_address( + FfiConverterString.lower(mnemonic), + FfiConverterOptionString.lower(passphrase),$0 + ) +}) +} +public func usdtFormatAmount(amount: UInt64) -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_bitkitcore_fn_func_usdt_format_amount( + FfiConverterUInt64.lower(amount),$0 + ) +}) +} +public func usdtParseAmount(value: String)throws -> UInt64 { + return try FfiConverterUInt64.lift(try rustCallWithError(FfiConverterTypeUsdtError_lift) { + uniffi_bitkitcore_fn_func_usdt_parse_amount( + FfiConverterString.lower(value),$0 + ) +}) +} +public func usdtParsePaymentRequest(value: String)throws -> UsdtPaymentRequest { + return try FfiConverterTypeUsdtPaymentRequest_lift(try rustCallWithError(FfiConverterTypeUsdtError_lift) { + uniffi_bitkitcore_fn_func_usdt_parse_payment_request( + FfiConverterString.lower(value),$0 + ) +}) +} public func validateBitcoinAddress(address: String)throws -> ValidationResult { return try FfiConverterTypeValidationResult_lift(try rustCallWithError(FfiConverterTypeAddressError_lift) { uniffi_bitkitcore_fn_func_validate_bitcoin_address( @@ -28691,6 +29663,18 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_func_ur_encode_crypto_psbt() != 32954) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_func_usdt_address() != 48498) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_usdt_format_amount() != 13064) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_usdt_parse_amount() != 3421) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_func_usdt_parse_payment_request() != 63265) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_func_validate_bitcoin_address() != 56003) { return InitializationResult.apiChecksumMismatch } @@ -28775,9 +29759,39 @@ private let initializationResult: InitializationResult = { if (uniffi_bitkitcore_checksum_method_urdecoder_reset() != 6027) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_method_usdtwallet_balance() != 12328) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_method_usdtwallet_check_recent_execution() != 33172) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_method_usdtwallet_history() != 4617) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_method_usdtwallet_quote_transfer() != 56645) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_method_usdtwallet_receive_address() != 540) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_method_usdtwallet_receive_uri() != 33484) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_method_usdtwallet_refresh_transfers() != 34299) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_method_usdtwallet_send() != 60030) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_bitkitcore_checksum_method_usdtwallet_sync_history() != 25445) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_bitkitcore_checksum_constructor_urdecoder_new() != 23014) { return InitializationResult.apiChecksumMismatch } + if (uniffi_bitkitcore_checksum_constructor_usdtwallet_new() != 62148) { + return InitializationResult.apiChecksumMismatch + } uniffiCallbackInitBoltzEventListener() uniffiCallbackInitEventListener() diff --git a/bindings/ios/bitkitcoreFFI.h b/bindings/ios/bitkitcoreFFI.h index f6e06a7..63b655a 100644 --- a/bindings/ios/bitkitcoreFFI.h +++ b/bindings/ios/bitkitcoreFFI.h @@ -660,6 +660,66 @@ RustBuffer uniffi_bitkitcore_fn_method_urdecoder_receive(void*_Nonnull ptr, Rust void uniffi_bitkitcore_fn_method_urdecoder_reset(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CLONE_USDTWALLET +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CLONE_USDTWALLET +void*_Nonnull uniffi_bitkitcore_fn_clone_usdtwallet(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FREE_USDTWALLET +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FREE_USDTWALLET +void uniffi_bitkitcore_fn_free_usdtwallet(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CONSTRUCTOR_USDTWALLET_NEW +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_CONSTRUCTOR_USDTWALLET_NEW +void*_Nonnull uniffi_bitkitcore_fn_constructor_usdtwallet_new(RustBuffer address, RustBuffer storage_path, RustBuffer rpc_url, RustBuffer bundler_url, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_BALANCE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_BALANCE +uint64_t uniffi_bitkitcore_fn_method_usdtwallet_balance(void*_Nonnull ptr +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_CHECK_RECENT_EXECUTION +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_CHECK_RECENT_EXECUTION +uint64_t uniffi_bitkitcore_fn_method_usdtwallet_check_recent_execution(void*_Nonnull ptr, RustBuffer id +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_HISTORY +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_HISTORY +RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_history(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_QUOTE_TRANSFER +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_QUOTE_TRANSFER +uint64_t uniffi_bitkitcore_fn_method_usdtwallet_quote_transfer(void*_Nonnull ptr, RustBuffer recipient, uint64_t amount +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_RECEIVE_ADDRESS +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_RECEIVE_ADDRESS +RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_receive_address(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_RECEIVE_URI +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_RECEIVE_URI +RustBuffer uniffi_bitkitcore_fn_method_usdtwallet_receive_uri(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_REFRESH_TRANSFERS +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_REFRESH_TRANSFERS +uint64_t uniffi_bitkitcore_fn_method_usdtwallet_refresh_transfers(void*_Nonnull ptr +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_SEND +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_SEND +uint64_t uniffi_bitkitcore_fn_method_usdtwallet_send(void*_Nonnull ptr, RustBuffer quote_id, RustBuffer mnemonic, RustBuffer passphrase +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_SYNC_HISTORY +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_METHOD_USDTWALLET_SYNC_HISTORY +uint64_t uniffi_bitkitcore_fn_method_usdtwallet_sync_history(void*_Nonnull ptr +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ACTIVITIES_FROM_JSON #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_ACTIVITIES_FROM_JSON RustBuffer uniffi_bitkitcore_fn_func_activities_from_json(RustBuffer json, RustCallStatus *_Nonnull out_status @@ -1694,6 +1754,26 @@ void uniffi_bitkitcore_fn_func_upsert_transaction_details(RustBuffer details_lis RustBuffer uniffi_bitkitcore_fn_func_ur_encode_crypto_psbt(RustBuffer psbt, uint32_t max_fragment_length, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_USDT_ADDRESS +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_USDT_ADDRESS +RustBuffer uniffi_bitkitcore_fn_func_usdt_address(RustBuffer mnemonic, RustBuffer passphrase, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_USDT_FORMAT_AMOUNT +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_USDT_FORMAT_AMOUNT +RustBuffer uniffi_bitkitcore_fn_func_usdt_format_amount(uint64_t amount, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_USDT_PARSE_AMOUNT +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_USDT_PARSE_AMOUNT +uint64_t uniffi_bitkitcore_fn_func_usdt_parse_amount(RustBuffer value, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_USDT_PARSE_PAYMENT_REQUEST +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_USDT_PARSE_PAYMENT_REQUEST +RustBuffer uniffi_bitkitcore_fn_func_usdt_parse_payment_request(RustBuffer value, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_VALIDATE_BITCOIN_ADDRESS #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_FN_FUNC_VALIDATE_BITCOIN_ADDRESS RustBuffer uniffi_bitkitcore_fn_func_validate_bitcoin_address(RustBuffer address, RustCallStatus *_Nonnull out_status @@ -3194,6 +3274,30 @@ uint16_t uniffi_bitkitcore_checksum_func_upsert_transaction_details(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_UR_ENCODE_CRYPTO_PSBT uint16_t uniffi_bitkitcore_checksum_func_ur_encode_crypto_psbt(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_USDT_ADDRESS +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_USDT_ADDRESS +uint16_t uniffi_bitkitcore_checksum_func_usdt_address(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_USDT_FORMAT_AMOUNT +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_USDT_FORMAT_AMOUNT +uint16_t uniffi_bitkitcore_checksum_func_usdt_format_amount(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_USDT_PARSE_AMOUNT +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_USDT_PARSE_AMOUNT +uint16_t uniffi_bitkitcore_checksum_func_usdt_parse_amount(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_USDT_PARSE_PAYMENT_REQUEST +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_USDT_PARSE_PAYMENT_REQUEST +uint16_t uniffi_bitkitcore_checksum_func_usdt_parse_payment_request(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_FUNC_VALIDATE_BITCOIN_ADDRESS @@ -3362,12 +3466,72 @@ uint16_t uniffi_bitkitcore_checksum_method_urdecoder_receive(void #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_URDECODER_RESET uint16_t uniffi_bitkitcore_checksum_method_urdecoder_reset(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_BALANCE +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_BALANCE +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_balance(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_CHECK_RECENT_EXECUTION +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_CHECK_RECENT_EXECUTION +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_check_recent_execution(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_HISTORY +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_HISTORY +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_history(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_QUOTE_TRANSFER +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_QUOTE_TRANSFER +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_quote_transfer(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_RECEIVE_ADDRESS +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_RECEIVE_ADDRESS +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_receive_address(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_RECEIVE_URI +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_RECEIVE_URI +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_receive_uri(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_REFRESH_TRANSFERS +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_REFRESH_TRANSFERS +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_refresh_transfers(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_SEND +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_SEND +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_send(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_SYNC_HISTORY +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_METHOD_USDTWALLET_SYNC_HISTORY +uint16_t uniffi_bitkitcore_checksum_method_usdtwallet_sync_history(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_CONSTRUCTOR_URDECODER_NEW #define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_CONSTRUCTOR_URDECODER_NEW uint16_t uniffi_bitkitcore_checksum_constructor_urdecoder_new(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_CONSTRUCTOR_USDTWALLET_NEW +#define UNIFFI_FFIDEF_UNIFFI_BITKITCORE_CHECKSUM_CONSTRUCTOR_USDTWALLET_NEW +uint16_t uniffi_bitkitcore_checksum_constructor_usdtwallet_new(void + ); #endif #ifndef UNIFFI_FFIDEF_FFI_BITKITCORE_UNIFFI_CONTRACT_VERSION diff --git a/src/lib.rs b/src/lib.rs index 6aefd74..035fb1d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -90,6 +90,10 @@ pub use modules::lnurl; pub use modules::onchain; pub use modules::scanner::{DecodingError, LnurlPayData, Scanner}; pub use modules::seedqr::{decode_compact_seed_qr, decode_standard_seed_qr, SeedQrError}; +pub use modules::usdt::{ + usdt_address, usdt_format_amount, usdt_parse_amount, usdt_parse_payment_request, UsdtError, + UsdtPaymentRequest, UsdtQuote, UsdtTransfer, UsdtTransferStatus, UsdtWallet, +}; use bip39::Mnemonic; use bitcoin::bip32::Xpriv; diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 5b2ff2a..d628203 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -10,3 +10,4 @@ pub mod scanner; pub mod seedqr; pub mod trezor; pub mod ur; +pub mod usdt; diff --git a/src/modules/usdt/README.md b/src/modules/usdt/README.md new file mode 100644 index 0000000..aaa8162 --- /dev/null +++ b/src/modules/usdt/README.md @@ -0,0 +1,62 @@ +# USDT on Arbitrum One + +`UsdtWallet` owns seed-derived accounts, fee quotes, signing, durable payment recovery and activity. Amounts use millionths of USDT. The native apps own credentials, payment authentication and presentation. + +## Account and authorization + +The address derives from the Bitkit BIP39 mnemonic and passphrase at `m/44'/60'/0'/0/0`. Arbitrum One (42161), USDT0, EntryPoint 0.8 (`0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108`) and Simple7702Account (`0xe6Cae83BdE06E4c305530e199D7217f42808555B`) are pinned in code. + +Each payment signs a chain-specific EIP-7702 authorization at the current account nonce and an EntryPoint operation committing to that delegate. Authorization costs are included in the USDT fee. There is no separate smart-wallet address or factory deployment. Another wallet with the same derivation can access the address; gas-payment support may differ. + +Delegation can persist even when an operation fails. A foreign delegate blocks sends without blocking balance/history reads. Bitkit never silently replaces it. + +Owned mnemonic/passphrase/seed buffers are zeroized and signing keys are erased on drop on a best-effort basis. FFI, compiler and library-internal copies cannot be guaranteed erased. SQLite contains no seed or private key. + +## Quotes and fees + +`quote_transfer` takes a raw recipient and a positive atomic amount; it never receives signing credentials. Local quotes last at most 120 seconds and newly quoted paymaster terms must expire within 15 minutes. `send` validates the owner, nonces, balance, gas estimates, the current slow gas-price recommendation and deadlines before signing the stored plan. Quotes use the fast gas-price recommendation; a modest price increase does not invalidate a quote that still covers the current slow recommendation. Changes beyond the approved bounds require a new review; signing cannot raise the approved fee. + +The pinned ERC-20 paymaster collects USDT. Its finite approval includes a 5% margin; the displayed maximum fee comes from signed gas limits and paymaster terms, not the allowance. Call/pre-verification estimates receive 10% execution/L1-data headroom; the charged pre-verification margin is included in the maximum. A residual paymaster allowance can remain and is reset to a finite amount on the next payment. + +`usdt_parse_payment_request` accepts raw addresses and chain-qualified ERC-681 requests for the pinned token, with exact atomic/scientific amounts. Ambiguous or unsupported parameters are rejected. The returned `chain_id` preserves explicit network restrictions; bare addresses leave it unset. Callers must honor it and review the parsed amount before requesting a quote. + +## Persistence and recovery + +Signed operations persist atomically before submission. Lost or rejected submission responses do not prove nonexecution: recovery retries only the identical signed operation. A quote ID cannot authorize a second payment. One source-chain payment remains pending at a time. + +A matching event in a canonical receipt settles the payment. Discovery logs alone never decide the outcome; unavailable log queries allow independent nonce/receipt proofs to proceed, while rate limits retain backoff. Expired signed paymaster terms and a confirmed EntryPoint nonce that has not passed the signed nonce release an unmined operation; the shorter quote deadline does not. With an advanced nonce and missing indexed events, recovery checks every receipt in the consuming block. A matching event settles/replaces the payment; complete absence proves external nonce consumption. Missing receipts preserve the pending operation. Progress is stored by payment and block hash so interruption does not restart the proof or carry it onto another block. + +Seed restoration recovers deposits and outgoing activity from genesis, including transfers before delegation and sends through another wallet. Supported direct EntryPoint calls and paymaster modes recover payment/fee attribution; unknown wrappers or payment modes preserve raw token transfers instead of guessing their intent. Failed payments retain attempted amounts but have no delivered amount. Transaction hashes are absent until execution is observed; callers derive explorer links from the source transaction hash rather than storing a second copy of it. + +`sync_history` returns `true` when caught up and `false` when more work remains. It uses adaptive log ranges and a 20-second soft budget between persisted receipts; an in-flight receipt may finish later. Learned range limits survive budget exits. A single-block log overflow falls back to that block's individual receipts. Complete receipt enrichment and fallback scans are retained by canonical block hash within the revisit window; log-only incoming observations remain eligible for later enrichment. Zero/self transfers are discarded before enrichment. Network failures preserve completed work and never silently skip a block. Callers should defer catch-up during payment review/submission so historical work does not delay sends. + +`check_recent_execution` checks one recent direct Arbitrum payment with a five-second request budget. It requires the expected operation outcome and token transfer in a matching canonical receipt and does not scan history, rebroadcast, expire payments or reconcile nonces. It can confirm execution at the current L2 tip; this is provisional sequencer execution, not parent-chain finality. Native send screens may call it approximately once per second during a short foreground window, with cancellation and rate-limit backoff between checks. Missing evidence leaves Pending intact. Normal recovery handles older payments outside its 64-block lookup window. + +Scans trail the reported tip by two blocks and revisit 4096 blocks for delayed indexing. This is not reorg rollback: previously recorded orphaned activity is not retracted. Providers must supply complete filtered logs, canonical blocks/receipts and historical state. The first scan starts at block zero, including pre-Nitro ranges; providers must answer those queries or return a supported range-limit error so the scan can narrow them. Logs first indexed more than 4096 blocks late can fall outside the revisit window. This is a block-count limit, not a guaranteed time interval or a measured provider indexing guarantee. + +Payment outcomes and expiry decisions trust the configured chain RPC. A malicious RPC can fabricate or suppress evidence and mislead a user into authorizing another payment; these checks are not light-client proofs. + +Storage is wallet-specific and must have one owning `UsdtWallet` object. Drop it before deleting its database during an explicit wallet wipe. Async exports use UniFFI's Tokio adapter; Kotlin cancellation can drop the polled future, whereas the current Swift bindings may finish an in-flight call after task cancellation. Callers must check cancellation between calls. Sends are not detached onto the global runtime used by stateless exports. + +## Transport + +Both chain and bundler endpoints must be controlled, credential-free HTTPS URLs; HTTP is accepted only on loopback for fixtures. Provider keys belong on the server. Chain/bundler calls share an 80/minute budget with a burst of 20. Responses are bounded to 2 MiB, except protocol-projected receipts up to 16 MiB. The companion service documents provider requirements, receipt projection and deployment limits. + +## Validation and bindings + +Run `cargo test --locked --lib modules::usdt`; CI runs these deterministic tests. They cover independent signing/address vectors, fee bounds, uncertain submission, nonce recovery and restored history. Fixtures use public test credentials. + +For the ignored deployed-contract test, start a fresh Arbitrum Anvil fork on port 18545 and `tests/usdt-fork/provider.mjs` on 18546 after installing its pinned dependencies. Run `cargo test deployed_contracts_collect_usdt_fees_without_account_eth -- --ignored`. The fixture requires Anvil, sets local balances/signing terms and executes deployed contracts; it does not establish real provider pricing. + +To include the service, start it with `NODE_ENV=test ARBITRUM_RPC_URL=http://127.0.0.1:18545 LOCAL_PROVIDER_URL=http://127.0.0.1:18546`, then pass `USDT_FORK_RPC_URL=http://127.0.0.1:3100/v1/usdt/chain-rpc` and `USDT_FORK_BUNDLER_URL=http://127.0.0.1:3100/v1/usdt/rpc` to the ignored test. + +Build iOS and Android sequentially with the repository scripts; Android temporarily edits the manifest/example. Generated bindings and native artifacts must use the same source. App configuration and local package overrides belong in each native repository's USDT documentation. + +## References + +- [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) +- [Alto EIP-7702 request validation](https://github.com/pimlicolabs/alto/blob/96529592b67a69be23c013359cbc9990657af64a/src/rpc/rpcHandler.ts) +- [Simple7702Account](https://github.com/eth-infinitism/account-abstraction/blob/releases/v0.8/contracts/accounts/Simple7702Account.sol) +- [Pimlico supported tokens](https://docs.pimlico.io/references/paymaster/erc20-paymaster/supported-tokens) +- [Pimlico pricing](https://www.pimlico.io/pricing) +- [Pimlico public endpoint limits](https://docs.pimlico.io/references/bundler/public-endpoint) diff --git a/src/modules/usdt/account.rs b/src/modules/usdt/account.rs new file mode 100644 index 0000000..5b966cc --- /dev/null +++ b/src/modules/usdt/account.rs @@ -0,0 +1,38 @@ +use super::UsdtError; +use alloy_primitives::{address, Address, Bytes, U256}; +use alloy_sol_types::{sol, SolCall}; + +pub(super) const ENTRY_POINT: Address = address!("4337084D9E255Ff0702461CF8895CE9E3b5Ff108"); +pub(super) const DELEGATE: Address = address!("e6Cae83BdE06E4c305530e199D7217f42808555B"); + +sol! { + struct Call { address target; uint256 value; bytes data; } + interface SimpleAccount { + function execute(address target, uint256 value, bytes data); + function executeBatch(Call[] calls); + } +} + +pub(super) fn validate_delegation(code: &[u8]) -> Result<(), UsdtError> { + if code.is_empty() + || (code.len() == 23 && code[..3] == [0xef, 0x01, 0x00] && code[3..] == DELEGATE[..]) + { + return Ok(()); + } + Err(UsdtError::UnsupportedDelegation) +} + +pub(super) fn batch(calls: &[(Address, Bytes)]) -> Bytes { + SimpleAccount::executeBatchCall { + calls: calls + .iter() + .map(|(target, data)| Call { + target: *target, + value: U256::ZERO, + data: data.clone(), + }) + .collect(), + } + .abi_encode() + .into() +} diff --git a/src/modules/usdt/amount.rs b/src/modules/usdt/amount.rs new file mode 100644 index 0000000..50aaf68 --- /dev/null +++ b/src/modules/usdt/amount.rs @@ -0,0 +1,112 @@ +use super::errors::UsdtError; +use alloy_primitives::U256; + +#[uniffi::export] +pub fn usdt_parse_amount(value: String) -> Result { + let mut parts = value.trim().split('.'); + let whole = parts.next().ok_or(UsdtError::InvalidAmount)?; + let fraction = parts.next().unwrap_or(""); + if parts.next().is_some() + || whole.is_empty() + || !whole.bytes().all(|b| b.is_ascii_digit()) + || fraction.len() > 6 + || !fraction.bytes().all(|b| b.is_ascii_digit()) + { + return Err(UsdtError::InvalidAmount); + } + let whole: u64 = whole.parse().map_err(|_| UsdtError::InvalidAmount)?; + let fraction: u64 = format!("{fraction:0<6}") + .parse() + .map_err(|_| UsdtError::InvalidAmount)?; + whole + .checked_mul(1_000_000) + .and_then(|v| v.checked_add(fraction)) + .ok_or(UsdtError::InvalidAmount) +} + +#[uniffi::export] +pub fn usdt_format_amount(amount: u64) -> String { + let value = format!("{}.{:06}", amount / 1_000_000, amount % 1_000_000); + value + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() +} + +pub(super) fn with_margin(value: U256, percent: u8) -> Result { + let hundred = U256::from(100); + let percent = U256::from(percent); + let margin = (value / hundred) + .checked_mul(percent) + .and_then(|margin| margin.checked_add(value % hundred * percent / hundred)) + .ok_or(UsdtError::InvalidResponse)?; + value + .checked_add(margin) + .and_then(|value| value.checked_add(U256::from(1))) + .ok_or(UsdtError::InvalidResponse) +} + +pub(super) fn token_amount(value: U256) -> Result { + value.try_into().map_err(|_| UsdtError::InvalidAmount) +} + +// ERC-681 token amounts are integers in atomic units, optionally in scientific notation. +pub(super) fn parse_atomic_amount(value: &str) -> Result { + let (mantissa, exponent) = match value.split_once(['e', 'E']) { + Some((mantissa, exponent)) => ( + mantissa, + exponent + .parse::() + .map_err(|_| UsdtError::InvalidAmount)?, + ), + None => (value, 0), + }; + let mantissa = mantissa.strip_prefix('+').unwrap_or(mantissa); + let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, "")); + if whole.is_empty() && fraction.is_empty() + || !whole + .bytes() + .chain(fraction.bytes()) + .all(|c| c.is_ascii_digit()) + { + return Err(UsdtError::InvalidAmount); + } + let fraction = fraction.trim_end_matches('0'); + let power = exponent + .checked_sub(fraction.len() as u32) + .ok_or(UsdtError::InvalidAmount)?; + let digits = format!("0{whole}{fraction}"); + let amount = digits + .parse::() + .map_err(|_| UsdtError::InvalidAmount)?; + if amount == 0 { + return Ok(0); + } + amount + .checked_mul(10u64.checked_pow(power).ok_or(UsdtError::InvalidAmount)?) + .ok_or(UsdtError::InvalidAmount) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn margins_preserve_rounding_without_intermediate_overflow() { + for percent in [5, 10, 20] { + for value in [ + U256::ZERO, + U256::from(19), + U256::from(20), + U256::MAX / U256::from(2), + ] { + let expected = value + value / U256::from(100 / percent) + U256::from(1); + assert_eq!(with_margin(value, percent).unwrap(), expected); + } + assert!(matches!( + with_margin(U256::MAX, percent), + Err(UsdtError::InvalidResponse) + )); + } + } +} diff --git a/src/modules/usdt/errors.rs b/src/modules/usdt/errors.rs new file mode 100644 index 0000000..3a5cc80 --- /dev/null +++ b/src/modules/usdt/errors.rs @@ -0,0 +1,51 @@ +use thiserror::Error; + +#[derive(Debug, Error, uniffi::Error)] +pub enum UsdtError { + #[error("Enter a valid USDT amount with at most six decimal places")] + InvalidAmount, + #[error("Enter a valid address for the selected network")] + InvalidAddress, + #[error("The network or token does not match this USDT account")] + WrongNetwork, + #[error("Wallet credentials do not match this USDT account")] + InvalidCredentials, + #[error("This account uses another wallet's smart account. Restore its delegation before sending with Bitkit")] + UnsupportedDelegation, + #[error("The USDT balance does not cover the amount and maximum fee")] + InsufficientBalance, + #[error("The fee quote has expired or changed. Review a new quote")] + QuoteExpired, + #[error("A USDT transaction is pending. Wait for confirmation before sending again")] + PendingTransfer, + #[error("The selected USDT payment route is unavailable")] + UnsupportedRoute, + #[error("USDT payments are not configured for this app build")] + NotConfigured, + #[error("The network could not be reached. Try again")] + NetworkUnavailable, + #[error("Too many requests. Try again shortly")] + RateLimited, + #[error("The requested log range exceeds the provider limit")] + LogRangeTooLarge, + #[error("The network rejected the transaction: {reason}")] + TransactionRejected { reason: String }, + #[error("USDT wallet storage failed: {reason}")] + Storage { reason: String }, + #[error("Invalid response from the USDT network")] + InvalidResponse, +} + +impl From for UsdtError { + fn from(error: rusqlite::Error) -> Self { + Self::Storage { + reason: error.to_string(), + } + } +} + +impl From for UsdtError { + fn from(_: serde_json::Error) -> Self { + Self::InvalidResponse + } +} diff --git a/src/modules/usdt/fixtures/eip7702-vectors.json b/src/modules/usdt/fixtures/eip7702-vectors.json new file mode 100644 index 0000000..73ec6b7 --- /dev/null +++ b/src/modules/usdt/fixtures/eip7702-vectors.json @@ -0,0 +1,42 @@ +{ + "source": "ethers@6.17.0; EntryPoint v0.8 / Simple7702Account; generated by tests/usdt-fork/vectors.mjs", + "address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "calls": [ + { + "to": "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + "data": "0x095ea7b3000000000000000000000000888888888888ec68a58ab8094cc1ad20ba3d240200000000000000000000000000000000000000000000000000000000000186a0" + }, + { + "to": "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + "data": "0xa9059cbb000000000000000000000000111111111111111111111111111111111111111100000000000000000000000000000000000000000000000000000000000f4240" + } + ], + "operation": { + "sender": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "nonce": "0x0", + "factory": "0x7702", + "factoryData": "0x", + "callData": "0x34fcd5be0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000120000000000000000000000000fd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044095ea7b3000000000000000000000000888888888888ec68a58ab8094cc1ad20ba3d240200000000000000000000000000000000000000000000000000000000000186a000000000000000000000000000000000000000000000000000000000000000000000000000000000fd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000111111111111111111111111111111111111111100000000000000000000000000000000000000000000000000000000000f424000000000000000000000000000000000000000000000000000000000", + "callGasLimit": "0x249f0", + "verificationGasLimit": "0x3d090", + "preVerificationGas": "0x13880", + "maxFeePerGas": "0x2faf080", + "maxPriorityFeePerGas": "0x186a0", + "paymaster": "0x888888888888Ec68A58AB8094Cc1AD20Ba3D2402", + "paymasterVerificationGasLimit": "0x186a0", + "paymasterPostOpGasLimit": "0xc350", + "paymasterData": "0x030000006b49d200000000000000fd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb90000000000000000000000000000c35000000000000000000000000000000000000000000000000000000000b2d05e00000000000000000000000000000186a0f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001", + "signature": "0x", + "eip7702Auth": { + "chainId": "0xa4b1", + "address": "0xe6Cae83BdE06E4c305530e199D7217f42808555B", + "nonce": "0x0", + "yParity": "0x01", + "r": "0xfeefad4175dd3eef21df8de147d204cd7e81eac16be98715e73b5c3472f33b1d", + "s": "0x5820588fa9c00582108e4937004de97dfbc51c448bf88c5eea98d2fe70ce1b5b" + } + }, + "userOperationHash": "0x93469ab8218846c22dfd239fc50be4e1295768cbfc9d25e535205be945368998", + "signature": "0x1bd9bf42152174a707ec01b5ff131274e5f382ae347dcdc08fea3829e5aa820f78d821ae3bc33e54594b92052e07173515664048bb1738e477dd1a68626ecfb81b", + "authorizationHash": "0xa66cea9a587fc7d54a769b64061e4faa03d2dc62f3b9a05cfa65a52c170505fa" +} diff --git a/src/modules/usdt/history.rs b/src/modules/usdt/history.rs new file mode 100644 index 0000000..8899563 --- /dev/null +++ b/src/modules/usdt/history.rs @@ -0,0 +1,410 @@ +use super::{ + account::{SimpleAccount, ENTRY_POINT}, + amount::token_amount, + transaction::{entry_point_event, event_data, EntryPoint, Erc20}, + types::TOKEN, + UsdtError, UsdtTransfer, UsdtTransferStatus, UsdtWallet, +}; +use alloy_primitives::{Address, Bytes, B256, U256}; +use alloy_sol_types::{SolCall, SolEvent}; +use serde_json::{json, Value}; +use std::{collections::BTreeMap, sync::atomic::Ordering}; + +pub(super) const MAX_LOG_RANGE: u64 = 10_000_000; +pub(super) const HISTORY_REVISIT_BLOCKS: u64 = 4096; +const HISTORY_BUDGET: std::time::Duration = std::time::Duration::from_secs(20); +const LOG_QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +impl UsdtWallet { + pub(super) async fn scan_history(&self) -> Result { + let deadline = tokio::time::Instant::now() + HISTORY_BUDGET; + let tip = self.block_number().await?.saturating_sub(2); + let previous = self.store.synced_block()?; + let start = self.store.history_progress()?.unwrap_or_else(|| { + previous + .map(|block| block.saturating_sub(HISTORY_REVISIT_BLOCKS)) + .unwrap_or(0) + }); + if start > tip { + return Err(UsdtError::NetworkUnavailable); + } + let initial_limit = self.history_range_limit.load(Ordering::Relaxed); + let mut ceiling = initial_limit; + let mut next = start; + let mut width = initial_limit.min(tip - start + 1); + loop { + if tokio::time::Instant::now() >= deadline { + self.history_range_limit.store(ceiling, Ordering::Relaxed); + return Ok(false); + } + let end = next.saturating_add(width - 1).min(tip); + let result = tokio::time::timeout(LOG_QUERY_TIMEOUT, self.history_logs(next, end)) + .await + .unwrap_or(Err(UsdtError::NetworkUnavailable)); + match result { + Ok(transactions) => { + if self.store.history_progress()? != Some(next) { + self.store.save_history_progress(next)?; + } + if !self.scan_history_logs(transactions, end, deadline).await? { + return Ok(false); + } + } + Err(UsdtError::LogRangeTooLarge) if next < end => { + width = (width / 2).max(1); + ceiling = width; + self.history_range_limit.store(width, Ordering::Relaxed); + continue; + } + Err(UsdtError::LogRangeTooLarge) => { + if !self.scan_block(next, deadline).await? { + return Ok(false); + } + // A dense block is not a range limit for the blocks after it. + ceiling = MAX_LOG_RANGE; + width = MAX_LOG_RANGE; + } + Err(UsdtError::NetworkUnavailable) => { + self.history_range_limit + .store((width / 2).max(1), Ordering::Relaxed); + return if next > start { + Ok(false) + } else { + Err(UsdtError::NetworkUnavailable) + }; + } + Err(error) => return Err(error), + } + if end == tip { + self.store.complete_history(tip)?; + // Probe for a higher provider limit only after completing a scan. + self.history_range_limit + .store((ceiling * 2).min(MAX_LOG_RANGE), Ordering::Relaxed); + return Ok(true); + } + next = end + 1; + self.store.save_history_progress(next)?; + width = (width * 2).min(ceiling); + self.history_range_limit.store(width, Ordering::Relaxed); + width = width.min(tip - next + 1); + } + } + + async fn scan_history_logs( + &self, + transactions: BTreeMap<(u64, String), Vec>, + end: u64, + deadline: tokio::time::Instant, + ) -> Result { + let mut blocks = BTreeMap::new(); + let mut transactions = transactions.into_iter().peekable(); + while let Some(((block, hash), logs)) = transactions.next() { + if tokio::time::Instant::now() >= deadline { + return Ok(false); + } + let needs_receipt = logs.iter().any(|log| { + serde_json::from_value::
(log["address"].clone()) + .is_ok_and(|address| address == ENTRY_POINT) + || event_data(log) + .ok() + .and_then(|data| Erc20::Transfer::decode_log_data(&data).ok()) + .is_some_and(|event| event.from == self.address) + }); + let canonical = match blocks.entry(block) { + std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(), + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(self.rpc.block(block).await?) + } + }; + for log in &logs { + if serde_json::from_value::(log["blockHash"].clone())? != canonical.hash { + return Err(UsdtError::NetworkUnavailable); + } + } + if !self.store.has_history_receipt( + &hash, + &format!("{:#x}", canonical.hash), + needs_receipt, + )? { + let receipt = if needs_receipt { + self.rpc + .block_receipt( + hash.parse().map_err(|_| UsdtError::InvalidResponse)?, + canonical.hash, + block, + ) + .await? + } else { + json!({"logs": logs}) + }; + self.save_receipt_history(&hash, block, canonical, &receipt, needs_receipt) + .await?; + } + if block < end + && transactions + .peek() + .is_none_or(|((next_block, _), _)| *next_block != block) + { + self.store.save_history_progress(block + 1)?; + } + } + Ok(true) + } + + async fn scan_block( + &self, + number: u64, + deadline: tokio::time::Instant, + ) -> Result { + let block = self.rpc.block(number).await?; + let block_hash = format!("{:#x}", block.hash); + if self.store.begin_history_block(number, &block_hash)? { + return Ok(true); + } + if self.store.history_progress()? != Some(number) { + self.store.save_history_progress(number)?; + } + for hash in &block.transactions { + let id = format!("{hash:#x}"); + if self.store.has_history_receipt(&id, &block_hash, true)? { + continue; + } + if tokio::time::Instant::now() >= deadline { + return Ok(false); + } + let receipt = self.rpc.block_receipt(*hash, block.hash, number).await?; + self.save_receipt_history(&id, number, &block, &receipt, true) + .await?; + } + if self.rpc.block(number).await?.hash != block.hash { + return Err(UsdtError::NetworkUnavailable); + } + self.store.complete_history_block(number, &block_hash)?; + Ok(true) + } + + async fn save_receipt_history( + &self, + hash: &str, + number: u64, + block: &super::rpc::Block, + receipt: &Value, + complete_receipt: bool, + ) -> Result<(), UsdtError> { + // Finish and persist a receipt before yielding the work budget. + let timestamp = block.timestamp()?; + let transfers = self.receipt_history(hash, timestamp, receipt).await?; + self.store.save_history_receipt( + &transfers, + hash, + number, + &format!("{:#x}", block.hash), + complete_receipt, + ) + } + + async fn history_logs( + &self, + start: u64, + end: u64, + ) -> Result>, UsdtError> { + let address = self.address.into_word(); + let filter = |topics: Value| json!({"address":TOKEN,"fromBlock":U256::from(start),"toBlock":U256::from(end),"topics":topics}); + let (incoming_and_operations, outgoing): (Vec, Vec) = tokio::try_join!( + self.rpc.call("eth_getLogs", json!([{ + "address":[TOKEN, ENTRY_POINT],"fromBlock":U256::from(start),"toBlock":U256::from(end), + "topics":[[Erc20::Transfer::SIGNATURE_HASH, EntryPoint::UserOperationEvent::SIGNATURE_HASH],null,address] + }])), + self.rpc.call("eth_getLogs", json!([filter(json!([Erc20::Transfer::SIGNATURE_HASH, address]))])) + )?; + let mut transactions: BTreeMap<(u64, String), Vec> = BTreeMap::new(); + for log in incoming_and_operations.into_iter().chain(outgoing) { + if log["removed"].as_bool() == Some(true) { + continue; + } + if serde_json::from_value::
(log["address"].clone())? == TOKEN { + let event = Erc20::Transfer::decode_log_data(&event_data(&log)?) + .map_err(|_| UsdtError::InvalidResponse)?; + if event.value.is_zero() || event.from == event.to { + continue; + } + } + let hash: B256 = serde_json::from_value(log["transactionHash"].clone())?; + let hash = format!("{hash:#x}"); + let block = u64::try_from(serde_json::from_value::(log["blockNumber"].clone())?) + .map_err(|_| UsdtError::InvalidResponse)?; + if !(start..=end).contains(&block) { + return Err(UsdtError::InvalidResponse); + } + transactions.entry((block, hash)).or_default().push(log); + } + Ok(transactions) + } + + async fn receipt_history( + &self, + hash: &str, + timestamp: u64, + receipt: &Value, + ) -> Result, UsdtError> { + let logs = receipt["logs"] + .as_array() + .ok_or(UsdtError::InvalidResponse)?; + let mut result = Vec::new(); + let mut owned_operations = Vec::new(); + for log in logs { + let address: Address = serde_json::from_value(log["address"].clone())?; + if let Some(event) = entry_point_event(log)? { + if event.sender == self.address { + let saved = self + .store + .transfer_by_hash(&format!("{:#x}", event.userOpHash))?; + owned_operations.push((event, saved)); + } + } + if address != TOKEN { + continue; + } + let Ok(event) = Erc20::Transfer::decode_log_data(&event_data(log)?) else { + continue; + }; + let incoming = event.to == self.address && event.from != self.address; + let outgoing = event.from == self.address && event.to != self.address; + if (!incoming && !outgoing) || event.value.is_zero() { + continue; + } + let index: U256 = serde_json::from_value(log["logIndex"].clone())?; + result.push(UsdtTransfer { + id: format!("{hash}:{index}"), + tx_hash: Some(hash.into()), + user_operation_hash: None, + recipient: event.to.to_checksum(None), + amount: token_amount(event.value)?, + received_amount: token_amount(event.value)?, + fee: None, + is_incoming: incoming, + status: UsdtTransferStatus::Confirmed, + timestamp, + }); + } + if owned_operations.is_empty() { + return Ok(result); + } + let batch = if owned_operations.iter().any(|(_, saved)| saved.is_none()) { + let tx: Value = self + .rpc + .call("eth_getTransactionByHash", json!([hash])) + .await?; + if tx.is_null() { + return Err(UsdtError::NetworkUnavailable); + } + if serde_json::from_value::(tx["hash"].clone())? + != hash + .parse::() + .map_err(|_| UsdtError::InvalidResponse)? + { + return Err(UsdtError::InvalidResponse); + } + let input: Bytes = serde_json::from_value(tx["input"].clone())?; + let target: Option
= serde_json::from_value(tx["to"].clone())?; + // A wrapper's outer calldata need not describe the operation it executes. + if target == Some(ENTRY_POINT) { + EntryPoint::handleOpsCall::abi_decode(&input).ok() + } else { + None + } + } else { + None + }; + for (event, saved) in owned_operations { + // Unknown fee collection keeps its raw debits and refunds intact. + if event.paymaster != super::paymaster::PAYMASTER { + continue; + } + let operation_hash = format!("{:#x}", event.userOpHash); + let (recipient, amount) = if let Some(saved) = saved { + (saved.recipient, saved.amount) + } else { + let Some(op) = batch.as_ref().and_then(|batch| { + batch + .ops + .iter() + .find(|op| op.sender == self.address && op.nonce == event.nonce) + }) else { + continue; + }; + if !super::paymaster::supported_payment(&op.paymasterAndData) { + continue; + } + let Some((recipient, amount)) = decode_payment(&op.callData, self.address) else { + continue; + }; + (recipient.to_checksum(None), amount) + }; + let mut transfer = UsdtTransfer { + id: operation_hash.clone(), + tx_hash: Some(hash.into()), + user_operation_hash: Some(operation_hash), + recipient, + amount, + received_amount: amount, + fee: None, + is_incoming: false, + status: UsdtTransferStatus::Pending, + timestamp, + }; + self.settle(&mut transfer, receipt)?; + let operation_logs = super::transaction::operation_logs(receipt, event.userOpHash)?; + let outgoing_ids: Vec<_> = operation_logs + .iter() + .filter_map(|log| { + let index: U256 = serde_json::from_value(log["logIndex"].clone()).ok()?; + Some(format!("{hash}:{index}")) + }) + .collect(); + result.retain(|transfer| transfer.is_incoming || !outgoing_ids.contains(&transfer.id)); + result.push(transfer); + } + Ok(result) + } +} + +fn decode_payment(data: &[u8], sender: Address) -> Option<(Address, u64)> { + let calls = decode_calls(data).ok()?; + let mut payment = None; + for (target, data) in calls { + if target != TOKEN { + return None; + } + if let Ok(call) = Erc20::transferCall::abi_decode(&data) { + if payment.is_some() || call.amount.is_zero() || call.recipient == sender { + return None; + } + payment = Some((call.recipient, token_amount(call.amount).ok()?)); + } else if Erc20::approveCall::abi_decode(&data).is_err() { + return None; + } + } + payment +} + +pub(super) fn decode_calls(data: &[u8]) -> Result, UsdtError> { + if let Ok(execute) = SimpleAccount::executeCall::abi_decode(data) { + if !execute.value.is_zero() { + return Err(UsdtError::InvalidResponse); + } + return Ok(vec![(execute.target, execute.data)]); + } + let batch = SimpleAccount::executeBatchCall::abi_decode(data) + .map_err(|_| UsdtError::InvalidResponse)?; + batch + .calls + .into_iter() + .map(|call| { + if !call.value.is_zero() { + return Err(UsdtError::InvalidResponse); + } + Ok((call.target, call.data)) + }) + .collect() +} diff --git a/src/modules/usdt/keys.rs b/src/modules/usdt/keys.rs new file mode 100644 index 0000000..414885f --- /dev/null +++ b/src/modules/usdt/keys.rs @@ -0,0 +1,78 @@ +use super::UsdtError; +use alloy_primitives::Address; +use bip39::Mnemonic; +use bitcoin::{ + bip32::{DerivationPath, Xpriv}, + secp256k1::{Secp256k1, SecretKey}, +}; +use std::str::FromStr; +use zeroize::Zeroizing; + +pub(super) struct SigningKey(SecretKey); +impl std::ops::Deref for SigningKey { + type Target = SecretKey; + fn deref(&self) -> &SecretKey { + &self.0 + } +} +impl Drop for SigningKey { + fn drop(&mut self) { + self.0.non_secure_erase(); + } +} + +pub(super) fn derive_key( + mnemonic: Zeroizing, + passphrase: Option>, +) -> Result { + let passphrase = passphrase.unwrap_or_default(); + let mnemonic = Mnemonic::parse(&*mnemonic).map_err(|_| UsdtError::InvalidCredentials)?; + let seed = Zeroizing::new(mnemonic.to_seed(passphrase.as_str())); + let mut root = Xpriv::new_master(bitcoin::Network::Bitcoin, seed.as_ref()) + .map_err(|_| UsdtError::InvalidCredentials)?; + let path = + DerivationPath::from_str("m/44'/60'/0'/0/0").map_err(|_| UsdtError::InvalidCredentials)?; + let derived = root.derive_priv(&Secp256k1::new(), &path); + root.private_key.non_secure_erase(); + let mut derived = derived.map_err(|_| UsdtError::InvalidCredentials)?; + let key = SigningKey(derived.private_key); + derived.private_key.non_secure_erase(); + Ok(key) +} + +pub(super) fn key_address(key: &SecretKey) -> Address { + let public = key.public_key(&Secp256k1::new()).serialize_uncompressed(); + Address::from_raw_public_key(&public[1..]) +} + +pub(super) fn derive_owner_key( + mnemonic: Zeroizing, + passphrase: Option>, + owner: Address, +) -> Result { + let key = derive_key(mnemonic, passphrase)?; + if key_address(&key) != owner { + return Err(UsdtError::InvalidCredentials); + } + Ok(key) +} + +#[uniffi::export] +pub fn usdt_address(mnemonic: String, passphrase: Option) -> Result { + Ok(key_address(&*derive_key(mnemonic.into(), passphrase.map(Into::into))?).to_checksum(None)) +} + +pub(super) fn parse_address(value: &str) -> Result { + let address = Address::from_str(value).map_err(|_| UsdtError::InvalidAddress)?; + if value.len() != 42 || !value.starts_with("0x") || address == Address::ZERO { + return Err(UsdtError::InvalidAddress); + } + let body = &value[2..]; + if body.bytes().any(|c| c.is_ascii_lowercase()) + && body.bytes().any(|c| c.is_ascii_uppercase()) + && address.to_checksum(None) != value + { + return Err(UsdtError::InvalidAddress); + } + Ok(address) +} diff --git a/src/modules/usdt/mod.rs b/src/modules/usdt/mod.rs new file mode 100644 index 0000000..9a56f06 --- /dev/null +++ b/src/modules/usdt/mod.rs @@ -0,0 +1,23 @@ +mod account; +mod amount; +mod errors; +mod history; +mod keys; +mod paymaster; +mod payment_request; +mod rpc; +mod store; +mod transaction; +mod types; +mod user_operation; +mod wallet; + +pub use amount::{usdt_format_amount, usdt_parse_amount}; +pub use errors::UsdtError; +pub use keys::usdt_address; +pub use payment_request::usdt_parse_payment_request; +pub use types::*; +pub use wallet::UsdtWallet; + +#[cfg(test)] +mod tests; diff --git a/src/modules/usdt/paymaster.rs b/src/modules/usdt/paymaster.rs new file mode 100644 index 0000000..ea1c292 --- /dev/null +++ b/src/modules/usdt/paymaster.rs @@ -0,0 +1,410 @@ +use super::{ + account::ENTRY_POINT, + amount::with_margin, + rpc::Rpc, + transaction::Erc20, + user_operation::{Authorization, UserOperation}, + UsdtError, +}; +use alloy_primitives::{address, Address, Bytes, U256}; +use alloy_sol_types::SolCall; +use serde::Deserialize; +use serde_json::json; + +use super::types::{CHAIN_ID as ARBITRUM_CHAIN_ID, TOKEN as USDT0}; +pub(super) const PAYMASTER: Address = address!("888888888888Ec68A58AB8094Cc1AD20Ba3D2402"); +const MIN_PAYMASTER_VALIDITY_SECONDS: u64 = 15; +const MAX_PAYMASTER_VALIDITY_SECONDS: u64 = 900; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GasPrice { + max_fee_per_gas: U256, + max_priority_fee_per_gas: U256, +} + +#[derive(Deserialize)] +struct GasPrices { + slow: GasPrice, + fast: GasPrice, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TokenQuote { + paymaster: Address, + token: Address, + post_op_gas: U256, + exchange_rate: U256, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PaymasterData { + paymaster: Address, + paymaster_data: Bytes, + paymaster_verification_gas_limit: Option, + paymaster_post_op_gas_limit: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GasEstimate { + call_gas_limit: U256, + verification_gas_limit: U256, + pre_verification_gas: U256, + paymaster_verification_gas_limit: Option, + paymaster_post_op_gas_limit: Option, +} + +impl GasEstimate { + fn apply(&self, op: &mut UserOperation) -> Result { + let mut changed = false; + // Pre-verification estimates also vary with Arbitrum's L1 data cost. + for (limit, estimate) in [ + (&mut op.call_gas_limit, self.call_gas_limit), + (&mut op.pre_verification_gas, self.pre_verification_gas), + ] { + if estimate > *limit { + *limit = with_margin(estimate, 10)?; + changed = true; + } + } + for (limit, estimate) in [ + (&mut op.verification_gas_limit, self.verification_gas_limit), + ( + &mut op.paymaster_verification_gas_limit, + self.paymaster_verification_gas_limit.unwrap_or_default(), + ), + ( + &mut op.paymaster_post_op_gas_limit, + self.paymaster_post_op_gas_limit.unwrap_or_default(), + ), + ] { + if estimate > *limit { + *limit = estimate; + changed = true; + } + } + Ok(changed) + } +} + +pub(super) struct Pimlico { + pub rpc: Rpc, +} + +impl Pimlico { + pub async fn prepare( + &self, + address: Address, + nonce: U256, + authorization: Authorization, + calls: &[(Address, Bytes)], + timestamp: u64, + ) -> Result<(UserOperation, u64, u64), UsdtError> { + self.rpc.verify_chain().await?; + #[derive(Deserialize)] + struct Quotes { + quotes: Vec, + } + let quotes: Quotes = self + .rpc + .call( + "pimlico_getTokenQuotes", + json!([{ "tokens": [USDT0] }, ENTRY_POINT, U256::from(ARBITRUM_CHAIN_ID)]), + ) + .await?; + let quote = quotes + .quotes + .into_iter() + .find(|quote| { + quote.token == USDT0 + && quote.paymaster == PAYMASTER + && !quote.exchange_rate.is_zero() + }) + .ok_or(UsdtError::UnsupportedRoute)?; + let price = self.gas_prices().await?.fast; + let dummy_signature = Bytes::from_static(&alloy_primitives::hex!("fffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c")); + let mut op = UserOperation { + sender: address, + nonce, + factory: Bytes::from_static(&[0x77, 0x02]), + factory_data: Bytes::new(), + call_data: with_approval(calls, U256::MAX), + call_gas_limit: U256::ZERO, + verification_gas_limit: U256::ZERO, + pre_verification_gas: U256::ZERO, + max_fee_per_gas: price.max_fee_per_gas, + max_priority_fee_per_gas: price.max_priority_fee_per_gas, + paymaster: PAYMASTER, + paymaster_verification_gas_limit: U256::ZERO, + paymaster_post_op_gas_limit: U256::ZERO, + paymaster_data: Bytes::new(), + signature: dummy_signature, + eip7702_auth: authorization, + }; + self.apply_paymaster(&mut op, "pm_getPaymasterStubData") + .await?; + let estimate: GasEstimate = self + .rpc + .call("eth_estimateUserOperationGas", json!([op, ENTRY_POINT])) + .await?; + estimate.apply(&mut op)?; + let estimate_terms = Terms { + exchange_rate: quote.exchange_rate, + post_op_gas: quote.post_op_gas, + constant_fee: U256::ZERO, + valid_until: 0, + valid_after: 0, + }; + let mut allowance = with_margin(estimate_terms.maximum_token_cost(&op)?, 5)?; + // Provider data can change gas limits and token charges. Refine both before signing. + for _ in 0..3 { + op.call_data = with_approval(calls, allowance); + self.apply_paymaster(&mut op, "pm_getPaymasterData").await?; + let terms = Terms::decode(&op.paymaster_data)?; + let estimate: GasEstimate = self + .rpc + .call("eth_estimateUserOperationGas", json!([op, ENTRY_POINT])) + .await?; + let gas_changed = estimate.apply(&mut op)?; + let required = terms.maximum_token_cost(&op)?; + if gas_changed || required > allowance { + allowance = allowance.max(with_margin(required, 5)?); + continue; + } + if terms.valid_until == 0 + || terms.valid_until > timestamp.saturating_add(MAX_PAYMASTER_VALIDITY_SECONDS) + { + return Err(UsdtError::InvalidResponse); + } + if terms.valid_after > timestamp + || terms.valid_until <= timestamp.saturating_add(MIN_PAYMASTER_VALIDITY_SECONDS) + { + return Err(UsdtError::QuoteExpired); + } + return Ok(( + op, + required + .try_into() + .map_err(|_| UsdtError::InvalidResponse)?, + terms.valid_until, + )); + } + Err(UsdtError::QuoteExpired) + } + + async fn apply_paymaster(&self, op: &mut UserOperation, method: &str) -> Result<(), UsdtError> { + let data: PaymasterData = self + .rpc + .call( + method, + json!([op, ENTRY_POINT, U256::from(ARBITRUM_CHAIN_ID), {"token": USDT0}]), + ) + .await?; + if data.paymaster != PAYMASTER + || (method == "pm_getPaymasterStubData" && data.paymaster_post_op_gas_limit.is_none()) + { + return Err(UsdtError::InvalidResponse); + } + op.paymaster_data = data.paymaster_data; + // Final data signs the estimated limits; only stub data supplies gas estimates. + if method == "pm_getPaymasterStubData" { + if let Some(gas) = data.paymaster_verification_gas_limit { + op.paymaster_verification_gas_limit = gas; + } + if let Some(gas) = data.paymaster_post_op_gas_limit { + op.paymaster_post_op_gas_limit = gas; + } + } + Ok(()) + } + + pub async fn validate_gas(&self, op: &UserOperation) -> Result<(), UsdtError> { + let price = self.gas_prices().await?.slow; + if price.max_fee_per_gas > op.max_fee_per_gas + || price.max_priority_fee_per_gas > op.max_priority_fee_per_gas + { + return Err(UsdtError::QuoteExpired); + } + let estimate: GasEstimate = self + .rpc + .call("eth_estimateUserOperationGas", json!([op, ENTRY_POINT])) + .await?; + if estimate.call_gas_limit > op.call_gas_limit + || estimate.verification_gas_limit > op.verification_gas_limit + || estimate.pre_verification_gas > op.pre_verification_gas + || estimate + .paymaster_verification_gas_limit + .is_some_and(|gas| gas > op.paymaster_verification_gas_limit) + || estimate + .paymaster_post_op_gas_limit + .is_some_and(|gas| gas > op.paymaster_post_op_gas_limit) + { + return Err(UsdtError::QuoteExpired); + } + Ok(()) + } + + async fn gas_prices(&self) -> Result { + let prices: GasPrices = self + .rpc + .call("pimlico_getUserOperationGasPrice", json!([])) + .await?; + for price in [&prices.slow, &prices.fast] { + if price.max_fee_per_gas.is_zero() + || price.max_priority_fee_per_gas > price.max_fee_per_gas + { + return Err(UsdtError::InvalidResponse); + } + } + if prices.slow.max_fee_per_gas > prices.fast.max_fee_per_gas + || prices.slow.max_priority_fee_per_gas > prices.fast.max_priority_fee_per_gas + { + return Err(UsdtError::InvalidResponse); + } + Ok(prices) + } +} + +fn with_approval(calls: &[(Address, Bytes)], amount: U256) -> Bytes { + let mut batch = vec![( + USDT0, + Erc20::approveCall { + spender: PAYMASTER, + amount, + } + .abi_encode() + .into(), + )]; + batch.extend_from_slice(calls); + super::account::batch(&batch) +} + +struct Terms { + exchange_rate: U256, + post_op_gas: U256, + constant_fee: U256, + valid_until: u64, + valid_after: u64, +} + +impl Terms { + // SingletonPaymasterV8 inherits the V7 token-fee encoding. + fn decode(data: &[u8]) -> Result { + if data.len() < 182 + || data[0] >> 1 != 1 + || data[1] & !1 != 0 + || Address::from_slice(&data[14..34]) != USDT0 + { + return Err(UsdtError::InvalidResponse); + } + // Pre-funding needs a separate approval; recipient mode can collect unused prefund. Neither is used. + let constant_present = data[1] & 1 != 0; + let signature_start = 118 + if constant_present { 16 } else { 0 }; + if !matches!(data.len().checked_sub(signature_start), Some(64 | 65)) { + return Err(UsdtError::InvalidResponse); + } + let terms = Self { + valid_until: U256::from_be_slice(&data[2..8]) + .try_into() + .map_err(|_| UsdtError::InvalidResponse)?, + valid_after: U256::from_be_slice(&data[8..14]) + .try_into() + .map_err(|_| UsdtError::InvalidResponse)?, + post_op_gas: U256::from_be_slice(&data[34..50]), + exchange_rate: U256::from_be_slice(&data[50..82]), + constant_fee: if constant_present { + U256::from_be_slice(&data[118..134]) + } else { + U256::ZERO + }, + }; + if terms.exchange_rate.is_zero() { + return Err(UsdtError::InvalidResponse); + } + Ok(terms) + } + + fn maximum_token_cost(&self, op: &UserOperation) -> Result { + let overhead = self + .post_op_gas + .checked_mul(op.max_fee_per_gas) + .ok_or(UsdtError::InvalidResponse)?; + let native = op + .maximum_native_cost()? + .checked_add(overhead) + .ok_or(UsdtError::InvalidResponse)?; + let scaled = native + .checked_mul(self.exchange_rate) + .ok_or(UsdtError::InvalidResponse)?; + let divisor = U256::from(1_000_000_000_000_000_000u64); + let rounded = scaled / divisor + U256::from(u8::from(scaled % divisor != U256::ZERO)); + rounded + .checked_add(self.constant_fee) + .ok_or(UsdtError::InvalidResponse) + } +} + +// Packed EntryPoint paymaster data starts after the address and two 16-byte gas limits. +pub(super) fn supported_payment(data: &[u8]) -> bool { + data.get(..20) + .is_some_and(|address| address == PAYMASTER.as_slice()) + && data + .get(52..) + .is_some_and(|terms| Terms::decode(terms).is_ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gas_margins_reject_overflow() { + let vector: serde_json::Value = + serde_json::from_str(include_str!("fixtures/eip7702-vectors.json")).unwrap(); + for (call, pre_verification) in [(U256::MAX, U256::ZERO), (U256::ZERO, U256::MAX)] { + let mut op: UserOperation = + serde_json::from_value(vector["operation"].clone()).unwrap(); + let estimate = GasEstimate { + call_gas_limit: call, + verification_gas_limit: U256::ZERO, + pre_verification_gas: pre_verification, + paymaster_verification_gas_limit: None, + paymaster_post_op_gas_limit: None, + }; + assert!(matches!( + estimate.apply(&mut op), + Err(UsdtError::InvalidResponse) + )); + } + } + + #[test] + fn token_fee_includes_fixed_charge_and_rejects_other_collection_modes() { + let mut data = vec![0; 118]; + data[0] = 3; + data[1] = 1; + data[14..34].copy_from_slice(USDT0.as_slice()); + data[34..50].copy_from_slice(&50_000u128.to_be_bytes()); + data[50..82].copy_from_slice(&U256::from(3_000_000_000u64).to_be_bytes::<32>()); + data.extend_from_slice(&123u128.to_be_bytes()); + data.extend_from_slice(&[1; 65]); + let vector: serde_json::Value = + serde_json::from_str(include_str!("fixtures/eip7702-vectors.json")).unwrap(); + let op: UserOperation = serde_json::from_value(vector["operation"].clone()).unwrap(); + let terms = Terms::decode(&data).unwrap(); + assert_eq!(terms.maximum_token_cost(&op).unwrap(), U256::from(102_123)); + for flags in [2, 4, 8] { + data[1] = flags; + assert!(Terms::decode(&data).is_err()); + } + data[1] = 1; + data[14] ^= 1; + assert!(Terms::decode(&data).is_err()); + for length in 0..182 { + assert!(Terms::decode(&data[..length]).is_err()); + } + } +} diff --git a/src/modules/usdt/payment_request.rs b/src/modules/usdt/payment_request.rs new file mode 100644 index 0000000..d509f35 --- /dev/null +++ b/src/modules/usdt/payment_request.rs @@ -0,0 +1,62 @@ +use super::{ + keys::parse_address, + types::{CHAIN_ID, TOKEN}, + UsdtError, UsdtPaymentRequest, +}; +use alloy_primitives::Address; + +#[uniffi::export] +pub fn usdt_parse_payment_request(value: String) -> Result { + let (recipient, amount, chain_id) = parse_request(&value)?; + Ok(UsdtPaymentRequest { + recipient: recipient.to_checksum(None), + amount, + chain_id, + }) +} + +fn parse_request(value: &str) -> Result<(Address, Option, Option), UsdtError> { + let value = value.trim(); + if value.len() > 2048 { + return Err(UsdtError::InvalidAddress); + } + let Some((scheme, uri)) = value.split_once(':') else { + return Ok((parse_address(value)?, None, None)); + }; + if !scheme.eq_ignore_ascii_case("ethereum") { + return Err(UsdtError::InvalidAddress); + } + let (target, query) = uri.split_once('?').unwrap_or((uri, "")); + let (address, chain) = target + .strip_prefix("pay-") + .unwrap_or(target) + .split_once('@') + .ok_or(UsdtError::WrongNetwork)?; + let Some(chain) = chain.strip_suffix("/transfer") else { + if chain != CHAIN_ID.to_string() || !query.is_empty() { + return Err(UsdtError::WrongNetwork); + } + return Ok((parse_address(address)?, None, Some(CHAIN_ID))); + }; + if chain != CHAIN_ID.to_string() || parse_address(address)? != TOKEN { + return Err(UsdtError::WrongNetwork); + } + let mut recipient = None; + let mut amount = None; + // ERC-681 uses a literal plus for positive numbers, not HTML form spaces. + let query = query.replace('+', "%2B"); + for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { + match key.as_ref() { + "address" if recipient.is_none() => recipient = Some(parse_address(&value)?), + "uint256" if amount.is_none() => { + amount = Some(super::amount::parse_atomic_amount(&value)?) + } + _ => return Err(UsdtError::InvalidAddress), + } + } + Ok(( + recipient.ok_or(UsdtError::InvalidAddress)?, + amount, + Some(CHAIN_ID), + )) +} diff --git a/src/modules/usdt/rpc.rs b/src/modules/usdt/rpc.rs new file mode 100644 index 0000000..1762cc1 --- /dev/null +++ b/src/modules/usdt/rpc.rs @@ -0,0 +1,345 @@ +use super::UsdtError; +use alloy_primitives::{Address, Bytes, B256, U256}; +use alloy_sol_types::SolCall; +use serde::{de::DeserializeOwned, Deserialize}; +use serde_json::{json, Value}; +use std::{sync::Arc, time::Duration}; +use tokio::{sync::Mutex, time::Instant}; + +const REQUEST_INTERVAL: Duration = Duration::from_millis(750); +const BURST_WINDOW: Duration = Duration::from_millis(750 * 19); + +#[derive(Deserialize)] +pub(super) struct Block { + pub hash: B256, + pub timestamp: U256, + pub transactions: Vec, +} + +impl Block { + pub fn timestamp(&self) -> Result { + self.timestamp + .try_into() + .map_err(|_| UsdtError::InvalidResponse) + } +} + +pub(super) struct Rpc { + client: reqwest::Client, + url: String, + chain_id: u64, + next_request: Arc>, +} + +#[derive(Deserialize)] +struct Response { + result: Option, + error: Option, +} +#[derive(Deserialize)] +struct RpcError { + code: i64, + message: String, +} + +impl Rpc { + pub fn new(url: String, chain_id: u64) -> Result { + let client = endpoint_client(&url, Duration::from_secs(25))?; + Ok(Self { + client, + url, + chain_id, + next_request: Arc::new(Mutex::new(Instant::now())), + }) + } + + pub fn with_url(&self, url: String) -> Result { + let mut rpc = Self::new(url, self.chain_id)?; + rpc.next_request = self.next_request.clone(); + Ok(rpc) + } + + pub async fn verify_chain(&self) -> Result<(), UsdtError> { + let chain: U256 = self.call("eth_chainId", json!([])).await?; + if chain != U256::from(self.chain_id) { + return Err(UsdtError::WrongNetwork); + } + Ok(()) + } + + pub async fn call( + &self, + method: &str, + params: Value, + ) -> Result { + self.reserve_request().await; + let response = self + .client + .post(&self.url) + .json(&json!({"jsonrpc":"2.0","id":1,"method":method,"params":params})) + .send() + .await + .map_err(|_| UsdtError::NetworkUnavailable)?; + if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { + return Err(UsdtError::RateLimited); + } + let status = response.status(); + let overflow = if method == "eth_getLogs" { + UsdtError::LogRangeTooLarge + } else { + UsdtError::InvalidResponse + }; + // The proxy projects receipts to fixed-size USDT protocol events. + let limit = if method == "eth_getTransactionReceipt" { + 16 * 1024 * 1024 + } else { + 2_097_152 + }; + if status.is_server_error() { + return Err(UsdtError::NetworkUnavailable); + } + let body = bounded_json(response, limit, overflow).await; + let response: Response = + match body.and_then(|value| serde_json::from_value(value).map_err(Into::into)) { + Ok(response) => response, + Err(_) if !status.is_success() => return Err(UsdtError::NetworkUnavailable), + Err(error) => return Err(error), + }; + if let Some(error) = response.error { + let message = error.message.to_ascii_lowercase(); + if error.code == -32016 + || [ + "rate limit", + "quota", + "credit", + "too many requests", + "requests per", + "request rate", + "compute units", + ] + .iter() + .any(|term| message.contains(term)) + { + return Err(UsdtError::RateLimited); + } + if method == "eth_getLogs" + && (error.code == -32005 + || [ + "block range too", + "block range exceeds", + "query returned too many", + "response size exceeded", + "log query limit", + ] + .iter() + .any(|term| message.contains(term))) + { + return Err(UsdtError::LogRangeTooLarge); + } + if matches!(error.code, -32002 | -32603) { + return Err(UsdtError::NetworkUnavailable); + } + if matches!( + method, + "eth_chainId" + | "eth_blockNumber" + | "eth_getCode" + | "eth_getTransactionCount" + | "eth_call" + | "eth_getLogs" + | "eth_getBlockByNumber" + | "eth_getTransactionReceipt" + | "eth_getTransactionByHash" + ) { + return Err(UsdtError::NetworkUnavailable); + } + return Err(UsdtError::TransactionRejected { + reason: error.message.chars().take(200).collect(), + }); + } + if !status.is_success() { + return Err(UsdtError::NetworkUnavailable); + } + serde_json::from_value(response.result.unwrap_or(Value::Null)).map_err(Into::into) + } + + async fn reserve_request(&self) { + let mut next = self.next_request.lock().await; + let scheduled = (*next).max(Instant::now()); + // Allow 20 requests in a burst, then 80/minute across chain and bundler calls. + tokio::time::sleep_until(scheduled - BURST_WINDOW).await; + *next = scheduled + REQUEST_INTERVAL; + } + + pub async fn block(&self, number: u64) -> Result { + self.call::>("eth_getBlockByNumber", json!([U256::from(number), false])) + .await? + .ok_or(UsdtError::NetworkUnavailable) + } + + pub async fn block_receipt( + &self, + hash: B256, + block_hash: B256, + number: u64, + ) -> Result { + let receipt: Value = self + .call("eth_getTransactionReceipt", json!([hash])) + .await?; + if receipt.is_null() { + return Err(UsdtError::NetworkUnavailable); + } + if serde_json::from_value::(receipt["transactionHash"].clone())? != hash + || serde_json::from_value::(receipt["blockHash"].clone())? != block_hash + || serde_json::from_value::(receipt["blockNumber"].clone())? != U256::from(number) + { + return Err(UsdtError::InvalidResponse); + } + Ok(receipt) + } + + pub async fn contract(&self, to: Address, call: C) -> Result { + self.contract_at(to, call, "latest").await + } + + pub async fn contract_at( + &self, + to: Address, + call: C, + block: &str, + ) -> Result { + let bytes: Bytes = self + .call( + "eth_call", + json!([{"to":to,"data":Bytes::from(call.abi_encode())},block]), + ) + .await?; + C::abi_decode_returns(&bytes).map_err(|_| UsdtError::InvalidResponse) + } +} + +pub(super) fn endpoint_client(url: &str, timeout: Duration) -> Result { + let parsed = url::Url::parse(url).map_err(|_| UsdtError::NotConfigured)?; + if (parsed.scheme() != "https" + && !(parsed.scheme() == "http" + && matches!(parsed.host_str(), Some("127.0.0.1" | "localhost")))) + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(UsdtError::NotConfigured); + } + reqwest::Client::builder() + .timeout(timeout) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| UsdtError::NetworkUnavailable) +} + +pub(super) async fn bounded_json( + mut response: reqwest::Response, + limit: usize, + overflow: UsdtError, +) -> Result { + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| UsdtError::NetworkUnavailable)? + { + if body.len() + chunk.len() > limit { + return Err(overflow); + } + body.extend_from_slice(&chunk); + } + serde_json::from_slice(&body).map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoints_reject_embedded_credentials_and_insecure_remote_hosts() { + for url in [ + "http://example.com", + "https://user:key@example.com", + "https://example.com?apikey=secret", + "https://example.com#secret", + "not a url", + ] { + assert!(matches!( + Rpc::new(url.into(), 42161), + Err(UsdtError::NotConfigured) + )); + } + assert!(Rpc::new("https://example.com/v1/usdt/chain-rpc".into(), 42161).is_ok()); + assert!(Rpc::new("http://127.0.0.1:3100/v1/usdt/chain-rpc".into(), 42161).is_ok()); + } + + #[tokio::test] + async fn http_errors_preserve_structured_rejections_and_network_failures() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + for (status, body) in [ + ( + 400, + r#"{"error":{"code":-32602,"message":"Unsupported USDT request"}}"#, + ), + ( + 502, + r#"{"error":{"code":-32002,"message":"Provider unavailable"}}"#, + ), + ( + 200, + r#"{"error":{"code":-32603,"message":"Internal server error"}}"#, + ), + (503, "Service unavailable"), + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 4096]; + assert!(socket.read(&mut request).await.unwrap() > 0); + let response = format!("HTTP/1.1 {status} Error\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + socket.write_all(response.as_bytes()).await.unwrap(); + }); + let error = Rpc::new(url, 42161) + .unwrap() + .call::("eth_estimateUserOperationGas", json!([])) + .await + .unwrap_err(); + if status == 400 { + assert!(matches!(error, UsdtError::TransactionRejected { .. })); + } else { + assert!(matches!(error, UsdtError::NetworkUnavailable)); + } + server.await.unwrap(); + } + } + + #[tokio::test(start_paused = true)] + async fn chain_and_bundler_share_bursts_and_sustained_budget() { + let chain = Rpc::new("https://chain.example".into(), 42161).unwrap(); + let bundler = chain.with_url("https://bundler.example".into()).unwrap(); + let start = Instant::now(); + for _ in 0..10 { + chain.reserve_request().await; + bundler.reserve_request().await; + } + assert_eq!(Instant::now(), start); + for _ in 0..80 { + chain.reserve_request().await; + } + assert_eq!(Instant::now() - start, Duration::from_secs(60)); + tokio::time::sleep(Duration::from_secs(30)).await; + let idle = Instant::now(); + for _ in 0..20 { + bundler.reserve_request().await; + } + assert_eq!(Instant::now(), idle); + chain.reserve_request().await; + assert_eq!(Instant::now() - idle, REQUEST_INTERVAL); + } +} diff --git a/src/modules/usdt/store.rs b/src/modules/usdt/store.rs new file mode 100644 index 0000000..d361566 --- /dev/null +++ b/src/modules/usdt/store.rs @@ -0,0 +1,371 @@ +use super::{ + history::HISTORY_REVISIT_BLOCKS, transaction::Plan, UsdtError, UsdtQuote, UsdtTransfer, + UsdtTransferStatus, +}; +use rusqlite::{params, Connection, OptionalExtension, Transaction}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::{ + path::Path, + sync::{Mutex, MutexGuard}, +}; + +#[derive(Clone, Serialize, Deserialize)] +pub(super) struct QuoteData { + pub quote: UsdtQuote, + pub plan: Plan, +} + +pub(super) struct Store(Mutex); + +impl Store { + pub fn open(path: &str, identity: &str) -> Result { + if let Some(parent) = Path::new(path).parent() { + std::fs::create_dir_all(parent).map_err(|e| UsdtError::Storage { + reason: e.to_string(), + })?; + } + let connection = Connection::open(path)?; + connection.busy_timeout(std::time::Duration::from_secs(5))?; + connection.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; + CREATE TABLE IF NOT EXISTS usdt_identity (id INTEGER PRIMARY KEY CHECK(id=1), identity TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS usdt_sync (id INTEGER PRIMARY KEY CHECK(id=1), newest INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS usdt_history_progress (id INTEGER PRIMARY KEY CHECK(id=1), next INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS usdt_history_receipts (hash TEXT PRIMARY KEY, block_number INTEGER NOT NULL, block_hash TEXT NOT NULL, complete_receipt INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS usdt_history_blocks (number INTEGER PRIMARY KEY, hash TEXT NOT NULL, complete INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS usdt_quotes (id TEXT PRIMARY KEY, data TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS usdt_nonce_recovery (id TEXT PRIMARY KEY, block_hash TEXT NOT NULL, next_transaction INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS usdt_transfers (id TEXT PRIMARY KEY, hash TEXT NOT NULL, raw TEXT, data TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS usdt_transfers_hash ON usdt_transfers(hash);")?; + connection.execute( + "INSERT OR IGNORE INTO usdt_identity VALUES (1,?1)", + [identity], + )?; + let stored: String = + connection.query_row("SELECT identity FROM usdt_identity WHERE id=1", [], |r| { + r.get(0) + })?; + if stored != identity { + return Err(UsdtError::InvalidCredentials); + } + Ok(Self(Mutex::new(connection))) + } + + fn connection(&self) -> Result, UsdtError> { + self.0.lock().map_err(|_| UsdtError::Storage { + reason: "USDT storage lock unavailable".into(), + }) + } + + pub fn save_quote(&self, data: &QuoteData) -> Result<(), UsdtError> { + let mut connection = self.connection()?; + let tx = connection.transaction()?; + tx.execute( + "DELETE FROM usdt_quotes WHERE json_extract(data, '$.quote.expires_at') <= ?1", + [super::wallet::now()], + )?; + tx.execute( + "INSERT INTO usdt_quotes VALUES (?1,?2)", + params![data.quote.id, serde_json::to_string(data)?], + )?; + tx.commit()?; + Ok(()) + } + + pub fn quote(&self, id: &str) -> Result { + let data: Option = self + .connection()? + .query_row("SELECT data FROM usdt_quotes WHERE id=?1", [id], |r| { + r.get(0) + }) + .optional()?; + decode(&data.ok_or(UsdtError::QuoteExpired)?) + } + + pub fn transfer(&self, id: &str) -> Result, UsdtError> { + let data: Option = self + .connection()? + .query_row("SELECT data FROM usdt_transfers WHERE id=?1", [id], |r| { + r.get(0) + }) + .optional()?; + data.map(|data| decode(&data)).transpose() + } + + pub fn transfer_by_hash(&self, hash: &str) -> Result, UsdtError> { + find_transfer_by_hash(&*self.connection()?, hash) + } + + pub fn transfers(&self) -> Result, UsdtError> { + let connection = self.connection()?; + let mut statement = connection.prepare( + "SELECT data FROM usdt_transfers ORDER BY json_extract(data, '$.timestamp') DESC", + )?; + let rows = statement.query_map([], |row| row.get::<_, String>(0))?; + rows.map(|row| decode(&row?)).collect() + } + + pub fn require_no_pending(&self) -> Result<(), UsdtError> { + require_no_pending(&*self.connection()?) + } + + pub fn record_signed(&self, transfer: &UsdtTransfer, raw: &str) -> Result<(), UsdtError> { + let mut connection = self.connection()?; + let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + require_no_pending(&tx)?; + tx.execute( + "INSERT INTO usdt_transfers (id,hash,raw,data) VALUES (?1,?2,?3,?4)", + params![ + transfer.id, + transfer.user_operation_hash, + raw, + serde_json::to_string(transfer)? + ], + )?; + tx.execute("DELETE FROM usdt_quotes WHERE id=?1", [&transfer.id])?; + tx.commit()?; + Ok(()) + } + + pub fn update_transfer(&self, transfer: &UsdtTransfer) -> Result<(), UsdtError> { + let mut connection = self.connection()?; + let tx = connection.transaction()?; + write_transfer(&tx, transfer)?; + tx.commit()?; + Ok(()) + } + + pub fn nonce_recovery(&self, id: &str, block_hash: &str) -> Result { + Ok(self + .connection()? + .query_row( + "SELECT next_transaction FROM usdt_nonce_recovery WHERE id=?1 AND block_hash=?2", + params![id, block_hash], + |row| row.get(0), + ) + .optional()? + .unwrap_or(0)) + } + + pub fn save_nonce_recovery( + &self, + id: &str, + block_hash: &str, + next_transaction: usize, + ) -> Result<(), UsdtError> { + self.connection()?.execute( + "INSERT INTO usdt_nonce_recovery VALUES (?1,?2,?3) ON CONFLICT(id) DO UPDATE SET block_hash=excluded.block_hash,next_transaction=excluded.next_transaction", + params![id, block_hash, next_transaction], + )?; + Ok(()) + } + + pub fn synced_block(&self) -> Result, UsdtError> { + Ok(self + .connection()? + .query_row("SELECT newest FROM usdt_sync WHERE id=1", [], |row| { + row.get(0) + }) + .optional()?) + } + + pub fn complete_history(&self, newest: u64) -> Result<(), UsdtError> { + let mut connection = self.connection()?; + let tx = connection.transaction()?; + tx.execute("DELETE FROM usdt_history_progress", [])?; + tx.execute( + "DELETE FROM usdt_history_receipts WHERE complete_receipt=0 OR block_number < ?1", + [newest.saturating_sub(HISTORY_REVISIT_BLOCKS)], + )?; + tx.execute( + "DELETE FROM usdt_history_blocks WHERE number < ?1", + [newest.saturating_sub(HISTORY_REVISIT_BLOCKS)], + )?; + tx.execute("INSERT INTO usdt_sync (id,newest) VALUES (1,?1) ON CONFLICT(id) DO UPDATE SET newest=excluded.newest", [newest])?; + tx.commit()?; + Ok(()) + } + + pub fn history_progress(&self) -> Result, UsdtError> { + Ok(self + .connection()? + .query_row( + "SELECT next FROM usdt_history_progress WHERE id=1", + [], + |row| row.get(0), + ) + .optional()?) + } + + pub fn save_history_progress(&self, next: u64) -> Result<(), UsdtError> { + let mut connection = self.connection()?; + let tx = connection.transaction()?; + tx.execute( + "DELETE FROM usdt_history_blocks WHERE number < ?1", + [next.saturating_sub(HISTORY_REVISIT_BLOCKS)], + )?; + tx.execute("INSERT INTO usdt_history_progress VALUES (1,?1) ON CONFLICT(id) DO UPDATE SET next=excluded.next", [next])?; + tx.execute( + "DELETE FROM usdt_history_receipts WHERE complete_receipt=0 OR block_number < ?1", + [next.saturating_sub(HISTORY_REVISIT_BLOCKS)], + )?; + tx.commit()?; + Ok(()) + } + + pub fn begin_history_block(&self, number: u64, hash: &str) -> Result { + let mut connection = self.connection()?; + let tx = connection.transaction()?; + let saved: Option<(String, bool)> = tx + .query_row( + "SELECT hash, complete FROM usdt_history_blocks WHERE number=?1", + [number], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((saved_hash, complete)) = saved { + if saved_hash == hash { + return Ok(complete); + } + tx.execute( + "DELETE FROM usdt_history_receipts WHERE block_number=?1", + [number], + )?; + } + tx.execute("INSERT INTO usdt_history_blocks VALUES (?1,?2,0) ON CONFLICT(number) DO UPDATE SET hash=excluded.hash,complete=0", params![number, hash])?; + tx.commit()?; + Ok(false) + } + + pub fn complete_history_block(&self, number: u64, hash: &str) -> Result<(), UsdtError> { + self.connection()?.execute( + "UPDATE usdt_history_blocks SET complete=1 WHERE number=?1 AND hash=?2", + params![number, hash], + )?; + Ok(()) + } + + pub fn has_history_receipt( + &self, + hash: &str, + block_hash: &str, + require_complete: bool, + ) -> Result { + Ok(self.connection()?.query_row( + "SELECT EXISTS(SELECT 1 FROM usdt_history_receipts WHERE hash=?1 AND block_hash=?2 AND (complete_receipt=1 OR ?3=0))", + params![hash, block_hash, require_complete], + |row| row.get(0), + )?) + } + + pub fn save_history_receipt( + &self, + transfers: &[UsdtTransfer], + hash: &str, + block_number: u64, + block_hash: &str, + complete_receipt: bool, + ) -> Result<(), UsdtError> { + let mut connection = self.connection()?; + let tx = connection.transaction()?; + Self::merge_history(&tx, transfers)?; + tx.execute( + "INSERT INTO usdt_history_receipts VALUES (?1,?2,?3,?4) ON CONFLICT(hash) DO UPDATE SET block_number=excluded.block_number,block_hash=excluded.block_hash,complete_receipt=excluded.complete_receipt", + params![hash, block_number, block_hash, complete_receipt], + )?; + tx.commit()?; + Ok(()) + } + + fn merge_history(tx: &Transaction<'_>, transfers: &[UsdtTransfer]) -> Result<(), UsdtError> { + for transfer in transfers { + let hash = transfer + .user_operation_hash + .as_deref() + .unwrap_or(&transfer.id); + let existing = find_transfer_by_hash(tx, hash)?; + let mut transfer = transfer.clone(); + if let Some(saved) = existing { + transfer.id = saved.id; + write_transfer(tx, &transfer)?; + } else { + tx.execute( + "INSERT INTO usdt_transfers (id,hash,data) VALUES (?1,?2,?3)", + params![transfer.id, hash, serde_json::to_string(&transfer)?], + )?; + } + } + Ok(()) + } + + pub fn pending_operation(&self) -> Result, UsdtError> { + let saved: Option<(String, String)> = self + .connection()? + .query_row( + "SELECT data,raw FROM usdt_transfers WHERE raw IS NOT NULL", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + saved + .map(|(data, raw)| Ok((decode(&data)?, decode(&raw)?))) + .transpose() + } + + pub fn pending_plan(&self, id: &str) -> Result, UsdtError> { + let raw: Option = self + .connection()? + .query_row("SELECT raw FROM usdt_transfers WHERE id=?1", [id], |r| { + r.get(0) + }) + .optional()? + .flatten(); + raw.map(|raw| decode(&raw)).transpose() + } +} + +fn write_transfer(connection: &Connection, transfer: &UsdtTransfer) -> Result<(), UsdtError> { + let settled = transfer.status != UsdtTransferStatus::Pending; + connection.execute( + "UPDATE usdt_transfers SET data=?1, raw=CASE WHEN ?2 THEN NULL ELSE raw END WHERE id=?3", + params![serde_json::to_string(transfer)?, settled, transfer.id], + )?; + if settled { + connection.execute( + "DELETE FROM usdt_nonce_recovery WHERE id=?1", + [&transfer.id], + )?; + } + Ok(()) +} + +fn find_transfer_by_hash( + connection: &Connection, + hash: &str, +) -> Result, UsdtError> { + let data: Option = connection + .query_row( + "SELECT data FROM usdt_transfers WHERE hash=?1", + [hash], + |row| row.get(0), + ) + .optional()?; + data.map(|data| decode(&data)).transpose() +} + +fn decode(data: &str) -> Result { + serde_json::from_str(data).map_err(|error| UsdtError::Storage { + reason: error.to_string(), + }) +} + +fn require_no_pending(connection: &Connection) -> Result<(), UsdtError> { + let pending: bool = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM usdt_transfers WHERE raw IS NOT NULL)", + [], + |row| row.get(0), + )?; + if pending { + return Err(UsdtError::PendingTransfer); + } + Ok(()) +} diff --git a/src/modules/usdt/tests.rs b/src/modules/usdt/tests.rs new file mode 100644 index 0000000..4285379 --- /dev/null +++ b/src/modules/usdt/tests.rs @@ -0,0 +1,2681 @@ +use super::*; + +#[test] +fn account_and_signatures_match_entrypoint_v8_reference() { + use alloy_primitives::{Address, Bytes, B256}; + let vector: serde_json::Value = + serde_json::from_str(include_str!("fixtures/eip7702-vectors.json")).unwrap(); + let address: Address = vector["address"].as_str().unwrap().parse().unwrap(); + assert_eq!( + usdt_address(TEST_PHRASE.into(), None).unwrap(), + address.to_checksum(None) + ); + let mut op: user_operation::UserOperation = + serde_json::from_value(vector["operation"].clone()).unwrap(); + let calls: Vec<(Address, Bytes)> = vector["calls"] + .as_array() + .unwrap() + .iter() + .map(|call| { + ( + call["to"].as_str().unwrap().parse().unwrap(), + call["data"].as_str().unwrap().parse().unwrap(), + ) + }) + .collect(); + assert_eq!(account::batch(&calls), op.call_data); + let expected_hash: B256 = vector["userOperationHash"] + .as_str() + .unwrap() + .parse() + .unwrap(); + assert_eq!(op.hash(42161).unwrap(), expected_hash); + assert_eq!( + op.eip7702_auth.hash().unwrap(), + vector["authorizationHash"] + .as_str() + .unwrap() + .parse::() + .unwrap() + ); + let key = keys::derive_key(TEST_PHRASE.to_owned().into(), None).unwrap(); + assert_eq!(op.sign(&key, 42161).unwrap(), expected_hash); + assert_eq!( + op.signature, + vector["signature"] + .as_str() + .unwrap() + .parse::() + .unwrap() + ); + assert_eq!( + op.eip7702_auth, + serde_json::from_value(vector["operation"]["eip7702Auth"].clone()).unwrap() + ); + assert_ne!(op.hash(42161).unwrap(), op.hash(1).unwrap()); + op.sender = RECIPIENT.parse().unwrap(); + assert!(matches!( + op.sign(&key, 42161), + Err(UsdtError::InvalidCredentials) + )); +} + +#[test] +fn amount_round_trips_without_rounding() { + for (text, amount) in [ + ("0", 0), + ("0.000001", 1), + ("12.345678", 12_345_678), + ("18446744073709.551615", u64::MAX), + ] { + assert_eq!(usdt_parse_amount(text.into()).unwrap(), amount); + assert_eq!(usdt_format_amount(amount), text); + } + for invalid in [ + "-1", + "+1", + "1e6", + "1.0000001", + "18446744073709.551616", + "1,000", + ".", + "", + ] { + assert!(usdt_parse_amount(invalid.into()).is_err()); + } +} + +#[test] +fn wallet_requires_both_provider_endpoints() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("usdt.sqlite"); + for (rpc, bundler) in [ + ("", "https://provider.example"), + ("https://provider.example", ""), + ("not a url", "https://provider.example"), + ( + "https://provider.example", + "https://example.com?api-key=secret", + ), + ] { + assert!(matches!( + UsdtWallet::new( + RECIPIENT.into(), + path.to_string_lossy().into(), + rpc.into(), + bundler.into() + ), + Err(UsdtError::NotConfigured) + )); + } + assert!(!path.exists()); +} + +#[test] +fn address_matches_standard_ethereum_recovery_path() { + let phrase = "test test test test test test test test test test test junk"; + assert_eq!( + usdt_address(phrase.into(), None).unwrap(), + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + ); + assert_ne!( + usdt_address(phrase.into(), Some("secret".into())).unwrap(), + usdt_address(phrase.into(), None).unwrap() + ); +} + +#[test] +fn payment_request_rejects_wrong_chain_and_malformed_checksum() { + let address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + assert_eq!( + usdt_parse_payment_request(format!("ethereum:{address}@42161")) + .unwrap() + .recipient, + address + ); + let request = format!( + "ethereum:{}@42161/transfer?address={address}", + types::TOKEN.to_checksum(None) + ); + assert_eq!( + usdt_parse_payment_request(request).unwrap().recipient, + address + ); + assert_eq!( + usdt_parse_payment_request(address.into()).unwrap().chain_id, + None + ); + assert_eq!( + usdt_parse_payment_request(format!("ethereum:{address}@42161")) + .unwrap() + .chain_id, + Some(42161) + ); + for invalid in [ + format!("ethereum:{address}@42161/transfer?address={address}"), + format!("ethereum:{address}@1"), + format!("ethereum:{address}@42161?value=1"), + "0x0000000000000000000000000000000000000000".into(), + "0xF39Fd6e51aad88F6F4ce6aB8827279cffFb92266".into(), + ] { + assert!(usdt_parse_payment_request(invalid).is_err()); + } +} + +#[test] +fn payment_requests_preserve_exact_token_amounts_and_reject_ambiguous_terms() { + let uri = |query: &str| { + format!( + "ethereum:pay-{}@42161/transfer?{query}", + types::TOKEN.to_checksum(None) + ) + }; + for (encoded, expected) in [ + ("1", 1), + ("1500000", 1_500_000), + ("1.5e6", 1_500_000), + (".5E6", 500_000), + ("+1.0000000e6", 1_000_000), + ("%2B1e6", 1_000_000), + ("0", 0), + (".0", 0), + ("18446744073709551615", u64::MAX), + ] { + let value = uri(&format!("uint256={encoded}&address={RECIPIENT}")); + let request = usdt_parse_payment_request(value).unwrap(); + assert_eq!(request.recipient, RECIPIENT); + assert_eq!(request.amount, Some(expected)); + } + for value in [RECIPIENT.into(), uri(&format!("address={RECIPIENT}"))] { + assert_eq!(usdt_parse_payment_request(value).unwrap().amount, None); + } + for amount in [ + "-1", + "1.01", + "1e-1", + "18446744073709551616", + "1e100000", + "1e", + "NaN", + "", + "1.2.3", + ] { + assert!( + usdt_parse_payment_request(uri(&format!("address={RECIPIENT}&uint256={amount}"))) + .is_err() + ); + } + for query in [ + format!("address={RECIPIENT}&address={RECIPIENT}"), + format!("address={RECIPIENT}&uint256=1&uint256=2"), + format!("address={RECIPIENT}&value=1"), + format!("address={RECIPIENT}&data=0x00"), + "uint256=1000000".into(), + ] { + assert!(usdt_parse_payment_request(uri(&query)).is_err()); + } + assert!(usdt_parse_payment_request( + uri(&format!("address={RECIPIENT}&uint256=1")).replace("@42161", "@1") + ) + .is_err()); +} + +const TEST_PHRASE: &str = "test test test test test test test test test test test junk"; +const RECIPIENT: &str = "0x1111111111111111111111111111111111111111"; + +struct MockChain { + url: String, + state: std::sync::Arc>, + task: tokio::task::JoinHandle<()>, +} +struct ChainState { + chain: u64, + account_code: alloy_primitives::Bytes, + authorization_nonce: u64, + paymaster_valid_until: Option, + pre_verification_estimates: std::collections::VecDeque, + gas_price: u64, + external_outgoing: bool, + nonce: u64, + balance: alloy_primitives::U256, + operations: Vec, + mined: bool, + tip: u64, + timestamp: alloy_primitives::U256, + block_hashes: std::collections::BTreeMap, + block_timestamps: std::collections::BTreeMap, + reject_broadcast: bool, + delay_gas_estimate: bool, + paymaster: alloy_primitives::Address, + history_input: Option, + history_target: Option, + receipt_logs: Option>, + incoming: bool, + hide_logs: bool, + hide_operation_logs: bool, + hide_receipts: bool, + receipt_failure: Option, + oversized_block: Option, + receipt_padding: usize, + log_response: Option>, + block_transactions: Option>, + replacement_block: Option, + log_error: Option<(i64, String)>, + max_log_range: Option, + oversized_logs: bool, + log_requests: usize, + log_ranges: Vec<(u64, u64)>, + incoming_count: u64, + block_reads: usize, + fail_block_read_at: Option, + authorization_change_on_block_read: bool, + receipt_reads: usize, + receipt_response: Option, +} +impl Drop for MockChain { + fn drop(&mut self) { + self.task.abort(); + } +} +impl MockChain { + async fn start() -> Self { + use alloy_primitives::U256; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let state = std::sync::Arc::new(std::sync::Mutex::new(ChainState { + chain: 42161, + account_code: alloy_primitives::Bytes::new(), + authorization_nonce: 0, + paymaster_valid_until: None, + pre_verification_estimates: Default::default(), + gas_price: 50_000_000, + external_outgoing: false, + nonce: 0, + balance: U256::from(10_000_000), + operations: vec![], + mined: false, + tip: 20000, + timestamp: U256::from(wallet::now()), + block_hashes: Default::default(), + block_timestamps: Default::default(), + reject_broadcast: false, + delay_gas_estimate: false, + paymaster: paymaster::PAYMASTER, + history_input: None, + history_target: Some(account::ENTRY_POINT), + receipt_logs: None, + incoming: false, + hide_logs: false, + hide_operation_logs: false, + hide_receipts: false, + receipt_failure: None, + oversized_block: None, + receipt_padding: 0, + log_response: None, + block_transactions: None, + replacement_block: None, + log_error: None, + max_log_range: None, + oversized_logs: false, + log_requests: 0, + log_ranges: vec![], + incoming_count: 0, + block_reads: 0, + fail_block_read_at: None, + authorization_change_on_block_read: false, + receipt_reads: 0, + receipt_response: None, + })); + let server_state = state.clone(); + let task = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut request = Vec::new(); + let header_end = loop { + let mut chunk = [0u8; 4096]; + let n = socket.read(&mut chunk).await.unwrap(); + if n == 0 { + break 0; + } + request.extend_from_slice(&chunk[..n]); + if let Some(p) = request.windows(4).position(|w| w == b"\r\n\r\n") { + break p + 4; + } + }; + if header_end == 0 { + continue; + } + let length: usize = String::from_utf8_lossy(&request[..header_end]) + .lines() + .find_map(|line| { + line.to_lowercase() + .strip_prefix("content-length:") + .map(|v| v.trim().parse().unwrap()) + }) + .unwrap(); + while request.len() < header_end + length { + let mut chunk = [0u8; 4096]; + let n = socket.read(&mut chunk).await.unwrap(); + if n == 0 { + break; + } + request.extend_from_slice(&chunk[..n]); + } + let body: serde_json::Value = + serde_json::from_slice(&request[header_end..]).unwrap(); + let path = String::from_utf8_lossy(&request[..header_end]) + .lines() + .next() + .unwrap() + .split_whitespace() + .nth(1) + .unwrap() + .to_owned(); + let method = body["method"].as_str().unwrap(); + let bundler = matches!( + method, + "pimlico_getTokenQuotes" + | "pimlico_getUserOperationGasPrice" + | "pm_getPaymasterData" + | "pm_getPaymasterStubData" + | "eth_estimateUserOperationGas" + | "eth_sendUserOperation" + ); + if path == "/chain" { + assert!(!bundler, "Bundler method on chain endpoint"); + } + if path == "/bundler" { + assert!( + bundler || method == "eth_chainId", + "Chain method on bundler endpoint" + ); + } + let delay = body["method"] == "eth_estimateUserOperationGas" + && std::mem::take(&mut server_state.lock().unwrap().delay_gas_estimate); + if delay { + tokio::time::sleep(std::time::Duration::from_secs(6)).await; + } + let mut response = server_state.lock().unwrap().respond(&body); + if body["method"] == "eth_getLogs" { + if let Some(logs) = response["result"].as_array_mut() { + for log in logs { + let number = serde_json::from_value::(log["blockNumber"].clone()) + .ok() + .and_then(|number| u64::try_from(number).ok()) + .unwrap_or(20000); + log.as_object_mut().unwrap().entry("blockHash").or_insert( + serde_json::json!(server_state.lock().unwrap().block_hash(number)), + ); + } + } + } + if body["method"] == "eth_getTransactionByHash" && !response["result"].is_null() { + response["result"]["hash"] = body["params"][0].clone(); + } + let response = response.to_string(); + let response=format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}",response.len()); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + Self { url, state, task } + } + fn wallet(&self, dir: &tempfile::TempDir) -> std::sync::Arc { + UsdtWallet::new( + usdt_address(TEST_PHRASE.into(), None).unwrap(), + dir.path().join("usdt.sqlite").to_string_lossy().into(), + format!("{}/chain", self.url), + format!("{}/bundler", self.url), + ) + .unwrap() + } +} +impl ChainState { + fn block_hash(&self, number: u64) -> alloy_primitives::B256 { + self.block_hashes.get(&number).copied().unwrap_or_else(|| { + alloy_primitives::B256::from(alloy_primitives::U256::from(number).to_be_bytes::<32>()) + }) + } + + fn respond(&mut self, body: &serde_json::Value) -> serde_json::Value { + use alloy_primitives::{Bytes, U256}; + use alloy_sol_types::{SolCall, SolValue}; + use serde_json::json; + if matches!( + body["method"].as_str(), + Some("eth_estimateUserOperationGas" | "eth_sendUserOperation") + ) { + let op = &body["params"][0]; + let auth = &op["eip7702Auth"]; + // Alto requires authorization with the marker, even for an existing delegation. + if op["factory"] != "0x7702" + || auth.is_null() + || serde_json::from_value::(auth["nonce"].clone()).ok() + != Some(U256::from(self.authorization_nonce)) + { + return json!({"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"Invalid EIP-7702 authorization"}}); + } + } + let result = match body["method"].as_str().unwrap() { + "eth_chainId" => json!(U256::from(self.chain)), + "eth_blockNumber" => json!(U256::from(self.tip)), + "eth_getBlockByNumber" => { + self.block_reads += 1; + if std::mem::take(&mut self.authorization_change_on_block_read) { + self.authorization_nonce += 1; + } + if self.fail_block_read_at == Some(self.block_reads) { + self.fail_block_read_at = None; + return json!({"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"temporarily unavailable"}}); + } + let number = u64::try_from( + serde_json::from_value::(body["params"][0].clone()).unwrap(), + ) + .unwrap(); + let timestamp = self + .block_timestamps + .range(..=number) + .next_back() + .map(|(_, timestamp)| *timestamp) + .unwrap_or(self.timestamp); + json!({"hash":self.block_hash(number),"timestamp":timestamp,"transactions":self.block_transactions.clone().unwrap_or_else(|| vec![alloy_primitives::B256::repeat_byte(7)])}) + } + "eth_getCode" => json!(self.account_code), + "eth_getTransactionCount" => json!(U256::from(self.authorization_nonce)), + "eth_call" => { + let data: Bytes = + serde_json::from_value(body["params"][0]["data"].clone()).unwrap(); + let encoded = if data.starts_with(&transaction::EntryPoint::getNonceCall::SELECTOR) + { + let block = serde_json::from_value::(body["params"][1].clone()).ok(); + let nonce = if self + .replacement_block + .zip(block) + .is_some_and(|(mined, at)| at < U256::from(mined)) + { + 0 + } else { + self.nonce + }; + U256::from(nonce).abi_encode() + } else { + self.balance.abi_encode() + }; + json!(Bytes::from(encoded)) + } + "pimlico_getTokenQuotes" => { + json!({"quotes":[{"token":types::TOKEN,"paymaster":self.paymaster,"postOpGas":"0xc350","exchangeRate":U256::from(3_000_000_000u64)}]}) + } + "pimlico_getUserOperationGasPrice" => { + json!({"slow":{"maxFeePerGas":U256::from(self.gas_price * 9 / 10),"maxPriorityFeePerGas":U256::from(100000)},"fast":{"maxFeePerGas":U256::from(self.gas_price),"maxPriorityFeePerGas":U256::from(100000)}}) + } + "pm_getPaymasterData" | "pm_getPaymasterStubData" => { + let mut data = vec![0; 118]; + data[0] = 3; + if body["method"] == "pm_getPaymasterData" { + data[2..8].copy_from_slice( + &self + .paymaster_valid_until + .unwrap_or(u64::try_from(self.timestamp).unwrap() + 600) + .to_be_bytes()[2..], + ); + } + data[14..34].copy_from_slice(types::TOKEN.as_slice()); + data[34..50].copy_from_slice(&50_000u128.to_be_bytes()); + data[50..82].copy_from_slice(&U256::from(3_000_000_000u64).to_be_bytes::<32>()); + data.extend_from_slice(&[1; 65]); + let mut response = + json!({"paymaster":self.paymaster,"paymasterData":Bytes::from(data)}); + if body["method"] == "pm_getPaymasterStubData" { + response["paymasterPostOpGasLimit"] = json!("0x186a0"); + } else { + response["paymasterVerificationGasLimit"] = json!("0xc350"); + } + response + } + "eth_estimateUserOperationGas" => { + let data: Bytes = + serde_json::from_value(body["params"][0]["paymasterData"].clone()).unwrap(); + let verification_gas = if data[2..8].iter().any(|byte| *byte != 0) { + "0x13880" + } else { + "0xc350" + }; + let pre_verification_gas = self + .pre_verification_estimates + .pop_front() + .unwrap_or(80_000); + json!({"callGasLimit":"0x249f0","verificationGasLimit":"0x3d090","preVerificationGas":U256::from(pre_verification_gas),"paymasterVerificationGasLimit":verification_gas,"paymasterPostOpGasLimit":"0xc350"}) + } + "eth_sendUserOperation" => { + let op: user_operation::UserOperation = + serde_json::from_value(body["params"][0].clone()).unwrap(); + let hash = op.hash(types::CHAIN_ID).unwrap(); + self.operations.push(op); + if self.reject_broadcast { + return json!({"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"temporarily unavailable"}}); + } + json!(hash) + } + "eth_getLogs" => { + self.log_requests += 1; + if let Some((code, message)) = &self.log_error { + return json!({"jsonrpc":"2.0","id":1,"error":{"code":code,"message":message}}); + } + let filter = &body["params"][0]; + let from: U256 = serde_json::from_value(filter["fromBlock"].clone()).unwrap(); + let to: U256 = serde_json::from_value(filter["toBlock"].clone()).unwrap(); + self.log_ranges + .push((u64::try_from(from).unwrap(), u64::try_from(to).unwrap())); + if self + .oversized_block + .is_some_and(|block| from <= U256::from(block) && to >= U256::from(block)) + { + return json!({"jsonrpc":"2.0","id":1,"error":{"code":-32005,"message":"Log query limit exceeded"}}); + } + if let Some(logs) = &self.log_response { + return json!({"jsonrpc":"2.0","id":1,"result":logs}); + } + if self + .max_log_range + .is_some_and(|limit| to - from + U256::from(1) > U256::from(limit)) + { + if self.oversized_logs { + return json!({"jsonrpc":"2.0","id":1,"result":[],"padding":" ".repeat(2_097_152)}); + } + return json!({"jsonrpc":"2.0","id":1,"error":{"code":-32005,"message":"Log query limit exceeded"}}); + } + if let Some(block) = self.replacement_block { + if !self.hide_logs + && from <= U256::from(block) + && to >= U256::from(block) + && (filter["address"] == json!(account::ENTRY_POINT) + || filter["address"].is_array()) + && filter["topics"][1].is_null() + { + use alloy_sol_types::SolEvent; + let event = transaction::EntryPoint::UserOperationEvent { + userOpHash: alloy_primitives::B256::repeat_byte(0xbb), + sender: self.operations[0].sender, + paymaster: self.paymaster, + nonce: U256::ZERO, + success: true, + actualGasCost: U256::from(1), + actualGasUsed: U256::from(1), + } + .encode_log_data(); + return json!({"jsonrpc":"2.0","id":1,"result":[{"address":account::ENTRY_POINT,"topics":event.topics(),"data":event.data,"transactionHash":alloy_primitives::B256::repeat_byte(8),"blockNumber":U256::from(block),"logIndex":"0x1"}]}); + } + } + if self.external_outgoing + && filter["address"] == json!(types::TOKEN) + && !filter["topics"][1].is_null() + && from <= U256::from(20000) + && to >= U256::from(20000) + { + return json!({"jsonrpc":"2.0","id":1,"result":[self.event_logs().last().unwrap()]}); + } + if self.incoming_count > 0 + && (filter["address"] == json!(types::TOKEN) || filter["address"].is_array()) + && !filter["topics"][2].is_null() + { + use alloy_sol_types::SolEvent; + let event = transaction::Erc20::Transfer { + from: alloy_primitives::Address::repeat_byte(8), + to: self.operations[0].sender, + value: U256::from(42), + } + .encode_log_data(); + let logs: Vec<_> = (1..=self.incoming_count).filter(|index| U256::from(20000 + index) >= from && U256::from(20000 + index) <= to).map(|index| { + json!({"address":types::TOKEN,"topics":event.topics(),"data":event.data,"transactionHash":format!("0x{index:064x}"),"blockNumber":U256::from(20000+index),"logIndex":"0x0"}) + }).collect(); + return json!({"jsonrpc":"2.0","id":1,"result":logs}); + } + if self.mined + && !self.hide_logs + && !self.hide_operation_logs + && from <= U256::from(20000) + && to >= U256::from(20000) + && (filter["address"] == json!(account::ENTRY_POINT) + || filter["address"].is_array()) + { + json!([self + .event_logs() + .into_iter() + .find(|log| log["address"] == json!(account::ENTRY_POINT.to_checksum(None))) + .unwrap()]) + } else { + json!([]) + } + } + "eth_getTransactionReceipt" => { + self.receipt_reads += 1; + if let Some(response) = &self.receipt_response { + return json!({"jsonrpc":"2.0","id":1,"result":response}); + } + if self.hide_receipts + || self + .receipt_failure + .is_some_and(|hash| body["params"][0] == json!(hash)) + { + return json!({"jsonrpc":"2.0","id":1,"result":null}); + } + let logs = self.receipt_logs.clone().unwrap_or_else(|| { + if body["params"][0] == json!(alloy_primitives::B256::repeat_byte(7)) { + self.event_logs() + } else { + vec![] + } + }); + json!({"transactionHash":body["params"][0],"blockHash":self.block_hash(20000),"blockNumber":"0x4e20","logs":logs,"padding":" ".repeat(self.receipt_padding)}) + } + "eth_getTransactionByHash" => { + let op = &self.operations[0]; + let input = transaction::EntryPoint::handleOpsCall { + ops: vec![transaction::PackedOperation { + sender: op.sender, + nonce: op.nonce, + // EntryPoint's onchain marker is right-padded to 20 bytes. + initCode: Bytes::from_static(&alloy_primitives::hex!( + "7702000000000000000000000000000000000000" + )), + callData: op.call_data.clone(), + accountGasLimits: Default::default(), + preVerificationGas: op.pre_verification_gas, + gasFees: Default::default(), + paymasterAndData: op.paymaster_and_data().unwrap(), + signature: op.signature.clone(), + }], + beneficiary: alloy_primitives::Address::repeat_byte(9), + } + .abi_encode(); + json!({"to":self.history_target,"input":self.history_input.clone().unwrap_or_else(|| Bytes::from(input))}) + } + method => panic!("Unexpected RPC method {method}"), + }; + json!({"jsonrpc":"2.0","id":1,"result":result}) + } + fn event_logs(&self) -> Vec { + use alloy_primitives::{B256, U256}; + use alloy_sol_types::{SolCall, SolEvent}; + use serde_json::json; + let op = self.operations.first().unwrap(); + let hash = op.hash(types::CHAIN_ID).unwrap(); + let gas = transaction::Paymaster::UserOperationSponsored { + userOpHash: hash, + user: op.sender, + paymasterMode: 1, + token: types::TOKEN, + tokenAmountPaid: U256::from(123), + exchangeRate: U256::from(3_000_000_000u64), + } + .encode_log_data(); + let event = transaction::EntryPoint::UserOperationEvent { + userOpHash: hash, + sender: op.sender, + paymaster: paymaster::PAYMASTER, + nonce: op.nonce, + success: true, + actualGasCost: U256::from(1), + actualGasUsed: U256::from(1), + } + .encode_log_data(); + let mut logs = vec![ + json!({"address":paymaster::PAYMASTER,"topics":gas.topics(),"data":gas.data,"transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20","logIndex":"0x0"}), + json!({"address":account::ENTRY_POINT.to_checksum(None),"topics":event.topics(),"data":event.data,"transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20","logIndex":"0x1"}), + ]; + for (target, data) in history::decode_calls(&op.call_data).unwrap_or_default() { + if target == types::TOKEN { + if let Ok(call) = transaction::Erc20::transferCall::abi_decode(&data) { + if call.amount > U256::from(u64::MAX) { + continue; + } + let payment = transaction::Erc20::Transfer { + from: op.sender, + to: call.recipient, + value: call.amount, + } + .encode_log_data(); + logs.insert(0, json!({"address":types::TOKEN,"topics":payment.topics(),"data":payment.data, + "transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20","logIndex":"0x0"})); + for (index, log) in logs.iter_mut().enumerate() { + log["logIndex"] = json!(format!("0x{index:x}")); + } + } + } + } + if !self.mined { + logs.clear(); + } + if self.incoming { + let event = transaction::Erc20::Transfer { + from: alloy_primitives::Address::repeat_byte(8), + to: op.sender, + value: U256::from(42), + } + .encode_log_data(); + logs.push(json!({"address":types::TOKEN,"topics":event.topics(),"data":event.data,"logIndex":format!("0x{:x}", logs.len())})); + } + if self.external_outgoing { + let event = transaction::Erc20::Transfer { + from: op.sender, + to: RECIPIENT.parse().unwrap(), + value: U256::from(77), + } + .encode_log_data(); + logs.push(json!({"address":types::TOKEN,"topics":event.topics(),"data":event.data,"transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20","logIndex":format!("0x{:x}", logs.len())})); + } + logs + } +} + +#[tokio::test] +async fn signed_operation_survives_uncertain_broadcast_and_restart() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let next = wallet + .quote_transfer(RECIPIENT.into(), 2_000_000) + .await + .unwrap(); + chain.state.lock().unwrap().reject_broadcast = true; + let sent = wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await + .unwrap(); + assert_eq!(sent.status, UsdtTransferStatus::Pending); + assert!(sent.tx_hash.is_none()); + let op = chain.state.lock().unwrap().operations[0].clone(); + assert_eq!(op.sender.to_checksum(None), wallet.receive_address()); + assert_eq!( + op.paymaster_verification_gas_limit, + alloy_primitives::U256::from(80_000) + ); + assert_eq!( + op.paymaster_post_op_gas_limit, + alloy_primitives::U256::from(100_000) + ); + drop(wallet); + let wallet = chain.wallet(&dir); + assert_eq!( + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap() + .user_operation_hash, + sent.user_operation_hash + ); + // A pending payment is rejected locally even if the provider is now misconfigured. + chain.state.lock().unwrap().chain = 1; + assert!(matches!( + wallet.quote_transfer(RECIPIENT.into(), 2_000_000).await, + Err(UsdtError::PendingTransfer) + )); + assert!(matches!( + wallet.send(next.id, TEST_PHRASE.into(), None).await, + Err(UsdtError::PendingTransfer) + )); + chain.state.lock().unwrap().chain = 42161; + chain.state.lock().unwrap().tip += 3; + wallet.refresh_transfers().await.unwrap(); + assert!(chain + .state + .lock() + .unwrap() + .operations + .iter() + .all(|item| serde_json::to_value(item).unwrap() == serde_json::to_value(&op).unwrap())); + chain.state.lock().unwrap().mined = true; + chain.state.lock().unwrap().timestamp += alloy_primitives::U256::from(60); + let history = wallet.refresh_transfers().await.unwrap(); + assert_eq!( + history[0].timestamp, + u64::try_from(chain.state.lock().unwrap().timestamp).unwrap() + ); + assert_eq!(history.len(), 1); + assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); + assert_eq!(history[0].fee, Some(123)); + assert!(history[0].tx_hash.is_some()); +} + +#[tokio::test] +async fn fluctuating_gas_estimates_preserve_the_reviewed_operation() { + use alloy_primitives::U256; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + chain.state.lock().unwrap().pre_verification_estimates = + [80_000, 80_100, 80_200, 80_300].into(); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let reviewed = wallet.store.quote("e.id).unwrap().plan.operation; + chain.state.lock().unwrap().pre_verification_estimates = [100_000].into(); + assert!(matches!( + wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await, + Err(UsdtError::QuoteExpired) + )); + assert!(wallet.history().unwrap().is_empty()); + assert!(chain.state.lock().unwrap().operations.is_empty()); + + chain.state.lock().unwrap().pre_verification_estimates = [80_400].into(); + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let signed = chain.state.lock().unwrap().operations[0].clone(); + assert_eq!( + signed.hash(types::CHAIN_ID).unwrap(), + reviewed.hash(types::CHAIN_ID).unwrap() + ); + assert!(signed.pre_verification_gas >= U256::from(80_400)); + assert!(signed.pre_verification_gas < U256::from(100_000)); +} + +#[tokio::test] +async fn wrong_network_owner_nonce_balance_and_paymaster_cannot_sign() { + use alloy_primitives::{Address, U256}; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + for amount in ["", "&uint256=2000000"] { + let request = format!( + "ethereum:{}@42161/transfer?address={RECIPIENT}{amount}", + types::TOKEN.to_checksum(None) + ); + assert!(matches!( + wallet.quote_transfer(request, 1_000_000).await, + Err(UsdtError::InvalidAddress) + )); + } + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + assert!(matches!( + wallet + .send(quote.id.clone(), TEST_PHRASE.into(), Some("wrong".into())) + .await, + Err(UsdtError::InvalidCredentials) + )); + chain.state.lock().unwrap().chain = 1; + assert!(matches!( + wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await, + Err(UsdtError::WrongNetwork) + )); + { + let mut state = chain.state.lock().unwrap(); + state.chain = 42161; + state.nonce = 1; + } + assert!(matches!( + wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await, + Err(UsdtError::QuoteExpired) + )); + { + let mut state = chain.state.lock().unwrap(); + state.nonce = 0; + state.balance = U256::ZERO; + } + assert!(matches!( + wallet.send(quote.id, TEST_PHRASE.into(), None).await, + Err(UsdtError::InsufficientBalance) + )); + { + let mut state = chain.state.lock().unwrap(); + state.balance = U256::from(10_000_000); + state.paymaster = Address::repeat_byte(1); + } + assert!(matches!( + wallet.quote_transfer(RECIPIENT.into(), 1_000_000).await, + Err(UsdtError::UnsupportedRoute) + )); + assert!(chain.state.lock().unwrap().operations.is_empty()); +} + +#[tokio::test] +async fn expired_unmined_operation_releases_nonce_for_a_new_approval() { + for (nonce, log_error) in [ + (0, None), + (1, Some((-32002, "Provider unavailable".into()))), + ] { + let chain = MockChain::start().await; + chain.state.lock().unwrap().nonce = nonce; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.nonce = 0; + state.timestamp += alloy_primitives::U256::from(180); + state.tip += 3; + state.max_log_range = Some(1); + state.log_error = log_error.clone(); + } + assert_eq!( + wallet.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Pending + ); + assert!(matches!( + wallet.quote_transfer(RECIPIENT.into(), 1_000_000).await, + Err(UsdtError::PendingTransfer) + )); + { + let mut state = chain.state.lock().unwrap(); + state.timestamp += alloy_primitives::U256::from(421); + state.log_error = Some((-32016, "Provider rate limit exceeded".into())); + } + assert!(matches!( + wallet.refresh_transfers().await, + Err(UsdtError::RateLimited) + )); + assert_eq!( + wallet.history().unwrap()[0].status, + UsdtTransferStatus::Pending + ); + chain.state.lock().unwrap().log_error = log_error; + assert_eq!( + wallet.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Failed + ); + let next = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + assert_eq!( + wallet + .send(next.id, TEST_PHRASE.into(), None) + .await + .unwrap() + .status, + UsdtTransferStatus::Pending + ); + } +} + +#[tokio::test] +async fn seed_restore_recovers_mined_payments_without_local_submission_data() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + } + let restored_dir = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restored_dir); + assert_eq!(restored.receive_address(), wallet.receive_address()); + sync_history_to_tip(&restored).await; + let history = restored.history().unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].user_operation_hash, sent.user_operation_hash); + assert_eq!(history[0].recipient, RECIPIENT); + assert_eq!(history[0].amount, 1_000_000); + assert_eq!(history[0].fee, Some(123)); + assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); + sync_history_to_tip(&restored).await; + assert_eq!(restored.history().unwrap().len(), 1); +} + +#[tokio::test] +async fn bundled_operations_cannot_contribute_another_payments_fee() { + use alloy_primitives::{B256, U256}; + use alloy_sol_types::SolEvent; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let mut transfer = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let own_hash = transfer + .user_operation_hash + .as_ref() + .unwrap() + .parse() + .unwrap(); + let other_hash = B256::repeat_byte(2); + let event = |hash, success| { + transaction::EntryPoint::UserOperationEvent { + userOpHash: hash, + sender: wallet.address, + paymaster: paymaster::PAYMASTER, + nonce: U256::ZERO, + success, + actualGasCost: U256::from(1), + actualGasUsed: U256::from(1), + } + .encode_log_data() + }; + let log = |address, data: alloy_primitives::LogData| json!({"address":address,"topics":data.topics(),"data":data.data}); + let receipt = json!({"logs":[ + log(paymaster::PAYMASTER, transaction::Paymaster::UserOperationSponsored { userOpHash:other_hash, user:wallet.address, paymasterMode:1, token:types::TOKEN, tokenAmountPaid:U256::from(500), exchangeRate:U256::from(1) }.encode_log_data()), + log(account::ENTRY_POINT, event(other_hash, true)), + log(paymaster::PAYMASTER, transaction::Paymaster::UserOperationSponsored { userOpHash:own_hash, user:wallet.address, paymasterMode:1, token:types::TOKEN, tokenAmountPaid:U256::from(123), exchangeRate:U256::from(1) }.encode_log_data()), + log(types::TOKEN, transaction::Erc20::Transfer { from: wallet.address, to: RECIPIENT.parse().unwrap(), value: U256::from(transfer.amount) }.encode_log_data()), + log(account::ENTRY_POINT, event(own_hash, true)), + ]}); + assert_eq!( + transaction::operation_logs(&receipt, own_hash).unwrap(), + &receipt["logs"].as_array().unwrap()[2..] + ); + let mut failed = receipt.clone(); + failed["logs"][4] = log(account::ENTRY_POINT, event(own_hash, false)); + wallet.settle(&mut transfer, &failed).unwrap(); + assert_eq!(transfer.status, UsdtTransferStatus::Failed); + assert_eq!(transfer.fee, Some(123)); + wallet.settle(&mut transfer, &receipt).unwrap(); + assert_eq!(transfer.status, UsdtTransferStatus::Confirmed); + assert_eq!(transfer.fee, Some(123)); +} + +#[tokio::test] +#[ignore = "requires a fresh local Arbitrum fork and tests/usdt-fork/provider.mjs"] +async fn deployed_contracts_collect_usdt_fees_without_account_eth() { + use alloy_primitives::U256; + use serde_json::json; + let rpc = rpc::Rpc::new("http://127.0.0.1:18545".into(), types::CHAIN_ID).unwrap(); + let client: String = rpc.call("web3_clientVersion", json!([])).await.unwrap(); + assert!(client.to_lowercase().contains("anvil")); + let dir = tempfile::tempdir().unwrap(); + let wallet = UsdtWallet::new( + usdt_address(TEST_PHRASE.into(), None).unwrap(), + dir.path().join("usdt.sqlite").to_string_lossy().into(), + std::env::var("USDT_FORK_RPC_URL").unwrap_or_else(|_| "http://127.0.0.1:18545".into()), + std::env::var("USDT_FORK_BUNDLER_URL").unwrap_or_else(|_| "http://127.0.0.1:18546".into()), + ) + .unwrap(); + assert_eq!( + rpc.call::("eth_getBalance", json!([wallet.address, "pending"])) + .await + .unwrap(), + U256::ZERO + ); + let initial = wallet.balance().await.unwrap(); + // Only locally mined transactions belong to this fixture's history. + wallet + .store + .complete_history(wallet.block_number().await.unwrap()) + .unwrap(); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let history = wallet.refresh_transfers().await.unwrap(); + let transfer = history.iter().find(|t| t.id == sent.id).unwrap(); + assert_eq!(transfer.status, UsdtTransferStatus::Confirmed); + let fee = transfer.fee.unwrap(); + assert!(fee > 0 && fee <= quote.maximum_fee); + assert_eq!(wallet.balance().await.unwrap(), initial - 1_000_000 - fee); + assert_eq!( + rpc.call::("eth_getBalance", json!([wallet.address, "pending"])) + .await + .unwrap(), + U256::ZERO + ); +} + +#[tokio::test] +async fn history_preserves_receipts_with_external_account_call_shapes() { + use alloy_primitives::{Bytes, U256}; + use alloy_sol_types::SolCall; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let original = chain.state.lock().unwrap().operations[0].call_data.clone(); + let oversized = account::batch(&[( + types::TOKEN, + transaction::Erc20::transferCall { + recipient: RECIPIENT.parse().unwrap(), + amount: U256::MAX, + } + .abi_encode() + .into(), + )]); + let single = account::SimpleAccount::executeCall { + target: types::TOKEN, + value: U256::ZERO, + data: transaction::Erc20::transferCall { + recipient: RECIPIENT.parse().unwrap(), + amount: U256::from(1_000_000), + } + .abi_encode() + .into(), + } + .abi_encode() + .into(); + for (call_data, outer, expected_outgoing) in [ + (original.clone(), None, true), + (single, None, true), + (oversized, None, false), + (Bytes::from_static(&[1, 2, 3, 4]), None, false), + (original, Some(Bytes::from_static(&[5, 6, 7, 8])), true), + ] { + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.incoming = true; + state.tip = 20003; + state.operations[0].call_data = call_data; + state.history_input = outer; + } + let restored_dir = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restored_dir); + sync_history_to_tip(&restored).await; + let history = restored.history().unwrap(); + assert_eq!( + history + .iter() + .filter(|t| t.is_incoming && t.amount == 42) + .count(), + 1 + ); + assert_eq!( + history.iter().filter(|t| !t.is_incoming).count(), + usize::from(expected_outgoing) + ); + sync_history_to_tip(&restored).await; + assert_eq!(restored.history().unwrap().len(), history.len()); + } +} + +#[tokio::test] +async fn wrapped_history_preserves_signed_payments_and_restores_token_transfers() { + use alloy_primitives::{Address, Bytes, U256}; + use alloy_sol_types::{SolCall, SolEvent}; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let decoy_input = { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + let tx = state.respond(&json!({"method":"eth_getTransactionByHash"})); + let input: Bytes = serde_json::from_value(tx["result"]["input"].clone()).unwrap(); + let mut batch = transaction::EntryPoint::handleOpsCall::abi_decode(&input).unwrap(); + batch.ops[0].callData = account::batch(&[( + types::TOKEN, + transaction::Erc20::transferCall { + recipient: Address::repeat_byte(0x22), + amount: U256::from(9_000_000), + } + .abi_encode() + .into(), + )]); + let mut logs = state.event_logs(); + logs.retain(|log| log["address"] != json!(types::TOKEN)); + for (recipient, amount) in [ + (RECIPIENT.parse().unwrap(), 1_000_000), + (paymaster::PAYMASTER, 123), + ] { + let transfer = transaction::Erc20::Transfer { + from: wallet.address, + to: recipient, + value: U256::from(amount), + } + .encode_log_data(); + logs.insert( + 0, + json!({"address":types::TOKEN,"topics":transfer.topics(),"data":transfer.data}), + ); + } + for (index, log) in logs.iter_mut().enumerate() { + log["logIndex"] = json!(U256::from(index)); + } + state.receipt_logs = Some(logs); + state.history_target = Some(Address::repeat_byte(0x33)); + Bytes::from(batch.abi_encode()) + }; + drop(wallet); + for input in [decoy_input, Bytes::from_static(&[1, 2, 3, 4])] { + chain.state.lock().unwrap().history_input = Some(input); + let reopened = chain.wallet(&dir); + sync_history_to_tip(&reopened).await; + let history = reopened.history().unwrap(); + assert_eq!(history.len(), 1); + let payment = &history[0]; + assert_eq!(payment.id, sent.id); + assert_eq!(payment.user_operation_hash, sent.user_operation_hash); + assert_eq!(payment.amount, 1_000_000); + assert_eq!(payment.received_amount, 1_000_000); + assert_eq!(payment.recipient, RECIPIENT); + assert_eq!(payment.status, UsdtTransferStatus::Confirmed); + assert_eq!(payment.fee, Some(123)); + assert!(payment.tx_hash.is_some()); + assert!(reopened.store.pending_plan(&sent.id).unwrap().is_none()); + + let restored_dir = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restored_dir); + for _ in 0..2 { + sync_history_to_tip(&restored).await; + let history = restored.history().unwrap(); + assert_eq!(history.len(), 2); + for (recipient, amount) in [ + (RECIPIENT.into(), 1_000_000), + (paymaster::PAYMASTER.to_checksum(None), 123), + ] { + let transfer = history.iter().find(|t| t.recipient == recipient).unwrap(); + assert_eq!(transfer.amount, amount); + assert_eq!(transfer.user_operation_hash, None); + assert_eq!(transfer.status, UsdtTransferStatus::Confirmed); + assert!(!transfer.is_incoming); + } + } + } +} + +#[test] +fn stored_activity_is_complete_and_sorted_newest_first() { + let dir = tempfile::tempdir().unwrap(); + let store = store::Store::open( + dir.path().join("history.sqlite").to_str().unwrap(), + "account", + ) + .unwrap(); + let transfers: Vec<_> = (0..501) + .map(|index| UsdtTransfer { + id: format!("receipt-{index}"), + tx_hash: Some(format!("tx-{index}")), + user_operation_hash: None, + recipient: RECIPIENT.into(), + amount: 1, + received_amount: 1, + fee: None, + is_incoming: true, + status: UsdtTransferStatus::Confirmed, + timestamp: index, + }) + .collect(); + store + .save_history_receipt(&transfers, "receipt", 1000, "block", true) + .unwrap(); + store.complete_history(1000).unwrap(); + let history = store.transfers().unwrap(); + assert_eq!(history.len(), 501); + assert_eq!(history.first().unwrap().id, "receipt-500"); + assert_eq!(history.last().unwrap().id, "receipt-0"); +} + +#[tokio::test] +async fn nonce_advance_with_delayed_logs_remains_pending_and_history_reconciles() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + for recipient in [wallet.receive_address(), types::TOKEN.to_checksum(None)] { + assert!(matches!( + wallet.quote_transfer(recipient, 1_000_000).await, + Err(UsdtError::InvalidAddress) + )); + } + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let sent = wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.hide_logs = true; + state.hide_receipts = true; + state.max_log_range = Some(1); + state.nonce = 1; + state.tip += 3; + state.timestamp += alloy_primitives::U256::from(180); + } + assert!(wallet.refresh_transfers().await.is_err()); + assert_eq!( + wallet.history().unwrap()[0].status, + UsdtTransferStatus::Pending + ); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + assert!(matches!( + wallet.quote_transfer(RECIPIENT.into(), 1_000_000).await, + Err(UsdtError::PendingTransfer) + )); + { + let mut state = chain.state.lock().unwrap(); + state.hide_logs = false; + state.hide_receipts = false; + state.max_log_range = None; + } + sync_history_to_tip(&wallet).await; + let history = wallet.history().unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].id, sent.id); + assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); + assert_eq!(history[0].fee, Some(123)); +} + +#[tokio::test] +async fn interrupted_history_resumes_without_repeating_completed_work() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let incoming_count = 100; + { + let mut state = chain.state.lock().unwrap(); + state.incoming_count = incoming_count as u64; + state.tip = 508_000_000; + state.block_reads = 0; + state.fail_block_read_at = Some(25); + } + let restored_dir = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restored_dir); + restored.store.save_history_progress(20_000).unwrap(); + let error = tokio::time::timeout(std::time::Duration::from_secs(90), async { + loop { + match restored.sync_history().await { + Ok(false) => {} + Ok(true) => panic!("The interrupted scan must report its provider error"), + Err(error) => break error, + } + } + }) + .await + .unwrap(); + assert!(matches!(error, UsdtError::NetworkUnavailable)); + assert_eq!(restored.history().unwrap().len(), 24); + assert!(restored.store.history_progress().unwrap().is_some()); + drop(restored); + let restored = chain.wallet(&restored_dir); + sync_history_to_tip(&restored).await; + assert_eq!(restored.history().unwrap().len(), incoming_count); + assert_eq!(chain.state.lock().unwrap().block_reads, incoming_count + 1); + sync_history_to_tip(&restored).await; + assert_eq!(restored.history().unwrap().len(), incoming_count); + assert_eq!(chain.state.lock().unwrap().block_reads, incoming_count + 1); + assert!(restored.store.history_progress().unwrap().is_none()); + assert_eq!(restored.store.synced_block().unwrap().unwrap(), 507_999_998); + assert!(!restored + .store + .has_history_receipt( + restored.history().unwrap()[0].tx_hash.as_deref().unwrap(), + &format!("{:#x}", chain.state.lock().unwrap().block_hash(20100)), + false + ) + .unwrap()); +} + +#[tokio::test] +async fn replacement_after_expiry_recovers_pending_send_after_restart() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + chain.state.lock().unwrap().reject_broadcast = true; + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + drop(wallet); + let expiry_block = 22_500; + { + let mut state = chain.state.lock().unwrap(); + state.nonce = 1; + state.timestamp += alloy_primitives::U256::from(600); + let expired = state.timestamp + alloy_primitives::U256::from(1); + state.block_timestamps.insert(expiry_block, expired); + state.tip = 508_000_000; + state.replacement_block = Some(507_000_000); + state.hide_logs = true; + state.hide_receipts = true; + state.max_log_range = Some(1); + } + let wallet = chain.wallet(&dir); + assert!(wallet.refresh_transfers().await.is_err()); + let log_ranges = chain.state.lock().unwrap().log_ranges.clone(); + assert!(!log_ranges.is_empty()); + assert!(log_ranges + .iter() + .all(|&(start, end)| start == end || end <= expiry_block)); + assert_eq!( + wallet.history().unwrap()[0].status, + UsdtTransferStatus::Pending + ); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + { + let mut state = chain.state.lock().unwrap(); + state.hide_logs = false; + // The discovery log alone is insufficient; recovery verifies the consuming block. + state.hide_receipts = false; + state.receipt_response = Some(serde_json::json!({ + "transactionHash":alloy_primitives::B256::repeat_byte(7), + "blockHash":state.block_hash(507_000_000), + "blockNumber":alloy_primitives::U256::from(507_000_000),"logs":[] + })); + } + assert_eq!( + wallet.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Replaced + ); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + assert_eq!( + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap() + .status, + UsdtTransferStatus::Pending + ); +} + +#[tokio::test] +async fn history_distinguishes_rate_limits_from_log_range_limits() { + for (code, message) in [ + (-32005, "Rate limit exceeded"), + (-32016, "Provider throttled"), + (-32000, "Request rate exceeded"), + (-32000, "Invalid request"), + ] { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + { + let mut state = chain.state.lock().unwrap(); + state.log_error = Some((code, message.into())); + } + let error = wallet.sync_history().await.unwrap_err(); + if message == "Invalid request" { + assert!(matches!(error, UsdtError::NetworkUnavailable)); + } else { + assert!(matches!(error, UsdtError::RateLimited)); + } + assert!((1..=2).contains(&chain.state.lock().unwrap().log_requests)); + } + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + chain.state.lock().unwrap().log_error = Some((-32005, "Log query limit exceeded".into())); + assert!(matches!( + wallet + .rpc + .call::( + "eth_getLogs", + serde_json::json!([{"fromBlock":"0x0","toBlock":"0x1"}]) + ) + .await, + Err(UsdtError::LogRangeTooLarge) + )); + { + let mut state = chain.state.lock().unwrap(); + state.log_error = None; + state.max_log_range = Some(10_000); + state.oversized_logs = true; + } + sync_history_to_tip(&wallet).await; + assert_eq!(wallet.store.synced_block().unwrap(), Some(19_998)); + chain.state.lock().unwrap().max_log_range = None; + wallet + .history_range_limit + .store(1, std::sync::atomic::Ordering::Relaxed); + wallet.store.save_history_progress(19_990).unwrap(); + sync_history_to_tip(&wallet).await; + assert!( + wallet + .history_range_limit + .load(std::sync::atomic::Ordering::Relaxed) + > 1 + ); +} + +#[tokio::test] +async fn history_budget_returns_incomplete_and_resumes_to_tip() { + let chain = MockChain::start().await; + { + let mut state = chain.state.lock().unwrap(); + state.tip = 508_000_000; + state.max_log_range = Some(1_000_000); + } + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + assert!(!wallet.sync_history().await.unwrap()); + let next = wallet.store.history_progress().unwrap().unwrap(); + assert!(next > 0 && next < 508_000_000); + assert!( + wallet + .history_range_limit + .load(std::sync::atomic::Ordering::Relaxed) + <= 1_000_000 + ); + chain.state.lock().unwrap().tip = next + 1_000_000; + sync_history_to_tip(&wallet).await; + assert_eq!(wallet.store.synced_block().unwrap(), Some(next + 999_998)); +} + +#[tokio::test] +async fn quote_expiring_during_validation_is_not_signed() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let mut data = wallet.store.quote("e.id).unwrap(); + data.quote.id = "expiring-quote".into(); + data.quote.expires_at = wallet::now() + 10; + data.plan.expires_at = data.quote.expires_at; + wallet.store.save_quote(&data).unwrap(); + chain.state.lock().unwrap().delay_gas_estimate = true; + assert!(matches!( + wallet.send(data.quote.id, TEST_PHRASE.into(), None).await, + Err(UsdtError::QuoteExpired) + )); + assert!(!chain.state.lock().unwrap().delay_gas_estimate); + assert!(wallet.history().unwrap().is_empty()); + assert!(chain.state.lock().unwrap().operations.is_empty()); +} + +async fn sync_history_to_tip(wallet: &UsdtWallet) { + tokio::time::timeout(std::time::Duration::from_secs(180), async { + while !wallet.sync_history().await.unwrap() {} + }) + .await + .expect("History must make progress within successive bounded scans"); +} + +#[tokio::test] +async fn invalid_chain_data_and_stored_json_have_distinct_errors() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let db = rusqlite::Connection::open(dir.path().join("usdt.sqlite")).unwrap(); + db.execute("INSERT INTO usdt_quotes VALUES ('invalid','{')", []) + .unwrap(); + assert!(matches!( + wallet.store.quote("invalid"), + Err(UsdtError::Storage { .. }) + )); + db.execute( + "INSERT INTO usdt_transfers (id,hash,raw,data) VALUES ('invalid','hash','{','{')", + [], + ) + .unwrap(); + assert!(matches!(wallet.history(), Err(UsdtError::Storage { .. }))); + assert!(matches!( + wallet.store.pending_plan("invalid"), + Err(UsdtError::Storage { .. }) + )); + chain.state.lock().unwrap().timestamp = alloy_primitives::U256::MAX; + assert!(matches!( + wallet.block_timestamp(1).await, + Err(UsdtError::InvalidResponse) + )); +} + +#[tokio::test] +async fn each_payment_authorizes_the_current_nonce_after_delegation() { + use alloy_primitives::{Bytes, U256}; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.account_code = + Bytes::from([&[0xef, 0x01, 0x00][..], account::DELEGATE.as_slice()].concat()); + state.authorization_nonce = 1; + state.nonce = 1; + state.mined = true; + state.tip += 3; + } + assert_eq!( + wallet.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Confirmed + ); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let state = chain.state.lock().unwrap(); + assert_eq!(state.operations.len(), 2); + for (nonce, operation) in state.operations.iter().enumerate() { + assert_eq!(operation.eip7702_auth.nonce, U256::from(nonce)); + assert_eq!(operation.nonce, U256::from(nonce)); + } + assert_ne!(state.operations[0].signature, state.operations[1].signature); +} + +#[tokio::test] +async fn consumed_authorization_preserves_pending_payment_until_signed_expiry() { + use alloy_primitives::{Bytes, U256}; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let quote_data = wallet.store.quote("e.id).unwrap(); + let dummy = quote_data.plan.operation.eip7702_auth; + assert_eq!(dummy, user_operation::Authorization::dummy(0)); + assert!(quote_data.plan.expires_at > quote.expires_at); + chain.state.lock().unwrap().authorization_nonce = 1; + assert!(matches!( + wallet.send(quote.id, TEST_PHRASE.into(), None).await, + Err(UsdtError::QuoteExpired) + )); + assert!(chain.state.lock().unwrap().operations.is_empty()); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + chain.state.lock().unwrap().reject_broadcast = true; + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let signed = wallet + .store + .pending_plan(&sent.id) + .unwrap() + .unwrap() + .operation; + assert_eq!(signed.eip7702_auth.nonce, U256::from(1)); + assert_ne!(signed.eip7702_auth.r, dummy.r); + drop(wallet); + { + let mut state = chain.state.lock().unwrap(); + state.account_code = + Bytes::from([&[0xef, 0x01, 0x00][..], account::DELEGATE.as_slice()].concat()); + state.authorization_nonce = 2; + state.tip += 3; + } + let restored = chain.wallet(&dir); + assert_eq!( + restored.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Pending + ); + assert_eq!(chain.state.lock().unwrap().operations.len(), 1); + let retained = restored.store.pending_plan(&sent.id).unwrap().unwrap(); + assert_eq!(retained.operation.eip7702_auth, signed.eip7702_auth); + assert_eq!(retained.operation.signature, signed.signature); + assert!(matches!( + restored.quote_transfer(RECIPIENT.into(), 1).await, + Err(UsdtError::PendingTransfer) + )); + chain.state.lock().unwrap().timestamp = U256::from(retained.expires_at + 1); + let history = restored.refresh_transfers().await.unwrap(); + assert_eq!(history[0].status, UsdtTransferStatus::Failed); + assert_eq!(history[0].fee, Some(0)); + assert!(restored.store.pending_plan(&sent.id).unwrap().is_none()); + let quote = restored.quote_transfer(RECIPIENT.into(), 1).await.unwrap(); + assert_eq!( + restored + .store + .quote("e.id) + .unwrap() + .plan + .operation + .eip7702_auth + .nonce, + U256::from(2) + ); +} + +#[tokio::test] +async fn foreign_delegation_and_unbounded_paymaster_terms_cannot_authorize_payments() { + use alloy_primitives::{Bytes, U256}; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + chain.state.lock().unwrap().account_code = Bytes::from_static(&[0xef, 0x01, 0x00, 1]); + assert!(matches!( + wallet.quote_transfer(RECIPIENT.into(), 1).await, + Err(UsdtError::UnsupportedDelegation) + )); + assert_eq!(wallet.balance().await.unwrap(), 10_000_000); + assert!(wallet.history().unwrap().is_empty()); + chain.state.lock().unwrap().account_code = Bytes::new(); + let timestamp = u64::try_from(chain.state.lock().unwrap().timestamp).unwrap(); + for expiry in [0, timestamp + 901] { + chain.state.lock().unwrap().paymaster_valid_until = Some(expiry); + assert!(matches!( + wallet.quote_transfer(RECIPIENT.into(), 1).await, + Err(UsdtError::InvalidResponse) + )); + } + chain.state.lock().unwrap().paymaster_valid_until = Some(timestamp + 900); + let quote = wallet.quote_transfer(RECIPIENT.into(), 1).await.unwrap(); + chain.state.lock().unwrap().account_code = Bytes::from( + [ + &[0xef, 0x01, 0x00][..], + alloy_primitives::Address::repeat_byte(4).as_slice(), + ] + .concat(), + ); + assert!(matches!( + wallet.send(quote.id, TEST_PHRASE.into(), None).await, + Err(UsdtError::UnsupportedDelegation) + )); + assert!(chain.state.lock().unwrap().operations.is_empty()); + assert_eq!(chain.state.lock().unwrap().nonce, 0); + assert_eq!(chain.state.lock().unwrap().balance, U256::from(10_000_000)); +} + +#[tokio::test] +async fn seed_restore_includes_external_token_sends_without_duplicate_operation_entries() { + use alloy_primitives::Bytes; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet.quote_transfer(RECIPIENT.into(), 77).await.unwrap(); + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.external_outgoing = true; + state.history_input = Some(Bytes::from_static(&[1, 2, 3, 4])); + state.tip += 3; + } + for unsupported_batch in [false, true] { + if unsupported_batch { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.external_outgoing = false; + state.history_input = None; + let mut calls = history::decode_calls(&state.operations[0].call_data).unwrap(); + calls.push(( + RECIPIENT.parse().unwrap(), + Bytes::from_static(&[1, 2, 3, 4]), + )); + state.operations[0].call_data = account::batch(&calls); + } + let restored_dir = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restored_dir); + sync_history_to_tip(&restored).await; + let history = restored.history().unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].amount, 77); + assert!(!history[0].is_incoming); + assert_eq!(history[0].user_operation_hash, None); + assert_eq!(history[0].recipient, RECIPIENT); + sync_history_to_tip(&restored).await; + assert_eq!(restored.history().unwrap().len(), 1); + } +} + +#[tokio::test] +async fn gas_price_changes_respect_the_approved_fee() { + for (gas_price, accepted) in [(52_000_000, true), (60_000_000, false)] { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let plan = wallet.store.quote("e.id).unwrap().plan; + chain.state.lock().unwrap().gas_price = gas_price; + let result = wallet.send(quote.id, TEST_PHRASE.into(), None).await; + if accepted { + result.unwrap(); + assert_eq!( + chain.state.lock().unwrap().operations[0].max_fee_per_gas, + plan.operation.max_fee_per_gas + ); + } else { + assert!(matches!(result, Err(UsdtError::QuoteExpired))); + assert!(wallet.history().unwrap().is_empty()); + assert!(chain.state.lock().unwrap().operations.is_empty()); + } + } +} + +#[tokio::test] +async fn consumed_nonce_recovery_requires_complete_receipts_and_resumes_after_restart() { + use alloy_primitives::B256; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.nonce = 1; + state.tip += 3; + state.hide_logs = true; + state.block_transactions = Some(vec![B256::repeat_byte(6), B256::repeat_byte(7)]); + state.receipt_failure = Some(B256::repeat_byte(7)); + } + assert!(wallet.refresh_transfers().await.is_err()); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + let block_hash = format!("{:#x}", chain.state.lock().unwrap().block_hash(20000)); + assert_eq!( + wallet.store.nonce_recovery(&sent.id, &block_hash).unwrap(), + 1 + ); + drop(wallet); + chain.state.lock().unwrap().receipt_failure = None; + let restored = chain.wallet(&dir); + let history = restored.refresh_transfers().await.unwrap(); + assert_eq!(history[0].status, UsdtTransferStatus::Replaced); + assert_eq!(history[0].fee, Some(0)); + assert_eq!(history[0].received_amount, 0); + assert!(restored.store.pending_plan(&sent.id).unwrap().is_none()); + assert_eq!( + restored + .store + .nonce_recovery(&sent.id, &block_hash) + .unwrap(), + 0 + ); + restored + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); +} + +#[tokio::test] +async fn consuming_block_receipts_recover_a_payment_hidden_from_log_queries() { + for unavailable in [false, true] { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.nonce = 1; + state.tip += 3; + state.hide_operation_logs = true; + state.replacement_block = Some(20000); + if unavailable { + state.log_error = Some((-32002, "Provider unavailable".into())); + } + state.mined = true; + } + let history = wallet.refresh_transfers().await.unwrap(); + assert_eq!(history[0].id, sent.id); + assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); + assert_eq!(history[0].fee, Some(123)); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); + } +} + +#[tokio::test] +async fn consumed_nonce_recovery_preserves_malformed_operation_evidence() { + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.nonce = 1; + state.tip += 3; + state.hide_logs = true; + state.replacement_block = Some(20000); + state.mined = true; + let mut logs = state.event_logs(); + logs.iter_mut() + .find(|log| log["address"] == json!(account::ENTRY_POINT.to_checksum(None))) + .unwrap()["data"] = json!("0x"); + state.receipt_logs = Some(logs); + } + assert!(matches!( + wallet.refresh_transfers().await, + Err(UsdtError::InvalidResponse) + )); + assert_eq!( + wallet.history().unwrap()[0].status, + UsdtTransferStatus::Pending + ); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + drop(wallet); + { + let mut state = chain.state.lock().unwrap(); + let mut logs = state.event_logs(); + logs.insert(0, json!({"address":account::ENTRY_POINT,"topics":[alloy_primitives::keccak256("BeforeExecution()")],"data":"0x"})); + state.receipt_logs = Some(logs); + } + let restored = chain.wallet(&dir); + let history = restored.refresh_transfers().await.unwrap(); + assert_eq!(history[0].id, sent.id); + assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); + assert_eq!(history[0].fee, Some(123)); + assert!(restored.store.pending_plan(&sent.id).unwrap().is_none()); +} + +#[tokio::test] +async fn dense_block_history_recovers_large_receipts_without_skipping_after_restart() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + state.oversized_block = Some(20000); + state.receipt_padding = 3 * 1024 * 1024; + state.hide_receipts = true; + } + while let Ok(complete) = wallet.sync_history().await { + assert!(!complete); + } + assert_eq!(wallet.store.history_progress().unwrap(), Some(20000)); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + drop(wallet); + chain.state.lock().unwrap().hide_receipts = false; + let restored = chain.wallet(&dir); + sync_history_to_tip(&restored).await; + let history = restored.history().unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].id, sent.id); + assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); + assert_eq!(history[0].fee, Some(123)); + assert_eq!(restored.store.synced_block().unwrap(), Some(20001)); + let reads = chain.state.lock().unwrap().receipt_reads; + drop(restored); + let restored = chain.wallet(&dir); + sync_history_to_tip(&restored).await; + assert_eq!(chain.state.lock().unwrap().receipt_reads, reads); + drop(restored); + { + let mut state = chain.state.lock().unwrap(); + state + .block_hashes + .insert(20000, alloy_primitives::B256::repeat_byte(0xaa)); + } + let restored = chain.wallet(&dir); + restored.store.save_history_progress(20000).unwrap(); + sync_history_to_tip(&restored).await; + assert_eq!(chain.state.lock().unwrap().receipt_reads, reads + 1); + assert_eq!(restored.history().unwrap()[0].id, sent.id); + assert_eq!( + restored.history().unwrap()[0].status, + UsdtTransferStatus::Confirmed + ); +} + +#[tokio::test] +async fn zero_value_transfers_do_not_require_receipts_or_timestamps() { + use alloy_primitives::{B256, U256}; + use alloy_sol_types::SolEvent; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let event = transaction::Erc20::Transfer { + from: wallet.address, + to: RECIPIENT.parse().unwrap(), + value: U256::ZERO, + } + .encode_log_data(); + { + let mut state = chain.state.lock().unwrap(); + state.log_response = Some(vec![ + json!({"address":types::TOKEN,"topics":event.topics(),"data":event.data,"transactionHash":B256::repeat_byte(7),"blockNumber":"0x1","logIndex":"0x0"}), + ]); + state.hide_receipts = true; + state.fail_block_read_at = Some(1); + } + assert!(wallet.sync_history().await.unwrap()); + assert!(wallet.history().unwrap().is_empty()); +} + +#[tokio::test] +async fn first_submission_precheck_releases_an_operation_that_was_never_sent() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + chain + .state + .lock() + .unwrap() + .authorization_change_on_block_read = true; + assert!(matches!( + wallet + .send(quote.id.clone(), TEST_PHRASE.into(), None) + .await, + Err(UsdtError::QuoteExpired) + )); + assert!(chain.state.lock().unwrap().operations.is_empty()); + assert!(wallet.store.pending_plan("e.id).unwrap().is_none()); + let failed = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + assert_eq!(failed.status, UsdtTransferStatus::Failed); + assert_eq!(failed.received_amount, 0); + assert_eq!(failed.fee, Some(0)); + wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); +} + +#[tokio::test] +async fn unknown_paymaster_history_preserves_principal_fee_and_refund() { + use alloy_primitives::{Address, B256, U256}; + use alloy_sol_types::SolEvent; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let payer = Address::repeat_byte(0xab); + let movement = |from, to, value, index| { + let event = transaction::Erc20::Transfer { + from, + to, + value: U256::from(value), + } + .encode_log_data(); + json!({"address": types::TOKEN, "topics":event.topics(), "data":event.data, "logIndex": U256::from(index)}) + }; + { + let mut state = chain.state.lock().unwrap(); + let op = &state.operations[0]; + let event = transaction::EntryPoint::UserOperationEvent { + userOpHash: op.hash(types::CHAIN_ID).unwrap(), + sender: wallet.address, + paymaster: payer, + nonce: U256::ZERO, + success: true, + actualGasCost: U256::from(1), + actualGasUsed: U256::from(1), + } + .encode_log_data(); + let log = json!({"address":account::ENTRY_POINT,"topics":event.topics(),"data":event.data,"transactionHash":B256::repeat_byte(7),"blockNumber":"0x4e20","logIndex":"0x3"}); + state.receipt_logs = Some(vec![ + movement(wallet.address, payer, 200u64, 0u64), + movement( + wallet.address, + RECIPIENT.parse().unwrap(), + 1_000_000u64, + 1u64, + ), + movement(payer, wallet.address, 50u64, 2u64), + log.clone(), + ]); + state.log_response = Some(vec![log]); + state.tip += 3; + } + let restored_dir = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restored_dir); + sync_history_to_tip(&restored).await; + let history = restored.history().unwrap(); + assert_eq!(history.len(), 3); + assert_eq!( + history + .iter() + .filter(|t| !t.is_incoming) + .map(|t| t.amount) + .sum::(), + 1_000_200 + ); + assert_eq!( + history + .iter() + .filter(|t| t.is_incoming) + .map(|t| t.amount) + .sum::(), + 50 + ); +} + +#[tokio::test] +async fn settlement_requires_matching_canonical_receipts() { + use alloy_primitives::B256; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let receipt = { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + state + .respond(&json!({"method":"eth_getTransactionReceipt","params":[B256::repeat_byte(7)]})) + ["result"] + .clone() + }; + for (field, value) in [ + ("transactionHash", json!(B256::ZERO)), + ("blockHash", json!(B256::ZERO)), + ("blockNumber", json!("0x1")), + ] { + let mut invalid = receipt.clone(); + invalid[field] = value; + chain.state.lock().unwrap().receipt_response = Some(invalid); + assert!(matches!( + wallet.check_recent_execution(sent.id.clone()).await, + Err(UsdtError::InvalidResponse) + )); + assert!(matches!( + wallet.refresh_transfers().await, + Err(UsdtError::InvalidResponse) + )); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + assert!(matches!( + wallet.sync_history().await, + Err(UsdtError::InvalidResponse) + )); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + } + chain.state.lock().unwrap().receipt_response = Some(serde_json::Value::Null); + assert!(matches!( + wallet.refresh_transfers().await, + Err(UsdtError::NetworkUnavailable) + )); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + assert!(matches!( + wallet.sync_history().await, + Err(UsdtError::NetworkUnavailable) + )); + chain.state.lock().unwrap().receipt_response = None; + assert_eq!( + wallet.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Confirmed + ); +} + +#[tokio::test] +async fn settlement_uses_the_canonical_operation_outcome() { + use alloy_primitives::{B256, U256}; + use alloy_sol_types::SolEvent; + use serde_json::json; + for success in [false, true] { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + let op = &state.operations[0]; + let event = |success| { + transaction::EntryPoint::UserOperationEvent { + userOpHash: op.hash(types::CHAIN_ID).unwrap(), + sender: op.sender, + paymaster: paymaster::PAYMASTER, + nonce: op.nonce, + success, + actualGasCost: U256::from(1), + actualGasUsed: U256::from(1), + } + .encode_log_data() + }; + let receipt_event = event(success); + let log_event = event(!success); + let mut logs = state.event_logs(); + logs[2]["data"] = json!(log_event.data); + state.log_response = Some(vec![logs[2].clone()]); + let mut receipt = state.respond( + &json!({"method":"eth_getTransactionReceipt","params":[B256::repeat_byte(7)]}), + )["result"] + .clone(); + receipt["logs"][2]["data"] = json!(receipt_event.data); + state.receipt_response = Some(receipt); + } + let expected = if success { + UsdtTransferStatus::Confirmed + } else { + UsdtTransferStatus::Failed + }; + assert_eq!( + wallet.refresh_transfers().await.unwrap()[0].status, + expected + ); + assert!(wallet.sync_history().await.unwrap()); + assert_eq!(wallet.history().unwrap()[0].status, expected); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); + } +} + +#[tokio::test] +async fn unrecognized_operations_preserve_raw_debits_and_refunds() { + use alloy_primitives::{Address, U256}; + use alloy_sol_types::{SolCall, SolEvent}; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + let movement = |from, to, value, index| { + let event = transaction::Erc20::Transfer { + from, + to, + value: U256::from(value), + } + .encode_log_data(); + json!({"address":types::TOKEN,"topics":event.topics(),"data":event.data,"logIndex":U256::from(index)}) + }; + let recipient = RECIPIENT.parse::
().unwrap(); + for (flags, recipient, amount, expected_outgoing) in [ + (2u8, recipient, 1_000_000u64, 1_000_200), + (4, recipient, 1_000_000, 1_000_200), + (0, recipient, 0, 200), + (0, wallet.address, 1_000_000, 200), + ] { + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip = 20003; + let mut data = state.operations[0].paymaster_data.to_vec(); + data[1] = flags; + state.operations[0].paymaster_data = data.into(); + state.operations[0].call_data = account::batch(&[( + types::TOKEN, + transaction::Erc20::transferCall { + recipient, + amount: U256::from(amount), + } + .abi_encode() + .into(), + )]); + let mut logs = state.event_logs(); + logs.retain(|log| log["address"] != json!(types::TOKEN)); + logs.insert( + 0, + movement(wallet.address, paymaster::PAYMASTER, 200u64, 2u64), + ); + logs.insert(1, movement(wallet.address, recipient, amount, 3u64)); + logs.insert( + 2, + movement(paymaster::PAYMASTER, wallet.address, 50u64, 4u64), + ); + state.receipt_logs = Some(logs); + } + let restored_dir = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restored_dir); + assert!(restored.sync_history().await.unwrap()); + let history = restored.history().unwrap(); + assert!(history.iter().all(|row| row.user_operation_hash.is_none())); + assert_eq!( + history + .iter() + .filter(|row| row.is_incoming) + .map(|row| row.amount) + .sum::(), + 50 + ); + assert_eq!( + history + .iter() + .filter(|row| !row.is_incoming) + .map(|row| row.amount) + .sum::(), + expected_outgoing + ); + } +} + +#[tokio::test] +async fn recent_execution_requires_the_expected_token_transfer() { + use alloy_primitives::U256; + use alloy_sol_types::SolEvent; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + chain.state.lock().unwrap().mined = true; + // Normal recovery still waits for its indexing buffer. + assert_eq!( + wallet.refresh_transfers().await.unwrap()[0].status, + UsdtTransferStatus::Pending + ); + for (recipient, amount) in [ + (RECIPIENT.parse().unwrap(), 999u64), + (wallet.address, sent.amount), + ] { + let mut logs = chain.state.lock().unwrap().event_logs(); + let token = transaction::Erc20::Transfer { + from: wallet.address, + to: recipient, + value: U256::from(amount), + } + .encode_log_data(); + logs[0]["topics"] = json!(token.topics()); + logs[0]["data"] = json!(token.data); + chain.state.lock().unwrap().receipt_logs = Some(logs); + assert!(matches!( + wallet.check_recent_execution(sent.id.clone()).await, + Err(UsdtError::InvalidResponse) + )); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + } + let mut logs = chain.state.lock().unwrap().event_logs(); + logs.remove(0); + chain.state.lock().unwrap().receipt_logs = Some(logs); + assert!(matches!( + wallet.check_recent_execution(sent.id.clone()).await, + Err(UsdtError::InvalidResponse) + )); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + chain.state.lock().unwrap().receipt_logs = None; + let result = wallet + .check_recent_execution(sent.id.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(result.status, UsdtTransferStatus::Confirmed); + assert_eq!(result.fee, Some(123)); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_none()); +} + +#[tokio::test] +async fn execution_check_preserves_unmined_payments_and_throttling() { + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.tip += 3; + state.timestamp += alloy_primitives::U256::from(1000); + } + let result = wallet + .check_recent_execution(sent.id.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(result.status, UsdtTransferStatus::Pending); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + assert_eq!(chain.state.lock().unwrap().operations.len(), 1); + chain.state.lock().unwrap().log_error = Some((-32016, "rate limit".into())); + assert!(matches!( + wallet.check_recent_execution(sent.id.clone()).await, + Err(UsdtError::RateLimited) + )); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); +} + +#[tokio::test] +async fn interrupted_nonce_recovery_restarts_on_a_changed_block() { + use alloy_primitives::B256; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.nonce = 1; + state.tip += 3; + state.hide_logs = true; + state.block_transactions = Some(vec![B256::repeat_byte(6), B256::repeat_byte(7)]); + state.receipt_failure = Some(B256::repeat_byte(7)); + } + assert!(wallet.refresh_transfers().await.is_err()); + assert!(wallet.store.pending_plan(&sent.id).unwrap().is_some()); + drop(wallet); + { + let mut state = chain.state.lock().unwrap(); + state.block_hashes.insert(20000, B256::repeat_byte(0xaa)); + state.block_transactions = Some(vec![B256::repeat_byte(7), B256::repeat_byte(6)]); + state.receipt_failure = None; + state.mined = true; + } + let restored = chain.wallet(&dir); + let history = restored.refresh_transfers().await.unwrap(); + assert_eq!(history[0].id, sent.id); + assert_eq!(history[0].status, UsdtTransferStatus::Confirmed); + assert_eq!(history[0].fee, Some(123)); + assert!(restored.store.pending_plan(&sent.id).unwrap().is_none()); +} + +#[tokio::test] +async fn history_reuses_receipts_only_while_their_block_remains_canonical() { + use alloy_primitives::{B256, U256}; + use alloy_sol_types::SolEvent; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + let sent = wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.tip += 3; + } + sync_history_to_tip(&wallet).await; + let reads = chain.state.lock().unwrap().receipt_reads; + drop(wallet); + let restored = chain.wallet(&dir); + sync_history_to_tip(&restored).await; + assert_eq!(chain.state.lock().unwrap().receipt_reads, reads); + { + let mut state = chain.state.lock().unwrap(); + let mut logs = state.event_logs(); + let mut event = transaction::Paymaster::UserOperationSponsored::decode_log_data( + &transaction::event_data(&logs[1]).unwrap(), + ) + .unwrap(); + event.tokenAmountPaid = U256::from(456); + logs[1]["data"] = json!(event.encode_log_data().data); + state.receipt_logs = Some(logs); + let mut stale = state.event_logs()[2].clone(); + stale["blockHash"] = json!(state.block_hash(20000)); + state.log_response = Some(vec![stale]); + state.block_hashes.insert(20000, B256::repeat_byte(0xaa)); + } + assert!(matches!( + restored.sync_history().await, + Err(UsdtError::NetworkUnavailable) + )); + assert_eq!(restored.history().unwrap()[0].fee, Some(123)); + chain.state.lock().unwrap().log_response = None; + sync_history_to_tip(&restored).await; + let history = restored.history().unwrap(); + assert_eq!(history[0].id, sent.id); + assert_eq!(history[0].fee, Some(456)); + assert_eq!(chain.state.lock().unwrap().receipt_reads, reads + 1); +} + +#[tokio::test] +async fn incoming_log_progress_does_not_hide_later_receipt_evidence() { + use alloy_primitives::{B256, U256}; + use serde_json::json; + let chain = MockChain::start().await; + let dir = tempfile::tempdir().unwrap(); + let wallet = chain.wallet(&dir); + let quote = wallet + .quote_transfer(RECIPIENT.into(), 1_000_000) + .await + .unwrap(); + wallet + .send(quote.id, TEST_PHRASE.into(), None) + .await + .unwrap(); + { + let mut state = chain.state.lock().unwrap(); + state.mined = true; + state.incoming = true; + state.tip += 3; + let mut incoming = state.event_logs().last().unwrap().clone(); + incoming["transactionHash"] = json!(B256::repeat_byte(7)); + incoming["blockNumber"] = json!(U256::from(20000)); + state.log_response = Some(vec![incoming]); + } + let restore_dir = tempfile::tempdir().unwrap(); + let restored = chain.wallet(&restore_dir); + sync_history_to_tip(&restored).await; + assert_eq!(restored.history().unwrap().len(), 1); + assert!(restored.history().unwrap()[0].is_incoming); + assert_eq!(chain.state.lock().unwrap().receipt_reads, 0); + chain.state.lock().unwrap().log_response = None; + sync_history_to_tip(&restored).await; + let history = restored.history().unwrap(); + assert_eq!(history.len(), 2); + let payment = history + .iter() + .find(|transfer| !transfer.is_incoming) + .unwrap(); + assert_eq!(payment.amount, 1_000_000); + assert_eq!(payment.fee, Some(123)); + assert_eq!(chain.state.lock().unwrap().receipt_reads, 1); +} diff --git a/src/modules/usdt/transaction.rs b/src/modules/usdt/transaction.rs new file mode 100644 index 0000000..8b4d3d6 --- /dev/null +++ b/src/modules/usdt/transaction.rs @@ -0,0 +1,91 @@ +use super::{user_operation::UserOperation, UsdtError}; +use alloy_primitives::{Bytes, B256}; +use alloy_sol_types::sol; +use bitcoin::secp256k1::SecretKey; +use serde::{Deserialize, Serialize}; + +sol! { + struct PackedOperation { + address sender; + uint256 nonce; + bytes initCode; + bytes callData; + bytes32 accountGasLimits; + uint256 preVerificationGas; + bytes32 gasFees; + bytes paymasterAndData; + bytes signature; + } + interface EntryPoint { + function getNonce(address sender, uint192 key) view returns (uint256); + function handleOps(PackedOperation[] ops, address beneficiary); + event UserOperationEvent(bytes32 indexed userOpHash, address indexed sender, address indexed paymaster, uint256 nonce, bool success, uint256 actualGasCost, uint256 actualGasUsed); + } + interface Paymaster { + event UserOperationSponsored(bytes32 indexed userOpHash, address indexed user, uint8 paymasterMode, address token, uint256 tokenAmountPaid, uint256 exchangeRate); + } + interface Erc20 { + function approve(address spender, uint256 amount) returns (bool); + function transfer(address recipient, uint256 amount) returns (bool); + function balanceOf(address owner) view returns (uint256); + event Transfer(address indexed from, address indexed to, uint256 value); + } +} + +pub(super) fn entry_point_event( + log: &serde_json::Value, +) -> Result, UsdtError> { + use alloy_sol_types::SolEvent; + let address: alloy_primitives::Address = serde_json::from_value(log["address"].clone())?; + if address != super::account::ENTRY_POINT { + return Ok(None); + } + let data = event_data(log)?; + if data.topics().first() != Some(&EntryPoint::UserOperationEvent::SIGNATURE_HASH) { + return Ok(None); + } + EntryPoint::UserOperationEvent::decode_log_data(&data) + .map(Some) + .map_err(|_| UsdtError::InvalidResponse) +} + +pub(super) fn operation_logs( + receipt: &serde_json::Value, + hash: B256, +) -> Result<&[serde_json::Value], UsdtError> { + let logs = receipt["logs"] + .as_array() + .ok_or(UsdtError::InvalidResponse)?; + let mut start = 0; + for (index, log) in logs.iter().enumerate() { + if let Some(event) = entry_point_event(log)? { + if event.userOpHash == hash { + return Ok(&logs[start..=index]); + } + start = index + 1; + } + } + Err(UsdtError::InvalidResponse) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(super) struct Plan { + pub operation: UserOperation, + pub created_block: u64, + pub expires_at: u64, +} + +impl Plan { + pub fn sign(&mut self, key: &SecretKey) -> Result<(B256, String), UsdtError> { + let hash = self.operation.sign(key, super::types::CHAIN_ID)?; + Ok((hash, serde_json::to_string(self)?)) + } +} + +pub(super) fn event_data( + value: &serde_json::Value, +) -> Result { + let topics: Vec = serde_json::from_value(value["topics"].clone())?; + let data: Bytes = serde_json::from_value(value["data"].clone())?; + alloy_primitives::LogData::new(topics, data).ok_or(UsdtError::InvalidResponse) +} diff --git a/src/modules/usdt/types.rs b/src/modules/usdt/types.rs new file mode 100644 index 0000000..391dfc6 --- /dev/null +++ b/src/modules/usdt/types.rs @@ -0,0 +1,57 @@ +use alloy_primitives::{address, Address}; +use serde::{Deserialize, Serialize}; + +pub(super) const CHAIN_ID: u64 = 42161; +pub(super) const TOKEN: Address = address!("Fd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"); + +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] +pub struct UsdtPaymentRequest { + pub recipient: String, + pub amount: Option, + /// An explicit network in the payment URI; bare addresses have no restriction. + pub chain_id: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, uniffi::Record)] +pub struct UsdtQuote { + pub id: String, + pub recipient: String, + pub amount: u64, + pub maximum_fee: u64, + pub expires_at: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +pub enum UsdtTransferStatus { + /// Signed payment awaiting a conclusive source-chain outcome. + Pending, + /// Payment received on its destination chain. + Confirmed, + /// Source payment failed or was proven not to have executed. + Failed, + /// Another operation consumed the payment nonce. + Replaced, +} + +#[derive(Clone, Debug, Serialize, Deserialize, uniffi::Record)] +pub struct UsdtTransfer { + pub id: String, + /// Source transaction hash, absent until execution is observed. + pub tx_hash: Option, + pub user_operation_hash: Option, + pub recipient: String, + pub amount: u64, + pub received_amount: u64, + pub fee: Option, + pub is_incoming: bool, + pub status: UsdtTransferStatus, + pub timestamp: u64, +} + +impl UsdtTransfer { + pub(super) fn mark_unexecuted(&mut self, status: UsdtTransferStatus) { + self.status = status; + self.received_amount = 0; + self.fee = Some(0); + } +} diff --git a/src/modules/usdt/user_operation.rs b/src/modules/usdt/user_operation.rs new file mode 100644 index 0000000..abe1ff8 --- /dev/null +++ b/src/modules/usdt/user_operation.rs @@ -0,0 +1,179 @@ +use super::{ + account::{DELEGATE, ENTRY_POINT}, + keys::key_address, + types::CHAIN_ID, + UsdtError, +}; +use alloy_primitives::{Address, Bytes, B256, U256}; +use alloy_rlp::Encodable; +use alloy_sol_types::{eip712_domain, sol, SolStruct}; +use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; +use serde::{Deserialize, Serialize}; + +sol! { + struct PackedUserOperation { + address sender; + uint256 nonce; + bytes initCode; + bytes callData; + bytes32 accountGasLimits; + uint256 preVerificationGas; + bytes32 gasFees; + bytes paymasterAndData; + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct Authorization { + pub chain_id: U256, + pub address: Address, + pub nonce: U256, + pub y_parity: U256, + pub r: B256, + pub s: B256, +} + +impl Authorization { + pub fn dummy(nonce: u64) -> Self { + Self { + chain_id: U256::from(CHAIN_ID), + address: DELEGATE, + nonce: U256::from(nonce), + y_parity: U256::ZERO, + r: B256::repeat_byte(0x11), + s: B256::repeat_byte(0x22), + } + } + + pub fn hash(&self) -> Result { + let nonce: u64 = self + .nonce + .try_into() + .map_err(|_| UsdtError::InvalidResponse)?; + if self.chain_id != U256::from(CHAIN_ID) || self.address != DELEGATE || nonce == u64::MAX { + return Err(UsdtError::InvalidResponse); + } + let mut payload = Vec::new(); + CHAIN_ID.encode(&mut payload); + self.address.as_slice().encode(&mut payload); + nonce.encode(&mut payload); + let mut encoded = vec![0x05]; + alloy_rlp::Header { + list: true, + payload_length: payload.len(), + } + .encode(&mut encoded); + encoded.extend(payload); + Ok(alloy_primitives::keccak256(encoded)) + } + + fn sign(&mut self, key: &SecretKey) -> Result<(), UsdtError> { + let signature = sign_hash(self.hash()?, key)?; + self.r = B256::from_slice(&signature[..32]); + self.s = B256::from_slice(&signature[32..64]); + self.y_parity = U256::from(signature[64] - 27); + Ok(()) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct UserOperation { + pub sender: Address, + pub nonce: U256, + pub factory: Bytes, + pub factory_data: Bytes, + pub call_data: Bytes, + pub call_gas_limit: U256, + pub verification_gas_limit: U256, + pub pre_verification_gas: U256, + pub max_fee_per_gas: U256, + pub max_priority_fee_per_gas: U256, + pub paymaster: Address, + pub paymaster_verification_gas_limit: U256, + pub paymaster_post_op_gas_limit: U256, + pub paymaster_data: Bytes, + pub signature: Bytes, + pub eip7702_auth: Authorization, +} + +impl UserOperation { + pub fn paymaster_and_data(&self) -> Result { + Ok([ + self.paymaster.as_slice(), + &narrow(self.paymaster_verification_gas_limit)?.to_be_bytes(), + &narrow(self.paymaster_post_op_gas_limit)?.to_be_bytes(), + &self.paymaster_data, + ] + .concat() + .into()) + } + + pub fn hash(&self, chain_id: u64) -> Result { + if self.factory.as_ref() != [0x77, 0x02] || !self.factory_data.is_empty() { + return Err(UsdtError::InvalidResponse); + } + let packed = PackedUserOperation { + sender: self.sender, + nonce: self.nonce, + // EntryPoint replaces the 7702 marker with the active delegate before hashing. + initCode: Bytes::copy_from_slice(DELEGATE.as_slice()), + callData: self.call_data.clone(), + accountGasLimits: pack(self.verification_gas_limit, self.call_gas_limit)?, + preVerificationGas: self.pre_verification_gas, + gasFees: pack(self.max_priority_fee_per_gas, self.max_fee_per_gas)?, + paymasterAndData: self.paymaster_and_data()?, + }; + Ok(packed.eip712_signing_hash(&eip712_domain! { + name: "ERC4337", version: "1", chain_id: chain_id, verifying_contract: ENTRY_POINT, + })) + } + + pub fn sign(&mut self, key: &SecretKey, chain_id: u64) -> Result { + if key_address(key) != self.sender || chain_id != CHAIN_ID { + return Err(UsdtError::InvalidCredentials); + } + self.eip7702_auth.sign(key)?; + let hash = self.hash(chain_id)?; + self.signature = sign_hash(hash, key)?; + Ok(hash) + } + + pub fn maximum_native_cost(&self) -> Result { + let gas = [ + self.call_gas_limit, + self.verification_gas_limit, + self.pre_verification_gas, + self.paymaster_verification_gas_limit, + self.paymaster_post_op_gas_limit, + ] + .into_iter() + .try_fold(U256::ZERO, |total, value| total.checked_add(value)) + .ok_or(UsdtError::InvalidResponse)?; + gas.checked_mul(self.max_fee_per_gas) + .ok_or(UsdtError::InvalidResponse) + } +} + +pub(super) fn sign_hash(hash: B256, key: &SecretKey) -> Result { + let (recovery, signature) = Secp256k1::new() + .sign_ecdsa_recoverable(&Message::from_digest(hash.0), key) + .serialize_compact(); + if recovery.to_i32() > 1 { + return Err(UsdtError::InvalidCredentials); + } + let mut encoded = signature.to_vec(); + encoded.push(27 + recovery.to_i32() as u8); + Ok(encoded.into()) +} + +fn pack(high: U256, low: U256) -> Result { + Ok(B256::from_slice( + &[narrow(high)?.to_be_bytes(), narrow(low)?.to_be_bytes()].concat(), + )) +} + +fn narrow(value: U256) -> Result { + value.try_into().map_err(|_| UsdtError::InvalidResponse) +} diff --git a/src/modules/usdt/wallet.rs b/src/modules/usdt/wallet.rs new file mode 100644 index 0000000..99a2863 --- /dev/null +++ b/src/modules/usdt/wallet.rs @@ -0,0 +1,612 @@ +use super::{ + account::{validate_delegation, ENTRY_POINT}, + amount::token_amount, + keys::{derive_owner_key, parse_address}, + paymaster::{Pimlico, PAYMASTER}, + rpc::Rpc, + store::{QuoteData, Store}, + transaction::{entry_point_event, event_data, EntryPoint, Erc20, Paymaster, Plan}, + types::{CHAIN_ID, TOKEN}, + user_operation::Authorization, + UsdtError, UsdtQuote, UsdtTransfer, UsdtTransferStatus, +}; +use alloy_primitives::{Address, Bytes, B256, U256}; +use alloy_sol_types::{SolCall, SolEvent}; +use serde_json::{json, Value}; +use std::sync::{atomic::AtomicU64, Arc}; +use tokio::sync::Mutex; + +const RECENT_EXECUTION_BLOCKS: u64 = 64; +const RECENT_EXECUTION_BUDGET: std::time::Duration = std::time::Duration::from_secs(5); +const NONCE_RECOVERY_BUDGET: std::time::Duration = std::time::Duration::from_secs(20); +const EXPIRY_SEARCH_BLOCKS: u64 = 4096; +const QUOTE_LIFETIME_SECONDS: u64 = 120; + +#[derive(uniffi::Object)] +pub struct UsdtWallet { + pub(super) address: Address, + pub(super) rpc: Rpc, + pub(super) paymaster: Pimlico, + pub(super) store: Store, + operation: Mutex<()>, + pub(super) history_range_limit: AtomicU64, +} + +#[uniffi::export(async_runtime = "tokio")] +impl UsdtWallet { + /// Creates the sole owner of this wallet's database; reuse it for all calls until it is dropped. + #[uniffi::constructor] + pub fn new( + address: String, + storage_path: String, + rpc_url: String, + bundler_url: String, + ) -> Result, UsdtError> { + if rpc_url.is_empty() || bundler_url.is_empty() { + return Err(UsdtError::NotConfigured); + } + let address = parse_address(&address)?; + let rpc = Rpc::new(rpc_url, CHAIN_ID)?; + let paymaster = Pimlico { + rpc: rpc.with_url(bundler_url)?, + }; + let store = Store::open(&storage_path, &format!("{CHAIN_ID}:{}", address))?; + Ok(Arc::new(Self { + address, + rpc, + paymaster, + store, + operation: Mutex::new(()), + history_range_limit: AtomicU64::new(super::history::MAX_LOG_RANGE), + })) + } + + pub fn receive_address(&self) -> String { + self.address.to_checksum(None) + } + + pub fn receive_uri(&self) -> String { + format!( + "ethereum:{}@{CHAIN_ID}/transfer?address={}", + TOKEN.to_checksum(None), + self.receive_address() + ) + } + + pub async fn balance(&self) -> Result { + self.rpc.verify_chain().await?; + token_amount(self.token_balance().await?) + } + + pub async fn quote_transfer( + &self, + recipient: String, + amount: u64, + ) -> Result { + if amount == 0 { + return Err(UsdtError::InvalidAmount); + } + let recipient = parse_address(recipient.trim())?; + if [ + self.address, + TOKEN, + ENTRY_POINT, + PAYMASTER, + super::account::DELEGATE, + ] + .contains(&recipient) + { + return Err(UsdtError::InvalidAddress); + } + self.store.require_no_pending()?; + self.rpc.verify_chain().await?; + self.require_balance(amount, 0).await?; + let calls = vec![( + TOKEN, + Erc20::transferCall { + recipient, + amount: U256::from(amount), + } + .abi_encode() + .into(), + )]; + let nonce = self.nonce("latest").await?; + let authorization = self.authorization().await?; + let created_block = self.block_number().await?; + let timestamp = self.block_timestamp(created_block).await?; + let (operation, maximum_fee, operation_expires_at) = self + .paymaster + .prepare(self.address, nonce, authorization, &calls, timestamp) + .await?; + let expires_at = now().saturating_add( + operation_expires_at + .saturating_sub(timestamp) + .min(QUOTE_LIFETIME_SECONDS), + ); + self.require_balance(amount, maximum_fee).await?; + let quote = UsdtQuote { + id: uuid::Uuid::new_v4().to_string(), + recipient: recipient.to_checksum(None), + amount, + maximum_fee, + expires_at, + }; + self.store.save_quote(&QuoteData { + quote: quote.clone(), + plan: Plan { + operation, + created_block, + expires_at: operation_expires_at, + }, + })?; + Ok(quote) + } + + /// Repeating a quote ID returns its stored outcome, which may already be failed or replaced. + /// A pending outcome is durable and retryable; it does not imply bundler acceptance. + pub async fn send( + &self, + quote_id: String, + mnemonic: String, + passphrase: Option, + ) -> Result { + let mnemonic = zeroize::Zeroizing::new(mnemonic); + let passphrase = passphrase.map(zeroize::Zeroizing::new); + let _guard = self.operation.lock().await; + if let Some(existing) = self.store.transfer("e_id)? { + derive_owner_key(mnemonic, passphrase, self.address)?; + return Ok(existing); + } + self.store.require_no_pending()?; + let mut data = self.store.quote("e_id)?; + if data.quote.expires_at <= now() + 5 { + return Err(UsdtError::QuoteExpired); + } + self.rpc.verify_chain().await?; + if self.nonce("latest").await? != data.plan.operation.nonce { + return Err(UsdtError::QuoteExpired); + } + let authorization = self.authorization().await?; + if authorization.nonce != data.plan.operation.eip7702_auth.nonce { + return Err(UsdtError::QuoteExpired); + } + self.require_balance(data.quote.amount, data.quote.maximum_fee) + .await?; + self.paymaster.validate_gas(&data.plan.operation).await?; + if self + .block_timestamp(self.block_number().await?) + .await? + .saturating_add(5) + >= data.plan.expires_at + { + return Err(UsdtError::QuoteExpired); + } + let key = derive_owner_key(mnemonic, passphrase, self.address)?; + if data.quote.expires_at <= now() + 5 { + return Err(UsdtError::QuoteExpired); + } + let (hash, raw) = data.plan.sign(&key)?; + drop(key); + let mut transfer = UsdtTransfer { + id: quote_id, + tx_hash: None, + user_operation_hash: Some(format!("{hash:#x}")), + recipient: data.quote.recipient, + amount: data.quote.amount, + received_amount: data.quote.amount, + fee: None, + is_incoming: false, + status: UsdtTransferStatus::Pending, + timestamp: now(), + }; + self.store.record_signed(&transfer, &raw)?; + // After persistence a lost response is indeterminate. Retry only the identical signed operation. + if let Err(error) = self.broadcast(&data.plan, hash).await { + // These errors occur before submission; later retries may already be queued. + if matches!( + error, + UsdtError::QuoteExpired | UsdtError::UnsupportedDelegation + ) { + transfer.mark_unexecuted(UsdtTransferStatus::Failed); + self.store.update_transfer(&transfer)?; + return Err(error); + } + } + Ok(transfer) + } + + /// Checks recent direct-payment execution with a bounded request budget. + /// Requires the expected operation and transfer in a canonical receipt; current-tip execution is provisional. + /// Does not rebroadcast, expire payments or reconcile nonces. Missing evidence leaves the payment pending. + pub async fn check_recent_execution( + &self, + id: String, + ) -> Result, UsdtError> { + let check = async { + let _guard = self.operation.lock().await; + let Some(mut transfer) = self.store.transfer(&id)? else { + return Ok(None); + }; + let Some(plan) = self.store.pending_plan(&id)? else { + return Ok(Some(transfer)); + }; + self.rpc.verify_chain().await?; + let hash = plan.operation.hash(CHAIN_ID)?; + let tip = self.block_number().await?; + let start = plan + .created_block + .max(tip.saturating_sub(RECENT_EXECUTION_BLOCKS - 1)); + if start <= tip { + let logs: Vec = self.operation_logs_in(start, tip, Some(hash)).await?; + if let Some(log) = logs + .iter() + .find(|log| log["removed"].as_bool() != Some(true)) + { + let event = entry_point_event(log)?.ok_or(UsdtError::InvalidResponse)?; + let number = + u64::try_from(serde_json::from_value::(log["blockNumber"].clone())?) + .map_err(|_| UsdtError::InvalidResponse)?; + if event.userOpHash != hash + || event.nonce != plan.operation.nonce + || number < start + || number > tip + { + return Err(UsdtError::InvalidResponse); + } + self.settle_from_log(&mut transfer, log, event).await?; + } + } + Ok(Some(transfer)) + }; + match tokio::time::timeout(RECENT_EXECUTION_BUDGET, check).await { + Ok(result) => result, + Err(_) => Err(UsdtError::NetworkUnavailable), + } + } + + /// Saves resumable history progress; returns true when caught up and false when more work remains. + /// Call between send flows. The soft budget permits an in-flight receipt to finish before yielding. + pub async fn sync_history(&self) -> Result { + let _guard = self.operation.lock().await; + self.rpc.verify_chain().await?; + self.scan_history().await + } + + pub fn history(&self) -> Result, UsdtError> { + self.store.transfers() + } + + /// Reconciles pending execution using chain proofs and may rebroadcast the identical signed operation. + pub async fn refresh_transfers(&self) -> Result, UsdtError> { + let _guard = self.operation.lock().await; + if let Some((mut transfer, plan)) = self.store.pending_operation()? { + self.recover_pending(&mut transfer, &plan).await?; + } + self.history() + } +} + +impl UsdtWallet { + async fn recover_pending( + &self, + transfer: &mut UsdtTransfer, + plan: &Plan, + ) -> Result<(), UsdtError> { + self.rpc.verify_chain().await?; + let hash = plan.operation.hash(CHAIN_ID)?; + let confirmed_tip = self.block_number().await?.saturating_sub(2); + if confirmed_tip < plan.created_block { + return Ok(()); + } + let end = self.pending_search_end(plan, confirmed_tip).await?; + let logs = match self + .operation_logs_in(plan.created_block, end, Some(hash)) + .await + { + Ok(logs) => logs, + // Discovery can be unavailable while independent nonce/receipt proofs still work. + Err(UsdtError::LogRangeTooLarge | UsdtError::NetworkUnavailable) => Vec::new(), + Err(error) => return Err(error), + }; + if let Some(log) = logs + .iter() + .find(|log| log["removed"].as_bool() != Some(true)) + { + let event = entry_point_event(log)?.ok_or(UsdtError::InvalidResponse)?; + if event.userOpHash != hash || event.nonce != plan.operation.nonce { + return Err(UsdtError::InvalidResponse); + } + return self.settle_from_log(transfer, log, event).await; + } + let nonce = self.nonce(&format!("0x{confirmed_tip:x}")).await?; + if nonce <= plan.operation.nonce { + if self.block_timestamp(confirmed_tip).await? > plan.expires_at { + transfer.mark_unexecuted(UsdtTransferStatus::Failed); + self.store.update_transfer(transfer)?; + } else { + let _ = self.broadcast(plan, hash).await; + } + return Ok(()); + } + // A nonce advance alone cannot distinguish this payment from a replacement. + let block = self.nonce_consumed_block(plan, confirmed_tip).await?; + let candidates = match self.operation_logs_in(block, block, None).await { + Ok(logs) => logs, + Err(UsdtError::LogRangeTooLarge | UsdtError::NetworkUnavailable) => Vec::new(), + Err(error) => return Err(error), + }; + for log in candidates + .iter() + .filter(|log| log["removed"].as_bool() != Some(true)) + { + let event = entry_point_event(log)?.ok_or(UsdtError::InvalidResponse)?; + if u64::try_from(serde_json::from_value::(log["blockNumber"].clone())?) + .map_err(|_| UsdtError::InvalidResponse)? + != block + || event.sender != self.address + { + return Err(UsdtError::InvalidResponse); + } + if event.nonce != plan.operation.nonce { + continue; + } + if event.userOpHash == hash { + return self.settle_from_log(transfer, log, event).await; + } + break; + } + self.reconcile_consumed_nonce(transfer, plan, block).await + } + + async fn operation_logs_in( + &self, + start: u64, + end: u64, + hash: Option, + ) -> Result, UsdtError> { + self.rpc.call("eth_getLogs", json!([{ + "address": ENTRY_POINT, "fromBlock": U256::from(start), "toBlock": U256::from(end), + "topics": [EntryPoint::UserOperationEvent::SIGNATURE_HASH, hash, self.address.into_word()] + }])).await + } + + async fn reconcile_consumed_nonce( + &self, + transfer: &mut UsdtTransfer, + plan: &Plan, + number: u64, + ) -> Result<(), UsdtError> { + let deadline = tokio::time::Instant::now() + NONCE_RECOVERY_BUDGET; + let block = self.rpc.block(number).await?; + let block_hash = format!("{:#x}", block.hash); + let start = self.store.nonce_recovery(&transfer.id, &block_hash)?; + if start > block.transactions.len() { + return Err(UsdtError::InvalidResponse); + } + for (index, hash) in block.transactions.iter().enumerate().skip(start) { + if tokio::time::Instant::now() >= deadline { + return Ok(()); + } + let receipt = self.rpc.block_receipt(*hash, block.hash, number).await?; + for log in receipt["logs"] + .as_array() + .ok_or(UsdtError::InvalidResponse)? + { + let Some(event) = entry_point_event(log)? else { + continue; + }; + if event.sender != self.address || event.nonce != plan.operation.nonce { + continue; + } + if event.userOpHash == plan.operation.hash(CHAIN_ID)? { + if event.paymaster != PAYMASTER { + return Err(UsdtError::InvalidResponse); + } + transfer.tx_hash = Some(format!("{hash:#x}")); + self.settle(transfer, &receipt)?; + } else { + transfer.mark_unexecuted(UsdtTransferStatus::Replaced); + } + transfer.timestamp = block.timestamp()?; + return self.store.update_transfer(transfer); + } + self.store + .save_nonce_recovery(&transfer.id, &block_hash, index + 1)?; + } + // Empty indexed logs are insufficient; every receipt in the consuming block must be checked. + if self.rpc.block(number).await?.hash != block.hash { + return Err(UsdtError::NetworkUnavailable); + } + transfer.mark_unexecuted(UsdtTransferStatus::Replaced); + transfer.timestamp = block.timestamp()?; + self.store.update_transfer(transfer) + } + + pub(super) async fn block_number(&self) -> Result { + u64::try_from(self.rpc.call::("eth_blockNumber", json!([])).await?) + .map_err(|_| UsdtError::InvalidResponse) + } + pub(super) async fn block_timestamp(&self, number: u64) -> Result { + self.rpc.block(number).await?.timestamp() + } + async fn token_balance(&self) -> Result { + self.rpc + .contract( + TOKEN, + Erc20::balanceOfCall { + owner: self.address, + }, + ) + .await + } + async fn require_balance(&self, amount: u64, fee: u64) -> Result<(), UsdtError> { + let required = U256::from(amount) + U256::from(fee); + if self.token_balance().await? < required { + return Err(UsdtError::InsufficientBalance); + } + Ok(()) + } + async fn nonce(&self, block: &str) -> Result { + self.rpc + .contract_at( + ENTRY_POINT, + EntryPoint::getNonceCall { + sender: self.address, + key: Default::default(), + }, + block, + ) + .await + } + async fn authorization(&self) -> Result { + let code: Bytes = self + .rpc + .call("eth_getCode", json!([self.address, "latest"])) + .await?; + validate_delegation(&code)?; + let nonce: U256 = self + .rpc + .call("eth_getTransactionCount", json!([self.address, "latest"])) + .await?; + Ok(Authorization::dummy( + nonce.try_into().map_err(|_| UsdtError::InvalidResponse)?, + )) + } + + async fn broadcast(&self, plan: &Plan, expected_hash: B256) -> Result<(), UsdtError> { + if self.authorization().await?.nonce != plan.operation.eip7702_auth.nonce { + return Err(UsdtError::QuoteExpired); + } + let result: Result = self + .paymaster + .rpc + .call( + "eth_sendUserOperation", + json!([plan.operation, ENTRY_POINT]), + ) + .await; + match result { + Ok(hash) if hash == expected_hash => Ok(()), + Ok(_) => { + log::warn!("USDT submission returned an unexpected operation hash; retaining pending payment"); + Err(UsdtError::InvalidResponse) + } + Err(error) => { + log::warn!("USDT submission could not be confirmed; retaining pending payment"); + Err(error) + } + } + } + async fn settle_from_log( + &self, + transfer: &mut UsdtTransfer, + log: &Value, + event: EntryPoint::UserOperationEvent, + ) -> Result<(), UsdtError> { + if event.sender != self.address || event.paymaster != PAYMASTER { + return Err(UsdtError::InvalidResponse); + } + let hash: B256 = serde_json::from_value(log["transactionHash"].clone())?; + transfer.tx_hash = Some(format!("{hash:#x}")); + let number = u64::try_from(serde_json::from_value::(log["blockNumber"].clone())?) + .map_err(|_| UsdtError::InvalidResponse)?; + let block = self.rpc.block(number).await?; + if serde_json::from_value::(log["blockHash"].clone())? != block.hash { + return Err(UsdtError::NetworkUnavailable); + } + let receipt = self.rpc.block_receipt(hash, block.hash, number).await?; + self.settle(transfer, &receipt)?; + transfer.timestamp = block.timestamp()?; + self.store.update_transfer(transfer) + } + + async fn nonce_consumed_block(&self, plan: &Plan, tip: u64) -> Result { + // EntryPoint nonces only increase. Locate the consuming block without a months-long log query. + let mut low = plan.created_block; + let mut high = tip; + while low < high { + let mid = low + (high - low) / 2; + if self.nonce(&format!("0x{mid:x}")).await? > plan.operation.nonce { + high = mid; + } else { + low = mid + 1; + } + } + Ok(low) + } + + async fn pending_search_end(&self, plan: &Plan, tip: u64) -> Result { + // The paymaster validity window bounds recovery even after a long absence. + if tip <= plan.created_block + EXPIRY_SEARCH_BLOCKS { + return Ok(tip); + } + let mut low = plan.created_block; + let mut high = tip; + while low + 1 < high { + let mid = low + (high - low) / 2; + if self.block_timestamp(mid).await? <= plan.expires_at { + low = mid; + } else { + high = mid; + } + } + Ok(high) + } + pub(super) fn settle( + &self, + transfer: &mut UsdtTransfer, + receipt: &Value, + ) -> Result<(), UsdtError> { + let operation_hash: B256 = transfer + .user_operation_hash + .as_ref() + .ok_or(UsdtError::InvalidResponse)? + .parse() + .map_err(|_| UsdtError::InvalidResponse)?; + let logs = super::transaction::operation_logs(receipt, operation_hash)?; + let event = entry_point_event(logs.last().ok_or(UsdtError::InvalidResponse)?)? + .ok_or(UsdtError::InvalidResponse)?; + if event.sender != self.address || event.paymaster != PAYMASTER { + return Err(UsdtError::InvalidResponse); + } + let mut transfer_proven = false; + let mut gas_fee = None; + for log in logs { + let address: Address = serde_json::from_value(log["address"].clone())?; + let data = event_data(log)?; + if address == TOKEN { + if let Ok(payment) = Erc20::Transfer::decode_log_data(&data) { + transfer_proven |= payment.from == self.address + && payment.to == parse_address(&transfer.recipient)? + && payment.value == U256::from(transfer.amount); + } + } + if address == PAYMASTER { + if let Ok(event) = Paymaster::UserOperationSponsored::decode_log_data(&data) { + if event.userOpHash == operation_hash + && event.user == self.address + && event.token == TOKEN + && event.paymasterMode == 1 + { + gas_fee = Some(token_amount(event.tokenAmountPaid)?); + } + } + } + } + if event.success && !transfer_proven { + return Err(UsdtError::InvalidResponse); + } + transfer.fee = gas_fee; + transfer.status = if !event.success { + transfer.received_amount = 0; + UsdtTransferStatus::Failed + } else { + UsdtTransferStatus::Confirmed + }; + Ok(()) + } +} + +pub(super) fn now() -> u64 { + chrono::Utc::now().timestamp().max(0) as u64 +} diff --git a/tests/usdt-fork/package-lock.json b/tests/usdt-fork/package-lock.json new file mode 100644 index 0000000..7148033 --- /dev/null +++ b/tests/usdt-fork/package-lock.json @@ -0,0 +1,119 @@ +{ + "name": "bitkit-usdt-contract-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bitkit-usdt-contract-tests", + "dependencies": { + "ethers": "6.17.0" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, + "node_modules/ethers": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", + "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.11.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.21.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/tests/usdt-fork/package.json b/tests/usdt-fork/package.json new file mode 100644 index 0000000..bead69b --- /dev/null +++ b/tests/usdt-fork/package.json @@ -0,0 +1,7 @@ +{ + "name": "bitkit-usdt-contract-tests", + "private": true, + "type": "module", + "dependencies": { "ethers": "6.17.0" }, + "scripts": { "start": "node provider.mjs" } +} diff --git a/tests/usdt-fork/provider.mjs b/tests/usdt-fork/provider.mjs new file mode 100644 index 0000000..65ad95b --- /dev/null +++ b/tests/usdt-fork/provider.mjs @@ -0,0 +1,217 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { readFileSync } from 'node:fs'; +import { + AbiCoder, + Contract, + HDNodeWallet, + JsonRpcProvider, + concat, + getBytes, + keccak256, + toBeHex, + zeroPadValue, +} from 'ethers'; + +// A local service fixture using the deployed contracts with a test paymaster signer. +// It does not model Pimlico's pricing, gas estimation, or service acceptance policy. +const rpc = new JsonRpcProvider('http://127.0.0.1:18545', 42161, { + staticNetwork: true, + cacheTimeout: -1, +}); +assert.match(await rpc.send('web3_clientVersion', []), /anvil/i); +assert.equal(await rpc.send('eth_chainId', []), '0xa4b1'); +const signer = HDNodeWallet.fromPhrase( + 'test test test test test test test test test test test junk', + undefined, + "m/44'/60'/0'/0/1", +); +const bundler = await rpc.getSigner(signer.address); +const vector = JSON.parse( + readFileSync(new URL('../../src/modules/usdt/fixtures/eip7702-vectors.json', import.meta.url)), +); +const account = vector.address; +const delegate = '0xe6Cae83BdE06E4c305530e199D7217f42808555B'; +const tokenAddress = '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9'; +const pmAddress = '0x888888888888Ec68A58AB8094Cc1AD20Ba3D2402'; +const entryAddress = '0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108'; +const packedType = + '(address sender,uint256 nonce,bytes initCode,bytes callData,bytes32 accountGasLimits,uint256 preVerificationGas,bytes32 gasFees,bytes paymasterAndData,bytes signature)'; +const token = new Contract( + tokenAddress, + ['function balanceOf(address) view returns (uint256)'], + rpc, +); +const pm = new Contract( + pmAddress, + [ + `function getHash(uint8,${packedType}) view returns (bytes32)`, + 'function signers(address) view returns (bool)', + ], + rpc, +); +const entry = new Contract( + entryAddress, + [ + `function handleOps(${packedType}[],address)`, + `function getUserOpHash(${packedType}) view returns (bytes32)`, + 'function depositTo(address) payable', + ], + bundler, +); +const coder = AbiCoder.defaultAbiCoder(); + +async function setMapping(contract, account, value, check) { + for (let slot = 0; slot < 100; slot++) { + const key = keccak256(coder.encode(['address', 'uint256'], [account, slot])); + const previous = await rpc.send('eth_getStorageAt', [contract, key, 'latest']); + await rpc.send('anvil_setStorageAt', [contract, key, toBeHex(value, 32)]); + if (await check()) return slot; + await rpc.send('anvil_setStorageAt', [contract, key, previous]); + } + throw Error('Could not locate fixture mapping'); +} +await rpc.send('anvil_setCode', [account, '0x']); +await rpc.send('anvil_setNonce', [account, '0x0']); +await rpc.send('anvil_setBalance', [account, '0x0']); +const balanceSlot = await setMapping( + tokenAddress, + account, + 1_000_000_000n, + async () => (await token.balanceOf(account)) === 1_000_000_000n, +); +await setMapping(pmAddress, signer.address, 1n, async () => await pm.signers(signer.address)); +await (await entry.depositTo(pmAddress, { value: 10n ** 18n })).wait(); + +const gas = { + callGasLimit: toBeHex(1_500_000), + verificationGasLimit: toBeHex(800_000), + preVerificationGas: toBeHex(100_000), + paymasterVerificationGasLimit: toBeHex(100_000), + paymasterPostOpGasLimit: toBeHex(100_000), +}; +const rate = 3_000_000_000n; +function pack(op) { + return { + sender: op.sender, + nonce: op.nonce, + initCode: concat(['0x7702', toBeHex(0, 18)]), + callData: op.callData, + accountGasLimits: concat([toBeHex(op.verificationGasLimit, 16), toBeHex(op.callGasLimit, 16)]), + preVerificationGas: op.preVerificationGas, + gasFees: concat([toBeHex(op.maxPriorityFeePerGas, 16), toBeHex(op.maxFeePerGas, 16)]), + paymasterAndData: concat([ + op.paymaster, + toBeHex(op.paymasterVerificationGasLimit, 16), + toBeHex(op.paymasterPostOpGasLimit, 16), + op.paymasterData, + ]), + signature: op.signature, + }; +} +async function dispatch(method, params) { + if (method === 'eth_estimateUserOperationGas' || method === 'eth_sendUserOperation') { + const op = params[0]; + assert.equal(op.factory, '0x7702'); + assert.ok(op.eip7702Auth, 'The 7702 factory marker requires authorization'); + assert.equal( + BigInt(op.eip7702Auth.nonce), + BigInt(await rpc.send('eth_getTransactionCount', [op.sender, 'latest'])), + 'Authorization must use the current account nonce', + ); + } + if (method === 'test_fundWallet') { + const key = keccak256(coder.encode(['address', 'uint256'], [params[0], balanceSlot])); + await rpc.send('anvil_setStorageAt', [tokenAddress, key, toBeHex(1_000_000_000n, 32)]); + return true; + } + if (method === 'pimlico_getTokenQuotes') + return { + quotes: [ + { + token: tokenAddress, + paymaster: pmAddress, + postOpGas: toBeHex(50000), + exchangeRate: toBeHex(rate), + }, + ], + }; + if (method === 'pimlico_getUserOperationGasPrice') + return { + slow: { maxFeePerGas: toBeHex(90_000_000), maxPriorityFeePerGas: toBeHex(1_000_000) }, + fast: { maxFeePerGas: toBeHex(100_000_000), maxPriorityFeePerGas: toBeHex(1_000_000) }, + }; + if (method === 'eth_estimateUserOperationGas') return gas; + if (method === 'pm_getPaymasterData' || method === 'pm_getPaymasterStubData') { + const stub = method === 'pm_getPaymasterStubData'; + const limits = stub ? { paymasterPostOpGasLimit: gas.paymasterPostOpGasLimit } : {}; + const op = { ...params[0], paymaster: pmAddress, ...limits }; + const unsigned = concat([ + '0x0300', + toBeHex(BigInt((await rpc.send('eth_getBlockByNumber', ['latest', false])).timestamp) + 600n, 6), + toBeHex(0, 6), + tokenAddress, + toBeHex(50000, 16), + toBeHex(rate, 32), + toBeHex(100000, 16), + signer.address, + ]); + op.paymasterData = concat([unsigned, zeroPadValue('0x01', 65)]); + const signature = await signer.signMessage(getBytes(await pm.getHash(1, pack(op)))); + return { paymaster: pmAddress, paymasterData: concat([unsigned, signature]), ...limits }; + } + if (method === 'eth_sendUserOperation') { + const op = pack(params[0]); + const hash = await rpc.send('eth_call', [ + { to: entryAddress, data: entry.interface.encodeFunctionData('getUserOpHash', [op]) }, + 'latest', + { [op.sender]: { code: concat(['0xef0100', delegate]) } }, + ]); + const auth = params[0].eip7702Auth; + const options = { + gasLimit: 6_000_000, + type: 4, + authorizationList: [ + { + address: auth.address, + chainId: BigInt(auth.chainId), + nonce: BigInt(auth.nonce), + signature: { r: auth.r, s: auth.s, yParity: Number(BigInt(auth.yParity)) }, + }, + ], + }; + const tx = await entry.handleOps([op], signer.address, options); + const receipt = await tx.wait(); + await rpc.send('anvil_mine', [3]); + console.log( + JSON.stringify({ + operation: hash, + transaction: receipt.hash, + gasUsed: receipt.gasUsed.toString(), + }), + ); + return hash; + } + return rpc.send(method, params); +} +const server = createServer(async (request, response) => { + let body = ''; + for await (const chunk of request) body += chunk; + const call = JSON.parse(body); + let result; + try { + result = { jsonrpc: '2.0', id: call.id, result: await dispatch(call.method, call.params) }; + } catch (error) { + console.error(call.method, error.shortMessage ?? error.message, error.data ?? ''); + result = { + jsonrpc: '2.0', + id: call.id, + error: { code: -32000, message: error.shortMessage ?? error.message }, + }; + } + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify(result)); +}); +server.listen(18546, '127.0.0.1', () => + console.log('Fork provider ready on localhost:18546; no mainnet writes.'), +); diff --git a/tests/usdt-fork/vectors.mjs b/tests/usdt-fork/vectors.mjs new file mode 100644 index 0000000..9581f8b --- /dev/null +++ b/tests/usdt-fork/vectors.mjs @@ -0,0 +1,122 @@ +import { writeFileSync } from 'node:fs'; +import { + HDNodeWallet, + Interface, + TypedDataEncoder, + concat, + hashAuthorization, + toBeHex, +} from 'ethers'; + +const wallet = HDNodeWallet.fromPhrase( + 'test test test test test test test test test test test junk', +); +const token = '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9'; +const paymaster = '0x888888888888Ec68A58AB8094Cc1AD20Ba3D2402'; +const delegate = '0xe6Cae83BdE06E4c305530e199D7217f42808555B'; +const entryPoint = '0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108'; +const tokenAbi = new Interface([ + 'function approve(address,uint256)', + 'function transfer(address,uint256)', +]); +const accountAbi = new Interface([ + 'function executeBatch((address target,uint256 value,bytes data)[] calls)', +]); +const calls = [ + { to: token, data: tokenAbi.encodeFunctionData('approve', [paymaster, 100000]) }, + { + to: token, + data: tokenAbi.encodeFunctionData('transfer', [ + '0x1111111111111111111111111111111111111111', + 1000000, + ]), + }, +]; +const authorization = { chainId: 42161, address: delegate, nonce: 0 }; +const authHash = hashAuthorization(authorization); +const authSignature = wallet.signingKey.sign(authHash); +const op = { + sender: wallet.address, + nonce: '0x0', + factory: '0x7702', + factoryData: '0x', + callData: accountAbi.encodeFunctionData('executeBatch', [calls.map((c) => [c.to, 0, c.data])]), + callGasLimit: '0x249f0', + verificationGasLimit: '0x3d090', + preVerificationGas: '0x13880', + maxFeePerGas: '0x2faf080', + maxPriorityFeePerGas: '0x186a0', + paymaster, + paymasterVerificationGasLimit: '0x186a0', + paymasterPostOpGasLimit: '0xc350', + paymasterData: concat([ + '0x0300', + toBeHex(1800000000, 6), + toBeHex(0, 6), + token, + toBeHex(50000, 16), + toBeHex(3000000000, 32), + toBeHex(100000, 16), + wallet.address, + toBeHex(1, 65), + ]), + signature: '0x', + eip7702Auth: { + chainId: '0xa4b1', + address: delegate, + nonce: '0x0', + yParity: toBeHex(authSignature.yParity), + r: authSignature.r, + s: authSignature.s, + }, +}; +const types = { + PackedUserOperation: [ + ['sender', 'address'], + ['nonce', 'uint256'], + ['initCode', 'bytes'], + ['callData', 'bytes'], + ['accountGasLimits', 'bytes32'], + ['preVerificationGas', 'uint256'], + ['gasFees', 'bytes32'], + ['paymasterAndData', 'bytes'], + ].map(([name, type]) => ({ name, type })), +}; +const packed = { + sender: op.sender, + nonce: op.nonce, + initCode: delegate, + callData: op.callData, + accountGasLimits: concat([toBeHex(op.verificationGasLimit, 16), toBeHex(op.callGasLimit, 16)]), + preVerificationGas: op.preVerificationGas, + gasFees: concat([toBeHex(op.maxPriorityFeePerGas, 16), toBeHex(op.maxFeePerGas, 16)]), + paymasterAndData: concat([ + paymaster, + toBeHex(op.paymasterVerificationGasLimit, 16), + toBeHex(op.paymasterPostOpGasLimit, 16), + op.paymasterData, + ]), +}; +const hash = TypedDataEncoder.hash( + { name: 'ERC4337', version: '1', chainId: 42161, verifyingContract: entryPoint }, + types, + packed, +); +const signature = wallet.signingKey.sign(hash).serialized; +writeFileSync( + new URL('../../src/modules/usdt/fixtures/eip7702-vectors.json', import.meta.url), + JSON.stringify( + { + source: + 'ethers@6.17.0; EntryPoint v0.8 / Simple7702Account; generated by tests/usdt-fork/vectors.mjs', + address: wallet.address, + calls, + operation: op, + userOperationHash: hash, + signature, + authorizationHash: authHash, + }, + null, + 2, + ) + '\n', +);